@weatherboard/gyde-design 0.4.0 → 0.4.2

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/selfgate.mjs ADDED
@@ -0,0 +1,162 @@
1
+ /**
2
+ * G-127 — Gyde runs its own gate over the scaffold Gyde emits.
3
+ *
4
+ * ===========================================================================
5
+ * WHY THIS FILE EXISTS
6
+ * ===========================================================================
7
+ *
8
+ * `emit.mjs` has carried this sentence since G-101:
9
+ *
10
+ * "So this stays below the threshold until the templates earn it. The rule,
11
+ * and `emit.test.mjs` enforces it: a fresh scaffold must pass the gate Gyde
12
+ * would run against it."
13
+ *
14
+ * `emit.test.mjs` did not enforce it. It checked that no component declares a
15
+ * style-shaped prop and that none spreads `{...rest}` — both worth checking,
16
+ * neither of them the gate. Nothing anywhere ran `gyde-design gate` against the
17
+ * emitted tree, so the claim was true of nobody's code and read as true of all
18
+ * of it.
19
+ *
20
+ * What that cost, measured: the scaffold emitted on 2026-09-15 shipped **ten**
21
+ * `optional-prop` findings across five of its five components — Button 3,
22
+ * Checkbox 3, Select 2, Card 1, Text 1. The tool that fails a customer for an
23
+ * optional prop wrote ten of them into every repository it scaffolded, and the
24
+ * customer found out, not us.
25
+ *
26
+ * ===========================================================================
27
+ * IT MEASURES THROUGH THE FRONT DOOR
28
+ * ===========================================================================
29
+ *
30
+ * `init` and `gate` are run as SUBPROCESSES, through `cli.mjs`, exactly as a
31
+ * customer runs them. Importing `scan()` and calling it here would be shorter
32
+ * and would measure a different thing: the CLI is where `design.scope` is read,
33
+ * where `exportedComponents` narrows `componentRoots`, and where the ledger is
34
+ * consulted. A number taken around it is a real number about a repository that
35
+ * does not exist.
36
+ *
37
+ * ===========================================================================
38
+ * THE ASSERTION IS ON THE BASELINE, NOT ON THE EXIT CODE
39
+ * ===========================================================================
40
+ *
41
+ * `gate` on a tree with no ledger RECORDS one and exits 1 — deliberately, since
42
+ * recording debt is not a pass. So a green exit code cannot be the check here:
43
+ * the clean run's exit code is 1 by design, and the second run's exit code is 0
44
+ * whatever the first run recorded, because the first run wrote every finding
45
+ * into the allowance.
46
+ *
47
+ * Gate a scaffold with ten optional props and the second run passes. That is
48
+ * correct behaviour for a customer adopting Gyde and useless as a check on
49
+ * ourselves, which is why this reads `total` out of the recorded baseline.
50
+ * **A scaffold Gyde emits starts at zero, or it is not a scaffold Gyde may
51
+ * emit.** The exit code of the second run is checked as well, because a
52
+ * baseline of zero followed by a failing gate would mean something else is
53
+ * wrong.
54
+ */
55
+
56
+ import { execFileSync } from "node:child_process";
57
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
58
+ import { tmpdir } from "node:os";
59
+ import { join, dirname } from "node:path";
60
+ import { fileURLToPath } from "node:url";
61
+
62
+ const HERE = dirname(fileURLToPath(import.meta.url));
63
+ const CLI = join(HERE, "cli.mjs");
64
+
65
+ /** The scope the probe repository declares. Arbitrary, but it must agree with itself (G-123). */
66
+ const SCOPE = "@selfgate";
67
+
68
+ function cli(args) {
69
+ try {
70
+ return { code: 0, out: execFileSync(process.execPath, [CLI, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) };
71
+ } catch (error) {
72
+ /**
73
+ * A non-zero exit is an ORDINARY outcome here — `gate` exits 1 when it
74
+ * records a baseline — so it is returned rather than thrown. Throwing would
75
+ * make the clean path the exceptional one.
76
+ */
77
+ return { code: error.status ?? 1, out: `${error.stdout ?? ""}${error.stderr ?? ""}` };
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Emit a scaffold into a fresh directory and gate it.
83
+ *
84
+ * `corrupt` receives the directory after `init` and before `gate`, so a caller
85
+ * can put a defect into the emitted source and prove the gate sees it. That is
86
+ * the negative fixture, and it is the only reason this parameter exists: a
87
+ * check that has never been watched to fail is a check nobody has evidence of.
88
+ */
89
+ export function gateAScaffold({ corrupt = null, dir = null } = {}) {
90
+ const root = dir ?? mkdtempSync(join(tmpdir(), "gyde-selfgate-"));
91
+ mkdirSync(root, { recursive: true });
92
+
93
+ writeFileSync(join(root, "package.json"),
94
+ JSON.stringify({ name: "gyde-selfgate-probe", private: true, workspaces: ["packages/*", "apps/*"] }, null, 2) + "\n");
95
+ writeFileSync(join(root, "gyde.config.json"),
96
+ JSON.stringify({ version: 1, design: { scope: SCOPE } }, null, 2) + "\n");
97
+
98
+ const init = cli(["init", root]);
99
+ if (init.code !== 0) return { root, phase: "init", ok: false, total: null, init, baseline: null, judged: null };
100
+
101
+ if (corrupt) corrupt(root);
102
+
103
+ const baseline = cli(["gate", root]);
104
+ let total = null;
105
+ try { total = JSON.parse(readFileSync(join(root, "gyde-allowance.json"), "utf8")).total ?? null; } catch { /* reported as null */ }
106
+
107
+ const judged = cli(["gate", root]);
108
+
109
+ return {
110
+ root,
111
+ phase: "gate",
112
+ total,
113
+ baseline,
114
+ judged,
115
+ ok: total === 0 && judged.code === 0,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * The findings the baseline recorded, as `file rule count` lines.
121
+ *
122
+ * Read back out of the written ledger rather than parsed from the gate's
123
+ * stdout: the prose changes, the file is the record.
124
+ */
125
+ export function baselineFindings(root) {
126
+ let led;
127
+ try { led = JSON.parse(readFileSync(join(root, "gyde-allowance.json"), "utf8")); } catch { return []; }
128
+ const out = [];
129
+ for (const [file, rules] of Object.entries(led.allowed ?? {})) {
130
+ for (const [rule, count] of Object.entries(rules)) out.push({ file, rule, count });
131
+ }
132
+ return out.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
133
+ }
134
+
135
+ export function formatSelfGate(result) {
136
+ const L = [];
137
+ if (result.phase === "init") {
138
+ L.push("the scaffold could not be emitted at all:");
139
+ L.push(result.init.out.trim());
140
+ return L.join("\n");
141
+ }
142
+ const findings = baselineFindings(result.root);
143
+ if (result.ok) return `a fresh scaffold records a baseline of 0 finding(s) and passes \`gyde-design gate\``;
144
+
145
+ L.push(`Gyde emitted a scaffold its own gate rejects: ${result.total} finding(s).`);
146
+ L.push("");
147
+ for (const f of findings) L.push(` ${f.file} ${f.rule} x${f.count}`);
148
+ L.push("");
149
+ if (result.judged.code !== 0) L.push(` the judged run also exited ${result.judged.code}`);
150
+ L.push(" Fix the TEMPLATES in packages/design/emit.mjs. Do not record a baseline for");
151
+ L.push(" this — a customer's ledger may carry pre-existing debt, but the scaffold we");
152
+ L.push(" hand them starts at zero or it is not one we may hand them.");
153
+ return L.join("\n");
154
+ }
155
+
156
+ /* Run directly (`node packages/design/selfgate.mjs`) — this is what `verify` calls. */
157
+ if (process.argv[1] && process.argv[1].endsWith("selfgate.mjs")) {
158
+ const result = gateAScaffold();
159
+ console.log(formatSelfGate(result));
160
+ try { rmSync(result.root, { recursive: true, force: true }); } catch { /* a leftover temp dir is not a failure */ }
161
+ process.exitCode = result.ok ? 0 : 1;
162
+ }
@@ -0,0 +1,111 @@
1
+ /** Browser theme lifecycle emitted into each product's design system. */
2
+ export function emitThemeChoice({ path = "packages/design-system", storageKey }) {
3
+ const key = JSON.stringify(storageKey);
4
+ const bootstrap = `/* GENERATED BY GYDE — then yours. Inline this script in <head> before styles. */
5
+ (() => {
6
+ const key = ${key};
7
+ let choice = "system";
8
+ try {
9
+ const stored = localStorage.getItem(key);
10
+ if (stored === "light" || stored === "dark") choice = stored;
11
+ } catch { /* Storage can be unavailable; the system preference remains usable. */ }
12
+ if (choice === "system") document.documentElement.removeAttribute("data-theme");
13
+ else document.documentElement.setAttribute("data-theme", choice);
14
+ })();
15
+ `;
16
+ const controller = `/* GENERATED BY GYDE — then yours.
17
+ * The app owns its root and calls startThemeChoice() once after hydration.
18
+ * This module adds no provider or layout element.
19
+ */
20
+ export const THEME_STORAGE_KEY = ${key};
21
+ const valid = (value) => value === "light" || value === "dark" || value === "system";
22
+
23
+ export function startThemeChoice({ doc = document, win = window } = {}) {
24
+ let choice = "system";
25
+ let root;
26
+ let rootObserver;
27
+ const listeners = new Set();
28
+ const media = win.matchMedia("(prefers-color-scheme: dark)");
29
+ const read = () => {
30
+ try {
31
+ const stored = win.localStorage.getItem(THEME_STORAGE_KEY);
32
+ choice = valid(stored) ? stored : "system";
33
+ } catch { /* Keep the in-memory choice when storage is unavailable. */ }
34
+ };
35
+ const resolvedTheme = () => choice === "system" ? (media.matches ? "dark" : "light") : choice;
36
+ const notify = () => { for (const listener of listeners) listener(choice, resolvedTheme()); };
37
+ const reconcile = () => {
38
+ if (root !== doc.documentElement) {
39
+ rootObserver?.disconnect();
40
+ root = doc.documentElement;
41
+ if (root) rootObserver?.observe(root, { attributes: true, attributeFilter: ["data-theme"] });
42
+ }
43
+ if (!root) return;
44
+ if (choice === "system") {
45
+ if (root.hasAttribute("data-theme")) root.removeAttribute("data-theme");
46
+ } else if (root.getAttribute("data-theme") !== choice) root.setAttribute("data-theme", choice);
47
+ };
48
+ rootObserver = new win.MutationObserver(reconcile);
49
+ const documentObserver = new win.MutationObserver(reconcile);
50
+ documentObserver.observe(doc, { childList: true });
51
+ read();
52
+ reconcile();
53
+ const onStorage = (event) => {
54
+ if (event.key !== THEME_STORAGE_KEY && event.key !== null) return;
55
+ try { if (event.storageArea && event.storageArea !== win.localStorage) return; }
56
+ catch { /* Storage access is blocked; read() retains the in-memory choice. */ }
57
+ read(); reconcile(); notify();
58
+ };
59
+ const onPageShow = () => { read(); reconcile(); notify(); };
60
+ const onNavigate = () => { reconcile(); notify(); };
61
+ const onMedia = () => { if (choice === "system") notify(); };
62
+ win.addEventListener("storage", onStorage);
63
+ win.addEventListener("pageshow", onPageShow);
64
+ win.addEventListener("popstate", onNavigate);
65
+ win.addEventListener("hashchange", onNavigate);
66
+ media.addEventListener("change", onMedia);
67
+ return {
68
+ getChoice: () => choice,
69
+ getResolvedTheme: resolvedTheme,
70
+ setChoice(next) {
71
+ if (!valid(next)) throw new TypeError("Theme choice must be light, dark, or system");
72
+ choice = next;
73
+ try { win.localStorage.setItem(THEME_STORAGE_KEY, next); } catch { /* Current tab still works. */ }
74
+ reconcile(); notify();
75
+ },
76
+ reconcile,
77
+ subscribe(listener) { listeners.add(listener); listener(choice, resolvedTheme()); return () => listeners.delete(listener); },
78
+ stop() {
79
+ rootObserver.disconnect();
80
+ documentObserver.disconnect();
81
+ win.removeEventListener("storage", onStorage);
82
+ win.removeEventListener("pageshow", onPageShow);
83
+ win.removeEventListener("popstate", onNavigate);
84
+ win.removeEventListener("hashchange", onNavigate);
85
+ media.removeEventListener("change", onMedia);
86
+ listeners.clear();
87
+ },
88
+ };
89
+ }
90
+ `;
91
+ return {
92
+ [`${path}/src/theme-bootstrap.js`]: bootstrap,
93
+ [`${path}/src/theme-choice.js`]: controller,
94
+ [`${path}/src/theme-choice.d.ts`]: `/* GENERATED BY GYDE — then yours.
95
+ * Gyde wrote this file once and will never overwrite it.
96
+ */
97
+ export type ThemeChoice = "light" | "dark" | "system";
98
+ export type ResolvedTheme = "light" | "dark";
99
+ export declare const THEME_STORAGE_KEY: string;
100
+ export interface ThemeChoiceController {
101
+ getChoice(): ThemeChoice;
102
+ getResolvedTheme(): ResolvedTheme;
103
+ setChoice(next: ThemeChoice): void;
104
+ reconcile(): void;
105
+ subscribe(listener: (choice: ThemeChoice, resolved: ResolvedTheme) => void): () => void;
106
+ stop(): void;
107
+ }
108
+ export declare function startThemeChoice(): ThemeChoiceController;
109
+ `,
110
+ };
111
+ }
package/tokens.mjs CHANGED
@@ -252,10 +252,12 @@ export function generateCss(dict = SEED) {
252
252
  "/* An explicit choice, on any element — which is what makes both themes",
253
253
  " renderable on one page, and what a portalled popup looks up. */",
254
254
  '[data-theme="dark"] {',
255
+ ' color-scheme: dark;',
255
256
  decls("dark"),
256
257
  "}",
257
258
  "",
258
259
  '[data-theme="light"] {',
260
+ ' color-scheme: light;',
259
261
  decls("light"),
260
262
  "}",
261
263
  "",
package/wiring.mjs CHANGED
@@ -28,7 +28,24 @@ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
28
28
  import { join, extname, relative, sep } from "node:path";
29
29
 
30
30
  const SKIP = new Set(["node_modules", "dist", "build", ".next", ".turbo", ".git", "coverage"]);
31
- const SOURCE = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".css", ".scss"]);
31
+ /**
32
+ * G-126. `.astro`, `.vue` and `.svelte` are import sites too.
33
+ *
34
+ * Measured 2026-09-15 on a consumer repository: `apps/web` was
35
+ * reported as "renders design-system components but never imports the token
36
+ * stylesheet" while `src/layouts/Base.astro` imported the stylesheet on line 2
37
+ * and the `:root` block was inlined into all four built pages. The walk simply
38
+ * never opened the file, so the import could not be seen.
39
+ *
40
+ * It is the worst direction to be wrong in for the second time in this file: a
41
+ * check that fails a correctly-wired repository gets switched off, and then it
42
+ * is not protecting the ones that ARE broken. The single-file-component formats
43
+ * put their imports in a frontmatter or `<script>` block rather than at the top
44
+ * of a `.js`, and everything below reads file TEXT rather than a parse tree, so
45
+ * recognising the extension is the whole fix — the `import "...css"` line looks
46
+ * identical once the file is read.
47
+ */
48
+ const SOURCE = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".css", ".scss", ".astro", ".vue", ".svelte"]);
32
49
 
33
50
  export const WIRING = {
34
51
  OK: "wired",
@@ -125,10 +142,48 @@ export function exportedComponents(root, systemPath) {
125
142
  * Depth is bounded and cycles are tracked, because a stylesheet importing
126
143
  * itself should be a finding somewhere else, not a hang here.
127
144
  */
128
- function reachesTokens(root, startFile, { packageDirs, tokensPackage, seen = new Set(), depth = 0 }) {
145
+ function reachesTokens(root, startFile, { packageDirs, tokensPackage, seen = new Set(), depth = 0, viaPackageSpecifier = false }) {
129
146
  if (depth > 8 || seen.has(startFile)) return false;
130
147
  seen.add(startFile);
131
148
 
149
+ /**
150
+ * G-126. The file we have arrived at IS the token stylesheet.
151
+ *
152
+ * Without this, a consumer that imports the tokens DIRECTLY — the simplest
153
+ * correct arrangement — reports unwired whenever `tokensPackage` is unset,
154
+ * because every branch below looks for an import OF the tokens rather than
155
+ * for the tokens themselves. A repository with no `gyde.config.json` has
156
+ * `tokensPackage` null, which is most of them.
157
+ *
158
+ * `viaPackageSpecifier` is the whole safety of it, and the first version of
159
+ * this change did not have it. A bare basename test fires on any path that
160
+ * ENDS in `tokens.css`, and two of the three ways into this function arrive
161
+ * with a path nobody resolved: `checkPackage` passes each walked file itself,
162
+ * and it passes a relative `./tokens.css` target with no `existsSync` behind
163
+ * it. The test then returned before the `readFileSync` that had been doing
164
+ * the existence check by accident. Four arrangements reported `wired` that
165
+ * are not: a stray unimported `apps/web/src/tokens.css`; a file importing an
166
+ * unrelated local `./tokens.css`; a file importing a `./tokens.css` that does
167
+ * not exist at all; and the stray-file case with `tokensPackage` configured,
168
+ * which had nothing to do with frontmatter and would have been a plain
169
+ * regression.
170
+ *
171
+ * So the shortcut fires only when we got here by resolving a workspace
172
+ * package specifier to a file that exists — the two `packageDirs` sites,
173
+ * which are the only callers that check. `depth` cannot stand in for this:
174
+ * `checkPackage`'s package-specifier loop calls at depth 0.
175
+ *
176
+ * And it declines entirely when `tokensPackage` IS configured, because then
177
+ * it can only ever be wrong. The two branches above already answer the
178
+ * question exactly for a configured repository — they compare against the
179
+ * package the config names — so the shortcut adds no correct verdict and one
180
+ * incorrect one: `import "@x/charts/src/tokens.css"`, an unrelated workspace
181
+ * package that happens to ship a file of that name, resolved to a real path
182
+ * and reported as wiring. Narrowing by HOW we arrived was not enough; the
183
+ * basename is only evidence at all when nothing better is available.
184
+ */
185
+ if (viaPackageSpecifier && !tokensPackage && /(^|\/)tokens\.css$/.test(startFile)) return true;
186
+
132
187
  let text; try { text = readFileSync(join(root, startFile), "utf8"); } catch { return false; }
133
188
  if (tokensPackage && text.includes(`${tokensPackage}/`) && /tokens\.css/.test(text)) return true;
134
189
 
@@ -142,7 +197,7 @@ function reachesTokens(root, startFile, { packageDirs, tokensPackage, seen = new
142
197
  const sub = spec.slice(name.length + 1);
143
198
  for (const guess of [join(dir, sub), join(dir, "src", sub)]) {
144
199
  if (existsSync(join(root, guess))) {
145
- if (reachesTokens(root, guess, { packageDirs, tokensPackage, seen, depth: depth + 1 })) return true;
200
+ if (reachesTokens(root, guess, { packageDirs, tokensPackage, seen, depth: depth + 1, viaPackageSpecifier: true })) return true;
146
201
  }
147
202
  }
148
203
  }
@@ -185,7 +240,7 @@ export function checkPackage(root, pkg, { systemPackage, tokensPackage, componen
185
240
  if (!spec.startsWith(name + "/")) continue;
186
241
  const sub = spec.slice(name.length + 1);
187
242
  for (const guess of [join(dir, sub), join(dir, "src", sub)]) {
188
- if (existsSync(join(root, guess)) && reachesTokens(root, guess, { packageDirs, tokensPackage })) importsTokens = true;
243
+ if (existsSync(join(root, guess)) && reachesTokens(root, guess, { packageDirs, tokensPackage, viaPackageSpecifier: true })) importsTokens = true;
189
244
  }
190
245
  }
191
246
  }
package/workflow.mjs CHANGED
@@ -127,7 +127,7 @@ export function emitWorkflow({
127
127
  // rewrote this to "System A/gyde@v1" and two tests caught it — an emitted
128
128
  // workflow pointing at an organisation that does not exist. The org and one
129
129
  // of the products genuinely share a name; only one of the two uses is data.
130
- action = "Another-Iteration/gyde@v1",
130
+ action = "Weatherboard-Studio/gyde@v1",
131
131
  /**
132
132
  * Blocking by default.
133
133
  *