@weatherboard/gyde-design 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Weatherboard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,264 @@
1
+ # Gyde — design system scaffolding and gate
2
+
3
+ Gyde measures a repository's design system, scaffolds what is missing, and then
4
+ keeps auditing what you build against it. It emits code you own, and it gates
5
+ against a ledger that may only ever get shorter.
6
+
7
+ ```
8
+ gyde-design scan <path> measure a repository as it is
9
+ gyde-design plan <path> say what would be emitted; write nothing
10
+ gyde-design init <path> write it; never overwrites
11
+ gyde-design gate <path> fail on anything new since the committed ledger
12
+ gyde-design tokens print the stylesheet for the seed dictionary
13
+ ```
14
+
15
+ ## What Gyde is
16
+
17
+ An **always-on creative director**. A product does not maintain its own review
18
+ standards in-tree and hope they keep firing; it asks Gyde, and Gyde answers with
19
+ a verdict.
20
+
21
+ The boundary in one line: **Gyde decides whether it is valid; the product
22
+ decides what it is.**
23
+
24
+ **Gyde owns** the rules, the normalised representation they are written against,
25
+ the ledger format, the templates, and the upgrade path that reaches an edited
26
+ file.
27
+
28
+ **Gyde never owns** your components, your token *values*, your content, layouts,
29
+ brand or product decisions, or your repository. Everything Gyde emits is yours
30
+ from the moment it is written, and Gyde will not overwrite it. Gyde enforces the
31
+ token *contract*; the values belong to you.
32
+
33
+ If design-system *logic* ends up living in your repo, that is a bug in the
34
+ installation, not a feature of the product.
35
+
36
+ ### Authority
37
+
38
+ The only sanctioned escape valve is the **allowance ledger**: per file, per
39
+ rule, per count, and only ever ratcheting down. There is no env var, no skip
40
+ flag, and no supported way to make a failing gate pass by editing a baseline
41
+ upward.
42
+
43
+ `gate` with no ledger records one and **exits non-zero**. Recording debt is the
44
+ sanctioned way it enters — amnesty never — and a run that recorded rather than
45
+ judged is not a pass.
46
+
47
+ **Degraded mode fails loudly, never silently green.** If Gyde cannot run, the
48
+ gate fails or reports "Gyde did not run" as an explicit state that CI treats as
49
+ failure. There is no configuration in which "could not run" produces a pass.
50
+ This is load-bearing, not a preference: the most expensive failures this tool
51
+ was built against were checks that reported success without executing — a
52
+ `paths:` filter skipping a job, a package filter matching nothing and exiting 0.
53
+
54
+ ## Installing it
55
+
56
+ **Repository CI** takes Gyde as a GitHub Action. No dependency, no token, no
57
+ `.npmrc`:
58
+
59
+ ```yaml
60
+ # .github/workflows/gyde.yml
61
+ name: Gyde
62
+ on:
63
+ pull_request:
64
+ push:
65
+ branches: [main]
66
+ permissions:
67
+ contents: read
68
+ jobs:
69
+ design-system:
70
+ runs-on: ubuntu-latest
71
+ steps:
72
+ - uses: actions/checkout@v4
73
+ - uses: Another-Iteration/gyde@v1
74
+ with:
75
+ fail-on: new
76
+ ```
77
+
78
+ Your dependency graph is untouched and no credential enters your CI. There is no
79
+ install step inside the action either: the engine imports nothing but `node:`
80
+ builtins, so checking the action out *is* the installation. A test pins that
81
+ property, and a registry outage cannot fail your gate.
82
+
83
+ **Laptops, sandboxes and agents** take the package, because neither runs GitHub
84
+ Actions:
85
+
86
+ ```
87
+ npm i -D @weatherboard/gyde-design
88
+ ```
89
+
90
+ > The package name is under review as part of going public — see the scope
91
+ > question in `docs/decisions/g-64-public.md`.
92
+
93
+ `fail-on: none` still runs, still reports, still writes evidence, and still
94
+ fails if the gate could not run. It only declines to block on the verdict, and
95
+ it does so visibly. It is what an adopting repository uses for a fortnight while
96
+ it decides whether the ledger is right. It is not what it uses forever.
97
+ `continue-on-error` is not supported and never will be — it makes every failure
98
+ look like a pass, which is the one outcome a gate exists to prevent.
99
+
100
+ ## The one idea
101
+
102
+ Every design-system audit we had was written against one repository's spelling,
103
+ and each was correct locally and useless one repository over. One matched
104
+ Tailwind utilities; another matched CSS declarations; neither would have found
105
+ anything in the other's repo.
106
+
107
+ So the rules do not match source text. Five spellings of one decision —
108
+
109
+ ```
110
+ rounded-lg Tailwind utility
111
+ border-radius: 8px CSS declaration
112
+ borderRadius: 8 JS / StyleX object
113
+ borderRadius: radius.card
114
+ var(--radius-card) custom property
115
+ ```
116
+
117
+ — all normalise to the same radius decision, and one rule asks the question it
118
+ was always trying to ask: *is this tokenised?* `rules.test.mjs` proves it
119
+ against all five.
120
+
121
+ ## The modules
122
+
123
+ | | |
124
+ | --- | --- |
125
+ | `workspace.mjs` | Discovers packages from the workspace declaration, never from directory names. Classifies each; every exclusion carries a reason. |
126
+ | `normalise.mjs` | Reduces Tailwind utilities, CSS declarations, JS/StyleX objects and cva strings to one representation. |
127
+ | `rules.mjs` | The violation rules, written once against that representation. Refuses to load a rule without both a failing and a passing fixture. |
128
+ | `scan.mjs` | Walks a tree, classifies, scores. Never emits a percentage without its denominator. |
129
+ | `tokens.mjs` | The dictionary shape, its guarantees, and generators for CSS, plain constants and DTCG. |
130
+ | `boundaries.mjs` | Import and dependency boundaries, the exemption register, and version-drift detection. |
131
+ | `ratchet.mjs` | The allowance ledger. A cleared entry is deleted, never zeroed. |
132
+ | `emit.mjs` | The seed components and the two structural modules. |
133
+ | `catalogue.mjs` | The catalogue, generated from the same list the barrel is. |
134
+ | `wiring.mjs` | Is the design system actually connected to the app that uses it? |
135
+ | `usage.mjs` | Which component is used where, and what the product keeps reinventing. |
136
+ | `upgrade.mjs` | Provenance, and the three-way classification that uses it. |
137
+ | `agentdocs.mjs` | What an agent building the product reads before writing UI. |
138
+
139
+ ## Measured, on the three repositories it was built from
140
+
141
+ De-identified, because the numbers are the evidence and the names are not.
142
+
143
+ | | product files | decls | tokenised | findings |
144
+ | --- | ---: | ---: | ---: | ---: |
145
+ | System A — mature system, CSS-declaration styling | 176 | 379 | 98% | 9 |
146
+ | System B — mature system, low application adoption | 1,801 | 7,361 | 15% | 6,078 |
147
+ | System C — shadcn-style, utility-first page code | 394 | 2,642 | 8% | 2,437 |
148
+
149
+ System A's 98% is corroboration worth having: that team independently reports
150
+ 96% adoption from a rule set built on entirely different premises. Two points
151
+ apart is the best evidence available that the normalised form measures the same
152
+ thing a hand-written rule set does.
153
+
154
+ **The two low numbers are not the same number twice.** System B has a mature
155
+ system its applications have not adopted. System C's apps import its UI package
156
+ in 109 files and define zero bespoke components — its 8% is Tailwind utilities
157
+ in page code, which is what shadcn expects you to write. A verdict that cannot
158
+ tell those apart is worthless, which is why `scan` classifies before it scores.
159
+
160
+ ## Why plan and init share a call
161
+
162
+ Both go through one `buildEmission()`. The dry run cannot describe a file the
163
+ real run would not write, and that is structural rather than a promise — a
164
+ scaffolder whose two modes can diverge is one nobody can review before it writes
165
+ into their repository.
166
+
167
+ `init` refuses to overwrite anything. Gyde emits once and the file becomes the
168
+ product's; a scaffolder that clobbers has taken ownership of something it does
169
+ not own, silently.
170
+
171
+ ## Rules that go quiet
172
+
173
+ A rule that matched nothing anywhere is reported as **suspicious**, not clean.
174
+ We have a recorded case of a rule reporting zero against 178 real hits with
175
+ nobody able to tell. Silence and success must not render the same.
176
+
177
+ ## A number we got wrong
178
+
179
+ The first version of the table above read System B at 12% and System C at 4,061
180
+ findings. Every class inside a `className` attribute was counted twice, so
181
+ utility-heavy repos were measured at roughly double. It was found end to end — a
182
+ scaffolded fixture recorded seven findings for four real defects — and not by
183
+ any fixture, which is worth remembering when reading the rest of this.
184
+
185
+ ## Two silences this makes audible
186
+
187
+ **An app can score 100% adoption while rendering unstyled HTML.** If it imports
188
+ design-system components but never the token stylesheet, every
189
+ `var(--color-text)` resolves to nothing. Nothing throws, the build passes, and
190
+ the audit is delighted — only system imports, no literal values. `wiring.mjs`
191
+ checks the link, following `@import` across workspace packages, and reports
192
+ "could not tell" as its own outcome rather than as "fine".
193
+
194
+ **A component nobody renders looks identical to one used everywhere.**
195
+ `usage.mjs` answers the two questions the audit cannot: *does the set already
196
+ compose this?* and *is anybody using what we shipped?* Advisory, never blocking
197
+ — a cluster of bespoke class names is evidence of a missing capability, and a
198
+ gate that cannot tell that from a broken rule just makes the workaround harder
199
+ to find.
200
+
201
+ ## A gap the catalogue cannot close
202
+
203
+ A closed component set cannot force its own interactive states. `Select`'s open
204
+ state is where theming across a portal boundary is proved, and exposing
205
+ `defaultOpen` to demonstrate it would be an escape hatch opened for the
206
+ catalogue's convenience. The generated catalogue records that as a note on the
207
+ entry rather than printing "open" beside a control that is closed — a label that
208
+ lies is worse than an admission.
209
+
210
+ ## The upgrade, which is the wedge
211
+
212
+ Gyde emits a component set, the product edits it — editing it is the point —
213
+ and later Gyde improves the template. Reaching a file somebody has been working
214
+ in is shadcn's openly unsolved problem: three multi-year issues, and its own
215
+ docs call re-syncing *"an ongoing responsibility rather than a solved problem"*.
216
+
217
+ `init` records provenance: the template version and a hash of each file's exact
218
+ emitted bytes. That is the missing third point, and with it every file is one of
219
+ four cases:
220
+
221
+ | | |
222
+ | --- | --- |
223
+ | neither changed | nothing to do |
224
+ | Gyde changed only | safe to apply |
225
+ | product changed only | their edit is the truth; left alone |
226
+ | **both changed** | a **conflict** — reported, never merged |
227
+
228
+ It never auto-merges, never touches a file it has no provenance for, never
229
+ deletes a file it stopped emitting, and never restores one the product deleted.
230
+
231
+ The subtle part: an **unapplied** change keeps its **old** baseline. Otherwise
232
+ the next upgrade reads it as a product edit, and a conflict quietly becomes
233
+ "yours" and is never offered again.
234
+
235
+ ## The one artefact that changes what gets written
236
+
237
+ `.gyde/design-system.md` is generated from the same metadata as the barrel and
238
+ the catalogue, so it cannot name a component that does not exist — the failure
239
+ it replaces is a hand-written registry pointing at a superseded generation of
240
+ its own components.
241
+
242
+ Its most important section is what to do when the set *cannot* do the thing.
243
+ Without it the honest agent and the lazy one produce the same bespoke CSS, and
244
+ only one of them knew it was a compromise.
245
+
246
+ Gyde does not write into your `CLAUDE.md`. It emits a fragment for you to
247
+ include — that file is the last one a tool should edit unasked.
248
+
249
+ ## Not built yet
250
+
251
+ Build-pipeline wiring for a compiled token layer — moot while the emitted tokens
252
+ are plain CSS custom properties, and required the moment they are not.
253
+ Service-side ledger and provenance storage; both are committed files where they
254
+ want to be unforgeable. And the genericity check exists only as a test over the
255
+ templates, not as a command anyone can run.
256
+
257
+ ## Status and licence
258
+
259
+ Pre-1.0. The action tag `v1` names the action's input/output interface, which
260
+ has been stable; the package is versioned independently and moves faster. No
261
+ stability promise is made below 1.0.
262
+
263
+ The licence is being decided as part of making this public. Until a `LICENSE`
264
+ file lands, all rights are reserved.
package/adoption.mjs ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * G-74 — one adoption number, and why it is a minimum rather than an average.
3
+ *
4
+ * A consumer reports 95% adoption. Gyde reports 15%. Both are correct, and
5
+ * until now nothing said so, which meant two teams could look at the same
6
+ * repository and disagree about whether the work was done.
7
+ *
8
+ * THE RECONCILIATION IS THAT THEY MEASURE DIFFERENT LAYERS.
9
+ *
10
+ * token of the style DECLARATIONS this code makes, how many come from the
11
+ * dictionary? (scan.mjs `adoption`)
12
+ * markup of the ELEMENTS that made a visual decision, how many are the
13
+ * system's components? (markup.mjs, G-73)
14
+ *
15
+ * They are close to orthogonal. A page of bare divs carrying semantic utility
16
+ * classes scores ~100% on token and ~0% on markup: every value it uses is from
17
+ * the dictionary and it is not using the component set at all. A page built
18
+ * from system components with a few hard-coded hex overrides scores the
19
+ * reverse. The 95%-against-15% spread is not a discrepancy to be resolved; it
20
+ * is two true statements about different things.
21
+ *
22
+ * CHARTER §2 makes the definition Gyde's: "What 'adoption' means is Gyde's; a
23
+ * product cannot redefine a metric locally." So this defines it.
24
+ *
25
+ * WHY THE MINIMUM, AND NOT THE AVERAGE.
26
+ *
27
+ * An average lets a product trade one layer against the other and report
28
+ * progress with nothing having improved. That is the same failure exactly — "an
29
+ * average is a number that can regress with nothing behind it regressing" —
30
+ * and it runs in both directions: it can also IMPROVE with nothing behind it
31
+ * improving. Tokenising a hundred hard-coded colours inside hand-rolled divs
32
+ * would move an average up while the component set stayed exactly as unused as
33
+ * it was.
34
+ *
35
+ * The minimum cannot be gamed that way. It says: a design system is adopted to
36
+ * the extent of its weakest layer, and the way to move the number is to fix
37
+ * the thing that is actually behind. It is also the number that matches what
38
+ * somebody means when they ask "are we on the design system?" — they do not
39
+ * mean "on average, partly".
40
+ *
41
+ * WHAT HAPPENS WHEN A LAYER CANNOT BE MEASURED IS THE LOAD-BEARING PART.
42
+ *
43
+ * If markup cannot be measured — no readable barrel — the overall number is
44
+ * NOT the token number. Reporting the layers that happened to work is how a
45
+ * headline silently inflates: the missing layer is usually the low one,
46
+ * because the repositories that cannot be attributed are the ones that have
47
+ * not adopted. So an unmeasured layer makes the overall figure `null` with the
48
+ * reason attached, and CHARTER §5 applies unchanged: "Gyde did not measure
49
+ * this" and "Gyde found nothing here" must never render the same.
50
+ */
51
+
52
+ /** The layers, in the order a reader should meet them. */
53
+ export const LAYERS = ["token", "markup"];
54
+
55
+ /**
56
+ * Reconcile the layers into one figure.
57
+ *
58
+ * Each input is `{ used, of, percent }` — the shape scan.mjs already produces,
59
+ * with `percent: null` meaning "nothing to measure here" rather than zero.
60
+ */
61
+ export function reconcile({ token = null, markup = null } = {}) {
62
+ const layers = {
63
+ token: normalise(token, "no styling declarations in product code"),
64
+ markup: normalise(markup, "no element made a visual decision"),
65
+ };
66
+
67
+ const measured = LAYERS.filter((k) => layers[k].percent !== null);
68
+ const missing = LAYERS.filter((k) => layers[k].percent === null);
69
+
70
+ // Nothing measurable at all is not 0%. It is a repository with no product
71
+ // surface to judge, and saying 0% would be an accusation.
72
+ if (measured.length === 0) {
73
+ return {
74
+ percent: null, layers, measured, missing,
75
+ why: "neither layer could be measured, so there is no adoption figure — this is not 0%",
76
+ };
77
+ }
78
+
79
+ // A partial measurement does not get a headline. The missing layer is
80
+ // usually the low one, so reporting what worked is how a number inflates.
81
+ if (missing.length > 0) {
82
+ return {
83
+ percent: null, layers, measured, missing,
84
+ why: `only ${measured.join(" and ")} could be measured; ` +
85
+ `reporting a figure without ${missing.join(" and ")} would overstate it`,
86
+ };
87
+ }
88
+
89
+ const weakest = measured.reduce((a, b) => (layers[a].percent <= layers[b].percent ? a : b));
90
+ return {
91
+ percent: layers[weakest].percent,
92
+ weakest,
93
+ layers, measured, missing,
94
+ why: `the weakest layer is ${weakest}; a design system is adopted to the extent of its weakest layer`,
95
+ };
96
+ }
97
+
98
+ function normalise(a, emptyWhy) {
99
+ if (!a) return { percent: null, used: null, of: null, why: "not measured" };
100
+ if (a.percent === null || a.of === 0) return { percent: null, used: a.used ?? 0, of: a.of ?? 0, why: emptyWhy };
101
+ return { percent: a.percent, used: a.used ?? a.tokenised ?? null, of: a.of };
102
+ }
103
+
104
+ export function formatAdoption(result) {
105
+ const L = [];
106
+ const { layers } = result;
107
+
108
+ const line = (name, label) => {
109
+ const l = layers[name];
110
+ return l.percent === null
111
+ ? ` ${label.padEnd(8)} n/a — ${l.why}`
112
+ : ` ${label.padEnd(8)} ${String(l.percent).padStart(3)}% (${l.used} of ${l.of})`;
113
+ };
114
+
115
+ if (result.percent === null) {
116
+ L.push(`adoption no single figure — ${result.why}`);
117
+ } else {
118
+ L.push(`adoption ${result.percent}% (${result.weakest}, the weakest layer)`);
119
+ }
120
+ L.push(line("token", "token"));
121
+ L.push(line("markup", "markup"));
122
+
123
+ if (result.percent !== null) {
124
+ const spread = Math.abs(layers.token.percent - layers.markup.percent);
125
+ if (spread >= 25) {
126
+ L.push("");
127
+ L.push(` The layers are ${spread} points apart, which is the number worth reading.`);
128
+ L.push(layers.token.percent > layers.markup.percent
129
+ ? " Style values are coming from the dictionary; the components are hand-rolled."
130
+ : " The component set is being used; the values inside it are not from the dictionary.");
131
+ }
132
+ }
133
+
134
+ L.push("");
135
+ L.push(" The minimum, not the average. An average moves when one layer improves and");
136
+ L.push(" the other rots, which is progress reported with nothing behind it.");
137
+ return L.join("\n");
138
+ }
package/agentdocs.mjs ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * G-17 / G-56 — what an agent building the product needs to know, generated.
3
+ *
4
+ * @gyde-emits-source-for-another-repo
5
+ *
6
+ * WHY THIS IS NOT DOCUMENTATION.
7
+ *
8
+ * Every other module here judges code after it is written. This one is the only
9
+ * one that changes what gets written, and G-59's note is the argument: an agent
10
+ * that knows the component set uses it; an agent that does not gets audited for
11
+ * not using it, which is the expensive order. A gate that fires on the hundredth
12
+ * bespoke card has already let ninety-nine through.
13
+ *
14
+ * WHY IT IS GENERATED RATHER THAN WRITTEN.
15
+ *
16
+ * A hand-maintained component doc is the failure mode CHARTER names directly —
17
+ * docs that route agents to deleted components. The larger source repository
18
+ * has it today: a 433-line hand-written registry whose paths point at a
19
+ * superseded generation of its own components and whose Button lists four
20
+ * variants where the real one has nine. An agent reading that would confidently
21
+ * write against an API that does not exist.
22
+ *
23
+ * So this is derived from the same metadata as the barrel and the catalogue,
24
+ * and `agentdocs.test.mjs` asserts every component it names is exported and
25
+ * every exported component is named. A doc that can drift will drift.
26
+ *
27
+ * WHAT IT DELIBERATELY CONTAINS.
28
+ *
29
+ * Not prose about design philosophy. Three things an agent can act on:
30
+ * the components that exist and when each applies; the refusals, stated as
31
+ * rules with their reasons; and — the one most likely to be skipped — what to
32
+ * do when the set cannot do the thing. Without that last part the honest agent
33
+ * and the lazy one produce the same bespoke CSS.
34
+ */
35
+
36
+ import { SEED_COMPONENTS } from "./emit.mjs";
37
+ import { CATALOGUE_ENTRIES } from "./catalogue.mjs";
38
+
39
+ /**
40
+ * The props an agent has to get right, per component.
41
+ *
42
+ * Derived from the catalogue's own example states rather than restated, so a
43
+ * prop cannot appear here and nowhere else. Required props are those present in
44
+ * every example — a prop the catalogue always passes is one the component needs.
45
+ */
46
+ function propsFor(name) {
47
+ const entry = CATALOGUE_ENTRIES[name];
48
+ const controlled = Object.keys(entry.controlled ?? {});
49
+ const seen = new Map();
50
+
51
+ for (const state of entry.states) {
52
+ for (const [k, v] of Object.entries({ ...(entry.controlled ?? {}), ...state.props })) {
53
+ if (!seen.has(k)) seen.set(k, new Set());
54
+ seen.get(k).add(typeof v === "object" ? "…" : String(v));
55
+ }
56
+ }
57
+
58
+ const inEvery = [...seen.keys()].filter((k) =>
59
+ entry.states.every((s) => k in { ...(entry.controlled ?? {}), ...s.props }));
60
+
61
+ return [...seen.entries()].map(([prop, values]) => ({
62
+ prop,
63
+ required: inEvery.includes(prop),
64
+ // Sorted, so adding an example never reorders an existing line. A generated
65
+ // doc whose diff churns on unrelated edits is one people stop reading.
66
+ values: [...values].filter((v) => v !== "…").sort(),
67
+ controlled: controlled.includes(prop),
68
+ }));
69
+ }
70
+
71
+ const componentSection = (name) => {
72
+ const entry = CATALOGUE_ENTRIES[name];
73
+ const props = propsFor(name);
74
+ const req = props.filter((p) => p.required);
75
+ const opt = props.filter((p) => !p.required);
76
+
77
+ const line = (p) => {
78
+ const vals = p.values.length && p.values.length <= 6 ? ` — ${p.values.join(" | ")}` : "";
79
+ return `- \`${p.prop}\`${p.required ? " (required)" : ""}${vals}`;
80
+ };
81
+
82
+ return `### ${name}
83
+
84
+ ${entry.purpose}
85
+
86
+ **Use instead of:** ${entry.replaces.join(", ")}.
87
+
88
+ ${req.map(line).join("\n")}${opt.length ? "\n" + opt.map(line).join("\n") : ""}
89
+ ${(entry.notes ?? []).map((n) => `\n> ${n}`).join("")}`;
90
+ };
91
+
92
+ /**
93
+ * The document.
94
+ *
95
+ * Written to be read by something that will act on it immediately, so the
96
+ * refusals come before the component list: an agent that reads only the first
97
+ * screen should still not reach for `className`.
98
+ */
99
+ export function agentDoc({
100
+ components = SEED_COMPONENTS,
101
+ systemPackage = "@scaffold/design-system",
102
+ /**
103
+ * Did Gyde emit these components, or only read their names? (G-66)
104
+ *
105
+ * This is the difference between two documents that look alike and make
106
+ * completely different claims.
107
+ *
108
+ * `true` — the set Gyde emitted. Gyde has the catalogue metadata, so it can
109
+ * state each component's purpose and props, and it knows they refuse
110
+ * `className` because it wrote the refusal.
111
+ *
112
+ * `false` — the product's own components, discovered from its barrel. Gyde
113
+ * knows the NAMES and nothing else. Printing prop tables here would be
114
+ * inventing an API: System B exports a `Button`, Gyde's catalogue has
115
+ * a `Button`, and they are not the same component. A doc that confidently
116
+ * describes the wrong props is worse than one that says it does not know —
117
+ * it is the hand-maintained-registry failure this file exists to replace,
118
+ * reproduced automatically.
119
+ *
120
+ * The rules still apply either way; the gate enforces them regardless of who
121
+ * wrote the components. But in unverified mode they are stated as
122
+ * requirements rather than as facts about the implementation.
123
+ */
124
+ verified = true,
125
+ } = {}) {
126
+ const closure = verified
127
+ ? `**Never pass \`className\` or \`style\` to a design-system component.** They do not
128
+ accept them, on purpose. That refusal is the only reason the set means anything:
129
+ a component that can be overridden will drift, and one escape hatch makes every
130
+ other rule advisory.`
131
+ : `**Never pass \`className\` or \`style\` to a design-system component.** If you
132
+ need an appearance the component does not offer, that is a gap to report — see
133
+ below. A component that can be overridden will drift, and one escape hatch makes
134
+ every other rule advisory.`;
135
+
136
+ return `<!-- GENERATED BY GYDE — regenerate rather than edit.
137
+ Every component named here is exported by the package: this list is read
138
+ from the barrel, not maintained by hand. A hand-maintained version routes
139
+ agents to components that no longer exist, which is the specific failure
140
+ this replaces.${verified ? "" : `
141
+ Names only. Gyde did not write these components and does not describe their
142
+ props — read the source for those.`} -->
143
+
144
+ # The design system
145
+
146
+ Everything visual in this product comes from \`${systemPackage}\`. Read this before
147
+ writing any UI.
148
+
149
+ ## The rules, and why they exist
150
+
151
+ **Never write a colour, a size, a radius, a spacing or a shadow at the call
152
+ site.** They come from the token dictionary. A value spelled in a component is
153
+ one nobody can change centrally, and it is what the audit fails on.
154
+
155
+ ${closure}
156
+
157
+ **Do not import the primitive library directly.** The design system wraps it and
158
+ keeps a closed API; importing it from an app walks around that wall. A boundary
159
+ rule fails the build if you do.
160
+
161
+ **Variants pick a tone and an emphasis. They never introduce a colour.** If the
162
+ variant you want does not exist, that is a gap to file — see below.
163
+
164
+ ## When the set cannot do the thing
165
+
166
+ This is the important part, and the one that decides whether this document is
167
+ worth having.
168
+
169
+ **A page that needs something the set cannot compose has found a gap in the set.
170
+ That is a finding to file, not a licence to write bespoke CSS.**
171
+
172
+ Concretely, in order:
173
+
174
+ 1. **Compose it** from what exists. Most "missing components" are two existing
175
+ ones next to each other.
176
+ 2. **If it genuinely cannot be composed**, say so in your output — name the
177
+ component you needed and what it would have done — and use the nearest
178
+ available component rather than inventing a styled \`div\`.
179
+ 3. **Do not** reach for a bespoke class name or an inline style to close the gap
180
+ quietly. That is the outcome that costs the most later: it looks like the
181
+ feature shipped and it means the set silently stopped covering the product.
182
+
183
+ A gap you report becomes a component. A gap you paper over becomes a shadow
184
+ system, and the measured cost of one of those is two applications reporting 0%
185
+ adoption while nobody broke a single rule.
186
+
187
+ ## Runtime theming
188
+
189
+ If a surface takes colours that are only known at request time — a per-tenant
190
+ brand — do **not** reach for inline styles. Spread \`themeVars(theme)\` onto an
191
+ element you already own and set the component's tone to \`themed\`. It returns
192
+ CSS custom properties only, which is why it is not an escape hatch.
193
+
194
+ ## The components
195
+
196
+ ${verified
197
+ ? components.map(componentSection).join("\n\n")
198
+ : `These ${components.length} components are exported by \`${systemPackage}\`. **Use one of
199
+ these before writing anything.** Gyde did not write them, so it does not
200
+ describe their props here — a generated prop table for a component Gyde has
201
+ never seen would be a guess, and a confident one. Read the component's source
202
+ for its API.
203
+
204
+ ${components.map((c) => `- \`${c}\``).join("\n")}`}
205
+
206
+ ## Where to look
207
+
208
+ ${verified ? `- The catalogue renders every component in every state, in both themes. It boots
209
+ with nothing else running.
210
+ ` : `- The component's own source, for its props. Gyde does not have them.
211
+ `}- \`gyde design scan\` reports what is tokenised, what is not, and which
212
+ vocabularies the product keeps reinventing.
213
+ `;
214
+ }
215
+
216
+ /**
217
+ * The fragment a customer's own agent instructions include.
218
+ *
219
+ * Kept separate and short because it is pasted into a file Gyde does not own.
220
+ * A scaffolder that rewrote somebody's CLAUDE.md would be taking ownership of
221
+ * the one document that is most obviously theirs.
222
+ */
223
+ export function agentInstructionsFragment({ docPath = ".gyde/design-system.md" } = {}) {
224
+ return `<!-- Add this to your agent instructions (CLAUDE.md, AGENTS.md, or equivalent).
225
+ Gyde does not edit that file — it is yours, and it is the last one a tool
226
+ should be writing into unasked. -->
227
+
228
+ ## Design system
229
+
230
+ Before writing any UI, read [\`${docPath}\`](${docPath}). Its component list is
231
+ read from the package's barrel rather than maintained by hand, so it cannot
232
+ route you to a component that no longer exists.
233
+
234
+ The three rules that matter most: no colour, size, radius or spacing spelled at
235
+ the call site; no \`className\` or \`style\` on a design-system component; and if
236
+ the set cannot compose what you need, report the gap rather than writing bespoke
237
+ CSS around it.
238
+ `;
239
+ }
240
+
241
+ export function emitAgentDocs({ components = SEED_COMPONENTS, systemPackage, path = ".gyde", verified = true } = {}) {
242
+ return {
243
+ [`${path}/design-system.md`]: agentDoc({ components, systemPackage, verified }),
244
+ [`${path}/agent-instructions-fragment.md`]: agentInstructionsFragment({ docPath: `${path}/design-system.md` }),
245
+ };
246
+ }