Files
Lumotia/src/app.css
Jake b9dd598937 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>
2026-05-15 16:00:17 +01:00

632 lines
19 KiB
CSS

/* === Bundled Fonts — offline-first ===
These @font-face declarations are mirrored in
src/design-system/colors_and_type.css for the buildless preview pages
(which load that file directly without going through Tailwind/Vite).
The two copies must keep font-family, font-weight, font-style, and
font-display in sync. The src URLs intentionally differ: this file
uses root-absolute paths served by Vite, the preview file uses
relative paths so the static HTML pages resolve fonts/ alongside. */
@font-face {
font-family: 'Lexend';
src: url('/fonts/lexend-variable.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Instrument Serif';
src: url('/fonts/instrument-serif-italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/jetbrains-mono.woff2') format('woff2');
font-weight: 100 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Atkinson Hyperlegible Next';
src: url('/fonts/atkinson-hyperlegible-next.woff2') format('woff2');
font-weight: 200 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'OpenDyslexic';
src: url('/fonts/opendyslexic.woff2') format('woff2');
font-weight: 400 700;
font-style: normal;
font-display: swap;
}
@import "tailwindcss";
/* 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 === */
@theme {
/* Surfaces — warm layered depth */
--color-bg: #0f0e0c;
--color-bg-elevated: #171614;
--color-bg-card: #1b1a17;
--color-bg-input: #151412;
--color-border: #2c2923;
--color-border-subtle: #221f1b;
/* Text — warm hierarchy */
--color-text: #f0ece4;
--color-text-secondary: #9a9486;
/* Phase 10a a11y P1: lifted from #716b60 to #8c8678 to clear AA 4.5:1 on
the dark surfaces this token is used on. Used as both decorative and
body-grade label, so the token has to meet AA. */
--color-text-tertiary: #8c8678;
/* Accent — warm amber/copper.
Phase 10a a11y P2: dark accent darkened from #e8a87c to #c98555 so the
"Browse Files" filled button (white text on accent fill) clears AA in
dark theme. Phase 10b: chroma bumped to #d68450 (same lightness band,
~OKLCH C 0.12 vs prior 0.08) so the brand reads warmer without losing
the AA Large headroom on filled buttons. Subtle / hover / glow
recomputed off the new base. */
--color-accent: #d68450;
--color-accent-hover: #c47340;
--color-accent-subtle: #d6845010;
--color-accent-glow: #d6845025;
/* Amber shadow scale. Hard-coded rgba shadows in components were tied to
the dark-theme accent and stayed off-hue in light mode. These tokens
theme-switch with the accent (overrides in :root[data-theme="light"]).
Shapes correspond to existing usage sites:
sm = subtle button/toggle glow (Toggle on-state)
md = lifted card / button drop (ModelDownloader card)
lg = hero glow under record button (DictationPage record)
glow = stronger 0.4-alpha emission (progress-bar glows)
pill = small selected-pill lift (SegmentedButton, pill rows)
raised = soft raised tile (FilesPage drop tile) */
--shadow-accent-sm: 0 0 8px rgba(214, 132, 80, 0.25);
--shadow-accent-md: 0 4px 16px rgba(214, 132, 80, 0.3);
--shadow-accent-lg: 0 4px 20px rgba(214, 132, 80, 0.3);
--shadow-accent-glow: 0 0 8px rgba(214, 132, 80, 0.4);
--shadow-accent-pill: 0 1px 4px rgba(214, 132, 80, 0.3);
--shadow-accent-raised: 0 2px 8px rgba(214, 132, 80, 0.2);
/* Semantic — Phase 10b: chroma bumped on all three so success/danger/
warning read as signals, not as cream-tinted body text. AA on bg-card
(#1b1a17) verified mentally for each. */
--color-success: #5fc28a;
--color-danger: #e85f5f;
/* v0.2 coherence-pass aliases. `caution` is the canonical name in the
new wrapper grammar; `warning` is kept as a CSS var() alias so
existing `text-warning` / `bg-warning` call sites resolve to the
same value without a touch. */
--color-caution: #e8be4a;
--color-warning: var(--color-caution);
--color-info: #7a9ec0;
/* v0.2: optional support token for sage/moss surfaces — empty-state
illustrations, environmental neutral status dots. NOT a brand
swap; --color-accent (amber/copper) stays primary. */
--color-accent-environment: #8fae9a;
/* Overlays — used by modal scrims. Derived from --color-bg #0f0e0c at
0.7 alpha so the dim sits on the brand neutral, not pure black. */
--color-overlay-dim: rgba(15, 14, 12, 0.7);
/* Layout */
--color-sidebar: #13120f;
--color-nav-active: #201e1a;
--color-hover: #1e1c18;
/* === Layout Tokens === */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--title-size: 26px;
/* Typography */
--font-family-body: 'Lexend', system-ui, sans-serif;
--font-family-display: 'Instrument Serif', serif;
--font-family-mono: 'JetBrains Mono', monospace;
--font-size-body: 16px;
--line-height-body: 1.5;
--letter-spacing-body: 0;
/* Motion */
--duration-ui: 150ms;
--duration-decorative: 300ms;
/* Window resize hit zones — consumed by ResizeHandles.svelte. One source
of truth so every Lumotia window feels identical. */
--lumotia-resize-edge: 12px;
--lumotia-resize-corner: 20px;
}
/* === Button Component Classes === */
.btn-sm { @apply px-2.5 py-1 text-[11px]; }
.btn-md { @apply px-3 py-1.5 text-[12px]; }
.btn-lg { @apply px-4 py-2 text-[13px]; }
/* === Filled-button text colour — a11y CA-1/CA-2 fix ===
Dark mode: bg-page (#0f0e0c) on accent (#d68450) = 6.68:1 PASS
bg-page (#0f0e0c) on danger (#e85f5f) = 5.73:1 PASS
Light mode: white on accent (#a3683a) = 4.57:1 PASS
white on danger (#b32626) = 6.52:1 PASS
Use .btn-filled-text in place of text-white on bg-accent / bg-danger buttons. */
.btn-filled-text { color: var(--color-bg); }
:root[data-theme="light"] .btn-filled-text { color: #ffffff; }
/* === Light theme overrides === */
:root[data-theme="light"] {
--color-bg: #faf8f5;
--color-bg-elevated: #f3f0eb;
--color-bg-card: #ffffff;
--color-bg-input: #f0ede8;
--color-border: #ddd8d0;
--color-border-subtle: #e6e2da;
--color-text: #1a1816;
--color-text-secondary: #5c574d;
/* Phase 10a a11y P1: darkened from #8a8578 to #6b6557 to clear AA 4.5:1
on cream/card surfaces in light theme. */
--color-text-tertiary: #6b6557;
/* Phase 10a a11y P2: darkened from #b87a4a to #a06a3e so white text on
accent fill (selected pills, primary buttons) clears AA. Phase 10b:
chroma bumped to #a3683a — same lightness band, more saturation. AA
on cream surfaces re-verified. */
--color-accent: #a3683a;
--color-accent-hover: #8a542b;
--color-accent-subtle: #a3683a10;
--color-accent-glow: #a3683a20;
/* Light-theme amber shadows. Mirror the dark-theme scale but use the
light-theme accent rgba so glows stay on-hue in cream surfaces. */
--shadow-accent-sm: 0 0 8px rgba(163, 104, 58, 0.25);
--shadow-accent-md: 0 4px 16px rgba(163, 104, 58, 0.3);
--shadow-accent-lg: 0 4px 20px rgba(163, 104, 58, 0.3);
--shadow-accent-glow: 0 0 8px rgba(163, 104, 58, 0.4);
--shadow-accent-pill: 0 1px 4px rgba(163, 104, 58, 0.3);
--shadow-accent-raised: 0 2px 8px rgba(163, 104, 58, 0.2);
/* Phase 10a a11y G3: darkened success from #3d8a5a to #2f7549 so success
text on light surfaces clears AA. Phase 10a a11y FR3 / D4: darkened
danger from #c44d4d to #a83838 for the same reason. Phase 10b: chroma
bumped on all three, lightness preserved or slightly darkened to keep
AA on cream backgrounds. */
--color-success: #1f7344;
--color-danger: #b32626;
/* v0.2 coherence-pass: --color-warning inherits @theme's
`var(--color-caution)`, so light theme only needs to redefine the
source token. */
--color-caution: #a08a1f;
--color-info: #3d6a8a;
--color-accent-environment: #4a7058;
--color-sidebar: #f5f2ed;
--color-nav-active: #eae6e0;
--color-hover: #ede9e3;
/* Warm dim for modal scrims in light theme. Lower alpha so the cream
surface still reads through softly. */
--color-overlay-dim: rgba(26, 24, 22, 0.55);
}
:root[data-theme="light"] ::-webkit-scrollbar-thumb {
background: #d0ccc4;
}
:root[data-theme="light"] ::-webkit-scrollbar-thumb:hover {
background: #b8b3aa;
}
/* === Sensory Zones — Dark Theme === */
/* Zones tint surfaces only. Accent tokens (amber) stay constant across all zones —
the brand colour is the constant, the environment is the variable.
Phase 10b: zone tints pushed further apart so each zone feels emotionally
distinct (cool blue-grey / warm red-orange / cool green) rather than three
nearly-identical near-blacks. AA verified for body text (--color-text
#f0ece4) on every Card surface. */
:root[data-zone="cave"] {
--color-bg: #0a1218;
--color-bg-elevated: #0f1a22;
--color-bg-card: #14222b;
--color-sidebar: #080f14;
}
:root[data-zone="energy"] {
--color-bg: #160e09;
--color-bg-elevated: #20140e;
--color-bg-card: #261913;
--color-sidebar: #100806;
}
:root[data-zone="reset"] {
--color-bg: #0c130c;
--color-bg-elevated: #111a11;
--color-bg-card: #161f15;
--color-sidebar: #0a0e0a;
}
/* === Sensory Zones — Light Theme === */
/* Phase 10b: light zones likewise pushed further from default cream so the
environment shift is felt, not merely intuited. AA verified for body text
(--color-text #1a1816) on every Card surface. */
:root[data-theme="light"][data-zone="cave"] {
--color-bg: #eef4f6;
--color-bg-elevated: #e2eef0;
--color-bg-card: #f5fafc;
--color-sidebar: #e8f0f2;
}
:root[data-theme="light"][data-zone="energy"] {
--color-bg: #fdf3eb;
--color-bg-elevated: #f8e8d8;
--color-bg-card: #fff8ef;
--color-sidebar: #f5e8d4;
}
:root[data-theme="light"][data-zone="reset"] {
--color-bg: #eff6ed;
--color-bg-elevated: #e4f0e0;
--color-bg-card: #f7fcf5;
--color-sidebar: #e8f3e4;
}
/* === Base === */
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family-body);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
letter-spacing: var(--letter-spacing-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* Selection allowed on content by default. Users need to copy error
strings, model paths, profile names, transcripts, etc. Chrome
elements opt out below. */
user-select: text;
}
/* Chrome elements opt out of selection so a click on a button label
doesn't drag-select. data-no-select is an escape hatch for app
surfaces (titlebar, drag handles) that should never select. */
button,
summary,
[role="button"],
[role="switch"],
[role="tab"],
[role="menuitem"],
[data-no-select] {
user-select: none;
}
/* === Display font === */
.font-display {
font-family: var(--font-family-display);
}
/* === Transcript font — optimised for reading === */
.font-transcript {
font-family: var(--font-family-body);
font-size: var(--text-transcript, 16px);
line-height: 1.85;
letter-spacing: 0.01em;
font-feature-settings: "liga" 1, "kern" 1;
}
/* === Grain texture overlay === */
.grain::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.025;
background-image: url('/textures/grain.png');
background-repeat: repeat;
background-size: 200px 200px;
z-index: 50;
}
:root[data-theme="light"] .grain::after {
opacity: 0.015;
}
/* === Scrollbar — thin, appears on hover === */
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #2c2923;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: #3d3830;
}
/* === Smooth transitions (global) === */
* {
transition-property: background-color, border-color, color, opacity, box-shadow;
transition-duration: var(--duration-ui);
transition-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Opt out for inputs */
textarea, input, [data-no-transition] {
transition: none;
}
/* === Focus ring — consistent, accessible ===
Low-specificity `:where()` wrapper lets component-level overrides win.
Covers all standard interactive elements + anything with a non-negative
tabindex. Uses `:focus-visible` (not `:focus`) so the ring only appears
for keyboard navigation, not mouse clicks. */
:where(button, a, input, textarea, select, summary, [tabindex]:not([tabindex="-1"])):focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: 4px;
}
/* Fallback: anything else that gets :focus-visible still gets a ring */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
border-radius: var(--radius-md);
}
/* Suppress transitions on focus-visible for reduced-motion users */
@media (prefers-reduced-motion: reduce) {
:focus-visible {
transition: none;
}
}
/* === Selection colour === */
::selection {
background: var(--color-accent-glow);
color: var(--color-text);
}
/* === Animations === */
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-soft {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
@keyframes pulse-warm {
0%, 100% { box-shadow: 0 0 24px rgba(232, 168, 124, 0.25), 0 0 48px rgba(232, 168, 124, 0.08); }
50% { box-shadow: 0 0 32px rgba(232, 168, 124, 0.4), 0 0 64px rgba(232, 168, 124, 0.15); }
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.04); }
}
.animate-fade-in {
animation: fade-in var(--duration-decorative) ease-out;
}
.animate-pulse-soft {
animation: pulse-soft 1.5s ease-in-out infinite;
}
.animate-pulse-warm {
animation: pulse-warm 2s ease-in-out infinite;
}
.animate-slide-up {
animation: slide-up var(--duration-decorative) ease-out;
}
.animate-breathe {
animation: breathe 3s ease-in-out infinite;
}
/* === Task sidebar slide === */
@keyframes slide-in-right {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-out-right {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* Task extraction highlight */
@keyframes highlight-warm {
0% { border-left-color: var(--color-accent); }
100% { border-left-color: transparent; }
}
/* Checkbox pop */
@keyframes scale-pop {
0% { transform: scale(0.95); }
100% { transform: scale(1); }
}
.animate-slide-in-right {
animation: slide-in-right var(--duration-decorative) ease-out;
}
.animate-highlight-warm {
border-left: 3px solid transparent;
animation: highlight-warm 2s ease-out;
}
.animate-scale-pop {
animation: scale-pop 50ms ease-out;
}
/* Window focus glow */
@keyframes glow-pulse {
0% { box-shadow: 0 0 0 0 rgba(232, 168, 124, 0); }
30% { box-shadow: 0 0 20px 4px rgba(232, 168, 124, 0.35), inset 0 0 12px rgba(232, 168, 124, 0.08); }
100% { box-shadow: 0 0 0 0 rgba(232, 168, 124, 0); }
}
.animate-glow-pulse {
animation: glow-pulse 700ms ease-out;
}
:root[data-theme="light"] .animate-glow-pulse {
animation: glow-pulse-light 700ms ease-out;
}
@keyframes glow-pulse-light {
0% { box-shadow: 0 0 0 0 rgba(184, 122, 74, 0); }
30% { box-shadow: 0 0 20px 4px rgba(184, 122, 74, 0.3), inset 0 0 12px rgba(184, 122, 74, 0.06); }
100% { box-shadow: 0 0 0 0 rgba(184, 122, 74, 0); }
}
/* Float window entrance */
@keyframes float-enter {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
.animate-float-enter {
animation: float-enter var(--duration-decorative) ease-out;
}
/* === Custom titlebar buttons === */
.titlebar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 32px;
color: var(--color-text-secondary);
}
/* Sinhala character spin (decorative, during recording) */
@keyframes sinhala-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-sinhala-spin {
animation: sinhala-spin 2s linear infinite;
}
/* === Reduced Motion === */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* === Data-attribute reduce-motion support === */
:root[data-reduce-motion="true"],
:root[data-reduce-motion="true"] *,
:root[data-reduce-motion="true"] *::before,
:root[data-reduce-motion="true"] *::after {
--duration-ui: 0ms;
--duration-decorative: 0ms;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* === Window resize handles (see ResizeHandles.svelte) ===
Lives in the global sheet so @tailwindcss/vite does not try to
parse it as a per-component virtual CSS module, which triggers an
"Invalid declaration" error on plain CSS inputs in some Tailwind v4
+ Svelte 5 combinations. */
.lumotia-rh {
position: fixed;
z-index: 2147483646;
background: transparent;
pointer-events: auto;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.lumotia-rh-nw,
.lumotia-rh-ne,
.lumotia-rh-sw,
.lumotia-rh-se {
z-index: 2147483647;
}
.lumotia-rh-n {
top: 0;
left: var(--lumotia-resize-corner);
right: var(--lumotia-resize-corner);
height: var(--lumotia-resize-edge);
cursor: n-resize;
}
.lumotia-rh-s {
bottom: 0;
left: var(--lumotia-resize-corner);
right: var(--lumotia-resize-corner);
height: var(--lumotia-resize-edge);
cursor: s-resize;
}
.lumotia-rh-w {
top: var(--lumotia-resize-corner);
bottom: var(--lumotia-resize-corner);
left: 0;
width: var(--lumotia-resize-edge);
cursor: w-resize;
}
.lumotia-rh-e {
top: var(--lumotia-resize-corner);
bottom: var(--lumotia-resize-corner);
right: 0;
width: var(--lumotia-resize-edge);
cursor: e-resize;
}
.lumotia-rh-nw {
top: 0;
left: 0;
width: var(--lumotia-resize-corner);
height: var(--lumotia-resize-corner);
cursor: nw-resize;
}
.lumotia-rh-ne {
top: 0;
right: 0;
width: var(--lumotia-resize-corner);
height: var(--lumotia-resize-corner);
cursor: ne-resize;
}
.lumotia-rh-sw {
bottom: 0;
left: 0;
width: var(--lumotia-resize-corner);
height: var(--lumotia-resize-corner);
cursor: sw-resize;
}
.lumotia-rh-se {
bottom: 0;
right: 0;
width: var(--lumotia-resize-corner);
height: var(--lumotia-resize-corner);
cursor: se-resize;
}