diff --git a/docs/design-system.md b/docs/design-system.md index d77766b42..184bc3484 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -18,7 +18,10 @@ contract and the code — not reinvention. If a change genuinely needs a new dir `src/app/globals.css` (`:root` + `.dark`). No raw Tailwind palette classes (`red-50`, `slate-200`, `bg-white`) and no hex values in components. **If you typed a hex or a Tailwind colour name in a component, you broke dark mode** — those values have no `.dark` override. - The only sanctioned exception: third-party brand marks (Microsoft/Google OAuth tiles). + Sanctioned raw-colour exceptions are explicit and narrow: brand artwork, diagnostic-only + visualizations, generated OpenGraph artwork, emergency error fallbacks, the scoped + fixed-white Therapy patient sheet, and the scoped factsheet print sheet. `scripts/check-design-system-contract.mjs` owns the + path allowlist; adding a category requires documenting why semantic app tokens are wrong. - **Semantic vs categorical vs brand.** Three token families, never interchangeable: - Semantic triads (`--info/-soft/-border`, `--success-*`, `--warning-*`, `--danger-*`) mean something happened or matters clinically. Green is success-only; red is safety/danger-only. @@ -167,12 +170,14 @@ image"}` — never a possibly-empty variable alone. 1. `npm run verify:cheap` — lint, typecheck, unit tests, runtime + sitemap checks (offline-safe). 2. `npm run ensure` then `npm run verify:ui` — Chromium Playwright (smoke, stress, accessibility, tools, overlap). Required for any UI/styling/routing change. -3. `node scripts/check-type-scale.mjs` — the count must not exceed the recorded baseline. -4. Manual dark-mode pass on every screen you touched (theme toggle in the sidebar). -5. Reduced-motion + forced-colors spot check on touched surfaces +3. `npm run check:design-system-contract` — production-only raw colours, literal shadows, + Therapy inline-parser/style debt, and tap-token drift must not exceed the recorded baseline. +4. `node scripts/check-type-scale.mjs` — the count must not exceed the recorded baseline. +5. Manual dark-mode pass on every screen you touched (theme toggle in the sidebar). +6. Reduced-motion + forced-colors spot check on touched surfaces (`ui-accessibility.spec.ts` covers the automated slice; emulate in devtools for the rest). -6. `npm run format:check`. -7. Fill the PR template; the clinical-governance preflight applies only if you touched +7. `npm run format:check`. +8. Fill the PR template; the clinical-governance preflight applies only if you touched ingestion/answer/search/source-access surfaces — pure UI work states that explicitly. ## 10. File conventions diff --git a/package.json b/package.json index a2f67ddb8..68251c959 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", "test:cross-tenant:staging": "node scripts/run-tsx.mjs scripts/test-cross-tenant-staging.ts", "verify:cheap": "node scripts/run-heavy.mjs --npm-script verify:cheap:internal", - "verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run sitemap:check && npm run brand:check && npm run check:type-scale && npm run check:icon-scale && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test", + "verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run sitemap:check && npm run brand:check && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test", "verify:pr-local": "node scripts/verify-pr-local.mjs", "verify:ui": "npm run check:runtime && npm run test:e2e:pr", "verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run governance:release && npm run eval:quality:release", @@ -103,6 +103,7 @@ "check:july8-live-batch": "node scripts/run-tsx.mjs scripts/check-july8-live-batch.ts", "check:type-scale": "node scripts/check-type-scale.mjs --strict", "check:icon-scale": "node scripts/check-icon-scale.mjs --strict", + "check:design-system-contract": "node scripts/check-design-system-contract.mjs", "check:function-grants": "node scripts/check-function-grants.mjs", "check:owner-scope": "node scripts/check-owner-scope-api.mjs", "recover:ingestion": "node scripts/run-tsx.mjs scripts/recover-ingestion-queue.ts", diff --git a/scripts/check-design-system-contract.mjs b/scripts/check-design-system-contract.mjs new file mode 100644 index 000000000..0cc962b65 --- /dev/null +++ b/scripts/check-design-system-contract.mjs @@ -0,0 +1,246 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import ts from "typescript"; + +import { + LEGACY_TAP_CLASS, + RAW_COLOR_EXEMPTIONS, + findInteractiveTapLiteralsInSource, + hasLegacyTapClass, + jsxClassText, + rawColorContractSource, +} from "./design-system-contract-utils.mjs"; + +const ROOT = process.cwd(); +const SRC_ROOT = path.join(ROOT, "src"); +const BASELINE_PATH = path.join(ROOT, "scripts", "design-system-contract-baseline.json"); +const PRINT_METRICS = process.argv.includes("--print-metrics"); +const SOURCE_EXTENSIONS = new Set([".css", ".ts", ".tsx"]); +const RAW_COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|oklch)\(/gi; +const LITERAL_SHADOW_CLASS = /shadow-\[(?!var\()[^\]]+\]/g; +const CUSTOM_CONTROL_CLASS_PROP = + /(?:closeButtonClassName|sheetCloseButtonClassName|buttonClassName|triggerClassName)\s*=\s*(?:"([^"]*)"|`([^`]*)`)/g; + +const toPosix = (value) => value.split(path.sep).join("/"); + +function isPrototype(relativePath) { + return ( + relativePath.includes("/mockups/") || + relativePath.includes("-mockup") || + relativePath.includes("/favourites-page-mockups/") || + relativePath.includes("/calculator-mockups/") + ); +} + +function walk(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) return walk(absolutePath); + if (!SOURCE_EXTENSIONS.has(path.extname(entry.name))) return []; + const relativePath = toPosix(path.relative(ROOT, absolutePath)); + return isPrototype(relativePath) ? [] : [{ absolutePath, relativePath }]; + }); +} + +function countMatches(text, pattern) { + return [...text.matchAll(pattern)].length; +} + +function withoutComments(text) { + return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +function findInteractiveTapLiterals(file) { + const sourceText = fs.readFileSync(file.absolutePath, "utf8"); + return findInteractiveTapLiteralsInSource(file.relativePath, sourceText); +} + +function findTherapyButtonsWithoutBaseClass(file) { + if (!file.relativePath.startsWith("src/components/therapy-compass/") || !file.relativePath.endsWith(".tsx")) + return []; + const sourceText = fs.readFileSync(file.absolutePath, "utf8"); + const source = ts.createSourceFile(file.relativePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const findings = []; + + function visit(node) { + if ( + (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && + node.tagName.getText(source) === "button" + ) { + const classAttribute = node.attributes.properties.find( + (attribute) => ts.isJsxAttribute(attribute) && attribute.name.getText(source) === "className", + ); + const classText = classAttribute && ts.isJsxAttribute(classAttribute) ? jsxClassText(classAttribute) : ""; + if (!classText.includes("tc-btn")) { + const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; + findings.push(`${file.relativePath}:${line}`); + } + } + ts.forEachChild(node, visit); + } + + visit(source); + return findings; +} + +const files = walk(SRC_ROOT); +const contents = new Map(files.map((file) => [file.relativePath, fs.readFileSync(file.absolutePath, "utf8")])); +const textAt = (relativePath) => contents.get(relativePath) ?? ""; +const failures = []; +const assert = (condition, message) => { + if (!condition) failures.push(message); +}; + +const metrics = { + rawColorLiterals: 0, + literalShadowClasses: 0, + legacyTapClasses: 0, +}; + +for (const file of files) { + const source = textAt(file.relativePath); + const contractSource = withoutComments(source); + const rawColorSource = withoutComments( + rawColorContractSource(file.relativePath, source, (message) => assert(false, message)), + ); + metrics.rawColorLiterals += countMatches(rawColorSource, RAW_COLOR); + metrics.literalShadowClasses += countMatches(contractSource, LITERAL_SHADOW_CLASS); + metrics.legacyTapClasses += countMatches(contractSource, LEGACY_TAP_CLASS); + for (const match of source.matchAll(CUSTOM_CONTROL_CLASS_PROP)) { + assert( + !hasLegacyTapClass(match[1] ?? match[2] ?? ""), + `${file.relativePath} contains a legacy 44px class in a control class prop`, + ); + } +} + +const interactiveTapFindings = files.flatMap(findInteractiveTapLiterals); +assert( + interactiveTapFindings.length === 0, + `interactive elements still use literal *-11 tap classes: ${interactiveTapFindings.join(", ")}`, +); + +const therapyFiles = files.filter(({ relativePath }) => relativePath.startsWith("src/components/therapy-compass/")); +const therapyButtonsWithoutBaseClass = therapyFiles.flatMap(findTherapyButtonsWithoutBaseClass); +const therapySource = therapyFiles.map(({ relativePath }) => textAt(relativePath)).join("\n"); +const therapyInlineStyleFindings = therapyFiles.flatMap(({ relativePath }) => { + const source = textAt(relativePath); + return source + .split(/\r?\n/) + .map((line, index) => ({ line, index: index + 1 })) + .filter(({ line }) => /style=\{/.test(line)) + .filter(({ line }) => { + if (relativePath.endsWith("/icons.tsx")) return !/style=\{style\}/.test(line); + if (relativePath.endsWith("/ui.tsx")) return !/--tc-meter-width/.test(line); + if (relativePath.endsWith("/screens/compare-screen.tsx")) return !/--tc-compare-columns/.test(line); + return true; + }) + .map(({ index }) => `${relativePath}:${index}`); +}); + +assert(!therapySource.includes("style={s("), "Therapy Compass still invokes the runtime style parser"); +assert(!therapySource.includes("style-utils"), "Therapy Compass still imports the runtime style parser"); +assert( + therapyButtonsWithoutBaseClass.length === 0, + `Therapy buttons bypass the shared interaction states: ${therapyButtonsWithoutBaseClass.join(", ")}`, +); +assert( + !fs.existsSync(path.join(ROOT, "src/components/therapy-compass/style-utils.ts")), + "style-utils.ts must stay retired", +); +assert( + !fs.existsSync(path.join(ROOT, "src/components/therapy-compass/styles.tsx")), + "the runtime Therapy style island must stay retired", +); +assert( + therapyInlineStyleFindings.length === 0, + `unscoped Therapy inline styles found: ${therapyInlineStyleFindings.join(", ")}`, +); +assert(!/outline\s*:\s*none/i.test(therapySource), "Therapy Compass suppresses a focus outline"); +assert(!therapySource.toLowerCase().includes("#8a94a3"), "the low-contrast patient-sheet gray returned"); + +const therapyCss = textAt("src/components/therapy-compass/therapy-compass.css"); +assert(!/(?:^|[^0-9])44px/.test(therapyCss), "Therapy Compass CSS contains a literal 44px tap target"); +assert(therapyCss.includes("--tc-paper-muted: #5b6472"), "the fixed paper palette must keep its accessible muted ink"); +assert( + therapyCss.includes('.tc-paper [contenteditable="true"]:focus-visible'), + "patient-sheet editing needs a visible focus state", +); +assert(therapyCss.includes(".tc-btn:hover:not(:disabled)"), "Therapy buttons need a hover state"); +assert(therapyCss.includes(".tc-btn:disabled"), "Therapy buttons need a disabled state"); + +const paperStart = therapyCss.indexOf(".tc-root .tc-screens-sheets-screen-023"); +const paperEnd = therapyCss.indexOf(".tc-root .tc-screens-sheets-screen-050"); +const hasPaperBoundaries = paperStart >= 0 && paperEnd > paperStart; +assert(hasPaperBoundaries, "patient-sheet paper rule boundaries are missing or misordered"); +const paperRules = hasPaperBoundaries ? therapyCss.slice(paperStart, paperEnd) : therapyCss; +assert( + !/var\(--(?:background|surface|border|text|clinical|command|focus)/.test(paperRules), + "patient-sheet paper rules leaked theme-reactive application tokens", +); + +const semanticSources = [ + "src/components/therapy-compass/nav.tsx", + "src/components/therapy-compass/therapy-card.tsx", + "src/components/therapy-compass/screens/brief-screen.tsx", + "src/components/therapy-compass/screens/compare-screen.tsx", + "src/components/therapy-compass/screens/search-screen.tsx", + "src/components/therapy-compass/screens/sheets-screen.tsx", +] + .map(textAt) + .join("\n"); +assert(semanticSources.includes("aria-current"), "Therapy navigation needs aria-current"); +assert(semanticSources.includes("aria-pressed"), "Therapy toggles need aria-pressed"); +assert(!semanticSources.includes('role="tab"'), "Therapy toggle groups must not claim incomplete tab semantics"); +assert( + /disabled=\{items\.length === 0\}/.test(textAt("src/components/therapy-compass/screens/compare-screen.tsx")), + "empty compare Clear must be disabled", +); + +const globals = textAt("src/app/globals.css"); +assert(!/^\s*--space-\d+\s*:/m.test(globals), "unused --space-* tokens returned"); +const primitives = textAt("src/components/ui-primitives.tsx"); +assert( + primitives.includes('export const chatComposerInput = "chat-composer-input"'), + "composer input chrome must have one CSS owner", +); +assert(primitives.includes("aria-[invalid=true]"), "shared fields need an invalid state"); +assert(primitives.includes("read-only:"), "shared fields need a read-only state"); +assert(primitives.includes("export function AsyncButton"), "shared async button semantics are missing"); + +for (const target of [ + "src/components/DocumentViewer.tsx", + "src/components/clinical-dashboard/favourites-hub.tsx", + "src/components/clinical-dashboard/master-search-header.tsx", + "src/components/clinical-dashboard/mode-action-popup.tsx", + "src/components/clinical-dashboard/settings-dialog.tsx", +]) { + assert(!/\bring-white\b|\bbg-white\b/.test(textAt(target)), `${target} bypasses the shared glass/toggle recipes`); +} + +if (!PRINT_METRICS) { + assert(fs.existsSync(BASELINE_PATH), "design-system contract baseline is missing"); + if (fs.existsSync(BASELINE_PATH)) { + const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); + for (const [metric, value] of Object.entries(metrics)) { + assert(value <= baseline[metric], `${metric} increased from ${baseline[metric]} to ${value}`); + } + } +} + +if (PRINT_METRICS) { + console.log(JSON.stringify(metrics, null, 2)); + process.exit(failures.length === 0 ? 0 : 1); +} + +if (failures.length > 0) { + console.error("Design-system contract failed:\n"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log( + `Design-system contract passed (${files.length} production files; raw colors ${metrics.rawColorLiterals}; literal shadows ${metrics.literalShadowClasses}; legacy tap classes ${metrics.legacyTapClasses}).`, +); +console.log(`Raw-color exemptions: ${RAW_COLOR_EXEMPTIONS.map(({ category }) => category).join(", ")}.`); diff --git a/scripts/design-system-contract-baseline.json b/scripts/design-system-contract-baseline.json new file mode 100644 index 000000000..cfe3462dc --- /dev/null +++ b/scripts/design-system-contract-baseline.json @@ -0,0 +1,5 @@ +{ + "rawColorLiterals": 9, + "literalShadowClasses": 6, + "legacyTapClasses": 28 +} diff --git a/scripts/design-system-contract-utils.mjs b/scripts/design-system-contract-utils.mjs new file mode 100644 index 000000000..ec6317f62 --- /dev/null +++ b/scripts/design-system-contract-utils.mjs @@ -0,0 +1,185 @@ +import ts from "typescript"; + +const LEGACY_TAP_TOKEN_SOURCE = String.raw`(?:[^\s:"'\x60]+:)*(?:h|w|min-h|min-w|size)-11`; + +export const LEGACY_TAP_CLASS = new RegExp(`(?:^|[\\s\"'\\x60])${LEGACY_TAP_TOKEN_SOURCE}(?=[\\s\"'\\x60]|$)`, "g"); +const LEGACY_TAP_CLASS_TEST = new RegExp(`(?:^|[\\s\"'\\x60])${LEGACY_TAP_TOKEN_SOURCE}(?=[\\s\"'\\x60]|$)`); + +export const RAW_COLOR_EXEMPTIONS = [ + { category: "global theme tokens", pattern: /^src\/app\/globals\.css$/, scope: "whole-file" }, + { + category: "brand artwork", + pattern: + /^src\/(?:lib\/brand-(?:mark\.ts|image\.tsx)|components\/clinical-dashboard\/(?:brand|provider-brand-icons)\.tsx)$/, + scope: "whole-file", + }, + { + category: "diagnostic visualizations", + pattern: /^src\/components\/(?:web-vitals-reporter|clinical-dashboard\/visual-evidence)\.tsx$/, + scope: "whole-file", + }, + { category: "OpenGraph artwork", pattern: /^src\/app\/opengraph-image\.tsx$/, scope: "whole-file" }, + { + category: "error fallbacks", + pattern: /^src\/(?:app\/global-error|components\/route-error-boundary)\.tsx$/, + scope: "whole-file", + }, + { + category: "printable Therapy paper", + pattern: /^src\/components\/therapy-compass\/therapy-compass\.css$/, + scope: "therapy-paper", + }, + { + category: "printable factsheet paper", + pattern: /^src\/components\/factsheets\/factsheet-detail-page\.tsx$/, + scope: "factsheet-print-sheet", + }, +]; + +export function hasLegacyTapClass(classText) { + return LEGACY_TAP_CLASS_TEST.test(classText); +} + +export function jsxClassSegments(attribute) { + const initializer = attribute.initializer; + if (!initializer) return []; + if (ts.isStringLiteral(initializer)) return [initializer.text]; + if (!ts.isJsxExpression(initializer) || !initializer.expression) return []; + + const segments = []; + function visit(node) { + if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + segments.push(node.text); + return; + } + if (ts.isTemplateExpression(node)) { + segments.push(node.head.text); + for (const span of node.templateSpans) { + visit(span.expression); + segments.push(span.literal.text); + } + return; + } + ts.forEachChild(node, visit); + } + + visit(initializer.expression); + return segments; +} + +export function jsxClassText(attribute) { + const segments = jsxClassSegments(attribute); + if (segments.length > 0) return segments.join(" "); + const initializer = attribute.initializer; + return ts.isJsxExpression(initializer) && initializer.expression ? initializer.expression.getText() : ""; +} + +export function findInteractiveTapLiteralsInSource(relativePath, sourceText) { + if (!relativePath.endsWith(".tsx")) return []; + const source = ts.createSourceFile(relativePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const findings = []; + const interactiveTags = new Set(["a", "button", "input", "select", "summary", "textarea"]); + + function inspectOpeningElement(node) { + const tag = node.tagName.getText(source); + if (!interactiveTags.has(tag)) return; + const classAttribute = node.attributes.properties.find( + (attribute) => ts.isJsxAttribute(attribute) && attribute.name.getText(source) === "className", + ); + if (!classAttribute || !ts.isJsxAttribute(classAttribute)) return; + if (!jsxClassSegments(classAttribute).some(hasLegacyTapClass)) return; + const line = source.getLineAndCharacterOfPosition(classAttribute.getStart(source)).line + 1; + findings.push(`${relativePath}:${line}`); + } + + function visit(node) { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) inspectOpeningElement(node); + ts.forEachChild(node, visit); + } + + visit(source); + return findings; +} + +function maskRanges(source, ranges) { + const characters = source.split(""); + for (const { start, end } of ranges) characters.fill(" ", start, end); + return characters.join(""); +} + +function balancedBlockRange(source, marker) { + const start = source.indexOf(marker); + if (start < 0) return null; + const openingBrace = source.indexOf("{", start); + if (openingBrace < 0) return null; + + let depth = 0; + let quote = null; + let escaped = false; + let inComment = false; + for (let index = openingBrace; index < source.length; index += 1) { + const character = source[index]; + if (inComment) { + if (character === "*" && source[index + 1] === "/") { + inComment = false; + index += 1; + } + continue; + } + if (quote) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = null; + continue; + } + if (character === "/" && source[index + 1] === "*") { + inComment = true; + index += 1; + continue; + } + if (character === '"' || character === "'") { + quote = character; + continue; + } + if (character === "{") depth += 1; + if (character !== "}") continue; + depth -= 1; + if (depth === 0) return { start, end: index + 1 }; + } + return null; +} + +function namedFunctionRange(relativePath, source, functionName) { + const parsed = ts.createSourceFile(relativePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const declaration = parsed.statements.find( + (node) => ts.isFunctionDeclaration(node) && node.name?.text === functionName, + ); + return declaration ? { start: declaration.getFullStart(), end: declaration.end } : null; +} + +export function rawColorContractSource(relativePath, source, reportFailure = () => {}) { + const exemption = RAW_COLOR_EXEMPTIONS.find(({ pattern }) => pattern.test(relativePath)); + if (!exemption) return source; + if (exemption.scope === "whole-file") return ""; + + if (exemption.scope === "therapy-paper") { + const ranges = [balancedBlockRange(source, ".tc-paper {"), balancedBlockRange(source, "@media print {")]; + if (ranges.some((range) => !range)) { + reportFailure("printable Therapy paper raw-color boundaries are missing"); + return source; + } + return maskRanges(source, ranges); + } + + if (exemption.scope === "factsheet-print-sheet") { + const range = namedFunctionRange(relativePath, source, "FactsheetPrintSheet"); + if (!range) { + reportFailure("printable factsheet paper boundary is missing"); + return source; + } + return maskRanges(source, [range]); + } + + reportFailure(`unknown raw-color exemption scope for ${relativePath}`); + return source; +} diff --git a/src/app/globals.css b/src/app/globals.css index 48dc21e36..297876764 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -263,14 +263,6 @@ --shadow-lux: inset 0 1px 0 rgb(255 255 255 / 64%), 0 12px 34px rgb(8 16 24 / 7%); --shadow-inset: inset 0 1px 0 rgb(255 255 255 / 58%); --shadow-card: var(--shadow-tight); - --space-1: 0.25rem; - --space-2: 0.5rem; - --space-3: 0.75rem; - --space-4: 1rem; - --space-6: 1.5rem; - --space-8: 2rem; - --space-12: 3rem; - --space-16: 4rem; --safe-area-top: env(safe-area-inset-top, 0px); --safe-area-right: env(safe-area-inset-right, 0px); --safe-area-bottom: env(safe-area-inset-bottom, 0px); @@ -553,14 +545,9 @@ summary::-webkit-details-marker { * scripts/capture-chrome-parity.ts). Responsive padding overrides are nested * here so the whole class stays in one layer. * - * The COMPOSER PILL surface below (answer-footer-search-pill, a
) is now - * ALSO layered via a chatComposerShell base/delta split (see its comment). The - * pill-interior controls (answer-footer-search-input/-send/-action) and the - * composer POSITIONING chrome (*-composer-edge, *-search-edge/-dock/-backdrop, - * -pill-open, document-mobile-search-*) stay INTENTIONALLY UNLAYERED — the - * controls are / - + ) : ( @@ -245,14 +244,16 @@ export function DocumentManagementActions({ - + )} diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 05f21b300..b50f41bd6 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -54,6 +54,7 @@ import { fieldControl, fieldLabel, floatingControl, + glassOverlaySurface, InlineNotice, LoadingPanel, panel, @@ -1262,7 +1263,7 @@ function DocumentManualTagEditor({ @@ -2834,7 +2835,7 @@ export function DocumentViewer({ ? "Standard viewer with built-in fit and zoom controls." : "Sharper zoom — uses your browser's PDF engine to keep heavy-zoom pages crisp." } - className={cn(secondaryButton, "min-h-11 w-full justify-center px-3 text-xs sm:w-auto")} + className={cn(secondaryButton, "min-h-tap w-full justify-center px-3 text-xs sm:w-auto")} > {useNativePdfViewer ? "Standard view" : "Sharper zoom"} @@ -3101,12 +3102,15 @@ export function DocumentViewer({ onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setComposerChromeFocused(false); }} - className="document-viewer-composer floating-composer-edge dashboard-composer-edge fixed z-40 mx-auto flex min-h-[56px] max-w-3xl items-center gap-2 rounded-full border border-[color:var(--border-lux)] bg-[color:var(--surface-lux)] px-2 shadow-[var(--shadow-lux)] ring-1 ring-white/35 dark:ring-white/10 backdrop-blur-xl max-sm:transition-transform max-sm:duration-200 max-sm:ease-out motion-reduce:transition-none" + className={cn( + glassOverlaySurface, + "document-viewer-composer floating-composer-edge dashboard-composer-edge fixed z-40 mx-auto flex min-h-[56px] max-w-3xl items-center gap-2 rounded-full bg-[color:var(--surface-lux)] px-2 shadow-[var(--shadow-lux)] max-sm:transition-transform max-sm:duration-200 max-sm:ease-out motion-reduce:transition-none", + )} > +
@@ -249,7 +260,7 @@ function ProviderButton({ provider, onClick }: { provider: SsoProvider; onClick: +
@@ -253,7 +255,7 @@ function ProviderButton({ provider, onClick }: { provider: "Apple" | "Google" | @@ -92,7 +92,7 @@ function CrossModeLinkChip({ link, Icon, query, onModeSearch }: CrossModeLinkCar logCrossModeLinkOpen(query, link)} - className="inline-flex min-h-11 min-w-0 items-center gap-2 px-2.5 transition hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[color:var(--focus)] md:min-h-9" + className="inline-flex min-h-tap min-w-0 items-center gap-2 px-2.5 transition hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[color:var(--focus)] md:min-h-9" > @@ -112,7 +112,7 @@ function CrossModeLinkChip({ link, Icon, query, onModeSearch }: CrossModeLinkCar // click would corrupt retrieval-quality/click telemetry. onClick={() => onModeSearch(link.modeId, link.modeSearchQuery)} aria-label={`Search ${link.title} in ${link.modeLabel}`} - className="grid min-h-11 w-11 shrink-0 place-items-center border-l border-[color:var(--border)] text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[color:var(--focus)] md:min-h-9 md:w-9" + className="grid min-h-tap w-tap shrink-0 place-items-center border-l border-[color:var(--border)] text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[color:var(--focus)] md:min-h-9 md:w-9" > diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index e4ecdede7..40c54affd 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -284,7 +284,7 @@ function ResultTypeTabs({ aria-label={`${tab.label} (${tab.count})`} onClick={() => onFilterChange(tab.id)} className={cn( - "inline-flex min-h-11 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border px-2.5 text-xs font-bold min-[390px]:text-sm", + "inline-flex min-h-tap shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border px-2.5 text-xs font-bold min-[390px]:text-sm", resultTypeTabFocusRing, active ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]" @@ -340,7 +340,7 @@ function SelectionToggle({ selected, onClick, label }: { selected: boolean; onCl aria-label={`${selected ? "Remove" : "Add"} ${label} ${selected ? "from" : "to"} comparison`} onClick={onClick} className={cn( - "grid h-11 w-11 shrink-0 place-items-center rounded-md border text-sm transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", + "grid h-tap w-tap shrink-0 place-items-center rounded-md border text-sm transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", selected ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]" : "border-[color:var(--border-strong)] bg-[color:var(--surface)] text-transparent hover:text-[color:var(--text-soft)]", @@ -1092,7 +1092,7 @@ function SearchResultsView({ View all catalogue matches ({results.length}) diff --git a/src/components/clinical-dashboard/document-admin.tsx b/src/components/clinical-dashboard/document-admin.tsx index 8a242779b..e5d8b8c4f 100644 --- a/src/components/clinical-dashboard/document-admin.tsx +++ b/src/components/clinical-dashboard/document-admin.tsx @@ -1038,7 +1038,7 @@ export function DocumentDrawer({
{documentDisplayTitle(document)}
- + ); } @@ -545,7 +545,7 @@ function SelectedDocumentEvidencePanel({
Open exact evidence @@ -554,7 +554,7 @@ function SelectedDocumentEvidencePanel({ onScopeDocument(document.document_id)} icon={Filter} - className="min-h-11 rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] px-2 text-xs" + className="min-h-tap rounded-lg border border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] px-2 text-xs" aria-label={`Scope search to ${document.title}`} > Scope @@ -562,7 +562,7 @@ function SelectedDocumentEvidencePanel({ onAnswerFromDocument(document.document_id)} icon={Sparkles} - className="min-h-11 rounded-lg border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-2 text-xs text-[color:var(--clinical-accent)]" + className="min-h-tap rounded-lg border border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] px-2 text-xs text-[color:var(--clinical-accent)]" aria-label={`Answer from ${document.title}`} > Answer @@ -667,7 +667,7 @@ function SearchRecordResults({

{service.title} @@ -679,7 +679,7 @@ function SearchRecordResults({ href={recordRoute(service.slug)} className={cn( floatingControl, - "inline-flex min-h-11 w-full justify-center rounded-lg px-3 text-sm text-[color:var(--clinical-accent)] sm:w-auto", + "inline-flex min-h-tap w-full justify-center rounded-lg px-3 text-sm text-[color:var(--clinical-accent)] sm:w-auto", )} aria-label={`Open ${service.title}`} > @@ -1041,7 +1041,7 @@ function DocumentSearchResultsPanelImpl({

{documentDisplayTitle(document)} @@ -1091,7 +1091,7 @@ function DocumentSearchResultsPanelImpl({ onClick={() => setSelectedDocumentId(document.document_id)} icon={Sparkles} className={cn( - "min-h-11 rounded-lg px-2.5 text-xs", + "min-h-tap rounded-lg px-2.5 text-xs", selected ? "bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" : "text-[color:var(--text)]", @@ -1102,7 +1102,7 @@ function DocumentSearchResultsPanelImpl({ {contextualOpenLabel(document)} @@ -1110,7 +1110,7 @@ function DocumentSearchResultsPanelImpl({ onScopeDocument(document.document_id)} icon={Filter} - className="min-h-11 rounded-lg px-2.5 text-xs text-[color:var(--text)]" + className="min-h-tap rounded-lg px-2.5 text-xs text-[color:var(--text)]" aria-label={`Scope search to ${document.title}`} > Scope @@ -1118,7 +1118,7 @@ function DocumentSearchResultsPanelImpl({ onAnswerFromDocument(document.document_id)} icon={Sparkles} - className="ml-auto min-h-11 rounded-lg px-2.5 text-xs text-[color:var(--clinical-accent)] hover:bg-[color:var(--clinical-accent-soft)]" + className="ml-auto min-h-tap rounded-lg px-2.5 text-xs text-[color:var(--clinical-accent)] hover:bg-[color:var(--clinical-accent-soft)]" aria-label={`Answer from ${document.title}`} > Answer diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index 47d3c984a..00db8fd13 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -695,7 +695,7 @@ export function ClinicalNotesChecklistPanel({ aria-label={`${tab.label} (${tab.count})`} onClick={() => setRequestedTab(tab.id)} className={cn( - "inline-flex min-h-11 min-w-0 items-center justify-center gap-1.5 rounded-md px-2 text-xs font-semibold leading-none transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", + "inline-flex min-h-tap min-w-0 items-center justify-center gap-1.5 rounded-md px-2 text-xs font-semibold leading-none transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", selected ? "bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]" : "text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] hover:text-[color:var(--text)]", @@ -824,7 +824,7 @@ export function ClinicalNotesChecklistPanel({ {bestSource ? (