perf(icons): reduce and guard SVG path precision (#6839)

* perf(react): reduce SVG path precision

* fix(react): preserve Sim wordmark precision

* fix(icons): preserve Quartr scale

* test(icons): ratchet SVG path precision

* fix(icons): make precision exceptions local

* perf(icons): enforce three-decimal paths
This commit is contained in:
Justin Blumencranz
2026-08-19 22:33:15 -07:00
committed by GitHub
parent 2ced737976
commit 0b71717241
11 changed files with 789 additions and 285 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -14,8 +14,9 @@
"dev:sockets": "cd apps/realtime && bun run dev",
"dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"",
"dev:full:capped": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev:capped\" \"cd apps/realtime && bun run dev\"",
"test": "bun run test:setup && turbo run test",
"test": "bun run test:setup && bun run test:icon-path-precision && turbo run test",
"test:setup": "bun run --cwd packages/sim-setup test",
"test:icon-path-precision": "bunx vitest run scripts/check-icon-path-precision.test.ts",
"format": "turbo run format",
"format:check": "turbo run format:check",
"lint": "turbo run lint",
@@ -47,6 +48,7 @@
"check:canvas-sentences": "bun run apps/sim/scripts/check-canvas-sentences.ts --require-coverage",
"check:bare-icons": "bun run scripts/check-bare-icons.ts",
"check:icon-paths": "bun run scripts/check-icon-paths.ts",
"check:icon-path-precision": "bun run scripts/check-icon-path-precision.ts",
"check:migrations": "bun run scripts/check-migrations-safety.ts",
"check:native-typecheck": "bun run scripts/check-native-typecheck.ts",
"check:source-text": "bun run scripts/check-source-text.ts",
@@ -21,7 +21,7 @@ export function BubbleChatDelay({ size = 24, width, height, ...props }: IconProp
{...props}
>
<path
d='M21.5 12C21.5 17.2467 17.2467 21.5 12 21.5C10.3719 21.5 8.8394 21.0904 7.5 20.3687C5.63177 19.362 4.37462 20.2979 3.26592 20.4658C3.09774 20.4913 2.93024 20.4302 2.80997 20.31C2.62741 20.1274 2.59266 19.8451 2.6935 19.6074C3.12865 18.5818 3.5282 16.6382 2.98341 15C2.6698 14.057 2.5 13.0483 2.5 12C2.5 6.75329 6.75329 2.5 12 2.5C17.2467 2.5 21.5 6.75329 21.5 12Z'
d='M21.5 12C21.5 17.25 17.25 21.5 12 21.5C10.37 21.5 8.84 21.09 7.5 20.37C5.63 19.36 4.37 20.3 3.27 20.47C3.1 20.49 2.93 20.43 2.81 20.31C2.63 20.13 2.59 19.85 2.69 19.61C3.13 18.58 3.53 16.64 2.98 15C2.67 14.06 2.5 13.05 2.5 12C2.5 6.75 6.75 2.5 12 2.5C17.25 2.5 21.5 6.75 21.5 12Z'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
@@ -16,7 +16,7 @@ export function DocumentAttachment(props: SVGProps<SVGSVGElement>) {
{...props}
>
<path
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'
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'
fill='currentColor'
/>
<path
+1 -1
View File
@@ -33,7 +33,7 @@ export function Download({ animate = false, className, ...props }: DownloadProps
{...props}
>
{/* tray — same as Upload */}
<path d='M0.75 12.75V16.75C0.75 17.8546 1.64543 18.75 2.75 18.75H17.75C18.8546 18.75 19.75 17.8546 19.75 16.75V12.75' />
<path d='M0.75 12.75V16.75C0.75 17.85 1.65 18.75 2.75 18.75H17.75C18.85 18.75 19.75 17.85 19.75 16.75V12.75' />
{/* stem — top to tray */}
<path d='M10.25 1.75V12.75' />
{/* arrowhead pointing down */}
+1 -1
View File
@@ -19,7 +19,7 @@ export function Duplicate(props: SVGProps<SVGSVGElement>) {
aria-hidden='true'
{...props}
>
<path d='M14.25 0.75H2.75C1.64543 0.75 0.75 1.64543 0.75 2.75V14.25' />
<path d='M14.25 0.75H2.75C1.65 0.75 0.75 1.65 0.75 2.75V14.25' />
<rect x='5.25' y='5.25' width='14' height='14' rx='2' />
</svg>
)
+1 -1
View File
@@ -20,7 +20,7 @@ export function Mic(props: SVGProps<SVGSVGElement>) {
{...props}
>
<rect x='7.25' y='0.75' width='6' height='12' rx='3' />
<path d='M3.75 9.25V10.25C3.75 13.8399 6.66015 16.75 10.25 16.75C13.8399 16.75 16.75 13.8399 16.75 10.25V9.25' />
<path d='M3.75 9.25V10.25C3.75 13.84 6.66 16.75 10.25 16.75C13.84 16.75 16.75 13.84 16.75 10.25V9.25' />
<path d='M10.25 16.75V19.25' />
</svg>
)
+1 -1
View File
@@ -19,7 +19,7 @@ export function Upload(props: SVGProps<SVGSVGElement>) {
aria-hidden='true'
{...props}
>
<path d='M0.75 12.75V16.75C0.75 17.8546 1.64543 18.75 2.75 18.75H17.75C18.8546 18.75 19.75 17.8546 19.75 16.75V12.75' />
<path d='M0.75 12.75V16.75C0.75 17.85 1.65 18.75 2.75 18.75H17.75C18.85 18.75 19.75 17.85 19.75 16.75V12.75' />
<path d='M10.25 14.75V1.75' />
<path d='M5.25 6.75L10.25 1.75L15.25 6.75' />
</svg>
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import {
analyzeIconSource,
effectiveFractionDigits,
findPrecisionCandidates,
} from './check-icon-path-precision'
const FIXTURE_PATH = '/repo/packages/emcn/src/icons/fixture.tsx'
describe('icon path precision audit', () => {
it('accepts the three-decimal boundary and ignores geometry outside literal paths', () => {
const source = `
const dynamicPath = 'M0.1234 1'
const unrelated = "d='M0.1234 1'"
export function SafeIcon() {
return (
<svg viewBox='0 0 10.1234 10' transform='scale(0.16624)'>
<path d='M.5 1.200L1.2e1 2' />
<path d={dynamicPath} />
</svg>
)
}
`
expect(analyzeIconSource(source, FIXTURE_PATH)).toEqual({
candidates: [],
invalidExceptions: [],
})
})
it('finds ordinary decimals and exponents finer than a thousandth', () => {
const source = `
export function PreciseIcon() {
return <svg><path d='M0.1234 1e-4L2.345 5' /></svg>
}
`
const candidates = findPrecisionCandidates(source, FIXTURE_PATH)
expect(candidates).toHaveLength(1)
expect(candidates[0]).toMatchObject({
icon: 'PreciseIcon',
maxFractionDigits: 4,
offendingNumbers: ['0.1234', '1e-4'],
})
expect(effectiveFractionDigits('2.13949e-05')).toBe(10)
})
it('checks literal JSX expressions and static template literals', () => {
const source = `
export function ExpressionIcon() {
return (
<svg>
<path d={'M0.1234 1'} />
<path d={\`M2.3456 3\`} />
</svg>
)
}
`
expect(findPrecisionCandidates(source, FIXTURE_PATH)).toHaveLength(2)
})
it('accepts a reasoned TSDoc exception on the immediately following path', () => {
const source = `
export function PreciseBrandIcon() {
return (
<svg>
{/**
* svg-path-precision-exception: Rounding visibly distorts the provider-authored mark.
*/}
<path d='M0.1234 1' />
</svg>
)
}
`
expect(analyzeIconSource(source, FIXTURE_PATH)).toEqual({
candidates: [],
invalidExceptions: [],
})
})
it('rejects exceptions without a reason and exceptions on already-clean paths', () => {
const missingReason = `
export function PreciseIcon() {
return <svg>{/** svg-path-precision-exception: */}<path d='M0.1234 1' /></svg>
}
`
const unnecessary = `
export function CleanIcon() {
return <svg>{/** svg-path-precision-exception: Keep detail. */}<path d='M0.12 1' /></svg>
}
`
const missingReasonAnalysis = analyzeIconSource(missingReason, FIXTURE_PATH)
expect(missingReasonAnalysis.candidates).toHaveLength(1)
expect(missingReasonAnalysis.invalidExceptions[0]?.message).toContain('specific reason')
const unnecessaryAnalysis = analyzeIconSource(unnecessary, FIXTURE_PATH)
expect(unnecessaryAnalysis.candidates).toEqual([])
expect(unnecessaryAnalysis.invalidExceptions[0]?.message).toContain('unnecessary')
})
it('requires a reasoned exception to remain while its precise path remains', () => {
const excepted = `
export function PreciseBrandIcon() {
return (
<svg>
{/** svg-path-precision-exception: Rounding visibly distorts the brand mark. */}
<path d='M0.1234 1' />
</svg>
)
}
`
const exceptionRemoved = excepted.replace(
'{/** svg-path-precision-exception: Rounding visibly distorts the brand mark. */}',
''
)
expect(findPrecisionCandidates(excepted, FIXTURE_PATH)).toEqual([])
expect(findPrecisionCandidates(exceptionRemoved, FIXTURE_PATH)).toHaveLength(1)
})
})
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env bun
/**
* Prevents overly precise numeric values in literal SVG icon `d` attributes.
*
* Three decimal places are enough for the reusable icons covered here: additional
* digits increase shipped source without a visible benefit. Every path must
* satisfy the limit or carry a reasoned local exception.
*
* Scope is intentionally limited to the shared app/docs icon catalogs and EMCN
* icon components. SVG transforms, view boxes, dynamic path expressions, and
* page-specific artwork are not inspected because their safe precision depends
* on context.
*
* Run with `bun run check:icon-path-precision`.
*/
import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { parse } from '@babel/parser'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const EMCN_ICONS_DIRECTORY = path.join(ROOT, 'packages/emcn/src/icons')
const STATIC_ICON_FILES = [
path.join(ROOT, 'apps/docs/components/icons.tsx'),
path.join(ROOT, 'apps/sim/components/icons.tsx'),
]
const SVG_NUMBER_PATTERN = /[+-]?(?:(?:\d+\.\d*)|(?:\.\d+)|(?:\d+))(?:[eE][+-]?\d+)?/g
const PRECISION_EXCEPTION_DIRECTIVE = 'svg-path-precision-exception:'
export const MAX_ICON_PATH_FRACTION_DIGITS = 3
interface ParsedPrecisionException {
line: number
reason: string | null
}
interface LiteralPath {
exception: ParsedPrecisionException | null
icon: string
line: number
value: string
}
export interface PrecisionCandidate {
file: string
icon: string
line: number
maxFractionDigits: number
offendingNumbers: string[]
}
export interface InvalidPrecisionException {
file: string
line: number
message: string
}
export interface IconPrecisionAnalysis {
candidates: PrecisionCandidate[]
invalidExceptions: InvalidPrecisionException[]
}
interface ExtractedPaths {
paths: LiteralPath[]
invalidExceptions: Omit<InvalidPrecisionException, 'file'>[]
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : null
}
function jsxStringValue(attribute: Record<string, unknown>): string | null {
const value = asRecord(attribute.value)
if (!value) return null
if (value.type === 'StringLiteral' && typeof value.value === 'string') return value.value
if (value.type !== 'JSXExpressionContainer') return null
const expression = asRecord(value.expression)
if (!expression) return null
if (expression.type === 'StringLiteral' && typeof expression.value === 'string') {
return expression.value
}
if (expression.type !== 'TemplateLiteral') return null
const expressions = expression.expressions
const quasis = expression.quasis
if (!Array.isArray(expressions) || expressions.length > 0 || !Array.isArray(quasis)) return null
const quasi = asRecord(quasis[0])
const quasiValue = asRecord(quasi?.value)
if (!quasiValue) return null
if (typeof quasiValue.cooked === 'string') return quasiValue.cooked
return typeof quasiValue.raw === 'string' ? quasiValue.raw : null
}
function iconNameAt(source: string, offset: number): string {
const before = source.slice(0, offset)
const matches = [...before.matchAll(/export (?:function|const) (\w+)\s*[=(]/g)]
return matches.length > 0 ? matches[matches.length - 1][1] : '<unknown>'
}
function nodeLine(node: Record<string, unknown>): number {
const location = asRecord(node.loc)
const start = asRecord(location?.start)
return typeof start?.line === 'number' ? start.line : 1
}
function jsxElementName(node: Record<string, unknown>): string | null {
const openingElement = asRecord(node.openingElement)
const name = asRecord(openingElement?.name)
return name?.type === 'JSXIdentifier' && typeof name.name === 'string' ? name.name : null
}
function normalizedComment(comment: Record<string, unknown>): string {
if (typeof comment.value !== 'string') return ''
return comment.value
.split('\n')
.map((line) => line.replace(/^\s*\*?\s?/, '').trim())
.filter(Boolean)
.join(' ')
}
function precisionExceptionFromChild(
child: Record<string, unknown>,
source: string
): ParsedPrecisionException | null {
if (child.type !== 'JSXExpressionContainer') return null
const expression = asRecord(child.expression)
if (expression?.type !== 'JSXEmptyExpression') return null
const comments = expression.innerComments
if (!Array.isArray(comments)) return null
for (const value of comments) {
const comment = asRecord(value)
if (!comment) continue
const start = typeof comment.start === 'number' ? comment.start : -1
const end = typeof comment.end === 'number' ? comment.end : -1
if (start < 0 || end < 0 || !source.slice(start, end).startsWith('/**')) continue
const text = normalizedComment(comment)
if (!text.startsWith(PRECISION_EXCEPTION_DIRECTIVE)) continue
const reason = text.slice(PRECISION_EXCEPTION_DIRECTIVE.length).trim()
return { line: nodeLine(comment), reason: reason || null }
}
return null
}
function extractLiteralPaths(source: string, file: string): ExtractedPaths {
const syntaxTree = parse(source, {
sourceFilename: file,
sourceType: 'module',
plugins: ['typescript', 'jsx'],
})
const paths: LiteralPath[] = []
const invalidExceptions: Omit<InvalidPrecisionException, 'file'>[] = []
function invalidate(exception: ParsedPrecisionException, message: string): void {
invalidExceptions.push({ line: exception.line, message })
}
function visitChildren(children: unknown): void {
if (!Array.isArray(children)) return
let pendingException: ParsedPrecisionException | null = null
for (const value of children) {
const child = asRecord(value)
if (!child) continue
if (child.type === 'JSXText' && typeof child.value === 'string' && !child.value.trim())
continue
const exception = precisionExceptionFromChild(child, source)
if (exception) {
if (pendingException) {
invalidate(pendingException, 'Exception must immediately precede one literal <path>.')
}
pendingException = exception
continue
}
if (pendingException && (child.type !== 'JSXElement' || jsxElementName(child) !== 'path')) {
invalidate(pendingException, 'Exception must immediately precede one literal <path>.')
pendingException = null
}
visit(child, pendingException)
pendingException = null
}
if (pendingException) {
invalidate(pendingException, 'Exception must immediately precede one literal <path>.')
}
}
function visit(value: unknown, exception: ParsedPrecisionException | null = null): void {
if (Array.isArray(value)) {
for (const entry of value) visit(entry)
return
}
const node = asRecord(value)
if (!node) return
if (node.type === 'JSXElement') {
const openingElement = asRecord(node.openingElement)
if (jsxElementName(node) === 'path' && openingElement) {
const attributes = openingElement.attributes
const dAttribute = Array.isArray(attributes)
? attributes.map(asRecord).find((attribute) => {
const name = asRecord(attribute?.name)
return name?.type === 'JSXIdentifier' && name.name === 'd'
})
: null
const pathValue = dAttribute ? jsxStringValue(dAttribute) : null
if (pathValue !== null) {
const start = typeof openingElement.start === 'number' ? openingElement.start : 0
paths.push({
exception,
icon: iconNameAt(source, start),
line: nodeLine(dAttribute ?? openingElement),
value: pathValue,
})
} else if (exception) {
invalidate(exception, 'Exception applies only to a literal <path d> value.')
}
} else if (exception) {
invalidate(exception, 'Exception must immediately precede one literal <path>.')
}
visitChildren(node.children)
return
}
if (node.type === 'JSXFragment') {
if (exception) {
invalidate(exception, 'Exception must immediately precede one literal <path>.')
}
visitChildren(node.children)
return
}
for (const [key, child] of Object.entries(node)) {
if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra') continue
visit(child)
}
}
visit(syntaxTree)
return { paths, invalidExceptions }
}
/**
* Counts both digits written after the decimal point and precision introduced
* by a negative exponent. This catches values such as `1.234` and `1e-3`.
*/
export function effectiveFractionDigits(numberLiteral: string): number {
const [mantissa, exponentText] = numberLiteral.toLowerCase().split('e')
const decimalIndex = mantissa.indexOf('.')
const writtenFractionDigits = decimalIndex < 0 ? 0 : mantissa.length - decimalIndex - 1
const exponent = exponentText === undefined ? 0 : Number.parseInt(exponentText, 10)
const exponentFractionDigits = Math.max(0, writtenFractionDigits - exponent)
return Math.max(writtenFractionDigits, exponentFractionDigits)
}
function normalizedRelativePath(file: string): string {
return path.relative(ROOT, file).split(path.sep).join('/')
}
export function analyzeIconSource(source: string, file: string): IconPrecisionAnalysis {
const extracted = extractLiteralPaths(source, file)
const candidates: PrecisionCandidate[] = []
const normalizedFile = normalizedRelativePath(file)
const invalidExceptions = extracted.invalidExceptions.map((exception) => ({
...exception,
file: normalizedFile,
}))
for (const literalPath of extracted.paths) {
const preciseNumbers = [...literalPath.value.matchAll(SVG_NUMBER_PATTERN)]
.map((match) => match[0])
.filter(
(numberLiteral) => effectiveFractionDigits(numberLiteral) > MAX_ICON_PATH_FRACTION_DIGITS
)
if (literalPath.exception) {
if (!literalPath.exception.reason) {
invalidExceptions.push({
file: normalizedFile,
line: literalPath.exception.line,
message: 'Exception must include a specific reason after the colon.',
})
} else if (preciseNumbers.length === 0) {
invalidExceptions.push({
file: normalizedFile,
line: literalPath.exception.line,
message: 'Exception is unnecessary because this path uses at most three decimal places.',
})
} else {
continue
}
}
if (preciseNumbers.length === 0) continue
candidates.push({
file: normalizedFile,
icon: literalPath.icon,
line: literalPath.line,
maxFractionDigits: Math.max(...preciseNumbers.map(effectiveFractionDigits)),
offendingNumbers: [...new Set(preciseNumbers)].slice(0, 4),
})
}
return { candidates, invalidExceptions }
}
export function findPrecisionCandidates(source: string, file: string): PrecisionCandidate[] {
return analyzeIconSource(source, file).candidates
}
async function currentIconFiles(): Promise<string[]> {
const emcnIcons = (await readdir(EMCN_ICONS_DIRECTORY))
.filter((file) => file.endsWith('.tsx'))
.sort()
.map((file) => path.join(EMCN_ICONS_DIRECTORY, file))
return [...STATIC_ICON_FILES, ...emcnIcons]
}
async function scanCurrentFiles(files: string[]): Promise<IconPrecisionAnalysis> {
const candidates: PrecisionCandidate[] = []
const invalidExceptions: InvalidPrecisionException[] = []
for (const file of files) {
const analysis = analyzeIconSource(await readFile(file, 'utf8'), file)
candidates.push(...analysis.candidates)
invalidExceptions.push(...analysis.invalidExceptions)
}
return { candidates, invalidExceptions }
}
function printCandidate(candidate: PrecisionCandidate): void {
console.error(
` ${candidate.file}:${candidate.line} (${candidate.icon}) — ${candidate.maxFractionDigits} fractional digits`
)
console.error(` values: ${candidate.offendingNumbers.join(', ')}`)
}
async function main(): Promise<void> {
if (process.argv.length > 2) {
console.error('Usage: bun run check:icon-path-precision')
process.exit(1)
}
const files = await currentIconFiles()
const current = await scanCurrentFiles(files)
if (current.invalidExceptions.length > 0) {
console.error(
`\nFound ${current.invalidExceptions.length} invalid SVG precision exception(s):\n`
)
for (const exception of current.invalidExceptions) {
console.error(` ${exception.file}:${exception.line} — ${exception.message}`)
}
}
if (current.candidates.length > 0) {
console.error(
`\nFound ${current.candidates.length} icon path(s) with more than ${MAX_ICON_PATH_FRACTION_DIGITS} fractional digits:\n`
)
for (const candidate of current.candidates) printCandidate(candidate)
console.error(
'\nRound only numeric values inside the literal d attribute to at most three decimal places.'
)
console.error(
'If extra precision is visibly necessary, place this reasoned exception immediately before that path:'
)
console.error(`{/**
* ${PRECISION_EXCEPTION_DIRECTIVE} Explain why rounding changes this geometry.
*/}`)
console.error(
'Do not round transform or viewBox values automatically; verify those geometry changes separately.'
)
}
if (current.invalidExceptions.length > 0 || current.candidates.length > 0) {
process.exit(1)
}
console.log(
`✓ All literal icon paths use at most three decimal places (${files.length} files checked).`
)
}
if (import.meta.main) {
main().catch((error: unknown) => {
console.error(error)
process.exit(1)
})
}