diff --git a/docs/design-system.md b/docs/design-system.md index dfa24d858..d77766b42 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -49,7 +49,9 @@ When you meet a pre-token hardcode (mockups being promoted, old branches), map i | `slate-200` / `slate-500` / `slate-600` | `var(--border)` / `var(--text-soft)` / `var(--text-muted)` | | ad-hoc `rgba(...)` shadows | `var(--shadow-tight/soft/hover/elevated/inset)` or `--glow-*` | -## 2. Type scale +## 2. Type & icon scale + +### Type Named steps live in the `@theme` block of `globals.css` and are **size-only** (no baked line-height/tracking — set `leading-*`/`tracking-*` at the call site): @@ -69,6 +71,23 @@ line-height/tracking — set `leading-*`/`tracking-*` at the call site): on hero/mode-home titles, and `*-mockups` files. Don't add scale steps for one-off display sizes. +### Icon size + +Icon **glyphs** use the parallel `--spacing-icon-*` scale in `@theme`: +`size-icon-xs` 12 · `size-icon-sm` 14 · `size-icon-md` 16 (default) · `size-icon-lg` 20 · +`size-icon-xl` 24 (px). These generate `size-icon-*` / `h-icon-*` / `w-icon-*`, exactly like +`--spacing-tap` → `size-tap`. + +- Prefer `size-icon-md` over raw `h-4 w-4` for an icon glyph. `npm run check:icon-scale --strict` + (in `verify:cheap`) blocks the retired `4.5` (18px) half-step — icon glyphs resolve to + `size-icon-lg`, non-icon 18px boxes to `h-5`. It does **not** touch raw `h-4 w-4` (which also + sizes non-icons), so migrating the long tail onto `size-icon-*` is opportunistic, not enforced. +- **Responsive** icons add a breakpoint variant — `size-icon-md sm:size-icon-lg`. Reserve it for + a few roles (nav, composer, hero, panel headings); most icons stay one fixed size. +- **Not** for container tiles (`iconTile` h-9, empty-state tile h-10) or non-icon boxes (the + `ToggleSwitch` knob, status dots) — those keep the integer spacing scale. Icon glyph size is + independent of the 44px tap target (§3), which stays on `--spacing-tap`. + ## 3. Spacing & tap targets - 4px grid via Tailwind spacing; safe-area env paddings on shell edges. @@ -165,6 +184,12 @@ image"}` — never a possibly-empty variable alone. noindexed (robots.ts + layout metadata), and exempt from token/type-scale rules — but **promoting a mockup to production means bringing it onto the token system first** (see the legacy-hex table above). +- **Brand mark** is single-sourced in `src/lib/brand-mark.ts` (geometry + SVG builders). + `BrandMark` (`clinical-dashboard/brand.tsx`) renders it token-themed; `app/icon.svg`, + `app/apple-icon`, the PWA maskable icons, and `app/opengraph-image` all derive from it. To + change the mark, edit `brand-mark.ts` then `npm run brand:update`; `brand:check` (in + `verify:cheap`) guards `app/icon.svg` from drift. `app/favicon.ico` is a multi-resolution + binary the toolchain can't emit — regenerate it offline from `icon.svg` when the mark changes. ## 11. What NOT to do diff --git a/eslint-rules/require-lucide-icon-aria.mjs b/eslint-rules/require-lucide-icon-aria.mjs new file mode 100644 index 000000000..fa82ad65a --- /dev/null +++ b/eslint-rules/require-lucide-icon-aria.mjs @@ -0,0 +1,71 @@ +/** + * Local ESLint rule: a lucide-react icon rendered as a JSX element must declare + * its accessibility intent — either `aria-hidden` (decorative, the common case) + * or an accessible name (`aria-label` / `aria-labelledby` / `role` / `title`). + * + * This enforces the codebase's existing convention (574 aria-hidden across the + * app) so a decorative glyph can't silently reach the accessibility tree. It + * only inspects JSX whose tag name is imported directly from "lucide-react"; + * icons passed around as `icon: LucideIcon` values are out of scope (their aria + * is set where they render). Elements that spread props ({...rest}) are skipped, + * since the aria attribute may arrive dynamically. + */ + +const ACCESSIBILITY_ATTRS = new Set(["aria-hidden", "aria-label", "aria-labelledby", "role", "title"]); + +/** @type {import("eslint").Rule.RuleModule} */ +const rule = { + meta: { + type: "problem", + // Auto-fix adds aria-hidden="true" (the decorative default). Safe: a bare + // lucide has no accessible name, so hiding it never removes a control's + // name — an icon-only button that needs a name was already unlabeled and is + // caught separately by runtime axe checks. + fixable: "code", + docs: { + description: "Require lucide-react icons to be decorative (aria-hidden) or to carry an accessible name.", + }, + schema: [], + messages: { + missing: + 'Lucide icon <{{name}}> needs aria-hidden="true" (if decorative) or an accessible name (aria-label / aria-labelledby / role / title).', + }, + }, + create(context) { + /** Local identifiers imported as values from lucide-react. */ + const lucideValueImports = new Set(); + + return { + ImportDeclaration(node) { + if (node.source.value !== "lucide-react") return; + if (node.importKind === "type") return; // whole `import type { … }` + for (const spec of node.specifiers) { + if (spec.type !== "ImportSpecifier" && spec.type !== "ImportDefaultSpecifier") continue; + if (spec.importKind === "type") continue; // inline `type X` + lucideValueImports.add(spec.local.name); + } + }, + JSXOpeningElement(node) { + if (node.name.type !== "JSXIdentifier") return; + if (!lucideValueImports.has(node.name.name)) return; + // A spread ({...props}) may inject aria-* dynamically — don't flag. + if (node.attributes.some((attr) => attr.type === "JSXSpreadAttribute")) return; + const declaresIntent = node.attributes.some( + (attr) => + attr.type === "JSXAttribute" && + attr.name.type === "JSXIdentifier" && + ACCESSIBILITY_ATTRS.has(attr.name.name), + ); + if (declaresIntent) return; + context.report({ + node, + messageId: "missing", + data: { name: node.name.name }, + fix: (fixer) => fixer.insertTextAfter(node.name, ' aria-hidden="true"'), + }); + }, + }; + }, +}; + +export default rule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 1445dcede..64e604084 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,6 +2,8 @@ import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; +import requireLucideIconAria from "./eslint-rules/require-lucide-icon-aria.mjs"; + const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, @@ -21,6 +23,31 @@ const eslintConfig = defineConfig([ "jsx-a11y/label-has-associated-control": "error", }, }, + // A lucide-react icon rendered as JSX must be decorative (aria-hidden) or + // carry an accessible name. Enforces the codebase's own convention so a glyph + // can't silently reach the a11y tree. Mockups are design-scratch and exempt. + { + files: ["**/*.{jsx,tsx}"], + ignores: ["**/*mockup*", "**/mockups/**"], + plugins: { + local: { rules: { "require-lucide-icon-aria": requireLucideIconAria } }, + }, + rules: { + "local/require-lucide-icon-aria": "error", + }, + }, + // next/og image routes render through Satori (rasterised server-side, + // not DOM); next/image cannot run there. Turn the rule off for these files via + // config rather than a per-file disable directive — the Next plugin reports + // this rule inconsistently across environments (it fires with a local .next + // present but not on a fresh CI checkout), so a disable directive flips + // between "used" and "unused" and trips `--max-warnings 0`. + { + files: ["src/lib/brand-image.tsx", "src/app/opengraph-image.tsx"], + rules: { + "@next/next/no-img-element": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/package.json b/package.json index f38968eaa..4749b3757 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "test:e2e:advisory": "node scripts/run-playwright.mjs --project=chromium --grep-invert @critical", "test:e2e:chromium": "node scripts/run-playwright.mjs --project=chromium", "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", - "verify:cheap": "npm run check:runtime && npm run check:github-actions && npm run sitemap:check && npm run check:type-scale && npm run lint && npm run typecheck && npm run test", + "verify:cheap": "npm run check:runtime && npm run check:github-actions && npm run sitemap:check && npm run brand:check && npm run check:type-scale && npm run check:icon-scale && 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:chromium", "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", @@ -35,6 +35,8 @@ "check:ci-scope": "node scripts/ci-change-scope.mjs --self-test", "sitemap:update": "node scripts/run-tsx.mjs scripts/generate-site-map.ts", "sitemap:check": "node scripts/run-tsx.mjs scripts/generate-site-map.ts --check", + "brand:update": "node scripts/run-tsx.mjs scripts/generate-brand-assets.ts", + "brand:check": "node scripts/run-tsx.mjs scripts/generate-brand-assets.ts --check", "check:runtime": "node scripts/run-tsx.mjs scripts/check-runtime.ts", "check:codex-autofix-workflow": "node scripts/check-codex-autofix-workflow.mjs", "check:deployment-readiness": "node scripts/deployment-boot-smoke.mjs", @@ -70,6 +72,7 @@ "check:m13-migration": "node scripts/run-tsx.mjs scripts/check-m13-migration.ts", "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", "recover:ingestion": "node scripts/run-tsx.mjs scripts/recover-ingestion-queue.ts", "registry:seed": "node scripts/run-tsx.mjs scripts/seed-registry-records.ts", "registry:embed": "node scripts/run-tsx.mjs scripts/embed-registry-records.ts", diff --git a/scripts/check-icon-scale.mjs b/scripts/check-icon-scale.mjs new file mode 100644 index 000000000..b9d9725e8 --- /dev/null +++ b/scripts/check-icon-scale.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// Guardrail/diagnostic: flags icon-sizing utilities that bypass the design +// icon-size scale. +// +// The scale lives in the @theme block of src/app/globals.css: +// --spacing-icon-xs 12px → size-icon-xs / h-icon-xs / w-icon-xs +// --spacing-icon-sm 14px → size-icon-sm +// --spacing-icon-md 16px → size-icon-md (default) +// --spacing-icon-lg 20px → size-icon-lg +// --spacing-icon-xl 24px → size-icon-xl +// +// It enforces one unambiguous drift signal: the retired `4.5` half-step +// (h-4.5 / w-4.5 / size-4.5, 18px). 18px is off the 4px grid; it was only ever +// reached by icon glyphs and a handful of tiny boxes, and it resolves cleanly to +// size-icon-lg (glyphs) or h-5 (non-icon boxes). +// +// It intentionally does NOT flag raw h-4 w-4 / h-5 w-5 etc.: those integer +// spacing steps also size non-icons (the ToggleSwitch knob, status dots, +// avatars, container tiles), so a blanket ban would false-positive. Migrating +// icon glyphs onto size-icon-* is a codemod, not a lint rule — see +// docs/design-system.md §2. It also does NOT flag arbitrary h-[Npx]: those are +// used by deliberate non-icon elements too (e.g. count-badge bubbles at 18px), +// so flagging them would false-positive; genuine icon glyphs simply use the scale. +// Mockups (*mockup*) are design-scratch and out of scope. +// +// Usage: +// node scripts/check-icon-scale.mjs report only, exit 0 (default) +// node scripts/check-icon-scale.mjs --strict exit 1 if any found (promote to a +// CI gate once the backlog is cleared) + +import { readFileSync } from "node:fs"; +import { execSync } from "node:child_process"; + +const strict = process.argv.includes("--strict"); + +// Retired 18px half-step: (min-)h-4.5 / (min-)w-4.5 / size-4.5. +const HALF_STEP = /\b(?:min-)?(?:h|w|size)-4\.5\b/g; + +// Tracked source files under src (fast; respects .gitignore). Exclude mockups. +const files = execSync("git ls-files src", { encoding: "utf8" }) + .split("\n") + .filter((f) => /\.(tsx?|jsx?)$/.test(f)) + .filter((f) => !/mockup/i.test(f)); + +const hits = []; +for (const file of files) { + let text; + try { + text = readFileSync(file, "utf8"); + } catch { + continue; + } + text.split("\n").forEach((line, i) => { + for (const m of line.matchAll(HALF_STEP)) { + hits.push({ file, line: i + 1, match: m[0] }); + } + }); +} + +if (hits.length === 0) { + console.log("✓ icon-scale: no retired 4.5 (18px) half-step icon sizes in src."); + process.exit(0); +} + +const byMatch = new Map(); +for (const h of hits) byMatch.set(h.match, (byMatch.get(h.match) ?? 0) + 1); +const fileCount = new Set(hits.map((h) => h.file)).size; + +console.log(`icon-scale: ${hits.length} off-scale icon sizes bypass the scale (across ${fileCount} files):\n`); +for (const [size, n] of [...byMatch.entries()].sort((a, b) => b[1] - a[1])) { + console.log(` ${String(n).padStart(4)} ${size}`); +} +console.log("\nPrefer a named @theme step from src/app/globals.css (size-icon-sm, size-icon-md, size-icon-lg, …)."); + +if (strict) { + console.error("\n✗ --strict: off-scale icon sizes present."); + process.exit(1); +} +process.exit(0); diff --git a/scripts/generate-brand-assets.ts b/scripts/generate-brand-assets.ts new file mode 100644 index 000000000..0baeddf4a --- /dev/null +++ b/scripts/generate-brand-assets.ts @@ -0,0 +1,40 @@ +// Generates static brand assets from the single source in src/lib/brand-mark. +// Currently emits src/app/icon.svg (the themed browser-tab icon). Run: +// node scripts/run-tsx.mjs scripts/generate-brand-assets.ts # write +// node scripts/run-tsx.mjs scripts/generate-brand-assets.ts --check # verify (CI) +// +// The --check mode is wired into `verify:cheap` so app/icon.svg can never drift +// from the brand-mark source (same idea as sitemap:check). +// +// NOTE: src/app/favicon.ico is a multi-resolution binary and cannot be emitted +// here — the toolchain has no rasteriser. Regenerate it offline from icon.svg +// when the mark changes; see docs/design-system.md. +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { brandIconSvg } from "@/lib/brand-mark"; + +const check = process.argv.includes("--check"); +const iconPath = resolve(process.cwd(), "src/app/icon.svg"); +const expected = brandIconSvg(); + +if (!check) { + writeFileSync(iconPath, expected); + console.log("✓ brand: wrote src/app/icon.svg from the brand-mark source."); + process.exit(0); +} + +let actual: string; +try { + actual = readFileSync(iconPath, "utf8"); +} catch { + console.error("✗ brand: src/app/icon.svg is missing. Run: npm run brand:update"); + process.exit(1); +} + +if (actual !== expected) { + console.error("✗ brand: src/app/icon.svg is out of sync with src/lib/brand-mark. Run: npm run brand:update"); + process.exit(1); +} + +console.log("✓ brand: src/app/icon.svg matches the brand-mark source."); diff --git a/src/app/apple-icon.tsx b/src/app/apple-icon.tsx new file mode 100644 index 000000000..1df184943 --- /dev/null +++ b/src/app/apple-icon.tsx @@ -0,0 +1,15 @@ +import { ImageResponse } from "next/og"; + +import { BRAND_ICON_FIELD, BrandIconImage } from "@/lib/brand-image"; + +// Fixes the appleWebApp.capable mismatch: without this, iOS "Add to Home +// Screen" falls back to a page screenshot. iOS masks corners and paints +// transparency black, so render a full-bleed opaque teal field. +export const size = { width: 180, height: 180 }; +export const contentType = "image/png"; + +export default function AppleIcon() { + return new ImageResponse(, { + ...size, + }); +} diff --git a/src/app/globals.css b/src/app/globals.css index b2c30862c..0909710d6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -39,6 +39,19 @@ deep in sheets stay on min-h-12 (48px) — do not downgrade those to the token. */ --spacing-tap: 2.75rem; + /* Icon glyph size scale. Named spacing tokens so icon sizing is a documented + scale (size-icon-md is the 16px default) instead of raw `h-4 w-4` literals, + and so a step can carry a responsive variant: `size-icon-md sm:size-icon-lg`. + Generates size-icon-*, h-icon-*, w-icon-* exactly like --spacing-tap generates + size-tap. The `4.5` (18px) half-step these retire snaps to sm/md/lg per site. + NB: container tiles (iconTile h-9, empty-state tile h-10) are NOT glyphs and + stay on the integer spacing scale — this token is for the icon glyph itself. */ + --spacing-icon-xs: 0.75rem; /* 12px — inline markers, dense metadata */ + --spacing-icon-sm: 0.875rem; /* 14px — badge / chip / metadata icons */ + --spacing-icon-md: 1rem; /* 16px — default: buttons, nav, fields */ + --spacing-icon-lg: 1.25rem; /* 20px — header / primary controls */ + --spacing-icon-xl: 1.5rem; /* 24px — hero / composer emphasis */ + /* Type scale — sub-`xs` micro steps plus the 13–22px "between" steps the dense clinical UI reaches for. These are intentionally SIZE-ONLY: unlike Tailwind's default text-* tokens they carry no `--line-height` / diff --git a/src/app/icon.svg b/src/app/icon.svg index f1c0fb8e0..591d749c4 100644 --- a/src/app/icon.svg +++ b/src/app/icon.svg @@ -2,22 +2,12 @@ - + - - + diff --git a/src/app/icons/[variant]/route.tsx b/src/app/icons/[variant]/route.tsx new file mode 100644 index 000000000..c9ccab94f --- /dev/null +++ b/src/app/icons/[variant]/route.tsx @@ -0,0 +1,29 @@ +import { ImageResponse } from "next/og"; + +import { BRAND_ICON_FIELD, BrandIconImage } from "@/lib/brand-image"; + +// PWA icon set referenced by app/manifest.ts. "any" icons are transparent so the +// mark's own rounded tile shows; "maskable" icons are full-bleed teal with the +// mark inside the ~72% safe zone so platform circular/rounded masks don't crop it. +const VARIANTS = { + "icon-192": { size: 192, background: "transparent", inset: 1 }, + "icon-512": { size: 512, background: "transparent", inset: 1 }, + "maskable-192": { size: 192, background: BRAND_ICON_FIELD, inset: 0.72 }, + "maskable-512": { size: 512, background: BRAND_ICON_FIELD, inset: 0.72 }, +} as const; + +export function generateStaticParams() { + return Object.keys(VARIANTS).map((variant) => ({ variant })); +} + +export const dynamicParams = false; + +export async function GET(_request: Request, { params }: { params: Promise<{ variant: string }> }) { + const { variant } = await params; + const conf = VARIANTS[variant as keyof typeof VARIANTS]; + if (!conf) return new Response("Not found", { status: 404 }); + return new ImageResponse(, { + width: conf.size, + height: conf.size, + }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a90eeef86..19152338e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -16,6 +16,10 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { + // Absolute base for OG/twitter image URLs (app/opengraph-image). Set + // NEXT_PUBLIC_SITE_URL in production; the localhost fallback only affects dev, + // where social unfurls aren't consumed. + metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"), applicationName: "Clinical KB", title: "Clinical KB", description: "Private medical guideline RAG knowledge base", diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 000000000..077555040 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,24 @@ +import type { MetadataRoute } from "next"; + +// PWA manifest — makes the app installable with a proper icon. Icons derive from +// the single brand-mark source: the SVG for modern browsers, plus generated PNG +// "any" and "maskable" sets from app/icons/[variant]. Colours match the light +// default of viewport.themeColor in app/layout.tsx. +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Clinical KB", + short_name: "Clinical KB", + description: "Private medical guideline RAG knowledge base", + start_url: "/", + display: "standalone", + background_color: "#ffffff", + theme_color: "#ffffff", + icons: [ + { src: "/icon.svg", type: "image/svg+xml", sizes: "any" }, + { src: "/icons/icon-192", type: "image/png", sizes: "192x192", purpose: "any" }, + { src: "/icons/icon-512", type: "image/png", sizes: "512x512", purpose: "any" }, + { src: "/icons/maskable-192", type: "image/png", sizes: "192x192", purpose: "maskable" }, + { src: "/icons/maskable-512", type: "image/png", sizes: "512x512", purpose: "maskable" }, + ], + }; +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 06b889292..4c4fe3b23 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -7,7 +7,7 @@ export default function NotFound() {
- +

Page not found

@@ -21,7 +21,7 @@ export default function NotFound() { href="/" className={cn(primaryControl, "flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium")} > - +
diff --git a/src/components/DocumentManagementActions.tsx b/src/components/DocumentManagementActions.tsx index 4b9a8ad51..6c936d704 100644 --- a/src/components/DocumentManagementActions.tsx +++ b/src/components/DocumentManagementActions.tsx @@ -1,6 +1,6 @@ "use client"; -import { AlertTriangle, Check, Loader2, Pencil, Trash2 } from "lucide-react"; +import { TriangleAlert, Check, Loader2, Pencil, Trash2 } from "lucide-react"; import { FormEvent, useRef, useState } from "react"; import { cn, @@ -138,7 +138,7 @@ export function DocumentManagementActions({ title="Rename document" aria-label={`Rename ${document.title}`} > - +
@@ -174,7 +174,7 @@ export function DocumentManagementActions({
- +
@@ -200,7 +200,11 @@ export function DocumentManagementActions({ className={cn(primaryControl, "bg-[color:var(--danger)] hover:bg-[color:var(--danger)]")} disabled={pending || deleteConfirmation !== document.title} > - {pending ? : } + {pending ? ( +