@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/catalogue.mjs
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-50 — the catalogue, generated rather than authored.
|
|
3
|
+
*
|
|
4
|
+
* @gyde-emits-source-for-another-repo
|
|
5
|
+
*
|
|
6
|
+
* WHY GENERATED.
|
|
7
|
+
*
|
|
8
|
+
* Both source repositories hand-authored one, and the larger of the two shows
|
|
9
|
+
* what that costs at scale: a 433-line registry whose `sourcePath` entries point
|
|
10
|
+
* at a superseded generation of its own components, whose Button lists four
|
|
11
|
+
* variants where the real one has nine, and which has **no coverage test**. It
|
|
12
|
+
* documents a system that no longer exists, confidently.
|
|
13
|
+
*
|
|
14
|
+
* Generating it makes "every component is catalogued" true by construction
|
|
15
|
+
* instead of true by a test somebody has to keep passing. The prior-art survey
|
|
16
|
+
* found Storybook does not enforce catalogue coverage either, so this is a real
|
|
17
|
+
* gap rather than a preference.
|
|
18
|
+
*
|
|
19
|
+
* WHAT IT MUST RENDER, AND WHY EACH ONE IS ON THE LIST.
|
|
20
|
+
*
|
|
21
|
+
* Every item here is a defect class a deployed catalogue has already caught once:
|
|
22
|
+
*
|
|
23
|
+
* every variant a Button that rendered nothing at all
|
|
24
|
+
* both themes three muted notes running together into one word
|
|
25
|
+
* a portalled surface a Select popup opening light against a dark panel
|
|
26
|
+
* a runtime-themed one the capability whose absence cost two apps 0% adoption
|
|
27
|
+
* the usage index a component reporting itself unused while demonstrating itself
|
|
28
|
+
*
|
|
29
|
+
* A catalogue that renders only first paint, in one theme, is a screenshot.
|
|
30
|
+
*
|
|
31
|
+
* THE HONESTY TEST, which is a gate rather than a convention:
|
|
32
|
+
*
|
|
33
|
+
* "It boots with nothing else running — no manager, no database, no network.
|
|
34
|
+
* That is not a convenience, it is the test of whether the catalogue is still
|
|
35
|
+
* honest… If this command ever grows a prerequisite, read the vite config
|
|
36
|
+
* before fixing it, because the prerequisite is the bug."
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What each seed component can be shown as.
|
|
41
|
+
*
|
|
42
|
+
* Data, not JSX, so the coverage test can read it as text — the same reason
|
|
43
|
+
* System A keeps its entries in a declarative array. A test that
|
|
44
|
+
* imports the catalogue would resolve a typo'd component name to `undefined`
|
|
45
|
+
* and pass while covering nothing.
|
|
46
|
+
*
|
|
47
|
+
* `states` is deliberately plural everywhere. The defects worth catching are
|
|
48
|
+
* states, and none of them exists on load.
|
|
49
|
+
*/
|
|
50
|
+
export const CATALOGUE_ENTRIES = {
|
|
51
|
+
Button: {
|
|
52
|
+
purpose: "The one action a view wants, and every lesser action beside it.",
|
|
53
|
+
replaces: ["a styled <button>", "an <a> that looks like a button"],
|
|
54
|
+
states: [
|
|
55
|
+
{ label: "accent / filled", props: { tone: "accent", emphasis: "filled", size: "md" }, children: "Save changes" },
|
|
56
|
+
{ label: "neutral / outline", props: { tone: "neutral", emphasis: "outline", size: "md" }, children: "Cancel" },
|
|
57
|
+
{ label: "danger / filled", props: { tone: "danger", emphasis: "filled", size: "md" }, children: "Delete" },
|
|
58
|
+
{ label: "ghost, small", props: { tone: "neutral", emphasis: "ghost", size: "sm" }, children: "Dismiss" },
|
|
59
|
+
{ label: "disabled", props: { tone: "accent", emphasis: "filled", size: "md", disabled: true }, children: "Saving…" },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
Text: {
|
|
63
|
+
purpose: "Every piece of prose, at the role it plays rather than the size it is.",
|
|
64
|
+
replaces: ["a <p> with a font-size", "a <span> with a colour"],
|
|
65
|
+
states: [
|
|
66
|
+
{ label: "display", props: { role: "display" }, children: "A page title" },
|
|
67
|
+
{ label: "title", props: { role: "title" }, children: "A panel title" },
|
|
68
|
+
{ label: "body", props: { role: "body" }, children: "Body copy, which is what most text is." },
|
|
69
|
+
{ label: "muted", props: { role: "muted", as: "p" }, children: "Supporting text that is still information." },
|
|
70
|
+
{ label: "faint", props: { role: "faint", as: "p" }, children: "Present, but not information." },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
Card: {
|
|
74
|
+
purpose: "A bordered surface. The only one, which is the point.",
|
|
75
|
+
replaces: ["a div with a border and a radius", "a panel", "a well"],
|
|
76
|
+
states: [
|
|
77
|
+
{ label: "default", props: { tone: "default", inset: "md" }, children: "A surface." },
|
|
78
|
+
{ label: "muted", props: { tone: "muted", inset: "md" }, children: "A surface sunk into another." },
|
|
79
|
+
{ label: "danger", props: { tone: "danger", inset: "md" }, children: "Something failed here." },
|
|
80
|
+
{ label: "no inset", props: { tone: "default", inset: "none" }, children: "Edge to edge." },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
Checkbox: {
|
|
84
|
+
purpose: "A binary choice that is part of a form.",
|
|
85
|
+
replaces: ["an <input type=checkbox> with a label beside it"],
|
|
86
|
+
controlled: { checked: false },
|
|
87
|
+
states: [
|
|
88
|
+
{ label: "unchecked", props: { checked: false, label: "Include drafts", id: "cb-1" } },
|
|
89
|
+
{ label: "checked", props: { checked: true, label: "Include drafts", id: "cb-2" } },
|
|
90
|
+
{ label: "indeterminate", props: { checked: false, indeterminate: true, label: "Some selected", id: "cb-3" } },
|
|
91
|
+
{ label: "disabled", props: { checked: true, disabled: true, label: "Locked", id: "cb-4" } },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
Select: {
|
|
95
|
+
purpose: "One of a known set of options.",
|
|
96
|
+
replaces: ["a <select>", "a dropdown built from a button and a list"],
|
|
97
|
+
controlled: { value: "b" },
|
|
98
|
+
/**
|
|
99
|
+
* The open state is the reason this component is in the seed set — the popup
|
|
100
|
+
* is portalled, and a portal is where inheritance-based theming stops
|
|
101
|
+
* reaching. It is also the state this catalogue CANNOT render: forcing it
|
|
102
|
+
* would need `defaultOpen`, which the wrapper deliberately does not expose.
|
|
103
|
+
*
|
|
104
|
+
* An earlier version listed an "open" example that rendered exactly the
|
|
105
|
+
* closed one. A label that lies is worse than a gap, so the states below are
|
|
106
|
+
* the ones the closed API can actually produce, and the gap is recorded in
|
|
107
|
+
* `notes` where a reader of the catalogue will see it.
|
|
108
|
+
*/
|
|
109
|
+
notes: [
|
|
110
|
+
"Its open state is portalled and cannot be forced through the closed API. " +
|
|
111
|
+
"That is where theming across a portal boundary is proved, so it needs a " +
|
|
112
|
+
"rendered-artifact review rather than a static example (G-11).",
|
|
113
|
+
],
|
|
114
|
+
states: [
|
|
115
|
+
{ label: "a value selected", props: { label: "Sort by", options: [{ value: "a", label: "Newest" }, { value: "b", label: "Oldest" }] } },
|
|
116
|
+
{ label: "a different value selected", props: { value: "a", label: "Sort by", options: [{ value: "a", label: "Newest" }, { value: "b", label: "Oldest" }] } },
|
|
117
|
+
{ label: "disabled", props: { disabled: true, label: "Sort by", options: [{ value: "a", label: "Newest" }, { value: "b", label: "Oldest" }] } },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const lit = (v) =>
|
|
123
|
+
typeof v === "string" ? JSON.stringify(v)
|
|
124
|
+
: typeof v === "boolean" || typeof v === "number" ? `{${v}}`
|
|
125
|
+
: `{${JSON.stringify(v)}}`;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Render one example as JSX.
|
|
129
|
+
*
|
|
130
|
+
* An explicit builder, not a chain of `.replace()` calls on a formatted string.
|
|
131
|
+
* The first version was the latter and shipped two bugs into a scaffolded
|
|
132
|
+
* repository: `Select` emitted without its required `value` prop, and every
|
|
133
|
+
* state of a controlled component rendered identically because the
|
|
134
|
+
* substitutions silently did not match.
|
|
135
|
+
*
|
|
136
|
+
* A generator whose output is assembled by patching text produces output nobody
|
|
137
|
+
* predicted, and the failure is invisible until somebody reads the emitted file.
|
|
138
|
+
*/
|
|
139
|
+
function exampleJsx(name, state, entry) {
|
|
140
|
+
const props = { ...(entry.controlled ?? {}), ...state.props };
|
|
141
|
+
const attrs = Object.entries(props).map(([k, v]) => `${k}=${lit(v)}`);
|
|
142
|
+
|
|
143
|
+
// A controlled component needs a handler. The catalogue holds no state on
|
|
144
|
+
// purpose — what is being reviewed is the rendering, not the interaction.
|
|
145
|
+
if (entry.controlled) attrs.push("onChange={() => {}}");
|
|
146
|
+
|
|
147
|
+
const open = `<${name} ${attrs.join(" ")}`;
|
|
148
|
+
return state.children ? `${open}>${state.children}</${name}>` : `${open} />`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A KNOWN GAP, stated rather than worked around.
|
|
153
|
+
*
|
|
154
|
+
* `Select`'s open state is the reason it is in the seed set: the popup is
|
|
155
|
+
* portalled, and a portal is where inheritance-based theming stops reaching.
|
|
156
|
+
* But the wrapper deliberately does not expose `defaultOpen` — the primitive
|
|
157
|
+
* has it, and forwarding it would be an escape hatch opened for the
|
|
158
|
+
* catalogue's convenience, which is exactly how closure erodes.
|
|
159
|
+
*
|
|
160
|
+
* So **a closed component set cannot demonstrate its own interactive states.**
|
|
161
|
+
* That is a real tension, not an oversight, and the catalogue says so at the
|
|
162
|
+
* call site rather than printing "open" beside a control that is closed. A
|
|
163
|
+
* label that lies is worse than an admission.
|
|
164
|
+
*
|
|
165
|
+
* The fix is a decision rather than a patch: either the set gains a reviewed
|
|
166
|
+
* way to force a state, or interactive states are explicitly out of the
|
|
167
|
+
* catalogue's scope and a rendered-artifact review (G-11) covers them instead.
|
|
168
|
+
*/
|
|
169
|
+
export const INTERACTIVE_GAP = "a closed component set cannot force its own interactive states";
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The entries module.
|
|
173
|
+
*
|
|
174
|
+
* Generated from the same list the barrel is generated from, which is what
|
|
175
|
+
* makes coverage structural: a component cannot be added to the set without
|
|
176
|
+
* appearing here, because both come from one array.
|
|
177
|
+
*/
|
|
178
|
+
function entriesSource(components, systemPackage) {
|
|
179
|
+
const missing = components.filter((c) => !CATALOGUE_ENTRIES[c]);
|
|
180
|
+
if (missing.length) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`No catalogue entry for ${missing.join(", ")}. A component that cannot be ` +
|
|
183
|
+
"demonstrated cannot be reviewed — add states before emitting it, or the " +
|
|
184
|
+
"catalogue documents a set it does not cover.",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const blocks = components.map((name) => {
|
|
189
|
+
const e = CATALOGUE_ENTRIES[name];
|
|
190
|
+
const states = e.states.map((s) =>
|
|
191
|
+
` { label: ${JSON.stringify(s.label)}, node: ${exampleJsx(name, s, e)} },`).join("\n");
|
|
192
|
+
|
|
193
|
+
return ` {
|
|
194
|
+
name: ${JSON.stringify(name)},
|
|
195
|
+
purpose: ${JSON.stringify(e.purpose)},
|
|
196
|
+
replaces: ${JSON.stringify(e.replaces)},
|
|
197
|
+
notes: ${JSON.stringify(e.notes ?? [])},
|
|
198
|
+
examples: [
|
|
199
|
+
${states}
|
|
200
|
+
],
|
|
201
|
+
},`;
|
|
202
|
+
}).join("\n");
|
|
203
|
+
|
|
204
|
+
return `/* GENERATED BY GYDE — regenerate rather than edit.
|
|
205
|
+
*
|
|
206
|
+
* Entries are DATA, not JSX scattered through a page, so the coverage test can
|
|
207
|
+
* read this file as text. A test that imported it would resolve a typo'd
|
|
208
|
+
* component name to \`undefined\` and pass while covering nothing.
|
|
209
|
+
*/
|
|
210
|
+
import type { ReactNode } from "react";
|
|
211
|
+
import { ${components.join(", ")} } from "${systemPackage}";
|
|
212
|
+
|
|
213
|
+
export type Example = { label: string; node: ReactNode };
|
|
214
|
+
export type Entry = { name: string; purpose: string; replaces: readonly string[]; notes: readonly string[]; examples: readonly Example[] };
|
|
215
|
+
|
|
216
|
+
export const ENTRIES: readonly Entry[] = [
|
|
217
|
+
${blocks}
|
|
218
|
+
];
|
|
219
|
+
`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const app = (systemPackage, tokensPackage) => `/* GENERATED BY GYDE — then yours.
|
|
223
|
+
*
|
|
224
|
+
* The shell. It renders every entry, in BOTH THEMES side by side, because
|
|
225
|
+
* \`[data-theme]\` is emitted as an unqualified selector and therefore works on a
|
|
226
|
+
* container. A catalogue that can only show one theme at a time is a catalogue
|
|
227
|
+
* where a theme regression is found by a person switching back and forth.
|
|
228
|
+
*/
|
|
229
|
+
import { ENTRIES } from "./entries";
|
|
230
|
+
import { Tokens } from "./Tokens";
|
|
231
|
+
import { Usage } from "./Usage";
|
|
232
|
+
|
|
233
|
+
export function App() {
|
|
234
|
+
return (
|
|
235
|
+
<main className="dx-page">
|
|
236
|
+
<h1 className="dx-title">The component set</h1>
|
|
237
|
+
<p className="dx-note">
|
|
238
|
+
Every component, every state, both themes. Nothing else is running —
|
|
239
|
+
no database, no network. If this page ever needs something else to
|
|
240
|
+
boot, that prerequisite is the bug.
|
|
241
|
+
</p>
|
|
242
|
+
|
|
243
|
+
<Tokens />
|
|
244
|
+
|
|
245
|
+
{ENTRIES.map((entry) => (
|
|
246
|
+
<section key={entry.name} className="dx-entry">
|
|
247
|
+
<h2 className="dx-entry__name">{entry.name}</h2>
|
|
248
|
+
<p className="dx-note">{entry.purpose}</p>
|
|
249
|
+
<p className="dx-note">Replaces: {entry.replaces.join(", ")}</p>
|
|
250
|
+
{entry.notes.map((n) => (
|
|
251
|
+
<p key={n} className="dx-note dx-note--gap">Not shown here: {n}</p>
|
|
252
|
+
))}
|
|
253
|
+
<Usage name={entry.name} />
|
|
254
|
+
|
|
255
|
+
{entry.examples.map((ex) => (
|
|
256
|
+
<div key={ex.label} className="dx-example">
|
|
257
|
+
<div className="dx-example__label">{ex.label}</div>
|
|
258
|
+
{/* Both themes, side by side, from one render. */}
|
|
259
|
+
<div className="dx-themes">
|
|
260
|
+
<div data-theme="light" className="dx-theme">{ex.node}</div>
|
|
261
|
+
<div data-theme="dark" className="dx-theme">{ex.node}</div>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
))}
|
|
265
|
+
|
|
266
|
+
{/* The runtime-theming path, which no static example exercises.
|
|
267
|
+
Its absence is what cost a real product two apps' adoption. */}
|
|
268
|
+
{entry.name === "Card" ? <ThemedExample /> : null}
|
|
269
|
+
</section>
|
|
270
|
+
))}
|
|
271
|
+
</main>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
import { Card, themeVars } from "${systemPackage}";
|
|
276
|
+
|
|
277
|
+
function ThemedExample() {
|
|
278
|
+
return (
|
|
279
|
+
<div className="dx-example">
|
|
280
|
+
<div className="dx-example__label">runtime theme — a per-tenant brand</div>
|
|
281
|
+
<div style={themeVars({ surface: "#1d2b53", text: "#f2f0e6", border: "#3b4d7a" })}>
|
|
282
|
+
<Card tone="themed" inset="md">
|
|
283
|
+
A surface taking colours resolved at request time, with the dictionary
|
|
284
|
+
as its fallback.
|
|
285
|
+
</Card>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
`;
|
|
291
|
+
|
|
292
|
+
const tokensPage = (tokensPackage) => `/* GENERATED BY GYDE — then yours. */
|
|
293
|
+
import { tokens } from "${tokensPackage}/dictionary.mjs";
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The dictionary, rendered.
|
|
297
|
+
*
|
|
298
|
+
* Every token with its ROLE beside it, because a swatch without a purpose is
|
|
299
|
+
* how a token gets used for the wrong thing. Both themes, for the same reason
|
|
300
|
+
* the components show both.
|
|
301
|
+
*/
|
|
302
|
+
export function Tokens() {
|
|
303
|
+
return (
|
|
304
|
+
<section className="dx-entry">
|
|
305
|
+
<h2 className="dx-entry__name">Tokens</h2>
|
|
306
|
+
{Object.entries(tokens).map(([group, entries]) => (
|
|
307
|
+
<div key={group} className="dx-tokens">
|
|
308
|
+
<h3 className="dx-tokens__group">{group}</h3>
|
|
309
|
+
{Object.entries(entries as Record<string, { value: unknown; role: string }>).map(([name, token]) => (
|
|
310
|
+
<div key={name} className="dx-token">
|
|
311
|
+
<code className="dx-token__name">--{group}-{name}</code>
|
|
312
|
+
<span className="dx-token__role">{token.role}</span>
|
|
313
|
+
</div>
|
|
314
|
+
))}
|
|
315
|
+
</div>
|
|
316
|
+
))}
|
|
317
|
+
</section>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
`;
|
|
321
|
+
|
|
322
|
+
const USAGE_PAGE = `/* GENERATED BY GYDE — regenerate rather than edit. */
|
|
323
|
+
import usage from "./usage.json";
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Where each component is actually rendered.
|
|
327
|
+
*
|
|
328
|
+
* Read from a committed file because this is a browser app with no filesystem.
|
|
329
|
+
* The index that produced it excludes THIS catalogue's entries — a component
|
|
330
|
+
* demonstrating itself is not a use — but not the catalogue's own chrome, which
|
|
331
|
+
* is. A real index once reported a component unused while it sat at the top of
|
|
332
|
+
* a page demonstrating itself.
|
|
333
|
+
*/
|
|
334
|
+
export function Usage({ name }: { name: string }) {
|
|
335
|
+
const sites: string[] = (usage.used as Record<string, string[]>)[name] ?? [];
|
|
336
|
+
if (sites.length === 0) {
|
|
337
|
+
return <p className="dx-note">Not rendered anywhere yet — either use it, or delete it.</p>;
|
|
338
|
+
}
|
|
339
|
+
return <p className="dx-note">Used in {sites.length} place{sites.length === 1 ? "" : "s"}.</p>;
|
|
340
|
+
}
|
|
341
|
+
`;
|
|
342
|
+
|
|
343
|
+
const CHROME_CSS = `/* GENERATED BY GYDE — then yours.
|
|
344
|
+
*
|
|
345
|
+
* The catalogue's own chrome, prefixed \`dx-\` so it is a DECLARED vocabulary
|
|
346
|
+
* rather than bespoke styling the audit has to guess about. A surface that is
|
|
347
|
+
* legitimately outside the component set says so at every call site, instead of
|
|
348
|
+
* being excluded by path in a config nobody reads.
|
|
349
|
+
*/
|
|
350
|
+
.dx-page { padding: var(--space-6); font-family: var(--font-sans); background: var(--color-surface); color: var(--color-text); }
|
|
351
|
+
.dx-title { font-size: var(--text-display); line-height: var(--line-height-flush); }
|
|
352
|
+
.dx-note { color: var(--color-muted); font-size: var(--text-small); }
|
|
353
|
+
.dx-entry { margin-top: var(--space-7); border-top: var(--border-hair) solid var(--color-border); padding-top: var(--space-5); }
|
|
354
|
+
.dx-entry__name { font-size: var(--text-title); }
|
|
355
|
+
.dx-example { margin-top: var(--space-5); }
|
|
356
|
+
.dx-example__label { font-size: var(--text-micro); color: var(--color-faint); margin-bottom: var(--space-2); }
|
|
357
|
+
.dx-themes { display: flex; gap: var(--space-4); }
|
|
358
|
+
.dx-theme { flex: 1; padding: var(--space-4); background: var(--color-surface); border-radius: var(--radius-card); }
|
|
359
|
+
.dx-tokens { margin-top: var(--space-4); }
|
|
360
|
+
.dx-token { display: flex; gap: var(--space-4); font-size: var(--text-small); }
|
|
361
|
+
.dx-token__name { font-family: var(--font-mono); color: var(--color-muted); min-width: 16rem; }
|
|
362
|
+
.dx-token__role { color: var(--color-text); }
|
|
363
|
+
`;
|
|
364
|
+
|
|
365
|
+
const VITE_CONFIG = `/* GENERATED BY GYDE — then yours.
|
|
366
|
+
*
|
|
367
|
+
* DO NOT ADD A PREREQUISITE TO THIS FILE.
|
|
368
|
+
*
|
|
369
|
+
* The catalogue boots with nothing else running — no database, no network, no
|
|
370
|
+
* other app. That is not a convenience, it is the test of whether it is still
|
|
371
|
+
* honest: a catalogue that needs the product running can only show what the
|
|
372
|
+
* product's state allows, which is a screenshot with extra steps.
|
|
373
|
+
*
|
|
374
|
+
* If this config ever grows a proxy, an env requirement or a fixture server,
|
|
375
|
+
* the prerequisite is the bug. Gyde's gate fails on it.
|
|
376
|
+
*/
|
|
377
|
+
import { defineConfig } from "vite";
|
|
378
|
+
import react from "@vitejs/plugin-react";
|
|
379
|
+
|
|
380
|
+
export default defineConfig({
|
|
381
|
+
plugins: [react()],
|
|
382
|
+
server: { port: 3300 },
|
|
383
|
+
build: { outDir: "dist" },
|
|
384
|
+
});
|
|
385
|
+
`;
|
|
386
|
+
|
|
387
|
+
const COVERAGE_TEST = `/* GENERATED BY GYDE — then yours. */
|
|
388
|
+
import { test } from "node:test";
|
|
389
|
+
import assert from "node:assert/strict";
|
|
390
|
+
import { readFileSync } from "node:fs";
|
|
391
|
+
import { fileURLToPath } from "node:url";
|
|
392
|
+
import { dirname, join } from "node:path";
|
|
393
|
+
|
|
394
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Coverage, read as TEXT rather than imported.
|
|
398
|
+
*
|
|
399
|
+
* An import resolves a typo'd name to \`undefined\`, so an import-based check
|
|
400
|
+
* passes while covering nothing. Both files are read as source for that reason.
|
|
401
|
+
*
|
|
402
|
+
* This test is a backstop, not the mechanism. Coverage is true by construction
|
|
403
|
+
* because the barrel and the entries are generated from one list — if this ever
|
|
404
|
+
* fails, something has been hand-edited, and that is worth knowing.
|
|
405
|
+
*/
|
|
406
|
+
test("every exported component appears in the catalogue", () => {
|
|
407
|
+
const barrel = readFileSync(join(HERE, "../../../packages/design-system/src/index.ts"), "utf8");
|
|
408
|
+
const entries = readFileSync(join(HERE, "entries.tsx"), "utf8");
|
|
409
|
+
|
|
410
|
+
const exported = [...barrel.matchAll(/export \\{ (\\w+) \\}/g)].map((m) => m[1]);
|
|
411
|
+
assert.ok(exported.length > 0, "read no components out of the barrel");
|
|
412
|
+
|
|
413
|
+
for (const name of exported) {
|
|
414
|
+
assert.match(entries, new RegExp(\`name: "\${name}"\`), \`\${name} is exported and not catalogued\`);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("the catalogue lists nothing that is not exported", () => {
|
|
419
|
+
const barrel = readFileSync(join(HERE, "../../../packages/design-system/src/index.ts"), "utf8");
|
|
420
|
+
const entries = readFileSync(join(HERE, "entries.tsx"), "utf8");
|
|
421
|
+
const exported = new Set([...barrel.matchAll(/export \\{ (\\w+) \\}/g)].map((m) => m[1]));
|
|
422
|
+
|
|
423
|
+
for (const m of entries.matchAll(/name: "(\\w+)"/g)) {
|
|
424
|
+
assert.ok(exported.has(m[1]), \`\${m[1]} is catalogued and not exported\`);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("every entry shows more than one state", () => {
|
|
429
|
+
// A single example is a screenshot. The defects worth catching are states.
|
|
430
|
+
const entries = readFileSync(join(HERE, "entries.tsx"), "utf8");
|
|
431
|
+
for (const block of entries.split(/\\n \\{\\n/).slice(1)) {
|
|
432
|
+
const name = block.match(/name: "(\\w+)"/)?.[1];
|
|
433
|
+
if (!name) continue;
|
|
434
|
+
const labels = [...block.matchAll(/label: "/g)].length;
|
|
435
|
+
assert.ok(labels >= 2, \`\${name} shows \${labels} state(s); a single example proves nothing\`);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
`;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Emit the catalogue.
|
|
442
|
+
*
|
|
443
|
+
* Throws when a component has no entry, rather than emitting a catalogue that
|
|
444
|
+
* silently covers less than the set. That is the whole guarantee: a component
|
|
445
|
+
* you cannot demonstrate is one nobody can review.
|
|
446
|
+
*/
|
|
447
|
+
export function emitCatalogue({
|
|
448
|
+
components,
|
|
449
|
+
path = "apps/design",
|
|
450
|
+
/**
|
|
451
|
+
* The names the SCAFFOLDED workspace uses, not names Gyde invented.
|
|
452
|
+
*
|
|
453
|
+
* The first version hardcoded `@gyde-scaffold/*`, so every emitted import
|
|
454
|
+
* resolved to nothing and a scaffolded repository would not build. Every test
|
|
455
|
+
* passed, because each compared the emitted text against itself. The CLI now
|
|
456
|
+
* derives these from the workspace's own scope.
|
|
457
|
+
*/
|
|
458
|
+
systemPackage = "@scaffold/design-system",
|
|
459
|
+
tokensPackage = "@scaffold/design-tokens",
|
|
460
|
+
cataloguePackage = "@scaffold/design-catalogue",
|
|
461
|
+
} = {}) {
|
|
462
|
+
return {
|
|
463
|
+
/**
|
|
464
|
+
* The catalogue is an app, so it needs a manifest.
|
|
465
|
+
*
|
|
466
|
+
* Found by Gyde's own scan reporting `apps/design — no package.json`:
|
|
467
|
+
* without one it cannot be started, cannot be filtered to by the package
|
|
468
|
+
* manager, and is invisible to workspace discovery — a catalogue nobody can
|
|
469
|
+
* run is a catalogue nobody looks at, which is the whole value gone.
|
|
470
|
+
*
|
|
471
|
+
* `dev` and `build` and nothing else. The moment this grows a script that
|
|
472
|
+
* needs another process, the honesty test in vite.config.ts is broken.
|
|
473
|
+
*/
|
|
474
|
+
[`${path}/package.json`]: JSON.stringify({
|
|
475
|
+
name: `${cataloguePackage}`,
|
|
476
|
+
version: "0.0.0",
|
|
477
|
+
private: true,
|
|
478
|
+
type: "module",
|
|
479
|
+
scripts: { dev: "vite", build: "vite build", test: "node --test src/*.test.mjs" },
|
|
480
|
+
dependencies: { [systemPackage]: "workspace:*", [tokensPackage]: "workspace:*", react: "^19.0.0", "react-dom": "^19.0.0" },
|
|
481
|
+
devDependencies: { vite: "^6.0.0", "@vitejs/plugin-react": "^4.3.0" },
|
|
482
|
+
}, null, 2) + "\n",
|
|
483
|
+
[`${path}/src/entries.tsx`]: entriesSource(components, systemPackage),
|
|
484
|
+
[`${path}/src/App.tsx`]: app(systemPackage, tokensPackage),
|
|
485
|
+
[`${path}/src/Tokens.tsx`]: tokensPage(tokensPackage),
|
|
486
|
+
[`${path}/src/Usage.tsx`]: USAGE_PAGE,
|
|
487
|
+
[`${path}/src/chrome.css`]: CHROME_CSS,
|
|
488
|
+
[`${path}/src/usage.json`]: JSON.stringify({ used: {}, unused: components, leftovers: {} }, null, 2) + "\n",
|
|
489
|
+
[`${path}/src/coverage.test.mjs`]: COVERAGE_TEST,
|
|
490
|
+
[`${path}/vite.config.ts`]: VITE_CONFIG,
|
|
491
|
+
[`${path}/src/main.tsx`]: `/* GENERATED BY GYDE — then yours. */
|
|
492
|
+
import { createRoot } from "react-dom/client";
|
|
493
|
+
import "${systemPackage}/styles.css";
|
|
494
|
+
import "./chrome.css";
|
|
495
|
+
import { App } from "./App";
|
|
496
|
+
|
|
497
|
+
createRoot(document.getElementById("root")!).render(<App />);
|
|
498
|
+
`,
|
|
499
|
+
[`${path}/index.html`]: `<!doctype html>
|
|
500
|
+
<html lang="en">
|
|
501
|
+
<head><meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" /><title>Design system</title></head>
|
|
502
|
+
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
|
503
|
+
</html>
|
|
504
|
+
`,
|
|
505
|
+
};
|
|
506
|
+
}
|