commit 1c570278883167aec74b5f201f369fe235800d29 Author: jules Date: Wed Jul 8 07:20:58 2026 +1000 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b20266 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# lib-theme-beacon + +**Beacon — dark-native AAA.** The access-first theme for photophobic and +low-vision users who need dark high contrast. Near-black neutral field (not +pure `#000`, to avoid halation) carrying ~16:1 light ink, cards separated by +strong light-alpha borders, and one bright accessible yellow-gold action — +the lighthouse beam — with pure-black ink at ~14:1. Links underlined at +rest; 3px focus outline in the beam yellow; warning pushed to orange so it +never reads as the primary. Light mode is a competent high-contrast light +variant (deep amber-brown actions, ~17:1 ink). + +Access-family DNA: type ramp starts at 1.125rem body / nothing below 14px, +generous line-heights, 44px controls for everyone (not just coarse +pointers), springs aliased to standard eases, `prefers-reduced-motion` +zeroes transitions. + +Part of the access-first candidate set (lucent / lantern / beacon), built +2026-07 against the Crema v1 token contract; passes +`create-crema-app/scripts/validate-theme.mjs`. + +## Fonts + +Consuming apps load **Public Sans** (USWDS accessibility heritage) and +**IBM Plex Mono** at the top of their entry CSS: + +```css +@import url("https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"); +``` + +Body and headings are both Public Sans (headings at weight 600); code is +IBM Plex Mono. diff --git a/theme.css b/theme.css new file mode 100644 index 0000000..6a5b11f --- /dev/null +++ b/theme.css @@ -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 55–60) 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 80–95 (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; + } +}