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
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")}
>
-
+
Back to search
diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx
new file mode 100644
index 000000000..5bc48bd26
--- /dev/null
+++ b/src/app/opengraph-image.tsx
@@ -0,0 +1,32 @@
+import { ImageResponse } from "next/og";
+
+import { brandMarkDataUri } from "@/lib/brand-image";
+
+export const alt = "Clinical KB — private medical guideline knowledge base";
+export const size = { width: 1200, height: 630 };
+export const contentType = "image/png";
+
+export default function OpengraphImage() {
+ return new ImageResponse(
+