mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-30 18:46:00 +00:00
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:
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user