What it solves
A site that was built with a design system and still drifted — because a design system nobody enforces is a folder of comments.
The build had system.css with tokens at the top and a rule written into
the file: a component may not invent a colour, a size, a gap or a radius.
Three weeks later, measured:
where radius typed by hand
contact form fields 8px
dashboard cards 12px
a small tag 6px
a checkbox 4px
Button.svelte 8px (the proposal's button was 13px)
Seven files, four different numbers, none of them a token. The client noticed before anyone else did — the button on the site did not match the button in the proposal he had already sent, which reads to a client not as a rounding error but as two different companies.
Worse, three pages each held their own copy of the same field styles. Each copy had already drifted a few pixels from the others, so "fix the form" meant finding three places and getting all three right.
The fix is three rules, and the third is the one that makes the other two survive contact with a busy week:
- A page may not invent a value. No colour, size, gap or radius typed at the call site. If it is not a token, it gets a name in the system first, then gets used.
- Two things needing the same look is a third component, not a second
copy.
FormPanel.svelteexists because three pages had drifted copies of the same fields. - Every rule gets a gate that fails the build. Not a lint warning anybody can scroll past — a non-zero exit that stops the deploy.
The code
Wire the gates in as the first line of the deploy script, before the build, so a failure stops the ship rather than filing a warning nobody reads:
bash bin/check.sh || exit 1
The eight gates
Every one of these exists because the thing it catches actually happened on this build. None are hypothetical and none are style opinions.
| Gate | Catches |
|---|---|
buttons come from Button.svelte |
class="btn btn--solid" on a page, matching nothing |
| nothing outside the tokens types a value | a colour, type size, spacing or radius typed at a call site |
| selects are reset | a <select> painting itself as a system control |
fields come from FormPanel |
a page starting its own copy of the field styles |
| the reset appears once | the CSS reset pasted into the sheet twice |
| the schema has every column the flow writes | CREATE TABLE IF NOT EXISTS silently keeping an old shape |
| every queued job has a handler | a job enqueued with nothing to run it |
| every internal link lands on a route | a link to a page that does not exist |
Each gate found something the first time it ran. The link gate found six
404s — every service card on the site pointed at /book/<slug>, which was
never a route. Six dead ends in the middle of the client's own path, live,
for weeks.
The trick that makes one component own every field
Svelte scopes styles to the component that declares them, which normally
means a wrapper cannot style the markup its children pass in. :global
inside a fenced class solves it — the styles reach the fields, but nothing
escapes onto anything else:
.panel :global(input),
.panel :global(select),
.panel :global(textarea) { ... }
That is what lets four pages share one set of field styles while the markup stays where it belongs, on the page.
Notes / gotchas
A gate you have not watched fail is not a gate. Break each rule on
purpose, confirm the gate goes red and the script exits non-zero, then put
it back. The first version of the link gate here only matched strings
written directly after href= — so the moment the link moved into a
$derived it went silent, on the exact link it had been written to catch.
It passed clean and proved nothing. Ten seconds of deliberately breaking it
found that.
Some rules cannot be grepped, and grep-shaped gates give false comfort.
The schema, job and link gates run as real scripts against real structures.
gates/schema.js builds the schema into a throwaway database and checks it
against every INSERT the code actually performs — because
CREATE TABLE IF NOT EXISTS is silent when the table already exists with
the wrong shape, which is how an old table survived a rewrite and 500'd
every form submission.
Adapt the file paths. gates/schema.js and gates/jobs.js read
src/lib/server/flow.js and db.js by name. Point them at wherever the
equivalent lives; the technique is the point, not the paths.
One place genuinely cannot read the tokens. A password gate rendered in
hooks.server.js is served before the app's CSS loads, so its values are
copied by hand. That is fine — but say so in a comment, or the next person
changes a token and cannot work out why one screen did not move.
Name a token for its job, not its size. --radius-control,
--radius-card, --radius-button survive a redesign; --radius-8 does
not. The one exception worth making is matching an artifact the client has
already seen — here the button's 13px, weight 650 and 15px/26px padding
came straight off the proposal document, deliberately, and --pad-button
carries a comment saying so.
A section scale is not a control scale, and pretending otherwise is how
call sites start inventing numbers. The page scale here is
6/12/20/32/52/80 — right for laying out sections, useless for a control
that needs 14px of padding to sit at 52px tall. Twenty-eight spacing values
had been typed at call sites for exactly this reason. The fix is not to
loosen the rule, it is a second set of tokens named for the job:
--pad-field, --pad-button, --pad-chip, --pad-card, --pad-tag,
--gap-label, --gap-tight.
Check declarations, not lines. gates/tokens.js parses each
property: value pair, because a rule that reads a token for one value and
hardcodes the next — border-radius: var(--radius-card); padding: 16px 18px
— passes any grep that looks for a line without var(--.