@weatherboard/gyde-design
v0.6.0
Published
Scaffolds a design system into a product repository, then keeps auditing it.
Readme
Gyde — design system scaffolding and gate
Gyde measures a repository's design system, scaffolds what is missing, and then keeps auditing what you build against it. It emits code you own, and it gates against a ledger that may only ever get shorter.
gyde-design scan <path> measure a repository as it is
gyde-design plan <path> say what would be emitted; write nothing
gyde-design init <path> write it; never overwrites
gyde-design gate <path> fail on anything new since the committed ledger
gyde-design tokens print the stylesheet for the seed dictionaryWhat Gyde is
An always-on creative director. A product does not maintain its own review standards in-tree and hope they keep firing; it asks Gyde, and Gyde answers with a verdict.
The boundary in one line: Gyde decides whether it is valid; the product decides what it is.
Gyde owns the rules, the normalised representation they are written against, the ledger format, the templates, and the upgrade path that reaches an edited file.
Gyde never owns your components, your token values, your content, layouts, brand or product decisions, or your repository. Everything Gyde emits is yours from the moment it is written, and Gyde will not overwrite it. Gyde enforces the token contract; the values belong to you.
If design-system logic ends up living in your repo, that is a bug in the installation, not a feature of the product.
Authority
The only sanctioned escape valve is the allowance ledger: per file, per rule, per count, and only ever ratcheting down. There is no env var, no skip flag, and no supported way to make a failing gate pass by editing a baseline upward.
gate with no ledger records one and exits non-zero. Recording debt is the
sanctioned way it enters — amnesty never — and a run that recorded rather than
judged is not a pass.
Degraded mode fails loudly, never silently green. If Gyde cannot run, the
gate fails or reports "Gyde did not run" as an explicit state that CI treats as
failure. There is no configuration in which "could not run" produces a pass.
This is load-bearing, not a preference: the most expensive failures this tool
was built against were checks that reported success without executing — a
paths: filter skipping a job, a package filter matching nothing and exiting 0.
Installing it
Repository CI takes Gyde as a GitHub Action. No dependency, no token, no
.npmrc:
# .github/workflows/gyde.yml
name: Gyde
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
design-system:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Weatherboard-Studio/gyde@v1
with:
fail-on: newYour dependency graph is untouched and no credential enters your CI. There is no
install step inside the action either: the engine imports nothing but node:
builtins, so checking the action out is the installation. A test pins that
property, and a registry outage cannot fail your gate.
Laptops, sandboxes and agents take the package, because neither runs GitHub Actions:
npm i -D @weatherboard/gyde-designThe package name is under review as part of going public — see the scope question in
docs/decisions/g-64-public.md.
fail-on: none still runs, still reports, still writes evidence, and still
fails if the gate could not run. It only declines to block on the verdict, and
it does so visibly. It is what an adopting repository uses for a fortnight while
it decides whether the ledger is right. It is not what it uses forever.
continue-on-error is not supported and never will be — it makes every failure
look like a pass, which is the one outcome a gate exists to prevent.
The one idea
Every design-system audit we had was written against one repository's spelling, and each was correct locally and useless one repository over. One matched Tailwind utilities; another matched CSS declarations; neither would have found anything in the other's repo.
So the rules do not match source text. Five spellings of one decision —
rounded-lg Tailwind utility
border-radius: 8px CSS declaration
borderRadius: 8 JS / StyleX object
borderRadius: radius.card
var(--radius-card) custom property— all normalise to the same radius decision, and one rule asks the question it
was always trying to ask: is this tokenised? rules.test.mjs proves it
against all five.
The modules
| | |
| --- | --- |
| workspace.mjs | Discovers packages from the workspace declaration, never from directory names. Classifies each; every exclusion carries a reason. |
| normalise.mjs | Reduces Tailwind utilities, CSS declarations, JS/StyleX objects and cva strings to one representation. |
| rules.mjs | The violation rules, written once against that representation. Refuses to load a rule without both a failing and a passing fixture. |
| scan.mjs | Walks a tree, classifies, scores. Never emits a percentage without its denominator. |
| tokens.mjs | The dictionary shape, its guarantees, and generators for CSS, plain constants and DTCG. |
| boundaries.mjs | Import and dependency boundaries, the exemption register, and version-drift detection. |
| ratchet.mjs | The allowance ledger. A cleared entry is deleted, never zeroed. |
| emit.mjs | The seed components and the two structural modules. |
| catalogue.mjs | The catalogue, generated from the same list the barrel is. |
| wiring.mjs | Is the design system actually connected to the app that uses it? |
| usage.mjs | Which component is used where, and what the product keeps reinventing. |
| upgrade.mjs | Provenance, and the three-way classification that uses it. |
| agentdocs.mjs | What an agent building the product reads before writing UI. |
| ruleindex.mjs | Every rule as data — id, name, intent, fix shape. Describes; never decides. |
| selfgate.mjs | Emits a scaffold and gates it. Gyde held to its own rules. |
| floor.mjs | Opt-in scoped adoption floors and their named structural exceptions. Fails closed on a scope that matches nothing. |
Measured, on the three repositories it was built from
De-identified, because the numbers are the evidence and the names are not.
| | product files | decls | tokenised | findings | | --- | ---: | ---: | ---: | ---: | | System A — mature system, CSS-declaration styling | 176 | 379 | 98% | 9 | | System B — mature system, low application adoption | 1,801 | 7,361 | 15% | 6,078 | | System C — shadcn-style, utility-first page code | 394 | 2,642 | 8% | 2,437 |
System A's 98% is corroboration worth having: that team independently reports 96% adoption from a rule set built on entirely different premises. Two points apart is the best evidence available that the normalised form measures the same thing a hand-written rule set does.
The two low numbers are not the same number twice. System B has a mature
system its applications have not adopted. System C's apps import its UI package
in 109 files and define zero bespoke components — its 8% is Tailwind utilities
in page code, which is what shadcn expects you to write. A verdict that cannot
tell those apart is worthless, which is why scan classifies before it scores.
Why plan and init share a call
Both go through one buildEmission(). The dry run cannot describe a file the
real run would not write, and that is structural rather than a promise — a
scaffolder whose two modes can diverge is one nobody can review before it writes
into their repository.
init refuses to overwrite anything. Gyde emits once and the file becomes the
product's; a scaffolder that clobbers has taken ownership of something it does
not own, silently.
Importing a module directly
The barrel (@weatherboard/gyde-design) is the curated named surface. Every
module the package ships is also reachable by its own subpath:
import { themeStartup } from "@weatherboard/gyde-design/theme-entry.mjs";
import pkg from "@weatherboard/gyde-design/package.json" with { type: "json" };That is a stated guarantee rather than an accident of file layout, and
exports.test.mjs fails if a shipped module is not reachable — so verifying
your own repository never means reaching into node_modules by filesystem
path. The two exceptions are named there with their reasons: the CLI, which is
the bin and executes on import, and the vendored parsers under vendor/,
which ship so the Action can run with nothing installed and are not ours to
promise.
The rules, as data
A rule id reaches you three times — in a finding, in the rules stamp inside
gyde-allowance.json, and in a failing gate — and every time it is a bare slug.
optional-prop says what matched. It does not say what the rule defends or what
the fix looks like.
So the rules are also available as data:
import { rules, rulesJson } from "@weatherboard/gyde-design/ruleindex.mjs";
rules();
// [
// {
// id: "optional-prop",
// name: "Optional prop",
// intent: "A prop a caller may omit, leaving the component to make the decision silently.",
// fix: "Make it required. Where absence is itself a real answer, make it required and NULLABLE …",
// },
// …
// ]rules() returns frozen entries sorted by id; rulesJson() is the same thing as
a JSON string, and node ruleindex.mjs prints it. Use it to generate a rules
page, to give an agent the fix shape alongside the finding, or to diff what a
version change means.
It describes and it never decides. Nothing in the scan, the ledger or the
gate reads it, and nothing may start to: a description that can change a verdict
is a second copy of the rule kept in prose, and it will disagree with the first
one quietly. The predicates stay in rules.mjs, props.mjs, compound.mjs and
docdrift.mjs.
Completeness is checked in both directions, against the rules stamp that a
real gate run wrote rather than a list typed out beside it. A rule implemented
and not described fails; a rule described and not implemented fails too — an
index naming a rule nobody runs is the shape that reads as coverage.
Gyde is held to its own rules
npm run verify emits a scaffold into a temporary directory, runs
gyde-design gate over it through the CLI, and fails unless the recorded
baseline is zero.
The check is on the baseline and not on the exit code, and that distinction is
the whole of it. A first gate on a tree with no ledger RECORDS what it finds
and exits 1 on purpose; the second run then passes whatever the first recorded.
So a scaffold shipping ten violations gates green — correctly, for a customer
adopting Gyde, and uselessly as a check on ourselves.
That is not hypothetical. The scaffold emitted on 2026-09-15 carried ten
optional-prop findings across all five seed components, and the consuming
repository found them, not us. The emitted set now declares every prop required,
nullable where absence is a real answer, with the ordinary values in
defaults.ts — exported from the package barrel, because an escape hatch behind
a deep import into src/ is one the boundary rules forbid you to use.
Rules that go quiet
A rule that matched nothing anywhere is reported as suspicious, not clean. We have a recorded case of a rule reporting zero against 178 real hits with nobody able to tell. Silence and success must not render the same.
A number we got wrong
The first version of the table above read System B at 12% and System C at 4,061
findings. Every class inside a className attribute was counted twice, so
utility-heavy repos were measured at roughly double. It was found end to end — a
scaffolded fixture recorded seven findings for four real defects — and not by
any fixture, which is worth remembering when reading the rest of this.
Two silences this makes audible
An app can score 100% adoption while rendering unstyled HTML. If it imports
design-system components but never the token stylesheet, every
var(--color-text) resolves to nothing. Nothing throws, the build passes, and
the audit is delighted — only system imports, no literal values. wiring.mjs
checks the link, following @import across workspace packages, and reports
"could not tell" as its own outcome rather than as "fine".
A component nobody renders looks identical to one used everywhere.
usage.mjs answers the two questions the audit cannot: does the set already
compose this? and is anybody using what we shipped? Advisory, never blocking
— a cluster of bespoke class names is evidence of a missing capability, and a
gate that cannot tell that from a broken rule just makes the workaround harder
to find.
A gap the catalogue cannot close
A closed component set cannot force its own interactive states. Select's open
state is where theming across a portal boundary is proved, and exposing
defaultOpen to demonstrate it would be an escape hatch opened for the
catalogue's convenience. The generated catalogue records that as a note on the
entry rather than printing "open" beside a control that is closed — a label that
lies is worse than an admission.
The upgrade, which is the wedge
Gyde emits a component set, the product edits it — editing it is the point — and later Gyde improves the template. Reaching a file somebody has been working in is shadcn's openly unsolved problem: three multi-year issues, and its own docs call re-syncing "an ongoing responsibility rather than a solved problem".
init records provenance: the template version and a hash of each file's exact
emitted bytes. That is the missing third point, and with it every file is one of
four cases:
| | | | --- | --- | | neither changed | nothing to do | | Gyde changed only | safe to apply | | product changed only | their edit is the truth; left alone | | both changed | a conflict — reported, never merged |
It never auto-merges, never touches a file it has no provenance for, never deletes a file it stopped emitting, and never restores one the product deleted.
The subtle part: an unapplied change keeps its old baseline. Otherwise the next upgrade reads it as a product edit, and a conflict quietly becomes "yours" and is never offered again.
The one artefact that changes what gets written
.gyde/design-system.md is generated from the same metadata as the barrel and
the catalogue, so it cannot name a component that does not exist — the failure
it replaces is a hand-written registry pointing at a superseded generation of
its own components.
Its most important section is what to do when the set cannot do the thing. Without it the honest agent and the lazy one produce the same bespoke CSS, and only one of them knew it was a compromise.
Gyde does not write into your CLAUDE.md. It emits a fragment for you to
include — that file is the last one a tool should edit unasked.
For an existing product-owned design system, run gyde-design guidance . to
check whether the generated guide matches its current component exports. Run
gyde-design guidance . --write to refresh the guide, then review the diff.
This command updates only .gyde/design-system.md and refuses to replace a
handwritten file. It lists component names without guessing their props or
claiming theme APIs that Gyde has not verified in that product.
A floor over a scope you have finished
The ledger answers has this got worse? That is the right instrument for a repository carrying four thousand findings and the wrong one for the opposite case: a product that has finished the work over a defined slice of its tree and wants the finishing to stay finished. An empty ledger cannot say that — "nothing was recorded here" is also what an unmeasured scope says.
So a product may declare a floor, and nothing more happens without one:
{
"design": {
"adoptionFloors": [
{
"scope": "apps/console/src",
"minimum": 100,
"exceptions": [
{
"file": "apps/console/src/frame/MediaFrame.tsx",
"element": "iframe",
"because": "A browser-owned element the design system cannot render or restyle from inside."
}
]
}
]
}
}| key | |
| --- | --- |
| scope | A path inside the repository. Not an npm scope — that is design.scope, a different key answering a different question. |
| minimum | A whole number, 0 to 100. The fraction of eligible visual elements under the scope that must be the system's. |
| exceptions | Elements excluded from the denominator. Each names one element, in one file, with one reason. |
gyde-design gate prints the figure with its numerator, its denominator and the
scope, and prints the exceptions underneath it rather than inside it. A working
example is in examples/scoped-floor.
100% means zero. The verdict is taken on the counts, never on the rendered percentage: 999 of 1,000 floors to 99% and does not meet a floor of 100. That is the whole reason this exists — a report reading "100%" over a denominator that still holds a bespoke element is a lie that looks like an achievement.
Exceptions cannot grow quietly. One entry excuses one element. Until an element is declared it is an ordinary bespoke element and it fails the floor, so the seventh exception costs a diff a reviewer reads. And an exception that stops matching anything is an error: a stale exemption is a line sitting open for whatever lands under that name next, and it is the only way the list could shrink invisibly. The same discipline the allowance ledger has — it may only ever get shorter, and never by accident.
One file + tag may repeat, but not with different reasons. An exception
identifies its element by file and tag name matched in source order, so nothing
records which <div> a reason was written about. Many elements sharing one
argument is normal and safe — an inline-styled block of markup is genuinely one
decision — and reordering them changes nothing a reader could act on. Two
entries for one file and tag carrying different arguments is a different
thing: a reorder reattaches each reason to an element nobody wrote it about,
with the count, the figure and the verdict all unchanged. That is an error,
and the fix is one reason true of the whole group, because that is the
granularity the exemption actually has. No occurrence index is offered: an
explicit ordinal makes the identity visible without making it stable, so the
same reorder still reattaches the reasons, now with a number agreeing.
Every path fails closed. A malformed entry, an unknown key, a scope that does not resolve, a scope that matches no markup files, a scope whose files make no visual decision, an unreadable component barrel, an exception naming something that is gone — each is an ERROR that names the cause, and none of them is allowanceable or recordable as a baseline. The zero-file case is the one that mattered most: a floor over an empty denominator would report success forever, and a rule that matches nothing reads exactly like a rule that passes.
What a floor does not protect against
- Gyde does not judge the exceptions. It checks that each names a real bespoke element and carries a reason somebody wrote. It cannot tell a true reason from a plausible sentence. What it buys is that every one of them is on the artefact, in the diff, with a name and a line number attached.
- Element identity is still ordinal. The reason-collision check above is a
check on the configuration, never on the source. A single entry for
<div>in a file that later grows a second bespoke<div>forms no group, so nothing fires — and that entry now matches whichever comes first in source order, which may not be the one it was written about. Within an identical-reason group nothing is guarded at all, by design; if one member is later edited so the shared reason stops being true of it, the group still validates. - It measures markup, not rendering. An element is the system's if its tag is one of the design system's exports. A component that imports the system and then restyles it at the call site still counts — that is the styling layer's question, not this one.
- The scope is a path, not a semantic boundary. Code moved out from under it leaves the denominator silently. The figure stays true and the coverage shrinks; nothing here notices. The scope is printed beside the figure precisely so that a reader can.
- A small denominator is still a denominator. A scope of two hundred files where three carry markup reports three. Whether three is enough evidence is a judgement Gyde does not make for you — which is why the number is never printed without it.
Not built yet
Build-pipeline wiring for a compiled token layer — moot while the emitted tokens are plain CSS custom properties, and required the moment they are not. Service-side ledger and provenance storage; both are committed files where they want to be unforgeable. And the genericity check exists only as a test over the templates, not as a command anyone can run.
Status and licence
Pre-1.0. The action tag v1 names the action's input/output interface, which
has been stable; the package is versioned independently and moves faster. No
stability promise is made below 1.0.
The licence is being decided as part of making this public. Until a LICENSE
file lands, all rights are reserved.
