@reqvire-org/ads 0.3.1 → 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.
@@ -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;
package/dist/check.mjs CHANGED
@@ -14,6 +14,7 @@ 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 { foundationDeclaration, foundationSelector } from "./foundation.mjs";
17
18
 
18
19
  const showcaseControls = new Set(["a", "button", "input", "select", "textarea", "option", "optgroup", "table", "thead", "tbody", "tfoot", "tr", "td", "th", "dialog", "details", "summary", "progress", "meter"]);
19
20
  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"]);
@@ -54,13 +55,14 @@ export function checkProject(directory, { trackingRoot } = {}) {
54
55
  const actual = imports.map(node => foundationImportTarget(node, foundationEntry));
55
56
  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
57
  } catch (error) { report(foundationEntry, "css-syntax", error.reason ?? error.message, error.line ?? 1); }
57
- function checkCss(file, text, offset = { line: 1, column: 1 }) {
58
+ function checkCss(file, text, offset = { line: 1, column: 1 }, foundation = false) {
58
59
  let css;
59
60
  try { css = postcss.parse(text, { from: file }); }
60
61
  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 (!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.");
62
63
  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);
63
64
  css.walkAtRules("import", (rule) => {
65
+ 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
66
  const first = valueParser(rule.params).nodes.find((node) => node.type !== "space" && node.type !== "comment");
65
67
  const specifier = first?.type === "string" ? first.value : first?.type === "function" && first.value === "url" ? first.nodes[0]?.value : null;
66
68
  if (!specifier || /^(?:[a-z]+:|\/\/)/i.test(specifier)) return;
@@ -69,19 +71,40 @@ export function checkProject(directory, { trackingRoot } = {}) {
69
71
  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
72
  });
71
73
  css.walkRules((rule) => {
74
+ if (foundation) {
75
+ 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);
76
+ const violation = foundationSelector(rule.selector, { definitions: rule.nodes.some(node => node.type === "decl" && node.prop.startsWith("--")) });
77
+ if (violation) report(file, "foundation-selector", violation, rule.source.start.line, rule.source.start.column);
78
+ }
72
79
  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
80
  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
81
  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
82
  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
83
  });
84
+ if (foundation) css.walkAtRules(rule => {
85
+ 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);
86
+ 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);
87
+ });
77
88
  css.walkDecls((decl) => {
89
+ if (foundation && decl.prop.startsWith("--")) {
90
+ 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);
91
+ return; // Definition names, values and aliases are validated by inspectTokens.
92
+ }
93
+ const fontDescriptor = foundation && path.basename(file) === "fonts.css" && decl.parent.type === "atrule" && decl.parent.name === "font-face";
94
+ 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);
95
+ if (foundation && path.basename(file) === "base.css" && decl.parent.type === "rule") {
96
+ const violation = foundationDeclaration(decl.parent, decl);
97
+ if (violation) report(file, "foundation-role", violation, decl.source.start.line, decl.source.start.column);
98
+ }
78
99
  const variable = checkComponentVariable(decl.prop, file, options);
79
100
  if (variable) report(file, variable.rule, variable.message, offset.line + decl.source.start.line - 1, decl.source.start.column);
80
101
  for (const ref of references(decl.value)) {
81
102
  const local = /^(?:--ds-|--ux-|--showcase-)/.test(ref.name);
103
+ 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
104
  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
105
  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
106
  }
107
+ if (fontDescriptor) return; // Font metadata defines source assets, not token-backed UI styles.
85
108
  const violations = new Set();
86
109
  valueParser(decl.value).walk((node) => {
87
110
  if (node.type === "function" && node.value === "url") return false;
@@ -91,12 +114,12 @@ export function checkProject(directory, { trackingRoot } = {}) {
91
114
  if (node.type === "word" && /^ease(?:-in|-out|-in-out)?$/.test(node.value)) violations.add("Use a motion token for easing.");
92
115
  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
116
  });
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.");
117
+ 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
118
  for (const message of violations) report(file, "style-token", message, offset.line + decl.source.start.line - 1, decl.source.start.column);
96
119
  });
97
120
  }
98
121
  for (const file of files) {
99
- if (file.endsWith(".css") && isInside(path.join(dsRoot, config.tokenDir), file) && !isDevelopmentFile(file, options)) continue;
122
+ if (file.endsWith(".css") && isInside(path.join(dsRoot, config.tokenDir), file) && !isDevelopmentFile(file, options)) { checkCss(file, readFileSync(file, "utf8"), undefined, true); continue; }
100
123
  if (file.endsWith(".html")) { inspectHtml(readFileSync(file, "utf8"), (rule, message, line) => report(file, rule, message, line)); continue; }
101
124
  if (file.endsWith(".css")) {
102
125
  const text = readFileSync(file, "utf8");
@@ -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
+ }
@@ -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) {