@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/emit.mjs ADDED
@@ -0,0 +1,862 @@
1
+ /**
2
+ * G-49 / G-60 — what the scaffolder writes.
3
+ *
4
+ * Every emitter here returns `{ path: contents }`. Nothing touches the disk:
5
+ * `plan` prints the paths, `init` writes them, and both call the same function.
6
+ * A scaffolder whose dry run and real run can diverge cannot be reviewed before
7
+ * it writes into somebody's repository, and this is the mechanism that stops
8
+ * them diverging rather than a promise that they will not.
9
+ *
10
+ * WHAT IS EMITTED, AND WHAT IS NOT.
11
+ *
12
+ * The values are placeholders and the product replaces every one (G-47). What
13
+ * Gyde is actually shipping is the *structure*: closure at the wrapper boundary,
14
+ * a theming mechanism that cannot become an escape hatch, a defaults module that
15
+ * survives the RSC boundary, and a test that proves the first of those by
16
+ * reading the emitted source as text.
17
+ *
18
+ * THE SEED SET IS SMALL ON PURPOSE.
19
+ *
20
+ * One project derived a set from the 72 selectors an app actually rendered and found
21
+ * *"roughly nine components cover all seventy-two."* The sharper
22
+ * lesson: System A wrote a `Disclosure`, then **deleted it before
23
+ * merging** because its only call sites were outside the set — *"exactly the
24
+ * speculation that principle forbids."* A team that deletes a finished component rather
25
+ * than ship it unused is the standard this emitter has to meet, so System B's
26
+ * fourteen Base-UI-backed components are a **menu**, not a delivery. Five are
27
+ * emitted by default because five is what every product provably has; the rest
28
+ * arrive when a declared surface needs them.
29
+ *
30
+ * @gyde-emits-source-for-another-repo
31
+ *
32
+ * The string literals below are source for a SCAFFOLDED project, not for this
33
+ * one. `packages/review/paths.test.mjs` reads that marker and skips this file:
34
+ * without it, the guard resolves the emitted `./dictionary.mjs` against this
35
+ * directory and reports six broken imports in a file that has none.
36
+ */
37
+
38
+ import { generateCss, SEED, varName, CSS_HEADER } from "./tokens.mjs";
39
+
40
+ /**
41
+ * The version of what this emits.
42
+ *
43
+ * Bumped whenever a template changes, because it is the label a scaffolded
44
+ * repository records in its provenance manifest and the thing an upgrade
45
+ * reports moving between. It is the TEMPLATES' version, not the tool's: a fix
46
+ * to the scanner does not change what a customer's files should contain.
47
+ */
48
+ /**
49
+ * The TEMPLATE version, which is not the package version and must not chase it.
50
+ *
51
+ * install.md has always said these move independently. G-101 gave that a hard
52
+ * consequence: the template version is what decides which rules BLOCK, so
53
+ * raising it to the enforcement threshold before the templates satisfy the
54
+ * mandates makes `init` scaffold a repository that fails its own gate on the
55
+ * first run. That is not a hypothetical — bumping this to 0.3.0 alongside the
56
+ * package did exactly that, because the emitted components style through CSS
57
+ * custom properties and G-100 mandates StyleX.
58
+ *
59
+ * So this stays below the threshold until the templates earn it. The rule, and
60
+ * `emit.test.mjs` enforces it: a fresh scaffold must pass the gate Gyde would
61
+ * run against it.
62
+ */
63
+ export const SCAFFOLD_VERSION = "0.2.4";
64
+
65
+ const GENERATED = (what) => `/* GENERATED BY GYDE — then yours.
66
+ *
67
+ * Gyde wrote this file once and will never overwrite it. Edit it freely: the
68
+ * values here are placeholders and replacing them is the point (CHARTER §3).
69
+ *
70
+ * What Gyde keeps checking is the ${what}. That is enforced continuously, not
71
+ * asserted at emission, because these components are expected to be edited —
72
+ * closure has to survive the editing or it was never a property of the system,
73
+ * only of its first commit.
74
+ */`;
75
+
76
+ /* ------------------------------------------------------------------ tokens */
77
+
78
+ export function emitTokens(dict = SEED, { path = "packages/design-tokens", tokensPackage = "@scaffold/design-tokens" } = {}) {
79
+ const dictSource =
80
+ `${GENERATED("dictionary's shape: roles not appearances, radius closed to three, both themes complete")}
81
+
82
+ /**
83
+ * Every token names what it is FOR, never what it looks like.
84
+ *
85
+ * "A token whose comment says 'grey' gets used for the wrong grey. If you cannot
86
+ * write what a token is FOR, it is not a token yet." Gyde's validator rejects a
87
+ * role written as an appearance, so this is checked rather than hoped for.
88
+ */
89
+ export const themed = (light, dark) => ({ light, dark });
90
+
91
+ export const tokens = ${JSON.stringify(dict, null, 2)
92
+ .replace(/"value": \{\s*"light": ("[^"]*"),\s*"dark": ("[^"]*")\s*\}/g, '"value": themed($1, $2)')};
93
+
94
+ export default tokens;
95
+ `;
96
+
97
+ /**
98
+ * The generator, emitted into the product's repository.
99
+ *
100
+ * NOT imported from Gyde. integration.md §1 says `gyde.config.json` and the
101
+ * workflow file are the entire non-generated footprint, and an emitted test
102
+ * that imports the scaffolder at runtime would make every scaffolded project
103
+ * take a permanent dependency on the tool that scaffolded it — which is the
104
+ * lock-in this product is supposed to be the opposite of.
105
+ *
106
+ * So the product owns its generator, as the reference implementation does.
107
+ * Gyde's own `emit.test.mjs` asserts this emitted generator produces byte-
108
+ * identical CSS to Gyde's — two independent implementations pinned to each
109
+ * other, rather than one shared through a dependency.
110
+ */
111
+ const generatorSource =
112
+ `${GENERATED("agreement between this file and the committed tokens.css")}
113
+
114
+ const isThemed = (v) => v !== null && typeof v === "object" && "light" in v && "dark" in v;
115
+
116
+ export const kebab = (s) => String(s).replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
117
+ export const varName = (group, name) => \`--\${kebab(group)}-\${kebab(name)}\`;
118
+
119
+ const APPEARANCE_WORDS = new Set([
120
+ "grey", "gray", "red", "blue", "green", "yellow", "orange", "purple", "pink",
121
+ "black", "white", "teal", "cyan", "dark", "light", "big", "small", "tiny",
122
+ "large", "huge", "bold", "thin", "round", "rounded", "square",
123
+ ]);
124
+
125
+ /** The guarantees. These hold whatever values you replace the placeholders with. */
126
+ export function validate(dict) {
127
+ const problems = [];
128
+ if (!dict.color) problems.push("no \`color\` group");
129
+ if (!dict.radius) problems.push("no \`radius\` group");
130
+
131
+ if (dict.radius) {
132
+ const keys = Object.keys(dict.radius).sort();
133
+ if (JSON.stringify(keys) !== JSON.stringify(["card", "control", "pill"])) {
134
+ problems.push(\`radius must be exactly card/control/pill, got \${keys.join("/")}\`);
135
+ }
136
+ }
137
+ if (dict.shadow && Object.keys(dict.shadow).length > 1) {
138
+ problems.push("depth is a semantic decision; a second shadow should be argued, not defaulted");
139
+ }
140
+
141
+ for (const [group, entries] of Object.entries(dict)) {
142
+ if (group.startsWith("$")) continue;
143
+ for (const [name, token] of Object.entries(entries)) {
144
+ const where = \`\${group}.\${name}\`;
145
+ if (!token || token.value === undefined) { problems.push(\`\${where}: no value\`); continue; }
146
+ if (!token.role || String(token.role).trim().length < 8) {
147
+ problems.push(\`\${where}: no role. If you cannot write what a token is FOR, it is not a token yet\`);
148
+ continue;
149
+ }
150
+ const bad = String(token.role).toLowerCase().split(/[^a-z]+/).find((w) => APPEARANCE_WORDS.has(w));
151
+ if (bad) problems.push(\`\${where}: role describes appearance ("\${bad}"), not purpose\`);
152
+ if (isThemed(token.value) && !(token.value.light && token.value.dark)) {
153
+ problems.push(\`\${where}: themed value is missing a theme\`);
154
+ }
155
+ }
156
+ }
157
+
158
+ const deliberate = new Set(dict.$sameInBothThemes || ["canvas"]);
159
+ for (const [name, token] of Object.entries(dict.color || {})) {
160
+ if (isThemed(token.value) && token.value.light === token.value.dark && !deliberate.has(name)) {
161
+ problems.push(\`color.\${name}: identical in both themes — declare it in $sameInBothThemes if intended\`);
162
+ }
163
+ }
164
+ return problems;
165
+ }
166
+
167
+ /**
168
+ * The header, interpolated from Gyde's rather than restated.
169
+ *
170
+ * These two strings must be byte-identical: Gyde writes tokens.css and the
171
+ * test below asserts this generator reproduces it. When they differed by one
172
+ * line of comment, a freshly scaffolded repository shipped a token test that
173
+ * failed on its first run — and the first thing anyone does with a failing
174
+ * generated test is delete it.
175
+ */
176
+ const HEADER = ${JSON.stringify(CSS_HEADER)}
177
+
178
+ export function generateCss(dict) {
179
+ const problems = validate(dict);
180
+ if (problems.length) throw new Error("Refusing to generate CSS from an invalid dictionary:\\n " + problems.join("\\n "));
181
+
182
+ const flat = [];
183
+ for (const [group, entries] of Object.entries(dict)) {
184
+ if (group.startsWith("$")) continue;
185
+ for (const [name, token] of Object.entries(entries)) flat.push({ name: varName(group, name), value: token.value });
186
+ }
187
+
188
+ const decls = (theme) => flat
189
+ .filter((t) => theme === "light" || isThemed(t.value))
190
+ .map((t) => \` \${t.name}: \${isThemed(t.value) ? t.value[theme] : t.value};\`)
191
+ .join("\\n");
192
+
193
+ return [
194
+ HEADER, "", ":root {", " color-scheme: light dark;", decls("light"), "}", "",
195
+ "/* The system preference, unless a container has explicitly opted out. */",
196
+ "@media (prefers-color-scheme: dark) {",
197
+ ' :root:not([data-theme="light"]) {',
198
+ decls("dark").split("\\n").map((l) => " " + l).join("\\n"),
199
+ " }", "}", "",
200
+ "/* An explicit choice, on any element — which is what makes both themes",
201
+ " renderable on one page, and what a portalled popup looks up. */",
202
+ '[data-theme="dark"] {', decls("dark"), "}", "",
203
+ '[data-theme="light"] {', decls("light"), "}", "",
204
+ ].join("\\n");
205
+ }
206
+ `;
207
+
208
+ const testSource =
209
+ `${GENERATED("source==output guarantee this file asserts")}
210
+ import { test } from "node:test";
211
+ import assert from "node:assert/strict";
212
+ import { readFileSync } from "node:fs";
213
+ import { fileURLToPath } from "node:url";
214
+ import { dirname, join } from "node:path";
215
+
216
+ import { validate, generateCss } from "./generate.mjs";
217
+ import { tokens } from "./dictionary.mjs";
218
+
219
+ const HERE = dirname(fileURLToPath(import.meta.url));
220
+
221
+ test("the dictionary holds its shape", () => {
222
+ assert.deepEqual(validate(tokens), []);
223
+ });
224
+
225
+ /**
226
+ * The source==output guarantee.
227
+ *
228
+ * The stylesheet is committed rather than built, so the CSS a reviewer approves
229
+ * is the CSS that ships. This test is what stops it rotting — without it, a
230
+ * generated file that nobody regenerates is just a stale file with a confident
231
+ * header.
232
+ */
233
+ test("the committed stylesheet still matches the dictionary", () => {
234
+ const committed = readFileSync(join(HERE, "tokens.css"), "utf8");
235
+ assert.equal(committed, generateCss(tokens),
236
+ "tokens.css is stale or was hand-edited — regenerate it, do not edit it");
237
+ });
238
+ `;
239
+
240
+ /**
241
+ * The package manifest.
242
+ *
243
+ * Without one, pnpm cannot resolve `@acme/design-tokens` and the emitted
244
+ * imports — which now correctly name the workspace's own scope — still
245
+ * resolve to nothing. Emitting the specifier and not the package it points at
246
+ * is half a fix.
247
+ *
248
+ * No build step and no dependencies: the dictionary is plain ESM and the
249
+ * generator uses nothing but the language.
250
+ */
251
+ const manifest = JSON.stringify({
252
+ name: tokensPackage,
253
+ version: "0.0.0",
254
+ private: true,
255
+ type: "module",
256
+ exports: {
257
+ ".": "./dictionary.mjs",
258
+ "./dictionary.mjs": "./dictionary.mjs",
259
+ "./generate.mjs": "./generate.mjs",
260
+ "./tokens.css": "./tokens.css",
261
+ },
262
+ scripts: {
263
+ tokens: "node -e \"import('./generate.mjs').then(async g=>{const{tokens}=await import('./dictionary.mjs');const{writeFileSync}=await import('node:fs');writeFileSync('tokens.css',g.generateCss(tokens))})\"",
264
+ test: "node --test tokens.test.mjs",
265
+ },
266
+ }, null, 2) + "\n";
267
+
268
+ return {
269
+ [`${path}/package.json`]: manifest,
270
+ [`${path}/dictionary.mjs`]: dictSource,
271
+ [`${path}/generate.mjs`]: generatorSource,
272
+ [`${path}/tokens.css`]: generateCss(dict),
273
+ [`${path}/tokens.test.mjs`]: testSource,
274
+ };
275
+ }
276
+
277
+ /* ------------------------------------------------------- structural modules */
278
+
279
+ /**
280
+ * The theming module (G-60 §1).
281
+ *
282
+ * This is the capability whose ABSENCE cost System B 754 lines of parallel
283
+ * primitives and 0% adoption in two apps — *"never a rule anyone broke; a
284
+ * capability the system did not have."* It ships on day one for that reason.
285
+ */
286
+ function themeModule(dict) {
287
+ const slots = Object.keys(dict.color);
288
+ return `${GENERATED("guarantee that this cannot be used to restyle a component from outside")}
289
+
290
+ /**
291
+ * Runtime theming, for colours that cannot be known when the CSS is written.
292
+ *
293
+ * A per-tenant brand is resolved per request, so it cannot be a build-time
294
+ * token. Without a first-class answer, products grow a parallel set of
295
+ * hand-styled primitives beside the design system and adoption goes to zero —
296
+ * that is measured, not hypothetical.
297
+ *
298
+ * HOW. A theme is plain CSS custom properties. The producer spreads
299
+ * \`themeVars(theme)\` onto an element **it already owns**; components beneath it
300
+ * that opt in with \`tone="themed"\` read them through \`var()\`. Inheritance is the
301
+ * connection, so there is no context and no provider.
302
+ *
303
+ * WHY NOT A PROVIDER. A provider has to render an element to carry the
304
+ * properties, which puts a box in the layout the caller did not ask for and
305
+ * cannot style — and this system deliberately has no \`className\` for them to
306
+ * fix it with.
307
+ *
308
+ * WHY THIS IS NOT A HOLE IN THE NO-CLASSNAME RULE. \`themeVars\` returns custom
309
+ * properties **only** — never \`backgroundColor\`, never a layout property — so it
310
+ * cannot restyle a component from outside. \`theme.test.ts\` asserts exactly that,
311
+ * and that test is the reason this is safe to expose.
312
+ */
313
+
314
+ export type ThemeSlot = ${slots.map((s) => JSON.stringify(s)).join(" | ")};
315
+
316
+ export type SurfaceTheme = Partial<Record<ThemeSlot, string>>;
317
+
318
+ const SLOTS: readonly ThemeSlot[] = [${slots.map((s) => JSON.stringify(s)).join(", ")}];
319
+
320
+ /** The custom property a slot is published as. Stable and human-readable, so it can be read in devtools. */
321
+ export const themeVar = (slot: ThemeSlot): string => \`${"--theme-${slot}"}\`;
322
+
323
+ export function themeVars(theme: SurfaceTheme): Record<string, string> {
324
+ const out: Record<string, string> = {};
325
+ for (const slot of SLOTS) {
326
+ const value = theme[slot];
327
+ if (value !== undefined) out[themeVar(slot)] = value;
328
+ }
329
+ return out;
330
+ }
331
+ `;
332
+ }
333
+
334
+ const themeTest = `${GENERATED("assertion below, which is what makes runtime theming safe to expose")}
335
+ import { test } from "node:test";
336
+ import assert from "node:assert/strict";
337
+ import { themeVars, themeVar } from "./theme.js";
338
+
339
+ /**
340
+ * The assertion that makes runtime theming safe to expose.
341
+ *
342
+ * If \`themeVars\` could ever return a real CSS property, it would be a
343
+ * \`style\` prop by another name and every refusal in this package would become
344
+ * advisory. So: custom properties only, checked.
345
+ */
346
+ test("themeVars returns custom properties and nothing else", () => {
347
+ const out = themeVars({ surface: "#123456", accent: "#abcdef" } as never);
348
+ for (const key of Object.keys(out)) {
349
+ assert.ok(key.startsWith("--"), \`\${key} is not a custom property\`);
350
+ }
351
+ });
352
+
353
+ test("an unknown slot is dropped rather than passed through", () => {
354
+ const out = themeVars({ backgroundColor: "red", surface: "#fff" } as never);
355
+ assert.deepEqual(Object.keys(out), [themeVar("surface")]);
356
+ });
357
+ `;
358
+
359
+ /**
360
+ * The defaults module (G-60 §2).
361
+ *
362
+ * The comment is long because the failure is invisible and the fix looks
363
+ * arbitrary. Somebody will eventually move these next to their components,
364
+ * which is the natural thing to do, and this is the only thing that will stop
365
+ * them.
366
+ */
367
+ const defaultsModule = `${GENERATED("absence of a \"use client\" directive in this file")}
368
+
369
+ /**
370
+ * Every component's defaults, spread at the call site.
371
+ *
372
+ * !! DO NOT ADD "use client" TO THIS FILE, AND DO NOT MOVE THESE BESIDE THEIR
373
+ * !! COMPONENTS.
374
+ *
375
+ * Most components in this package are "use client". A server component that
376
+ * imports a VALUE across that boundary does not get the value — it gets an
377
+ * opaque client reference with no enumerable properties. Spreading one yields
378
+ * {} and every prop silently reverts to undefined.
379
+ *
380
+ * That is not theoretical. \`<Card {...CARD_DEFAULTS}>\` on a server page rendered
381
+ * with every prop undefined, and React reported only "Element type is invalid …
382
+ * got: undefined" — naming neither the element nor the prop.
383
+ *
384
+ * This module carries no directive and imports nothing but types, so it stays in
385
+ * both graphs and its values survive on the server.
386
+ */
387
+
388
+ export const BUTTON_DEFAULTS = { tone: "neutral", emphasis: "filled", size: "md" } as const;
389
+ export const TEXT_DEFAULTS = { role: "body" } as const;
390
+ export const CARD_DEFAULTS = { tone: "default", inset: "md" } as const;
391
+ export const CHECKBOX_DEFAULTS = { disabled: false, indeterminate: false } as const;
392
+ export const SELECT_DEFAULTS = { disabled: false } as const;
393
+ `;
394
+
395
+ /* -------------------------------------------------------------- components */
396
+
397
+ /**
398
+ * The seed components.
399
+ *
400
+ * Five, and each one is here to carry a pattern the rest of the set will copy:
401
+ * a variant matrix, a component with no primitive beneath it, a surface, a
402
+ * simple primitive wrap, and a portalled primitive.
403
+ */
404
+ export const SEED_COMPONENTS = ["Button", "Text", "Card", "Checkbox", "Select"];
405
+
406
+ function componentSources(primitive) {
407
+ return {
408
+ "Button.tsx": `"use client";
409
+ ${GENERATED("closed prop surface — no className, no style, no render prop")}
410
+ import { Button as Base } from "${primitive}/button";
411
+ import type { ReactNode } from "react";
412
+
413
+ /**
414
+ * TONE AND EMPHASIS, NOT A COLOUR.
415
+ *
416
+ * The variants are a matrix — three tones by three emphases — rather than nine
417
+ * independent style blocks. The rule that keeps it a system: **a new variant
418
+ * cannot introduce a colour.** It can only pick a tone and an emphasis. That is
419
+ * what makes "variants only where a real choice exists" checkable instead of a
420
+ * slogan.
421
+ */
422
+ export type ButtonTone = "neutral" | "accent" | "danger" | "themed";
423
+ export type ButtonEmphasis = "filled" | "outline" | "ghost";
424
+ export type ButtonSize = "sm" | "md" | "lg";
425
+
426
+ export type ButtonProps = {
427
+ children: ReactNode;
428
+ tone: ButtonTone;
429
+ emphasis: ButtonEmphasis;
430
+ size: ButtonSize;
431
+ /**
432
+ * Required and nullable rather than optional. An omittable onClick on a real
433
+ * button compiles, renders, and does nothing — pass null to say the no-op is
434
+ * deliberate.
435
+ */
436
+ onClick: (() => void) | null;
437
+ type?: "button" | "submit";
438
+ disabled?: boolean;
439
+ /** A disabled control with no explanation is the most common accessibility defect there is. */
440
+ describedBy?: string;
441
+ };
442
+
443
+ export function Button({ children, tone, emphasis, size, onClick, type = "button", disabled = false, describedBy }: ButtonProps) {
444
+ return (
445
+ <Base
446
+ // focusableWhenDisabled: a disabled button that leaves the tab order is a
447
+ // control a keyboard user cannot discover the existence of.
448
+ focusableWhenDisabled
449
+ type={type}
450
+ disabled={disabled}
451
+ aria-describedby={describedBy}
452
+ onClick={onClick ?? undefined}
453
+ data-ds="button"
454
+ data-tone={tone}
455
+ data-emphasis={emphasis}
456
+ data-size={size}
457
+ >
458
+ {children}
459
+ </Base>
460
+ );
461
+ }
462
+ `,
463
+
464
+ "Text.tsx": `${GENERATED("closed prop surface")}
465
+ import type { ReactNode } from "react";
466
+
467
+ /**
468
+ * No primitive beneath this one: text has no behaviour to adopt.
469
+ *
470
+ * \`role\` and \`as\` are separate axes on purpose. A heading that must be an <h2>
471
+ * for the document outline should not be forced to LOOK like one, and the
472
+ * alternative — one prop meaning both — is how type scales drift.
473
+ *
474
+ * \`as\` is three values, not a general element passthrough. Each was needed.
475
+ */
476
+ export type TextRole = "micro" | "small" | "body" | "lead" | "title" | "display" | "muted" | "faint";
477
+
478
+ export type TextProps = {
479
+ children: ReactNode;
480
+ role: TextRole;
481
+ as?: "p" | "span" | "code";
482
+ };
483
+
484
+ const DEFAULT_ELEMENT: Record<TextRole, "p" | "span"> = {
485
+ micro: "span", small: "span", body: "p", lead: "p",
486
+ title: "p", display: "p", muted: "span", faint: "span",
487
+ };
488
+
489
+ export function Text({ children, role, as }: TextProps) {
490
+ const Tag = as ?? DEFAULT_ELEMENT[role];
491
+ return <Tag data-ds="text" data-role={role}>{children}</Tag>;
492
+ }
493
+ `,
494
+
495
+ "Card.tsx": `${GENERATED("refusal to accept a radius, a colour, a shadow or a spacing override")}
496
+ import type { ReactNode } from "react";
497
+
498
+ /**
499
+ * The bordered box.
500
+ *
501
+ * It takes no radius prop and no shadow prop, and that is the entire point: one
502
+ * radius is what stops a repository growing seven. A caller who needs a
503
+ * different corner has found a gap in the set, which is a finding to file — not
504
+ * a licence to write bespoke CSS.
505
+ *
506
+ * \`tone="themed"\` reads the runtime theme (see theme.ts) rather than the
507
+ * dictionary, which is how a per-tenant brand works without an escape hatch.
508
+ *
509
+ * WHY THE SPACING PROP IS CALLED \`inset\` AND NOT \`padding\`.
510
+ *
511
+ * The two mature systems this was learned from disagree here. One takes a
512
+ * \`padding\` prop with a closed set of steps; the other forbids the key outright,
513
+ * alongside className and style. Both are defending the same rule — no component
514
+ * accepts a spacing OVERRIDE — and a closed set of four semantic steps is not an
515
+ * override, it is a variant.
516
+ *
517
+ * The stricter convention wins, on the narrow ground that a prop named after a
518
+ * CSS property is the one somebody eventually widens to take a raw value,
519
+ * because the name already implies it can. \`inset\` names the intent and cannot
520
+ * be read as an invitation.
521
+ *
522
+ * The sharper rule this stands in for — a prop is closed if its TYPE is a union
523
+ * of semantic names, whatever it is called — needs a type-aware check rather
524
+ * than a textual one, and is not built.
525
+ */
526
+ export type CardTone = "default" | "muted" | "themed" | "success" | "warning" | "danger";
527
+ export type CardInset = "none" | "sm" | "md" | "lg";
528
+
529
+ export type CardProps = {
530
+ children: ReactNode;
531
+ tone: CardTone;
532
+ inset: CardInset;
533
+ as?: "div" | "section" | "article" | "li";
534
+ };
535
+
536
+ export function Card({ children, tone, inset, as: Tag = "div" }: CardProps) {
537
+ return <Tag data-ds="card" data-tone={tone} data-inset={inset}>{children}</Tag>;
538
+ }
539
+ `,
540
+
541
+ "Checkbox.tsx": `"use client";
542
+ ${GENERATED("closed prop surface across the wrapper boundary")}
543
+ import { Checkbox as Base } from "${primitive}/checkbox";
544
+
545
+ /**
546
+ * The primitive supplies BEHAVIOUR only — focus, keyboard, ARIA, and the
547
+ * data-checked / data-disabled / data-indeterminate attributes the stylesheet
548
+ * hooks onto. It never supplies the API.
549
+ *
550
+ * Its parts accept className, style and a render prop on every element, by
551
+ * design, because they are unstyled primitives. None of that is forwarded. That
552
+ * refusal is the wrapper's whole job, and a boundary rule stops anyone walking
553
+ * round it by importing the primitive directly.
554
+ *
555
+ * Controlled only. An uncontrolled mode would mean two sources of truth for
556
+ * whether the box is ticked, and the bug that produces is invisible in review.
557
+ */
558
+ export type CheckboxProps = {
559
+ checked: boolean;
560
+ onChange: (checked: boolean) => void;
561
+ label: string;
562
+ id: string;
563
+ name?: string;
564
+ disabled?: boolean;
565
+ indeterminate?: boolean;
566
+ };
567
+
568
+ export function Checkbox({ checked, onChange, label, id, name, disabled = false, indeterminate = false }: CheckboxProps) {
569
+ return (
570
+ <span data-ds="checkbox">
571
+ <Base.Root
572
+ id={id}
573
+ name={name}
574
+ checked={checked}
575
+ indeterminate={indeterminate}
576
+ disabled={disabled}
577
+ onCheckedChange={onChange}
578
+ >
579
+ {/* The mark is drawn entirely by CSS from the data-* attributes. */}
580
+ <Base.Indicator />
581
+ </Base.Root>
582
+ <label htmlFor={id}>{label}</label>
583
+ </span>
584
+ );
585
+ }
586
+ `,
587
+
588
+ "Select.tsx": `"use client";
589
+ ${GENERATED("closed prop surface, and the portal's theme lookup")}
590
+ import { useRef } from "react";
591
+ import { Select as Base } from "${primitive}/select";
592
+
593
+ /**
594
+ * A portalled primitive, which is why this file exists in the seed set.
595
+ *
596
+ * THE BUG THIS SHIPS ALREADY FIXED. A popup renders in a portal, outside the
597
+ * DOM subtree of the surface that opened it — so inheritance-based theming stops
598
+ * reaching it and the popup opens light against a dark panel. Found by looking
599
+ * at a deployed catalogue; no unit test would have caught it.
600
+ *
601
+ * The fix is to portal into the nearest themed container rather than the body:
602
+ * \`trigger.closest("[data-theme]")\`. That works because the token stylesheet
603
+ * emits [data-theme] as an unqualified selector, so it applies on any element
604
+ * and not only on :root.
605
+ */
606
+ export type SelectOption = { value: string; label: string };
607
+
608
+ export type SelectProps = {
609
+ value: string;
610
+ onChange: (value: string) => void;
611
+ options: readonly SelectOption[];
612
+ label: string;
613
+ disabled?: boolean;
614
+ name?: string;
615
+ };
616
+
617
+ export function Select({ value, onChange, options, label, disabled = false, name }: SelectProps) {
618
+ const trigger = useRef<HTMLDivElement>(null);
619
+
620
+ return (
621
+ <div ref={trigger} data-ds="select">
622
+ <Base.Root
623
+ value={value}
624
+ name={name}
625
+ disabled={disabled}
626
+ // The primitive can clear to null; this control has no clear affordance,
627
+ // so narrowing here keeps the callback honest about what can happen.
628
+ onValueChange={(next) => { if (next !== null) onChange(next as string); }}
629
+ >
630
+ <Base.Trigger aria-label={label}>
631
+ <Base.Value />
632
+ </Base.Trigger>
633
+ <Base.Portal container={() => trigger.current?.closest("[data-theme]") ?? null}>
634
+ <Base.Positioner>
635
+ <Base.Popup>
636
+ {options.map((o) => (
637
+ <Base.Item key={o.value} value={o.value}>
638
+ <Base.ItemText>{o.label}</Base.ItemText>
639
+ </Base.Item>
640
+ ))}
641
+ </Base.Popup>
642
+ </Base.Positioner>
643
+ </Base.Portal>
644
+ </Base.Root>
645
+ </div>
646
+ );
647
+ }
648
+ `,
649
+ };
650
+ }
651
+
652
+ /**
653
+ * The closure test.
654
+ *
655
+ * It reads the emitted source AS TEXT rather than importing it, for the same
656
+ * reason System A's does: a typo'd prop name would resolve to
657
+ * `undefined` through an import and the assertion would pass while the rule it
658
+ * checks had quietly stopped applying.
659
+ */
660
+ function setTest(components) {
661
+ return `${GENERATED("rule this file enforces — it runs on every commit, not at emission")}
662
+ import { test } from "node:test";
663
+ import assert from "node:assert/strict";
664
+ import { readFileSync } from "node:fs";
665
+ import { fileURLToPath } from "node:url";
666
+ import { dirname, join } from "node:path";
667
+
668
+ const HERE = dirname(fileURLToPath(import.meta.url));
669
+ const COMPONENTS = ${JSON.stringify(components)};
670
+
671
+ /**
672
+ * THE LOAD-BEARING RULE.
673
+ *
674
+ * "No className passthrough. This is the load-bearing one. It is the escape
675
+ * hatch that quietly makes every other rule advisory, and a system with it is a
676
+ * folder of components with extra steps."
677
+ *
678
+ * Source is read as text, not imported: a typo'd prop name resolves to undefined
679
+ * through an import, so an import-based check passes while the rule it is
680
+ * checking has stopped applying.
681
+ *
682
+ * These components are EXPECTED to be edited. That is why this runs on every
683
+ * commit rather than at emission — closure has to survive the editing, or it was
684
+ * only ever a property of the first commit.
685
+ */
686
+ const FORBIDDEN = /\\b(className|style|radius|colour|color|padding|margin|width|height|boxShadow|render)\\s*[?:]/;
687
+
688
+ for (const name of COMPONENTS) {
689
+ test(\`\${name} accepts no style-shaped prop\`, () => {
690
+ const src = readFileSync(join(HERE, \`\${name}.tsx\`), "utf8");
691
+ const propBlocks = src.match(/export type \\w+Props = \\{[^}]*\\}/gs) ?? [];
692
+ assert.ok(propBlocks.length > 0, \`\${name} exports no Props type to check\`);
693
+ for (const block of propBlocks) {
694
+ const hit = block.match(FORBIDDEN);
695
+ assert.equal(hit, null,
696
+ \`\${name} accepts "\${hit?.[1]}" — a component that can be overridden will drift\`);
697
+ }
698
+ });
699
+ }
700
+
701
+ test("every component is exported from the barrel", () => {
702
+ const barrel = readFileSync(join(HERE, "index.ts"), "utf8");
703
+ for (const name of COMPONENTS) {
704
+ assert.match(barrel, new RegExp(\`\\\\b\${name}\\\\b\`), \`\${name} is not exported\`);
705
+ }
706
+ });
707
+ `;
708
+ }
709
+
710
+ function stylesheet(dict) {
711
+ const v = (g, n) => `var(${varName(g, n)})`;
712
+ return `${GENERATED("absence of any literal value in this file")}
713
+
714
+ /* Every value here is a var(). The audit asserts it, so a hex or a raw px in
715
+ * this file fails the build rather than becoming the first crack. */
716
+
717
+ [data-ds="card"] {
718
+ border-radius: ${v("radius", "card")};
719
+ border: ${v("border", "hair")} solid ${v("color", "border")};
720
+ background: ${v("color", "raised")};
721
+ color: ${v("color", "text")};
722
+ }
723
+ [data-ds="card"][data-inset="sm"] { padding: ${v("space", 3)}; }
724
+ [data-ds="card"][data-inset="md"] { padding: ${v("space", 5)}; }
725
+ [data-ds="card"][data-inset="lg"] { padding: ${v("space", 6)}; }
726
+ [data-ds="card"][data-tone="muted"] { background: ${v("color", "inset")}; }
727
+ [data-ds="card"][data-tone="danger"] { border-color: ${v("color", "danger")}; }
728
+
729
+ /* The themed tone reads the runtime theme, with the dictionary as the fallback.
730
+ * The fallback must be a literal var() reference rather than a value, so an
731
+ * unthemed surface still renders from the dictionary. */
732
+ [data-ds="card"][data-tone="themed"] {
733
+ background: var(--theme-surface, ${v("color", "raised")});
734
+ border-color: var(--theme-border, ${v("color", "border")});
735
+ color: var(--theme-text, ${v("color", "text")});
736
+ }
737
+
738
+ [data-ds="button"] {
739
+ border-radius: ${v("radius", "control")};
740
+ font-size: ${v("text", "body")};
741
+ font-weight: ${v("weight", "medium")};
742
+ padding: ${v("space", 3)} ${v("space", 5)};
743
+ }
744
+ [data-ds="button"][data-size="sm"] { font-size: ${v("text", "small")}; padding: ${v("space", 2)} ${v("space", 4)}; }
745
+ [data-ds="button"][data-tone="accent"][data-emphasis="filled"] {
746
+ background: ${v("color", "accent")};
747
+ color: ${v("color", "onAccent")};
748
+ }
749
+ [data-ds="button"][data-tone="danger"][data-emphasis="filled"] {
750
+ background: ${v("color", "danger")};
751
+ color: ${v("color", "onAccent")};
752
+ }
753
+ [data-ds="button"][data-emphasis="ghost"] { background: transparent; }
754
+
755
+ [data-ds="text"][data-role="body"] { font-size: ${v("text", "body")}; line-height: ${v("lineHeight", "body")}; color: ${v("color", "text")}; }
756
+ [data-ds="text"][data-role="muted"] { font-size: ${v("text", "body")}; color: ${v("color", "muted")}; }
757
+ [data-ds="text"][data-role="faint"] { font-size: ${v("text", "small")}; color: ${v("color", "faint")}; }
758
+ [data-ds="text"][data-role="title"] { font-size: ${v("text", "title")}; line-height: ${v("lineHeight", "flush")}; }
759
+ [data-ds="text"][data-role="display"] { font-size: ${v("text", "display")}; line-height: ${v("lineHeight", "flush")}; }
760
+
761
+ [data-ds="checkbox"], [data-ds="select"] { font-size: ${v("text", "body")}; }
762
+ [data-ds="select"] [role="listbox"] {
763
+ border-radius: ${v("radius", "card")};
764
+ background: ${v("color", "raised")};
765
+ box-shadow: ${v("shadow", "overlay")};
766
+ }
767
+ `;
768
+ }
769
+
770
+ export function emitSystem({ dict = SEED, primitive = "@base-ui/react", path = "packages/design-system", components = SEED_COMPONENTS, tokensPackage = "@scaffold/design-tokens", systemPackage = "@scaffold/design-system" } = {}) {
771
+ const sources = componentSources(primitive);
772
+ const out = {};
773
+
774
+ for (const name of components) {
775
+ const src = sources[`${name}.tsx`];
776
+ if (!src) throw new Error(`No seed template for "${name}". The seed set is a menu, not a catalogue — add a template or drop it from the list.`);
777
+ out[`${path}/src/${name}.tsx`] = src;
778
+ }
779
+
780
+ out[`${path}/src/theme.ts`] = themeModule(dict);
781
+ out[`${path}/src/theme.test.ts`] = themeTest;
782
+ out[`${path}/src/defaults.ts`] = defaultsModule;
783
+ out[`${path}/src/styles.css`] = stylesheet(dict);
784
+ out[`${path}/src/set.test.ts`] = setTest(components);
785
+ out[`${path}/package.json`] = JSON.stringify({
786
+ name: systemPackage,
787
+ version: "0.0.0",
788
+ private: true,
789
+ type: "module",
790
+ main: "./src/index.ts",
791
+ types: "./src/index.ts",
792
+ exports: { ".": "./src/index.ts", "./styles.css": "./src/styles.css" },
793
+ // The primitive is a real dependency of THIS package and of nothing else —
794
+ // the boundary rule forbids an app from even declaring it.
795
+ dependencies: { [primitive]: "^1.0.0", [tokensPackage]: "workspace:*" },
796
+ peerDependencies: { react: "^19.0.0" },
797
+ scripts: { test: "node --test src/*.test.ts" },
798
+ }, null, 2) + "\n";
799
+
800
+ out[`${path}/src/index.ts`] =
801
+ `${GENERATED("completeness of this barrel — set.test.ts fails if a component is missing")}
802
+ ${components.map((n) => `export { ${n} } from "./${n}";\nexport type { ${n}Props } from "./${n}";`).join("\n")}
803
+ export { themeVars, themeVar } from "./theme";
804
+ export type { SurfaceTheme, ThemeSlot } from "./theme";
805
+ `;
806
+ return out;
807
+ }
808
+
809
+ /* ------------------------------------------------------------------ config */
810
+
811
+ /**
812
+ * Paths that are written once and never upgraded.
813
+ *
814
+ * `gyde.config.json` is the PRODUCT's configuration, not a template: Gyde has
815
+ * no version of it to ship. Recording it as provenance made an upgrade report
816
+ * it as "retired" — a file this version no longer emits — which is both wrong
817
+ * and alarming, since the retired list is where a reader looks for things that
818
+ * were dropped.
819
+ *
820
+ * The rule: provenance is for files Gyde has an opinion about the CONTENTS of.
821
+ */
822
+ export const NOT_UPGRADEABLE = new Set(["gyde.config.json"]);
823
+
824
+ export function emitConfig({ systemPath = "packages/design-system", tokensPath = "packages/design-tokens", foreignPaths = [], primitive = "@base-ui/react", scope = null, defaultBranch = null, runsOn = null } = {}) {
825
+ return {
826
+ "gyde.config.json": JSON.stringify({
827
+ version: 1,
828
+ design: {
829
+ primitive,
830
+ // Recorded so the next run does not have to re-derive it, and so a
831
+ // workspace that adds a second scope does not silently change what the
832
+ // emitted code imports.
833
+ scope,
834
+ systemPath,
835
+ tokensPath,
836
+ /**
837
+ * Detected once, then recorded — for the same reason as `scope` above,
838
+ * and G-66 is what made the reason concrete.
839
+ *
840
+ * These two are DETECTED from the repository: the default branch from
841
+ * git, the runner from the existing workflows. Detection is right at
842
+ * `init`, and re-running it on every `upgrade` is how a workflow gets
843
+ * silently rewritten. System B had `branches: [staging, main]`
844
+ * on disk; a later `upgrade` re-detected `staging` alone and proposed
845
+ * to revert the promotion trigger, reporting it as "Gyde changed and
846
+ * you had not touched" — when a person had changed it deliberately.
847
+ *
848
+ * Recorded here, the emitted workflow is reproducible from committed
849
+ * configuration, so an upgrade at the same version is a genuine no-op
850
+ * and a real change is a real diff.
851
+ */
852
+ defaultBranch,
853
+ runsOn,
854
+ // Both of these SUPPRESS findings, so a wrong value here is a silent
855
+ // pass. They are committed configuration a reviewer can see and argue
856
+ // with — never something Gyde infers from a path heuristic.
857
+ systemPaths: [systemPath, tokensPath],
858
+ foreignPaths,
859
+ },
860
+ }, null, 2) + "\n",
861
+ };
862
+ }