@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/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
|
+
}
|