v0.3 Phase 4k: palette architecture hardening — scales + component tokens + contrast check

Token-hardening pass per round-9 redirect. No new visual decisions —
just plumbing the tokens correctly so the colour grammar locked in
Phase 4j has a system underneath instead of hand-tuned hex.

Scales.

  NEW: src/design-system/v0.3-quietware-scales.css. 11-step tonal
  scales (50, 100, ..., 900, 950) for five source hues and their five
  complement sources. Saturation drops at extremes to avoid neon
  highs and washed-out lows. Hue-stable lightness curve picked to
  mimic Material 3 / IBM Carbon tonal scales.

  Generated by scripts/generate-quietware-scales.py (committed) so
  values are reproducible. To regenerate:
    python3 scripts/generate-quietware-scales.py > scales.css

  Wired into src/app.css directly before v0.3-quietware-tokens.css.

Component tokens consume scale references only.

  All --button-{role}-{tier} and --progress-{state}-fill tokens now
  read from var(--{hue}-{step}) instead of raw hex literals. Future
  scale tuning happens in one place; component callers never change.

    --button-record-bg:    var(--red-600)
    --button-primary-bg:   var(--blue-700)
    --button-success-bg:   var(--green-800)   /* darkened so white
                                                  text clears AA */
    --button-caution-bg:   var(--yellow-400)
    --button-neutral-bg:   var(--color-bg-elevated)
    --button-disabled-bg:  var(--color-bg-elevated)
    --brand-accent:        var(--orange-500)

Progress component tokens.

  New family: --progress-{download,transcribing,success,caution,danger,
  disk,disk-caution,disk-danger}-fill plus --progress-track. Each
  mode tunes the shade step so the bar reads on its track:

    Dark:    blue-400 / green-500 / yellow-500 / red-500
    Light:   blue-700 / green-800 / yellow-600 / red-700
    HC-light: blue-700 / green-800 / yellow-700 / red-700
    HC-dark:  blue-300 / green-400 / yellow-400 / red-400

  LumotiaProgress.svelte gains tone="transcribing", "disk",
  "disk-caution", "disk-danger" so callers map progress role to
  token explicitly.

Border / wireline / focus-ring discipline.

  Three distinct token families:
    --button-primary-border    structural component edge
    --button-primary-wire      complementary identity detail
    --focus-ring-color         keyboard interaction

  Documented as separate concerns in the tokens file.

Contrast check script.

  NEW: scripts/check-colour-contrast.mjs. Parses scales + tokens
  CSS, builds per-scope token tables for dark / light / HC-light /
  HC-dark, resolves var() chains and color-mix() expressions, then
  verifies twelve load-bearing component pairs against WCAG 2.2
  minima (4.5:1 text, 3:1 UI). Exit code 1 on failure.

  Current state: 0 failures across 4 modes.

  Two pairs reported as "warn" rather than "fail": caution progress
  on cream + caution notice border on cream. The yellow source colour
  cannot clear 3:1 on a near-white surface without losing its
  identity. Role recognition is carried by left-bar + icon + label,
  not by surface contrast.

Verified.

  - node scripts/check-colour-contrast.mjs: 0 failures, 4 modes, 12
    load-bearing pairs + 2 known-limitation warns.
  - npm run check: 0 errors, 0 warnings across 5707 files.

Phase 5c (Dictation layout migration) is the natural next focused
sitting. This commit gives Phase 5c the systematic colour grammar to
build on, and the contrast check gates future palette regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 16:00:17 +01:00
parent 2c6f5ca230
commit b9dd598937
7 changed files with 624 additions and 76 deletions

View File

@@ -50,6 +50,7 @@
/* v0.3 Tactile Quietware tokens. Inert until <html data-design="quietware">.
See docs/release/v0.3-tactile-quietware.md and design-system/v0.3-quietware-tokens.css. */
@import "./design-system/v0.3-quietware-scales.css";
@import "./design-system/v0.3-quietware-tokens.css";
/* === Lumotia Design Tokens === */

View File

@@ -0,0 +1,146 @@
/* ============================================================
Lumotia v0.3 — Quietware tonal + complement scales.
Generated by scripts/generate-quietware-scales.py (committed
alongside this file). 11 shades per hue, source colour anchored
at the closest natural step (often 500 or 600). Saturation drops
at extremes to avoid neon highs and washed-out lows.
Components should NEVER reference these scales directly — they
are consumed only by component tokens defined in
v0.3-quietware-tokens.css.
Active only under html[data-design="quietware"]; the v0.2 surface
does not see these tokens.
============================================================ */
:root[data-design="quietware"] {
/* red — source #FF0700 */
--red-50: #FAF5F5;
--red-100: #F6E5E4;
--red-200: #F4C4C2;
--red-300: #F6918E;
--red-400: #FB5651;
--red-500: #FF110A;
--red-600: #E60600;
--red-700: #B30905;
--red-800: #830A07;
--red-900: #530B09;
--red-950: #300908;
/* blue — source #000AFF */
--blue-50: #F5F5FA;
--blue-100: #E4E5F6;
--blue-200: #C2C4F4;
--blue-300: #8E92F6;
--blue-400: #5158FB;
--blue-500: #0A14FF;
--blue-600: #0009E6;
--blue-700: #050BB3;
--blue-800: #070C83;
--blue-900: #090C53;
--blue-950: #080A30;
/* green — source #00FF56 */
--green-50: #F5FAF7;
--green-100: #E4F6EA;
--green-200: #C2F4D3;
--green-300: #8EF6B1;
--green-400: #51FB8A;
--green-500: #0AFF5D;
--green-600: #00E64D;
--green-700: #05B33F;
--green-800: #078331;
--green-900: #095322;
--green-950: #083016;
/* yellow — source #FFCD00 */
--yellow-50: #FAF9F5;
--yellow-100: #F6F3E4;
--yellow-200: #F4EAC2;
--yellow-300: #F6E18E;
--yellow-400: #FBD951;
--yellow-500: #FFCF0A;
--yellow-600: #E6B800;
--yellow-700: #B39105;
--yellow-800: #836B07;
--yellow-900: #534409;
--yellow-950: #302808;
/* orange (brand) — source #F0620A */
--orange-50: #F9F7F5;
--orange-100: #F5EBE5;
--orange-200: #F2D6C4;
--orange-300: #F2B792;
--orange-400: #F49358;
--orange-500: #F56A14;
--orange-600: #DC5A09;
--orange-700: #AC490C;
--orange-800: #7E370C;
--orange-900: #50260C;
--orange-950: #2E180A;
/* red-complement (cyan) — source #00F8FF */
--red-complement-50: #F5FAFA;
--red-complement-100: #E4F6F6;
--red-complement-200: #C2F3F4;
--red-complement-300: #8EF3F6;
--red-complement-400: #51F6FB;
--red-complement-500: #0AF8FF;
--red-complement-600: #00DFE6;
--red-complement-700: #05AEB3;
--red-complement-800: #077F83;
--red-complement-900: #095153;
--red-complement-950: #082F30;
/* blue-complement (yellow) — source #FFF500 */
--blue-complement-50: #FAF9F5;
--blue-complement-100: #F6F5E4;
--blue-complement-200: #F4F2C2;
--blue-complement-300: #F6F28E;
--blue-complement-400: #FBF451;
--blue-complement-500: #FFF50A;
--blue-complement-600: #E6DC00;
--blue-complement-700: #B3AC05;
--blue-complement-800: #837E07;
--blue-complement-900: #535009;
--blue-complement-950: #302E08;
/* green-complement (magenta) — source #FF00A9 */
--green-complement-50: #FAF5F8;
--green-complement-100: #F6E4F0;
--green-complement-200: #F4C2E3;
--green-complement-300: #F68ED3;
--green-complement-400: #FB51C1;
--green-complement-500: #FF0AAC;
--green-complement-600: #E60098;
--green-complement-700: #B30578;
--green-complement-800: #830759;
--green-complement-900: #53093A;
--green-complement-950: #300822;
/* yellow-complement (blue) — source #0032FF */
--yellow-complement-50: #F5F6FA;
--yellow-complement-100: #E4E8F6;
--yellow-complement-200: #C2CCF4;
--yellow-complement-300: #8EA2F6;
--yellow-complement-400: #5172FB;
--yellow-complement-500: #0A3AFF;
--yellow-complement-600: #002DE6;
--yellow-complement-700: #0527B3;
--yellow-complement-800: #071F83;
--yellow-complement-900: #091853;
--yellow-complement-950: #081030;
/* orange-complement (sky-blue) — source #0A98F0 */
--orange-complement-50: #F5F8F9;
--orange-complement-100: #E5EFF5;
--orange-complement-200: #C4E1F2;
--orange-complement-300: #92CDF2;
--orange-complement-400: #58B8F4;
--orange-complement-500: #149FF5;
--orange-complement-600: #098CDC;
--orange-complement-700: #0C6FAC;
--orange-complement-800: #0C527E;
--orange-complement-900: #0C3650;
--orange-complement-950: #0A202E;
}

View File

@@ -197,19 +197,15 @@
--color-text-secondary: #BDB6A9;
--color-text-tertiary: #908779;
/* Phase 4j — brand-accent is bright orange #F0620A, restricted to
logo / brand surfaces. Primary action colour is BLUE, not orange.
--color-accent stays as the convenience alias for primary action
so v0.2 component callers (bg-accent etc.) get blue under
quietware. Brand-only surfaces use --brand-accent. */
--brand-accent: var(--source-orange-brand);
--brand-accent-strong: #C24E07;
--brand-wire: var(--orange-complement-source);
--color-accent: #4A7BFF; /* primary action — blue, AA on dark + white */
--color-accent-hover: #2F62F0;
--color-accent-subtle: #4A7BFF14;
--color-accent-glow: #4A7BFF25;
/* Phase 4j — primary action is BLUE; brand-accent (orange) defined
in the component-tokens block below and restricted to logo /
brand surfaces. --color-accent stays as the convenience alias for
primary action so v0.2 component callers (bg-accent etc.) get
blue under quietware. */
--color-accent: var(--blue-700);
--color-accent-hover: var(--blue-800);
--color-accent-subtle: color-mix(in srgb, var(--blue-700) 12%, transparent);
--color-accent-glow: color-mix(in srgb, var(--blue-700) 25%, transparent);
/* Four-tier role tokens. Signal stays bright (the role's identity);
ink lightens for text legibility on dark; border is a middle value;
@@ -315,34 +311,71 @@
--counterline-opacity-light: var(--wire-opacity-light);
--counterline-opacity-focus: var(--wire-opacity-focus);
/* Component tokens for buttons. Hand-tuned hex values cleared at
WCAG AA on the foreground each button pairs with. Tonal scales
(red-50..red-950 etc.) will replace these in a focused future
commit; component names stay stable. */
--button-record-bg: #DC2626;
/* Component tokens — consume scale tokens only. Never raw hex.
Phase 4k: scale tokens defined in v0.3-quietware-scales.css. */
/* Record. Red mid-step on dark surface clears AA on white text. */
--button-record-bg: var(--red-600);
--button-record-fg: #FFFFFF;
--button-record-border: #B91C1C;
--button-record-wire: #00D8E0; /* cyan — complement of red */
--button-record-border: var(--red-700);
--button-record-wire: var(--red-complement-400);
--button-primary-bg: #2563EB;
/* Primary app action. Blue 600 on dark, AA on white. */
--button-primary-bg: var(--blue-700);
--button-primary-fg: #FFFFFF;
--button-primary-border: #1D4ED8;
--button-primary-wire: #F6E600; /* yellow — complement of blue */
--button-primary-border: var(--blue-800);
--button-primary-wire: var(--blue-complement-400);
--button-danger-bg: var(--button-record-bg);
--button-danger-fg: var(--button-record-fg);
--button-danger-border: var(--button-record-border);
--button-danger-wire: var(--button-record-wire);
/* Destructive shares record fill but separate token for semantic clarity. */
--button-danger-bg: var(--red-600);
--button-danger-fg: #FFFFFF;
--button-danger-border: var(--red-700);
--button-danger-wire: var(--red-complement-400);
--button-success-bg: #15803D;
/* Success. green-800 chosen so white foreground clears AA 4.5:1. */
--button-success-bg: var(--green-800);
--button-success-fg: #FFFFFF;
--button-success-border: #14532D;
--button-success-wire: #E600A0; /* magenta — complement of green */
--button-success-border: var(--green-900);
--button-success-wire: var(--green-complement-400);
--button-caution-bg: #FACC15;
--button-caution-fg: #1A1500;
--button-caution-border: #CA8A04;
--button-caution-wire: #1850D8; /* blue — complement of yellow */
/* Caution. Yellow 400 — bright enough to read, paired with near-black
foreground (yellow-950) so text passes AA on the yellow surface. */
--button-caution-bg: var(--yellow-400);
--button-caution-fg: var(--yellow-950);
--button-caution-border: var(--yellow-600);
--button-caution-wire: var(--yellow-complement-500);
/* Neutral — non-semantic, low-priority controls. */
--button-neutral-bg: var(--color-bg-elevated);
--button-neutral-fg: var(--color-text);
--button-neutral-border: var(--color-border-subtle);
--button-neutral-wire: transparent;
/* Disabled — flat, low-saturation, never carries a wireline. */
--button-disabled-bg: var(--color-bg-elevated);
--button-disabled-fg: var(--color-text-tertiary);
--button-disabled-border: var(--color-border-subtle);
--button-disabled-wire: transparent;
/* Progress component tokens. Track + fill per role. Default progress
= primary blue; success / caution / danger explicit. Disk-space
reads neutral by default and only escalates per threshold. */
/* Progress fills shifted lighter on dark surfaces so the bar
reads on the dark elevated track. */
--progress-track: var(--color-bg-elevated);
--progress-download-fill: var(--blue-400);
--progress-transcribing-fill: var(--blue-400);
--progress-success-fill: var(--green-500);
--progress-caution-fill: var(--yellow-500);
--progress-danger-fill: var(--red-500);
--progress-disk-fill: var(--color-text-tertiary);
--progress-disk-caution-fill: var(--yellow-500);
--progress-disk-danger-fill: var(--red-500);
/* Brand accent — orange-500. Restricted to logo / brand surfaces. */
--brand-accent: var(--orange-500);
--brand-accent-strong: var(--orange-600);
--brand-wire: var(--orange-complement-400);
/* Notice counterline tokens retired from defaults (LumotiaNotice
stays opt-in via the `counterline` prop). Tokens kept available
@@ -379,16 +412,12 @@
--color-text-secondary: #5E574C;
--color-text-tertiary: #74685B;
/* Phase 4jbrand-accent stays as #F0620A (logo only); --color-accent
(primary action) is BLUE under quietware. */
--brand-accent: var(--source-orange-brand);
--brand-accent-strong: #C24E07;
--brand-wire: var(--orange-complement-source);
--color-accent: #1D4ED8; /* primary blue, AA on cream */
--color-accent-hover: #1E40AF;
--color-accent-subtle: #1D4ED814;
--color-accent-glow: #1D4ED825;
/* Phase 4k--color-accent points at scale-blue under light mode.
Brand accent (orange) defined alongside component tokens below. */
--color-accent: var(--blue-700);
--color-accent-hover: var(--blue-800);
--color-accent-subtle: color-mix(in srgb, var(--blue-700) 12%, transparent);
--color-accent-glow: color-mix(in srgb, var(--blue-700) 25%, transparent);
/* Four-tier role tokens, light mode. Bg values hand-picked as
clean pale tints, not muddy color-mix products. */
@@ -449,35 +478,58 @@
--quietware-texture-opacity: var(--grain-opacity);
--quietware-texture-color: rgba(29, 27, 24, 1);
/* Phase 4j light-mode component tokens for buttons. Same component
names as dark mode; values tuned for paper-mode contrast. The
filled buttons keep their dark-mode bg colours (they pair with
white foreground regardless of theme); the wireline tokens drop
opacity via --wire-opacity-light when applied. */
--button-record-bg: #B91C1C; /* slightly darker red so 4.5:1 holds */
/* Phase 4k — light-mode component tokens consume scale tokens at
slightly deeper steps for AA on cream paper. */
--button-record-bg: var(--red-700);
--button-record-fg: #FFFFFF;
--button-record-border: #7F1D1D;
--button-record-wire: #00D8E0;
--button-record-border: var(--red-800);
--button-record-wire: var(--red-complement-400);
--button-primary-bg: #1D4ED8;
--button-primary-bg: var(--blue-700);
--button-primary-fg: #FFFFFF;
--button-primary-border: #1E3A8A;
--button-primary-wire: #F6E600;
--button-primary-border: var(--blue-800);
--button-primary-wire: var(--blue-complement-400);
--button-danger-bg: var(--button-record-bg);
--button-danger-fg: var(--button-record-fg);
--button-danger-border: var(--button-record-border);
--button-danger-wire: var(--button-record-wire);
--button-danger-bg: var(--red-700);
--button-danger-fg: #FFFFFF;
--button-danger-border: var(--red-800);
--button-danger-wire: var(--red-complement-400);
--button-success-bg: #166534;
--button-success-bg: var(--green-800);
--button-success-fg: #FFFFFF;
--button-success-border: #14532D;
--button-success-wire: #E600A0;
--button-success-border: var(--green-900);
--button-success-wire: var(--green-complement-400);
--button-caution-bg: #FACC15;
--button-caution-fg: #1A1500;
--button-caution-border: #A16207;
--button-caution-wire: #1850D8;
--button-caution-bg: var(--yellow-400);
--button-caution-fg: var(--yellow-950);
--button-caution-border: var(--yellow-700);
--button-caution-wire: var(--yellow-complement-500);
--button-neutral-bg: var(--color-bg-elevated);
--button-neutral-fg: var(--color-text);
--button-neutral-border: var(--color-border-subtle);
--button-neutral-wire: transparent;
--button-disabled-bg: var(--color-bg-elevated);
--button-disabled-fg: var(--color-text-tertiary);
--button-disabled-border: var(--color-border-subtle);
--button-disabled-wire: transparent;
/* Progress fills shifted darker on cream so the bar reads on the
pale paper track. Success uses green-800 to clear 3:1 on cream. */
--progress-track: var(--color-bg-elevated);
--progress-download-fill: var(--blue-700);
--progress-transcribing-fill: var(--blue-700);
--progress-success-fill: var(--green-800);
--progress-caution-fill: var(--yellow-600);
--progress-danger-fill: var(--red-700);
--progress-disk-fill: var(--color-text-tertiary);
--progress-disk-caution-fill: var(--yellow-600);
--progress-disk-danger-fill: var(--red-700);
--brand-accent: var(--orange-500);
--brand-accent-strong: var(--orange-700);
--brand-wire: var(--orange-complement-400);
--wire-opacity: var(--wire-opacity-light); /* light-mode resolver */
@@ -594,6 +646,16 @@
/* HC focus ring is the brand-strong blue, not a complement. */
--focus-ring-color: #005FCC;
/* HC progress fills — track is white, fills must be dark enough
for 3:1 contrast. (HC-dark sub-block overrides to bright shades
for the black surface.) */
--progress-track: var(--color-bg-elevated);
--progress-download-fill: var(--blue-700);
--progress-transcribing-fill: var(--blue-700);
--progress-success-fill: var(--green-800);
--progress-caution-fill: var(--yellow-700);
--progress-danger-fill: var(--red-700);
}
/* Dark-base high-contrast — black background, white borders. Used
@@ -650,6 +712,16 @@
--color-sidebar: #000000;
--color-nav-active: #FFFFFF;
--color-hover: #1A1A1A;
/* HC-dark progress fills — track is black, fills must be light for 3:1. */
--progress-download-fill: var(--blue-300);
--progress-transcribing-fill: var(--blue-300);
--progress-success-fill: var(--green-400);
--progress-caution-fill: var(--yellow-400);
--progress-danger-fill: var(--red-400);
--progress-disk-fill: var(--color-text-secondary);
--progress-disk-caution-fill: var(--yellow-400);
--progress-disk-danger-fill: var(--red-400);
}
/* ============================================================

View File

@@ -4,13 +4,20 @@
// only if native styling proved inconsistent; Chromium on Linux/
// macOS/Windows now renders ::-webkit-progress-* uniformly, so
// native <progress> stands.
// v0.3 Phase 4k — progress tones now map to dedicated component
// tokens defined in v0.3-quietware-tokens.css. Downloads and
// transcribing default to primary blue; success / caution / danger
// / disk variants each have their own fill token so the role
// mapping is explicit and testable. v0.2 fallback at :root maps
// the new tokens to existing --color-* values so non-quietware
// surface keeps rendering unchanged.
interface Props {
value: number;
max?: number;
label?: string; // visible label (above bar)
ariaLabel?: string; // sr-only label when no visible label
showValue?: boolean; // render "X / max" beside label
tone?: "default" | "success" | "caution" | "danger";
label?: string;
ariaLabel?: string;
showValue?: boolean;
tone?: "default" | "transcribing" | "success" | "caution" | "danger" | "disk" | "disk-caution" | "disk-danger";
}
let {
@@ -23,10 +30,14 @@
}: Props = $props();
const fillToken = $derived(
tone === "success" ? "var(--color-success)"
: tone === "caution" ? "var(--color-caution)"
: tone === "danger" ? "var(--color-danger)"
: "var(--color-accent)"
tone === "transcribing" ? "var(--progress-transcribing-fill, var(--color-accent))"
: tone === "success" ? "var(--progress-success-fill, var(--color-success))"
: tone === "caution" ? "var(--progress-caution-fill, var(--color-caution))"
: tone === "danger" ? "var(--progress-danger-fill, var(--color-danger))"
: tone === "disk" ? "var(--progress-disk-fill, var(--color-text-tertiary))"
: tone === "disk-caution" ? "var(--progress-disk-caution-fill, var(--color-caution))"
: tone === "disk-danger" ? "var(--progress-disk-danger-fill, var(--color-danger))"
: "var(--progress-download-fill, var(--color-accent))"
);
const pct = $derived(max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0);
</script>
@@ -51,12 +62,12 @@
.lumotia-progress {
appearance: none;
-webkit-appearance: none;
background-color: var(--color-bg-elevated);
background-color: var(--progress-track, var(--color-bg-elevated));
border: 1px solid var(--color-border-subtle);
}
/* Chromium / Edge */
.lumotia-progress::-webkit-progress-bar {
background-color: var(--color-bg-elevated);
background-color: var(--progress-track, var(--color-bg-elevated));
}
.lumotia-progress::-webkit-progress-value {
background-color: var(--lumotia-progress-fill);