Skip to content

Commit 61a843a

Browse files
committed
perf(icons): enforce three-decimal paths
1 parent 9ac5bbe commit 61a843a

7 files changed

Lines changed: 65 additions & 175 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,6 @@ jobs:
123123
- name: Repo audits
124124
run: bun run check:audits
125125

126-
- name: Check SVG icon path precision
127-
run: |
128-
if [ "${{ github.event_name }}" = "pull_request" ]; then
129-
BASE_REF="origin/${{ github.base_ref }}"
130-
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
131-
else
132-
BASE_REF="HEAD~1"
133-
fi
134-
bun run check:icon-path-precision "$BASE_REF"
135-
136126
- name: Migration safety (zero-downtime) audit
137127
run: |
138128
if [ "${{ github.event_name }}" = "pull_request" ]; then

apps/docs/components/icons.tsx

Lines changed: 19 additions & 19 deletions
Large diffs are not rendered by default.

apps/sim/components/icons.tsx

Lines changed: 19 additions & 19 deletions
Large diffs are not rendered by default.

packages/emcn/src/icons/document-attachment.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function DocumentAttachment(props: SVGProps<SVGSVGElement>) {
1616
{...props}
1717
>
1818
<path
19-
d='M10.5 5.69V5.1C10.5 3.99 10.499 3.2 10.414 2.61C10.33 2.02 10.18 1.69 9.91 1.44C9.65 1.19 9.28 1.04 8.65 0.96C8.01 0.88 7.17 0.875 6 0.875H5.38C4.2 0.875 3.36 0.88 2.72 0.96C2.09 1.04 1.73 1.19 1.46 1.44C1.2 1.69 1.04 2.02 0.96 2.61C0.88 3.2 0.88 3.99 0.88 5.1V7.44C0.88 8.55 0.88 9.34 0.96 9.94C1.04 10.52 1.2 10.85 1.46 11.1C1.73 11.354 2.09 11.5 2.72 11.58C3.36 11.67 4.2 11.67 5.38 11.67H5.69C5.93 11.67 6.125 11.86 6.125 12.1C6.125 12.35 5.93 12.54 5.69 12.54H5.38C4.23 12.54 3.32 12.54 2.61 12.45C1.9 12.36 1.32 12.17 0.86 11.739C0.4 11.3 0.19 10.75 0.09 10.06C0 9.39 8.49e-07 8.52 8.99e-07 7.44V5.1C9.49e-07 4.02 0 3.16 0.09 2.48C0.19 1.79 0.4 1.24 0.86 0.8C1.32 0.37 1.9 0.18 2.61 0.09C3.32 0 4.23 2.75e-07 5.38 3.23e-07H6C7.15 3.71e-07 8.06 0 8.76 0.09C9.48 0.18 10.06 0.37 10.51 0.8C10.98 1.24 11.18 1.79 11.28 2.48C11.38 3.16 11.375 4.02 11.375 5.1V5.69C11.375 5.93 11.18 6.125 10.94 6.125C10.7 6.125 10.5 5.93 10.5 5.69Z'
19+
d='M10.5 5.69V5.1C10.5 3.99 10.499 3.2 10.414 2.61C10.33 2.02 10.18 1.69 9.91 1.44C9.65 1.19 9.28 1.04 8.65 0.96C8.01 0.88 7.17 0.875 6 0.875H5.38C4.2 0.875 3.36 0.88 2.72 0.96C2.09 1.04 1.73 1.19 1.46 1.44C1.2 1.69 1.04 2.02 0.96 2.61C0.88 3.2 0.88 3.99 0.88 5.1V7.44C0.88 8.55 0.88 9.34 0.96 9.94C1.04 10.52 1.2 10.85 1.46 11.1C1.73 11.354 2.09 11.5 2.72 11.58C3.36 11.67 4.2 11.67 5.38 11.67H5.69C5.93 11.67 6.125 11.86 6.125 12.1C6.125 12.35 5.93 12.54 5.69 12.54H5.38C4.23 12.54 3.32 12.54 2.61 12.45C1.9 12.36 1.32 12.17 0.86 11.739C0.4 11.3 0.19 10.75 0.09 10.06C0 9.39 0 8.52 0 7.44V5.1C0 4.02 0 3.16 0.09 2.48C0.19 1.79 0.4 1.24 0.86 0.8C1.32 0.37 1.9 0.18 2.61 0.09C3.32 0 4.23 0 5.38 0H6C7.15 0 8.06 0 8.76 0.09C9.48 0.18 10.06 0.37 10.51 0.8C10.98 1.24 11.18 1.79 11.28 2.48C11.38 3.16 11.375 4.02 11.375 5.1V5.69C11.375 5.93 11.18 6.125 10.94 6.125C10.7 6.125 10.5 5.93 10.5 5.69Z'
2020
fill='currentColor'
2121
/>
2222
<path

scripts/check-icon-path-precision.test.ts

Lines changed: 11 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,20 @@ import { describe, expect, it } from 'vitest'
22
import {
33
analyzeIconSource,
44
effectiveFractionDigits,
5-
findNewPrecisionCandidates,
65
findPrecisionCandidates,
76
} from './check-icon-path-precision'
87

98
const FIXTURE_PATH = '/repo/packages/emcn/src/icons/fixture.tsx'
109

1110
describe('icon path precision audit', () => {
12-
it('accepts the two-decimal boundary and ignores geometry outside literal paths', () => {
11+
it('accepts the three-decimal boundary and ignores geometry outside literal paths', () => {
1312
const source = `
1413
const dynamicPath = 'M0.1234 1'
1514
const unrelated = "d='M0.1234 1'"
1615
export function SafeIcon() {
1716
return (
1817
<svg viewBox='0 0 10.1234 10' transform='scale(0.16624)'>
19-
<path d='M.5 1.20L1.2e1 2' />
18+
<path d='M.5 1.200L1.2e1 2' />
2019
<path d={dynamicPath} />
2120
</svg>
2221
)
@@ -29,19 +28,19 @@ describe('icon path precision audit', () => {
2928
})
3029
})
3130

32-
it('finds ordinary decimals and exponents finer than a hundredth', () => {
31+
it('finds ordinary decimals and exponents finer than a thousandth', () => {
3332
const source = `
3433
export function PreciseIcon() {
35-
return <svg><path d='M0.123 1e-3L2.34 5' /></svg>
34+
return <svg><path d='M0.1234 1e-4L2.345 5' /></svg>
3635
}
3736
`
3837

3938
const candidates = findPrecisionCandidates(source, FIXTURE_PATH)
4039
expect(candidates).toHaveLength(1)
4140
expect(candidates[0]).toMatchObject({
4241
icon: 'PreciseIcon',
43-
maxFractionDigits: 3,
44-
offendingNumbers: ['0.123', '1e-3'],
42+
maxFractionDigits: 4,
43+
offendingNumbers: ['0.1234', '1e-4'],
4544
})
4645
expect(effectiveFractionDigits('2.13949e-05')).toBe(10)
4746
})
@@ -51,8 +50,8 @@ describe('icon path precision audit', () => {
5150
export function ExpressionIcon() {
5251
return (
5352
<svg>
54-
<path d={'M0.123 1'} />
55-
<path d={\`M2.345 3\`} />
53+
<path d={'M0.1234 1'} />
54+
<path d={\`M2.3456 3\`} />
5655
</svg>
5756
)
5857
}
@@ -84,7 +83,7 @@ describe('icon path precision audit', () => {
8483
it('rejects exceptions without a reason and exceptions on already-clean paths', () => {
8584
const missingReason = `
8685
export function PreciseIcon() {
87-
return <svg>{/** svg-path-precision-exception: */}<path d='M0.123 1' /></svg>
86+
return <svg>{/** svg-path-precision-exception: */}<path d='M0.1234 1' /></svg>
8887
}
8988
`
9089
const unnecessary = `
@@ -102,31 +101,7 @@ describe('icon path precision audit', () => {
102101
expect(unnecessaryAnalysis.invalidExceptions[0]?.message).toContain('unnecessary')
103102
})
104103

105-
it('grandfathers exact paths from the target branch but rejects additions and edits', () => {
106-
const base = `
107-
export function LegacyIcon() {
108-
return <svg><path d='M0.123 1' /></svg>
109-
}
110-
`
111-
const unchanged = findPrecisionCandidates(base, FIXTURE_PATH)
112-
expect(findNewPrecisionCandidates(unchanged, unchanged)).toEqual([])
113-
114-
const duplicate = `
115-
export function LegacyIcon() {
116-
return <svg><path d='M0.123 1' /><path d='M0.123 1' /></svg>
117-
}
118-
`
119-
expect(
120-
findNewPrecisionCandidates(findPrecisionCandidates(duplicate, FIXTURE_PATH), unchanged)
121-
).toHaveLength(1)
122-
123-
const changed = base.replace('0.123', '0.1234')
124-
expect(
125-
findNewPrecisionCandidates(findPrecisionCandidates(changed, FIXTURE_PATH), unchanged)
126-
).toHaveLength(1)
127-
})
128-
129-
it('requires a committed exception to remain while its precise path remains', () => {
104+
it('requires a reasoned exception to remain while its precise path remains', () => {
130105
const excepted = `
131106
export function PreciseBrandIcon() {
132107
return (
@@ -143,8 +118,6 @@ describe('icon path precision audit', () => {
143118
)
144119

145120
expect(findPrecisionCandidates(excepted, FIXTURE_PATH)).toEqual([])
146-
expect(
147-
findNewPrecisionCandidates(findPrecisionCandidates(exceptionRemoved, FIXTURE_PATH), [])
148-
).toHaveLength(1)
121+
expect(findPrecisionCandidates(exceptionRemoved, FIXTURE_PATH)).toHaveLength(1)
149122
})
150123
})

scripts/check-icon-path-precision.ts

Lines changed: 15 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
#!/usr/bin/env bun
22
/**
3-
* Prevents newly introduced overly precise numeric values in literal SVG icon
4-
* `d` attributes.
3+
* Prevents overly precise numeric values in literal SVG icon `d` attributes.
54
*
6-
* Two decimal places are enough for the reusable icons covered here: additional
7-
* digits increase shipped source without a visible benefit. Paths already in the
8-
* target branch are grandfathered by exact content, while new paths and edits to
9-
* old paths must satisfy the limit or carry a reasoned local exception.
5+
* Three decimal places are enough for the reusable icons covered here: additional
6+
* digits increase shipped source without a visible benefit. Every path must
7+
* satisfy the limit or carry a reasoned local exception.
108
*
119
* Scope is intentionally limited to the shared app/docs icon catalogs and EMCN
1210
* icon components. SVG transforms, view boxes, dynamic path expressions, and
1311
* page-specific artwork are not inspected because their safe precision depends
1412
* on context.
1513
*
16-
* Run against the intended merge target: `bun run check:icon-path-precision staging`
14+
* Run with `bun run check:icon-path-precision`.
1715
*/
18-
import { createHash } from 'node:crypto'
1916
import { readdir, readFile } from 'node:fs/promises'
2017
import path from 'node:path'
2118
import { fileURLToPath } from 'node:url'
@@ -27,11 +24,10 @@ const STATIC_ICON_FILES = [
2724
path.join(ROOT, 'apps/docs/components/icons.tsx'),
2825
path.join(ROOT, 'apps/sim/components/icons.tsx'),
2926
]
30-
const STATIC_ICON_PATHS = STATIC_ICON_FILES.map((file) => normalizedRelativePath(file))
3127
const SVG_NUMBER_PATTERN = /[+-]?(?:(?:\d+\.\d*)|(?:\.\d+)|(?:\d+))(?:[eE][+-]?\d+)?/g
3228
const PRECISION_EXCEPTION_DIRECTIVE = 'svg-path-precision-exception:'
3329

34-
export const MAX_ICON_PATH_FRACTION_DIGITS = 2
30+
export const MAX_ICON_PATH_FRACTION_DIGITS = 3
3531

3632
interface ParsedPrecisionException {
3733
line: number
@@ -49,7 +45,6 @@ export interface PrecisionCandidate {
4945
file: string
5046
icon: string
5147
line: number
52-
pathHash: string
5348
maxFractionDigits: number
5449
offendingNumbers: string[]
5550
}
@@ -261,10 +256,6 @@ export function effectiveFractionDigits(numberLiteral: string): number {
261256
return Math.max(writtenFractionDigits, exponentFractionDigits)
262257
}
263258

264-
function hashPath(pathValue: string): string {
265-
return createHash('sha256').update(pathValue).digest('hex')
266-
}
267-
268259
function normalizedRelativePath(file: string): string {
269260
return path.relative(ROOT, file).split(path.sep).join('/')
270261
}
@@ -296,7 +287,7 @@ export function analyzeIconSource(source: string, file: string): IconPrecisionAn
296287
invalidExceptions.push({
297288
file: normalizedFile,
298289
line: literalPath.exception.line,
299-
message: 'Exception is unnecessary because this path uses at most two decimal places.',
290+
message: 'Exception is unnecessary because this path uses at most three decimal places.',
300291
})
301292
} else {
302293
continue
@@ -308,7 +299,6 @@ export function analyzeIconSource(source: string, file: string): IconPrecisionAn
308299
file: normalizedFile,
309300
icon: literalPath.icon,
310301
line: literalPath.line,
311-
pathHash: hashPath(literalPath.value),
312302
maxFractionDigits: Math.max(...preciseNumbers.map(effectiveFractionDigits)),
313303
offendingNumbers: [...new Set(preciseNumbers)].slice(0, 4),
314304
})
@@ -321,23 +311,6 @@ export function findPrecisionCandidates(source: string, file: string): Precision
321311
return analyzeIconSource(source, file).candidates
322312
}
323313

324-
export function findNewPrecisionCandidates(
325-
current: PrecisionCandidate[],
326-
base: PrecisionCandidate[]
327-
): PrecisionCandidate[] {
328-
const allowedCounts = new Map<string, number>()
329-
for (const candidate of base) {
330-
allowedCounts.set(candidate.pathHash, (allowedCounts.get(candidate.pathHash) ?? 0) + 1)
331-
}
332-
333-
const seenCounts = new Map<string, number>()
334-
return current.filter((candidate) => {
335-
const seen = (seenCounts.get(candidate.pathHash) ?? 0) + 1
336-
seenCounts.set(candidate.pathHash, seen)
337-
return seen > (allowedCounts.get(candidate.pathHash) ?? 0)
338-
})
339-
}
340-
341314
async function currentIconFiles(): Promise<string[]> {
342315
const emcnIcons = (await readdir(EMCN_ICONS_DIRECTORY))
343316
.filter((file) => file.endsWith('.tsx'))
@@ -346,37 +319,6 @@ async function currentIconFiles(): Promise<string[]> {
346319
return [...STATIC_ICON_FILES, ...emcnIcons]
347320
}
348321

349-
function gitOutput(arguments_: string[]): string {
350-
const result = Bun.spawnSync(['git', ...arguments_], {
351-
cwd: ROOT,
352-
stdout: 'pipe',
353-
stderr: 'pipe',
354-
})
355-
if (result.exitCode !== 0) {
356-
const error = new TextDecoder().decode(result.stderr).trim()
357-
throw new Error(`git ${arguments_.join(' ')} failed: ${error}`)
358-
}
359-
return new TextDecoder().decode(result.stdout)
360-
}
361-
362-
function baseIconPaths(baseCommit: string): string[] {
363-
const output = gitOutput([
364-
'ls-tree',
365-
'-r',
366-
'--name-only',
367-
baseCommit,
368-
'--',
369-
...STATIC_ICON_PATHS,
370-
'packages/emcn/src/icons',
371-
])
372-
return output
373-
.split('\n')
374-
.filter(
375-
(file) =>
376-
STATIC_ICON_PATHS.includes(file) || /^packages\/emcn\/src\/icons\/.*\.tsx$/.test(file)
377-
)
378-
}
379-
380322
async function scanCurrentFiles(files: string[]): Promise<IconPrecisionAnalysis> {
381323
const candidates: PrecisionCandidate[] = []
382324
const invalidExceptions: InvalidPrecisionException[] = []
@@ -388,15 +330,6 @@ async function scanCurrentFiles(files: string[]): Promise<IconPrecisionAnalysis>
388330
return { candidates, invalidExceptions }
389331
}
390332

391-
function scanBaseFiles(baseCommit: string, files: string[]): PrecisionCandidate[] {
392-
const candidates: PrecisionCandidate[] = []
393-
for (const file of files) {
394-
const source = gitOutput(['show', `${baseCommit}:${file}`])
395-
candidates.push(...findPrecisionCandidates(source, path.join(ROOT, file)))
396-
}
397-
return candidates
398-
}
399-
400333
function printCandidate(candidate: PrecisionCandidate): void {
401334
console.error(
402335
` ${candidate.file}:${candidate.line} (${candidate.icon}) — ${candidate.maxFractionDigits} fractional digits`
@@ -405,18 +338,13 @@ function printCandidate(candidate: PrecisionCandidate): void {
405338
}
406339

407340
async function main(): Promise<void> {
408-
const [baseRef, ...unknownArguments] = process.argv.slice(2)
409-
if (!baseRef || unknownArguments.length > 0 || baseRef.startsWith('-')) {
410-
console.error('Usage: bun run check:icon-path-precision <base-ref>')
411-
console.error('Example: bun run check:icon-path-precision staging')
341+
if (process.argv.length > 2) {
342+
console.error('Usage: bun run check:icon-path-precision')
412343
process.exit(1)
413344
}
414345

415-
const baseCommit = gitOutput(['rev-parse', '--verify', `${baseRef}^{commit}`]).trim()
416346
const files = await currentIconFiles()
417347
const current = await scanCurrentFiles(files)
418-
const baseCandidates = scanBaseFiles(baseCommit, baseIconPaths(baseCommit))
419-
const newCandidates = findNewPrecisionCandidates(current.candidates, baseCandidates)
420348

421349
if (current.invalidExceptions.length > 0) {
422350
console.error(
@@ -427,13 +355,13 @@ async function main(): Promise<void> {
427355
}
428356
}
429357

430-
if (newCandidates.length > 0) {
358+
if (current.candidates.length > 0) {
431359
console.error(
432-
`\nFound ${newCandidates.length} new or changed icon path(s) with more than ${MAX_ICON_PATH_FRACTION_DIGITS} fractional digits compared with ${baseRef}:\n`
360+
`\nFound ${current.candidates.length} icon path(s) with more than ${MAX_ICON_PATH_FRACTION_DIGITS} fractional digits:\n`
433361
)
434-
for (const candidate of newCandidates) printCandidate(candidate)
362+
for (const candidate of current.candidates) printCandidate(candidate)
435363
console.error(
436-
'\nRound only numeric values inside the literal d attribute to at most two decimal places.'
364+
'\nRound only numeric values inside the literal d attribute to at most three decimal places.'
437365
)
438366
console.error(
439367
'If extra precision is visibly necessary, place this reasoned exception immediately before that path:'
@@ -446,12 +374,12 @@ async function main(): Promise<void> {
446374
)
447375
}
448376

449-
if (current.invalidExceptions.length > 0 || newCandidates.length > 0) {
377+
if (current.invalidExceptions.length > 0 || current.candidates.length > 0) {
450378
process.exit(1)
451379
}
452380

453381
console.log(
454-
`✓ No new overly precise icon paths compared with ${baseRef} (${files.length} current icon files checked).`
382+
`✓ All literal icon paths use at most three decimal places (${files.length} files checked).`
455383
)
456384
}
457385

scripts/run-audits.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import path from 'node:path'
1717
const EXCLUDED: Record<string, string> = {
1818
'check:audits': 'this runner',
1919
'check:migrations': 'needs a git base ref argument',
20-
'check:icon-path-precision': 'needs a git base ref argument',
2120
'check:api-validation': 'superseded by the :strict variant, which this runner does run',
2221
}
2322

0 commit comments

Comments
 (0)