@reqvire-org/ads 0.3.0 → 0.3.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.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/dist/bootstrap-ds/ADS.md +47 -7
  3. package/dist/bootstrap-ds/README.md +12 -10
  4. package/dist/bootstrap-ds/design-system/index.ts +1 -1
  5. package/dist/bootstrap-ds/design-system/patterns/token-preview/Scenario.tsx +4 -0
  6. package/dist/bootstrap-ds/design-system/patterns/token-preview/TokenPreview.tsx +31 -0
  7. package/dist/bootstrap-ds/design-system/patterns/token-preview/fixture.ts +8 -0
  8. package/dist/bootstrap-ds/design-system/tokens/Foundation.fixture.ts +44 -43
  9. package/dist/bootstrap-ds/design-system/tokens/Foundation.scenario.tsx +3 -13
  10. package/dist/bootstrap-ds/design-system/tokens/base.css +4 -1
  11. package/dist/bootstrap-ds/design-system/tokens/colors.css +2 -2
  12. package/dist/bootstrap-ds/ds.config.json +10 -0
  13. package/dist/check.mjs +29 -13
  14. package/dist/contracts/foundation.json +211 -0
  15. package/dist/foundation.mjs +114 -0
  16. package/dist/init.mjs +22 -5
  17. package/dist/mcp/apps.mjs +2 -2
  18. package/dist/mcp/build-status.mjs +22 -9
  19. package/dist/mcp/context.mjs +1 -1
  20. package/dist/mcp/preview-watch.mjs +35 -12
  21. package/dist/mcp/preview-widget.bundle.js +40 -38
  22. package/dist/mcp/project-widget.bundle.js +42 -38
  23. package/dist/mcp/prompts.mjs +1 -1
  24. package/dist/mcp/server.mjs +5 -5
  25. package/dist/preview-resources.mjs +1 -1
  26. package/dist/scenarios-compiler.mjs +86 -43
  27. package/dist/scenarios.mjs +16 -5
  28. package/dist/skills/ads-assimilate/SKILL.md +47 -12
  29. package/dist/skills/ads-assimilate/references/cli.md +3 -2
  30. package/dist/skills/ads-break-ui/SKILL.md +47 -12
  31. package/dist/skills/ads-break-ui/references/verification.md +4 -0
  32. package/dist/skills/ads-designer/SKILL.md +49 -14
  33. package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +47 -7
  34. package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +12 -10
  35. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/index.ts +1 -1
  36. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/patterns/token-preview/Scenario.tsx +4 -0
  37. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/patterns/token-preview/TokenPreview.tsx +31 -0
  38. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/patterns/token-preview/fixture.ts +8 -0
  39. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/Foundation.fixture.ts +44 -43
  40. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/Foundation.scenario.tsx +3 -13
  41. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/base.css +4 -1
  42. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/colors.css +2 -2
  43. package/dist/skills/ads-designer/assets/bootstrap-ds/ds.config.json +10 -0
  44. package/dist/skills/ads-designer/references/cli.md +3 -2
  45. package/dist/skills/ads-import/SKILL.md +47 -12
  46. package/dist/skills/ads-import/references/cli.md +3 -2
  47. package/dist/skills/ads-motion/SKILL.md +47 -12
  48. package/dist/token-policy.mjs +12 -0
  49. package/dist/token-samples.mjs +8 -0
  50. package/dist/units.mjs +34 -10
  51. package/docs/ADS.md +52 -9
  52. package/docs/ARCHITECTURE.md +44 -3
  53. package/docs/CHANGELOG.md +32 -1
  54. package/docs/GUIDE.md +17 -6
  55. package/docs/MCP.md +62 -9
  56. package/package.json +3 -2
@@ -0,0 +1,211 @@
1
+ {
2
+ "document": [
3
+ "color",
4
+ "background",
5
+ "background-color",
6
+ "font",
7
+ "font-family",
8
+ "font-size",
9
+ "font-weight",
10
+ "font-style",
11
+ "font-stretch",
12
+ "line-height",
13
+ "letter-spacing",
14
+ "word-spacing",
15
+ "text-rendering",
16
+ "font-synthesis",
17
+ "font-kerning",
18
+ "font-feature-settings",
19
+ "font-variation-settings",
20
+ "font-optical-sizing",
21
+ "-webkit-font-smoothing",
22
+ "-moz-osx-font-smoothing",
23
+ "-webkit-text-size-adjust",
24
+ "text-size-adjust",
25
+ "color-scheme",
26
+ "scrollbar-color",
27
+ "scrollbar-width"
28
+ ],
29
+ "reset": {
30
+ "box-sizing": [
31
+ "border-box",
32
+ "content-box"
33
+ ],
34
+ "font": [
35
+ "inherit"
36
+ ],
37
+ "font-family": [
38
+ "inherit"
39
+ ],
40
+ "font-size": [
41
+ "inherit",
42
+ "100%"
43
+ ],
44
+ "font-weight": [
45
+ "inherit",
46
+ "bolder",
47
+ "lighter"
48
+ ],
49
+ "font-style": [
50
+ "inherit",
51
+ "normal"
52
+ ],
53
+ "line-height": [
54
+ "inherit",
55
+ "normal"
56
+ ],
57
+ "letter-spacing": [
58
+ "inherit",
59
+ "normal"
60
+ ],
61
+ "word-spacing": [
62
+ "inherit",
63
+ "normal"
64
+ ],
65
+ "text-align": [
66
+ "inherit",
67
+ "start"
68
+ ],
69
+ "text-transform": [
70
+ "inherit",
71
+ "none"
72
+ ],
73
+ "text-decoration": [
74
+ "inherit",
75
+ "none"
76
+ ],
77
+ "text-indent": [
78
+ "0"
79
+ ],
80
+ "color": [
81
+ "inherit",
82
+ "currentcolor"
83
+ ],
84
+ "background": [
85
+ "transparent",
86
+ "none"
87
+ ],
88
+ "background-color": [
89
+ "transparent"
90
+ ],
91
+ "border": [
92
+ "0",
93
+ "none"
94
+ ],
95
+ "border-width": [
96
+ "0"
97
+ ],
98
+ "border-style": [
99
+ "none"
100
+ ],
101
+ "border-color": [
102
+ "inherit",
103
+ "currentcolor",
104
+ "transparent"
105
+ ],
106
+ "border-radius": [
107
+ "0"
108
+ ],
109
+ "border-collapse": [
110
+ "collapse",
111
+ "separate"
112
+ ],
113
+ "border-spacing": [
114
+ "0"
115
+ ],
116
+ "list-style": [
117
+ "none"
118
+ ],
119
+ "list-style-type": [
120
+ "none"
121
+ ],
122
+ "list-style-image": [
123
+ "none"
124
+ ],
125
+ "vertical-align": [
126
+ "baseline",
127
+ "middle"
128
+ ],
129
+ "box-shadow": [
130
+ "none"
131
+ ],
132
+ "text-shadow": [
133
+ "none"
134
+ ],
135
+ "appearance": [
136
+ "none",
137
+ "auto"
138
+ ],
139
+ "-webkit-appearance": [
140
+ "none",
141
+ "auto"
142
+ ],
143
+ "overflow": [
144
+ "visible"
145
+ ],
146
+ "cursor": [
147
+ "inherit",
148
+ "default"
149
+ ],
150
+ "-webkit-tap-highlight-color": [
151
+ "transparent"
152
+ ]
153
+ },
154
+ "replacedElements": [
155
+ "img",
156
+ "svg",
157
+ "video",
158
+ "canvas",
159
+ "audio",
160
+ "iframe",
161
+ "embed",
162
+ "object"
163
+ ],
164
+ "replacedReset": {
165
+ "display": [
166
+ "block",
167
+ "inline-block"
168
+ ],
169
+ "max-width": [
170
+ "100%"
171
+ ],
172
+ "width": [
173
+ "auto"
174
+ ],
175
+ "height": [
176
+ "auto"
177
+ ]
178
+ },
179
+ "focus": [
180
+ "outline",
181
+ "outline-width",
182
+ "outline-style",
183
+ "outline-color",
184
+ "outline-offset"
185
+ ],
186
+ "reducedMotion": [
187
+ "animation-duration",
188
+ "animation-delay",
189
+ "animation-iteration-count",
190
+ "transition-duration",
191
+ "transition-delay",
192
+ "scroll-behavior"
193
+ ],
194
+ "selection": [
195
+ "color",
196
+ "background",
197
+ "background-color"
198
+ ],
199
+ "scrollbar": [
200
+ "width",
201
+ "height",
202
+ "background",
203
+ "background-color",
204
+ "background-clip",
205
+ "border",
206
+ "border-width",
207
+ "border-style",
208
+ "border-color",
209
+ "border-radius"
210
+ ]
211
+ }
@@ -0,0 +1,114 @@
1
+ import selectorParser from "postcss-selector-parser";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ // CSS-language policy, not a project token catalogue or renderer classification.
5
+ const policy = JSON.parse(readFileSync(new URL("./contracts/foundation.json", import.meta.url), "utf8"));
6
+ const cssWide = /^(?:inherit|initial|unset|revert|revert-layer)$/i;
7
+
8
+ function rootAnchor(nodes) {
9
+ return nodes.some(node => node.type === "tag" && !node.namespace && node.value.toLowerCase() === "html"
10
+ || node.type === "pseudo" && node.value.toLowerCase() === ":root"
11
+ || node.type === "pseudo" && /^:(?:is|where)$/i.test(node.value) && node.nodes.length && node.nodes.every(selector => !selector.nodes.some(node => node.type === "combinator") && rootAnchor(selector.nodes)));
12
+ }
13
+
14
+ function rootCompound(node) {
15
+ for (let current = node; current.parent; current = current.parent) {
16
+ const parent = current.parent;
17
+ if (parent.type !== "selector") continue;
18
+ const index = parent.nodes.indexOf(current);
19
+ let start = index, end = index + 1;
20
+ while (start && parent.nodes[start - 1].type !== "combinator") --start;
21
+ while (end < parent.nodes.length && parent.nodes[end].type !== "combinator") ++end;
22
+ // A negative/relational argument cannot establish root context, but an
23
+ // explicit root outside it still can: :root:not([data-theme=light]).
24
+ const argument = parent.parent?.type === "pseudo" && !/^:(?:is|where)$/i.test(parent.parent.value);
25
+ if (!argument && rootAnchor(parent.nodes.slice(start, end))) return true;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /** Global defaults may address native elements and explicit document themes, not UI owners. */
31
+ export function foundationSelector(selector, { definitions = false } = {}) {
32
+ try {
33
+ let violation = false;
34
+ selectorParser(root => {
35
+ if (definitions && root.nodes.some(selector => selector.nodes.some(node => node.type === "combinator") || !rootAnchor(selector.nodes))) violation = true;
36
+ root.walk(node => {
37
+ if (["class", "id"].includes(node.type) && !rootCompound(node)) violation = true;
38
+ if (node.type === "tag" && node.value.includes("-")) violation = true;
39
+ if (node.type === "attribute" && /^(?:class|id|style|data-.+|aria-.+)$/i.test(node.attribute) && !rootCompound(node)) violation = true;
40
+ if (node.type === "pseudo" && node.value.toLowerCase() === ":has") violation = true;
41
+ if (node.type === "combinator") {
42
+ const before = node.parent.nodes.slice(0, node.parent.nodes.indexOf(node));
43
+ if (before.some(node => node.type === "combinator") || !rootAnchor(before)) violation = true;
44
+ }
45
+ });
46
+ }).processSync(selector);
47
+ return violation ? "Foundation defaults address native elements or explicit document-root themes. Component/pattern hooks, contextual layout and relational state selectors belong to their Linaria owner." : null;
48
+ } catch (error) { return `Invalid foundation selector: ${error.message}`; }
49
+ }
50
+
51
+ function subjects(nodes) {
52
+ let variants = [[]];
53
+ for (const node of nodes) {
54
+ if (node.type === "pseudo" && /^:(?:where|is)$/i.test(node.value)) {
55
+ variants = variants.flatMap(prefix => node.nodes.flatMap(selector => subjects(selector.nodes).map(subject => [...prefix, ...subject])));
56
+ } else variants.forEach(subject => subject.push(node));
57
+ }
58
+ return variants;
59
+ }
60
+
61
+ function reducedMotion(rule) {
62
+ for (let parent = rule.parent; parent; parent = parent.parent) {
63
+ if (parent.type === "atrule" && parent.name.toLowerCase() === "media"
64
+ && /^\(\s*prefers-reduced-motion\s*:\s*reduce\s*\)$/i.test(parent.params.trim())) return true;
65
+ }
66
+ return false;
67
+ }
68
+
69
+ function reset(property, value, nodes) {
70
+ const words = value.toLowerCase().split(/\s+/);
71
+ if (/^(?:margin|padding)(?:-(?:top|right|bottom|left|block|inline)(?:-(?:start|end))?)?$/.test(property)) return words.every(word => word === "0") || cssWide.test(value);
72
+ const tag = nodes.find(node => node.type === "tag")?.value.toLowerCase();
73
+ if (property === "font-weight" && /^(?:bolder|lighter)$/.test(value)) return ["b", "strong"].includes(tag);
74
+ if (policy.reset[property]) return policy.reset[property].includes(value.toLowerCase()) || cssWide.test(value);
75
+ if (policy.replacedElements.includes(tag) && policy.replacedReset[property]) return policy.replacedReset[property].includes(value.toLowerCase()) || cssWide.test(value);
76
+ return tag === "textarea" && property === "resize" && /^(?:vertical|inherit|initial|unset|revert(?:-layer)?)$/.test(value);
77
+ }
78
+
79
+ function roleAllows(rule, nodes, property, value) {
80
+ const pseudos = nodes.filter(node => node.type === "pseudo");
81
+ const tag = nodes.find(node => node.type === "tag")?.value.toLowerCase();
82
+ const universal = nodes.every(node => node.type === "universal" || node.type === "pseudo" || node.type === "comment");
83
+ if (universal && pseudos.some(node => node.value === ":focus-visible") && pseudos.every(node => node.value === ":focus-visible")) return policy.focus.includes(property) && !/^(?:none|0)$/.test(value);
84
+ if (universal && pseudos.some(node => node.value === "::selection") && pseudos.every(node => node.value === "::selection")) return policy.selection.includes(property);
85
+ if (universal && pseudos.every(node => ["::before", "::after"].includes(node.value)) && reducedMotion(rule)) {
86
+ if (policy.reducedMotion.includes(property)) return property !== "scroll-behavior" || value === "auto";
87
+ if (["animation", "transition"].includes(property) && value === "none") return true;
88
+ }
89
+ const document = (rootAnchor(nodes) || tag === "body") && pseudos.every(node => [":root", ":not"].includes(node.value));
90
+ if (document && policy.document.includes(property)) return true;
91
+ const documentScrollbar = (rootAnchor(nodes) || tag === "body") && pseudos.some(node => /^::-webkit-scrollbar(?:-thumb|-track)?$/.test(node.value))
92
+ && pseudos.every(node => node.value === ":root" || /^::-webkit-scrollbar(?:-thumb|-track)?$/.test(node.value) || node.value === ":hover");
93
+ if (documentScrollbar) return policy.scrollbar.includes(property);
94
+ if (pseudos.some(node => node.value === "::placeholder") && pseudos.every(node => node.value === "::placeholder")) return property === "color" || property === "opacity" && value === "1";
95
+ if (pseudos.some(node => ![":root", ":not", "::before", "::after"].includes(node.value))) return false;
96
+ if (pseudos.some(node => node.value === ":not") && !rootAnchor(nodes)) return false;
97
+ return reset(property, value, nodes);
98
+ }
99
+
100
+ /** Closed foundation roles apply even when a native selector uses valid tokens. */
101
+ export function foundationDeclaration(rule, declaration) {
102
+ try {
103
+ const selectors = selectorParser().astSync(rule.selector);
104
+ const property = declaration.prop.toLowerCase(), value = declaration.value.trim();
105
+ for (const selector of selectors.nodes) {
106
+ const lastCombinator = selector.nodes.findLastIndex(node => node.type === "combinator");
107
+ for (let nodes of subjects(selector.nodes.slice(lastCombinator + 1))) {
108
+ nodes = nodes.slice(nodes.findLastIndex(node => node.type === "combinator") + 1);
109
+ if (!roleAllows(rule, nodes, property, value)) return "base.css permits document-root/body typography and canvas defaults, limited native normalization, global focus indicators and reduced-motion rules. Put control skins, layout and state styling in their Linaria owner.";
110
+ }
111
+ }
112
+ return null;
113
+ } catch (error) { return `Invalid foundation declaration context: ${error.message}`; }
114
+ }
package/dist/init.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, writeFileSync, chmodSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import ts from "typescript";
4
5
  import { isInside, loadConfig, sourceFiles, supportedFrameworks } from "./config.mjs";
5
6
  import { createLintConfig } from "./lint-config.mjs";
6
7
  import { foundationFiles } from "./token-policy.mjs";
@@ -152,10 +153,16 @@ export function initProject(directory = process.cwd(), { toolPackage, packageMan
152
153
  });
153
154
  if (!existingTokens.length) {
154
155
  for (const name of ["Foundation.scenario.tsx", "Foundation.fixture.ts"]) {
155
- let content = readFileSync(path.join(starter, "design-system/tokens", name), "utf8");
156
- if (selectedFramework === "preact" && name === "Foundation.scenario.tsx") content = content.replace('from "react"', 'from "preact/hooks"');
156
+ const content = readFileSync(path.join(starter, "design-system/tokens", name), "utf8").replaceAll('"@ds"', JSON.stringify(config.publicAlias));
157
157
  addMissing(path.join(tokenRoot, name), content);
158
158
  }
159
+ for (const name of ["TokenPreview.tsx", "Scenario.tsx", "fixture.ts"]) {
160
+ let content = readFileSync(path.join(starter, "design-system/patterns/token-preview", name), "utf8");
161
+ if (selectedFramework === "preact") content = content.replace('from "react"', 'from "preact/hooks"');
162
+ addMissing(path.join(dsRoot, "patterns/token-preview", name), content.replaceAll('"@ds"', JSON.stringify(config.publicAlias)));
163
+ }
164
+ const reviewFile = path.relative(root, path.join(dsRoot, "patterns/token-preview/TokenPreview.tsx")).replaceAll("\\", "/");
165
+ if (!config.boundaries.styleWrites.some(item => item.file === reviewFile)) config.boundaries.styleWrites.push({ file: reviewFile, properties: ["--ux-token-preview-value"] });
159
166
  }
160
167
  tokenFiles.sort((a, b) => foundationFiles.indexOf(path.basename(a)) - foundationFiles.indexOf(path.basename(b)) || a.localeCompare(b));
161
168
  const stylesFile = path.join(dsRoot, config.stylesEntry);
@@ -165,7 +172,17 @@ export function initProject(directory = process.cwd(), { toolPackage, packageMan
165
172
  };
166
173
  addMissing(stylesFile, tokenFiles.map((file) => `@import ${JSON.stringify(relativeImport(stylesFile, file))};`).join("\n") + "\n");
167
174
  const entryFile = path.join(dsRoot, config.publicEntry);
168
- addMissing(entryFile, `// Export your project's public components and types here.\nimport ${JSON.stringify(relativeImport(entryFile, stylesFile))};\nexport {};\n`);
175
+ const reviewExport = !existingTokens.length ? `export { TokenPreview } from ${JSON.stringify(relativeImport(entryFile, path.join(dsRoot, "patterns/token-preview/TokenPreview")))};\n` : "";
176
+ addMissing(entryFile, `// Export your project's public components and types here.\nimport ${JSON.stringify(relativeImport(entryFile, stylesFile))};\n${reviewExport}`);
177
+ if (reviewExport && existsSync(entryFile) && !readFileSync(entryFile, "utf8").includes("patterns/token-preview/TokenPreview")) {
178
+ let current = readFileSync(entryFile, "utf8");
179
+ if (/\bTokenPreview\b/.test(current)) throw new Error("TokenPreview is already owned by this project. Add a token review scenario using its public API before initializing the foundation.");
180
+ const ast = ts.createSourceFile(entryFile, current, ts.ScriptTarget.Latest, true);
181
+ for (const statement of [...ast.statements].reverse()) if (ts.isExportDeclaration(statement) && !statement.moduleSpecifier && statement.exportClause && ts.isNamedExports(statement.exportClause) && !statement.exportClause.elements.length) {
182
+ current = current.slice(0, statement.getStart(ast)) + current.slice(statement.end);
183
+ }
184
+ updates.set(entryFile, current + "\n" + reviewExport);
185
+ }
169
186
  for (const section of ["components", "patterns", "composition"]) addMissing(path.join(dsRoot, section, ".gitkeep"), "");
170
187
  addMissing(path.join(dsRoot, "assets/brand/README.md"), readFileSync(path.join(starter, "design-system/assets/brand/README.md")));
171
188
  addMissing(path.join(dsRoot, "assets/fonts/.gitkeep"), "");
@@ -175,9 +192,9 @@ export function initProject(directory = process.cwd(), { toolPackage, packageMan
175
192
  else {
176
193
  const original = readFileSync(configFile, "utf8");
177
194
  const saved = JSON.parse(original);
178
- if (saved.framework === undefined) {
195
+ if (saved.framework === undefined || !existingTokens.length) {
179
196
  const indent = original.match(/\n([\t ]+)"/)?.[1] ?? " ";
180
- updates.set(configFile, `${JSON.stringify({ ...saved, framework: selectedFramework }, null, indent)}\n`);
197
+ updates.set(configFile, `${JSON.stringify({ ...saved, framework: selectedFramework, ...(!existingTokens.length ? { boundaries: config.boundaries } : {}) }, null, indent)}\n`);
181
198
  }
182
199
  }
183
200
  const lintConfigFile = path.join(root, ".ds/oxlintrc.json");
package/dist/mcp/apps.mjs CHANGED
@@ -12,7 +12,7 @@ const bridge = (project = false) => readFileSync(new URL(project ? "./project-wi
12
12
 
13
13
  export function previewApp(selection) {
14
14
  const script = readFileSync(new URL("./preview-widget.bundle.js", import.meta.url), "utf8").replaceAll("</script", "<\\/script");
15
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ADS preview</title><style>html,body,#root{height:100%;margin:0}body{overflow:hidden}#ads-preview-status{position:fixed;inset:12px 12px auto;z-index:2147483647;padding:12px;border:1px solid GrayText;background:Canvas;color:CanvasText;font:13px system-ui;white-space:pre-wrap}#ads-preview-status[hidden]{display:none}</style></head><body><aside id="ads-preview-status" role="status" hidden></aside><div id="root"></div>${selection ? `<script type="application/json" id="ads-preview-selection">${safeJson(selection)}</script>` : ""}<script type="module">${script}</script></body></html>`;
15
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ADS preview</title><style>html,body,#root{height:100%;margin:0}body{overflow:auto}#ads-preview-status{position:fixed;inset:12px 12px auto;z-index:2147483647;padding:12px;border:1px solid GrayText;background:Canvas;color:CanvasText;font:13px system-ui;white-space:pre-wrap}#ads-preview-status[hidden]{display:none}</style></head><body><aside id="ads-preview-status" role="status" hidden></aside><div id="root"></div>${selection ? `<script type="application/json" id="ads-preview-selection">${safeJson(selection)}</script>` : ""}<script type="module">${script}</script></body></html>`;
16
16
  }
17
17
 
18
18
  export function tokenWidgetData(inventory) {
@@ -44,7 +44,7 @@ export function projectApp(options, app, build) {
44
44
  if (statSync(file).size > 2 * 1024 * 1024) throw new Error("Preview exceeds 2 MiB. Build a focused component/pattern example.");
45
45
  const html = readFileSync(file, "utf8");
46
46
  if (!/<\/body\s*>/i.test(html) || /<script\b[^>]*\bsrc\s*=|<link\b[^>]*\brel\s*=\s*["']?stylesheet\b/i.test(html)) throw new Error("Preview must be built as self-contained HTML, with JavaScript and styles inlined.");
47
- const seed = { id: app.id, session: build?.session ?? null, revision: build?.current ? build.revision : null, state: build?.state ?? "unreported" };
47
+ const seed = { id: app.id, kind: app.kind, ...(app.samples ? { samples: app.samples } : {}), session: build?.session ?? null, revision: build?.current ? build.revision : null, state: build?.state ?? "unreported" };
48
48
  return html.replace(/<\/body\s*>/i, () => `<script type="application/json" id="ads-preview-seed">${safeJson(seed)}</script><script type="module">${bridge(true)}</script></body>`);
49
49
  }
50
50
 
@@ -8,9 +8,9 @@ import { readPreviewManifest } from "../preview-resources.mjs";
8
8
  export const BUILD_URI = "ads://project/build";
9
9
  export const buildStatusFile = (root) => path.join(root, ".ds/build-status.json");
10
10
  const maxBytes = 256 * 1024;
11
- const diagnostic = z.object({ message: z.string().max(4000), file: z.string().max(1000).optional(), line: z.number().int().positive().optional(), column: z.number().int().positive().optional(), plugin: z.string().max(200).optional() }).strict();
11
+ const diagnostic = z.object({ message: z.string().max(4000), file: z.string().max(1000).optional(), line: z.number().int().positive().optional(), column: z.number().int().positive().optional(), plugin: z.string().max(200).optional(), code: z.string().max(100).optional() }).strict();
12
12
  const preview = z.object({ id: z.string().max(256), state: z.enum(["idle", "building", "ready", "error"]), revision: z.number().int().nonnegative(), lastSuccessfulRevision: z.number().int().positive().nullable(), artifactDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable(), diagnostics: z.array(diagnostic).max(20), diagnosticSet: z.number().int().nonnegative().optional() }).strict();
13
- const snapshot = z.object({ schemaVersion: z.literal(1), session: z.string().regex(/^[\w-]{1,100}$/), sequence: z.number().int().nonnegative(), updatedAt: z.string().datetime(), previews: z.array(preview), diagnosticSets: z.array(z.array(diagnostic).max(20)).optional() }).strict().refine(value => value.previews.every(entry => entry.diagnosticSet === undefined || value.diagnosticSets?.[entry.diagnosticSet]), "Invalid shared diagnostic reference.");
13
+ const snapshot = z.object({ schemaVersion: z.literal(1), session: z.string().regex(/^[\w-]{1,100}$/), sequence: z.number().int().nonnegative(), updatedAt: z.string().datetime(), previews: z.array(preview), diagnostics: z.array(diagnostic).max(20).optional(), diagnosticSets: z.array(z.array(diagnostic).max(20)).optional() }).strict().refine(value => value.previews.every(entry => entry.diagnosticSet === undefined || value.diagnosticSets?.[entry.diagnosticSet]), "Invalid shared diagnostic reference.");
14
14
  export const artifactDigest = (file) => {
15
15
  if (realpathSync(file) !== file) throw new Error("Preview output cannot resolve through a symlink.");
16
16
  if (statSync(file).size > 2 * 1024 * 1024) throw new Error("Preview exceeds 2 MiB.");
@@ -26,7 +26,7 @@ function normalizeDiagnostic(root, error) {
26
26
  return { message: String(error?.message ?? error).slice(0, 4000),
27
27
  ...(file ? { file: (path.isAbsolute(file) ? path.relative(root, file) : file).replaceAll("\\", "/").slice(0, 1000) } : {}),
28
28
  ...(Number.isInteger(line) && line > 0 ? { line } : {}), ...(Number.isInteger(column) && column > 0 ? { column } : {}),
29
- ...(typeof error?.plugin === "string" ? { plugin: error.plugin.slice(0, 200) } : {}) };
29
+ ...(typeof error?.plugin === "string" ? { plugin: error.plugin.slice(0, 200) } : {}), ...(typeof error?.code === "string" ? { code: error.code.slice(0, 100) } : {}) };
30
30
  }
31
31
 
32
32
  /** One publisher per project compiler; no dependency on a UI framework. */
@@ -36,8 +36,9 @@ export function createBuildReporter(directory, { apps, session = process.env.ADS
36
36
  if (!isInside(root, target)) throw new Error("Build status must stay inside the project.");
37
37
  const entries = new Map(apps.map((app) => [app.id, { id: app.id, state: "idle", revision: 0, lastSuccessfulRevision: null, artifactDigest: null, diagnostics: [] }]));
38
38
  let sequence = 0;
39
+ let diagnostics = [];
39
40
  function save() {
40
- const value = { schemaVersion: 1, session, sequence: ++sequence, updatedAt: new Date().toISOString(), previews: [...entries.values()] };
41
+ const value = { schemaVersion: 1, session, sequence: ++sequence, updatedAt: new Date().toISOString(), previews: [...entries.values()], ...(diagnostics.length ? { diagnostics } : {}) };
41
42
  let text = JSON.stringify(snapshot.parse(value));
42
43
  // A shared graph failure applies to every scenario. Store repeated details
43
44
  // once only when necessary; ordinary reports retain inline diagnostics.
@@ -66,6 +67,17 @@ export function createBuildReporter(directory, { apps, session = process.env.ADS
66
67
  save();
67
68
  return {
68
69
  session,
70
+ failure(error) {
71
+ diagnostics = (Array.isArray(error) ? error : [error]).slice(0, 20).map(error => normalizeDiagnostic(root, error));
72
+ for (const value of entries.values()) { value.state = "error"; ++value.revision; value.diagnostics = []; }
73
+ save();
74
+ },
75
+ clearFailure() {
76
+ if (!diagnostics.length) return;
77
+ diagnostics = [];
78
+ for (const value of entries.values()) if (value.state === "error" && !value.diagnostics.length) value.state = "idle";
79
+ save();
80
+ },
69
81
  setApps(next) {
70
82
  const ids = new Set(next.map(app => app.id));
71
83
  for (const id of entries.keys()) if (!ids.has(id)) entries.delete(id);
@@ -104,11 +116,11 @@ export function createBuildReporter(directory, { apps, session = process.env.ADS
104
116
  }
105
117
 
106
118
  /** Read durable build events; a running process alone never means a fresh build. */
107
- export function inspectBuildStatus(root, config, { session, processState } = {}) {
119
+ export function inspectBuildStatus(root, config, { session, processState, file = buildStatusFile(root) } = {}) {
108
120
  let report;
109
121
  let reportError;
110
- const file = buildStatusFile(root);
111
122
  try {
123
+ if (!isInside(root, path.resolve(file))) throw new Error("Build status must stay inside the project.");
112
124
  if (existsSync(file)) {
113
125
  if (realpathSync(file) !== file || statSync(file).size > maxBytes) throw new Error("Build status must be a real project file under 256 KiB.");
114
126
  report = snapshot.parse(JSON.parse(readFileSync(file, "utf8")));
@@ -125,6 +137,7 @@ export function inspectBuildStatus(root, config, { session, processState } = {})
125
137
  const value = report?.previews.find((entry) => entry.id === app.id);
126
138
  let state = value?.state ?? "unreported";
127
139
  let diagnostics = value?.diagnosticSet !== undefined ? report.diagnosticSets[value.diagnosticSet] : value?.diagnostics ?? [];
140
+ if (report?.diagnostics?.length) { state = "error"; diagnostics = [...report.diagnostics, ...diagnostics].slice(0, 20); }
128
141
  const available = existsSync(path.resolve(root, app.file));
129
142
  if (reportError) { state = "error"; diagnostics = [{ message: reportError }]; }
130
143
  if (graphError && app.id.includes("/")) { state = "error"; diagnostics = [{ message: graphError }]; }
@@ -139,8 +152,8 @@ export function inspectBuildStatus(root, config, { session, processState } = {})
139
152
  return { id: app.id, state, session: report?.session ?? null, revision: value?.revision ?? null, lastSuccessfulRevision: value?.lastSuccessfulRevision ?? null,
140
153
  current, stale: state === "unreported" ? null : !current, available, diagnostics };
141
154
  });
142
- const state = previews.some((entry) => entry.state === "error") ? "error" : previews.some((entry) => entry.state === "building") ? "building" : previews.some((entry) => entry.state === "unreported") ? "unreported" : previews.some(entry => entry.current) ? "ready" : previews.length ? "idle" : "disabled";
143
- return { state, reported: Boolean(report), session: report?.session ?? null, sequence: report?.sequence ?? null, updatedAt: report?.updatedAt ?? null, previews, resourceUri: BUILD_URI };
155
+ const state = reportError || report?.diagnostics?.length || previews.some((entry) => entry.state === "error") ? "error" : previews.some((entry) => entry.state === "building") ? "building" : previews.some((entry) => entry.state === "unreported") ? "unreported" : previews.some(entry => entry.current) ? "ready" : previews.length ? "idle" : "disabled";
156
+ return { state, reported: Boolean(report), session: report?.session ?? null, sequence: report?.sequence ?? null, updatedAt: report?.updatedAt ?? null, previews, ...(report?.diagnostics?.length ? { diagnostics: report.diagnostics } : {}), resourceUri: BUILD_URI };
144
157
  }
145
158
 
146
159
  export class PreviewBuildError extends Error {
@@ -154,7 +167,7 @@ export function summarizeBuild(build) {
154
167
  return { ...value, diagnostics, diagnosticCount: value.diagnostics.length,
155
168
  diagnosticsTruncated: value.diagnostics.length > 3 || value.diagnostics.some((entry) => entry.message.length > 1000) };
156
169
  }
157
- return build.previews ? { ...build, previews: build.previews.map(bounded) } : { ...bounded(build), resourceUri: BUILD_URI };
170
+ return build.previews ? { ...build, ...(build.diagnostics ? bounded(build) : {}), previews: build.previews.map(bounded) } : { ...bounded(build), resourceUri: BUILD_URI };
158
171
  }
159
172
 
160
173
  export function buildFailure(build) {
@@ -56,7 +56,7 @@ Use this server for UI components/patterns, tokens/themes/branding, layout, inte
56
56
  3. Select one workflow with ads_skills, then ads_read_skill. ${route.skills.map((skill) => `${skill.task}: ${skill.name}`).join("; ")}. Native skill activation is host-controlled. Relative skill references resolve from its SKILL.md URI. Read support files only when relevant.
57
57
  4. ${route.preview} Token samples: ads_scenarios(kind: tokens), then ads_show_scenario or ads_show_tokens(category: id). Token source inspection: ads_tokens with a focused query. Registered previews: ${previews.map((preview) => `${preview.id}: ${preview.tool ?? "ads_show_scenario"}`).join(", ") || "none"}. Showcase URL: ${context.showcase.url ?? "not configured; use the project's showcase command"}.
58
58
  Inspection connects a public export to its owning source, available guides, token values and discovered preview IDs. Bounded tools keep discovery focused; explicit resource reads supply detail. Source metadata does not prove layout, interaction or motion. A scenario renders the real public implementation and its fixture context in either review surface.
59
- 5. Check ads_status.mutations. When enabled, discover one owner with ads_units, read relevant source ranges with ads_read_file and submit only a Git unified diff through ads_apply_patch({unit, patch, dryRun}). Include that owner's scenarios, fixtures and required public re-exports, never peer units. tokens is the foundation unit. Dry-run to inspect validation; apply only a passing patch. Fix rejected patches in the owning unit. Intentional project setup, registered guard/compiler code and exceptions use configuration; shared globals use foundation, guides documentation and assets their discovered assets/* unit. Keep mandatory ADS gates. Binary/large Git diffs may use patchFile under .ds/patches/ to avoid model-context bytes. In read-only mode, source edits use the client editor/filesystem tools. Reuse the local public API (${context.publicEntry}). Edit presentation in its ADS owner; keep application data/services/routing in application code and development fixtures beside their scenario; larger scenarios live in composition/. Linaria extracts owned styling. Inspect relevant tokens with ads_tokens and source/owning guides before edits.
59
+ 5. Check ads_status.mutations. When enabled, discover one owner with ads_units, read relevant source ranges with ads_read_file and submit only a Git unified diff through ads_apply_patch({unit, patch, dryRun}). Include that owner's scenarios, fixtures and required public re-exports, never peer units. tokens is the foundation unit; its manifest lists companionFiles for required canonical foundation imports when token files are created/deleted in the same patch. Preserve unrelated imports/comments and public-entry statements, including export {};. Dry-run to inspect validation; apply only a passing patch. Fix rejected patches in the owning unit; report unsupported coupled changes instead of bypassing rejection with direct writes. Intentional project setup, registered guard/compiler code and exceptions use configuration; shared globals use foundation, guides documentation and assets their discovered assets/* unit. Keep mandatory ADS gates. Binary/large Git diffs may use patchFile under .ds/patches/ to avoid model-context bytes. In read-only mode, source edits use the client editor/filesystem tools. Reuse the local public API (${context.publicEntry}). Edit presentation in its ADS owner; keep application data/services/routing in application code and development fixtures beside their scenario; larger scenarios live in composition/. Linaria extracts owned styling. Inspect relevant tokens with ads_tokens and source/owning guides before edits.
60
60
  6. ${route.validation} Registered commands: ${context.commands.map((command) => command.args?.join(" ") ?? `choose the project package manager for ${command.script}`).join("; ") || "none discovered; inspect the project's build setup"}. Run them from ${context.projectRoot}.
61
61
  7. After edits, inspect ads_status.build or ads://project/build explicitly. Running/unreported/old output is not a current successful preview. Fix diagnostics in owned source, wait for rebuilding and invoke the preview tool again. Notifications may not start an agent turn.
62
62
 
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { statSync, mkdirSync, writeFileSync, renameSync, rmSync, realpathSync } from "node:fs";
4
4
  import path from "node:path";
5
- import { buildFailure, buildStatusFile, inspectBuildStatus } from "./build-status.mjs";
5
+ import { buildFailure, inspectBuildStatus } from "./build-status.mjs";
6
6
 
7
7
  function stamp(file) {
8
8
  try { const stat = statSync(file); return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`; }
@@ -13,7 +13,7 @@ function stamp(file) {
13
13
  export function createPreviewWatch(root, config, { log = process.stderr, buildTimeout = 30000 } = {}) {
14
14
  config = { ...config, apps: [...config.apps] };
15
15
  const command = config.previewWatch;
16
- const session = randomUUID();
16
+ let session = randomUUID();
17
17
  let state = command ? "idle" : "disabled";
18
18
  let child;
19
19
  let error;
@@ -21,11 +21,14 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
21
21
  let childClosed = false;
22
22
  let killTimer;
23
23
  let stopping;
24
+ let restarting;
25
+ let disposed = false;
24
26
  const baselines = new Map();
25
27
  const ready = new Set();
26
28
  const requests = new Set();
27
29
  let active;
28
- const requestFile = path.join(root, ".ds", `preview-requests-${session}.json`);
30
+ let requestFile = path.join(root, ".ds", `preview-requests-${session}.json`);
31
+ let reportFile = path.join(root, ".ds", `build-status-${session}.json`);
29
32
  function request(file) {
30
33
  if (!command || requests.has(file) && active === file) return;
31
34
  requests.add(file);
@@ -48,7 +51,7 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
48
51
  } catch (failure) { if (failure.code !== "ESRCH") throw failure; }
49
52
  }
50
53
  function start() {
51
- if (state !== "idle") return;
54
+ if (disposed || state !== "idle") return;
52
55
  for (const app of config.apps) {
53
56
  const file = path.resolve(root, app.file);
54
57
  baselines.set(file, stamp(file));
@@ -57,7 +60,7 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
57
60
  // No shell: configured arguments remain literal. Child stdin cannot consume
58
61
  // the MCP channel, and both output streams go exclusively to diagnostics.
59
62
  const executable = command.command === "node" ? process.execPath : command.command;
60
- const env = { ...process.env, ADS_MCP_BUILD_SESSION: session, ADS_MCP_BUILD_STATUS: buildStatusFile(root), ADS_MCP_REQUEST_FILE: requestFile, PATH: [path.join(root, "node_modules/.bin"), path.dirname(process.execPath), process.env.PATH ?? ""].join(path.delimiter) };
63
+ const env = { ...process.env, ADS_MCP_BUILD_SESSION: session, ADS_MCP_BUILD_STATUS: reportFile, ADS_MCP_REQUEST_FILE: requestFile, PATH: [path.join(root, "node_modules/.bin"), path.dirname(process.execPath), process.env.PATH ?? ""].join(path.delimiter) };
61
64
  try {
62
65
  child = spawn(executable, command.args, { cwd: root, env, shell: false, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
63
66
  } catch (failure) {
@@ -76,7 +79,7 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
76
79
  clearTimeout(killTimer);
77
80
  if (state !== "stopping") {
78
81
  state = "failed";
79
- error ??= `Preview watcher exited (${signal ?? code}). Restart MCP after fixing the configured command.`;
82
+ error ??= `Preview watcher exited (${signal ?? code}). Fix the builder and invoke the preview again to restart it; MCP inspection remains available.`;
80
83
  log.write(`ads mcp: ${error}\n`);
81
84
  } else state = "stopped";
82
85
  resolve();
@@ -91,16 +94,36 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
91
94
  child.once("error", (failure) => { error = `Cannot start preview watcher: ${failure.message}`; });
92
95
  }
93
96
 
97
+ function restart() {
98
+ return restarting ??= (async () => {
99
+ await stopChild();
100
+ if (disposed) return;
101
+ session = randomUUID();
102
+ requestFile = path.join(root, ".ds", `preview-requests-${session}.json`);
103
+ reportFile = path.join(root, ".ds", `build-status-${session}.json`);
104
+ state = "idle"; error = undefined; child = closed = stopping = undefined; childClosed = false;
105
+ ready.clear(); baselines.clear(); active = undefined;
106
+ start();
107
+ })().finally(() => { restarting = undefined; });
108
+ }
109
+
94
110
  async function ensure(file, signal) {
111
+ if (signal?.aborted) throw new Error("Preview request cancelled.");
112
+ if (state === "failed" || restarting) await restart();
113
+ if (disposed) throw new Error("Preview watcher is stopped.");
95
114
  start();
96
115
  request(file);
97
116
  if (signal?.aborted) throw new Error("Preview request cancelled.");
98
117
  const app = config.apps.find((entry) => path.resolve(root, entry.file) === file);
99
- const baseline = baselines.get(file) ?? null;
118
+ let baseline = baselines.get(file) ?? null;
100
119
  const deadline = Date.now() + buildTimeout;
120
+ let restarted = false;
101
121
  while (Date.now() < deadline) {
102
122
  if (signal?.aborted) throw new Error("Preview request cancelled.");
103
123
  const build = buildStatus().previews.find((entry) => entry.id === app?.id);
124
+ if (!restarted && build?.diagnostics.some(item => item.code === "ADS_PREVIEW_RESTART")) {
125
+ restarted = true; await restart(); request(file); baseline = baselines.get(file) ?? null; continue;
126
+ }
104
127
  if (build?.state === "error") throw buildFailure(build);
105
128
  if (command && state !== "running") throw new Error(error ?? "Preview watcher is stopped.");
106
129
  if (build?.current) return build;
@@ -113,23 +136,23 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
113
136
  }
114
137
 
115
138
  const status = () => ({ state, ...(child?.pid ? { pid: child.pid } : {}), ...(error ? { error } : {}) });
116
- const buildStatus = () => inspectBuildStatus(root, config, command ? { session, processState: status() } : {});
139
+ const buildStatus = () => inspectBuildStatus(root, config, command ? { session, file: reportFile, processState: status() } : {});
117
140
 
118
- async function stop() {
141
+ async function stopChild() {
119
142
  rmSync(requestFile, { force: true });
120
143
  if (stopping) return stopping;
121
- if (!child || childClosed) { if (command) state = "stopped"; return; }
144
+ if (!child || childClosed) { if (command) state = "stopped"; rmSync(reportFile, { force: true }); return; }
122
145
  if (state === "stopped") return;
123
146
  state = "stopping";
124
147
  stopping = (async () => {
125
148
  kill("SIGTERM");
126
149
  clearTimeout(killTimer);
127
150
  const timer = setTimeout(() => kill("SIGKILL"), 1500);
128
- try { await closed; } finally { clearTimeout(timer); }
151
+ try { await closed; } finally { clearTimeout(timer); rmSync(reportFile, { force: true }); }
129
152
  })();
130
153
  return stopping;
131
154
  }
132
- return { start, ensure, stop, kill: () => kill("SIGKILL"), status, buildStatus, setApps(apps) {
155
+ return { start, ensure, async stop() { disposed = true; await restarting; return stopChild(); }, kill: () => kill("SIGKILL"), status, buildStatus, setApps(apps) {
133
156
  for (const app of apps) {
134
157
  const file = path.resolve(root, app.file);
135
158
  if (!baselines.has(file)) baselines.set(file, stamp(file));