@weatherboard/gyde-design 0.4.1 → 0.4.3

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/agentdocs.mjs CHANGED
@@ -122,6 +122,9 @@ export function agentDoc({
122
122
  * requirements rather than as facts about the implementation.
123
123
  */
124
124
  verified = true,
125
+ // An older Gyde-emitted set may predate the app theme controller. Its
126
+ // component metadata is verified, but this optional capability is absent.
127
+ themeChoice = verified,
125
128
  } = {}) {
126
129
  const closure = verified
127
130
  ? `**Never pass \`className\` or \`style\` to a design-system component.** They do not
@@ -191,7 +194,9 @@ brand — do **not** reach for inline styles. Spread \`themeVars(theme)\` onto a
191
194
  element you already own and set the component's tone to \`themed\`. It returns
192
195
  CSS custom properties only, which is why it is not an escape hatch.
193
196
 
194
- ## App theme choice
197
+ ` : ""}
198
+
199
+ ${themeChoice ? `## App theme choice
195
200
 
196
201
  The design-system package supplies \`src/theme-bootstrap.js\` and exports
197
202
  \`${systemPackage}/theme-choice\`. The product must wire both into its app shell:
@@ -209,6 +214,17 @@ The design-system package supplies \`src/theme-bootstrap.js\` and exports
209
214
  replacement and repairs external attribute changes. Keep local route theme
210
215
  overrides on inner containers so they do not overwrite the global choice.
211
216
 
217
+ Declare each browser host in \`gyde.config.json\` under \`design.themeHosts\`.
218
+ For a standard HTML entry, use
219
+ \`{ "appPath": "apps/web", "adapter": "html-inline", "html": "index.html", "entry": "src/main.js" }\`.
220
+ The HTML head must contain the exact emitted bootstrap as a synchronous inline
221
+ script before styles or external scripts, and load \`/src/main.js\` as a module.
222
+ That module must import and call \`startThemeChoice\` from
223
+ \`${systemPackage}/theme-choice\` immediately at module top level after that
224
+ import. Gyde's gate checks these files and refuses
225
+ an unsupported adapter; a framework-owned head needs its own adapter. Library
226
+ packages without a browser host leave \`themeHosts\` absent or empty.
227
+
212
228
  The storage key is scoped to this design-system package. \`system\` is an
213
229
  explicit stored choice that removes \`data-theme\` from the document root and
214
230
  lets \`prefers-color-scheme\` decide. Unavailable storage keeps the current tab
package/cli.mjs CHANGED
@@ -49,6 +49,7 @@ import { buildUsage, guidance, formatUsage } from "./usage.mjs";
49
49
  import { record, gate, formatGate, adopt, trim, formatTrim, LEDGER_NOTE } from "./ratchet.mjs";
50
50
  import { loadRules } from "./rules.mjs";
51
51
  import { validateScopeShape, checkScopeAgreement, formatScope } from "./scope.mjs";
52
+ import { checkThemeHosts, formatThemeHosts } from "./themehost.mjs";
52
53
 
53
54
  /**
54
55
  * The single source of what would be written.
@@ -688,6 +689,16 @@ function cmdGate(root, config, { recordNewRules = false, trimLedger = false } =
688
689
  return 1;
689
690
  }
690
691
 
692
+ // Host wiring is a binary runtime contract, never an allowance entry. Check
693
+ // before the first-baseline path so recording debt cannot turn a missing
694
+ // pre-paint script into a passing gate.
695
+ const themeHosts = checkThemeHosts(root, design, { packages: ws.packages });
696
+ console.log(formatThemeHosts(themeHosts));
697
+ if (themeHosts.errors.length) {
698
+ writeEvidence(root, null, { ok: false, why: "declared theme host wiring is incomplete" });
699
+ return 1;
700
+ }
701
+
691
702
  const roots = exportedComponents(root, systemPath);
692
703
  const result = scan(root, { config: { ...design, componentRoots: roots ? new Set(roots) : null } });
693
704
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weatherboard/gyde-design",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": false,
5
5
  "description": "Scaffolds a design system into a product repository, then keeps auditing it.",
6
6
  "type": "module",
@@ -0,0 +1,30 @@
1
+ /** Parse the linked JavaScript module before accepting theme startup wiring. */
2
+ import { parse } from "acorn";
3
+
4
+ const directCall = (node, binding) => node?.type === "CallExpression" &&
5
+ node.callee.type === "Identifier" && node.callee.name === binding &&
6
+ node.arguments.length === 0 && !node.optional;
7
+
8
+ /**
9
+ * Only an actual named import followed by a direct top-level call counts.
10
+ * Acorn's module AST excludes comments, strings, template literals, regex
11
+ * literals, branches and callbacks from this path. A syntax error is a failed
12
+ * static contract rather than a guess that source-looking text will execute.
13
+ */
14
+ export function hasThemeStartup(source, systemPackage) {
15
+ let program;
16
+ try { program = parse(source, { ecmaVersion: "latest", sourceType: "module" }); }
17
+ catch { return false; }
18
+ for (const [index, node] of program.body.entries()) {
19
+ if (node.type !== "ImportDeclaration" ||
20
+ node.source.value !== `${systemPackage}/theme-choice`) continue;
21
+ const binding = node.specifiers.find((part) => part.type === "ImportSpecifier" &&
22
+ part.imported.type === "Identifier" && part.imported.name === "startThemeChoice")?.local.name;
23
+ if (!binding) continue;
24
+ const next = program.body[index + 1];
25
+ if (next?.type === "ExpressionStatement" && directCall(next.expression, binding)) return true;
26
+ if (next?.type === "VariableDeclaration" && next.kind === "const" &&
27
+ next.declarations.length === 1 && directCall(next.declarations[0].init, binding)) return true;
28
+ }
29
+ return false;
30
+ }
package/themehost.mjs ADDED
@@ -0,0 +1,119 @@
1
+ /** Static host contract for app shells Gyde knows how to inspect. */
2
+ import { readFileSync, existsSync } from "node:fs";
3
+ import { join, resolve, relative, sep } from "node:path";
4
+ import { parse } from "parse5";
5
+ import { hasThemeStartup } from "./theme-entry.mjs";
6
+
7
+ const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
8
+
9
+ /**
10
+ * Read browser-parsed elements in a plain HTML entry. The parser handles
11
+ * raw text, RCDATA, and template contents as the browser does, so markup
12
+ * written inside them cannot masquerade as executable scripts.
13
+ */
14
+ function htmlElements(html) {
15
+ const nodes = [];
16
+ const visit = (node, parent = null) => {
17
+ if (node.tagName && node.namespaceURI === HTML_NAMESPACE) {
18
+ if (node.tagName === "noscript") return;
19
+ // An implied head is not evidence that the source declares one.
20
+ if (node.sourceCodeLocation) {
21
+ nodes.push({
22
+ name: node.tagName,
23
+ attrs: new Map(node.attrs.map(({ name, value }) => [name, value])),
24
+ parent,
25
+ inert: false,
26
+ index: node.sourceCodeLocation.startOffset,
27
+ text: node.childNodes?.filter((child) => child.nodeName === "#text")
28
+ .map((child) => child.value).join("") ?? "",
29
+ });
30
+ }
31
+ parent = node.tagName;
32
+ }
33
+ // parse5 stores template children in `content`, separate from childNodes.
34
+ // Do not visit them: their scripts are inert in the host document.
35
+ for (const child of node.childNodes ?? []) visit(child, parent);
36
+ };
37
+ visit(parse(html, { sourceCodeLocationInfo: true, scriptingEnabled: true }));
38
+ return nodes;
39
+ }
40
+
41
+ function within(root, path) {
42
+ if (typeof path !== "string" || !path || path.startsWith("/")) return null;
43
+ const full = resolve(root, path);
44
+ const rel = relative(root, full);
45
+ return rel && rel !== ".." && !rel.startsWith(`..${sep}`) ? full : null;
46
+ }
47
+
48
+ function read(root, path) {
49
+ const full = within(root, path);
50
+ if (!full || !existsSync(full)) return null;
51
+ try { return readFileSync(full, "utf8"); } catch { return null; }
52
+ }
53
+
54
+ /**
55
+ * `html-inline` is intentionally narrow: it proves concrete wiring in a
56
+ * standard HTML entry and its directly linked module. Framework layouts need
57
+ * their own adapter; a text marker in config is never accepted as evidence.
58
+ */
59
+ export function checkThemeHosts(root, design = {}, { packages = null } = {}) {
60
+ const declared = design.themeHosts;
61
+ if (declared === undefined) return { checked: 0, errors: [] };
62
+ if (!Array.isArray(declared)) return { checked: 0, errors: ["design.themeHosts must be an array"] };
63
+ const errors = [];
64
+ const systemPath = design.systemPath || "packages/design-system";
65
+ const systemPackage = design.systemPackage || `${design.scope}/design-system`;
66
+ const expected = read(root, `${systemPath}/src/theme-bootstrap.js`);
67
+ for (const [index, host] of declared.entries()) {
68
+ const label = `design.themeHosts[${index}]`;
69
+ if (!host || typeof host !== "object" || host.adapter !== "html-inline") {
70
+ errors.push(`${label}: unsupported adapter; this gate currently supports html-inline`);
71
+ continue;
72
+ }
73
+ const app = within(root, host.appPath);
74
+ if (!app || !existsSync(join(app, "package.json")) ||
75
+ (packages && !packages.some((p) => p.absolute === app))) {
76
+ errors.push(`${label}: appPath must name an existing workspace package`);
77
+ continue;
78
+ }
79
+ const html = read(app, host.html);
80
+ const entry = read(app, host.entry);
81
+ if (html === null) errors.push(`${label}: declared HTML file is missing or unreadable`);
82
+ if (entry === null) errors.push(`${label}: declared module entry is missing or unreadable`);
83
+ if (expected === null) errors.push(`${label}: emitted theme-bootstrap.js is missing or unreadable`);
84
+ if (html === null || entry === null || expected === null) continue;
85
+ const elements = htmlElements(html);
86
+ if (!elements.some((n) => n.name === "head" && !n.inert)) {
87
+ errors.push(`${label}: HTML has no head element`);
88
+ continue;
89
+ }
90
+ const headNodes = elements.filter((n) => n.parent === "head" && !n.inert);
91
+ const inline = headNodes.find((n) => n.name === "script" &&
92
+ !["src", "type", "defer", "async", "nomodule"].some((attr) => n.attrs.has(attr)) &&
93
+ n.text.trim() === expected.trim());
94
+ if (!inline) errors.push(`${label}: head must contain the exact emitted bootstrap as an inline script`);
95
+ else {
96
+ const earlyResource = headNodes.some((n) => n.index < inline.index &&
97
+ (n.name === "style" ||
98
+ (n.name === "link" && n.attrs.get("rel")?.toLowerCase().split(/\s+/).includes("stylesheet")) ||
99
+ (n.name === "script" && n.attrs.has("src"))));
100
+ if (earlyResource) {
101
+ errors.push(`${label}: bootstrap must precede styles and external scripts in head`);
102
+ }
103
+ }
104
+ const src = `/${host.entry?.replace(/^\/+/, "")}`;
105
+ const moduleTag = elements.some((n) => n.name === "script" && !n.inert &&
106
+ n.attrs.get("type")?.toLowerCase() === "module" && n.attrs.get("src") === src);
107
+ if (!moduleTag) errors.push(`${label}: HTML must load the declared entry as a module script`);
108
+ if (!hasThemeStartup(entry, systemPackage)) {
109
+ errors.push(`${label}: linked entry must import and immediately call startThemeChoice at module top level`);
110
+ }
111
+ }
112
+ return { checked: declared.length, errors };
113
+ }
114
+
115
+ export function formatThemeHosts(result) {
116
+ return result.errors.length
117
+ ? `theme hosts ${result.checked} declared, ${result.errors.length} error(s):\n${result.errors.map((e) => ` ${e}`).join("\n")}`
118
+ : `theme hosts ${result.checked} declared, checked`;
119
+ }