@reqvire-org/ads 0.3.5 → 0.3.7

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 (51) hide show
  1. package/README.md +5 -1
  2. package/dist/atomic-composition.mjs +183 -0
  3. package/dist/bootstrap-ds/ADS.md +9 -3
  4. package/dist/bootstrap-ds/README.md +10 -0
  5. package/dist/boundaries.mjs +3 -0
  6. package/dist/check.mjs +16 -0
  7. package/dist/cli.mjs +1 -1
  8. package/dist/config.mjs +6 -2
  9. package/dist/mcp/build-status.mjs +7 -4
  10. package/dist/mcp/context.mjs +2 -2
  11. package/dist/mcp/edit-preparation.mjs +111 -0
  12. package/dist/mcp/mutations.mjs +54 -38
  13. package/dist/mcp/preview-watch.mjs +10 -4
  14. package/dist/mcp/server.mjs +36 -24
  15. package/dist/mcp/text-diff.mjs +19 -0
  16. package/dist/preview-resources.mjs +45 -8
  17. package/dist/preview-scope.mjs +30 -0
  18. package/dist/public-api.mjs +37 -1
  19. package/dist/scenarios-compiler.mjs +24 -17
  20. package/dist/scenarios.mjs +58 -6
  21. package/dist/scoped-tokens.mjs +57 -0
  22. package/dist/skills/ads-break-ui/SKILL.md +10 -2
  23. package/dist/skills/ads-break-ui/references/atomic-styles.md +121 -0
  24. package/dist/skills/ads-break-ui/references/guarded-editing.md +44 -0
  25. package/dist/skills/ads-break-ui/references/verification.md +6 -0
  26. package/dist/skills/ads-designer/SKILL.md +10 -2
  27. package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +9 -3
  28. package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +10 -0
  29. package/dist/skills/ads-designer/references/atomic-styles.md +121 -0
  30. package/dist/skills/ads-designer/references/guarded-editing.md +44 -0
  31. package/dist/skills/ads-import/SKILL.md +10 -2
  32. package/dist/skills/ads-import/references/atomic-styles.md +121 -0
  33. package/dist/skills/ads-import/references/guarded-editing.md +44 -0
  34. package/dist/skills/ads-import/references/tokens.md +4 -2
  35. package/dist/skills/ads-migrate/SKILL.md +10 -2
  36. package/dist/skills/ads-migrate/references/atomic-styles.md +121 -0
  37. package/dist/skills/ads-migrate/references/guarded-editing.md +44 -0
  38. package/dist/skills/ads-motion/SKILL.md +10 -2
  39. package/dist/skills/ads-motion/references/atomic-styles.md +121 -0
  40. package/dist/skills/ads-motion/references/guarded-editing.md +44 -0
  41. package/dist/token-fixtures.mjs +30 -0
  42. package/dist/tokens.mjs +8 -2
  43. package/dist/units.mjs +15 -0
  44. package/dist/verification.mjs +1 -1
  45. package/docs/ADS.md +59 -2
  46. package/docs/ARCHITECTURE.md +27 -0
  47. package/docs/BEHAVIORS.md +20 -0
  48. package/docs/CHANGELOG.md +32 -0
  49. package/docs/GUIDE.md +8 -3
  50. package/docs/MCP.md +70 -5
  51. package/package.json +4 -2
@@ -2,6 +2,38 @@ import path from "node:path";
2
2
  import ts from "typescript";
3
3
  import { isDevelopmentFile, isInside } from "./config.mjs";
4
4
  import { uiOperation } from "./ui-framework.mjs";
5
+ import { projectCompilerOptions } from "./boundaries.mjs";
6
+
7
+ const resolvedCallables = new WeakMap();
8
+
9
+ // The syntax guard omits libraries and dependency types. Resolve an ambiguous
10
+ // public value lazily against the project's actual types, without importing it.
11
+ // Keep the result with this immutable program so repeated discovery shares it.
12
+ function resolvedCallable(program, options, name) {
13
+ if (!resolvedCallables.has(program)) {
14
+ const { dsRoot, config } = options;
15
+ const entryFile = path.join(dsRoot, config.publicEntry);
16
+ const compiler = projectCompilerOptions(options);
17
+ const semantic = ts.createProgram([entryFile], {
18
+ ...compiler, allowJs: true,
19
+ paths: { ...compiler.paths, [config.publicAlias]: [entryFile] },
20
+ types: compiler.types ?? [],
21
+ });
22
+ const checker = semantic.getTypeChecker();
23
+ const entry = semantic.getSourceFile(entryFile);
24
+ const module = entry && checker.getSymbolAtLocation(entry);
25
+ const callables = new Map();
26
+ for (const exported of module ? checker.getExportsOfModule(module) : []) {
27
+ const symbol = exported.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(exported) : exported;
28
+ const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0];
29
+ if (!declaration || !(symbol.flags & ts.SymbolFlags.Value)) continue;
30
+ const type = checker.getTypeOfSymbolAtLocation(symbol, declaration);
31
+ callables.set(exported.name, !!(type.getCallSignatures().length || type.getConstructSignatures().length));
32
+ }
33
+ resolvedCallables.set(program, callables);
34
+ }
35
+ return resolvedCallables.get(program).get(name) ?? false;
36
+ }
5
37
 
6
38
  export function publicApi(program, options) {
7
39
  const checker = program.getTypeChecker();
@@ -33,7 +65,11 @@ export function publicApi(program, options) {
33
65
  }
34
66
  visit(declaration);
35
67
  const type = checker.getTypeOfSymbolAtLocation(symbol, declaration);
36
- items.push({ name: exported.name, kind, file, declaration, renderable: kind !== "controllers" && renderable, behavioral: !!(type.getCallSignatures().length || type.getConstructSignatures().length) });
68
+ const behavioral = !!(type.getCallSignatures().length || type.getConstructSignatures().length
69
+ || !renderable && !!(type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))
70
+ && (program.getCompilerOptions().noLib || program.getCompilerOptions().noResolve)
71
+ && resolvedCallable(program, options, exported.name));
72
+ items.push({ name: exported.name, kind, file, declaration, renderable: kind !== "controllers" && renderable, behavioral });
37
73
  }
38
74
  return items;
39
75
  }
@@ -4,7 +4,8 @@ import path from "node:path";
4
4
  import { loadConfig, isInside } from "./config.mjs";
5
5
  import { inspectScenarios } from "./scenarios.mjs";
6
6
  import { artifactDigest, createBuildReporter } from "./mcp/build-status.mjs";
7
- import { readPreviewManifest } from "./preview-resources.mjs";
7
+ import { cachedPreviewPublication, previewArtifactHtml, readPreviewManifest } from "./preview-resources.mjs";
8
+ import { previewArtifactPath, withPreviewSession } from "./preview-scope.mjs";
8
9
 
9
10
  const digest = value => createHash("sha256").update(value).digest("hex");
10
11
  const fileDigest = file => { try { return digest(readFileSync(file)); } catch { return null; } };
@@ -13,7 +14,7 @@ const fileDigest = file => { try { return digest(readFileSync(file)); } catch {
13
14
  * Cache state is development output, never proof that a failed build is current. */
14
15
  export function createPreviewCache(options, compilerFiles = []) {
15
16
  const { root, config } = options;
16
- const adapterFiles = [import.meta.filename, path.join(import.meta.dirname, "preview-resources.mjs"), path.join(import.meta.dirname, "scenarios-vite.mjs"), ...compilerFiles,
17
+ const adapterFiles = [import.meta.filename, path.join(import.meta.dirname, "preview-resources.mjs"), path.join(import.meta.dirname, "preview-scope.mjs"), path.join(import.meta.dirname, "scenarios-vite.mjs"), ...compilerFiles,
17
18
  ...["package.json", "package-lock.json", "bun.lock", "bun.lockb", "node_modules/.package-lock.json"].map(file => path.join(root, file))];
18
19
  const adapterDigest = () => digest(JSON.stringify(adapterFiles.map(file => [file, fileDigest(file)])));
19
20
  const loadedAdapter = adapterDigest();
@@ -62,14 +63,17 @@ export function createPreviewCache(options, compilerFiles = []) {
62
63
  ["scheduler", fileDigest(import.meta.filename)],
63
64
  ["node-runtime", `${process.version}/${process.platform}/${process.arch}`],
64
65
  ]));
65
- function reusable(entry) {
66
+ function reusable(entry, expectedHtml) {
66
67
  const cached = value.entries[entry.id];
67
68
  if (!cached || cached.metadata !== JSON.stringify(entry) || cached.compiler !== compilerDigest()) return false;
68
69
  if (!cached.dependencies || !Object.keys(cached.dependencies).length) return false;
69
70
  if (Object.entries(cached.dependencies).some(([file, hash]) => fileDigest(path.resolve(root, file)) !== hash)) return false;
70
- try { return cached.artifact === artifactDigest(path.join(root, entry.artifact)); } catch { return false; }
71
+ if (expectedHtml !== undefined && cached.artifact !== `sha256:${digest(expectedHtml)}`) return false;
72
+ const file = path.join(root, previewArtifactPath(options, entry.artifact));
73
+ if (expectedHtml !== undefined && !existsSync(file)) return true;
74
+ try { return cached.artifact === artifactDigest(file); } catch { return false; }
71
75
  }
72
- return { inputs, reusable, validated: stamp => value.validation === stamp,
76
+ return { inputs, reusable, graphRevision: () => value.graphRevision, recordGraph(revision) { value.graphRevision = revision; save(); }, validated: stamp => value.validation === stamp,
73
77
  assertCompiler() {
74
78
  if (adapterDigest() !== loadedAdapter) throw Object.assign(new Error("Preview compiler code or dependencies changed. Invoke the preview again to restart the builder and load current code."), { code: "ADS_PREVIEW_RESTART" });
75
79
  },
@@ -78,7 +82,7 @@ export function createPreviewCache(options, compilerFiles = []) {
78
82
  value.entries[entry.id] = { metadata: JSON.stringify(entry), compiler: compilerDigest(),
79
83
  dependencies: Object.fromEntries([...new Set([path.join(root, entry.file), ...dependencies])]
80
84
  .filter(file => isInside(root, file) && !file.includes(`${path.sep}node_modules${path.sep}`)).map(file => [path.relative(root, file), fileDigest(file)])),
81
- artifact: artifactDigest(path.join(root, entry.artifact)) };
85
+ artifact: artifactDigest(path.join(root, previewArtifactPath(options, entry.artifact))) };
82
86
  save();
83
87
  },
84
88
  };
@@ -87,11 +91,11 @@ export function createPreviewCache(options, compilerFiles = []) {
87
91
  /** Shared scheduling and cache; the project supplies its own React/Preact build. */
88
92
  export async function runScenarioCompiler(directory, { watched = false, validate, build, buildGraph, compilerFiles = [] }) {
89
93
  const root = path.resolve(directory);
90
- let options = loadConfig(root);
94
+ let options = withPreviewSession(loadConfig(root), process.env.ADS_MCP_BUILD_SESSION);
91
95
  if (buildGraph && (!options.config.mcp.previewManifest || !options.config.framework)) throw new Error("Resource preview builds require an explicit framework and mcp.previewManifest in ds.config.json.");
92
96
  const cache = createPreviewCache(options, compilerFiles);
93
97
  function registry() {
94
- options = loadConfig(root);
98
+ options = withPreviewSession(loadConfig(root), process.env.ADS_MCP_BUILD_SESSION);
95
99
  const result = inspectScenarios(options);
96
100
  if (result.diagnostics.length) throw Object.assign(new Error(result.diagnostics.map(item => `${item.file}: ${item.message}`).join("\n")), { scenarios: result.scenarios });
97
101
  mkdirSync(path.join(root, ".ds/scenarios"), { recursive: true });
@@ -104,7 +108,8 @@ export async function runScenarioCompiler(directory, { watched = false, validate
104
108
  if (!watched) throw error;
105
109
  registryFailure = error; entries = error.scenarios ?? [];
106
110
  }
107
- const reporter = createBuildReporter(root, { apps: entries.map(entry => ({ id: entry.id, file: entry.artifact })) });
111
+ const buildApps = () => entries.map(entry => ({ id: entry.id, file: previewArtifactPath(options, entry.artifact) }));
112
+ const reporter = createBuildReporter(root, { apps: buildApps() });
108
113
  const requested = new Set();
109
114
  const pending = new Set();
110
115
  let active;
@@ -202,8 +207,12 @@ export async function runScenarioCompiler(directory, { watched = false, validate
202
207
  }
203
208
  } };
204
209
  cache.assertCompiler();
205
- let reusable = entries.every(entry => cache.reusable(entry));
206
- if (reusable) { try { reusable = Boolean(readPreviewManifest(options, { verify: true })); } catch { reusable = false; } }
210
+ const cached = cachedPreviewPublication(options, entries, cache.graphRevision());
211
+ let reusable = cached && entries.every(entry => cache.reusable(entry, previewArtifactHtml(entry.id, cached.revision)));
212
+ if (reusable) {
213
+ try { readPreviewManifest(options, { verify: true }); }
214
+ catch { reusable = false; }
215
+ }
207
216
  const outcomes = await checkedBuild(stamp, () => reusable ? null : buildGraph(entries, options, track));
208
217
  const failure = outcomes.find(outcome => outcome.status === "rejected");
209
218
  if (failure) throw failure.reason;
@@ -212,12 +221,10 @@ export async function runScenarioCompiler(directory, { watched = false, validate
212
221
  const publication = outcomes[1].value;
213
222
  if (publication) {
214
223
  publication.publish(reporter.session);
215
- for (const entry of entries) cache.record(entry, [...dependencies, ...publication.assetFiles]);
224
+ for (const entry of entries) cache.record(entry, dependencies);
225
+ cache.recordGraph(publication.revision);
216
226
  } else {
217
- const file = path.join(root, options.config.mcp.previewManifest);
218
- const manifest = readPreviewManifest(options);
219
- writeFileSync(`${file}.tmp`, JSON.stringify({ ...manifest, session: reporter.session }));
220
- renameSync(`${file}.tmp`, file);
227
+ cached.publish(reporter.session);
221
228
  }
222
229
  for (const entry of entries) { pending.delete(entry.id); reporter.ready(entry.id, revisions.get(entry.id)); }
223
230
  publishedStamp = stamp;
@@ -281,7 +288,7 @@ export async function runScenarioCompiler(directory, { watched = false, validate
281
288
  try {
282
289
  entries = registry();
283
290
  registryFailure = undefined;
284
- reporter.setApps(entries.map(entry => ({ id: entry.id, file: entry.artifact })));
291
+ reporter.setApps(buildApps());
285
292
  reporter.clearFailure();
286
293
  request(false);
287
294
  for (const id of requested) if (!entries.some(entry => entry.id === id)) { requested.delete(id); pending.delete(id); }
@@ -8,6 +8,7 @@ import { projectCompilerOptions } from "./boundaries.mjs";
8
8
  import { inspectTokens } from "./tokens.mjs";
9
9
  import { isSharedToken } from "./token-policy.mjs";
10
10
  import { validTokenSamples } from "./token-samples.mjs";
11
+ import { scopedTokens } from "./scoped-tokens.mjs";
11
12
 
12
13
  export const scenarioArtifact = (root, id) => path.join(root, ".ds/scenarios", id, "index.html");
13
14
 
@@ -39,14 +40,16 @@ export function inspectScenarios(options, suppliedProgram) {
39
40
  const known = new Map(publicUi(program, options).map((item) => [item.name, item]));
40
41
  const checker = program.getTypeChecker();
41
42
  const behaviorApis = new Map(publicApi(program, options).filter(item => !item.renderable && item.behavioral).map(item => [item.name, item]));
42
- const tokenNames = new Set(inspectTokens(root).tokens.map(token => token.name));
43
+ const tokenInventory = inspectTokens(root);
44
+ const tokenNames = new Set(tokenInventory.tokens.map(token => token.name));
45
+ const scoped = scopedTokens(options);
43
46
  const scenarios = [];
44
47
  const diagnostics = [];
45
48
  const ids = new Set();
46
49
  function report(file, message) { diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule: "scenario-contract", line: 1, column: 1, message }); }
47
50
  for (const file of files) {
48
51
  const relative = path.relative(dsRoot, file).replaceAll("\\", "/");
49
- const kind = isInside(path.join(dsRoot, options.config.tokenDir), file) ? "tokens" : relative.split("/")[0];
52
+ let kind = isInside(path.join(dsRoot, options.config.tokenDir), file) ? "tokens" : relative.split("/")[0];
50
53
  if (!["tokens", "components", "patterns", "composition"].includes(kind)) { report(file, "Scenarios live beside tokens/components/patterns or under composition/, not inside showcase pages."); continue; }
51
54
  const id = relative.replace(/(?:\/Scenario|\.scenario)\.[^.]+$/, "");
52
55
  if (id.length > 256) { report(file, "Scenario paths must fit within 256 characters."); continue; }
@@ -62,6 +65,11 @@ export function inspectScenarios(options, suppliedProgram) {
62
65
  for (const declaration of node.declarationList.declarations) if (ts.isIdentifier(declaration.name) && declaration.name.text === "scenario") metadata = literal(declaration.initializer, checker);
63
66
  }
64
67
  }
68
+ if (metadata?.kind !== undefined) {
69
+ const owner = scoped.inventory.owner(relative);
70
+ if (metadata.kind !== "tokens" || !scoped.definitions.some(entry => entry.unit === owner)) { report(file, "Only an owner with registered local tokens may set kind: tokens for a colocated token preview."); continue; }
71
+ kind = "tokens";
72
+ }
65
73
  if (!entry) report(file, "Export a default independently mountable scenario entrypoint.");
66
74
  const module = checker.getSymbolAtLocation(source);
67
75
  const defaultExport = module && checker.getExportsOfModule(module).find(item => item.name === "default");
@@ -77,17 +85,31 @@ export function inspectScenarios(options, suppliedProgram) {
77
85
  }
78
86
  const fixtureBase = path.basename(file).startsWith("Scenario.") ? path.join(path.dirname(file), "fixture") : file.replace(/\.scenario\.[^.]+$/, ".fixture");
79
87
  if (![".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs", ".json"].some(extension => existsSync(fixtureBase + extension))) report(file, "Keep this scenario's inputs beside it: fixture.ts (directory entry) or <Owner>.fixture.ts (flat entry). An empty fixture is valid for a component without inputs.");
80
- if (!metadata || Object.keys(metadata).some((key) => !["title", "covers", "exercises", "tokens", "samples"].includes(key)) || typeof metadata.title !== "string" || !metadata.title.trim() || !Array.isArray(metadata.covers) || !metadata.covers.length || metadata.covers.some((name) => typeof name !== "string" || !known.get(name)?.renderable)
81
- || Object.hasOwn(metadata, "exercises") && (!Array.isArray(metadata.exercises) || metadata.exercises.some(name => typeof name !== "string" || !behaviorApis.has(name)))
88
+ if (!metadata || Object.keys(metadata).some((key) => !["title", "kind", "covers", "exercises", "tokens", "samples"].includes(key)) || typeof metadata.title !== "string" || !metadata.title.trim() || !Array.isArray(metadata.covers) || !metadata.covers.length || metadata.covers.some((name) => typeof name !== "string" || !known.get(name)?.renderable)
82
89
  || Object.hasOwn(metadata, "tokens") && (!Array.isArray(metadata.tokens) || metadata.tokens.some(name => typeof name !== "string" || !name.startsWith("--")))
83
90
  || kind === "tokens" && (Object.hasOwn(metadata, "tokens") || !validTokenSamples(metadata.samples) || !metadata.covers.some(name => known.get(name)?.kind === "patterns"))
84
91
  || Object.hasOwn(metadata, "samples") && !validTokenSamples(metadata.samples)) {
85
92
  report(file, "Export static metadata with nonempty covers naming rendered public UI. Optional exercises names public behavioral APIs, not rendered UI. Token scenarios must render a project-owned public review pattern and declare samples: fixture.samples, a literal list of { token, property, probe } CSS samples. A plain value list or covers: [] is incomplete."); continue;
86
93
  }
94
+ // A bad behavioral entry must fail validation without erasing independent
95
+ // render evidence and manufacturing missing-coverage errors for real UI.
96
+ const exercises = [];
97
+ if (Object.hasOwn(metadata, "exercises")) {
98
+ if (!Array.isArray(metadata.exercises)) report(file, "Scenario exercises must be a static array naming public behavioral APIs.");
99
+ else for (const [index, name] of metadata.exercises.entries()) {
100
+ if (typeof name !== "string") report(file, `Scenario exercises[${index}] must name a public behavioral API, not ${JSON.stringify(name) ?? "an unresolved value"}.`);
101
+ else if (!behaviorApis.has(name)) report(file, `Scenario exercises[${index}] ${JSON.stringify(name)} is not a recognized public behavioral API. Use a public callable function, hook or controller; rendered UI belongs in covers. Discover the project's exports before declaring exercises.`);
102
+ else exercises.push(name);
103
+ }
104
+ }
87
105
  if (!["composition", "tokens"].includes(kind) && metadata.covers.some((name) => known.get(name).kind !== kind)) report(file, "Component scenarios cover primitives; pattern scenarios cover patterns. Use composition/ for larger interfaces.");
88
106
  const tokens = [...new Set([...(metadata.tokens ?? []), ...(metadata.samples ?? []).map(sample => sample.token)])];
89
- for (const name of tokens) if (!tokenNames.has(name)) diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule: "token-scenario-reference", line: 1, column: 1, message: `Scenario references undeclared token ${name}. Use a real foundation token.` });
90
- scenarios.push({ id, kind, title: metadata.title, covers: [...new Set(metadata.covers)], ...(metadata.exercises ? { exercises: [...new Set(metadata.exercises)] } : {}), tokens, ...(metadata.samples ? { samples: metadata.samples } : {}), file: path.relative(root, file).replaceAll("\\", "/"), artifact: path.relative(root, scenarioArtifact(root, id)).replaceAll("\\", "/") });
107
+ for (const name of tokens) if (!tokenNames.has(name)) diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule: "token-scenario-reference", line: 1, column: 1, message: `Scenario references undeclared token ${name}. Use a real token from its defining owner.` });
108
+ if (kind === "tokens") {
109
+ const owner = scoped.inventory.owner(relative);
110
+ for (const name of tokens) if (tokenInventory.tokens.find(token => token.name === name)?.owner !== owner) report(file, `Token ${name} needs a preview beside its defining owner. Shared tokens belong under the configured token directory; local tokens stay with their component/pattern.`);
111
+ }
112
+ scenarios.push({ id, kind, title: metadata.title, covers: [...new Set(metadata.covers)], ...(Object.hasOwn(metadata, "exercises") ? { exercises: [...new Set(exercises)] } : {}), tokens, ...(metadata.samples ? { samples: metadata.samples } : {}), file: path.relative(root, file).replaceAll("\\", "/"), artifact: path.relative(root, scenarioArtifact(root, id)).replaceAll("\\", "/") });
91
113
  }
92
114
  return { scenarios: scenarios.sort((a, b) => a.id.localeCompare(b.id)), diagnostics };
93
115
  }
@@ -209,3 +231,33 @@ export function writeScenarioRegistry(options, write) {
209
231
  write({ schemaVersion: 1, scenarios: registry.scenarios });
210
232
  return registry.scenarios;
211
233
  }
234
+
235
+ /** Locate the actual consumed array, rather than regenerating a whole fixture. */
236
+ export function tokenSampleSource(options, scenario) {
237
+ const file = path.join(options.root, scenario.file);
238
+ const program = ts.createProgram([file], { ...projectCompilerOptions(options), allowJs:true, resolveJsonModule:true });
239
+ const checker = program.getTypeChecker(), source = program.getSourceFile(file);
240
+ function resolve(node, seen = new Set()) {
241
+ while (node && (ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isParenthesizedExpression(node))) node = node.expression;
242
+ if (!node || seen.has(node)) return null;
243
+ if (ts.isArrayLiteralExpression(node) || ts.isObjectLiteralExpression(node)) return node;
244
+ let symbol = checker.getSymbolAtLocation(ts.isPropertyAccessExpression(node) ? node.name : node);
245
+ if (symbol?.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
246
+ const declaration = symbol?.valueDeclaration;
247
+ return declaration?.initializer ? resolve(declaration.initializer, new Set([...seen,node])) : null;
248
+ }
249
+ for (const statement of source.statements) if (ts.isVariableStatement(statement)) for (const declaration of statement.declarationList.declarations) {
250
+ if (!ts.isIdentifier(declaration.name) || declaration.name.text !== "scenario") continue;
251
+ const metadata = resolve(declaration.initializer);
252
+ const property = metadata && ts.isObjectLiteralExpression(metadata) && metadata.properties.find(node => ts.isPropertyAssignment(node) && (ts.isIdentifier(node.name) || ts.isStringLiteralLike(node.name)) && node.name.text === "samples");
253
+ const array = property && resolve(property.initializer);
254
+ if (array && ts.isArrayLiteralExpression(array)) {
255
+ const source = array.getSourceFile();
256
+ const scoped = scopedTokens(options), owner = scoped.inventory.owner(scoped.inventory.relative(scenario.file));
257
+ const relative = path.relative(options.root, source.fileName).replaceAll("\\", "/");
258
+ if (!isDevelopmentFile(source.fileName, options) || scoped.inventory.owner(scoped.inventory.relative(relative)) !== owner) throw new Error("The consumed samples array must live in this scenario's own development fixture.");
259
+ return {file:relative, content:source.text, start:array.getStart(source), end:array.end};
260
+ }
261
+ }
262
+ throw new Error("Cannot locate a literal consumed samples array. Keep fixture.samples statically inspectable; edit other fixture shapes with a normal owner patch.");
263
+ }
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import postcss from "postcss";
4
+ import ts from "typescript";
5
+ import { inspectUnits } from "./units.mjs";
6
+ import { isDevelopmentFile } from "./config.mjs";
7
+ import { foundationSelector } from "./foundation.mjs";
8
+ import { isSharedToken, tokenContract } from "./token-policy.mjs";
9
+
10
+ /** An explicit owner contract, never a heuristic based on a token's name. */
11
+ export function scopedTokens(options) {
12
+ if (options.scopedTokens) return options.scopedTokens;
13
+ const inventory = inspectUnits(options), definitions = [], diagnostics = [];
14
+ const report = (file, message, line = 1) => diagnostics.push({rule:"scoped-token", file, message, line, column:1});
15
+ for (const unit of inventory.units.filter(unit => ["components", "patterns"].includes(unit.kind))) {
16
+ for (const manifest of unit.files.filter(file => /(?:^|\/)tokens\.json$|\.tokens\.json$/.test(file))) {
17
+ const absolute = path.join(options.root, manifest);
18
+ try {
19
+ if (statSync(absolute).size > 65536 || realpathSync(absolute) !== absolute) throw new Error("Token manifests must be ordinary files no larger than 64 KiB.");
20
+ const data = JSON.parse(readFileSync(absolute, "utf8"));
21
+ const stylesheet = manifest.replace(/\.json$/, ".css");
22
+ if (!data || data.schemaVersion !== 1 || Object.keys(data).some(key => !["schemaVersion", "namespace"].includes(key))
23
+ || typeof data.namespace !== "string" || !/^--[a-z][a-z0-9-]*-$/.test(data.namespace)
24
+ || /^(?:--ds-|--ux-|--showcase-)/.test(data.namespace) || isSharedToken(data.namespace + "value")
25
+ || tokenContract.naming.prefixes.some(prefix => prefix.startsWith(data.namespace))
26
+ || [...tokenContract.naming.names,...Object.keys(tokenContract.tokens)].some(name => name.startsWith(data.namespace))) throw new Error("Use {schemaVersion:1, namespace:\"--<owner>-\"}; shared and runtime-variable prefixes are reserved.");
27
+ if (definitions.some(entry => entry.unit === unit.id || entry.namespace.startsWith(data.namespace) || data.namespace.startsWith(entry.namespace))) throw new Error("Each unit has one token namespace; namespaces must not overlap other owners.");
28
+ if (!unit.files.includes(stylesheet) || !existsSync(path.join(options.root, stylesheet))) throw new Error(`Colocate the definition-only stylesheet ${stylesheet}.`);
29
+ const file = path.join(options.root, stylesheet);
30
+ if (realpathSync(file) !== file) throw new Error("Scoped token stylesheets cannot resolve through symlinks.");
31
+ const css = postcss.parse(readFileSync(file, "utf8"), {from:file});
32
+ let count = 0;
33
+ css.walk(node => {
34
+ if (node.type === "comment" || node.type === "root") return;
35
+ if (node.type === "atrule" && ["media", "supports", "layer"].includes(node.name) && node.nodes) return;
36
+ if (node.type === "rule" && node.parent.type !== "rule" && !foundationSelector(node.selector, {definitions:true})) return;
37
+ if (node.type === "decl" && node.parent.type === "rule" && node.prop.startsWith(data.namespace) && node.prop.length > data.namespace.length && !node.important) { count++; return; }
38
+ report(stylesheet, "Owner token sheets contain only namespaced custom-property definitions under root/theme conditions. UI selectors, imports, font faces, animations and ordinary declarations belong in their runtime owners.", node.source?.start.line);
39
+ });
40
+ if (!count) report(stylesheet, "Define at least one real namespaced token.");
41
+ const imported = unit.files.filter(file => /\.[cm]?[jt]sx?$/.test(file) && !isDevelopmentFile(path.join(options.root, file), options)).some(source => {
42
+ const ast = ts.createSourceFile(source, readFileSync(path.join(options.root, source), "utf8"), ts.ScriptTarget.Latest, true);
43
+ return ast.statements.some(node => ts.isImportDeclaration(node) && !node.importClause && ts.isStringLiteralLike(node.moduleSpecifier) && path.resolve(options.root, path.dirname(source), node.moduleSpecifier.text) === file);
44
+ });
45
+ if (!imported) report(manifest, `Import ./${path.basename(stylesheet)} from this owner's runtime source so its real preview and product consume the same definitions.`);
46
+ definitions.push({unit:unit.id, namespace:data.namespace, manifest, file:stylesheet});
47
+ } catch (error) { report(manifest, error.reason ?? error.message, error.line); }
48
+ }
49
+ }
50
+ const result = {definitions, diagnostics, inventory};
51
+ options.scopedTokens = result;
52
+ return result;
53
+ }
54
+
55
+ export function scopedTokenOwner(options, name) {
56
+ return scopedTokens(options).definitions.find(entry => name.startsWith(entry.namespace));
57
+ }
@@ -36,8 +36,8 @@ and `boundaries.styleWrites` entries together with its source. Unused permission
36
36
  fail the core checks; do not pre-register a boundary without its implementation.
37
37
  Use `dryRun: true` to
38
38
  inspect validation; applying re-runs the gates. Fix rejected patches in their
39
- owner. Report unsupported coupled changes instead of bypassing a rejection with
40
- direct source writes. Shared styles/helpers use `foundation`, root guides use
39
+ owner. Use `ads_apply_transaction` for genuinely coupled owner patches;
40
+ never bypass a rejection with direct source writes. Shared styles/helpers use `foundation`, root guides use
41
41
  `documentation`, and branding/fonts use their discovered `assets/*` unit.
42
42
  Intentional configuration, rule exceptions, registered guard code and compiler
43
43
  changes use `configuration`; validate the proposed settings and keep mandatory
@@ -45,8 +45,16 @@ ADS gates. For binary/large assets, generate `git diff --binary --full-index` in
45
45
  `.ds/patches/<name>.patch` and send `patchFile` instead of inline `patch`, so bytes
46
46
  stay out of model context. Binary changes require Git. When editing is disabled,
47
47
  use the client's normal source-editing tools.
48
+ For owner drafts, token-sample reconciliation, coupled transactions and local
49
+ constant namespaces, read [guarded-editing.md](references/guarded-editing.md).
48
50
 
49
51
 
52
+ For atomic base/variant styling or an `atomic-composition-review` warning, read
53
+ [atomic style composition](references/atomic-styles.md). The same guide is served
54
+ as `ads://guides/atomic-styles`. It explains shorthand/logical conflicts and
55
+ matching-property fixes. Review warnings in the real `cx` composition and verify
56
+ computed styles across affected sizes/states; retain intentional product behavior.
57
+
50
58
  Controllers are UI behavior owners: private helpers stay with their component or
51
59
  pattern; reusable controllers use discovered `controllers/<Name>` units. Use the
52
60
  project's configured framework and declared dependencies, or plain TypeScript,
@@ -0,0 +1,121 @@
1
+ # Atomic style composition
2
+
3
+ Use this guide when creating or migrating component/pattern styles, changing
4
+ base/size/state combinations, or reviewing `atomic-composition-review` warnings.
5
+ It is also available as `ads://guides/atomic-styles` through ADS MCP.
6
+
7
+ ## Why valid tokens can still render incorrectly
8
+
9
+ Atomic Linaria extracts declarations into reusable classes. `cx` from
10
+ `@linaria/atomic` or `@linaria/core` replaces earlier atoms with the same property
11
+ and selector/condition key. It does not expand every CSS shorthand or resolve
12
+ every overlap between physical and logical properties.
13
+
14
+ For example, these declarations have different atomic keys:
15
+
16
+ ```tsx
17
+ const base = css`
18
+ padding: 0;
19
+ `;
20
+ const small = css`
21
+ padding-inline: var(--space-6);
22
+ `;
23
+
24
+ cx(base, small);
25
+ ```
26
+
27
+ Both classes survive. Both declarations can affect horizontal padding in a
28
+ horizontal writing mode. In the inspected Linaria implementation, `padding` and
29
+ `padding-inline` receive equal specificity. If a reset atom is emitted later,
30
+ it can override the intended size. Shared atom reuse and CSS splitting can make
31
+ this visible in a larger build even when an isolated view appears correct.
32
+ The order of class names in HTML does not decide equal-specificity rule order.
33
+ Valid tokens, successful extraction and a passing source check do not establish
34
+ the intended computed value.
35
+
36
+ See Linaria's [class merging implementation](https://github.com/callstack/linaria/blob/master/packages/core/src/cx.ts)
37
+ and [property priority implementation](https://github.com/callstack/linaria/blob/master/packages/atomic/src/processors/helpers/propertyPriority.ts).
38
+ Inspect the project's installed version when investigating its output.
39
+
40
+ ## Prefer matching properties across composed blocks
41
+
42
+ Express the reset and overrides using the same property vocabulary:
43
+
44
+ ```tsx
45
+ const base = css`
46
+ padding-block: 0;
47
+ padding-inline: 0;
48
+ `;
49
+ const small = css`
50
+ padding-inline: var(--space-6);
51
+ `;
52
+ const regular = css`
53
+ padding-block: var(--space-4);
54
+ padding-inline: var(--space-8);
55
+ `;
56
+
57
+ cx(base, compact ? small : regular);
58
+ ```
59
+
60
+ Now each selected size replaces the corresponding base atom through `cx`.
61
+ Review every size, including the default and icon-only variants. Changing only
62
+ one size can leave another size mixing shorthand and logical properties.
63
+ The names and tokens above are illustrative: use the project's real API and
64
+ declared tokens. Preserve its intended dimensions and supported writing modes.
65
+
66
+ The same reasoning applies to margin, inset, logical borders/corners, dimensions
67
+ and overflow. For example, prefer `margin-inline: 0` plus a size's
68
+ `margin-inline: var(...)` over combining `margin: 0` with that logical override.
69
+ Choose consistent physical properties when the product deliberately positions
70
+ against physical edges; logical properties are not mandatory.
71
+
72
+ An intentional state override may have a different selector or condition:
73
+
74
+ ```tsx
75
+ const treatment = css`
76
+ padding-inline: var(--space-6);
77
+ &:hover { padding-inline: var(--space-8); }
78
+ `;
79
+ ```
80
+
81
+ This is a distinct state, not two competing base atoms. Whether changing padding
82
+ on hover is desirable remains a product decision.
83
+
84
+ ## Respond to a warning
85
+
86
+ 1. Follow the warning's related file/line locations and inspect the actual `cx`
87
+ composition. Variant lookups may contribute several possible combinations.
88
+ 2. In a current built preview, inspect computed properties and the winning CSS
89
+ rules. Establish whether extraction order, specificity, writing mode or an
90
+ intentional override explains the result.
91
+ 3. For accidental overlap, align property names/granularity across the base and
92
+ affected variants. Use Linaria's `cx`, preserve tokens and fix the owning
93
+ component/pattern. Reordering class strings does not remove overlapping keys.
94
+ 4. Verify the affected sizes/states and supported directions in the normal app
95
+ and preview build. Add a registered rendered check when a specific geometry
96
+ or computed-style guarantee matters.
97
+
98
+ Increasing specificity, adding `!important`, copying styles into scenarios or
99
+ moving component styling into `base.css` can hide the symptom while retaining
100
+ the conflict. Prefer resolving the composition itself. If an overlap is
101
+ intentional, document why its precedence is stable and verify the resulting
102
+ behavior rather than changing product capabilities to silence a warning.
103
+
104
+ ## What ADS checks
105
+
106
+ `atomic-composition-review` is a nonblocking warning, returned under `advisories`
107
+ with `severity: "warning"`. CLI checks still exit successfully when there are no
108
+ errors; MCP checks and mutations preserve the warning and its guidance URI.
109
+
110
+ The source review examines atomic Linaria templates and statically traceable
111
+ `cx` arguments, including aliases, local imports, nested composition, conditional
112
+ expressions and variant-object lookups. It checks known overlaps involving box
113
+ spacing, inset, borders/corners, dimensions, overflow, backgrounds, typography,
114
+ motion and common layout shorthands, plus `all` resets. It compares shared selector/condition contexts; separate alternatives
115
+ within one conditional or lookup are not assumed to apply together.
116
+
117
+ This is a bounded source review, not a CSS engine or a proof of precedence. It
118
+ does not enumerate every external class, runtime function result, selector
119
+ interaction or overlapping media query. An empty warning list does not establish
120
+ visual correctness. Actual failed registered browser/behavior checks remain
121
+ blocking; potentially intentional source overlaps remain warnings.
@@ -0,0 +1,44 @@
1
+ # Advanced guarded editing
2
+
3
+ Use `ads_apply_patch` for independent owner edits. `ads_prepare_patch` can create a
4
+ local draft of one owner and its companions; edit that directory with local file
5
+ tools, request `action:"diff"`, then apply the returned patchFile. Discard the
6
+ draft afterward. Remote clients can keep sending inline Git diffs. Neither draft
7
+ preparation nor diff generation validates or changes live source.
8
+
9
+ For genuinely coupled changes, `ads_apply_transaction` accepts 2–8 distinct owner
10
+ patches (32 distinct files and 16 MiB combined). Each patch keeps its ownership and
11
+ companion restrictions. Patches apply in order; later shared-entry hunks match the
12
+ earlier candidate. All guards, registered checks and affected behavior checks run
13
+ on the final candidate before live source is written. Dry-run first. Rejection,
14
+ conflict and cancellation publish nothing. Dependency installation remains local;
15
+ the candidate uses installed node_modules. Never bypass a rejected gate directly.
16
+
17
+ A component/pattern can own constants without expanding the shared vocabulary:
18
+ colocate `tokens.json` with `{ "schemaVersion":1, "namespace":"--<owner>-" }`
19
+ and a definition-only `tokens.css`, imported by its runtime source. Flat owners use
20
+ `<Owner>.tokens.json`/`<Owner>.tokens.css`. Namespaces are unique and cannot overlap
21
+ shared or --ds-/--ux-/--showcase- variables. Only that owner consumes/defines its
22
+ local tokens; aliases can use shared or own tokens. UI styles stay in Linaria.
23
+ Add a colocated token scenario with `kind:"tokens"`, real fixture `samples` and a
24
+ public review pattern. These inputs belong to the same owner and need real sample
25
+ coverage. Manifests and fixtures stay out of the product bundle.
26
+
27
+ `ads_prepare_token_samples({id, bindings?})` preserves existing project samples,
28
+ removes undeclared names and returns an owner patchFile. Supply explicit
29
+ `{token,property,probe}` bindings for new samples; unresolved tokens stay reported.
30
+ ADS does not infer visual meaning or fabricate renderers. Apply through the guard,
31
+ then inspect the actual App sample effects.
32
+
33
+ An empty .gitkeep can be deleted alongside new source beneath it in the selected
34
+ owner. Nonempty markers and other hidden files stay protected. A redundant
35
+ Scenario.tsx can be deleted when real coverage remains elsewhere; removing a
36
+ scenario does not erase ownership of the unit's runtime source.
37
+
38
+ Keep contextual public parts public when they are intended consumer API. Demonstrate
39
+ them through their actual public composition/customization path and list their real
40
+ render references in covers. Do not count internal production renders as public API
41
+ coverage. Behavioral APIs use exercises and registered guarantees instead.
42
+ Scenario controls still use public UI; native link/button markup belongs in an
43
+ owned public primitive/pattern or an owner-local test, including polymorphic/slot
44
+ adaptation. Do not add demo-only component props or global styles for review glue.
@@ -26,6 +26,12 @@ The showcase keeps a notification stream open. Wait for document loading and the
26
26
  selected App's ready/error state; `networkidle` is not a readiness condition.
27
27
  For tall previews, scroll the App document to its last example and verify it is
28
28
  reachable without the host shell scrolling or clipping the preview.
29
+ Compare references under equivalent framing. With `content-visibility`, App
30
+ readiness can precede off-screen layout: bring measured content into view and
31
+ wait for observable geometry to settle. Record initial placeholder behavior
32
+ separately when relevant; a framed/top-level difference alone does not identify
33
+ an ADS defect. Wait for actual focus/positioning readiness for keyboard checks;
34
+ exercise rapid input as a separate case instead of adding arbitrary delays.
29
35
  Capture the fixture, viewport/container size, theme, zoom, and actions alongside
30
36
  each failure. CSS viewport emulation is useful; it is not evidence of real
31
37
  mobile keyboard, browser chrome, or assistive-technology behavior.
@@ -68,8 +68,8 @@ and `boundaries.styleWrites` entries together with its source. Unused permission
68
68
  fail the core checks; do not pre-register a boundary without its implementation.
69
69
  Use `dryRun: true` to
70
70
  inspect validation; applying re-runs the gates. Fix rejected patches in their
71
- owner. Report unsupported coupled changes instead of bypassing a rejection with
72
- direct source writes. Shared styles/helpers use `foundation`, root guides use
71
+ owner. Use `ads_apply_transaction` for genuinely coupled owner patches;
72
+ never bypass a rejection with direct source writes. Shared styles/helpers use `foundation`, root guides use
73
73
  `documentation`, and branding/fonts use their discovered `assets/*` unit.
74
74
  Intentional configuration, rule exceptions, registered guard code and compiler
75
75
  changes use `configuration`; validate the proposed settings and keep mandatory
@@ -77,6 +77,14 @@ ADS gates. For binary/large assets, generate `git diff --binary --full-index` in
77
77
  `.ds/patches/<name>.patch` and send `patchFile` instead of inline `patch`, so bytes
78
78
  stay out of model context. Binary changes require Git. When editing is disabled,
79
79
  use the client's normal source-editing tools.
80
+ For owner drafts, token-sample reconciliation, coupled transactions and local
81
+ constant namespaces, read [guarded-editing.md](references/guarded-editing.md).
82
+
83
+ For atomic base/variant styling or an `atomic-composition-review` warning, read
84
+ [atomic style composition](references/atomic-styles.md). The same guide is served
85
+ as `ads://guides/atomic-styles`. It explains shorthand/logical conflicts and
86
+ matching-property fixes. Review warnings in the real `cx` composition and verify
87
+ computed styles across affected sizes/states; retain intentional product behavior.
80
88
 
81
89
  Controllers are UI behavior owners: private helpers stay with their component or
82
90
  pattern; reusable controllers use discovered `controllers/<Name>` units. Use the
@@ -36,8 +36,14 @@ Projects sharing this ADS use the same token names, meanings, and value types;
36
36
  their concrete values and theme overrides can differ. Reuse existing local values
37
37
  when importing components/patterns, add missing shared definitions, and import
38
38
  source token values only when adopting that theme is requested.
39
- Token names and prefixes are fixed by ADS architecture; foreign brand prefixes
40
- are rejected. Shared assignments belong in token files. Components own
39
+ Shared token names and prefixes follow the ADS contract. A component/pattern may
40
+ register one unique local namespace in colocated `tokens.json` and define it in
41
+ `tokens.css`, imported by its runtime. Flat owners use `<Owner>.tokens.json` and
42
+ `<Owner>.tokens.css`. These definition-only sheets contain root/theme custom
43
+ properties, not UI selectors or ordinary styles. Other owners consume shared
44
+ tokens; they cannot override or consume a peer's local namespace. A colocated
45
+ `kind:"tokens"` scenario needs real samples for every local token. Shared
46
+ assignments remain in the shared token files. Components own
41
47
  `--ds-*`, patterns `--ux-*`, and the showcase `--showcase-*`; token files cannot
42
48
  assign those local variables or override component state.
43
49
 
@@ -183,7 +189,7 @@ mounting, correct props, every branch/state or visual parity. The older
183
189
 
184
190
  `token-scenario-reference` rejects undeclared token names in metadata and reachable
185
191
  inputs. `token-scenario-render` rejects claimed tokens with no reachable input
186
- reference; `token-scenario-coverage` requires every discovered foundation token
192
+ reference; `token-scenario-coverage` requires every discovered shared or owner-local token
187
193
  to have coverage. A new or removed token therefore needs a scenario/fixture
188
194
  update. All three are errors in `ads check`, run by `lint:ds` and the build.
189
195
  Coverage is static reachability, not proof that every branch actually renders
@@ -62,6 +62,10 @@ the public `TokenPreview` pattern owns their presentation and applies the real C
62
62
  variables. Add/remove samples alongside token definitions. `ads check` rejects
63
63
  missing coverage and invented names; the App verifies the declared CSS bindings
64
64
  in the browser. Extend or replace the project-owned pattern as your tokens evolve.
65
+ `ads_prepare_token_samples` can prepare a fixture patch from explicit project
66
+ property/probe bindings. It preserves existing samples and reports unmapped tokens;
67
+ it does not guess how a new value should look. Component/pattern constants can use
68
+ the colocated namespace contract in [ADS.md](ADS.md), with their own token scenario.
65
69
 
66
70
  Create a component under `design-system/components/`, export it through `@ds`,
67
71
  and colocate `Scenario.tsx` plus `fixture.ts` (or `<Owner>.scenario.tsx` and
@@ -79,6 +83,8 @@ stale retained output. Switching or rebuilding a scenario unmounts its previous
79
83
  state. `showcase/mcp/main.tsx` exports `mountScenario(Scenario, container)` and
80
84
  returns an unmount callback; it is not the shell. Keep the saved `framework`,
81
85
  compiler and mount adapter consistent.
86
+ Managed sessions own their graph output; standalone prebuilds cannot replace it.
87
+ Verified generated graph snapshots preserve warm-start reuse across sessions.
82
88
 
83
89
  The application and previews use the same foundation CSS and Linaria pipeline.
84
90
  The source barrel loads the foundation once. The private `build:lib` script uses
@@ -98,3 +104,7 @@ from a user-selected external repository, preserving revisions and licenses.
98
104
  inspection builds it automatically, then loads selected entries. Browser review
99
105
  still checks layout, accessibility, keyboard/focus, states, themes and motion. A clean
100
106
  build or delivered HTML alone does not establish those behaviors.
107
+ Opt-in MCP editing uses owner Git patches. `ads_prepare_patch` creates a temporary
108
+ owner draft/diff; `ads_apply_transaction` validates bounded coupled owner patches
109
+ against one final candidate. Preparation changes no live source. Keep all guards
110
+ and review the rendered result after applying.