@adia-ai/adia-ui-factory 0.8.55 → 0.8.56

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adia-ui-kit-factory",
3
- "version": "0.8.55",
3
+ "version": "0.8.56",
4
4
  "description": "Author and verify apps built ON the adia-ui (@adia-ai) light-DOM web-component framework \u2014 orient, scaffold, compose, wire, verify, and migrate across SPA and SSR rendering modes. Wires the a2ui MCP for catalog retrieval, UI generation, and validation.",
5
5
  "author": {
6
6
  "name": "Kim",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adia-ui-kit-factory",
3
- "version": "0.8.55",
3
+ "version": "0.8.56",
4
4
  "description": "Author and verify apps built ON the adia-ui (@adia-ai) light-DOM web-component framework — orient, scaffold, compose, wire, verify, and migrate across SPA and SSR rendering modes. Wires the a2ui MCP for catalog retrieval, UI generation, and validation.",
5
5
  "author": {
6
6
  "name": "Kim",
package/.mcp.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "adia-gen-ui": {
4
4
  "command": "npx",
5
- "args": ["-y", "@adia-ai/mcp@0.8.55", "gen-ui"]
5
+ "args": ["-y", "@adia-ai/mcp@0.8.56", "gen-ui"]
6
6
  }
7
7
  }
8
8
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog — adia-ui-kit-factory
2
2
 
3
+ ## [0.8.56] — 2026-08-31
4
+
5
+ ### Added
6
+ - **`theme-audit` skill, `/theme-audit` command, and `scripts/adia-theme-audit.mjs` runner (lld-0012 B1–B3, gh#2257).** Audits a consumer app's `theme.css` for the four redundancy classes (restated defaults, token re-derivations, dead selectors, hand-built components) across every sitemap route, with a Playwright class-1 toggle and route discovery (#2308, #2312, #2413). Fixtures under `scripts/theme-audit-fixtures/`; report shape in `skills/theme-audit/references/report-shape.md`; Codex/Pi/Hermes manifests derived (`skills/theme-audit/agents/openai.yaml`, `prompts/theme-audit.md`).
7
+ - **`HARNESS-NOTES.md`** pointer for non-Claude harnesses (gh#2449) — points at `AGENTS.md`/README rather than duplicating install prose.
8
+
9
+ ### Changed
10
+ - **`scripts/adia-probe.mjs`** gains the route-discovery + class-1 toggle hooks the theme-audit runner drives (#2413); `agents/surface-qa-agent.md` cites the extended probe.
11
+ - **`skills/token-selection/references/a-alias-layer.md`** documents the 15 new `--a-*` aliases from tokens batch C0 and the advisory reroute gate (gh#2240, #2243).
12
+ - **`skills/pattern-catalog/references/{pattern-index.md,annotations.yaml}`** pick up the sidebar-modal pattern (#2226) and the registration/NPI-search and errors surfaces (#2242, #2270); `skills/table-composition/references/base-table.md` follows `table-toolbar-ui`'s count-cluster redesign (#2190); `skills/find-unused/SKILL.md` wording tightened.
13
+
14
+ ### Maintenance
15
+ - **`.claude-plugin/plugin.json` version bump** — moves in lockstep with package.json (the `/plugin update` cache key).
16
+ - **`.codex-plugin/` touched in this release window** (1 file(s), e.g. `.codex-plugin/plugin.json`) — carried by the entries above.
17
+ - **`commands/` touched in this release window** (1 file(s), e.g. `commands/theme-audit.md`) — carried by the entries above.
18
+
3
19
  ## [0.8.55] — 2026-08-28
4
20
 
5
21
  ### Changed
package/README.md CHANGED
@@ -51,7 +51,7 @@ posture (ADR-0040) is about this plugin working standalone in any consumer
51
51
  repo once installed some other way (no monorepo path assumptions in its own
52
52
  scripts) — it does not add an npm-sourced marketplace entry.
53
53
 
54
- ## Skills (19 — `skills/` on disk is the roster; `check:plugin-count-claims` guards this header count)
54
+ ## Skills (20 — `skills/` on disk is the roster; `check:plugin-count-claims` guards this header count)
55
55
 
56
56
  | Skill | Species | Job |
57
57
  |---|---|---|
@@ -74,6 +74,7 @@ scripts) — it does not add an npm-sourced marketplace entry.
74
74
  | `app-migration` | procedural | version upgrades + port-to-adia sweeps (breaking changes) |
75
75
  | `find-unused` | procedural | what a *non*-breaking upgrade left unused — inert opt-in layers, stale workarounds |
76
76
  | `surface-qa` | procedural | browser + a11y + composition exit gate |
77
+ | `theme-audit` | procedural | classify a consumer theme.css against the framework's own defaults/tokens/primitives — restated defaults, re-derived tokens, dead selectors, hand-built component work; audit + report only, no rewrites (gh#2257) |
77
78
 
78
79
  ## Agents (4)
79
80
 
@@ -97,11 +98,20 @@ surface-qa-agent) · `component-model.md` · the `references/contracts/` twins.
97
98
 
98
99
  ## Commands
99
100
 
100
- `/project-scaffolding` · `/app-planning` · `/surface-qa` · `/app-migration` · `/find-unused` · `/gen-ui-wiring` · `/app-audit`
101
+ `/project-scaffolding` · `/app-planning` · `/surface-qa` · `/app-migration` · `/find-unused` · `/gen-ui-wiring` · `/app-audit` · `/theme-audit`
102
+
103
+ ## Scripts
104
+
105
+ Consumer-runnable checks shipped as plain Node scripts under `scripts/`, no
106
+ MCP server required: `adia-probe.mjs` (the browser-QA gate; see
107
+ `surface-qa`), `adia-preflight.mjs` (every CI-step prerequisite, each
108
+ failure named with its remedy), `adia-contract-check.mjs` (authored markup
109
+ attributes vs. the shipped component contracts), and `adia-theme-audit.mjs`
110
+ (the theme.css redundancy classifier; see `theme-audit`).
101
111
 
102
112
  ## MCP
103
113
 
104
- `adia-gen-ui` server pinned: `@adia-ai/mcp@0.8.55` (tool SoT:
114
+ `adia-gen-ui` server pinned: `@adia-ai/mcp@0.8.56` (tool SoT:
105
115
  `packages/gen-ui/mcp/TOOLS.md` — the `gen-ui` section; stability rule in
106
116
  `references/contracts/`; pin lives in `.mcp.json` — `check:plugin-count-claims`
107
117
  guards this README copy against it). The server is `adia-mcp gen-ui`,
@@ -120,6 +130,12 @@ plug-and-play install** — read this section before promising Pi/Hermes
120
130
  support for this plugin specifically, since its whole value proposition is
121
131
  wiring the `adia-gen-ui` MCP server.
122
132
 
133
+ `theme-audit`'s `adia-theme-audit` bin runs identically on every harness — a
134
+ plain Node script with no Claude-specific runtime dependency; the
135
+ `/theme-audit` command degrades to invoking the skill directly on Codex and
136
+ Hermes, and becomes a genuine `prompts/theme-audit.md` Pi command via
137
+ `build:harness-manifests`.
138
+
123
139
  - **Codex** (gh#1888): `.codex-plugin/plugin.json` + per-skill
124
140
  `agents/openai.yaml`, derived; `mcpServers` points at the same
125
141
  `.mcp.json` Claude Code uses. No manifest key for hooks/commands/agents —
package/__init__.py CHANGED
@@ -263,7 +263,7 @@ def register(ctx):
263
263
  ctx.register_skill(
264
264
  name="find-unused",
265
265
  path=Path(os.path.join(_HERE, "skills", "find-unused", "SKILL.md")),
266
- description="Finds what a non-breaking @adia-ai upgrade shipped that this app never picked up — the opt-in layers nothing imports, the local workarounds a fixed bug made redundant, retired enum values still sitting in stored state, and the fixes that never reached MIGRATION.md. Use after \"we bumped and nothing broke\", \"what are we missing from 0.8.x\", \"are we actually using what we ship\", \"why is [scale]/[theme] doing nothing\", or when a PATCH span needs adoption rather than repair. NOT for breaking-change repair (app-migration); NOT for a first-time install (adia-scaffold); NOT for diagnosing a broken surface (app-audit).",
266
+ description="Finds what a non-breaking @adia-ai upgrade shipped that this app never picked up — the opt-in layers nothing imports, the local workarounds a fixed bug made redundant, retired enum values still sitting in stored state, and the fixes that never reached MIGRATION.md. Use after \"we bumped and nothing broke\", \"what are we missing from 0.8.x\", \"are we actually using what we ship\", \"why is [scale]/[theme] doing nothing\", or when a PATCH span needs adoption rather than repair. NOT for breaking-change repair (app-migration); NOT for a first-time install (adia-scaffold); NOT for diagnosing a broken surface (app-audit); NOT for CSS-only redundancy in a theme file (theme-audit — the CSS-side sibling).",
267
267
  )
268
268
  ctx.register_skill(
269
269
  name="gen-ui-wiring",
@@ -310,6 +310,11 @@ def register(ctx):
310
310
  path=Path(os.path.join(_HERE, "skills", "table-composition", "SKILL.md")),
311
311
  description="Answers how to display/organize tabular data with table-ui: contained vs uncontained chrome, [raw] (consumer-owned body, a separate axis), opt-in striped rows, and resize/sort defaults that flip between JS .columns and declarative col-def forms, plus inline-edit-grid and tree/hierarchical-row patterns. Use for \"how do I show a table\", \"add sorting/filtering\", \"make this table striped\", \"columns aren't resizable\", \"card vs bare table\", \"editable/spreadsheet grid\", \"tree table / nested rows\", \"huge list of rows\". NOT for composing the surrounding screen (screen-composition), data wiring (data-wiring), OTHER non-table patterns (pattern-catalog), or 1000+-row virtualized lists (list-window-ui).",
312
312
  )
313
+ ctx.register_skill(
314
+ name="theme-audit",
315
+ path=Path(os.path.join(_HERE, "skills", "theme-audit", "SKILL.md")),
316
+ description="Classifies a consumer app's theme.css against what the adia-ui framework already ships — restated defaults, re-derived tokens, dead selectors, and hand-built component work. Runs `adia-theme-audit` against a theme file + app root; audits and reports, never rewrites. Use when a theme.css is suspected of restating the kit, after bumping `@adia-ai/web-components`, or before a `find-unused` sweep. NOT for rendering/grading a surface (surface-qa); NOT for picking a token going forward (token-selection); NOT for JS/markup an upgrade left unused (find-unused — the CSS-side sibling); NOT for the framework's OWN token layer in packages/web-components (forge's component-token-audit).",
317
+ )
313
318
  ctx.register_skill(
314
319
  name="token-selection",
315
320
  path=Path(os.path.join(_HERE, "skills", "token-selection", "SKILL.md")),
@@ -9,6 +9,7 @@ description: |
9
9
  tools: Read, Grep, Glob, Bash
10
10
  skills:
11
11
  - surface-qa
12
+ - theme-audit
12
13
  # Explicit pin (gh#618, tier corrected gh#1045): a review/critic seat's
13
14
  # verdict must not depend on the caller's model tier — never `inherit`.
14
15
  # Operator's explicit standing instruction for this seat family: sonnet + xhigh.
@@ -0,0 +1,28 @@
1
+ ---
2
+ wraps: theme-audit
3
+ description: Classify a consumer app's theme.css against what the adia-ui framework already ships — restated defaults, re-derived tokens, dead selectors, hand-built component work. Audit and report only; no rewrites.
4
+ argument-hint: "[--theme <file>] [--app <root>]"
5
+ disable-model-invocation: false
6
+ user-invocable: true
7
+ ---
8
+
9
+ Audit a theme stylesheet. **$ARGUMENTS**
10
+
11
+ Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/adia-theme-audit.mjs" --theme <file>
12
+ --app <root>` (add `--json`/`--json-out <path>` for a structured report,
13
+ `--strict` to exit non-zero on any `high`-confidence finding, `--class
14
+ 1,2,3,4` to narrow scope). No `@adia-ai/web-components` resolvable from
15
+ `<app>` → the tool reports `E_NO_FRAMEWORK` with the install remedy; pass
16
+ `--framework-root <checkout>` to audit against an unreleased framework
17
+ checkout instead. No `--base-url`/Playwright unreachable → class 3's
18
+ rendered census degrades to `UNMEASURED` and the run still exits `0`
19
+ (unless `--strict`); static `last-wins` detection still runs.
20
+
21
+ Return the human-readable summary and per-class tables (theme-audit
22
+ §Reading the report). Findings route back to the owning skill — a
23
+ restated default or re-derived token deletes from the theme file directly;
24
+ a hand-built-component finding routes to `screen-composition` to swap in
25
+ the primitive; a dead selector still needs a human's confirmation before
26
+ removal (the census may not have covered every interaction state). Never
27
+ apply a fix inline from this command — theme-audit and its runner never
28
+ rewrite the file, and neither does this wrapper.
package/hermes-mcp.yaml CHANGED
@@ -6,5 +6,5 @@ mcp_servers:
6
6
  command: "npx"
7
7
  args:
8
8
  - "-y"
9
- - "@adia-ai/mcp@0.8.55"
9
+ - "@adia-ai/mcp@0.8.56"
10
10
  - "gen-ui"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/adia-ui-factory",
3
- "version": "0.8.55",
3
+ "version": "0.8.56",
4
4
  "description": "Author and verify apps built ON the adia-ui (@adia-ai) light-DOM web-component framework — orient, scaffold, compose, wire, verify, and migrate across SPA and SSR rendering modes. Wires the a2ui MCP for catalog retrieval, UI generation, and validation.",
5
5
  "keywords": [
6
6
  "adia-ui",
@@ -19,6 +19,9 @@
19
19
  "bugs": {
20
20
  "email": "kim.granlund@adia.ai"
21
21
  },
22
+ "bin": {
23
+ "adia-theme-audit": "./scripts/adia-theme-audit.mjs"
24
+ },
22
25
  "pi": {
23
26
  "skills": ["./skills"],
24
27
  "prompts": ["./prompts"],
package/plugin.yaml CHANGED
@@ -1,4 +1,4 @@
1
1
  name: "adia-ui-kit-factory"
2
- version: "0.8.55"
2
+ version: "0.8.56"
3
3
  description: "Author and verify apps built ON the adia-ui (@adia-ai) light-DOM web-component framework — orient, scaffold, compose, wire, verify, and migrate across SPA and SSR rendering modes. Wires the a2ui MCP for catalog retrieval, UI generation, and validation."
4
4
  manifest_version: 1
@@ -0,0 +1,25 @@
1
+ ---
2
+ description: "Classify a consumer app's theme.css against what the adia-ui framework already ships — restated defaults, re-derived tokens, dead selectors, hand-built component work. Audit and report only; no rewrites."
3
+ argument-hint: "[--theme <file>] [--app <root>]"
4
+ ---
5
+
6
+ Audit a theme stylesheet. **$ARGUMENTS**
7
+
8
+ Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/adia-theme-audit.mjs" --theme <file>
9
+ --app <root>` (add `--json`/`--json-out <path>` for a structured report,
10
+ `--strict` to exit non-zero on any `high`-confidence finding, `--class
11
+ 1,2,3,4` to narrow scope). No `@adia-ai/web-components` resolvable from
12
+ `<app>` → the tool reports `E_NO_FRAMEWORK` with the install remedy; pass
13
+ `--framework-root <checkout>` to audit against an unreleased framework
14
+ checkout instead. No `--base-url`/Playwright unreachable → class 3's
15
+ rendered census degrades to `UNMEASURED` and the run still exits `0`
16
+ (unless `--strict`); static `last-wins` detection still runs.
17
+
18
+ Return the human-readable summary and per-class tables (theme-audit
19
+ §Reading the report). Findings route back to the owning skill — a
20
+ restated default or re-derived token deletes from the theme file directly;
21
+ a hand-built-component finding routes to `screen-composition` to swap in
22
+ the primitive; a dead selector still needs a human's confirmation before
23
+ removal (the census may not have covered every interaction state). Never
24
+ apply a fix inline from this command — theme-audit and its runner never
25
+ rewrite the file, and neither does this wrapper.
@@ -72,7 +72,7 @@ export function isLargeText(fontSizePx, fontWeight) {
72
72
  }
73
73
 
74
74
  // samples: [{ text, element, fg:[r,g,b], bg:[r,g,b], fontSizePx, fontWeight }]
75
- export function scoreContrastSamples(samples, { skipped = 0 } = {}) {
75
+ export function scoreContrastSamples(samples, { skipped = 0, unparsed = 0 } = {}) {
76
76
  const pairs = samples.map((s) => {
77
77
  const large = isLargeText(s.fontSizePx, s.fontWeight);
78
78
  const required = large ? 3.0 : 4.5;
@@ -85,12 +85,16 @@ export function scoreContrastSamples(samples, { skipped = 0 } = {}) {
85
85
  standard: 'WCAG AA — 4.5:1 normal / 3.0:1 large text (≥18pt, or ≥14pt bold)',
86
86
  checked: pairs.length,
87
87
  skippedImageBacked: skipped,
88
+ // Sampler health travels with the row even when some pairs parsed: a
89
+ // page with 1 literal rgb() pair and 200 unreadable token colors is a
90
+ // 1/1 pass only in the narrowest sense. Distinct unreadable strings.
91
+ ...(unparsed > 0 ? { unparsedColors: unparsed } : {}),
88
92
  failing,
89
93
  pairs,
90
94
  };
91
95
  }
92
96
 
93
- export function buildProof({ url, errors, boxes, screenshotPath, navTiming, perfBudgetMs = DEFAULT_PERF_BUDGET_MS, contrastSamples = null, contrastSkipped = 0 }) {
97
+ export function buildProof({ url, errors, boxes, screenshotPath, navTiming, perfBudgetMs = DEFAULT_PERF_BUDGET_MS, contrastSamples = null, contrastSkipped = 0, contrastUnparsed = 0 }) {
94
98
  const consolePass = errors.length === 0;
95
99
  const boxFailures = Object.entries(boxes)
96
100
  .filter(([, b]) => !b || b.width <= 0 || b.height <= 0)
@@ -112,9 +116,13 @@ export function buildProof({ url, errors, boxes, screenshotPath, navTiming, perf
112
116
  // captured no samples (selftest fixtures, an all-image page, a sampler
113
117
  // crash). This mirrors the perf row's degradation shape but NOT its
114
118
  // advisory nature: a measured failing pair fails the verdict.
115
- const contrast = contrastSamples
116
- ? scoreContrastSamples(contrastSamples, { skipped: contrastSkipped })
117
- : { pass: null, reason: 'UNMEASURED — no text foreground/background pairs sampled' };
119
+ // A page whose colors were SEEN but none PARSED is a broken sampler, not
120
+ // an unmeasurable page fail loud instead of reporting UNMEASURED.
121
+ const contrast = contrastSamples?.length
122
+ ? scoreContrastSamples(contrastSamples, { skipped: contrastSkipped, unparsed: contrastUnparsed })
123
+ : contrastUnparsed > 0
124
+ ? { pass: false, checked: 0, failing: [], unparsedColors: contrastUnparsed, reason: `sampler parsed 0 pairs but saw ${contrastUnparsed} unreadable color value(s) — color syntax unsupported by the sampler` }
125
+ : { pass: null, reason: 'UNMEASURED — no text foreground/background pairs sampled' };
118
126
  return {
119
127
  record: 'VerifyProof',
120
128
  url,
@@ -176,6 +184,26 @@ function selftest() {
176
184
  { text: 'gray at body size', element: 'p', fg: [148, 148, 148], bg: [255, 255, 255], fontSizePx: 16, fontWeight: 400 },
177
185
  { text: 'gray as a heading', element: 'h1', fg: [148, 148, 148], bg: [255, 255, 255], fontSizePx: 24, fontWeight: 400 },
178
186
  ]);
187
+ // Sampler saw colors but parsed none — a broken sampler must fail loud,
188
+ // never degrade to UNMEASURED.
189
+ const allUnparsed = buildProof({
190
+ url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
191
+ contrastUnparsed: 7,
192
+ });
193
+
194
+ const emptySamples = buildProof({
195
+ url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
196
+ contrastSamples: [],
197
+ });
198
+ // One pair parsed, many colors unreadable — the pass stands (a measured
199
+ // pair is a measured pair) but the sampler-health count must ride on the
200
+ // row, never vanish behind a 1/1.
201
+ const partialUnparsed = buildProof({
202
+ url: 'http://x', errors: [], boxes: { 'my-surface': { width: 640, height: 480 } }, screenshotPath: 'p.png',
203
+ contrastSamples: [{ text: 'ok', element: 'p', fg: [0, 0, 0], bg: [255, 255, 255], fontSizePx: 16, fontWeight: 400 }],
204
+ contrastUnparsed: 200,
205
+ });
206
+
179
207
  const fails = [];
180
208
  const bw = contrastRatio([0, 0, 0], [255, 255, 255]);
181
209
  if (bw < 20.9 || bw > 21.1) fails.push(`black/white ratio ${bw} not ≈21`);
@@ -198,6 +226,19 @@ function selftest() {
198
226
  if (good.gates.contrast.pass !== null || !good.gates.contrast.reason.includes('UNMEASURED')) {
199
227
  fails.push('missing contrast samples did not degrade to UNMEASURED contrast row');
200
228
  }
229
+ if (emptySamples.gates.contrast.pass !== null || emptySamples.verdict !== 'pass-pending-read') {
230
+ fails.push('empty contrast sample array did not degrade to UNMEASURED');
231
+ }
232
+ if (allUnparsed.verdict !== 'fail' || allUnparsed.gates.contrast.pass !== false
233
+ || !allUnparsed.gates.contrast.reason.includes('unreadable color')) {
234
+ fails.push('all-unparsed sampler did not fail loud');
235
+ }
236
+ if (partialUnparsed.gates.contrast.pass !== true || partialUnparsed.gates.contrast.unparsedColors !== 200) {
237
+ fails.push('partially-parsed sampler dropped the unparsedColors count');
238
+ }
239
+ if (good.gates.contrast.unparsedColors !== undefined) {
240
+ fails.push('unparsedColors leaked onto a row with nothing unparsed');
241
+ }
201
242
  if (good.verdict !== 'pass-pending-read') fails.push('good fixture not pass-pending-read');
202
243
  if (badBox.verdict !== 'fail' || badBox.gates.boundingBoxes.failing[0] !== 'my-surface') fails.push('0x0 box not failed');
203
244
  if (badErr.verdict !== 'fail' || badErr.gates.consoleErrors.pass) fails.push('console error not failed');
@@ -213,7 +254,7 @@ function selftest() {
213
254
  console.error('selftest FAIL: ' + fails.join(' | '));
214
255
  process.exit(1);
215
256
  }
216
- console.log('selftest OK — 8 fixtures, verdicts + imageRead + advisory perf row + AA contrast gate (positive/negative/large-text) correct');
257
+ console.log('selftest OK — 11 fixtures, verdicts + imageRead + advisory perf row + AA contrast gate (positive/negative/large-text) correct');
217
258
  process.exit(0);
218
259
  }
219
260
 
@@ -310,11 +351,38 @@ async function main() {
310
351
  // guessed. Deduped by (fg, bg, size, weight) and capped, so the report
311
352
  // stays bounded on long pages.
312
353
  const sampled = await page.evaluate(() => {
354
+ // gh#1259 follow-up: computed styles come back as oklch()/color() in
355
+ // current Chromium for token-driven colors — the rgb() regex alone read
356
+ // checked:0 on every AdiaUI page. Any non-rgb syntax rasterizes through
357
+ // a 1x1 canvas (ground-truth sRGB bytes, not a hand-rolled conversion).
358
+ const rasterCtx = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
359
+ rasterCtx.canvas.width = rasterCtx.canvas.height = 1;
360
+ // Memoized by the computed string: bgFor re-parses every ancestor
361
+ // background per text node, so without a memo the same token color
362
+ // rasterizes O(textNodes x depth) times and one unreadable body
363
+ // background counts once per visit instead of once. `unparsed` is
364
+ // the number of DISTINCT unreadable strings.
365
+ const memo = new Map();
366
+ let unparsed = 0;
313
367
  const parse = (s) => {
314
- const m = /rgba?\(([^)]+)\)/.exec(s || '');
315
- if (!m) return null;
316
- const [r, g, b, a = 1] = m[1].split(',').map(Number);
317
- return { rgb: [r, g, b], a };
368
+ if (!s) return null;
369
+ if (memo.has(s)) return memo.get(s);
370
+ let out = null;
371
+ const m = /rgba?\(([^)]+)\)/.exec(s);
372
+ if (m) {
373
+ const [r, g, b, a = 1] = m[1].split(',').map(Number);
374
+ out = { rgb: [r, g, b], a };
375
+ } else if (!CSS.supports('color', s)) {
376
+ unparsed += 1;
377
+ } else {
378
+ rasterCtx.fillStyle = s;
379
+ rasterCtx.clearRect(0, 0, 1, 1);
380
+ rasterCtx.fillRect(0, 0, 1, 1);
381
+ const d = rasterCtx.getImageData(0, 0, 1, 1).data;
382
+ out = { rgb: [d[0], d[1], d[2]], a: d[3] / 255 };
383
+ }
384
+ memo.set(s, out);
385
+ return out;
318
386
  };
319
387
  const over = (fg, a, bg) => fg.map((c, i) => Math.round(c * a + bg[i] * (1 - a)));
320
388
  const bgFor = (el) => {
@@ -322,7 +390,12 @@ async function main() {
322
390
  for (let n = el; n && n.nodeType === 1; n = n.parentElement || (n.getRootNode && n.getRootNode().host) || null) {
323
391
  const cs = getComputedStyle(n);
324
392
  if (cs.backgroundImage && cs.backgroundImage !== 'none') return null; // indeterminate — pixels, not computed styles
325
- const c = parse(cs.backgroundColor);
393
+ const raw = cs.backgroundColor;
394
+ const c = parse(raw);
395
+ // An unreadable layer is indeterminate too, not transparent: falling
396
+ // through to the white default would score white-on-dark as ~1:1
397
+ // (measured, and wrong). Skip and count, same as the image branch.
398
+ if (raw && c === null) return null;
326
399
  if (c && c.a > 0) {
327
400
  layers.push(c);
328
401
  if (c.a >= 1) break;
@@ -382,7 +455,7 @@ async function main() {
382
455
  }
383
456
  };
384
457
  visitRoot(document.body);
385
- return { samples, skipped };
458
+ return { samples, skipped, unparsed };
386
459
  }).catch(() => null);
387
460
  await page.screenshot({ path: screenshotPath, scale: 'device', fullPage: true });
388
461
  await browser.close();
@@ -391,6 +464,7 @@ async function main() {
391
464
  url, errors, boxes, screenshotPath, navTiming, perfBudgetMs,
392
465
  contrastSamples: sampled && sampled.samples.length ? sampled.samples : null,
393
466
  contrastSkipped: sampled ? sampled.skipped : 0,
467
+ contrastUnparsed: sampled ? sampled.unparsed : 0,
394
468
  });
395
469
  if (process.argv.includes('--json')) {
396
470
  console.log(JSON.stringify(proof, null, 1));
@@ -401,7 +475,9 @@ async function main() {
401
475
  const c = proof.gates.contrast;
402
476
  const contrastNote = c.pass === null
403
477
  ? 'contrast:UNMEASURED'
404
- : `contrast:${c.pass ? 'pass' : 'FAIL'} ${c.checked - c.failing.length}/${c.checked} pairs${c.skippedImageBacked ? ` (+${c.skippedImageBacked} image-backed skipped)` : ''}`;
478
+ : c.reason
479
+ ? `contrast:FAIL ${c.reason}`
480
+ : `contrast:${c.pass ? 'pass' : 'FAIL'} ${c.checked - c.failing.length}/${c.checked} pairs${c.skippedImageBacked ? ` (+${c.skippedImageBacked} image-backed skipped)` : ''}${c.unparsedColors ? ` (+${c.unparsedColors} unreadable color value(s) — sampler coverage partial)` : ''}`;
405
481
  console.log(`[adia-probe] ${proof.verdict} — errors:${errors.length} boxes:${selectors.length - proof.gates.boundingBoxes.failing.length}/${selectors.length} shot:${screenshotPath} ${perfNote} ${contrastNote}`);
406
482
  if (proof.verdict === 'fail') {
407
483
  for (const e of errors) console.log(` console: ${e}`);