From 4c761b91a4a2d68965d07f4f1ab80f545dc7edb2 Mon Sep 17 00:00:00 2001 From: jules Date: Wed, 8 Jul 2026 07:20:58 +1000 Subject: [PATCH] lib-theme-lucent: 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 --- README.md | 57 +++++++ theme.css | 473 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 530 insertions(+) create mode 100644 README.md create mode 100644 theme.css diff --git a/README.md b/README.md new file mode 100644 index 0000000..6656fe2 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# lib-theme-lucent + +**Lucent — cool daylight clarity.** An access-first Crema theme where WCAG AAA is the +identity, not the compliance line. One of three candidates in the access family, built +for NDIS participants, their families, and older support coordinators (IGH / IveGotHome +portals): dignity, clarity, zero squinting. The discipline IS the personality. + +## Thesis + +GOV.UK / NHS design heritage, modernized. Light mode is a near-white cool field with +pure white cards and TRUE near-black ink — body text lands at ~19:1, and even +`muted-foreground` clears 7:1 on white, because in an access theme muted text is still +reading text. One deep accessible blue does all the pointing (white label ≈ 8.5:1), and +links are that same blue, **underlined at rest** — the underline thickens on hover. +Dark mode is a very dark cool charcoal with very light ink (~17:1); cards step clearly +lighter, and primary flips to a lighter blue carrying near-black ink (≈ 8.5:1). + +The access-family DNA: + +- **Type ramp starts larger** — body 1.125rem/1.65, label 1rem/1.5, caption 0.875rem + (never below 14px). Generous line-heights throughout. +- **Contrast verified, not hoped for** — body ink and muted-foreground ≥7:1 on + background AND card in both modes; primary label ≥7:1; status pairs target ≥7:1 and + never dip below 4.5:1. Computed ratios are stated in `theme.css` comments. +- **Focus is loud** — 3px solid ring + 2px offset on buttons/controls; inputs get a + border-color change plus a 2px ring. Always visible. +- **Touch targets 44px for everyone** — not gated behind a coarse-pointer query. +- **Borders are strong** (ink at 0.25–0.32 alpha) — visible boundaries for low vision; + elevation stays low; separation comes from borders, not shadows. +- **Motion minimal and quick** (120/200/300/400ms), all springs aliased to standard + eases; a `prefers-reduced-motion` block zeroes transitions on interactive slots. +- Radius a modest 0.5rem. No glass (inert stubs). + +## Fonts + +**Atkinson Hyperlegible** (Braille Institute; drawn for character disambiguation at low +vision) for both body and headings — hierarchy comes from weight 700 and the ramp, not a +second family. **JetBrains Mono** for code. No negative letter-spacing anywhere: the +face was designed for legibility; don't squeeze it. + +Consuming apps load fonts at the very top of their entry CSS (never inside `theme.css`): + +```css +@import url("https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&family=JetBrains+Mono:wght@400;500;600&display=swap"); +``` + +## Notes + +- Atkinson Hyperlegible ships 400/700 only — buttons and headings use 700 (the GOV.UK + button voice); never request intermediate weights or they'll synthesize. +- Dark surfaces: background 0.16 → card 0.22 → popover 0.24 → muted 0.26 (popover above + card, below muted). +- Status pattern: light mode uses deep solid fills with white labels (dark ink on amber + for warning); dark mode flips to bright swatches with near-black ink. +- Headings use `color: inherit` so titles on colored slabs keep the slab's pairing; + `[data-slot="card-title"]` is pinned to the subtitle role. +- Validate: `node create-crema-app/scripts/validate-theme.mjs lib-theme-lucent/theme.css` diff --git a/theme.css b/theme.css new file mode 100644 index 0000000..f474b8c --- /dev/null +++ b/theme.css @@ -0,0 +1,473 @@ +/* + * Lucent — cool daylight clarity. Access-first: WCAG AAA as identity. + * + * One of three candidates in the access-first family (NDIS participants, + * their families, older support coordinators — IGH portals). The thesis: + * maximum legibility is not compliance overhead, it IS the personality. + * Dignity, clarity, zero squinting. GOV.UK / NHS design heritage, + * modernized: a near-white cool field, pure white cards, TRUE near-black + * ink, and one deep accessible blue doing all the pointing. Links are the + * same blue and underlined BY DEFAULT — an affordance, not a decoration. + * Boundaries are drawn with strong ink-alpha borders (0.25–0.3), not + * shadows: low-vision users need edges, not atmosphere. Radius is a modest + * 0.5rem; elevation is low; motion is minimal and quick with all springs + * aliased to standard eases. + * + * Contrast is verified, not hoped for (Y ≈ L³; C = (Y1+.05)/(Y2+.05)): + * LIGHT body ink 0.15 on bg 0.99 → ~19.1:1 (on white card ~19.7:1) + * muted-fg 0.35 on white → ~11.3:1 (muted is still reading text) + * white label on primary 0.42 → ~8.5:1 (blue's real WCAG Y sits + * below L³, so the true ratio is higher still) + * DARK body ink 0.96 on bg 0.16 → ~17.3:1 (on card 0.22 ~15.4:1) + * muted-fg 0.78 on card 0.22 → ~8.7:1 (on bg ~9.7:1) + * ink 0.15 on primary 0.74 → ~8.5:1 + * Every status pair targets ≥7:1 and never dips below 4.5:1. + * + * Type is Atkinson Hyperlegible (designed by the Braille Institute for + * low-vision readers) for BOTH body and headings — hierarchy comes from + * weight 700 and the ramp, not a second family. JetBrains Mono for code. + * The ramp starts larger than the house norm (body 1.125rem/1.65) and the + * caption never drops below 14px. Touch targets are 44px for EVERYONE, + * not just coarse pointers. + * + * Token contract matches lib-theme-caffe-florian/theme.css (Crema v1). + */ + +/* + * Fonts: consuming apps load Atkinson Hyperlegible + JetBrains Mono at the + * top of their entry CSS (where @import statements are allowed), via the + * Google Fonts URL documented in this theme's README. Embedding the + * @import here — or smuggling the URL in a comment for a build script to + * lift — breaks PostCSS's ordering rules, so neither is done in this file. + */ + +:root { + --font-sans: "Atkinson Hyperlegible", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-heading: "Atkinson Hyperlegible", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + + /* Tailwind utility families. Distinct names so a consuming app's + * `@theme inline` can indirect to these (--font-heading/--font-sans + * collide with Tailwind's own font-* namespace). */ + --heading-family: "Atkinson Hyperlegible", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --body-family: "Atkinson Hyperlegible", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + + /* ── Colors ── near-white cool daylight field, pure white cards, true + * near-black cool ink. Body ink on bg ≈ 19.1:1; on card ≈ 19.7:1. */ + --background: oklch(0.99 0.002 240); + --foreground: oklch(0.15 0.005 250); + + --card: oklch(1 0 0); + --card-foreground: oklch(0.15 0.005 250); + + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.15 0.005 250); + + /* Primary — one deep accessible blue (GOV.UK lineage) with a pure + * white label. Approx (1+.05)/(0.42³+.05) ≈ 8.5:1; saturated blue's + * true WCAG luminance is lower than L³, so the real ratio is higher. */ + --primary: oklch(0.42 0.15 255); + --primary-foreground: oklch(1 0 0); + + --secondary: oklch(0.93 0.008 245); + --secondary-foreground: oklch(0.22 0.01 250); + + /* muted-foreground is still reading text in an access theme — it must + * clear 7:1 on white too. 0.35³=0.0429 → 1.05/0.0929 ≈ 11.3:1. */ + --muted: oklch(0.945 0.004 245); + --muted-foreground: oklch(0.35 0.01 250); + + /* Accent — a pale blue wash for hover/selection, deep blue ink on top + * (0.854/0.083 ≈ 10.3:1). Never a second competing fill. */ + --accent: oklch(0.93 0.03 250); + --accent-foreground: oklch(0.32 0.11 255); + + /* ── Status ── deep solid fills with white labels (destructive / + * success / info), dark ink on amber for warning — every pair targets + * ≥7:1. destructive 1.05/(0.45³+.05) ≈ 7.4:1 (red's true Y is lower → + * real ratio higher); success deepened to 0.40 because green's true Y + * runs ABOVE L³ (1.05/0.114 ≈ 9.2:1 approx, ≈7.5:1 real); info + * 1.05/0.124 ≈ 8.5:1; warning ink-on-amber 0.562/0.058 ≈ 9.7:1. */ + --destructive: oklch(0.45 0.17 27); + --destructive-foreground: oklch(1 0 0); + --success: oklch(0.40 0.11 155); + --success-foreground: oklch(1 0 0); + --warning: oklch(0.80 0.14 80); + --warning-foreground: oklch(0.20 0.03 80); + --info: oklch(0.42 0.11 230); + --info-foreground: oklch(1 0 0); + + /* Borders STRONG — ink at 0.25–0.3 alpha. Visible boundaries for low + * vision, not decorative hairlines. */ + --border: oklch(0.15 0.005 250 / 0.28); + --input: oklch(0.15 0.005 250 / 0.32); + --ring: oklch(0.42 0.15 255); + + /* Chart palette — blue lead, adjacent hues ≥60° apart, adjacent-index + * lightness varied so series separate for color-blind readers too. */ + --chart-1: oklch(0.45 0.14 255); + --chart-2: oklch(0.58 0.12 150); + --chart-3: oklch(0.70 0.13 80); + --chart-4: oklch(0.55 0.16 20); + --chart-5: oklch(0.48 0.14 310); + + --sidebar: oklch(0.965 0.004 245); + --sidebar-foreground: oklch(0.15 0.005 250); + --sidebar-primary: oklch(0.42 0.15 255); + --sidebar-primary-foreground: oklch(1 0 0); + --sidebar-accent: oklch(0.93 0.03 250); + --sidebar-accent-foreground: oklch(0.32 0.11 255); + --sidebar-border: oklch(0.15 0.005 250 / 0.25); + --sidebar-ring: oklch(0.42 0.15 255); + + /* Syntax — every token deep enough to read as body text on white; + * comments are NOT faded into illegibility (0.40 ≈ 9:1). */ + --syntax-keyword: oklch(0.42 0.14 310); + --syntax-string: oklch(0.42 0.10 155); + --syntax-number: oklch(0.45 0.12 55); + --syntax-comment: oklch(0.40 0.01 250); + --syntax-function: oklch(0.42 0.13 255); + --syntax-type: oklch(0.42 0.09 210); + + /* ── Chat surfaces ── + * User bubble is the deep blue with white text (≈8.5:1); assistant is + * a white card with a strong visible border. */ + --chat-user-bg: oklch(0.42 0.15 255); + --chat-user-fg: oklch(1 0 0); + --chat-assistant-bg: oklch(1 0 0); + --chat-assistant-fg: oklch(0.15 0.005 250); + --chat-assistant-border: oklch(0.15 0.005 250 / 0.28); + + /* Modest radius — calm, institutional, never pill-happy. */ + --radius: 0.5rem; + + /* ── Motion ── minimal and quick; nothing decorative. All springs + * alias to the standard eases — personality lives in restraint. */ + --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-decelerate); + --ease-spring-bouncy: var(--ease-standard); + + /* ── Typography ── access ramp: starts larger, generous line-heights, + * caption never below 14px. Fixed rem, product register. */ + --text-hero-size: 3rem; + --text-hero-lh: 1.15; + --text-display-size: 2.5rem; + --text-display-lh: 1.2; + --text-headline-size: 1.875rem; + --text-headline-lh: 1.25; + --text-title-size: 1.5rem; + --text-title-lh: 1.3; + --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; + --text-caption-lh: 1.4; + + /* ── Elevation ── low. Separation is the job of borders here; shadows + * only whisper altitude for popovers and dialogs. */ + --elevation-0: none; + --elevation-1: 0 1px 2px oklch(0.15 0.005 250 / 0.05); + --elevation-2: 0 1px 3px oklch(0.15 0.005 250 / 0.07), 0 1px 2px oklch(0.15 0.005 250 / 0.04); + --elevation-3: 0 4px 8px -2px oklch(0.15 0.005 250 / 0.08), 0 2px 4px -2px oklch(0.15 0.005 250 / 0.04); + --elevation-4: 0 12px 24px -6px oklch(0.15 0.005 250 / 0.1), 0 4px 8px -4px oklch(0.15 0.005 250 / 0.05); + --elevation-5: 0 20px 40px -8px oklch(0.15 0.005 250 / 0.14); + + --spacing: 0.25rem; + + /* No sub-pixel hairlines in an access theme — thin is a real 1px. */ + --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 — very dark cool charcoal with very light ink (≈17.3:1), cards + * stepping clearly lighter (bg 0.16 → card 0.22 → popover 0.24 → muted + * 0.26: popover above card, below muted). Primary flips to a lighter blue + * carrying near-black ink (≈8.5:1) — dark-ink-on-bright beats + * white-on-mid-tone. + */ +.dark { + --background: oklch(0.16 0.005 250); + --foreground: oklch(0.96 0.005 250); + + --card: oklch(0.22 0.005 250); + --card-foreground: oklch(0.96 0.005 250); + + --popover: oklch(0.24 0.005 250); + --popover-foreground: oklch(0.96 0.005 250); + + /* Lighter blue, near-black ink: (0.74³+.05)/(0.15³+.05) ≈ 8.5:1. */ + --primary: oklch(0.74 0.11 255); + --primary-foreground: oklch(0.15 0.01 255); + + --secondary: oklch(0.26 0.006 250); + --secondary-foreground: oklch(0.93 0.005 250); + + /* muted-fg 0.78: on card 0.22 ≈ 8.7:1, on bg ≈ 9.7:1, on muted 0.26 + * ≈ 7.8:1 — muted text stays AAA reading text in dark mode too. */ + --muted: oklch(0.26 0.006 250); + --muted-foreground: oklch(0.78 0.01 250); + + --accent: oklch(0.30 0.05 255); + --accent-foreground: oklch(0.85 0.08 255); + + /* Status flips to bright swatches with near-black ink so every pair + * clears ≥7:1: destructive 0.423/0.054 ≈ 7.8:1 approx (kept at 0.72 + * because red's true Y runs below L³); success ≈ 9.7:1; warning + * ≈ 11.1:1; info ≈ 9.0:1. */ + --destructive: oklch(0.72 0.16 25); + --destructive-foreground: oklch(0.16 0.02 25); + --success: oklch(0.78 0.14 155); + --success-foreground: oklch(0.16 0.03 155); + --warning: oklch(0.82 0.14 80); + --warning-foreground: oklch(0.16 0.03 80); + --info: oklch(0.76 0.10 230); + --info-foreground: oklch(0.15 0.03 230); + + /* Strong light-ink borders — boundaries stay visible at night too. */ + --border: oklch(0.96 0.005 250 / 0.28); + --input: oklch(0.96 0.005 250 / 0.32); + --ring: oklch(0.74 0.11 255); + + --chart-1: oklch(0.70 0.12 255); + --chart-2: oklch(0.78 0.13 150); + --chart-3: oklch(0.82 0.13 80); + --chart-4: oklch(0.70 0.15 20); + --chart-5: oklch(0.68 0.13 310); + + --sidebar: oklch(0.18 0.005 250); + --sidebar-foreground: oklch(0.96 0.005 250); + --sidebar-primary: oklch(0.74 0.11 255); + --sidebar-primary-foreground: oklch(0.15 0.01 255); + --sidebar-accent: oklch(0.30 0.05 255); + --sidebar-accent-foreground: oklch(0.85 0.08 255); + --sidebar-border: oklch(0.96 0.005 250 / 0.25); + --sidebar-ring: oklch(0.74 0.11 255); + + --syntax-keyword: oklch(0.78 0.11 310); + --syntax-string: oklch(0.80 0.11 155); + --syntax-number: oklch(0.82 0.11 55); + --syntax-comment: oklch(0.74 0.01 250); + --syntax-function: oklch(0.78 0.10 255); + --syntax-type: oklch(0.80 0.08 210); + + /* ── Chat surfaces (dark) ── user bubble is a deep blue panel with + * very light ink (≈12:1); assistant matches the card. */ + --chat-user-bg: oklch(0.30 0.06 255); + --chat-user-fg: oklch(0.96 0.005 250); + --chat-assistant-bg: oklch(0.22 0.005 250); + --chat-assistant-fg: oklch(0.96 0.005 250); + --chat-assistant-border: oklch(0.96 0.005 250 / 0.28); + + --elevation-1: 0 1px 2px oklch(0 0 0 / 0.3); + --elevation-2: 0 1px 3px oklch(0 0 0 / 0.4), 0 1px 2px oklch(0 0 0 / 0.25); + --elevation-3: 0 4px 8px -2px oklch(0 0 0 / 0.4), 0 2px 4px -2px oklch(0 0 0 / 0.25); + --elevation-4: 0 12px 24px -6px oklch(0 0 0 / 0.45), 0 4px 8px -4px oklch(0 0 0 / 0.25); + --elevation-5: 0 20px 40px -8px oklch(0 0 0 / 0.55); +} + +/* + * @layer base — Lucent's access-first philosophy. + * + * Atkinson Hyperlegible everywhere; no negative tracking (the font was + * drawn for character disambiguation — don't squeeze it). Links carry a + * real underline at rest. Boundaries are borders, not shadows. + */ +@layer base { + body { + min-height: 100vh; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + letter-spacing: 0; + font-family: var(--font-sans); + font-size: var(--text-body-size); + line-height: var(--text-body-lh); + } + + /* Headings — same family, weight 700 does the hierarchy work. + * `color: inherit` (NOT var(--foreground)) so headings sitting on + * colored slabs keep the slab's foreground pairing. */ + 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: 700; + letter-spacing: 0; + 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 (house + * pattern); slightly stronger tint so the chip boundary is visible. */ + :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.9em; + } + + /* Selection — a solid blue tint, ink stays put. */ + ::selection { + background: color-mix(in oklch, var(--primary) 25%, transparent); + color: var(--foreground); + } + + /* Links — the blue, UNDERLINED AT REST (an affordance you can see, + * not one you must discover); the underline thickens on hover. */ + a { + color: var(--primary); + text-decoration-line: underline; + text-decoration-thickness: 1px; + text-decoration-color: currentColor; + 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 — thicker than the house norm: a 3px solid ring, offset + * 2px, on every actionable control. Keyboard users are first-class here. + * Placed outside @layer base so it beats Tailwind's default ring utilities. + */ +[data-slot="button"]:focus-visible, +[data-slot="select-trigger"]:focus-visible, +[data-slot="checkbox"]:focus-visible, +[data-slot="radio-group-item"]:focus-visible, +[data-slot="switch"]:focus-visible, +[data-slot="tabs-trigger"]:focus-visible, +a:focus-visible { + outline: 3px solid var(--ring); + outline-offset: 2px; + box-shadow: none; +} + +/* Buttons — bold labels (Atkinson ships 400/700; the 700 IS the button + * voice, GOV.UK style). */ +[data-slot="button"] { + font-weight: 700; + letter-spacing: 0; +} +[data-slot="button"]:hover:not(:disabled) { + opacity: 0.9; +} +[data-slot="button"]:active:not(:disabled) { + opacity: 0.82; +} + +/* 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 — 44px minimum for EVERYONE, not gated behind a coarse- + * pointer query. Tremor, low vision, and arthritis don't check your + * pointer type. */ +[data-slot="button"] { + min-height: 2.75rem; + min-width: 2.75rem; +} +[data-slot="input"], +[data-slot="select-trigger"] { + min-height: 2.75rem; +} + +/* Coarse pointers keep the same floor (harmless, kept for parity with the + * house pattern). */ +@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 — white field, strong visible border; focus is a border-color + * change PLUS a 2px ring so the state change never hides. */ +[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); + opacity: 1; +} +[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); +} +[data-slot="input"]:focus, +[data-slot="input"]:focus-visible, +[data-slot="textarea"]:focus, +[data-slot="textarea"]:focus-visible, +[data-slot="select-trigger"]:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--ring); + outline: none; +} + +/* Reduced motion — transitions on interactive slots drop to zero; nothing + * in this theme is motion-dependent. */ +@media (prefers-reduced-motion: reduce) { + [data-slot="button"], + [data-slot="input"], + [data-slot="textarea"], + [data-slot="select-trigger"], + [data-slot="tabs-trigger"], + [data-slot="switch"], + [data-slot="checkbox"], + a { + transition: none; + } +}