lib-theme-beacon: initial import (access-first set, 2026-07)

One of three candidates for the access-first / maximum-legibility theme
family (WCAG AAA as identity): 7:1+ contrast on every pair (passes
validate-theme.mjs --aaa), 1.125rem body floor with nothing under 14px,
44px controls for everyone, 3px focus rings, links underlined at rest,
reduced-motion built in. Built for IGH/NDIS-facing surfaces first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jules
2026-07-08 07:20:58 +10:00
commit 1c57027888
2 changed files with 488 additions and 0 deletions

457
theme.css Normal file
View File

@@ -0,0 +1,457 @@
/*
* Beacon — dark-native AAA. The lighthouse theme.
*
* Access-first / maximum-legibility family, candidate 1 of 3. WCAG AAA is the
* identity, not the compliance checkbox: built for NDIS participants, their
* families, and older support coordinators — including photophobic and
* low-vision users who need DARK high contrast. The discipline IS the
* personality.
*
* The dark mode is the theme. A near-black cool-neutral field (L 0.13 — dark
* but never pure #000, which causes halation for astigmatic users) carries
* very light ink at ~18:1. Cards step clearly LIGHTER (L 0.19) so surface
* boundaries are legible without squinting, and borders are strong light-alpha
* strokes (0.3) — on near-black, borders do all the separation; elevation
* stays low. One accent: a bright accessible yellow-gold (hue 95) with
* near-black ink on top — the classic high-contrast-dark action idiom, a
* lighthouse beam. Links are the yellow, underlined by default. Focus is a
* 3px yellow outline — unmistakable.
*
* Light mode is the competent daylight variant: white field, near-black ink
* ~19:1, primary flips to a deep amber-brown with a white label, and the
* yellow survives as the selection highlight.
*
* Status hues are tuned so every label pair computes ≥7:1 (AAA at body size),
* and warning sits at orange (hue 5560) well away from the yellow primary
* (hue 95) so the two never read as the same swatch.
*
* Type: Public Sans (USWDS accessibility heritage) for body AND headings
* (weight 600); IBM Plex Mono for code. The ramp starts larger than product
* default — body 1.125rem/1.65, nothing below 14px — with generous
* line-heights throughout. Radius modest (0.5rem). Motion minimal and quick;
* all springs alias to standard; reduced-motion zeroes transitions.
*
* Contrast ratios in comments are computed with the validator's own OKLCH →
* sRGB → WCAG-luminance math (quick check: Y ≈ L³; ratio = (Y1+.05)/(Y2+.05)).
*
* Token contract: Crema v1 (matches lib-theme-harbor/theme.css structure).
*/
/*
* Fonts: consuming apps load Public Sans + IBM Plex Mono at the top of their
* entry CSS via the Google Fonts URL documented in this theme's README.
* Embedding the @import here breaks PostCSS ordering, so it is not done.
*/
:root {
--font-sans: "Public Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-heading: "Public Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
/* Tailwind utility families — mirror --font-heading/--font-sans. */
--heading-family: "Public Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--body-family: "Public Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
/* ── Colors (light) ── white field, near-black ink, deep amber-brown
* action. foreground/background 18.58:1; foreground/card 19.12:1. */
--background: oklch(0.99 0.002 250);
--foreground: oklch(0.17 0.004 250);
--card: oklch(1 0 0);
--card-foreground: oklch(0.17 0.004 250);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.17 0.004 250);
/* Primary — deep amber-brown (the beam by day), white label 8.30:1 (AAA).
* The bright yellow returns as the selection highlight (--accent). */
--primary: oklch(0.42 0.11 80);
--primary-foreground: oklch(0.99 0.005 80);
--secondary: oklch(0.94 0.004 250);
--secondary-foreground: oklch(0.25 0.005 250); /* 13.42:1 */
--muted: oklch(0.94 0.004 250);
/* muted-foreground: 8.22:1 on background, 8.46:1 on card, 7.09:1 on muted
* — AAA everywhere it can land. */
--muted-foreground: oklch(0.42 0.006 250);
/* Accent — the yellow kept for selection/hover wash, deep amber ink
* 10.22:1. */
--accent: oklch(0.9 0.1 95);
--accent-foreground: oklch(0.3 0.06 80);
/* ── Status ── deep fills, white labels, every pair ≥7:1 (AAA).
* Warning at hue 55 (orange), far from primary's 8095 (yellow). */
--destructive: oklch(0.45 0.18 27);
--destructive-foreground: oklch(0.99 0.005 27); /* 7.93:1 */
--success: oklch(0.42 0.12 150);
--success-foreground: oklch(0.99 0.005 150); /* 7.76:1 */
--warning: oklch(0.45 0.13 55);
--warning-foreground: oklch(0.99 0.005 55); /* 7.54:1 */
--info: oklch(0.44 0.11 245);
--info-foreground: oklch(0.99 0.005 245); /* 7.50:1 */
/* Strong visible boundaries — 0.25 alpha ink, not a whisper. */
--border: oklch(0.17 0.004 250 / 0.25);
--input: oklch(0.17 0.004 250 / 0.3);
--ring: oklch(0.42 0.11 80);
/* Charts — amber lead, hues ≥60° apart (20/85/160/245/305), adjacent
* lightness varied, all dark enough to read on white. */
--chart-1: oklch(0.55 0.12 85);
--chart-2: oklch(0.42 0.13 245);
--chart-3: oklch(0.52 0.12 160);
--chart-4: oklch(0.6 0.16 20);
--chart-5: oklch(0.45 0.15 305);
--sidebar: oklch(0.96 0.003 250);
--sidebar-foreground: oklch(0.17 0.004 250); /* 17.02:1 */
--sidebar-primary: oklch(0.42 0.11 80);
--sidebar-primary-foreground: oklch(0.99 0.005 80);
--sidebar-accent: oklch(0.9 0.1 95);
--sidebar-accent-foreground: oklch(0.3 0.06 80);
--sidebar-border: oklch(0.17 0.004 250 / 0.2);
--sidebar-ring: oklch(0.42 0.11 80);
/* Syntax — deep, high-contrast hues on white; comment stays ≥7:1. */
--syntax-keyword: oklch(0.45 0.15 305);
--syntax-string: oklch(0.42 0.11 150);
--syntax-number: oklch(0.45 0.12 55);
--syntax-comment: oklch(0.45 0.005 250);
--syntax-function: oklch(0.44 0.12 245);
--syntax-type: oklch(0.42 0.08 200);
/* ── Chat surfaces ── user bubble is a deep charcoal panel with light
* text (15.10:1); assistant is a white card with a strong border. */
--chat-user-bg: oklch(0.25 0.005 250);
--chat-user-fg: oklch(0.98 0.002 250);
--chat-assistant-bg: oklch(1 0 0);
--chat-assistant-fg: oklch(0.17 0.004 250);
--chat-assistant-border: oklch(0.17 0.004 250 / 0.25);
/* Modest radius — calm, legible geometry. */
--radius: 0.5rem;
/* ── Motion ── minimal and quick; springs alias to standard — the
* personality is steadiness, never bounce. */
--duration-fast: 120ms;
--duration-base: 200ms;
--duration-slow: 300ms;
--duration-slower: 400ms;
--duration-spring: 200ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-decelerate: cubic-bezier(0, 0, 0, 1);
--ease-accelerate: cubic-bezier(0.3, 0, 1, 1);
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1);
--ease-spring-gentle: var(--ease-standard);
--ease-spring-snappy: var(--ease-standard);
--ease-spring-bouncy: var(--ease-standard);
/* ── Typography ── access ramp: starts larger, generous line-heights,
* caption never below 14px. */
--text-hero-size: 3rem;
--text-hero-lh: 1.1;
--text-display-size: 2.5rem;
--text-display-lh: 1.15;
--text-headline-size: 1.875rem;
--text-headline-lh: 1.25;
--text-title-size: 1.5rem;
--text-title-lh: 1.35;
--text-subtitle-size: 1.25rem;
--text-subtitle-lh: 1.45;
--text-body-size: 1.125rem;
--text-body-lh: 1.65;
--text-label-size: 1rem;
--text-label-lh: 1.5;
--text-caption-size: 0.875rem; /* 14px — the floor, never below */
--text-caption-lh: 1.4;
/* ── Elevation ── low; borders carry separation. */
--elevation-0: none;
--elevation-1: 0 1px 2px oklch(0.17 0.004 250 / 0.06);
--elevation-2: 0 1px 3px oklch(0.17 0.004 250 / 0.08), 0 1px 2px oklch(0.17 0.004 250 / 0.05);
--elevation-3: 0 4px 8px -2px oklch(0.17 0.004 250 / 0.08), 0 2px 4px -2px oklch(0.17 0.004 250 / 0.05);
--elevation-4: 0 12px 24px -6px oklch(0.17 0.004 250 / 0.1), 0 4px 8px -4px oklch(0.17 0.004 250 / 0.05);
--elevation-5: 0 20px 40px -8px oklch(0.17 0.004 250 / 0.14);
--spacing: 0.25rem;
--border-width-thin: 1px;
--border-width-base: 1px;
--border-width-thick: 2px;
--border-width-heavy: 4px;
/* No glass — inert values so consuming apps referencing these get no-ops. */
--glass-blur: 0px;
--glass-saturate: 100%;
--glass-brightness: 1;
--glass-contrast: 1;
}
/*
* Dark mode — the identity. Near-black cool-neutral field (not #000 — avoids
* halation), very light ink, cards stepping clearly lighter, strong
* light-alpha borders, and the yellow-gold beam with near-black ink on top.
* Popover sits above card, below muted (0.19 < 0.21 < 0.24).
*/
.dark {
/* foreground/background 18.44:1. */
--background: oklch(0.13 0.004 250);
--foreground: oklch(0.97 0.002 250);
/* Cards step clearly LIGHTER — visible surface, not a guess. 16.93:1. */
--card: oklch(0.19 0.005 250);
--card-foreground: oklch(0.97 0.002 250);
--popover: oklch(0.21 0.005 250);
--popover-foreground: oklch(0.97 0.002 250); /* 16.24:1 */
/* Primary — the beam. Bright yellow-gold (hue 95, clear of warning's 60)
* with near-black ink: 12.96:1. Beam vs field itself: 12.75:1. */
--primary: oklch(0.85 0.16 95);
--primary-foreground: oklch(0.11 0.01 95);
--secondary: oklch(0.24 0.006 250);
--secondary-foreground: oklch(0.93 0.003 250); /* 13.38:1 */
--muted: oklch(0.24 0.006 250);
/* muted-foreground: 10.05:1 on background, 9.23:1 on card, 8.22:1 on
* muted — AAA on every field it can sit on. */
--muted-foreground: oklch(0.78 0.005 250);
/* Accent — dim gold wash with bright gold ink, 10.14:1. */
--accent: oklch(0.3 0.05 95);
--accent-foreground: oklch(0.9 0.12 95);
/* ── Status ── bright fills, near-black ink (the beacon idiom), every
* pair ≥7:1. Warning at orange 60 — primary holds yellow 95. */
--destructive: oklch(0.7 0.19 30);
--destructive-foreground: oklch(0.11 0.02 30); /* 7.06:1 */
--success: oklch(0.8 0.19 145);
--success-foreground: oklch(0.12 0.03 145); /* 11.57:1 */
--warning: oklch(0.78 0.16 60);
--warning-foreground: oklch(0.12 0.03 60); /* 9.72:1 */
--info: oklch(0.78 0.1 230);
--info-foreground: oklch(0.11 0.02 230); /* 10.43:1 */
/* STRONG light-alpha borders — on near-black these do all separation. */
--border: oklch(0.97 0.002 250 / 0.3);
--input: oklch(0.97 0.002 250 / 0.35);
--ring: oklch(0.85 0.16 95);
/* Charts — gold lead, hues 25/95/160/240/305 (≥65° apart), adjacent
* lightness varied, all bright enough to read on near-black. */
--chart-1: oklch(0.85 0.16 95);
--chart-2: oklch(0.68 0.13 240);
--chart-3: oklch(0.78 0.14 160);
--chart-4: oklch(0.7 0.17 25);
--chart-5: oklch(0.6 0.15 305);
--sidebar: oklch(0.16 0.004 250);
--sidebar-foreground: oklch(0.97 0.002 250); /* 17.80:1 */
--sidebar-primary: oklch(0.85 0.16 95);
--sidebar-primary-foreground: oklch(0.11 0.01 95);
--sidebar-accent: oklch(0.3 0.05 95);
--sidebar-accent-foreground: oklch(0.9 0.12 95);
--sidebar-border: oklch(0.97 0.002 250 / 0.25);
--sidebar-ring: oklch(0.85 0.16 95);
/* Syntax — bright, well-spaced hues on near-black. */
--syntax-keyword: oklch(0.8 0.1 305);
--syntax-string: oklch(0.82 0.12 150);
--syntax-number: oklch(0.84 0.12 60);
--syntax-comment: oklch(0.72 0.005 250);
--syntax-function: oklch(0.8 0.09 230);
--syntax-type: oklch(0.82 0.08 190);
/* ── Chat surfaces (dark) ── user panel a step above muted; assistant
* on the card surface with the strong border. User pair 14.24:1. */
--chat-user-bg: oklch(0.26 0.008 250);
--chat-user-fg: oklch(0.97 0.002 250);
--chat-assistant-bg: oklch(0.19 0.005 250);
--chat-assistant-fg: oklch(0.97 0.002 250);
--chat-assistant-border: oklch(0.97 0.002 250 / 0.3);
/* Elevation near-zero — borders are the separation system on near-black. */
--elevation-1: 0 1px 2px oklch(0 0 0 / 0.4);
--elevation-2: 0 1px 3px oklch(0 0 0 / 0.45), 0 1px 2px oklch(0 0 0 / 0.3);
--elevation-3: 0 4px 8px -2px oklch(0 0 0 / 0.45), 0 2px 4px -2px oklch(0 0 0 / 0.3);
--elevation-4: 0 12px 24px -6px oklch(0 0 0 / 0.5), 0 4px 8px -4px oklch(0 0 0 / 0.3);
--elevation-5: 0 20px 40px -8px oklch(0 0 0 / 0.6);
}
/*
* @layer base — Beacon's legibility-first philosophy.
*
* Larger type, generous leading, underlined links, strong boundaries, one
* unmistakable accent. Nothing decorative; everything readable.
*/
@layer base {
body {
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: var(--font-sans);
font-size: var(--text-body-size);
line-height: var(--text-body-lh);
}
/* Headings — Public Sans 600. `color: inherit` (NOT var(--foreground))
* so headings recolor correctly inside tinted surfaces (alerts, chips,
* status banners). */
h1, h2, h3, h4,
[data-slot="card-title"],
[data-slot="dialog-title"],
[data-slot="sheet-title"],
[data-slot="alert-dialog-title"] {
font-family: var(--font-heading);
font-weight: 600;
letter-spacing: -0.01em;
color: inherit;
}
code, kbd, samp, pre,
[data-slot="code"],
[data-slot="code-block"] {
font-family: var(--font-mono);
}
/* Inline code — foreground-tint chip with foreground ink; never a
* low-contrast pastel. */
:not(pre) > code {
padding: 0.1em 0.35em;
border-radius: calc(var(--radius) * 0.5);
background: color-mix(in oklch, var(--foreground) 8%, transparent);
color: var(--foreground);
font-size: 0.925em;
}
/* Selection — the gold wash by day. */
::selection {
background: var(--accent);
color: var(--accent-foreground);
}
/* Selection in the dark — the beam itself. */
.dark ::selection {
background: var(--primary);
color: var(--primary-foreground);
}
/* Links — always underlined (never color-only affordance); underline
* thickens on hover. Amber-brown by day, the yellow beam at night. */
a {
color: var(--primary);
text-decoration-line: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
transition: text-decoration-thickness var(--duration-fast) var(--ease-standard);
}
a:hover {
text-decoration-thickness: 2px;
}
[data-slot="sidebar"] {
background: var(--sidebar);
border-right: var(--border-width-base) solid var(--sidebar-border);
}
}
/*
* Focus-visible — Beacon's signature: a 3px solid ring, offset 2px, in the
* theme's accent (deep amber by day, the yellow beam in the dark). Placed
* outside @layer base so it beats Tailwind's default ring utilities.
* Always visible; never outline: none without replacement.
*/
[data-slot="button"]:focus-visible,
[data-slot="select-trigger"]:focus-visible {
outline: 3px solid var(--ring);
outline-offset: 2px;
box-shadow: none;
}
/* Inputs focus — border-color change + 2px ring (flush, no offset gap that
* could read as a broken border). */
[data-slot="input"]:focus-visible,
[data-slot="textarea"]:focus-visible,
[data-slot="input"]:focus,
[data-slot="textarea"]:focus,
[data-slot="select-trigger"]:focus {
outline: none;
border-color: var(--ring);
box-shadow: 0 0 0 2px var(--ring);
}
/* Buttons — steady weight, no transform theatrics; state reads through
* opacity only, product-fast. */
[data-slot="button"] {
font-weight: 600;
min-height: 2.75rem;
}
[data-slot="button"]:hover:not(:disabled) {
opacity: 0.92;
}
[data-slot="button"]:active:not(:disabled) {
opacity: 0.84;
}
/* Card titles — pinned to the subtitle role so the scale is the theme's
* decision, not the card primitive's default. */
[data-slot="card-title"] {
font-size: var(--text-subtitle-size);
line-height: var(--text-subtitle-lh);
}
/* Touch targets — the 44px floor is GLOBAL, not behind a media query.
* Motor-impairment doesn't correlate with pointer type. */
[data-slot="input"],
[data-slot="select-trigger"] {
min-height: 2.75rem;
}
@media (pointer: coarse) {
[data-slot="button"] {
min-height: 2.75rem;
min-width: 2.75rem;
}
[data-slot="input"],
[data-slot="select-trigger"] {
min-height: 2.75rem;
}
}
/* Inputs — card surface, strong border, generous hit area. */
[data-slot="input"],
[data-slot="textarea"],
[data-slot="select-trigger"] {
background: var(--card);
border: var(--border-width-base) solid var(--input);
border-radius: var(--radius);
color: var(--foreground);
transition:
border-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
}
[data-slot="input"]::placeholder,
[data-slot="textarea"]::placeholder {
color: var(--muted-foreground);
}
[data-slot="input"]:hover:not(:disabled),
[data-slot="textarea"]:hover:not(:disabled),
[data-slot="select-trigger"]:hover:not(:disabled) {
border-color: color-mix(in oklch, var(--foreground) 45%, transparent);
}
/* Reduced motion — zero transitions on every interactive slot; nothing
* essential is motion-only. */
@media (prefers-reduced-motion: reduce) {
a,
[data-slot="button"],
[data-slot="input"],
[data-slot="textarea"],
[data-slot="select-trigger"] {
transition: none;
}
}