feat(appearance): token-driven theme engine with schemes and FOUC-safe boot

applyAppearance is the single writer of styling to the DOM (the .dark class plus data-scheme/-no-transparency/-density/-reduce-motion and the custom-accent/type-scale CSS vars). An external pre-paint /theme-boot.js replays a cached snapshot before first paint and complies with the production CSP (script-src 'self'), fixing the long-standing theme FOUC. Adds seven color schemes (incl. a true high-contrast that raises neutral contrast), a custom accent with auto-derived legible text, an extended token layer (accent variants, status/shadow/overlay/inverse), a scheme-gated legacy accent bridge, and a transparency-off layer. The default scheme sets no attributes, so existing users are unaffected.
This commit is contained in:
Maurice
2026-06-29 10:59:39 +02:00
parent 22aa28ee46
commit ad0da3d6c8
9 changed files with 568 additions and 20 deletions
+4
View File
@@ -5,6 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>TREK</title>
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs
before first paint AND complies with the prod CSP (script-src 'self'). -->
<script src="/theme-boot.js"></script>
<!-- PWA / iOS -->
<meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
+59
View File
@@ -0,0 +1,59 @@
/*
* Pre-paint appearance boot — kills the flash of default/wrong theme (FOUC).
*
* Loaded as an external, render-blocking CLASSIC script in <head> (NOT a module)
* so it runs before first paint AND complies with the production CSP
* (script-src 'self'; inline scripts are blocked). It reads the compact snapshot
* written by client/src/theme/applyAppearance.ts and applies it verbatim. Keep
* this in sync with that module's snapshot shape + apply logic.
*
* It must never throw — any failure silently falls back to the default look.
*/
(function () {
try {
var raw = localStorage.getItem('trek_appearance');
if (!raw) return;
var s = JSON.parse(raw);
if (!s || s.v !== 1) return;
var root = document.documentElement;
var path = location.pathname;
var isShared = path.indexOf('/shared/') === 0 || path.indexOf('/public/') === 0;
var dark;
if (isShared) dark = false;
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
else dark = s.darkMode === true || s.darkMode === 'dark';
root.classList.toggle('dark', dark);
var scheme = isShared ? 'default' : s.scheme;
if (scheme && scheme !== 'default') root.setAttribute('data-scheme', scheme);
if (!isShared && s.noTransparency) root.setAttribute('data-no-transparency', '');
if (s.density === 'compact') root.setAttribute('data-density', 'compact');
if (s.reduceMotion) root.setAttribute('data-reduce-motion', '');
if (!isShared && scheme === 'custom' && s.accent) {
root.style.setProperty('--accent-custom-light', s.accent.light);
root.style.setProperty('--accent-custom-dark', s.accent.dark);
if (s.accentText) {
root.style.setProperty('--accent-custom-text-light', s.accentText.light);
root.style.setProperty('--accent-custom-text-dark', s.accentText.dark);
}
}
var ts = s.typeScale || {};
setScale('--fs-scale-title', ts.title);
setScale('--fs-scale-subtitle', ts.subtitle);
setScale('--fs-scale-body', ts.body);
setScale('--fs-scale-caption', ts.caption);
if (typeof s.fontScale === 'number' && s.fontScale !== 1) {
root.style.fontSize = s.fontScale * 100 + '%';
}
function setScale(name, v) {
if (typeof v === 'number' && v !== 1) root.style.setProperty(name, String(v));
}
} catch (e) {
/* never block boot */
}
})();
+12 -20
View File
@@ -2,6 +2,7 @@ import React, { useEffect, ReactNode } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { applyAppearance } from './theme/applyAppearance'
import { useAddonStore } from './store/addonStore'
import LoginPage from './pages/LoginPage'
import ForgotPasswordPage from './pages/ForgotPasswordPage'
@@ -175,30 +176,21 @@ export default function App() {
const isSharedPage = location.pathname.startsWith('/shared/')
useEffect(() => {
// Shared page always forces light mode
if (isSharedPage) {
document.documentElement.classList.remove('dark')
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', '#ffffff')
return
}
const mode = settings.dark_mode
const applyDark = (isDark: boolean) => {
document.documentElement.classList.toggle('dark', isDark)
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
}
if (mode === 'auto') {
const run = () =>
applyAppearance({
darkMode: settings.dark_mode,
appearance: settings.appearance,
isSharedPage,
})
run()
// Re-resolve on OS theme change while in auto mode.
if (!isSharedPage && settings.dark_mode === 'auto') {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
applyDark(mq.matches)
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
const handler = () => run()
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}
applyDark(mode === true || mode === 'dark')
}, [settings.dark_mode, isSharedPage])
}, [settings.dark_mode, settings.appearance, isSharedPage])
const isAuthPage = location.pathname.startsWith('/login')
|| location.pathname.startsWith('/register')
+207
View File
@@ -490,6 +490,35 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
--scrollbar-thumb: #d1d5db;
--scrollbar-hover: #9ca3af;
/* ── Extended semantic tokens (appearance platform) ──────────────
Accent variants + status/shadow/overlay/inverse + per-tier type scales.
Additive: nothing consumes these until components migrate to them, so
defining them is visually inert. Colored schemes override --accent* via
[data-scheme]; status/shadow/inverse stay scheme-independent. */
--accent-on: var(--accent); /* accent as text/icon/border on a surface */
--accent-hover: #1f2937; /* hover state of an accent fill */
--accent-subtle: #f1f5f9; /* faint accent-tinted surface (chips, selected) */
--fs-scale-title: 1;
--fs-scale-subtitle: 1;
--fs-scale-body: 1;
--fs-scale-caption: 1;
--success: #16a34a; --success-soft: #dcfce7;
--danger: #dc2626; --danger-soft: #fef2f2;
--warning: #d97706; --warning-soft: #fffbeb;
--info: #2563eb; --info-soft: #eff6ff;
--bg-inverse: #111827; --text-inverse: #ffffff;
--overlay: rgba(0, 0, 0, 0.5);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12);
--shadow-modal: 0 16px 48px rgba(0, 0, 0, 0.20);
--shadow-dropdown: 0 8px 24px rgba(0, 0, 0, 0.12);
--shadow-popover: 0 8px 32px rgba(0, 0, 0, 0.18);
/* Journey design tokens */
--journal-bg: #FAFAFA;
--journal-card: #FFFFFF;
@@ -538,6 +567,26 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
--scrollbar-thumb: #3f3f46;
--scrollbar-hover: #52525b;
/* Extended semantic tokens (dark) */
--accent-on: var(--accent);
--accent-hover: #d4d4d8;
--accent-subtle: rgba(255, 255, 255, 0.08);
--success: #22c55e; --success-soft: rgba(34, 197, 94, 0.15);
--danger: #ef4444; --danger-soft: rgba(239, 68, 68, 0.15);
--warning: #f59e0b; --warning-soft: rgba(245, 158, 11, 0.15);
--info: #3b82f6; --info-soft: rgba(59, 130, 246, 0.15);
--bg-inverse: #e4e4e7; --text-inverse: #09090b;
--overlay: rgba(0, 0, 0, 0.6);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.5);
--shadow-modal: 0 16px 48px rgba(0, 0, 0, 0.6);
--shadow-dropdown: 0 8px 24px rgba(0, 0, 0, 0.5);
--shadow-popover: 0 8px 32px rgba(0, 0, 0, 0.55);
/* Journey design tokens (dark) */
--journal-bg: #09090B;
--journal-card: #18181B;
@@ -553,6 +602,164 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
--mood-rough: #a9a3f0;
}
/* ── Color schemes (data-scheme) ──────────────────────────────────
Each non-default scheme overrides the accent family (High-Contrast also
raises neutral text/border contrast and solidifies surfaces). Light and dark
are tuned independently. The 'default' scheme sets NO attribute, so the
monochrome accent above is untouched — existing users see no change. */
:root[data-scheme="indigo"] {
--accent: #4f46e5; --accent-text: #ffffff; --accent-on: #4338ca;
--accent-hover: #4338ca; --accent-subtle: #eef2ff;
}
.dark[data-scheme="indigo"] {
--accent: #6366f1; --accent-text: #ffffff; --accent-on: #a5b4fc;
--accent-hover: #4f46e5; --accent-subtle: rgba(99, 102, 241, 0.18);
}
:root[data-scheme="teal"] {
--accent: #0d9488; --accent-text: #ffffff; --accent-on: #0f766e;
--accent-hover: #0f766e; --accent-subtle: #f0fdfa;
}
.dark[data-scheme="teal"] {
--accent: #14b8a6; --accent-text: #04211f; --accent-on: #5eead4;
--accent-hover: #0d9488; --accent-subtle: rgba(20, 184, 166, 0.18);
}
:root[data-scheme="rose"] {
--accent: #e11d48; --accent-text: #ffffff; --accent-on: #be123c;
--accent-hover: #be123c; --accent-subtle: #fff1f2;
}
.dark[data-scheme="rose"] {
--accent: #f43f5e; --accent-text: #ffffff; --accent-on: #fda4af;
--accent-hover: #e11d48; --accent-subtle: rgba(244, 63, 94, 0.18);
}
:root[data-scheme="amber"] {
--accent: #d97706; --accent-text: #ffffff; --accent-on: #b45309;
--accent-hover: #b45309; --accent-subtle: #fffbeb;
}
.dark[data-scheme="amber"] {
--accent: #f59e0b; --accent-text: #1c1917; --accent-on: #fcd34d;
--accent-hover: #d97706; --accent-subtle: rgba(245, 158, 11, 0.18);
}
:root[data-scheme="violet"] {
--accent: #7c3aed; --accent-text: #ffffff; --accent-on: #6d28d9;
--accent-hover: #6d28d9; --accent-subtle: #f5f3ff;
}
.dark[data-scheme="violet"] {
--accent: #8b5cf6; --accent-text: #ffffff; --accent-on: #c4b5fd;
--accent-hover: #7c3aed; --accent-subtle: rgba(139, 92, 246, 0.18);
}
/* High-Contrast — raises neutral text/border contrast and solidifies surfaces,
directly answering #951 / #1025 (grey-on-grey, transparency hurting legibility). */
:root[data-scheme="highContrast"] {
--accent: #1d4ed8; --accent-text: #ffffff; --accent-on: #1d4ed8;
--accent-hover: #1e40af; --accent-subtle: #dbeafe;
--text-primary: #000000; --text-secondary: #1f2937;
--text-muted: #374151; --text-faint: #4b5563;
--border-primary: #475569; --border-secondary: #94a3b8;
--border-faint: rgba(0, 0, 0, 0.35);
--bg-elevated: #ffffff; --bg-hover: #e2e8f0; --bg-selected: #cbd5e1;
--sidebar-bg: #ffffff; --tooltip-bg: #ffffff;
}
.dark[data-scheme="highContrast"] {
--accent: #60a5fa; --accent-text: #06182f; --accent-on: #93c5fd;
--accent-hover: #3b82f6; --accent-subtle: rgba(96, 165, 250, 0.2);
--text-primary: #ffffff; --text-secondary: #f1f5f9;
--text-muted: #e2e8f0; --text-faint: #cbd5e1;
--border-primary: #64748b; --border-secondary: #475569;
--border-faint: rgba(255, 255, 255, 0.35);
--bg-elevated: #131316; --bg-hover: #2a2a30; --bg-selected: #3a3a42;
--sidebar-bg: #131316; --tooltip-bg: #131316;
}
/* Custom — the user's picked accent (applyAppearance writes the inline vars;
color-mix derives hover/subtle so a single picked color yields a full set). */
:root[data-scheme="custom"] {
--accent: var(--accent-custom-light, var(--accent));
--accent-text: var(--accent-custom-text-light, #ffffff);
--accent-on: var(--accent-custom-light, var(--accent));
--accent-hover: color-mix(in srgb, var(--accent-custom-light, #4f46e5) 86%, #000);
--accent-subtle: color-mix(in srgb, var(--accent-custom-light, #4f46e5) 12%, transparent);
}
.dark[data-scheme="custom"] {
--accent: var(--accent-custom-dark, var(--accent));
--accent-text: var(--accent-custom-text-dark, #ffffff);
--accent-on: var(--accent-custom-dark, var(--accent));
--accent-hover: color-mix(in srgb, var(--accent-custom-dark, #6366f1) 84%, #fff);
--accent-subtle: color-mix(in srgb, var(--accent-custom-dark, #6366f1) 20%, transparent);
}
/* ── Legacy accent bridge (gated) ─────────────────────────────────
Under a NON-default scheme ONLY, redirect the hardcoded "primary/black"
action surfaces (and raw indigo accents) to the chosen accent so un-migrated
components follow the scheme without editing ~50 files. These selectors carry
higher specificity than the legacy dark shim, so they win under a scheme; on
the default scheme nothing here matches (zero change for existing users).
TEMPORARY scaffold — the Phase-8 codemod migrates these to bg-accent and this
block shrinks file-by-file until it is deleted. */
html[data-scheme]:not([data-scheme="default"]) :is(.bg-slate-900, .bg-gray-900, .bg-zinc-900, .bg-neutral-900, .bg-black) {
background-color: var(--accent) !important;
color: var(--accent-text) !important;
}
html[data-scheme]:not([data-scheme="default"]) :is(.bg-slate-900, .bg-gray-900, .bg-zinc-900, .bg-neutral-900, .bg-black):hover {
background-color: var(--accent-hover) !important;
}
html[data-scheme]:not([data-scheme="default"])
:is(.hover\:bg-slate-900, .hover\:bg-slate-800, .hover\:bg-slate-700, .hover\:bg-gray-900, .hover\:bg-gray-800, .hover\:bg-black):hover {
background-color: var(--accent-hover) !important;
color: var(--accent-text) !important;
}
html[data-scheme]:not([data-scheme="default"]) :is(.bg-indigo-500, .bg-indigo-600, .bg-indigo-700) {
background-color: var(--accent) !important;
color: var(--accent-text) !important;
}
html[data-scheme]:not([data-scheme="default"]) :is(.text-indigo-400, .text-indigo-500, .text-indigo-600, .text-indigo-700) {
color: var(--accent-on) !important;
}
html[data-scheme]:not([data-scheme="default"])
:is(.border-indigo-500, .border-indigo-600, .ring-indigo-500, .ring-indigo-600) {
border-color: var(--accent) !important;
}
/* ── Transparency off (data-no-transparency) ──────────────────────
Solidify the alpha-bearing surface tokens, flatten the dashboard liquid-glass,
and disable backdrop blur app-wide so content over the map is fully opaque
(#1025). Inert unless the attribute is set. Intentional dimming scrims (modal
backdrops) stay translucent on purpose — forcing them opaque blacks out the
screen. */
html[data-no-transparency] {
--bg-elevated: #fafafa;
--bg-hover: #f1f3f5;
--bg-selected: #e2e8f0;
--border-faint: #e5e7eb;
--sidebar-bg: #ffffff;
--tooltip-bg: #ffffff;
}
html.dark[data-no-transparency] {
--bg-elevated: #131316;
--bg-hover: #26262c;
--bg-selected: #2f2f37;
--border-faint: #27272a;
--sidebar-bg: #131316;
--tooltip-bg: #131316;
}
html[data-no-transparency] .trek-dash {
--glass-bg: var(--surface, var(--bg-card));
--glass-border: var(--line, var(--border-primary));
--glass-blur: none;
--glass-highlight: none;
}
html[data-no-transparency] *,
html[data-no-transparency] *::before,
html[data-no-transparency] *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
body {
font-family: var(--font-system);
background-color: var(--bg-primary);
+4
View File
@@ -9,6 +9,7 @@ import { reopenForUser, deleteCurrentUserDb } from '../db/offlineDb'
import { setAuthed } from '../sync/authGate'
import { unregisterSyncTriggers } from '../sync/syncTriggers'
import { useSystemNoticeStore } from './systemNoticeStore.js'
import { clearAppearanceSnapshot } from '../theme/applyAppearance'
interface AuthResponse {
user: User
@@ -191,6 +192,9 @@ export const useAuthStore = create<AuthState>()(
// 3. Tear down the live connection.
disconnect()
useSystemNoticeStore.getState().reset()
// Drop the per-device appearance snapshot so the next user on a shared
// browser doesn't get a pre-paint flash of this user's theme.
clearAppearanceSnapshot()
// 4. Tell server to clear the httpOnly cookie (best-effort).
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
// 5. Clear service worker caches containing sensitive data.
+61
View File
@@ -0,0 +1,61 @@
# Appearance & theming
TREK's look is driven by **design tokens** (CSS custom properties) so a single
per-user config can re-skin the whole app — color scheme, accent, transparency,
text size, density — without touching component code. New pages get this for
free **if they follow the contract below**.
## How it works
- The per-user config is one validated blob (`AppearanceConfig` in
`@trek/shared`), stored under the `appearance` settings key.
- `applyAppearance()` (`src/theme/applyAppearance.ts`) is the **only** code that
writes styling to the DOM: it toggles `.dark`, sets `data-scheme` /
`data-no-transparency` / `data-density` / `data-reduce-motion` on `<html>`,
and writes the custom-accent + type-scale CSS variables.
- A render-blocking `public/theme-boot.js` replays a cached snapshot before
first paint to avoid FOUC (external classic script — the prod CSP blocks
inline scripts).
- Schemes are data: swatch metadata in `src/theme/schemes.ts`, token values in
the `[data-scheme="…"]` blocks in `src/index.css`.
## Token taxonomy
| Purpose | Token / utility |
| --- | --- |
| Surfaces | `--bg-primary/secondary/tertiary/elevated/card/input/hover/selected` · `bg-surface*` |
| Text | `--text-primary/secondary/muted/faint` · `text-content*` |
| Borders | `--border-primary/secondary/faint` · `border-edge*` |
| Accent | `--accent` (fill) · `--accent-text` (on fill) · `--accent-on` (on surface) · `--accent-hover` · `--accent-subtle` · `bg-accent` / `text-accent-on` / `bg-accent-subtle` |
| Status | `--success/-soft`, `--danger/-soft`, `--warning/-soft`, `--info/-soft` · `bg-danger-soft text-danger` etc. |
| Elevation | `--shadow-sm/md/lg/card/elevated/modal/dropdown/popover` · `shadow-modal` etc. |
| Overlay / inverse | `--overlay` · `--bg-inverse` / `--text-inverse` · `bg-inverse text-inverse-text` |
| Type tiers | `text-title` / `text-subtitle` / `text-body` / `text-caption` (scale with the user's per-tier multipliers) |
## The contract (enforced by `npm run theme:lint`)
1. **Surfaces/text/borders** use the semantic utilities (`bg-surface*`,
`text-content*`, `border-edge*`) or `var(--token)` — never raw
`bg-slate-*` / `text-gray-*` / `bg-white`.
2. **The primary/"black" action look** uses `bg-accent` + `text-accent-text`
(+ `ring-accent`) — never `bg-slate-900` / `bg-black` / `bg-indigo-*`. This
is what lets a user's accent reach new surfaces automatically.
3. **Semantic text size** uses the tier utilities `text-title/subtitle/body/caption`
— never an inline `fontSize: <px>`, never raw `text-sm`/`text-xs` to *mean* a tier.
4. **Translucent/blurred** surfaces consume an alpha/glass token
(`--bg-elevated`, `--tooltip-bg`, `--glass-*`) or a `backdrop-blur` utility,
so transparency-off can neutralize them centrally — never inline `rgba()`
backgrounds or `backdrop-filter` in JSX.
5. **Components never read `AppearanceConfig` to compute styles.** Only
`applyAppearance` reads the config and writes tokens; components read tokens.
Dark state is read off the `.dark` class, never duplicated into React state.
6. **New appearance dimensions** are added to `AppearanceConfig` +
`applyAppearance` + `schemes.ts` — never a second applier or a parallel token
family. New feature palettes derive their accent from `var(--accent)`.
### Allowed to stay inline / literal
Genuinely dynamic values (data-driven colors like `cat.color`, computed
geometry/transforms/sizes), and the surfaces CSS variables can't reach: injected
map popup/marker HTML, Mapbox/MapLibre paint, and the standalone `@react-pdf`
documents. Mark intentional exceptions with a `theme-lint-disable` line comment.
+166
View File
@@ -0,0 +1,166 @@
import { normalizeAppearance, type AppearanceConfig } from '@trek/shared'
/**
* The ONE place that writes appearance state to the DOM.
*
* Components never compute colors from the config they only read CSS tokens.
* This writer toggles the `.dark` class (the single source of truth that 8+
* components observe), sets the `data-*` appearance attributes, and writes the
* handful of inline CSS variables that can't live in a static stylesheet
* (custom accent + type scales). It is idempotent and safe to call on every
* settings change.
*
* With DEFAULT_APPEARANCE every branch below is a no-op (default scheme sets no
* attribute, transparency stays on, all scales are 1), so the rendered result is
* byte-identical to TREK before this feature existed.
*
* Keep the snapshot shape and the apply logic in sync with the pre-paint boot
* script at client/public/theme-boot.js that script mirrors this to kill FOUC.
*/
export type DarkModeSetting = boolean | string // 'light' | 'dark' | 'auto' | boolean
export const APPEARANCE_SNAPSHOT_KEY = 'trek_appearance'
export interface ApplyAppearanceInput {
darkMode: DarkModeSetting
/** Raw appearance value from settings (may be partial/missing — normalized here). */
appearance?: unknown
/** Public /shared and /public pages force the neutral default look. */
isSharedPage?: boolean
}
interface AppearanceSnapshot {
v: 1
darkMode: DarkModeSetting
scheme: AppearanceConfig['schemeId']
noTransparency: boolean
density: AppearanceConfig['density']
reduceMotion: boolean
accent: AppearanceConfig['accent']
accentText: { light: string; dark: string } | null
typeScale: AppearanceConfig['typeScale']
fontScale: number
}
function resolveDark(darkMode: DarkModeSetting, isSharedPage: boolean): boolean {
if (isSharedPage) return false
if (darkMode === 'auto') {
return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
}
return darkMode === true || darkMode === 'dark'
}
/**
* Pick a legible text color (near-black or white) for a custom accent fill,
* using WCAG relative luminance. Keeps user-picked accents readable.
*/
function accentTextFor(hex: string): string {
const c = hex.replace('#', '')
const full = c.length === 3 ? c.split('').map((x) => x + x).join('') : c
const toLin = (v: number) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4))
const r = toLin(parseInt(full.slice(0, 2), 16) / 255)
const g = toLin(parseInt(full.slice(2, 4), 16) / 255)
const b = toLin(parseInt(full.slice(4, 6), 16) / 255)
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
return luminance > 0.45 ? '#111827' : '#ffffff'
}
function setScaleVar(root: HTMLElement, name: string, value: number): void {
if (value === 1) root.style.removeProperty(name)
else root.style.setProperty(name, String(value))
}
function writeSnapshot(snap: AppearanceSnapshot): void {
try {
localStorage.setItem(APPEARANCE_SNAPSHOT_KEY, JSON.stringify(snap))
} catch {
/* private mode / quota — non-fatal, we just lose the FOUC optimisation */
}
}
/** Clear the per-device snapshot (call on logout so the next user on a shared
* browser doesn't get a flash of the previous user's theme). */
export function clearAppearanceSnapshot(): void {
try {
localStorage.removeItem(APPEARANCE_SNAPSHOT_KEY)
} catch {
/* non-fatal */
}
}
export function applyAppearance(input: ApplyAppearanceInput): AppearanceConfig {
const cfg = normalizeAppearance(input.appearance)
const isShared = !!input.isSharedPage
// Public pages render the neutral default regardless of the viewer's account.
const eff: AppearanceConfig = isShared
? { ...cfg, schemeId: 'default', accent: null, transparency: true }
: cfg
const dark = resolveDark(input.darkMode, isShared)
const root = document.documentElement
root.classList.toggle('dark', dark)
// data-scheme — only for non-default schemes (default keeps the monochrome accent).
if (eff.schemeId === 'default') root.removeAttribute('data-scheme')
else root.setAttribute('data-scheme', eff.schemeId)
// transparency-off marker
if (eff.transparency) root.removeAttribute('data-no-transparency')
else root.setAttribute('data-no-transparency', '')
// density — only 'compact' deviates from today.
if (eff.density === 'compact') root.setAttribute('data-density', 'compact')
else root.removeAttribute('data-density')
// user reduce-motion override (layered over the OS prefers-reduced-motion rule)
if (eff.reduceMotion) root.setAttribute('data-reduce-motion', '')
else root.removeAttribute('data-reduce-motion')
// custom accent inline vars (+ auto-derived legible text) — only for 'custom'.
const accentText =
eff.schemeId === 'custom' && eff.accent
? { light: accentTextFor(eff.accent.light), dark: accentTextFor(eff.accent.dark) }
: null
if (eff.schemeId === 'custom' && eff.accent && accentText) {
root.style.setProperty('--accent-custom-light', eff.accent.light)
root.style.setProperty('--accent-custom-dark', eff.accent.dark)
root.style.setProperty('--accent-custom-text-light', accentText.light)
root.style.setProperty('--accent-custom-text-dark', accentText.dark)
} else {
root.style.removeProperty('--accent-custom-light')
root.style.removeProperty('--accent-custom-dark')
root.style.removeProperty('--accent-custom-text-light')
root.style.removeProperty('--accent-custom-text-dark')
}
// per-tier type scales (consumed by the text-title/subtitle/body/caption utilities)
setScaleVar(root, '--fs-scale-title', eff.typeScale.title)
setScaleVar(root, '--fs-scale-subtitle', eff.typeScale.subtitle)
setScaleVar(root, '--fs-scale-body', eff.typeScale.body)
setScaleVar(root, '--fs-scale-caption', eff.typeScale.caption)
// global font scale — coarse accessibility knob; root font-size drives all rem text.
if (eff.fontScale === 1) root.style.removeProperty('font-size')
else root.style.fontSize = `${eff.fontScale * 100}%`
// theme-color meta — unchanged from before (dark #09090b / light #ffffff).
const meta = document.querySelector('meta[name="theme-color"]')
if (meta) meta.setAttribute('content', dark ? '#09090b' : '#ffffff')
writeSnapshot({
v: 1,
darkMode: input.darkMode,
scheme: eff.schemeId,
noTransparency: !eff.transparency,
density: eff.density,
reduceMotion: eff.reduceMotion,
accent: eff.accent,
accentText,
typeScale: eff.typeScale,
fontScale: eff.fontScale,
})
return cfg
}
+29
View File
@@ -0,0 +1,29 @@
import type { AppearanceSchemeId } from '@trek/shared'
/**
* Color-scheme registry for the appearance picker. The actual token values live
* in CSS ([data-scheme] blocks in index.css) this only carries the metadata
* the settings UI needs: a representative accent swatch per mode for the picker
* dot. Labels come from i18n (settings.appearance.scheme.<id>).
*/
export interface SchemeSwatch {
id: Exclude<AppearanceSchemeId, 'custom'>
/** Representative accent color shown in the picker, per color mode. */
swatch: { light: string; dark: string }
}
export const APPEARANCE_SCHEMES: SchemeSwatch[] = [
{ id: 'default', swatch: { light: '#111827', dark: '#e4e4e7' } },
{ id: 'highContrast', swatch: { light: '#1d4ed8', dark: '#60a5fa' } },
{ id: 'indigo', swatch: { light: '#4f46e5', dark: '#6366f1' } },
{ id: 'teal', swatch: { light: '#0d9488', dark: '#14b8a6' } },
{ id: 'rose', swatch: { light: '#e11d48', dark: '#f43f5e' } },
{ id: 'amber', swatch: { light: '#d97706', dark: '#f59e0b' } },
{ id: 'violet', swatch: { light: '#7c3aed', dark: '#8b5cf6' } },
]
/** Sensible starting points when a user first opens the custom-accent picker. */
export const CUSTOM_ACCENT_PRESETS: string[] = [
'#4f46e5', '#0d9488', '#e11d48', '#d97706', '#7c3aed',
'#2563eb', '#db2777', '#059669', '#ea580c', '#0891b2',
]
+26
View File
@@ -58,12 +58,38 @@ export default {
accent: {
DEFAULT: 'var(--accent)',
text: 'var(--accent-text)',
on: 'var(--accent-on)',
hover: 'var(--accent-hover)',
subtle: 'var(--accent-subtle)',
},
// Semantic status colors (+ soft tinted background variant).
success: { DEFAULT: 'var(--success)', soft: 'var(--success-soft)' },
danger: { DEFAULT: 'var(--danger)', soft: 'var(--danger-soft)' },
warning: { DEFAULT: 'var(--warning)', soft: 'var(--warning-soft)' },
info: { DEFAULT: 'var(--info)', soft: 'var(--info-soft)' },
// Inverse surface (the near-black/near-white "pill" header pattern).
inverse: { DEFAULT: 'var(--bg-inverse)', text: 'var(--text-inverse)' },
},
boxShadow: {
'day-column': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
'place-card': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'drag-overlay': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
// Token-backed elevation (scheme/dark aware) — for migrating inline rgba shadows.
'card': 'var(--shadow-card)',
'elevated': 'var(--shadow-elevated)',
'modal': 'var(--shadow-modal)',
'dropdown': 'var(--shadow-dropdown)',
'popover': 'var(--shadow-popover)',
},
// Semantic type tiers — each scales with its own user multiplier (defaults
// to 1). Use text-title/subtitle/body/caption for headings/labels so the
// appearance "text size" control reaches them; the global fontScale (root
// font-size) additionally scales all rem-based text.
fontSize: {
title: ['calc(1.5rem * var(--fs-scale-title, 1))', '1.2'],
subtitle: ['calc(1.125rem * var(--fs-scale-subtitle, 1))', '1.35'],
body: ['calc(0.875rem * var(--fs-scale-body, 1))', '1.5'],
caption: ['calc(0.75rem * var(--fs-scale-caption, 1))', '1.4'],
},
},
},