docs: record the icon safe-area invariant and guard the viewBox shape

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-08-21 10:24:15 +02:00
co-authored by Claude Opus 5
parent 4b38d23294
commit 6ad24b7e20
2 changed files with 56 additions and 0 deletions
+10
View File
@@ -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
@@ -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)
})
})