diff --git a/frontend/brand-guidelines.md b/frontend/brand-guidelines.md index 38eaa588c5..8264f075a9 100644 --- a/frontend/brand-guidelines.md +++ b/frontend/brand-guidelines.md @@ -658,6 +658,16 @@ When implementing any component: We use the **[Lucide icon library](https://lucide.dev/)** to ensure a consistent, modern, and lightweight visual language. Icons are line-only, aligning with our clean and technical aesthetic. +## Brand marks and the safe area + +Third-party brand marks live in `frontend/src/lib/components/icons/`. They sit next to Lucide glyphs at the same requested size — in resource rows, pickers and trigger strips — so they have to occupy the same box, or the coloured mark reads as larger than the grey glyph beside it. + +Lucide draws on a 24×24 grid and its artwork, stroke included, spans 22 of those 24 units. Every brand mark matches that: **a square `viewBox` centred on the artwork, sized so the painted artwork spans 22/24 of it**, leaving roughly 4% of the box as margin on the tightest side. + +When adding an icon, do not paste the brand's own `viewBox` from its press kit — those are cropped to the artwork, so the mark lands edge to edge and draws about 10% larger than everything around it. Instead, measure the painted bounding box, then set the `viewBox` to a square of `max(width, height) × 24/22` centred on it. `/kitchen_sink/icons` renders the whole library with an **Icon box** toggle that outlines each icon's box, which is the quickest way to see whether a new mark sits with its neighbours. + +`iconViewBox.test.ts` fails on a non-square `viewBox`, the usual symptom of a pasted one. The 22/24 ratio itself needs a renderer to check, so it is not enforced automatically. + ## Do's and Don'ts ### ✅ Do diff --git a/frontend/src/lib/components/icons/iconViewBox.test.ts b/frontend/src/lib/components/icons/iconViewBox.test.ts new file mode 100644 index 0000000000..e1adc5f0a6 --- /dev/null +++ b/frontend/src/lib/components/icons/iconViewBox.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { readdirSync, readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +// Brand marks share the box lucide draws in: a square viewBox centred on the artwork, sized so +// the painted artwork spans 22/24 of it. Squareness is the half that can be checked without a +// renderer — the 22/24 ratio needs rasterising, so it is enforced by review, not here. A brand's +// own viewBox pasted from a press kit is the way this drifts, and it is almost always non-square. +const iconsDir = dirname(fileURLToPath(import.meta.url)) + +function svelteFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name) + if (entry.isDirectory()) return svelteFiles(full) + return entry.name.endsWith('.svelte') ? [full] : [] + }) +} + +describe('icon viewBox', () => { + const boxes = svelteFiles(iconsDir).flatMap((file) => { + const source = readFileSync(file, 'utf8') + return [...source.matchAll(/viewBox="([^"]*)"/g)].map((m) => ({ + icon: file.slice(iconsDir.length + 1), + viewBox: m[1] + })) + }) + + it('is square on every icon', () => { + const offenders = boxes.filter(({ viewBox }) => { + const parts = viewBox + .trim() + .split(/[\s,]+/) + .map(Number) + if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return true + const [, , width, height] = parts + return !(width > 0) || width !== height + }) + expect(offenders.map((o) => `${o.icon}: ${o.viewBox}`)).toEqual([]) + }) + + // Guards the walk itself: a broken path would make the assertion above pass over nothing. + it('finds the icon library', () => { + expect(boxes.length).toBeGreaterThan(300) + }) +})