@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 +21 -0
- package/README.md +264 -0
- package/adoption.mjs +138 -0
- package/agentdocs.mjs +246 -0
- package/boundaries.mjs +350 -0
- package/catalogue.mjs +506 -0
- package/cli.mjs +723 -0
- package/clientboundary.mjs +399 -0
- package/compound.mjs +123 -0
- package/docdrift.mjs +439 -0
- package/emit.mjs +862 -0
- package/enforcement.mjs +100 -0
- package/index.mjs +40 -0
- package/markup.mjs +177 -0
- package/migration.mjs +148 -0
- package/normalise.mjs +416 -0
- package/package.json +59 -0
- package/props.mjs +255 -0
- package/ratchet.mjs +290 -0
- package/rules.mjs +258 -0
- package/scan.mjs +291 -0
- package/stylex.mjs +178 -0
- package/tailwind.mjs +238 -0
- package/tokens.mjs +398 -0
- package/upgrade.mjs +344 -0
- package/usage.mjs +245 -0
- package/wiring.mjs +297 -0
- package/workflow.mjs +221 -0
- package/workspace.mjs +318 -0
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
|
+
}
|