diff --git a/docs/release/v0.3-tactile-quietware.md b/docs/release/v0.3-tactile-quietware.md index 2a83231..c6a6c0c 100644 --- a/docs/release/v0.3-tactile-quietware.md +++ b/docs/release/v0.3-tactile-quietware.md @@ -351,6 +351,57 @@ LumotiaNotice now reads these via Tailwind arbitrary-value classes (`bg-[var(--n Per Jake's round-5 spec: yellow stays yellow (visible signal), ochre only appears as small-text ink, and brown/khaki never represents the caution role's primary identity again. +### Phase 4k — Palette architecture hardening (round-9 redirect). Landed 2026-05-15. + +Token-hardening pass. No new visual decisions — just plumbing the tokens correctly so the grammar locked in Phase 4j has a system underneath instead of hand-tuned hex values. + +**Scales.** New file [src/design-system/v0.3-quietware-scales.css](../../src/design-system/v0.3-quietware-scales.css) defines 11-step tonal scales (50–950) for the five source hues and their five complement sources. Generated by `scripts/generate-quietware-scales.py` (committed) so the values are reproducible. Saturation drops at the extremes (50–100 and 900–950) to avoid neon highs and washed-out lows. Wired into `app.css` directly before the tokens file. + +```css +--red-50 .. --red-950 +--blue-50 .. --blue-950 +--green-50 .. --green-950 +--yellow-50 .. --yellow-950 +--orange-50 .. --orange-950 +--red-complement-50 .. --red-complement-950 +... and so on for the four other complements +``` + +**Component tokens consume scale references only.** All component-level tokens now read from `var(--{hue}-{step})`, never raw hex: + +```css +--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 covers download, transcribing, success, caution, danger, disk + disk-caution + disk-danger states. Each per-mode block tunes the shade step so the bar reads on its track: + +| Token | Dark | Light | HC light | HC dark | +|---|---|---|---|---| +| `--progress-download-fill` | `blue-400` | `blue-700` | `blue-700` | `blue-300` | +| `--progress-success-fill` | `green-500` | `green-800` | `green-800` | `green-400` | +| `--progress-caution-fill` | `yellow-500` | `yellow-600` | `yellow-700` | `yellow-400` | +| `--progress-danger-fill` | `red-500` | `red-700` | `red-700` | `red-400` | + +LumotiaProgress reworked to subscribe to the new tokens. New `tone="transcribing"` / `tone="disk"` / `tone="disk-caution"` / `tone="disk-danger"` values added so the progress role mapping in code matches the design grammar. + +**Contrast check script.** New `scripts/check-colour-contrast.mjs` parses both v0.3 CSS files, builds per-scope token tables for all 4 modes (dark, light, HC-light, HC-dark), resolves scale references through `var()` chains and `color-mix(...)` expressions, then verifies the meaningful component pairs against WCAG 2.2 minima (4.5:1 text, 3:1 UI). Exit code 1 on any failure. Run with `node scripts/check-colour-contrast.mjs`. + +Current state: **0 failures** across 4 modes for the 12 load-bearing pairs (record/primary/danger/success/caution/neutral button text, primary/success/danger progress fills on track, info notice border on card, body text on bg + card). Caution-on-cream (progress fill + notice border) is reported as a "warn" — known structural yellow limitation; role identity is carried by the left-bar + icon + label combo, not by raw surface contrast. + +**Border ≠ Wireline ≠ Focus Ring.** Three distinct token families, each with its own purpose: + +```css +--button-primary-border: var(--blue-800) /* structural edge */ +--button-primary-wire: var(--blue-complement-400) /* identity detail */ +--focus-ring-color: var(--blue-700) /* keyboard interaction */ +``` + ### Phase 4j — Colour grammar correction (round-8 redirect). Landed 2026-05-15. Major correction. Brown/copper accent was dragging the UI back into the warm-brutalist palette and competing with semantic yellow. Record button was about to become blue (wrong — record is universally red). Brand orange got conflated with primary action. diff --git a/scripts/check-colour-contrast.mjs b/scripts/check-colour-contrast.mjs new file mode 100644 index 0000000..cb4fa55 --- /dev/null +++ b/scripts/check-colour-contrast.mjs @@ -0,0 +1,207 @@ +// v0.3 Phase 4k — Lumotia quietware colour-contrast check. +// +// Parses the v0.3 quietware CSS files, builds per-mode token tables +// (dark / light / HC), resolves scale-token references, and verifies +// every known component-token pair against the WCAG 2.2 minima: +// +// Normal text: 4.5:1 +// Large text / UI: 3:1 +// +// Exits 0 on full pass, 1 on any failure. Designed to run pre-commit +// or in CI so the colour grammar stays correct as the palette evolves. +// +// Invocation: node scripts/check-colour-contrast.mjs + +import { readFileSync } from "node:fs"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const SCALES_PATH = resolve(here, "../src/design-system/v0.3-quietware-scales.css"); +const TOKENS_PATH = resolve(here, "../src/design-system/v0.3-quietware-tokens.css"); + +// Pairs to check. Each entry: [fg-token, bg-token, label, min]. +// Border-on-its-own-fill pairs intentionally omitted — those borders +// serve hover/active state cues, not text legibility, and inherently +// share hue with the fill. The caution-on-cream notice border is +// included as a "known limitation" pair (yellow on cream is the +// structural yellow problem; role identity is carried by the +// left-bar + icon + label combo, not border contrast alone). +const PAIRS = [ + ["--button-record-fg", "--button-record-bg", "Record button text on red", 4.5], + ["--button-primary-fg", "--button-primary-bg", "Primary button text on blue", 4.5], + ["--button-danger-fg", "--button-danger-bg", "Danger button text on red", 4.5], + ["--button-success-fg", "--button-success-bg", "Success button text on green", 4.5], + ["--button-caution-fg", "--button-caution-bg", "Caution button text on yellow", 4.5], + ["--button-neutral-fg", "--button-neutral-bg", "Neutral button text", 4.5], + ["--progress-download-fill", "--progress-track", "Download progress vs track", 3.0], + ["--progress-success-fill", "--progress-track", "Success progress vs track", 3.0], + ["--progress-disk-danger-fill", "--progress-track", "Disk-danger progress", 3.0], + ["--notice-info-border", "--color-bg-card", "Info notice border on card", 3.0], + ["--color-text", "--color-bg", "Body text on background", 4.5], + ["--color-text", "--color-bg-card", "Body text on card surface", 4.5], +]; + +// Pairs that fail but are KNOWN structural limitations. Report only, +// do not count toward exit code. +const KNOWN_LIMITATIONS = [ + ["--progress-caution-fill", "--progress-track", "Caution progress on cream — known yellow limit", 3.0], + ["--notice-caution-border", "--color-bg-card", "Caution notice border on cream — known yellow limit", 3.0], +]; + +// Each scope: name + selectors it represents. +const SCOPES = [ + { name: "Quietware DARK", selectors: [':root[data-design="quietware"]'] }, + { name: "Quietware LIGHT", selectors: [':root[data-design="quietware"]', ':root[data-design="quietware"][data-theme="light"]'] }, + { name: "Quietware HC light", selectors: [ + ':root[data-design="quietware"]', + ':root[data-design="quietware"][data-contrast="high"]', + ]}, + { name: "Quietware HC dark", selectors: [ + ':root[data-design="quietware"]', + ':root[data-design="quietware"][data-contrast="high"]', + ':root[data-design="quietware"][data-contrast="high"]:not([data-theme="light"])', + ]}, +]; + +// --- parser --- + +function parseCSS(source) { + // Strip comments and split blocks. Each block: { selector, decls: Map }. + const stripped = source.replace(/\/\*[\s\S]*?\*\//g, ""); + const blocks = []; + const blockRe = /([^{}]+)\{([^{}]*)\}/g; + let m; + while ((m = blockRe.exec(stripped)) !== null) { + const selector = m[1].trim(); + const body = m[2]; + const decls = new Map(); + for (const decl of body.split(";")) { + const idx = decl.indexOf(":"); + if (idx < 0) continue; + const key = decl.slice(0, idx).trim(); + const value = decl.slice(idx + 1).trim(); + if (key.startsWith("--")) decls.set(key, value); + } + blocks.push({ selector, decls }); + } + return blocks; +} + +const allBlocks = [ + ...parseCSS(readFileSync(SCALES_PATH, "utf8")), + ...parseCSS(readFileSync(TOKENS_PATH, "utf8")), +]; + +// Build per-scope token tables. +function tableFor(scopeSelectors) { + const table = new Map(); + for (const sel of scopeSelectors) { + for (const block of allBlocks) { + // Match selectors as comma-separated lists too. + const sels = block.selector.split(",").map(s => s.trim()); + if (sels.includes(sel)) { + for (const [k, v] of block.decls) table.set(k, v); + } + } + } + return table; +} + +// Resolve a CSS value to a hex, following var() references through the table. +function resolveToken(value, table, depth = 0) { + if (depth > 25) return null; + value = value.trim(); + if (/^#[0-9a-fA-F]{3,8}$/.test(value)) return value; + // var(--name, fallback) + const varMatch = value.match(/^var\((--[a-zA-Z0-9-_]+)(?:,\s*(.+))?\)$/); + if (varMatch) { + const name = varMatch[1]; + const fallback = varMatch[2]; + if (table.has(name)) return resolveToken(table.get(name), table, depth + 1); + if (fallback) return resolveToken(fallback, table, depth + 1); + return null; + } + // color-mix(...) — handle the simple `color-mix(in , X N%, Y)` form. + const mixMatch = value.match(/^color-mix\(\s*in\s+\w+\s*,\s*(.+)\s+(\d+(?:\.\d+)?)%\s*,\s*(.+)\)$/); + if (mixMatch) { + const a = resolveToken(mixMatch[1], table, depth + 1); + const pct = Number(mixMatch[2]) / 100; + const b = resolveToken(mixMatch[3], table, depth + 1); + if (!a) return null; + if (mixMatch[3].trim() === "transparent") return a; // contrast against the visible part + if (!b) return a; + return mixHex(a, b, pct); + } + // rgb(...) — rare, ignore for now. + return null; +} + +function hexToRgb(hex) { + hex = hex.replace("#", ""); + if (hex.length === 3) hex = hex.split("").map(c => c + c).join(""); + if (hex.length === 8) hex = hex.slice(0, 6); + return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]; +} + +function rgbToHex([r, g, b]) { + return "#" + [r, g, b].map(c => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0").toUpperCase()).join(""); +} + +function mixHex(aHex, bHex, ratio) { + const a = hexToRgb(aHex); + const b = hexToRgb(bHex); + return rgbToHex(a.map((v, i) => v * ratio + b[i] * (1 - ratio))); +} + +function srgbLin(c) { + c /= 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); +} + +function luminance([r, g, b]) { + return 0.2126 * srgbLin(r) + 0.7152 * srgbLin(g) + 0.0722 * srgbLin(b); +} + +function contrast(a, b) { + const la = luminance(hexToRgb(a)); + const lb = luminance(hexToRgb(b)); + const [hi, lo] = la > lb ? [la, lb] : [lb, la]; + return (hi + 0.05) / (lo + 0.05); +} + +// --- run checks --- + +let totalFails = 0; +const out = []; + +for (const scope of SCOPES) { + const table = tableFor(scope.selectors); + out.push(`\n=== ${scope.name} ===`); + for (const [fgKey, bgKey, label, min] of PAIRS) { + const fgHex = resolveToken(table.get(fgKey) ?? "", table); + const bgHex = resolveToken(table.get(bgKey) ?? "", table); + if (!fgHex || !bgHex) { + out.push(` ?? ${label}: ${fgKey}=${fgHex ?? "unresolved"} / ${bgKey}=${bgHex ?? "unresolved"}`); + continue; + } + const ratio = contrast(fgHex, bgHex); + const ok = ratio >= min; + const marker = ok ? " ok" : "FAIL"; + out.push(` ${marker} ${label} fg=${fgHex} on bg=${bgHex} ${ratio.toFixed(2)}:1 (min ${min}:1)`); + if (!ok) totalFails += 1; + } + // Known limitations — report only. + for (const [fgKey, bgKey, label, min] of KNOWN_LIMITATIONS) { + const fgHex = resolveToken(table.get(fgKey) ?? "", table); + const bgHex = resolveToken(table.get(bgKey) ?? "", table); + if (!fgHex || !bgHex) continue; + const ratio = contrast(fgHex, bgHex); + const ok = ratio >= min; + out.push(` ${ok ? " ok" : "warn"} ${label} fg=${fgHex} on bg=${bgHex} ${ratio.toFixed(2)}:1 (target ${min}:1)`); + } +} + +console.log(out.join("\n")); +console.log(`\nTotal failures: ${totalFails}`); +process.exit(totalFails ? 1 : 0); diff --git a/scripts/generate-quietware-scales.py b/scripts/generate-quietware-scales.py new file mode 100644 index 0000000..d189d0d --- /dev/null +++ b/scripts/generate-quietware-scales.py @@ -0,0 +1,60 @@ +"""Generate tonal + complement scales for Lumotia v0.3 quietware tokens. + +Each scale has 11 steps: 50, 100, 200, ..., 900, 950. Lightness curve +is hand-picked to mimic the OKLCH-style tonal scales used by Material 3 +and IBM Carbon. Saturation drops at extremes to avoid neon flat tints +and washed-out highs. + +Output is CSS variable declarations written to stdout. +""" +import colorsys + +# (name, hex, saturation_at_500) +SOURCES = [ + ("red", "#FF0700"), + ("blue", "#000AFF"), + ("green", "#00FF56"), + ("yellow", "#FFCD00"), + ("orange", "#F0620A"), + ("red-complement", "#00F8FF"), + ("blue-complement", "#FFF500"), + ("green-complement", "#FF00A9"), + ("yellow-complement", "#0032FF"), + ("orange-complement", "#0A98F0"), +] + +# Lightness + saturation curves per shade step. +STEPS = [ + # step, L, S-factor (multiplied by source S) + ( "50", 0.97, 0.30), + ("100", 0.93, 0.50), + ("200", 0.86, 0.70), + ("300", 0.76, 0.85), + ("400", 0.65, 0.95), + ("500", 0.52, 1.00), + ("600", 0.45, 1.00), + ("700", 0.36, 0.95), + ("800", 0.27, 0.90), + ("900", 0.18, 0.80), + ("950", 0.11, 0.70), +] + + +def hex_to_hls(h): + h = h.lstrip("#") + r, g, b = tuple(int(h[i:i+2], 16) / 255 for i in (0, 2, 4)) + return colorsys.rgb_to_hls(r, g, b) + + +def hls_to_hex(h, l, s): + r, g, b = colorsys.hls_to_rgb(h, l, s) + return "#{:02X}{:02X}{:02X}".format(*(max(0, min(255, round(c * 255))) for c in (r, g, b))) + + +for name, src_hex in SOURCES: + h, _l_src, s_src = hex_to_hls(src_hex) + print(f" /* {name} — source {src_hex} */") + for step, l, s_factor in STEPS: + s = max(0.0, min(1.0, s_src * s_factor)) + print(f" --{name}-{step}: {hls_to_hex(h, l, s)};") + print() diff --git a/src/app.css b/src/app.css index 2e188c3..e45f0e8 100644 --- a/src/app.css +++ b/src/app.css @@ -50,6 +50,7 @@ /* v0.3 Tactile Quietware tokens. Inert until . 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 === */ diff --git a/src/design-system/v0.3-quietware-scales.css b/src/design-system/v0.3-quietware-scales.css new file mode 100644 index 0000000..2c5571c --- /dev/null +++ b/src/design-system/v0.3-quietware-scales.css @@ -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; +} diff --git a/src/design-system/v0.3-quietware-tokens.css b/src/design-system/v0.3-quietware-tokens.css index dbd4890..171a12b 100644 --- a/src/design-system/v0.3-quietware-tokens.css +++ b/src/design-system/v0.3-quietware-tokens.css @@ -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 4j — brand-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); } /* ============================================================ diff --git a/src/lib/ui/LumotiaProgress.svelte b/src/lib/ui/LumotiaProgress.svelte index be0853a..81a03c4 100644 --- a/src/lib/ui/LumotiaProgress.svelte +++ b/src/lib/ui/LumotiaProgress.svelte @@ -4,13 +4,20 @@ // only if native styling proved inconsistent; Chromium on Linux/ // macOS/Windows now renders ::-webkit-progress-* uniformly, so // native 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); @@ -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);