Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
71 changes: 71 additions & 0 deletions eslint-rules/require-lucide-icon-aria.mjs
Original file line number Diff line number Diff line change
@@ -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 <svg> 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;
27 changes: 27 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 <img> 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:
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
79 changes: 79 additions & 0 deletions scripts/check-icon-scale.mjs
Original file line number Diff line number Diff line change
@@ -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);
40 changes: 40 additions & 0 deletions scripts/generate-brand-assets.ts
Original file line number Diff line number Diff line change
@@ -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.");
15 changes: 15 additions & 0 deletions src/app/apple-icon.tsx
Original file line number Diff line number Diff line change
@@ -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(<BrandIconImage size={size.width} background={BRAND_ICON_FIELD} inset={0.82} />, {
...size,
});
}
13 changes: 13 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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` /
Expand Down
14 changes: 2 additions & 12 deletions src/app/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions src/app/icons/[variant]/route.tsx
Original file line number Diff line number Diff line change
@@ -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(<BrandIconImage size={conf.size} background={conf.background} inset={conf.inset} />, {
width: conf.size,
height: conf.size,
});
}
Loading
Loading