From 1a624fdb6bea3221b699d144c2054e9f54bd18db Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:57:50 -0400 Subject: [PATCH 1/5] fix(dashboard): define the font and token variables that were dropped Six custom properties were referenced by the Dashboard stylesheets but never defined anywhere in the repository. A bare `var(--x)` with no fallback makes the whole declaration invalid at computed-value time, so the browser dropped it and the element silently inherited the body face. Nothing logged and no test failed. Verified in Chromium against the built stylesheet: before this change `.delivery-acceptance-content code` computed to the body font; after, it computes to the Geist Mono stack. - `--font-sans` / `--font-mono` are now declared in `styles.css`. The body face resolves through `--font-sans`, and the stylesheets that referenced `--font-mono` now compute instead of dropping. - `--pw-font-mono` was a misspelled reference; it now reads `--font-mono`. - `--pw-danger`, `--pw-border`, `--pw-canvas`, and `--pw-surface` had no definition, so the Goal delete hover colour, the refresh error colour, and the operator-credential panel border, background, and input border were all being discarded. They now read the existing `--pw-red`, `--pw-line-strong`, `--pw-bg`, and `--pw-card` tokens that the three theme blocks already define, rather than adding a fourth set of names. - The three `var(--font-mono, monospace)` fallbacks in the Goal LoopX mode stylesheets named a bare `monospace` keyword, so those blocks rendered a different face from every other code surface whenever the token was missing. They now name the same family as the token. `var(--pw-surface, #fff)` in `.personal-manager-team-result` is left alone: it supplies a fallback and is a legitimate optional token. Signed-off-by: song <22676124+songoow@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) --- .../personal-workspace/goal-loopx-mode.css | 6 +++--- .../personal-workspace/personal-workspace.css | 10 +++++----- apps/presentation/dashboard/src/styles.css | 16 +++++++++++++++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css index 294b5046bb..748cdaf6ed 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css @@ -43,7 +43,7 @@ .goal-team-evidence { margin-block: 16px; padding: 16px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 12px; background: var(--pw-surface, #fff); } .goal-team-evidence h3, .goal-team-evidence h4 { margin: 0; font-size: 13px; } .goal-team-evidence article { margin-block: 16px; } -.goal-team-evidence pre { max-height: 320px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.6 var(--font-mono, monospace); padding: 12px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; } +.goal-team-evidence pre { max-height: 320px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.6 var(--font-mono, "Geist Mono Variable", "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace); padding: 12px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; } .goal-team-evidence label { display: grid; gap: 8px; font-weight: 500; } .goal-team-evidence textarea { box-sizing: border-box; resize: vertical; min-height: 88px; width: 100%; padding: 8px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; background: var(--pw-bg, #fafafa); color: inherit; font: inherit; } .goal-team-evidence textarea:focus-visible, .goal-team-evidence pre:focus-visible { outline: 2px solid #0070f3; outline-offset: 2px; } @@ -107,7 +107,7 @@ .goal-team-results summary { cursor: pointer; padding: 12px 0; font-size: 12px; min-height: 44px; } .goal-team-results code { overflow-wrap: anywhere; } .goal-team-report table code { white-space: pre; overflow-wrap: normal; word-break: normal; } -.goal-team-results pre { padding: 16px; max-height: 400px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.7 var(--font-mono, monospace); background: var(--pw-bg); } +.goal-team-results pre { padding: 16px; max-height: 400px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.7 var(--font-mono, "Geist Mono Variable", "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace); background: var(--pw-bg); } .goal-team-results h3 { font-size: 20px; letter-spacing: -.02em; } .goal-team-results > header > button { display: inline-flex; align-items: center; gap: 8px; } .goal-team-results-layout { display: grid; grid-template-columns: minmax(150px, 190px) minmax(0, 1fr); margin-top: 24px; gap: 24px; } @@ -116,7 +116,7 @@ .goal-team-result-list button > svg { flex-shrink: 0; margin-top: 2px; } .goal-team-result-list button > span { min-width: 0; display: grid; gap: 4px; } .goal-team-result-list button strong { font-size: 13px; font-weight: 500; } -.goal-team-result-list button small { color: var(--pw-muted, #666); font: 11px/1.5 var(--font-mono, monospace); } +.goal-team-result-list button small { color: var(--pw-muted, #666); font: 11px/1.5 var(--font-mono, "Geist Mono Variable", "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace); } .goal-team-result-check { margin-left: auto; visibility: hidden; } .goal-team-result-list button[aria-pressed="true"] .goal-team-result-check { visibility: visible; } .goal-team-result-reader { min-width: 0; border-left: 1px solid var(--pw-line); padding-left: 24px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index a22c30ce07..4a7d8d28dc 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -113,7 +113,7 @@ .personal-goal-lifecycle:disabled { opacity: .45; cursor: wait; } .personal-goal-lifecycle.is-pending svg { animation: personal-goal-lifecycle-spin .8s linear infinite; } @keyframes personal-goal-lifecycle-spin { to { transform: rotate(360deg); } } -.personal-goal-delete:hover { color: var(--pw-danger); } +.personal-goal-delete:hover { color: var(--pw-red); } .personal-goal-link-copy { display: grid; min-width: 0; gap: 2px; } .personal-goal-link-copy strong, .personal-goal-link-copy small { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } /* Two lines keep the distinguishing tail of similar Goal titles visible. */ @@ -261,7 +261,7 @@ .personal-action-feedback button { display: grid; place-items: center; border: 0; background: transparent; color: inherit; cursor: pointer; } .personal-refresh-control { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 6px; } .personal-refresh-control small { color: var(--pw-muted); font-size: 11px; white-space: nowrap; } -.personal-refresh-control.is-error small { color: var(--pw-danger); } +.personal-refresh-control.is-error small { color: var(--pw-red); } .personal-icon-button { display: inline-grid; flex: 0 0 36px; place-items: center; width: 36px; min-width: 36px; height: 36px; min-height: 36px; padding: 0; border: 1px solid var(--pw-line); border-radius: 10px; background: #fff; color: var(--pw-muted); cursor: pointer; } .personal-icon-button:hover { border-color: var(--pw-line-strong); color: var(--pw-text); } .personal-channel-scroll { overflow: auto; min-width: 0; min-height: 0; padding: 22px max(26px, calc((100% - 820px) / 2)); } @@ -820,17 +820,17 @@ /* The operator credential is a write-only machine setting: the panel keeps the redacted readback and the two inputs on one hairline surface so an operator can compare "what is configured" with "what I am about to store". */ -.personal-operator-credential { display: grid; gap: 12px; margin: 16px 0 24px; padding: 16px; border: 1px solid var(--pw-border); border-radius: 10px; background: var(--pw-surface); } +.personal-operator-credential { display: grid; gap: 12px; margin: 16px 0 24px; padding: 16px; border: 1px solid var(--pw-line-strong); border-radius: 10px; background: var(--pw-card); } .personal-operator-credential > header { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: start; } .personal-operator-credential > header strong { display: block; font-size: 13px; } .personal-operator-credential > header p { margin: 4px 0 0; color: var(--pw-muted); font-size: 12px; line-height: 1.5; } -.personal-operator-credential-status { color: var(--pw-muted); font-family: var(--pw-font-mono); font-size: 11px; } +.personal-operator-credential-status { color: var(--pw-muted); font-family: var(--font-mono); font-size: 11px; } .personal-operator-credential-readback { display: grid; gap: 6px; margin: 0; font-size: 12px; } .personal-operator-credential-readback > div { display: grid; grid-template-columns: 140px 1fr; gap: 10px; } .personal-operator-credential-readback dt { color: var(--pw-muted); } .personal-operator-credential-readback dd { margin: 0; overflow-wrap: anywhere; } .personal-operator-credential label { display: grid; gap: 4px; font-size: 12px; } -.personal-operator-credential input { padding: 7px 9px; border: 1px solid var(--pw-border); border-radius: 8px; background: var(--pw-canvas); color: inherit; font-size: 12px; } +.personal-operator-credential input { padding: 7px 9px; border: 1px solid var(--pw-line-strong); border-radius: 8px; background: var(--pw-bg); color: inherit; font-size: 12px; } .personal-operator-credential input:disabled { opacity: 0.6; } .personal-capability-scope-note p { margin: 0; font-size: 12px; line-height: 1.55; } .personal-capability-scope-note strong { display: block; color: var(--pw-text); } diff --git a/apps/presentation/dashboard/src/styles.css b/apps/presentation/dashboard/src/styles.css index 35994dfe54..f3a061e66e 100644 --- a/apps/presentation/dashboard/src/styles.css +++ b/apps/presentation/dashboard/src/styles.css @@ -5,11 +5,25 @@ @custom-variant dark (&:where(.dark, .dark *)); +/* + * Font tokens. `docs/development/design.md` names these two variables as the + * canonical families, so a stylesheet can reference them instead of repeating + * a stack. Both are declared here because a bare `var(--font-mono)` with no + * fallback drops the whole declaration when the token is missing, and the + * browser then falls back to the body face — which is how code blocks and + * monospace metadata silently rendered as sans-serif. + * + * `--font-mono` intentionally leads with the variable-font family name so it + * matches the `@fontsource-variable/geist-mono` import above, with the static + * and platform faces as fallbacks. + */ :root { color-scheme: light; - font-family: + --font-sans: "Geist Variable", "Geist", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "Geist Mono Variable", "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + font-family: var(--font-sans); } .dark { From 37285e924cbfc214987ce0a568725a8e71b13b3c Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:58:25 -0400 Subject: [PATCH 2/5] test(dashboard): reject references to undefined CSS custom properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `scripts/check-css-custom-properties.mjs` plus a focused contract test, so the class of defect fixed in the previous commit cannot return silently. The check fails on any bare `var(--token)` that no stylesheet — or inline style — defines. `var(--token, fallback)` is allowed, because a fallback is an explicit statement that the token is optional and the declaration still computes. This distinction matters: `--pw-surface` is legitimately optional in one place while its bare use elsewhere was a real bug. Two guards keep the check from passing vacuously, since a non-zero exit is its only signal. It fails when the scope yields no stylesheets, and it fails when the definition scan finds fewer than 30 tokens — a floor asserted from the tree rather than from the scan, so a broken definition regex cannot report a clean result. Both were validated by mutation: reintroducing an undefined reference, deleting a real token, and breaking the definition scan each produce exit 1. The check runs in the existing `dashboard-acceptance` job, after dependency install and before the coverage run, and is available locally as `npm run check:css-custom-properties`. `font-token.test.mjs` pins the specific decisions — what the two font tokens are defined as, that the regressed call sites resolve through them, and that the four dead private names stay gone. It joins `smoke:personal-workspace`. Scope is the Dashboard surface only. A scope is added when a real surface needs it; the marketing site defines its own tokens and `--terminal-delay` there is set from `App.tsx`, which the definition scan reads. Signed-off-by: song <22676124+songoow@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/python-tests.yml | 3 + apps/presentation/dashboard/package.json | 5 +- .../personal-workspace/font-token.test.mjs | 98 ++++++++ scripts/check-css-custom-properties.mjs | 231 ++++++++++++++++++ 4 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/font-token.test.mjs create mode 100644 scripts/check-css-custom-properties.mjs diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index c08e8367f5..27b7217b96 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -324,6 +324,9 @@ jobs: npm ci --ignore-scripts npm ci --ignore-scripts --prefix apps/presentation/dashboard + - name: Reject undefined CSS custom properties + run: node scripts/check-css-custom-properties.mjs dashboard + - name: Measure Dashboard unit and browser interactions env: LOOPX_DASHBOARD_COVERAGE: "1" diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 7a9d0d4382..8451d6467b 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -10,6 +10,7 @@ "build": "tsc --noEmit && vite build && npm run build:chat", "build:chat": "node ../../../scripts/chat_bundle_launcher.mjs build", "build:desktop": "tsc --noEmit && vite build", + "check:css-custom-properties": "node ../../../scripts/check-css-custom-properties.mjs dashboard", "dev": "bash ../../../scripts/dashboard-dev.sh", "dev:web": "vite --host 127.0.0.1", "export:frontstage-share": "node ../../../examples/export-frontstage-share-bundle.mjs", @@ -35,9 +36,9 @@ "smoke:home-route": "rm -rf /tmp/loopx-home-route-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-home-route-smoke smoke/home-route-smoke.ts && node /tmp/loopx-home-route-smoke/home-route-smoke.js", "smoke:delegation-preflight": "rm -rf node_modules/.cache/loopx-delegation-preflight-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --jsx react-jsx --skipLibCheck --strict --rootDir . --outDir node_modules/.cache/loopx-delegation-preflight-smoke smoke/delegation-preflight-smoke.tsx src/data/delegation-preflight.ts src/features/personal-workspace/delegation-preflight-status.tsx && node node_modules/.cache/loopx-delegation-preflight-smoke/smoke/delegation-preflight-smoke.js", "smoke:delegation-preflight-browser": "node --experimental-strip-types smoke/delegation-preflight-browser-smoke.mjs", - "smoke:personal-workspace": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:workspace-locale": "LOOPX_PERSONAL_WORKSPACE_SCENARIO=workspace-locale node ../../../examples/personal-workspace-browser-smoke.mjs", - "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", + "smoke:personal-workspace-packaged": "npm run smoke:goal-order && npm run smoke:goal-activity && npm run smoke:proposal-recency && npm run smoke:delegation-preflight && node src/features/personal-workspace/workspace-theme.test.mjs && node src/features/personal-workspace/font-token.test.mjs && node src/features/personal-workspace/personal-workspace-contract.test.mjs && LOOPX_PERSONAL_WORKSPACE_PACKAGED=1 LOOPX_PLAYWRIGHT_PACKAGE=\"$PWD/node_modules/playwright\" node ../../../examples/personal-workspace-browser-smoke.mjs", "smoke:todo-resume-condition": "rm -rf /tmp/loopx-todo-resume-condition-smoke && tsc --ignoreConfig --target ES2022 --module commonjs --moduleResolution node --ignoreDeprecations 6.0 --skipLibCheck --strict --outDir /tmp/loopx-todo-resume-condition-smoke smoke/todo-resume-condition-smoke.ts src/features/personal-workspace/todo-resume-condition.ts && node /tmp/loopx-todo-resume-condition-smoke/smoke/todo-resume-condition-smoke.js", "smoke:presentation-surface-schema": "rm -rf /tmp/loopx-presentation-surface-schema-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --skipLibCheck --strict --resolveJsonModule --esModuleInterop --outDir /tmp/loopx-presentation-surface-schema-smoke smoke/presentation-surface-schema-smoke.ts src/data/status.ts src/data/decision-research.ts src/data/goal-channel-frontstage.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-presentation-surface-schema-smoke/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.js", "smoke:projection-localization": "rm -rf /tmp/loopx-projection-localization-smoke && tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --outDir /tmp/loopx-projection-localization-smoke smoke/projection-localization-smoke.ts src/features/personal-workspace/projection-localization.ts && node /tmp/loopx-projection-localization-smoke/smoke/projection-localization-smoke.js", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/font-token.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/font-token.test.mjs new file mode 100644 index 0000000000..cfd0765f4e --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/font-token.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +/** + * Contract for the font and monospace tokens the Dashboard stylesheets reference. + * + * `docs/development/design.md` names `--font-sans` and `--font-mono` as the + * canonical families, so stylesheets reference them instead of repeating a + * stack. That only works while both tokens are actually defined: a bare + * `var(--font-mono)` with no fallback makes the browser drop the whole + * declaration, and the element silently inherits the body face. Code blocks and + * monospace metadata then render as sans-serif with nothing to notice. + * + * `scripts/check-css-custom-properties.mjs` guards the general case (any bare + * reference to any undefined token). This test pins the specific decision: what + * the two font tokens are defined as, and that the sites that regressed keep + * resolving through them rather than through a locally repeated stack. + */ + +const dashboardStyles = readFileSync(new URL("../../styles.css", import.meta.url), "utf8"); +const workspaceStyles = readFileSync(new URL("./personal-workspace.css", import.meta.url), "utf8"); +const loopxModeStyles = readFileSync(new URL("./goal-loopx-mode.css", import.meta.url), "utf8"); +const collaborationStyles = readFileSync(new URL("./collaboration-card.css", import.meta.url), "utf8"); +const deliveryStyles = readFileSync(new URL("./delivery-review.css", import.meta.url), "utf8"); + +// The two font tokens exist, and the body face is expressed through the sans token. +assert.match( + dashboardStyles, + /--font-sans:\s*\n?\s*"Geist Variable", "Geist", Inter,/, + "The sans token is defined from the Geist Variable family", +); +assert.match( + dashboardStyles, + /--font-mono: "Geist Mono Variable", "Geist Mono", ui-monospace,/, + "The mono token is defined from the Geist Mono family with platform fallbacks", +); +assert.match( + dashboardStyles, + /font-family: var\(--font-sans\)/, + "The document body face resolves through the sans token", +); + +// Sites that previously regressed must reference the token, not a repeated stack. +assert.ok( + deliveryStyles.includes(".delivery-acceptance-content code { font-family: var(--font-mono);"), + "Delivery review code resolves through the mono token", +); +assert.ok( + collaborationStyles.includes(".personal-collaboration small { display: block; font-family: var(--font-mono);"), + "Collaboration card metadata resolves through the mono token", +); +assert.ok( + workspaceStyles.includes(".personal-operator-credential-status { color: var(--pw-muted); font-family: var(--font-mono);"), + "Operator credential status resolves through the mono token, not a misspelled private one", +); + +// Where a fallback is given, it must be the same family — a bare `monospace` +// keyword would quietly render a different face from every other code surface. +const fallbacks = [...loopxModeStyles.matchAll(/var\(--font-mono,\s*([^)]*)\)/g)].map((match) => match[1]); +assert.ok(fallbacks.length > 0, "Goal LoopX mode keeps an explicit mono fallback"); +for (const fallback of fallbacks) { + assert.match( + fallback, + /"Geist Mono Variable"/, + `The mono fallback names the Geist Mono family rather than a bare keyword: ${fallback}`, + ); +} + +// The private token names that had *bare* references and no definition must not +// come back. `--pw-surface` is deliberately not listed: it stays valid as an +// optional token because `.personal-manager-team-result` supplies a fallback +// (`var(--pw-surface, #fff)`), so the general check — which only rejects bare +// references — is the right guard for it. +// +// Match on a token boundary so `--pw-surface-soft`, a different real token, +// cannot be mistaken for a dead name. +const escapeForRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +for (const [name, source] of [ + ["--pw-font-mono", workspaceStyles], + ["--pw-danger", workspaceStyles], + ["--pw-border", workspaceStyles], + ["--pw-canvas", workspaceStyles], +]) { + const pattern = new RegExp(`${escapeForRegExp(name)}(?![A-Za-z0-9_-])`); + assert.ok( + !pattern.test(source), + `${name} was never defined anywhere; use the existing token instead of reintroducing it`, + ); +} + +// The bare `var(--pw-surface)` in the operator credential was the regression; +// the optional use with a fallback is legitimate and must stay. +assert.ok( + !workspaceStyles.includes("background: var(--pw-surface);"), + "The operator credential reads the card token rather than the optional surface token", +); + +console.log("font-token contract: ok"); diff --git a/scripts/check-css-custom-properties.mjs b/scripts/check-css-custom-properties.mjs new file mode 100644 index 0000000000..3e7820e205 --- /dev/null +++ b/scripts/check-css-custom-properties.mjs @@ -0,0 +1,231 @@ +#!/usr/bin/env node +/** + * Reject `var(--token)` references that no stylesheet — and no inline style — + * ever defines. + * + * Why this exists + * --------------- + * An undefined custom property in `var(--x)` with no fallback makes the whole + * declaration *invalid at computed-value time*. The browser drops that one + * declaration and the element silently inherits whatever the cascade would + * otherwise give it: the wrong font stack, a missing border, a missing + * background. Nothing logs, nothing fails, and a component can render slightly + * wrong in production for months. + * + * That is exactly what happened here. Six tokens were referenced but never + * defined anywhere in the repository: + * + * var(--font-mono) delivery-review.css, collaboration-card.css + * var(--pw-font-mono) personal-workspace.css (typo for --font-mono) + * var(--pw-border) personal-workspace.css + * var(--pw-canvas) personal-workspace.css + * var(--pw-surface) personal-workspace.css + * var(--pw-danger) personal-workspace.css + * + * `var(--x, fallback)` is deliberately allowed: a fallback is an explicit + * statement that the token is optional, so the declaration still computes. + * Only bare `var(--x)` is checked. + * + * Scopes + * ------ + * --scope dashboard apps/presentation/dashboard/src (default) + * + * Adding a new scope means adding a real surface, not a directory that happens + * to contain CSS. A scope that over-reports gets muted, which is worse than + * not having the check. + * + * Exit codes: 0 = clean, 1 = undefined token references found. + */ + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; + +const REPO_ROOT = resolve(import.meta.dirname, ".."); + +const SCOPES = { + dashboard: { + roots: ["apps/presentation/dashboard/src"], + // Files that legitimately reference tokens owned by another surface. + // Keep this list empty unless review establishes why a token is genuinely + // out of scope for this surface. + allowlistedFiles: new Set(), + }, +}; + +const STYLE_EXTENSIONS = new Set([".css"]); +const CODE_EXTENSIONS = new Set([".ts", ".tsx"]); + +function walk(dir, extensions, found = []) { + for (const entry of readdirSync(dir)) { + if (entry === "node_modules" || entry === "dist") continue; + const full = join(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + walk(full, extensions, found); + continue; + } + const dot = entry.lastIndexOf("."); + if (dot === -1) continue; + if (extensions.has(entry.slice(dot))) found.push(full); + } + return found; +} + +/** + * Strip /* ... *\/ comments so a token mentioned in prose is not read as a + * reference, while preserving character offsets for line numbers. + */ +function stripComments(text) { + return text.replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, " ")); +} + +function lineAt(text, index) { + let line = 1; + for (let i = 0; i < index && i < text.length; i += 1) { + if (text[i] === "\n") line += 1; + } + return line; +} + +/** + * Definitions, per file. + * + * Definition forms that occur in this repository: + * 1. inside a rule block `.selector { --x: value; }` + * 2. registered custom property `@property --x { ... }` + * 3. inline style object `style={{ "--x": value }}` + * 4. setProperty("--x", ...) imperative + * + * Definitions are collected globally rather than per-cascade-scope. A token + * defined on `.personal-workspace-shell` and referenced inside it is correct, + * and proving that statically needs a cascade engine. Global collection trades + * a little precision for zero false positives, which is the right trade for a + * gate that must never cry wolf. + */ +function collectDefinitions(files) { + const definitions = new Set(); + const reasons = new Map(); + const add = (name, reason) => { + definitions.add(name); + if (!reasons.has(name)) reasons.set(name, reason); + }; + + for (const file of files) { + const rel = relative(REPO_ROOT, file).split(sep).join("/"); + const raw = readFileSync(file, "utf8"); + const text = stripComments(raw); + + for (const match of text.matchAll(/@property\s+(--[A-Za-z0-9_-]+)/g)) { + add(match[1], `@property in ${rel}`); + } + for (const match of text.matchAll(/(?:^|[;{(\s,])(--[A-Za-z0-9_-]+)\s*:/gm)) { + add(match[1], `declared in ${rel}`); + } + for (const match of text.matchAll(/setProperty\(\s*["'`](--[A-Za-z0-9_-]+)["'`]/g)) { + add(match[1], `setProperty in ${rel}`); + } + for (const match of text.matchAll(/["'`](--[A-Za-z0-9_-]+)["'`]\s*:/g)) { + add(match[1], `inline style in ${rel}`); + } + } + + return { definitions, reasons }; +} + +/** + * Bare references: `var(--x)` with no fallback. `var(--x, ...)` is skipped even + * when the fallback is a nested var() or another token. + */ +function collectBareReferences(files) { + const references = []; + for (const file of files) { + const rel = relative(REPO_ROOT, file).split(sep).join("/"); + const text = stripComments(readFileSync(file, "utf8")); + for (const match of text.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)\s*\)/g)) { + references.push({ token: match[1], file: rel, line: lineAt(text, match.index) }); + } + } + return references; +} + +function main() { + const scopeName = process.argv[2] ?? "dashboard"; + const scope = SCOPES[scopeName]; + if (!scope) { + console.error(`Unknown scope: ${scopeName}`); + console.error(`Known scopes: ${Object.keys(SCOPES).join(", ")}`); + process.exitCode = 2; + return; + } + + const styleFiles = []; + const codeFiles = []; + for (const root of scope.roots) { + const absolute = join(REPO_ROOT, root); + styleFiles.push(...walk(absolute, STYLE_EXTENSIONS)); + codeFiles.push(...walk(absolute, CODE_EXTENSIONS)); + } + + // Anti-vacuity: if the roots or extensions stop matching, fail loudly rather + // than reporting a clean tree that was never parsed. + if (styleFiles.length === 0) { + console.error(`check-css-custom-properties: no stylesheets found under ${scope.roots.join(", ")}`); + process.exitCode = 1; + return; + } + + const { definitions, reasons } = collectDefinitions([...styleFiles, ...codeFiles]); + const references = collectBareReferences(styleFiles).filter( + (reference) => !scope.allowlistedFiles.has(reference.file), + ); + + const undefinedReferences = references.filter((reference) => !definitions.has(reference.token)); + + // Anti-vacuity guard, independent of the definitions scan. + // + // A sentinel set drawn from the same scan cannot detect a scan that stopped + // matching: if the definition regex breaks, the sentinels simply are not + // found either, and a naive check would report every reference as undefined + // (noisy) — or, if written as "fail only when a sentinel IS found", pass + // silently (dangerous). So this asserts a floor on how many tokens the scan + // must produce, derived from the tree rather than from the scan. + // + // The floor is a lower bound, not an equality: adding tokens is normal. When + // a genuine token removal drops the count below it, lower the constant in the + // same change that removes the tokens. + const DEFINED_TOKEN_FLOOR = 30; + if (definitions.size < DEFINED_TOKEN_FLOOR) { + console.error( + `check-css-custom-properties: only ${definitions.size} defined tokens found under ` + + `${scope.roots.join(", ")}, below the floor of ${DEFINED_TOKEN_FLOOR}. ` + + `The definition scan is probably broken, not the tree.`, + ); + process.exitCode = 1; + return; + } + + if (undefinedReferences.length > 0) { + console.error( + `check-css-custom-properties: ${undefinedReferences.length} bare var() reference(s) ` + + `to tokens that are never defined. Each one silently drops its declaration.\n`, + ); + for (const reference of undefinedReferences) { + console.error(` ${reference.file}:${reference.line} var(${reference.token})`); + } + console.error( + `\nFix by defining the token or giving the reference a fallback. ` + + `Do not delete the declaration to silence this check.`, + ); + process.exitCode = 1; + return; + } + + const scoped = references.filter((reference) => reference.token.startsWith("--pw-")); + console.log( + `check-css-custom-properties[${scopeName}]: ${styleFiles.length} stylesheets, ` + + `${definitions.size} defined tokens, ${references.length} bare references ` + + `(${scoped.length} --pw-*). No undefined references.`, + ); +} + +main(); From 9b05fee62a76526738afc122c46476e5931e8c94 Mon Sep 17 00:00:00 2001 From: song <22676124+songoow@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:58:47 -0400 Subject: [PATCH 3/5] docs(design): record the per-surface font fallbacks and the token check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typography section listed fallback stacks that no surface used, and did not say where the tokens live or what happens when one is missing. State the rule the fix depends on — reference the token, never repeat the stack, and define the token on the surface that uses it — and give the real declarations for both surfaces, since each bundles its own font files and the two differ. Note the check that enforces it and the `var(--token, fallback)` exception. Signed-off-by: song <22676124+songoow@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/development/design.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/development/design.md b/docs/development/design.md index fe77acb5b7..deb3a4b33e 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -111,13 +111,32 @@ Dark mode is an inverse of the same system, not a separate visual identity: Use **Geist Sans** for UI and prose and **Geist Mono** for code, data, compact technical labels, and section eyebrows. -Fallbacks: +Fallbacks are owned per surface, because each bundles its own font files. A +stylesheet must reference the token rather than repeat the stack, and the token +must be defined on the surface that uses it — a bare `var(--font-mono)` with no +fallback drops the whole declaration when the token is missing, and the element +silently inherits the body face instead of failing visibly. + +Dashboard (`apps/presentation/dashboard/src/styles.css`): + +```css +--font-sans: + "Geist Variable", "Geist", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; +--font-mono: "Geist Mono Variable", "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; +``` + +Marketing site (`apps/presentation/site/src/styles.css`): ```css ---font-sans: "Geist", "Inter", "Helvetica Neue", Arial, sans-serif; --font-mono: "Geist Mono", "JetBrains Mono", "SFMono-Regular", monospace; ``` +`scripts/check-css-custom-properties.mjs` rejects any bare reference to a custom +property that no stylesheet or inline style defines. Tokens that are genuinely +optional may keep a fallback (`var(--pw-surface, #fff)`); the check only rejects +references that would be dropped. + ### Type Scale | Token | Size / line height | Weight | Tracking | Use | From 51424c608f4c8f9c8342e8a2f29e4caf29a22b31 Mon Sep 17 00:00:00 2001 From: song Date: Wed, 30 Sep 2026 06:56:28 -0400 Subject: [PATCH 4/5] fix(dashboard): bound the CSS token classifier to real style sinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found a P1 in the guard added by the previous commit: the inline-style branch matched any quoted `"--token":` key in TS/TSX, so unrelated data could satisfy a CSS reference and the required check would exit 0. Reproduced on the exact head before fixing. With export const themeMetadata = { "--review-ghost": "not an inline style" }; in a `.tsx` file and `.probe { color: var(--review-ghost) }` in CSS, the check reported "No undefined references" and exited 0 — recreating the silent failure the gate exists to prevent. The classifier is now anchored to a style sink rather than to key syntax: style={{ ... }} JSX style attribute style: { ... } style object property const s: CSSProperties = {...} annotated style constant { ... } as CSSProperties trailing cast (the Dashboard's own form) A quoted key only counts inside one of those bodies. The trailing-cast case is anchored on `as CSSProperties` rather than on `return {`, because matching a bare `return {` would accept any function returning an object with such a key — the same defect in a different shape. Object bodies are delimited by brace depth with strings skipped, so a nested object cannot truncate the match early. Why a bounded match and not an AST: this check runs under a bare `node` in the dashboard-acceptance job, and `typescript` is not resolvable from the repository root there (`MODULE_NOT_FOUND`). An AST would make the gate depend on a package it does not currently need. Committed fixtures, as the review asked, under `scripts/fixtures/css-custom-properties/`: `sinks.tsx` + `positive.css` for the five sink forms this must accept, and `negative/dataKeys.ts` + `negative.css` for the look-alike key this must reject. The test drives them through the shipped `collectDefinitions`, so it cannot drift from the implementation. The test was mutation-checked in both directions: reinstating the over-broad match fails on the data-key assertion, and dropping cast support fails on the `--fixture-cast` assertion. The classifier contract runs before the scan step in CI so a broken classifier cannot report a clean tree. `--goal-hue` in `goal-activity-view.tsx` — the only inline custom property in the Dashboard — stays recognised, and the dashboard scope still reports no undefined references. Signed-off-by: song --- .github/workflows/python-tests.yml | 3 + apps/presentation/dashboard/package.json | 2 +- scripts/check-css-custom-properties.mjs | 152 ++++++++++++++++-- scripts/check-css-custom-properties.test.mjs | 134 +++++++++++++++ .../css-custom-properties/negative.css | 8 + .../negative/dataKeys.ts | 20 +++ .../css-custom-properties/positive.css | 27 ++++ .../fixtures/css-custom-properties/sinks.tsx | 43 +++++ 8 files changed, 377 insertions(+), 12 deletions(-) create mode 100644 scripts/check-css-custom-properties.test.mjs create mode 100644 scripts/fixtures/css-custom-properties/negative.css create mode 100644 scripts/fixtures/css-custom-properties/negative/dataKeys.ts create mode 100644 scripts/fixtures/css-custom-properties/positive.css create mode 100644 scripts/fixtures/css-custom-properties/sinks.tsx diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 27b7217b96..9a5b2e28e6 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -324,6 +324,9 @@ jobs: npm ci --ignore-scripts npm ci --ignore-scripts --prefix apps/presentation/dashboard + - name: Check the CSS custom-property classifier contract + run: node scripts/check-css-custom-properties.test.mjs + - name: Reject undefined CSS custom properties run: node scripts/check-css-custom-properties.mjs dashboard diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 8451d6467b..3b4caad9b1 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -10,7 +10,7 @@ "build": "tsc --noEmit && vite build && npm run build:chat", "build:chat": "node ../../../scripts/chat_bundle_launcher.mjs build", "build:desktop": "tsc --noEmit && vite build", - "check:css-custom-properties": "node ../../../scripts/check-css-custom-properties.mjs dashboard", + "check:css-custom-properties": "node ../../../scripts/check-css-custom-properties.test.mjs && node ../../../scripts/check-css-custom-properties.mjs dashboard", "dev": "bash ../../../scripts/dashboard-dev.sh", "dev:web": "vite --host 127.0.0.1", "export:frontstage-share": "node ../../../examples/export-frontstage-share-bundle.mjs", diff --git a/scripts/check-css-custom-properties.mjs b/scripts/check-css-custom-properties.mjs index 3e7820e205..b9ec58155b 100644 --- a/scripts/check-css-custom-properties.mjs +++ b/scripts/check-css-custom-properties.mjs @@ -49,12 +49,20 @@ const SCOPES = { // Keep this list empty unless review establishes why a token is genuinely // out of scope for this surface. allowlistedFiles: new Set(), + // Lower bound on how many definitions the scan must produce. See the + // anti-vacuity guard in main() for why this is asserted from the tree. + definedTokenFloor: 30, }, }; const STYLE_EXTENSIONS = new Set([".css"]); const CODE_EXTENSIONS = new Set([".ts", ".tsx"]); +function extensionOf(file) { + const dot = file.lastIndexOf("."); + return dot === -1 ? "" : file.slice(dot); +} + function walk(dir, extensions, found = []) { for (const entry of readdirSync(dir)) { if (entry === "node_modules" || entry === "dist") continue; @@ -87,6 +95,117 @@ function lineAt(text, index) { return line; } +/** + * Inline-style custom properties written from TS/TSX, e.g. + * + * style={{ "--goal-hue": identity.hue } as CSSProperties} + * const style: CSSProperties = { "--pw-offset": "2px" }; + * + * Why this is not "any quoted `--x:` key" + * --------------------------------------- + * A quoted property is only a *definition* when it lands in a style sink. An + * ordinary data object — a theme metadata table, an i18n map, a token catalog — + * can carry the same quoted key without ever setting a CSS property: + * + * export const themeMetadata = { "--review-ghost": "not a style" }; + * + * Treating that as a definition is how a genuinely undefined + * `var(--review-ghost)` gets waved through: the reference is subtracted from the + * undefined set and the gate exits 0. That is the exact silent failure this + * checker exists to prevent, so the match has to be bounded to a style sink. + * + * `setProperty` is matched separately below. + */ +const STYLE_SINK_OPENERS = [ + // JSX attribute: style={{ ... }} / style={{ ... } as CSSProperties} + /style\s*=\s*\{\s*\{/g, + // Object property in a style constant: style: { ... } + /(?:^|[^A-Za-z0-9_$])style\s*:\s*\{/g, + // A value annotated as CSSProperties: const style: CSSProperties = { ... } + /:\s*CSSProperties\s*=\s*\{/g, +]; + +/** + * Objects justified by a trailing cast rather than a leading annotation: + * + * return { "--goal-hue": hue } as CSSProperties; + * + * The cast is the only thing that makes this a style sink, so it has to be the + * anchor. Matching `return {` instead would accept any function that returns an + * object containing a quoted `--token` key — which is the false-negative the + * classifier exists to avoid, just wearing a different shape. + */ +const STYLE_SINK_TRAILING_CAST = /\}\s*as\s+CSSProperties\b/g; + +const QUOTED_CUSTOM_PROPERTY = /["'`](--[A-Za-z0-9_-]+)["'`]\s*:/g; + +/** + * Return the inner text of the object literal whose opening brace is the last + * character of `openIndex`, using brace depth rather than a lazy regex so a + * nested object (`{ "--x": f({ a: 1 }) }`) does not truncate the match early. + */ +function objectLiteralBody(text, openIndex) { + let depth = 0; + for (let i = openIndex; i < text.length; i += 1) { + const char = text[i]; + if (char === "{") depth += 1; + else if (char === "}") { + depth -= 1; + if (depth === 0) return text.slice(openIndex + 1, i); + } else if (char === '"' || char === "'" || char === "`") { + // Skip string contents so a brace inside a string cannot unbalance depth. + for (let j = i + 1; j < text.length; j += 1) { + if (text[j] === "\\") { j += 1; continue; } + if (text[j] === char) { i = j; break; } + } + } + } + return null; +} + +/** + * Return the inner text of the object literal whose *closing* brace is `closeIndex`, + * walking backwards with brace depth. Used for the trailing-cast form, where the + * cast is the reliable anchor and the object start has to be found by matching + * outwards from the end. + */ +function objectLiteralBodyBefore(text, closeIndex) { + let depth = 0; + for (let i = closeIndex; i >= 0; i -= 1) { + const char = text[i]; + if (char === "}") depth += 1; + else if (char === "{") { + depth -= 1; + if (depth === 0) return text.slice(i + 1, closeIndex); + } + } + return null; +} + +function collectInlineStyleDefinitions(text, add, rel) { + const bodies = []; + + for (const opener of STYLE_SINK_OPENERS) { + for (const match of text.matchAll(opener)) { + const openIndex = match.index + match[0].length - 1; + const body = objectLiteralBody(text, openIndex); + if (body !== null) bodies.push(body); + } + } + + for (const match of text.matchAll(STYLE_SINK_TRAILING_CAST)) { + const closeIndex = match.index; + const body = objectLiteralBodyBefore(text, closeIndex); + if (body !== null) bodies.push(body); + } + + for (const body of bodies) { + for (const key of body.matchAll(QUOTED_CUSTOM_PROPERTY)) { + add(key[1], `inline style in ${rel}`); + } + } +} + /** * Definitions, per file. * @@ -118,14 +237,21 @@ function collectDefinitions(files) { for (const match of text.matchAll(/@property\s+(--[A-Za-z0-9_-]+)/g)) { add(match[1], `@property in ${rel}`); } - for (const match of text.matchAll(/(?:^|[;{(\s,])(--[A-Za-z0-9_-]+)\s*:/gm)) { - add(match[1], `declared in ${rel}`); - } for (const match of text.matchAll(/setProperty\(\s*["'`](--[A-Za-z0-9_-]+)["'`]/g)) { add(match[1], `setProperty in ${rel}`); } - for (const match of text.matchAll(/["'`](--[A-Za-z0-9_-]+)["'`]\s*:/g)) { - add(match[1], `inline style in ${rel}`); + // A stylesheet declares a property wherever it appears, so the permissive + // pattern is right there. It is wrong for TS/TSX, where the same shape is + // usually a plain data key: `{ "--review-ghost": "not a style" }` is an + // object property, not a definition, and counting it lets a genuinely + // undefined `var(--review-ghost)` through the gate. Code files therefore + // contribute only through the style sinks matched below. + if (CODE_EXTENSIONS.has(extensionOf(file))) { + collectInlineStyleDefinitions(text, add, rel); + } else { + for (const match of text.matchAll(/(?:^|[;{(\s,])(--[A-Za-z0-9_-]+)\s*:/gm)) { + add(match[1], `declared in ${rel}`); + } } } @@ -191,13 +317,13 @@ function main() { // must produce, derived from the tree rather than from the scan. // // The floor is a lower bound, not an equality: adding tokens is normal. When - // a genuine token removal drops the count below it, lower the constant in the - // same change that removes the tokens. - const DEFINED_TOKEN_FLOOR = 30; - if (definitions.size < DEFINED_TOKEN_FLOOR) { + // a genuine token removal drops the count below it, lower the const in the + // scope definition in the same change that removes the tokens. + const definedTokenFloor = scope.definedTokenFloor; + if (definitions.size < definedTokenFloor) { console.error( `check-css-custom-properties: only ${definitions.size} defined tokens found under ` + - `${scope.roots.join(", ")}, below the floor of ${DEFINED_TOKEN_FLOOR}. ` + + `${scope.roots.join(", ")}, below the floor of ${definedTokenFloor}. ` + `The definition scan is probably broken, not the tree.`, ); process.exitCode = 1; @@ -228,4 +354,8 @@ function main() { ); } -main(); +// Imported by `check-css-custom-properties.test.mjs`, which covers the +// classifier's negative case directly. Running the script still scans. +export { collectDefinitions }; + +if (import.meta.filename === process.argv[1]) main(); diff --git a/scripts/check-css-custom-properties.test.mjs b/scripts/check-css-custom-properties.test.mjs new file mode 100644 index 0000000000..cd4c377f20 --- /dev/null +++ b/scripts/check-css-custom-properties.test.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * Contract for the definition classifier in `check-css-custom-properties.mjs`. + * + * The scan is a required CI gate whose only signal is a non-zero exit, so the + * way it *fails to fail* matters more than the way it passes. A definition set + * that is too generous is the dangerous direction: any token it wrongly counts + * as defined lets a genuinely undefined `var(--token)` through, and the browser + * then drops that declaration silently. Nothing else catches the regression. + * + * The cases below are driven by two committed fixtures, not by strings built in + * this file, so the contract is reviewable as a diff and the same inputs can be + * run through the script by hand: + * + * scripts/fixtures/css-custom-properties/sinks.tsx real style sinks + * scripts/fixtures/css-custom-properties/positive.css their bare references + * scripts/fixtures/css-custom-properties/negative/ the look-alike data key + * scripts/fixtures/css-custom-properties/negative.css its bare reference + * + * The boundary under test, one row per line: + * + * defined `style={{ "--x": v }}` a JSX style sink + * defined `{ … } as CSSProperties` a cast style sink + * defined `const s: CSSProperties = {...}` an annotated style constant + * defined `style: { "--x": v }` a style object property + * defined `el.style.setProperty(...)` an imperative write + * defined `selector { --x: v }` a stylesheet declaration + * NOT defined `{ "--x": "data" }` an ordinary data key + * + * The last row is the one that regressed: a theme metadata table, an i18n map or + * a token catalogue can carry the same quoted key without setting any CSS + * property, and reading it as a definition waves the real reference through. + */ + +import assert from "node:assert/strict"; + +import { collectDefinitions } from "./check-css-custom-properties.mjs"; + +const FIXTURES = new URL("./fixtures/css-custom-properties/", import.meta.url); +const fixturePath = (name) => new URL(name, FIXTURES).pathname; + +const POSITIVE_FILES = [fixturePath("sinks.tsx"), fixturePath("positive.css")]; +const NEGATIVE_FILES = [ + fixturePath("negative/dataKeys.ts"), + fixturePath("negative.css"), +]; + +const definitionsFor = (paths) => collectDefinitions(paths).definitions; + +// --- Positive direction: every real style sink must be recognised ------------ +// +// Each token below is written through one sink form, and `positive.css` carries a +// bare `var()` for it. Missing any one of these turns correct code into a gate +// failure — which is how a required check gets muted. +{ + const definitions = definitionsFor(POSITIVE_FILES); + const expected = { + "--fixture-jsx": "a JSX style attribute", + "--fixture-cast": "a trailing `as CSSProperties` cast", + "--fixture-annotated": "a CSSProperties-annotated constant", + "--fixture-object-property": "a `style:` object property", + "--fixture-imperative": "a setProperty call", + }; + for (const [token, form] of Object.entries(expected)) { + assert.ok(definitions.has(token), `${form} defines ${token}`); + } +} + +// --- Negative direction: a data key is not a definition --------------------- +// +// The reviewer's P1 case. `negative/dataKeys.ts` carries `--fixture-ghost` as an +// ordinary object key and `negative.css` references it bare; the reference must +// stay undefined so the scope exits non-zero. +{ + const definitions = definitionsFor(NEGATIVE_FILES); + assert.ok( + !definitions.has("--fixture-ghost"), + "an ordinary data key does not define a custom property, so a bare var() reference to it stays undefined", + ); +} + +// --- The two directions in one file ---------------------------------------- +// +// `sinks.tsx` carries the ghost keys beside the real sinks. Only the sinks may +// count; if the classifier scanned every quoted key, the ghosts would appear too. +{ + const definitions = definitionsFor(POSITIVE_FILES); + assert.ok( + !definitions.has("--fixture-ghost-data-key"), + "a data key beside real style sinks stays out of the definitions", + ); + assert.ok( + !definitions.has("--fixture-ghost-nested"), + "a nested config key beside real style sinks stays out of the definitions", + ); +} + +// --- Stylesheet declarations still count ------------------------------------ +// +// The plain CSS form is the one the gate exists for, and it must survive the +// narrowing that fixed the regression. `negative.css` declares nothing, so it +// also proves a references-only stylesheet yields an empty definition set +// instead of an error or a phantom token. +{ + const declared = collectDefinitions([fixturePath("positive.css")]).definitions; + assert.ok( + !declared.has("--fixture-ghost"), + "a stylesheet that only references a token does not define it", + ); + + const negativeDeclarations = definitionsFor([fixturePath("negative.css")]); + assert.equal( + negativeDeclarations.size, + 0, + "a references-only stylesheet produces no definitions", + ); +} + +// --- Anti-vacuity: the stylesheet direction must actually match ------------- +// +// Without this, a classifier that matched nothing at all would still satisfy +// every negative assertion above while silently disabling the gate. The positive +// fixture carries both stylesheet declarations and sink-defined tokens, so the +// scan has to produce definitions from more than one source. +{ + const fromSinksAndSheet = definitionsFor(POSITIVE_FILES); + const fromSheetOnly = collectDefinitions([fixturePath("positive.css")]).definitions; + assert.ok( + fromSinksAndSheet.size > fromSheetOnly.size, + "inline sinks add definitions beyond the stylesheet declarations alone", + ); +} + +console.log("check-css-custom-properties classifier contract: ok"); diff --git a/scripts/fixtures/css-custom-properties/negative.css b/scripts/fixtures/css-custom-properties/negative.css new file mode 100644 index 0000000000..54a4d68696 --- /dev/null +++ b/scripts/fixtures/css-custom-properties/negative.css @@ -0,0 +1,8 @@ +/* + * Negative fixture stylesheet. `--fixture-ghost` is defined nowhere except as an + * ordinary data key in `dataKeys.ts`, so this bare reference is genuinely + * undefined and the scope must exit non-zero. + */ +.fixture-ghost-surface { + color: var(--fixture-ghost); +} diff --git a/scripts/fixtures/css-custom-properties/negative/dataKeys.ts b/scripts/fixtures/css-custom-properties/negative/dataKeys.ts new file mode 100644 index 0000000000..e1ecf56542 --- /dev/null +++ b/scripts/fixtures/css-custom-properties/negative/dataKeys.ts @@ -0,0 +1,20 @@ +/* + * Negative fixture: the reviewer's P1 case. + * + * `negative.css` references `--fixture-ghost` with no fallback. The only place + * that token appears is the ordinary data key below — never a style sink. The + * check must therefore report it and exit non-zero. + * + * This is what the bug looks like in real code: a theme metadata table, an i18n + * catalogue, or a token listing can carry a quoted `"--token":` key without ever + * setting a CSS property. Reading such a key as a definition subtracts the + * reference from the undefined set and the gate exits 0, so a genuine CSS typo + * hides behind unrelated data. + * + * `sinks.tsx` in the sibling directory holds the same key shape inside a real + * style sink. The pair is the whole contract: identical syntax, opposite meaning, + * decided by whether the key lands in a style sink. + */ +export const fixtureThemeMetadata = { + "--fixture-ghost": "not an inline style", +}; diff --git a/scripts/fixtures/css-custom-properties/positive.css b/scripts/fixtures/css-custom-properties/positive.css new file mode 100644 index 0000000000..3e33e5456f --- /dev/null +++ b/scripts/fixtures/css-custom-properties/positive.css @@ -0,0 +1,27 @@ +/* + * Positive fixture stylesheet: one bare `var()` per style sink in `sinks.tsx`. + * + * The check must exit 0 for this scope. A non-zero exit means the classifier + * stopped recognising a real inline style definition. The two `--fixture-ghost-*` + * tokens are deliberately NOT referenced here; they belong to the negative case + * and are asserted directly in the test's case table. + */ +.fixture-jsx { + border-color: var(--fixture-jsx); +} + +.fixture-cast { + color: var(--fixture-cast); +} + +.fixture-annotated { + border-color: var(--fixture-annotated); +} + +.fixture-object-property { + border-color: var(--fixture-object-property); +} + +.fixture-imperative { + border-color: var(--fixture-imperative); +} diff --git a/scripts/fixtures/css-custom-properties/sinks.tsx b/scripts/fixtures/css-custom-properties/sinks.tsx new file mode 100644 index 0000000000..a0acbed320 --- /dev/null +++ b/scripts/fixtures/css-custom-properties/sinks.tsx @@ -0,0 +1,43 @@ +/* + * Positive fixture for `scripts/check-css-custom-properties.test.mjs`. + * + * Every style-sink form the classifier must recognise, with the token each one + * defines listed in the case table of that test. If a form stops being matched, + * the classifier reports the paired `var()` reference in `positive.css` as + * undefined — a false positive on correct code — and the Dashboard's own + * `goal-activity-view.tsx` (`style={{ "--goal-hue": ... } as CSSProperties}`) + * starts failing the required gate. + */ +import type { CSSProperties } from "react"; + +// 1. JSX style attribute. +export const jsxSink =
; + +// 2. JSX style attribute with a trailing cast, the form used in the Dashboard. +export function castSink(hue: number) { + return { "--fixture-cast": `hsl(${hue} 60% 94%)` } as CSSProperties; +} + +// 3. Annotated style constant. +export const annotatedSink: CSSProperties = { "--fixture-annotated": "2px" }; + +// 4. Style object property. +export const objectPropertySink = { style: { "--fixture-object-property": "3px" } }; + +// 5. Imperative write. +export const imperativeSink = (element: HTMLElement) => + element.style.setProperty("--fixture-imperative", "4px"); + +/* + * Nothing below is a style sink, even though the quoted-key shape is identical. + * The paired references in `positive.css` are deliberately absent: these tokens + * are listed as NOT defined in the test's case table, which is the reviewer's + * regression. + */ +export const fixtureThemeMetadata = { + "--fixture-ghost-data-key": "not an inline style", +}; + +export const fixtureNestedConfig = { + tokens: { "--fixture-ghost-nested": "also not an inline style" }, +}; From d3910f293c66ee685c14e12798306a7a45edd36f Mon Sep 17 00:00:00 2001 From: song Date: Wed, 30 Sep 2026 11:23:39 -0400 Subject: [PATCH 5/5] fix(dashboard): make the CSS token check lexical, and test it through the command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The required guard still read raw source outside block comments, so text no browser executes could satisfy a bare reference. Reproduced on the reviewed head through the shipped command: a commented-out `style: { "--review-comment-ghost": ... }` plus `.probe { color: var(--review-comment-ghost) }` printed "No undefined references" and exited 0 — the same silent false negative the gate exists to prevent. Three changes, all in one classifier owner: - `blankComments` blanks block *and* line comments while preserving offsets, and every file is read through it. String bodies are kept, because a quoted custom-property key is only recognisable with its text and `collectBareReferences` reads the same masked text, so a `var(--x)` inside a string is not a reference either. - The sink list no longer accepts an object that merely has a `style` key. Nothing proves such an object is ever bound to an element, so `{ style: { "--x": v } }` is data, not a definition. The forms that remain — a JSX `style={{...}}` attribute, an explicit `CSSProperties` annotation, a trailing `as CSSProperties` cast, and `setProperty` — each carry evidence of a style write. - `objectLiteralBodyBefore` now shares the forward scanner's lexical rule and steps over string literals instead of counting their braces. This is hardening rather than a reproduced failure: I could not construct an input where the previous walk gave a different answer on today's tree, and the PR says so. Negative coverage is now committed and runs the **shipped command**, not only the exported classifier, because the gate's only signal is its exit code: - `fixture-dead-source` — commented, block-commented, stringified and unbound `style`-shaped keys, each paired with a bare reference; must exit 1 and name every token. - `fixture-brace-value` — a style value carrying `{`/`}` with neighbouring data objects; must exit 1. - `fixture-clean` — a reference satisfied by a real JSX sink; must exit 0, so the negatives cannot be satisfied by a classifier that matches nothing. Both closed bypasses are mutation-checked: restoring the block-comments-only mask or re-accepting the unbound `style` object each fails the suite. Signed-off-by: song --- scripts/check-css-custom-properties.mjs | 146 ++++++++++++++++-- scripts/check-css-custom-properties.test.mjs | 35 ++++- .../brace-value/brace.css | 4 + .../brace-value/brace.tsx | 25 +++ .../css-custom-properties/clean/clean.css | 6 + .../css-custom-properties/clean/clean.tsx | 2 + .../dead-source/dead-source.css | 9 ++ .../dead-source/dead.tsx | 25 +++ .../css-custom-properties/positive.css | 3 - .../fixtures/css-custom-properties/sinks.tsx | 5 +- 10 files changed, 239 insertions(+), 21 deletions(-) create mode 100644 scripts/fixtures/css-custom-properties/brace-value/brace.css create mode 100644 scripts/fixtures/css-custom-properties/brace-value/brace.tsx create mode 100644 scripts/fixtures/css-custom-properties/clean/clean.css create mode 100644 scripts/fixtures/css-custom-properties/clean/clean.tsx create mode 100644 scripts/fixtures/css-custom-properties/dead-source/dead-source.css create mode 100644 scripts/fixtures/css-custom-properties/dead-source/dead.tsx diff --git a/scripts/check-css-custom-properties.mjs b/scripts/check-css-custom-properties.mjs index b9ec58155b..3b0780aa62 100644 --- a/scripts/check-css-custom-properties.mjs +++ b/scripts/check-css-custom-properties.mjs @@ -53,6 +53,27 @@ const SCOPES = { // anti-vacuity guard in main() for why this is asserted from the tree. definedTokenFloor: 30, }, + // Fixture scopes so the contract test can run the **shipped command** over + // committed inputs. A gate whose only signal is an exit code has to be tested + // on the exit code, not only on an exported function. + "fixture-clean": { + // A stylesheet reference satisfied by a real JSX/cast style sink. + roots: ["scripts/fixtures/css-custom-properties/clean"], + allowlistedFiles: new Set(), + definedTokenFloor: 1, + }, + "fixture-dead-source": { + // References the only commented-out, stringified or unbound text mentions. + roots: ["scripts/fixtures/css-custom-properties/dead-source"], + allowlistedFiles: new Set(), + definedTokenFloor: 0, + }, + "fixture-brace-value": { + // A closing brace inside a style value must not end the object early. + roots: ["scripts/fixtures/css-custom-properties/brace-value"], + allowlistedFiles: new Set(), + definedTokenFloor: 0, + }, }; const STYLE_EXTENSIONS = new Set([".css"]); @@ -80,13 +101,80 @@ function walk(dir, extensions, found = []) { } /** - * Strip /* ... *\/ comments so a token mentioned in prose is not read as a - * reference, while preserving character offsets for line numbers. + * Blank comments so no match can come from text a browser never executes, while + * preserving character offsets for line numbers. + * + * Removing only block comments was not enough: a commented-out + * `style: { "--x": ... }` could satisfy a bare `var(--x)` elsewhere, and the + * required gate exited 0 for a declaration the browser will drop. Line comments + * are blanked here too. + * + * String bodies are deliberately kept. A quoted custom-property key is only + * recognisable together with its text, and `collectBareReferences` reads the + * same comment-blanked text the definitions scan does, so a `var(--x)` written + * inside a string is not a reference either. */ -function stripComments(text) { +function blankComments(text) { + const out = new Array(text.length); + let index = 0; + const blank = (from, to) => { + for (let i = from; i < to; i += 1) out[i] = text[i] === "\n" ? "\n" : " "; + }; + while (index < text.length) { + const char = text[index]; + const next = text[index + 1]; + if (char === "/" && next === "*") { + const end = text.indexOf("*/", index + 2); + const stop = end === -1 ? text.length : end + 2; + blank(index, stop); + index = stop; + continue; + } + if (char === "/" && next === "/") { + const end = text.indexOf("\n", index + 2); + const stop = end === -1 ? text.length : end; + blank(index, stop); + index = stop; + continue; + } + if (char === '"' || char === "'" || char === "`") { + // Keep the literal, escapes included, so quoted keys stay readable. + out[index] = char; + let i = index + 1; + let closed = false; + while (i < text.length) { + if (text[i] === "\\") { + out[i] = text[i]; + if (i + 1 < text.length) out[i + 1] = text[i + 1]; + i += 2; + continue; + } + out[i] = text[i]; + if (text[i] === char) { + closed = true; + break; + } + i += 1; + } + index = closed ? i + 1 : Math.max(i, index + 1); + continue; + } + out[index] = char; + index += 1; + } + return out.join(""); +} + +/** A stylesheet has no line comments: only its `/* *\/` blocks can be dead text. */ +function stripStylesheetComments(text) { return text.replace(/\/\*[\s\S]*?\*\//g, (match) => match.replace(/[^\n]/g, " ")); } +/** The mask a file's kind calls for: code files blank line comments too. */ +function maskedSource(file, raw) { + return extensionOf(file) === ".css" ? stripStylesheetComments(raw) : blankComments(raw); +} + function lineAt(text, index) { let line = 1; for (let i = 0; i < index && i < text.length; i += 1) { @@ -117,11 +205,10 @@ function lineAt(text, index) { * `setProperty` is matched separately below. */ const STYLE_SINK_OPENERS = [ - // JSX attribute: style={{ ... }} / style={{ ... } as CSSProperties} + // A JSX attribute: `style={{ "--x": v }}`. The doubled brace is what makes it + // an element prop rather than an object that merely has a `style` key. /style\s*=\s*\{\s*\{/g, - // Object property in a style constant: style: { ... } - /(?:^|[^A-Za-z0-9_$])style\s*:\s*\{/g, - // A value annotated as CSSProperties: const style: CSSProperties = { ... } + // A value annotated as CSSProperties: `const s: CSSProperties = { ... }`. /:\s*CSSProperties\s*=\s*\{/g, ]; @@ -164,19 +251,48 @@ function objectLiteralBody(text, openIndex) { } /** - * Return the inner text of the object literal whose *closing* brace is `closeIndex`, - * walking backwards with brace depth. Used for the trailing-cast form, where the - * cast is the reliable anchor and the object start has to be found by matching - * outwards from the end. + * The opening quote of the string literal that ends at `endIndex`, or null when + * the position is not a literal's closing quote. + */ +function findStringStart(text, endIndex) { + const quote = text[endIndex]; + for (let i = endIndex - 1; i >= 0; i -= 1) { + if (text[i] === "\\") { + i -= 1; + continue; + } + if (text[i] === quote) return i; + // A literal does not span a line unless it is a template. + if (text[i] === "\n" && quote !== "`") return null; + } + return null; +} + +/** + * Return the inner text of the object literal whose *closing* brace is `closeIndex`. + * + * Used for the trailing-cast form, where `as CSSProperties` is the anchor and + * the object start has to be found by brace depth walking backwards. It applies + * the same lexical rule as the forward scan: a brace inside a string is not a + * brace. Counting it closed the body early and pulled a neighbouring object's + * keys in, so unrelated data could satisfy a CSS reference. */ function objectLiteralBodyBefore(text, closeIndex) { let depth = 0; for (let i = closeIndex; i >= 0; i -= 1) { const char = text[i]; - if (char === "}") depth += 1; - else if (char === "{") { + if (char === "}") { + depth += 1; + continue; + } + if (char === "{") { depth -= 1; if (depth === 0) return text.slice(i + 1, closeIndex); + continue; + } + if (char === '"' || char === "'" || char === "`") { + const start = findStringStart(text, i); + if (start !== null) i = start; } } return null; @@ -232,7 +348,7 @@ function collectDefinitions(files) { for (const file of files) { const rel = relative(REPO_ROOT, file).split(sep).join("/"); const raw = readFileSync(file, "utf8"); - const text = stripComments(raw); + const text = maskedSource(file, raw); for (const match of text.matchAll(/@property\s+(--[A-Za-z0-9_-]+)/g)) { add(match[1], `@property in ${rel}`); @@ -266,7 +382,7 @@ function collectBareReferences(files) { const references = []; for (const file of files) { const rel = relative(REPO_ROOT, file).split(sep).join("/"); - const text = stripComments(readFileSync(file, "utf8")); + const text = maskedSource(file, readFileSync(file, "utf8")); for (const match of text.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)\s*\)/g)) { references.push({ token: match[1], file: rel, line: lineAt(text, match.index) }); } diff --git a/scripts/check-css-custom-properties.test.mjs b/scripts/check-css-custom-properties.test.mjs index cd4c377f20..e5a617c993 100644 --- a/scripts/check-css-custom-properties.test.mjs +++ b/scripts/check-css-custom-properties.test.mjs @@ -33,6 +33,8 @@ */ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { collectDefinitions } from "./check-css-custom-properties.mjs"; @@ -58,7 +60,6 @@ const definitionsFor = (paths) => collectDefinitions(paths).definitions; "--fixture-jsx": "a JSX style attribute", "--fixture-cast": "a trailing `as CSSProperties` cast", "--fixture-annotated": "a CSSProperties-annotated constant", - "--fixture-object-property": "a `style:` object property", "--fixture-imperative": "a setProperty call", }; for (const [token, form] of Object.entries(expected)) { @@ -131,4 +132,36 @@ const definitionsFor = (paths) => collectDefinitions(paths).definitions; ); } +// --- The gate as the repository actually runs it ---------------------------- +// +// The classifier assertions above read an exported function. The gate's only +// signal is its exit code, so the negative cases are re-run through the shipped +// command over committed fixture scopes. A regression that made the classifier +// return the right *set* while `main` still exited 0 would pass every check +// above and fail here. +{ + const run = (scope) => spawnSync(process.execPath, + [fileURLToPath(new URL("./check-css-custom-properties.mjs", import.meta.url)), scope], + {encoding: "utf8"}); + + const clean = run("fixture-clean"); + assert.equal(clean.status, 0, + `a reference satisfied by a real style sink must pass:\n${clean.stdout}${clean.stderr}`); + + for (const [scope, token] of [ + ["fixture-dead-source", "--fixture-commented"], + ["fixture-dead-source", "--fixture-block-commented"], + ["fixture-dead-source", "--fixture-stringified"], + ["fixture-dead-source", "--fixture-unbound"], + ["fixture-brace-value", "--fixture-brace-ghost-before"], + ["fixture-brace-value", "--fixture-brace-ghost-after"], + ]) { + const failed = run(scope); + assert.equal(failed.status, 1, + `${scope} must exit non-zero: commented, stringified and unbound text is not a definition`); + assert.ok(failed.stderr.includes(token), + `${scope} must report ${token} as undefined:\n${failed.stderr}`); + } +} + console.log("check-css-custom-properties classifier contract: ok"); diff --git a/scripts/fixtures/css-custom-properties/brace-value/brace.css b/scripts/fixtures/css-custom-properties/brace-value/brace.css new file mode 100644 index 0000000000..f91791f82b --- /dev/null +++ b/scripts/fixtures/css-custom-properties/brace-value/brace.css @@ -0,0 +1,4 @@ +/* The real key is defined by the style sink above; both ghosts are not. */ +.fixture-brace-live { color: var(--fixture-brace-live); } +.fixture-brace-ghost-before { color: var(--fixture-brace-ghost-before); } +.fixture-brace-ghost-after { color: var(--fixture-brace-ghost-after); } diff --git a/scripts/fixtures/css-custom-properties/brace-value/brace.tsx b/scripts/fixtures/css-custom-properties/brace-value/brace.tsx new file mode 100644 index 0000000000..af501bbdfc --- /dev/null +++ b/scripts/fixtures/css-custom-properties/brace-value/brace.tsx @@ -0,0 +1,25 @@ +/* + * Negative fixture: a closing brace inside a style value must not end the object + * early, and a brace inside a string must not be counted at all. + * + * The trailing-cast form is anchored on `as CSSProperties` and walks backwards + * to the object's opening brace. Its value deliberately carries `}` and `{` + * characters, so a walk that counts braces inside strings finds the wrong start: + * + * - with the correct walk, the body is the style object, so + * `--fixture-brace-live` is defined and the two ghost keys are not; + * - with a string-blind walk, the body stops short or spans too far, and + * either a ghost key becomes a definition or the real one is lost. + * + * `brace.css` references the real key and both ghosts with no fallback, so the + * scope exits non-zero unless the walk is exactly right. + */ +import type { CSSProperties } from "react"; + +export const neighbourBefore = { "--fixture-brace-ghost-before": "not a style sink" }; + +export function withBraces(): CSSProperties { + return { "--fixture-brace-live": "a}b{c", "--fixture-brace-second": "d}" } as CSSProperties; +} + +export const neighbourAfter = { "--fixture-brace-ghost-after": "not a style sink" }; diff --git a/scripts/fixtures/css-custom-properties/clean/clean.css b/scripts/fixtures/css-custom-properties/clean/clean.css new file mode 100644 index 0000000000..64a1ff77d8 --- /dev/null +++ b/scripts/fixtures/css-custom-properties/clean/clean.css @@ -0,0 +1,6 @@ +/* + * Positive fixture for the shipped command: the reference below is satisfied by + * a real style sink in `clean.tsx`. The scope must exit 0; if the classifier + * stops recognising that sink, correct code starts failing the required check. + */ +.fixture-clean-surface { color: var(--fixture-clean); } diff --git a/scripts/fixtures/css-custom-properties/clean/clean.tsx b/scripts/fixtures/css-custom-properties/clean/clean.tsx new file mode 100644 index 0000000000..d31aabdac1 --- /dev/null +++ b/scripts/fixtures/css-custom-properties/clean/clean.tsx @@ -0,0 +1,2 @@ +/* The only definition of `--fixture-clean`, written through a JSX style sink. */ +export const cleanSink =
; diff --git a/scripts/fixtures/css-custom-properties/dead-source/dead-source.css b/scripts/fixtures/css-custom-properties/dead-source/dead-source.css new file mode 100644 index 0000000000..b6d471a338 --- /dev/null +++ b/scripts/fixtures/css-custom-properties/dead-source/dead-source.css @@ -0,0 +1,9 @@ +/* + * Negative fixture stylesheet: every reference below is genuinely undefined, + * because the only places those tokens appear are comments, a string, and an + * object that never reaches a style sink. + */ +.fixture-dead-commented { color: var(--fixture-commented); } +.fixture-dead-block { color: var(--fixture-block-commented); } +.fixture-dead-string { color: var(--fixture-stringified); } +.fixture-dead-unbound { color: var(--fixture-unbound); } diff --git a/scripts/fixtures/css-custom-properties/dead-source/dead.tsx b/scripts/fixtures/css-custom-properties/dead-source/dead.tsx new file mode 100644 index 0000000000..ff511aacca --- /dev/null +++ b/scripts/fixtures/css-custom-properties/dead-source/dead.tsx @@ -0,0 +1,25 @@ +/* + * Negative fixture: text that is not executable must not define a token. + * + * `dead-source.css` references all four tokens below with no fallback. None of + * them is set on an element: + * + * - `--fixture-commented` appears inside a line comment; + * - `--fixture-block-commented` inside a block comment; + * - `--fixture-stringified` inside a string literal; + * - `--fixture-unbound` in an ordinary `style`-shaped data object that never + * reaches a DOM node. + * + * A classifier that reads raw source or that skips only block comments lets + * these satisfy the references, and the required gate exits 0 for declarations + * the browser will drop. The whole scope must exit non-zero. + */ +export const live = 1; + +// const sink =
; + +/* const other =
; */ + +export const docs = 'a note about style: { "--fixture-stringified": "1px" }'; + +export const unboundConfig = { style: { "--fixture-unbound": "never applied" } }; diff --git a/scripts/fixtures/css-custom-properties/positive.css b/scripts/fixtures/css-custom-properties/positive.css index 3e33e5456f..a12c8168b4 100644 --- a/scripts/fixtures/css-custom-properties/positive.css +++ b/scripts/fixtures/css-custom-properties/positive.css @@ -18,9 +18,6 @@ border-color: var(--fixture-annotated); } -.fixture-object-property { - border-color: var(--fixture-object-property); -} .fixture-imperative { border-color: var(--fixture-imperative); diff --git a/scripts/fixtures/css-custom-properties/sinks.tsx b/scripts/fixtures/css-custom-properties/sinks.tsx index a0acbed320..45b38f936d 100644 --- a/scripts/fixtures/css-custom-properties/sinks.tsx +++ b/scripts/fixtures/css-custom-properties/sinks.tsx @@ -21,8 +21,9 @@ export function castSink(hue: number) { // 3. Annotated style constant. export const annotatedSink: CSSProperties = { "--fixture-annotated": "2px" }; -// 4. Style object property. -export const objectPropertySink = { style: { "--fixture-object-property": "3px" } }; +// 4. An object that merely has a `style` key is NOT a sink: nothing proves it +// is ever bound to an element, so it must not satisfy a reference. +export const unboundStyleLikeData = { style: { "--fixture-object-property": "3px" } }; // 5. Imperative write. export const imperativeSink = (element: HTMLElement) =>