diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index c08e8367f..9a5b2e28e 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -324,6 +324,12 @@ 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 + - 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 50c276b8d..7c39568ed 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.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", @@ -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 000000000..cfd0765f4 --- /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/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css index 294b5046b..748cdaf6e 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 e83412695..9f67e63af 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -117,7 +117,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. */ @@ -270,7 +270,7 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli .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)); } @@ -832,17 +832,17 @@ button.personal-execution-chip:focus-visible { outline: 2px solid #0070f3; outli /* 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 35994dfe5..f3a061e66 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 { diff --git a/docs/development/design.md b/docs/development/design.md index fe77acb5b..deb3a4b33 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 | diff --git a/scripts/check-css-custom-properties.mjs b/scripts/check-css-custom-properties.mjs new file mode 100644 index 000000000..3b0780aa6 --- /dev/null +++ b/scripts/check-css-custom-properties.mjs @@ -0,0 +1,477 @@ +#!/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(), + // 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, + }, + // 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"]); +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; + 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; +} + +/** + * 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 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) { + if (text[i] === "\n") line += 1; + } + 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 = [ + // 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, + // A value annotated as CSSProperties: `const s: 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; +} + +/** + * 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; + 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; +} + +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. + * + * 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 = maskedSource(file, 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(/setProperty\(\s*["'`](--[A-Za-z0-9_-]+)["'`]/g)) { + add(match[1], `setProperty 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}`); + } + } + } + + 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 = 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) }); + } + } + 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 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 ${definedTokenFloor}. ` + + `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.`, + ); +} + +// 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 000000000..e5a617c99 --- /dev/null +++ b/scripts/check-css-custom-properties.test.mjs @@ -0,0 +1,167 @@ +#!/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 { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +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-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", + ); +} + +// --- 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 000000000..f91791f82 --- /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 000000000..af501bbdf --- /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 000000000..64a1ff77d --- /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 000000000..d31aabdac --- /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 000000000..b6d471a33 --- /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 000000000..ff511aacc --- /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/negative.css b/scripts/fixtures/css-custom-properties/negative.css new file mode 100644 index 000000000..54a4d6869 --- /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 000000000..e1ecf5654 --- /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 000000000..a12c8168b --- /dev/null +++ b/scripts/fixtures/css-custom-properties/positive.css @@ -0,0 +1,24 @@ +/* + * 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-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 000000000..45b38f936 --- /dev/null +++ b/scripts/fixtures/css-custom-properties/sinks.tsx @@ -0,0 +1,44 @@ +/* + * 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. 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) => + 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" }, +};