diff --git a/docs/design-system/token-layer-divergences.json b/docs/design-system/token-layer-divergences.json index 121722221..e70f99154 100644 --- a/docs/design-system/token-layer-divergences.json +++ b/docs/design-system/token-layer-divergences.json @@ -3,7 +3,8 @@ "generatedBy": "scripts/token-layer-divergences.mjs", "counts": { "light": 29, - "dark": 24 + "dark": 22, + "forcedColors": 3 }, "divergences": { "light": { @@ -141,9 +142,9 @@ "compat": "#3b444b", "v2": "#47505a" }, - "--clinical-accent-soft": { - "compat": "var(--primary-soft)", - "v2": "#123556" + "--clinical-chat-document": { + "compat": "var(--surface-inset)", + "v2": "var(--surface-inset)" }, "--clinical-chat-table-header": { "compat": "var(--surface-subtle)", @@ -201,14 +202,6 @@ "compat": "#0a0c0e", "v2": "#161a1e" }, - "--text": { - "compat": "var(--neutral-900)", - "v2": "#f4f6f8" - }, - "--text-heading": { - "compat": "var(--neutral-950)", - "v2": "#fbfcfd" - }, "--text-muted": { "compat": "var(--neutral-600)", "v2": "#a8b2bd" @@ -221,6 +214,20 @@ "compat": "var(--neutral-500)", "v2": "#7d8792" } + }, + "forcedColors": { + "--clinical-accent-border": { + "compat": "ButtonBorder", + "v2": "CanvasText" + }, + "--overlay-backdrop": { + "compat": "CanvasText", + "v2": "transparent" + }, + "--text-soft": { + "compat": "CanvasText", + "v2": "GrayText" + } } } } diff --git a/scripts/generate-gates-figures.mjs b/scripts/generate-gates-figures.mjs index 7078d0fab..4795341a6 100644 --- a/scripts/generate-gates-figures.mjs +++ b/scripts/generate-gates-figures.mjs @@ -52,18 +52,36 @@ export function renderFigures(baseline) { return lines.join("\n"); } +/** + * Locate the single marked block, refusing anything ambiguous. `indexOf` alone takes + * the FIRST marker pair, so a duplicated or stray marker (a bad merge, a copy-pasted + * example) would silently retarget both the comparison and the `--write` overwrite at + * the wrong slice, reporting success while §0 stayed stale. Fail loudly instead. + */ +function blockBounds(document) { + const starts = [...document.matchAll(new RegExp(START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"))]; + const ends = [...document.matchAll(new RegExp(END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"))]; + if (starts.length !== 1 || ends.length !== 1) { + throw new Error( + `GATES.md must contain exactly one ${START} and one ${END} ` + + `(found ${starts.length} start, ${ends.length} end). Ambiguous markers would silently ` + + `retarget the generated block.`, + ); + } + const start = starts[0].index; + const end = ends[0].index; + if (end < start) throw new Error("GATES.md figures end marker precedes its start marker"); + return { start, end: end + END.length }; +} + function replaceBlock(document, rendered) { - const start = document.indexOf(START); - const end = document.indexOf(END); - if (start === -1 || end === -1) throw new Error(`GATES.md is missing the ${START} / ${END} markers`); - return document.slice(0, start) + rendered + document.slice(end + END.length); + const { start, end } = blockBounds(document); + return document.slice(0, start) + rendered + document.slice(end); } function currentBlock(document) { - const start = document.indexOf(START); - const end = document.indexOf(END); - if (start === -1 || end === -1) throw new Error(`GATES.md is missing the ${START} / ${END} markers`); - return document.slice(start, end + END.length); + const { start, end } = blockBounds(document); + return document.slice(start, end); } /** diff --git a/scripts/token-layer-divergences.mjs b/scripts/token-layer-divergences.mjs index 08faa7fca..6cd2e7148 100644 --- a/scripts/token-layer-divergences.mjs +++ b/scripts/token-layer-divergences.mjs @@ -21,79 +21,188 @@ const GLOBALS = fileURLToPath(new URL("../src/app/globals.css", import.meta.url) const V2 = fileURLToPath(new URL("../src/app/ckb-v2-tokens.css", import.meta.url)); export const PIN_PATH = fileURLToPath(new URL("../docs/design-system/token-layer-divergences.json", import.meta.url)); -/** Slice from `marker` to the first line-initial `}` that closes it. */ -function block(source, marker) { - const start = source.indexOf(marker); - if (start === -1) return null; - const end = source.indexOf("\n}", start); - if (end === -1) return null; - return source.slice(start, end); -} - -/** Every block for one selector, including grouped (`.sel,`) openers, concatenated. */ -function allBlocks(source, selector) { - const opener = `\n${selector} {`; - const grouped = `\n${selector},`; - let start = source.indexOf(opener); - if (start === -1) start = source.indexOf(grouped); - let combined = ""; - while (start > -1) { - const end = source.indexOf("\n}", start); - // A missing terminator would restart the scan at 0 and loop forever. - if (end === -1) break; - combined += source.slice(start, end); - const nextOpener = source.indexOf(opener, end + 1); - const nextGrouped = source.indexOf(grouped, end + 1); - if (nextOpener === -1) start = nextGrouped; - else if (nextGrouped === -1) start = nextOpener; - else start = Math.min(nextOpener, nextGrouped); +/** + * Every rule block in a stylesheet, as {media, selectors, body}, using real brace + * matching rather than "slice to the next line-initial `}`". Brace matching is what + * lets an `@media (forced-colors: active) { … }` wrapper be seen as context rather + * than terminating the block early. + */ +function ruleBlocks(rawSource) { + // Strip comments first: otherwise a comment preceding a selector is accumulated + // into that selector's prelude and the match fails. Replaced with a space rather + // than removed so `a/**/b` cannot become one token. + const source = rawSource.replace(/\/\*[\s\S]*?\*\//g, " "); + const blocks = []; + const stack = []; + let index = 0; + let pending = ""; + while (index < source.length) { + const character = source[index]; + if (character === "{") { + const prelude = pending.trim(); + pending = ""; + if (prelude.startsWith("@media") || prelude.startsWith("@supports")) { + stack.push({ kind: "at", prelude }); + index += 1; + continue; + } + // A rule block: capture its body by matching braces from here. + let depth = 1; + let cursor = index + 1; + while (cursor < source.length && depth > 0) { + if (source[cursor] === "{") depth += 1; + else if (source[cursor] === "}") depth -= 1; + cursor += 1; + } + blocks.push({ + media: stack + .filter((frame) => frame.kind === "at") + .map((frame) => frame.prelude) + .join(" "), + selectors: prelude + .split(",") + .map((selector) => selector.trim()) + .filter(Boolean), + body: source.slice(index + 1, cursor - 1), + }); + index = cursor; + continue; + } + if (character === "}") { + stack.pop(); + pending = ""; + index += 1; + continue; + } + if (character === ";") pending = ""; + else pending += character; + index += 1; } - return combined; + return blocks; } -function declarations(source) { +/** + * Custom-property declarations in a block body. Indentation-insensitive on purpose: + * an earlier version required exactly two leading spaces, so re-indenting a + * declaration — a change with no rendered effect — silently dropped it from the + * comparison and the tool then reported the divergence as resolved. + */ +function declarations(body) { const map = new Map(); - if (!source) return map; - for (const [, name, value] of source.matchAll(/^ {2}(--[a-z0-9-]+)\s*:\s*([^;]+);/gim)) { + if (!body) return map; + for (const [, name, value] of body.matchAll(/(?:^|;)\s*(--[a-zA-Z0-9-]+)\s*:\s*([^;]+)/g)) { map.set(name, value.trim().replace(/\s+/g, " ")); } return map; } /** - * Both layers, per theme. Missing a block is a hard error rather than an empty - * comparison: an empty map would report "no divergence" and pass loudly-green. + * Resolve `var(--x)` chains inside one layer. Two layers can declare the SAME alias + * text and still paint different colours when the alias itself diverges — dark + * `--clinical-chat-document` is `var(--surface-inset)` on both sides while + * `--surface-inset` differs, so a raw string comparison called it identical. */ -export function readLayers() { - const globals = readFileSync(GLOBALS, "utf8"); - const v2 = readFileSync(V2, "utf8"); - // `@theme` and `:root` both land at (0,1,0) on , so within globals.css the - // later block wins — `:root` follows `@theme`, so `:root` is overlaid second. - // `@theme` carries the structural roles (radius, spacing, type scale), which is - // exactly where a silent mismatch is most expensive, so it cannot be skipped. - const themeConfig = block(globals, "\n@theme {"); - const root = block(globals, "\n:root {"); - const darkRoot = block(globals, "\n.dark {"); - if (!themeConfig) throw new Error("globals.css is missing its @theme block"); - if (!root) throw new Error("globals.css is missing its :root block"); - if (!darkRoot) throw new Error("globals.css is missing its .dark block"); +function resolveValue(tokens, value, seen = new Set()) { + const alias = /^var\(\s*(--[a-zA-Z0-9-]+)\s*\)$/.exec(value ?? ""); + if (!alias) return value; + const name = alias[1]; + if (seen.has(name) || !tokens.has(name)) return value; + seen.add(name); + return resolveValue(tokens, tokens.get(name), seen); +} - const lightCompat = declarations(themeConfig); - for (const [name, value] of declarations(root)) lightCompat.set(name, value); +const THEMES = { + light: { + forcedColors: false, + compat: (selectors) => selectors.some((s) => s === ":root" || s === "@theme"), + v2: (selectors) => selectors.some((s) => s === ".ckb-v2.ckb-v2"), + }, + dark: { + forcedColors: false, + compat: (selectors) => selectors.some((s) => s === ".dark"), + v2: (selectors) => selectors.some((s) => s === ".dark .ckb-v2.ckb-v2" || s === ".ckb-v2.dark.ckb-v2"), + }, + // Forced colours (Windows High Contrast) is a third theme both files declare, and + // the same specificity trap applies there. It went unmonitored until 2026-09-01, + // and four roles were already silently dead in it. + forcedColors: { + forcedColors: true, + compat: (selectors) => selectors.some((s) => s === ":root" || s === ".dark"), + v2: (selectors) => + selectors.some((s) => s === ".ckb-v2.ckb-v2" || s === ".dark .ckb-v2.ckb-v2" || s === ".ckb-v2.dark.ckb-v2"), + }, +}; - const themes = { - light: [lightCompat, allBlocks(v2, ".ckb-v2.ckb-v2")], - dark: [declarations(darkRoot), allBlocks(v2, ".dark .ckb-v2.ckb-v2")], - }; +/** + * Declarations for one theme, from blocks whose CONDITION matches that theme. + * + * The base themes take unconditional blocks only. An earlier version tested just + * `/forced-colors/`, which let any OTHER `@media` block into the base map, where a + * later responsive override silently replaced the base value — `globals.css` has + * three such `:root` blocks today (`--mode-home-copy-reserve` twice, + * `--spacing-mode-home-composer-wide` once). Comparing a narrow-viewport override + * against an unconditional v2 declaration is comparing two different contexts, and + * it reports identical when they diverge everywhere the condition does not apply. + * + * Conditional non-forced-colors blocks are therefore excluded rather than merged. + * That is the conservative direction: a token declared ONLY under such a condition + * goes uncompared instead of being compared wrongly. `unconditionalOnly` is not a + * synonym for "no media" in the forced-colours case, which is itself a condition and + * is modelled as its own theme. + */ +function collect(blocks, matches, wantForcedColors) { + const map = new Map(); + for (const block of blocks) { + const inForcedColors = /forced-colors/.test(block.media); + if (wantForcedColors) { + if (!inForcedColors) continue; + } else if (block.media !== "") { + continue; + } + if (!matches(block.selectors)) continue; + for (const [name, value] of declarations(block.body)) map.set(name, value); + } + return map; +} + +/** + * Both layers, per theme. An empty map for any side is a hard error rather than a + * quiet "no divergence": an empty comparison would pass loudly-green. + * + * @typedef {{ compat: Map, v2: Map }} LayerPair + * @returns {Record} keyed by the theme names in `THEMES` + */ +export function readLayers() { + const globalsBlocks = ruleBlocks(readFileSync(GLOBALS, "utf8")); + const v2Blocks = ruleBlocks(readFileSync(V2, "utf8")); + // Tailwind's `@theme` is an at-rule by syntax but declares tokens like `:root`. + const themeBlock = readFileSync(GLOBALS, "utf8").match(/@theme\s*\{([\s\S]*?)\n\}/); + /** @type {Record} */ const out = {}; - for (const [theme, [compat, v2Source]] of Object.entries(themes)) { - if (!v2Source) throw new Error(`ckb-v2-tokens.css is missing its ${theme} token block`); - out[theme] = { compat, v2: declarations(v2Source) }; + for (const [theme, spec] of Object.entries(THEMES)) { + const compat = collect(globalsBlocks, spec.compat, spec.forcedColors); + if (theme === "light" && themeBlock) { + // `:root` follows `@theme` in source order, so `:root` overlays it. + const merged = declarations(themeBlock[1]); + for (const [name, value] of compat) merged.set(name, value); + out[theme] = { compat: merged, v2: collect(v2Blocks, spec.v2, spec.forcedColors) }; + } else { + out[theme] = { compat, v2: collect(v2Blocks, spec.v2, spec.forcedColors) }; + } + if (out[theme].compat.size === 0) + throw new Error(`globals.css declares no ${theme} tokens — parser or file changed`); + if (out[theme].v2.size === 0) + throw new Error(`ckb-v2-tokens.css declares no ${theme} tokens — parser or file changed`); } return out; } -/** `{ light: { "--surface": { compat, v2 } }, dark: {...} }` for every shared, differing role. */ +/** + * `{ : { "--surface": { compat, v2 } } }` for every role both layers declare + * whose RESOLVED value differs. Resolution matters in both directions: identical + * alias text over a diverging alias is a real divergence, and different text that + * resolves to the same value is not one. + */ export function computeDivergences() { const layers = readLayers(); const result = {}; @@ -102,12 +211,13 @@ export function computeDivergences() { for (const [name, compatValue] of compat) { if (!v2.has(name)) continue; const v2Value = v2.get(name); - if (v2Value !== compatValue) diverging[name] = { compat: compatValue, v2: v2Value }; + if (resolveValue(compat, compatValue) === resolveValue(v2, v2Value)) continue; + diverging[name] = { compat: compatValue, v2: v2Value }; } result[theme] = Object.fromEntries( Object.keys(diverging) .sort() - .map((k) => [k, diverging[k]]), + .map((key) => [key, diverging[key]]), ); } return result; diff --git a/src/components/clinical-dashboard/medication-record-page.tsx b/src/components/clinical-dashboard/medication-record-page.tsx index d58bcf663..92be6ab7e 100644 --- a/src/components/clinical-dashboard/medication-record-page.tsx +++ b/src/components/clinical-dashboard/medication-record-page.tsx @@ -495,7 +495,8 @@ export function MedicationRecordPage({ )} - PsychSift provides evidence summaries, not medical advice. Verify clinical decisions. + PsychSift is a clinical reference prototype, not validated decision support. Verify every dose and interaction + against the linked source before acting on it. diff --git a/src/components/clinical-dashboard/patient-profile-panel.tsx b/src/components/clinical-dashboard/patient-profile-panel.tsx index 41b77ff36..d88e4b871 100644 --- a/src/components/clinical-dashboard/patient-profile-panel.tsx +++ b/src/components/clinical-dashboard/patient-profile-panel.tsx @@ -367,8 +367,8 @@ export function PatientProfilePanel({

- Anonymous values only — no patient‑identifying information is stored. Cleared when the tab closes. Decision - support, not medical advice. + Anonymous values only — no patient‑identifying information is stored. Cleared when the tab closes. Clinical + reference — not validated decision support.

diff --git a/tests/design-token-contract.test.ts b/tests/design-token-contract.test.ts index 2b8938568..f290e5b6a 100644 --- a/tests/design-token-contract.test.ts +++ b/tests/design-token-contract.test.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { computeDivergences, diffAgainstPin, readPin } from "../scripts/token-layer-divergences.mjs"; +import { computeDivergences, diffAgainstPin, readLayers, readPin } from "../scripts/token-layer-divergences.mjs"; import { sourceFrom, sourceSegment } from "./helpers/source-contract"; /** @@ -568,6 +568,27 @@ describe("compat layer agrees with the v2 layer", () => { expect(diffAgainstPin()).toEqual([]); }); + // A conditional `@media` override is a different comparison context from an + // unconditional declaration. An earlier parser filtered only on `forced-colors`, + // so any other media block was merged into the base map and its override silently + // replaced the base value — which reports "identical" for a pair that diverges + // everywhere the condition does not apply. globals.css has three such `:root` + // blocks, so this is checked against the real file rather than a fixture. + it("reads base-theme tokens from unconditional blocks, not from media overrides", () => { + const layers = readLayers(); + const base = /^\s*--mode-home-copy-reserve:\s*(.+);\s*$/m.exec(globals.slice(globals.indexOf("\n:root {"))); + expect(base, "--mode-home-copy-reserve should still be declared unconditionally").toBeTruthy(); + expect( + layers.light.compat.get("--mode-home-copy-reserve"), + "the (min-width: 412px) override must not replace the unconditional value", + ).toBe(base![1].replace(/\s+/g, " ").trim()); + + // Same shape, second instance: `@theme` declares 5.5rem and a + // (min-width: 640px) block overrides it to 10rem. The base map must hold the + // unconditional value, because that is the one comparable to a v2 declaration. + expect(layers.light.compat.get("--spacing-mode-home-composer-wide")).toBe("5.5rem"); + }); + it("rejects a pin whose counts metadata disagrees with divergences", () => { const pin = readPin(); const bad = structuredClone(pin);