@reqvire-org/ads 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/bootstrap-ds/ADS.md +29 -0
  2. package/dist/bootstrap-ds/design-system/tokens/base.css +4 -1
  3. package/dist/bootstrap-ds/design-system/tokens/colors.css +2 -2
  4. package/dist/boundaries.mjs +21 -23
  5. package/dist/check.mjs +45 -15
  6. package/dist/cli.mjs +1 -0
  7. package/dist/contracts/foundation.json +211 -0
  8. package/dist/foundation.mjs +114 -0
  9. package/dist/mcp/build-status.mjs +22 -9
  10. package/dist/mcp/mutations.mjs +1 -1
  11. package/dist/mcp/preview-watch.mjs +35 -12
  12. package/dist/mcp/prompts.mjs +1 -1
  13. package/dist/mcp/server.mjs +6 -6
  14. package/dist/oxlint.mjs +1 -1
  15. package/dist/scenarios-compiler.mjs +86 -43
  16. package/dist/skills/ads-assimilate/SKILL.md +43 -2
  17. package/dist/skills/ads-assimilate/references/cli.md +3 -2
  18. package/dist/skills/ads-assimilate/references/native-implementation.md +16 -0
  19. package/dist/skills/ads-break-ui/SKILL.md +43 -2
  20. package/dist/skills/ads-designer/SKILL.md +43 -2
  21. package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +29 -0
  22. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/base.css +4 -1
  23. package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/colors.css +2 -2
  24. package/dist/skills/ads-designer/references/cli.md +3 -2
  25. package/dist/skills/ads-import/SKILL.md +43 -2
  26. package/dist/skills/ads-import/references/cli.md +3 -2
  27. package/dist/skills/ads-motion/SKILL.md +43 -2
  28. package/dist/style-boundaries.mjs +59 -0
  29. package/dist/units.mjs +37 -1
  30. package/docs/ADS.md +34 -1
  31. package/docs/ARCHITECTURE.md +38 -6
  32. package/docs/CHANGELOG.md +28 -0
  33. package/docs/GUIDE.md +18 -1
  34. package/docs/MCP.md +48 -7
  35. package/package.json +2 -1
@@ -40,6 +40,35 @@ Token names and prefixes are fixed by ADS architecture; foreign brand prefixes
40
40
  are rejected. Shared assignments belong in token files. Components own
41
41
  `--ds-*`, patterns `--ux-*`, and the showcase `--showcase-*`; token files cannot
42
42
  assign those local variables or override component state.
43
+
44
+ Foundation roles are enforced by `foundation-role`, `foundation-selector` and
45
+ `foundation-import`, alongside the regular token checks. Shared definitions
46
+ belong on the document root or explicit root themes; ordinary declarations belong
47
+ in `base.css`, and font-face descriptors in optional `fonts.css`. The entry imports
48
+ canonical sheets; those sheets cannot import another styling layer. Component
49
+ classes, IDs, data/ARIA hooks, contextual selectors, keyframes and scoped UI rules
50
+ are rejected in the foundation, including nested functional selectors. Raw visual
51
+ values and undefined token references are checked in native defaults too; literal
52
+ font-face metadata keeps its separate asset role.
53
+
54
+ `base.css` has three closed roles:
55
+
56
+ - Document defaults: root/body typography, canvas colors and theme behavior,
57
+ global text selection and document scrollbars.
58
+ - Native resets: permitted normalization properties and values, such as zero
59
+ spacing, inherited control typography, list resets and responsive replaced media.
60
+ - Global accessibility: universal `:focus-visible` outline rules, and reduced-motion
61
+ overrides inside `@media (prefers-reduced-motion: reduce)`.
62
+
63
+ The `foundation-role` check validates every selector branch, property and value.
64
+ Native selectors are not a bypass: token-backed button backgrounds, flex/grid
65
+ layout, component-specific focus rules and hover/disabled skins are rejected.
66
+ Shared token definitions belong in value sheets, not `base.css`. No project token
67
+ names are embedded in this policy. A zero-specificity `:where()` wrapper does not
68
+ change ownership. Put component design and animation in Linaria owners, and
69
+ scenario layout variations in typed public review-pattern props. Browser review
70
+ still checks the effects and accessibility of permitted defaults.
71
+
43
72
  Own primitive markup, focus, hover, selected, disabled, loading, and motion in
44
73
  the primitive. Use typed props for variations. Keep reusable structure and skin
45
74
  in colocated Linaria blocks using `css` and `cx` from `@linaria/atomic`.
@@ -1,3 +1,6 @@
1
+ :root { color-scheme: light; }
2
+ :root[data-theme="dark"] { color-scheme: dark; }
3
+
1
4
  *,
2
5
  *::before,
3
6
  *::after {
@@ -5,7 +8,7 @@
5
8
  }
6
9
 
7
10
  body {
8
- margin: var(--space-0);
11
+ margin: 0;
9
12
  background: var(--bg-canvas);
10
13
  color: var(--text-body);
11
14
  font-family: var(--font-sans);
@@ -1,5 +1,5 @@
1
1
  :root {
2
- color-scheme: light;
2
+
3
3
  --bg-canvas: #faf9f6;
4
4
  --bg-surface: #fff;
5
5
  --bg-hover: #f1efea;
@@ -15,7 +15,7 @@
15
15
  }
16
16
 
17
17
  :root[data-theme="dark"] {
18
- color-scheme: dark;
18
+
19
19
  --bg-canvas: #17191e;
20
20
  --bg-surface: #20232a;
21
21
  --bg-hover: #2c3039;
@@ -90,31 +90,29 @@ function isWrite(node) {
90
90
  || ts.isDeleteExpression(parent);
91
91
  }
92
92
 
93
- // Inspects style access itself, so aliases, computed keys, cssText and bulk writes
94
- // cannot silently turn a narrow property exception into a whole-file exception.
93
+ /** Exact writes and known reads share one interpretation with exception audits.
94
+ * undefined = unrelated, false = read, null = unknown/bulk, string = exact write. */
95
+ export function styleAccessProperty(node) {
96
+ if (!isMember(node) || memberName(node) !== "style") return undefined;
97
+ const access = unwrapped(node), parent = access.parent;
98
+ if (!isMember(parent) || parent.expression !== access) return null;
99
+ const property = cssName(memberName(parent)), target = unwrapped(parent);
100
+ if (ts.isCallExpression(target.parent) && target.parent.expression === target) {
101
+ if (["set-property", "remove-property"].includes(property)) {
102
+ const name = target.parent.arguments[0];
103
+ return name && ts.isStringLiteralLike(name) ? name.text : null;
104
+ }
105
+ return ["get-property-value", "get-property-priority", "item"].includes(property) ? false : null;
106
+ }
107
+ if (isWrite(target)) return property ?? null;
108
+ return property && !["set-property", "remove-property"].includes(property) ? false : null;
109
+ }
110
+
111
+ // Aliases, computed keys, cssText and bulk writes cannot widen an exception.
95
112
  export function styleWriteViolation(node, filename, { root, config }) {
96
113
  const allowed = config.boundaries.styleWrites.find((boundary) => path.resolve(root, boundary.file) === filename)?.properties ?? [];
97
- if (isMember(node) && memberName(node) === "style") {
98
- const access = unwrapped(node);
99
- const parent = access.parent;
100
- let property;
101
- if (isMember(parent) && parent.expression === access) {
102
- property = cssName(memberName(parent));
103
- const target = unwrapped(parent);
104
- if (ts.isCallExpression(target.parent) && target.parent.expression === target) {
105
- if (["set-property", "remove-property"].includes(property)) {
106
- const name = target.parent.arguments[0];
107
- property = name && ts.isStringLiteralLike(name) ? name.text : null;
108
- } else if (["get-property-value", "get-property-priority", "item"].includes(property)) return null;
109
- else property = null;
110
- } else if (!isWrite(target)) {
111
- // Plain property reads are allowed. Passing a method reference or a
112
- // computed member elsewhere is not a known property read.
113
- if (property && !["set-property", "remove-property"].includes(property)) return null;
114
- property = null;
115
- }
116
- }
117
- if (property && property !== "css-text" && allowed.includes(property)) return null;
114
+ const property = styleAccessProperty(node);
115
+ if (property !== undefined && property !== false && !(property && property !== "css-text" && allowed.includes(property))) {
118
116
  return "Imperative styles require a file boundary and an explicit property. Bulk writes, computed property names, and style-object escapes are forbidden.";
119
117
  }
120
118
  if (ts.isCallExpression(node) && isMember(node.expression) && ["setAttribute", "setAttributeNS"].includes(memberName(node.expression))) {
package/dist/check.mjs CHANGED
@@ -14,6 +14,11 @@ import { inspectShowcasePages } from "./showcase.mjs";
14
14
  import { inspectShowcaseCoverage } from "./showcase-coverage.mjs";
15
15
  import { inspectScenarioCoverage } from "./scenarios.mjs";
16
16
  import { uiOperation, importedUiApi } from "./ui-framework.mjs";
17
+ import { inspectStyleBoundaries, pointerCaptureAdvisories } from "./style-boundaries.mjs";
18
+ import { foundationDeclaration, foundationSelector } from "./foundation.mjs";
19
+
20
+ const scenarioLayoutGuidance = "Put demo-only sizing and framing in a public review/layout pattern. Keep required behavior in the runtime owner; add component props for deliberate product API needs.";
21
+ const inlineStyleGuidance = `Own visual styles in component/pattern Linaria. ${scenarioLayoutGuidance} Reserve inlineStyleFiles for documented measured geometry, not demo layout.`;
17
22
 
18
23
  const showcaseControls = new Set(["a", "button", "input", "select", "textarea", "option", "optgroup", "table", "thead", "tbody", "tfoot", "tr", "td", "th", "dialog", "details", "summary", "progress", "meter"]);
19
24
  const svgTags = new Set(["svg", "g", "path", "circle", "rect", "ellipse", "line", "polyline", "polygon", "text", "tspan", "use", "defs", "clipPath", "mask", "filter", "linearGradient", "radialGradient", "stop", "marker", "pattern"]);
@@ -23,7 +28,7 @@ const widgetRoles = new Set(["button", "link", "checkbox", "radio", "switch", "c
23
28
  export function checkProject(directory, { trackingRoot } = {}) {
24
29
  const options = { ...loadConfig(directory), ...(trackingRoot ? { trackingRoot: path.resolve(trackingRoot) } : {}) };
25
30
  const { root, config, dsRoot, sourceRoot } = options;
26
- const diagnostics = [];
31
+ const diagnostics = [], advisories = [];
27
32
  for (const [field, file] of [["dsDir", dsRoot], ["publicEntry", path.join(dsRoot, config.publicEntry)], ["stylesEntry", path.join(dsRoot, config.stylesEntry)]]) {
28
33
  if (!existsSync(file) || (field === "dsDir" ? !statSync(file).isDirectory() : !statSync(file).isFile())) diagnostics.push({ rule: "project-structure", file: path.relative(root, file), line: 1, column: 1, message: `Missing or invalid ${field}. Bootstrap an empty ADS or configure its actual path.` });
29
34
  }
@@ -35,13 +40,15 @@ export function checkProject(directory, { trackingRoot } = {}) {
35
40
  const resolveImport = importResolver(options);
36
41
  const rootHtml = path.join(root, "index.html");
37
42
  const files = [...new Set([...sourceFiles(sourceRoot), ...sourceFiles(dsRoot), ...(existsSync(rootHtml) ? [rootHtml] : [])])];
38
- const scriptFiles = files.filter((file) => /\.[cm]?[jt]sx?$/.test(file));
43
+ const boundaryFiles = [...config.inlineStyleFiles, ...config.boundaries.styleWrites.map(entry => entry.file)].map(file => path.resolve(root, file)).filter(file => existsSync(file) && statSync(file).isFile());
44
+ const scriptFiles = [...new Set([...files, ...boundaryFiles])].filter((file) => /\.[cm]?[jt]sx?$/.test(file));
39
45
  const compiler = projectCompilerOptions(options);
40
46
  const program = ts.createProgram(scriptFiles, { ...compiler, paths: { ...compiler.paths, [config.publicAlias]: [path.join(dsRoot, config.publicEntry)] }, allowJs: true, noResolve: true, noLib: true, types: [], target: ts.ScriptTarget.Latest, module: ts.ModuleKind.ESNext, jsx: ts.JsxEmit.Preserve });
41
47
  const checker = program.getTypeChecker();
42
48
  function report(file, rule, message, line = 1, column = 1) {
43
49
  diagnostics.push({ file: path.relative(root, file).replaceAll("\\", "/"), rule, message, line, column });
44
50
  }
51
+ inspectStyleBoundaries(options, program, report);
45
52
  inspectShowcasePages(options, program, resolveImport, report);
46
53
  inspectShowcaseCoverage(options, program, report);
47
54
  inspectScenarioCoverage(options, program, report);
@@ -54,13 +61,14 @@ export function checkProject(directory, { trackingRoot } = {}) {
54
61
  const actual = imports.map(node => foundationImportTarget(node, foundationEntry));
55
62
  if (actual.length !== expected.length || actual.some((file, index) => file !== expected[index] || !existsSync(file))) report(foundationEntry, "styles-entry", "Foundation CSS must import only fonts (when present), colors, typography, spacing, elevation, and base, in that order.");
56
63
  } catch (error) { report(foundationEntry, "css-syntax", error.reason ?? error.message, error.line ?? 1); }
57
- function checkCss(file, text, offset = { line: 1, column: 1 }) {
64
+ function checkCss(file, text, offset = { line: 1, column: 1 }, foundation = false) {
58
65
  let css;
59
66
  try { css = postcss.parse(text, { from: file }); }
60
67
  catch (error) { report(file, "css-syntax", error.reason ?? error.message, offset.line + (error.line ?? 1) - 1); return; }
61
- if (isDevelopmentFile(file, options) || !isInside(path.join(dsRoot, "components"), file) && !isInside(path.join(dsRoot, "patterns"), file) && file !== foundationEntry) report(file, "style-ownership", "Visual CSS belongs to the local ADS's runtime components or patterns.");
62
- if (isDevelopmentFile(file, options) && css.nodes.some((node) => node.type !== "comment" && !(node.type === "atrule" && node.name === "import"))) report(file, "showcase-style", "Scenarios and fixtures define no styles. Put the frame/layout in a public ADS pattern and product UI in ADS components or patterns.", offset.line);
68
+ if (!foundation && (isDevelopmentFile(file, options) || !isInside(path.join(dsRoot, "components"), file) && !isInside(path.join(dsRoot, "patterns"), file) && file !== foundationEntry)) report(file, "style-ownership", "Visual CSS belongs to the local ADS's runtime components or patterns.");
69
+ if (isDevelopmentFile(file, options) && css.nodes.some((node) => node.type !== "comment" && !(node.type === "atrule" && node.name === "import"))) report(file, "showcase-style", `Scenarios and fixtures define no styles. ${scenarioLayoutGuidance}`, offset.line);
63
70
  css.walkAtRules("import", (rule) => {
71
+ if (foundation) report(file, "foundation-import", "Import foundation sheets through the configured styles entry. Token sheets cannot import an unchecked styling layer.", rule.source.start.line);
64
72
  const first = valueParser(rule.params).nodes.find((node) => node.type !== "space" && node.type !== "comment");
65
73
  const specifier = first?.type === "string" ? first.value : first?.type === "function" && first.value === "url" ? first.nodes[0]?.value : null;
66
74
  if (!specifier || /^(?:[a-z]+:|\/\/)/i.test(specifier)) return;
@@ -69,19 +77,40 @@ export function checkProject(directory, { trackingRoot } = {}) {
69
77
  if (showcaseImportViolation(specifier, file, options, resolveImport)) report(file, "showcase-import", "Showcase code and styles are development fixtures; runtime code must not import them.", line);
70
78
  });
71
79
  css.walkRules((rule) => {
80
+ if (foundation) {
81
+ if (rule.parent.type === "rule") report(file, "foundation-role", "Foundation rules cannot nest owner/context selectors. Use root theme conditions or Linaria owners.", rule.source.start.line);
82
+ const violation = foundationSelector(rule.selector, { definitions: rule.nodes.some(node => node.type === "decl" && node.prop.startsWith("--")) });
83
+ if (violation) report(file, "foundation-selector", violation, rule.source.start.line, rule.source.start.column);
84
+ }
72
85
  if (isInside(path.join(dsRoot, "showcase"), file) && /:(?:hover|active|focus(?:-visible|-within)?|checked|disabled|enabled)\b|\[(?:aria-(?:selected|pressed|checked|disabled|expanded)|data-state)\b/.test(rule.selector)) report(file, "showcase-state", "Interactive state styling belongs to the real ADS component or pattern, not showcase or mock styles.", offset.line + rule.source.start.line - 1);
73
86
  if (/\.ads-[\w-]+/.test(rule.selector) && !isInside(path.join(dsRoot, "components"), file)) report(file, "style-ownership", "ADS component internals are owned by components, not app or showcase CSS.", offset.line + rule.source.start.line - 1);
74
87
  if (/\.ux-[\w-]+/.test(rule.selector) && !isInside(path.join(dsRoot, "patterns"), file)) report(file, "style-ownership", "Pattern internals are owned by patterns, not app or showcase CSS.", offset.line + rule.source.start.line - 1);
75
88
  if (/\.showcase-[\w-]+/.test(rule.selector) && !isInside(path.join(dsRoot, "showcase"), file)) report(file, "style-ownership", "Showcase internals are owned by showcase scaffolding.", offset.line + rule.source.start.line - 1);
76
89
  });
90
+ if (foundation) css.walkAtRules(rule => {
91
+ if (/(?:^|-)keyframes$|^(?:scope|custom-selector|starting-style)$/i.test(rule.name)) report(file, "foundation-role", "Animations and scoped UI styles belong in their component/pattern's Linaria source, not the token foundation.", rule.source.start.line);
92
+ if (rule.name === "font-face" && path.basename(file) !== "fonts.css") report(file, "foundation-role", "Font faces belong in the canonical fonts.css foundation sheet.", rule.source.start.line);
93
+ });
77
94
  css.walkDecls((decl) => {
95
+ if (foundation && decl.prop.startsWith("--")) {
96
+ if (path.basename(file) === "base.css") report(file, "foundation-role", "Shared token definitions belong in value sheets, not base.css.", decl.source.start.line, decl.source.start.column);
97
+ return; // Definition names, values and aliases are validated by inspectTokens.
98
+ }
99
+ const fontDescriptor = foundation && path.basename(file) === "fonts.css" && decl.parent.type === "atrule" && decl.parent.name === "font-face";
100
+ if (foundation && !fontDescriptor && (path.basename(file) !== "base.css" || decl.parent.type !== "rule")) report(file, "foundation-role", "Token sheets contain shared definitions; native defaults belong in base.css, font descriptors in fonts.css, and UI styling in Linaria owners.", decl.source.start.line, decl.source.start.column);
101
+ if (foundation && path.basename(file) === "base.css" && decl.parent.type === "rule") {
102
+ const violation = foundationDeclaration(decl.parent, decl);
103
+ if (violation) report(file, "foundation-role", violation, decl.source.start.line, decl.source.start.column);
104
+ }
78
105
  const variable = checkComponentVariable(decl.prop, file, options);
79
106
  if (variable) report(file, variable.rule, variable.message, offset.line + decl.source.start.line - 1, decl.source.start.column);
80
107
  for (const ref of references(decl.value)) {
81
108
  const local = /^(?:--ds-|--ux-|--showcase-)/.test(ref.name);
109
+ if (foundation && local) report(file, "foundation-role", "Foundation defaults consume shared tokens, not component, pattern or scenario-local variables.", offset.line + decl.source.start.line - 1, decl.source.start.column);
82
110
  if (!local && !isSharedToken(ref.name)) report(file, "token-name", `${ref.name} does not follow ADS's fixed token naming contract.`, offset.line + decl.source.start.line - 1, decl.source.start.column);
83
111
  else if (!local && !declaredTokens.has(ref.name) && !ref.fallback) report(file, "token-reference", `Undefined shared token ${ref.name}.`, offset.line + decl.source.start.line - 1, decl.source.start.column);
84
112
  }
113
+ if (fontDescriptor) return; // Font metadata defines source assets, not token-backed UI styles.
85
114
  const violations = new Set();
86
115
  valueParser(decl.value).walk((node) => {
87
116
  if (node.type === "function" && node.value === "url") return false;
@@ -91,12 +120,12 @@ export function checkProject(directory, { trackingRoot } = {}) {
91
120
  if (node.type === "word" && /^ease(?:-in|-out|-in-out)?$/.test(node.value)) violations.add("Use a motion token for easing.");
92
121
  if (node.type === "word" && /^(?:color|background-color|(?:border|outline|caret|text-decoration|column-rule)(?:-[a-z]+)?-color|fill|stroke)$/.test(decl.prop) && /^[a-z]+$/i.test(node.value) && !/^(?:inherit|initial|unset|revert|currentcolor|transparent|none)$/i.test(node.value)) violations.add("Use a semantic color token.");
93
122
  });
94
- if (/^(?:font-family|font-weight|z-index)$/.test(decl.prop) && !/^(?:var\(|inherit$|auto$)/.test(decl.value)) violations.add("Use a semantic typography or layering token.");
123
+ if (/^(?:font-family|font-weight|z-index)$/.test(decl.prop) && !/^(?:var\(|inherit$|initial$|unset$|revert(?:-layer)?$|auto$)/.test(decl.value) && !(foundation && decl.prop === "font-weight" && /^(?:bolder|lighter)$/.test(decl.value))) violations.add("Use a semantic typography or layering token.");
95
124
  for (const message of violations) report(file, "style-token", message, offset.line + decl.source.start.line - 1, decl.source.start.column);
96
125
  });
97
126
  }
98
127
  for (const file of files) {
99
- if (file.endsWith(".css") && isInside(path.join(dsRoot, config.tokenDir), file) && !isDevelopmentFile(file, options)) continue;
128
+ if (file.endsWith(".css") && isInside(path.join(dsRoot, config.tokenDir), file) && !isDevelopmentFile(file, options)) { checkCss(file, readFileSync(file, "utf8"), undefined, true); continue; }
100
129
  if (file.endsWith(".html")) { inspectHtml(readFileSync(file, "utf8"), (rule, message, line) => report(file, rule, message, line)); continue; }
101
130
  if (file.endsWith(".css")) {
102
131
  const text = readFileSync(file, "utf8");
@@ -105,6 +134,7 @@ export function checkProject(directory, { trackingRoot } = {}) {
105
134
  }
106
135
  const source = program.getSourceFile(file);
107
136
  if (!source) continue;
137
+ advisories.push(...pointerCaptureAdvisories(source).map(entry => ({ file: path.relative(root, file).replaceAll("\\", "/"), ...entry })));
108
138
  for (const diagnostic of program.getSyntacticDiagnostics(source)) {
109
139
  const position = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
110
140
  report(file, "source-syntax", ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), position.line + 1, position.character + 1);
@@ -163,14 +193,14 @@ export function checkProject(directory, { trackingRoot } = {}) {
163
193
  for (const property of properties) {
164
194
  if (!ts.isPropertyAssignment(property) || !(ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name))) continue;
165
195
  if (tag && ts.isStringLiteralLike(tag) && svgTags.has(tag.text) && importedUiApi(node.expression, checker)?.module === "preact") checkSvgAttribute(property, property.name.text);
166
- if (property.name.text === "style" && (!inlineAllowed || showcase)) nodeReport(property, "no-inline-style", "UI factories follow the same style ownership as JSX. Use semantic ADS props or a narrow renderer boundary.");
196
+ if (property.name.text === "style" && (!inlineAllowed || showcase)) nodeReport(property, "no-inline-style", showcase ? `Scenarios define no inline styles. ${scenarioLayoutGuidance}` : inlineStyleGuidance);
167
197
  if (property.name.text === "style") inspectProperty(property.initializer);
168
198
  if (property.name.text === "className") inspectClass(property.initializer);
169
- if (showcase && property.name.text === "className") nodeReport(property, "showcase-class", "Scenarios use public ADS props, not custom class names.");
199
+ if (showcase && property.name.text === "className") nodeReport(property, "showcase-class", `Scenarios define no custom class names. ${scenarioLayoutGuidance}`);
170
200
  }
171
201
  if (showcase && tag && ts.isStringLiteralLike(tag)) {
172
202
  const role = properties.find(property => ts.isPropertyAssignment(property) && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) && property.name.text === "role")?.initializer;
173
- if (showcaseControls.has(tag.text) || role && ts.isStringLiteralLike(role) && widgetRoles.has(role.text)) nodeReport(node, "showcase-control", "Scenario controls must render public ADS components.");
203
+ if (showcaseControls.has(tag.text) || role && ts.isStringLiteralLike(role) && widgetRoles.has(role.text)) nodeReport(node, "showcase-control", `Scenario controls must render public ADS components for actual interaction. ${scenarioLayoutGuidance}`);
174
204
  }
175
205
  }
176
206
  if (isInside(dsRoot, file) && (ts.isIdentifier(node) && ["fetch", "XMLHttpRequest", "WebSocket", "EventSource"].includes(node.text) && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node))) {
@@ -181,9 +211,9 @@ export function checkProject(directory, { trackingRoot } = {}) {
181
211
  if (showcase && (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && ts.isIdentifier(node.tagName) && /^[a-z]/.test(node.tagName.text)) {
182
212
  const role = node.attributes.properties.find((attr) => ts.isJsxAttribute(attr) && attr.name.text === "role")?.initializer;
183
213
  const roleValue = role && ts.isStringLiteralLike(role) ? role.text : role && ts.isJsxExpression(role) && role.expression && ts.isStringLiteralLike(role.expression) ? role.expression.text : null;
184
- if (showcaseControls.has(node.tagName.text) || widgetRoles.has(roleValue)) nodeReport(node, "showcase-control", "Showcase and mock controls must use public ADS components. Implement missing reusable controls in the ADS, then import them here.");
214
+ if (showcaseControls.has(node.tagName.text) || widgetRoles.has(roleValue)) nodeReport(node, "showcase-control", `Scenario controls must render public ADS components for actual interaction. ${scenarioLayoutGuidance}`);
185
215
  }
186
- if (showcase && ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === "className") nodeReport(node, "showcase-class", "Showcase and mocks use known public ADS entities and props, not custom class names. Put styling in the owning ADS component or frame pattern.");
216
+ if (showcase && ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === "className") nodeReport(node, "showcase-class", `Scenarios define no custom class names. ${scenarioLayoutGuidance}`);
187
217
  let specifier;
188
218
  if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) specifier = node.moduleSpecifier.text;
189
219
  if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) specifier = node.arguments[0].text;
@@ -191,13 +221,13 @@ export function checkProject(directory, { trackingRoot } = {}) {
191
221
  if (specifier && resolveImport(specifier, file).some((target) => (specifier.startsWith(".") || existsSync(target)) && target.endsWith(".css") && isInside(root, target) && !isInside(dsRoot, target) && target !== path.join(sourceRoot, "app-mount.css") && !isInside(path.join(root, "node_modules"), target))) nodeReport(node, "style-ownership", "Imported project CSS belongs to ADS components, patterns, or the foundation. Only structural app-mount.css is allowed outside the ADS.");
192
222
  if (specifier && appImportViolation(specifier, file, options, resolveImport)) nodeReport(node, "no-app-import", "ADS implementations and showcase fixtures must not import app code. Use public ADS APIs with local fixture data; keep application integration harnesses in application tests.");
193
223
  if (specifier && showcaseImportViolation(specifier, file, options, resolveImport)) nodeReport(node, "showcase-import", "Showcase code and styles are development fixtures; runtime code must not import them.");
194
- if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === "style" && (!inlineAllowed || showcase)) nodeReport(node, "no-inline-style", "Move visual policy into the local ADS, or document a narrow measured-geometry exception in inlineStyleFiles.");
224
+ if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === "style" && (!inlineAllowed || showcase)) nodeReport(node, "no-inline-style", showcase ? `Scenarios define no inline styles. ${scenarioLayoutGuidance}` : inlineStyleGuidance);
195
225
  if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === "style" && node.initializer) {
196
226
  inspectProperty(node.initializer);
197
227
  }
198
228
  const mutation = styleWriteViolation(node, file, options);
199
229
  if (mutation) nodeReport(node, "style-mutation", mutation);
200
- if (!inlineAllowed && isUiCssProperties(node, checker)) nodeReport(node, "css-properties", "Do not expose CSSProperties-based visual plumbing. Use semantic props, or document a narrow JSX renderer boundary.");
230
+ if (!inlineAllowed && isUiCssProperties(node, checker)) nodeReport(node, "css-properties", "Keep CSSProperties out of the public API. Use deliberate component contracts or a documented measured-geometry renderer boundary; demo layout belongs in a public review pattern.");
201
231
  if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.name.text === "className" && node.initializer) {
202
232
  inspectClass(node.initializer);
203
233
  }
@@ -211,5 +241,5 @@ export function checkProject(directory, { trackingRoot } = {}) {
211
241
  }
212
242
  visit(source);
213
243
  }
214
- return { ok: diagnostics.length === 0, diagnostics };
244
+ return { ok: diagnostics.length === 0, diagnostics, ...(advisories.length ? { advisories } : {}) };
215
245
  }
package/dist/cli.mjs CHANGED
@@ -96,6 +96,7 @@ async function main(args) {
96
96
  if (!json && result.diagnostics?.length) {
97
97
  for (const diagnostic of result.diagnostics) console.error(`${diagnostic.file}:${diagnostic.line}:${diagnostic.column} [${diagnostic.rule}] ${diagnostic.message}`);
98
98
  }
99
+ if (!json) for (const advisory of result.advisories ?? []) console.error(`${advisory.file}:${advisory.line}:${advisory.column} [advisory:${advisory.rule}] ${advisory.message}`);
99
100
  if (result.ok === false || result.diagnostics?.length) process.exitCode = 1;
100
101
  }
101
102
 
@@ -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
+ }