From 19f989301131b92dd668a1482edb9d5f741b25bc Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sun, 12 Jul 2026 10:05:52 -0400 Subject: [PATCH 01/18] feat(theme): add accent/surface theme stores + before-render applier --- app/src/main.tsx | 4 ++ app/src/state/stores/settings/theme.ts | 65 ++++++++++++++++++++++++++ app/tests/state/settings/theme.test.ts | 53 +++++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 app/src/state/stores/settings/theme.ts create mode 100644 app/tests/state/settings/theme.test.ts diff --git a/app/src/main.tsx b/app/src/main.tsx index 4ed12df4..88b05ef2 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -4,6 +4,10 @@ import { render } from 'preact'; import './styles/index.css'; +// Applies the persisted accent/surface theme to before the first +// render (no flash). persistedSignal hydrates synchronously, so the module's +// effect sets data-cc-* before Preact mounts. +import '@/state/stores/settings/theme'; import { App } from '@/layout/App/App'; const mount = document.getElementById('app'); diff --git a/app/src/state/stores/settings/theme.ts b/app/src/state/stores/settings/theme.ts new file mode 100644 index 00000000..2e031624 --- /dev/null +++ b/app/src/state/stores/settings/theme.ts @@ -0,0 +1,65 @@ +// state/stores/settings/theme.ts — Interface theme: accent + surface presets. +// Two persisted choices applied by overriding root custom properties via +// data-cc-accent / data-cc-surface on ; themes.css holds the per-preset +// token blocks and every color-mix variant in tokens.css cascades from them. +// Mirrors syntaxTheme.ts: plain persistedSignal + autosave (no draft/Save +// step). The applier is a module-scope effect (not a component) so it runs +// before the first render — main.tsx imports this before render() and +// persistedSignal hydrates synchronously, so the first paint is already themed. + +import { effect } from '@preact/signals'; +import { persistedSignal } from '@/state/persist'; +import { markSettingStore, markAutosave } from '@/state/settingsSchema'; + +export interface ThemePresetOption { + value: string; + label: string; +} + +// Accent presets — plain color names. `blue` is the default and has NO block in +// themes.css: absent data-cc-accent, tokens.css stands as its single source. +export const ACCENT_PRESETS: ThemePresetOption[] = [ + { value: 'blue', label: 'Blue' }, + { value: 'cyan', label: 'Cyan' }, + { value: 'purple', label: 'Purple' }, + { value: 'green', label: 'Green' }, + { value: 'pink', label: 'Pink' }, + { value: 'amber', label: 'Amber' }, +]; + +// Surface presets — plain tint names. `cool` is the default (no block). +export const SURFACE_PRESETS: ThemePresetOption[] = [ + { value: 'cool', label: 'Cool' }, + { value: 'neutral', label: 'Neutral' }, + { value: 'green', label: 'Green' }, + { value: 'warm', label: 'Warm' }, +]; + +export const ACCENT_THEME_DEFAULT = 'blue'; +export const SURFACE_THEME_DEFAULT = 'cool'; + +export const ACCENT_THEME = persistedSignal('ACCENT_THEME', ACCENT_THEME_DEFAULT); +export const SURFACE_THEME = persistedSignal('SURFACE_THEME', SURFACE_THEME_DEFAULT); + +// These live in the Appearance tab but use plain persistedSignals, so register +// + mark autosave explicitly (settingSignal would do this implicitly). +markSettingStore(ACCENT_THEME); +markAutosave(ACCENT_THEME); +markSettingStore(SURFACE_THEME); +markAutosave(SURFACE_THEME); + +// Apply to . The default preset removes its attribute so tokens.css +// wins; any other preset sets it so themes.css [data-cc-*] blocks override. +function applyThemeAttrs(): void { + if (typeof document === 'undefined') return; + const root = document.documentElement; + const accent = ACCENT_THEME.value; + const surface = SURFACE_THEME.value; + if (accent === ACCENT_THEME_DEFAULT) delete root.dataset.ccAccent; + else root.dataset.ccAccent = accent; + if (surface === SURFACE_THEME_DEFAULT) delete root.dataset.ccSurface; + else root.dataset.ccSurface = surface; +} + +// Runs synchronously at module eval and re-runs on either signal's change. +effect(applyThemeAttrs); diff --git a/app/tests/state/settings/theme.test.ts b/app/tests/state/settings/theme.test.ts new file mode 100644 index 00000000..f5c37e02 --- /dev/null +++ b/app/tests/state/settings/theme.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + ACCENT_THEME, + SURFACE_THEME, + ACCENT_THEME_DEFAULT, + SURFACE_THEME_DEFAULT, + ACCENT_PRESETS, + SURFACE_PRESETS, +} from '@/state/stores/settings/theme'; + +afterEach(() => { + ACCENT_THEME.value = ACCENT_THEME_DEFAULT; + SURFACE_THEME.value = SURFACE_THEME_DEFAULT; +}); + +describe('theme stores', () => { + it('defaults are blue / cool and are the first preset of each list', () => { + expect(ACCENT_THEME_DEFAULT).toBe('blue'); + expect(SURFACE_THEME_DEFAULT).toBe('cool'); + expect(ACCENT_PRESETS[0].value).toBe('blue'); + expect(SURFACE_PRESETS[0].value).toBe('cool'); + expect(ACCENT_PRESETS.map((p) => p.value)).toEqual([ + 'blue', + 'cyan', + 'purple', + 'green', + 'pink', + 'amber', + ]); + expect(SURFACE_PRESETS.map((p) => p.value)).toEqual(['cool', 'neutral', 'green', 'warm']); + }); + + it('sets no html attribute for the default preset', () => { + ACCENT_THEME.value = ACCENT_THEME_DEFAULT; + SURFACE_THEME.value = SURFACE_THEME_DEFAULT; + expect(document.documentElement.dataset.ccAccent).toBeUndefined(); + expect(document.documentElement.dataset.ccSurface).toBeUndefined(); + }); + + it('sets data-cc-accent / data-cc-surface for non-default presets, reactively', () => { + ACCENT_THEME.value = 'cyan'; + SURFACE_THEME.value = 'warm'; + expect(document.documentElement.getAttribute('data-cc-accent')).toBe('cyan'); + expect(document.documentElement.getAttribute('data-cc-surface')).toBe('warm'); + }); + + it('clears the attribute when returning to the default preset', () => { + ACCENT_THEME.value = 'green'; + expect(document.documentElement.dataset.ccAccent).toBe('green'); + ACCENT_THEME.value = ACCENT_THEME_DEFAULT; + expect(document.documentElement.dataset.ccAccent).toBeUndefined(); + }); +}); From 61c2c56e26471ec5848c4f5c65cfbfae98352f31 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sun, 12 Jul 2026 10:17:32 -0400 Subject: [PATCH 02/18] feat(theme): add preset token blocks; derive glow-accent from --cc-accent --- app/src/styles/index.css | 1 + app/src/styles/themes.css | 55 +++++++++++++++++++++++++++ app/src/styles/tokens.css | 2 +- app/tests/styles/themePresets.test.ts | 37 ++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 app/src/styles/themes.css create mode 100644 app/tests/styles/themePresets.test.ts diff --git a/app/src/styles/index.css b/app/src/styles/index.css index 37d92be5..046407e3 100644 --- a/app/src/styles/index.css +++ b/app/src/styles/index.css @@ -4,6 +4,7 @@ with its own .tsx. */ @import './reset.css'; @import './tokens.css'; +@import './themes.css'; @import './text.css'; @import './atoms.css'; @import './panes.css'; diff --git a/app/src/styles/themes.css b/app/src/styles/themes.css new file mode 100644 index 00000000..f34a84ec --- /dev/null +++ b/app/src/styles/themes.css @@ -0,0 +1,55 @@ +/* styles/themes.css — Interface theme presets (issue #87). + + Each non-default accent/surface preset overrides ONLY the base token(s); + every color-mix variant in tokens.css cascades from them. The default preset + (blue / cool) has no block here: tokens.css is its single source. Applied via + data-cc-accent / data-cc-surface on (see stores/settings/theme.ts). + + OKLCH only. Accents hold the default blue's L/C profile (accent L.70/C.16, + light L.80/C.10, dark L.59/C.20) and vary hue. Surfaces hold the lightness + ladder (app .151 / chrome .162 / sidebar .181) and vary hue/chroma, so + contrast against the fixed text ladder is preserved by construction. */ + +/* ── Accent presets ─────────────────────────────────────────────────────── */ +[data-cc-accent='cyan'] { + --cc-accent: oklch(0.697 0.16 200); + --cc-accent-light: oklch(0.806 0.097 202); + --cc-accent-dark: oklch(0.588 0.196 205); +} +[data-cc-accent='purple'] { + --cc-accent: oklch(0.697 0.16 295); + --cc-accent-light: oklch(0.806 0.097 297); + --cc-accent-dark: oklch(0.588 0.196 300); +} +[data-cc-accent='green'] { + --cc-accent: oklch(0.72 0.15 150); + --cc-accent-light: oklch(0.82 0.1 152); + --cc-accent-dark: oklch(0.6 0.17 155); +} +[data-cc-accent='pink'] { + --cc-accent: oklch(0.7 0.17 350); + --cc-accent-light: oklch(0.81 0.1 352); + --cc-accent-dark: oklch(0.59 0.2 355); +} +[data-cc-accent='amber'] { + --cc-accent: oklch(0.78 0.15 85); + --cc-accent-light: oklch(0.86 0.1 88); + --cc-accent-dark: oklch(0.66 0.15 80); +} + +/* ── Surface presets ────────────────────────────────────────────────────── */ +[data-cc-surface='neutral'] { + --cc-bg-app: oklch(0.151 0.002 277); + --cc-bg-chrome: oklch(0.162 0.003 277); + --cc-bg-sidebar: oklch(0.181 0.004 277); +} +[data-cc-surface='green'] { + --cc-bg-app: oklch(0.151 0.012 155); + --cc-bg-chrome: oklch(0.162 0.016 155); + --cc-bg-sidebar: oklch(0.181 0.02 155); +} +[data-cc-surface='warm'] { + --cc-bg-app: oklch(0.151 0.012 55); + --cc-bg-chrome: oklch(0.162 0.016 55); + --cc-bg-sidebar: oklch(0.181 0.02 55); +} diff --git a/app/src/styles/tokens.css b/app/src/styles/tokens.css index ae50cba4..c512cedc 100644 --- a/app/src/styles/tokens.css +++ b/app/src/styles/tokens.css @@ -183,5 +183,5 @@ --cc-shadow-lg: 0 10px 40px oklch(0 0 0 / 0.6); /* modal, loading-card */ --cc-shadow-base: var(--cc-shadow-md); --cc-shadow-text: 0 1px 2px oklch(0 0 0 / 0.5); /* canvas overlay text */ - --cc-glow-accent: 0 0 6px oklch(0.697 0.16 258.2 / 0.35); /* range-pair fill */ + --cc-glow-accent: 0 0 6px color-mix(in oklch, var(--cc-accent) 35%, transparent); /* range-pair fill */ } diff --git a/app/tests/styles/themePresets.test.ts b/app/tests/styles/themePresets.test.ts new file mode 100644 index 00000000..41493524 --- /dev/null +++ b/app/tests/styles/themePresets.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + ACCENT_PRESETS, + SURFACE_PRESETS, + ACCENT_THEME_DEFAULT, + SURFACE_THEME_DEFAULT, +} from '@/state/stores/settings/theme'; + +// `new URL('literal', import.meta.url)` is Vite's static asset-URL pattern — +// it gets rewritten to a dev-server URL, not a file:// path. Resolve via +// __dirname (matches shader-source tests elsewhere in tests/) instead. +const __dirname = dirname(fileURLToPath(import.meta.url)); +const css = readFileSync(resolve(__dirname, '../../src/styles/themes.css'), 'utf8'); + +describe('themes.css preset blocks', () => { + it('defines a data-cc-accent block for every non-default accent preset', () => { + for (const p of ACCENT_PRESETS) { + if (p.value === ACCENT_THEME_DEFAULT) continue; + expect(css).toContain(`[data-cc-accent='${p.value}']`); + } + }); + + it('defines a data-cc-surface block for every non-default surface preset', () => { + for (const p of SURFACE_PRESETS) { + if (p.value === SURFACE_THEME_DEFAULT) continue; + expect(css).toContain(`[data-cc-surface='${p.value}']`); + } + }); + + it('defines no block for the default preset (tokens.css stands)', () => { + expect(css).not.toContain(`[data-cc-accent='${ACCENT_THEME_DEFAULT}']`); + expect(css).not.toContain(`[data-cc-surface='${SURFACE_THEME_DEFAULT}']`); + }); +}); From 513d20599355fdbaf5494d4e1a8a7eef3bb8d6c7 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sun, 12 Jul 2026 10:27:47 -0400 Subject: [PATCH 03/18] feat(theme): Appearance tab with accent/surface swatch pickers Renames the Preview subtab to Appearance and adds a radiogroup swatch picker for ACCENT_THEME/SURFACE_THEME (Task 1/2 stores + presets), mounted alongside the existing syntax-theme picker. Also fixes a pre-existing tsconfig gap where tests/styles/themePresets.test.ts (Task 2) used Node builtins without being excluded from the browser tsconfig, which broke `just lint`'s typecheck step. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/state/settingsDrafts.ts | 2 +- app/src/state/stores/settings/syntaxTheme.ts | 4 +- app/src/views/ControlsPane/ControlsPane.tsx | 25 ++-- .../partials/InterfaceThemeSection.css | 38 ++++++ .../partials/InterfaceThemeSection.tsx | 129 ++++++++++++++++++ .../views/ControlsPane/controlsPane.test.tsx | 7 +- .../interfaceThemeSection.test.tsx | 64 +++++++++ app/tsconfig.json | 3 +- app/tsconfig.node.json | 3 +- 9 files changed, 257 insertions(+), 18 deletions(-) create mode 100644 app/src/views/ControlsPane/partials/InterfaceThemeSection.css create mode 100644 app/src/views/ControlsPane/partials/InterfaceThemeSection.tsx create mode 100644 app/tests/views/ControlsPane/interfaceThemeSection.test.tsx diff --git a/app/src/state/settingsDrafts.ts b/app/src/state/settingsDrafts.ts index 3ea9b9fb..8063f85f 100644 --- a/app/src/state/settingsDrafts.ts +++ b/app/src/state/settingsDrafts.ts @@ -45,7 +45,7 @@ function _committedValue(store: SignalLike, key: DraftKey): unknown { export function setDraft(store: SignalLike, key: DraftKey, value: unknown): void { if (isAutosave(store as object)) { - // Write-through: apply immediately, never stage (Updates / Preview tabs). + // Write-through: apply immediately, never stage (Updates / Appearance tabs). store.value = key === null ? value : { ...store.value, [key]: value }; _emit(); return; diff --git a/app/src/state/stores/settings/syntaxTheme.ts b/app/src/state/stores/settings/syntaxTheme.ts index aba337e9..e2752d64 100644 --- a/app/src/state/stores/settings/syntaxTheme.ts +++ b/app/src/state/stores/settings/syntaxTheme.ts @@ -47,9 +47,9 @@ export const SYNTAX_THEME_OPTIONS: SyntaxThemeOption[] = [ export const SYNTAX_THEME_DEFAULT = 'atom-one-dark'; export const SYNTAX_THEME = persistedSignal('SYNTAX_THEME', SYNTAX_THEME_DEFAULT); -// SYNTAX_THEME is a setting (it lives in the Preview tab) but uses a plain +// SYNTAX_THEME is a setting (it lives in the Appearance tab) but uses a plain // persistedSignal rather than settingSignal, so register it explicitly. markSettingStore(SYNTAX_THEME); -// Autosave (write-through): the Preview tab applies on change, no Save step; +// Autosave (write-through): the Appearance tab applies on change, no Save step; // "Reset all" (World-only) skips it — see settingsDrafts.ts. markAutosave(SYNTAX_THEME); diff --git a/app/src/views/ControlsPane/ControlsPane.tsx b/app/src/views/ControlsPane/ControlsPane.tsx index 244c385b..3b7bc11e 100644 --- a/app/src/views/ControlsPane/ControlsPane.tsx +++ b/app/src/views/ControlsPane/ControlsPane.tsx @@ -1,11 +1,12 @@ // views/ControlsPane/ControlsPane.tsx — "Settings" tab in the left sidebar. // -// Composition shell: World, Updates, Preview subtabs. The active subtab's -// sections render into the scrolling body. World is draft-backed (9 +// Composition shell: World, Updates, Appearance subtabs. The active subtab's +// sections render into the scrolling body. World is draft-backed (10 // accordion sections + the sticky Reset all/Discard/Save ActionsBar); -// Updates and Preview autosave (Task 6) and each hold one section, so they -// render inline (no footer, no
— a one-item accordion is pointless -// UI). Shortcuts and Debug moved to header-triggered modals (Tasks 8/9). +// Updates and Appearance autosave. Updates holds one section; Appearance holds +// two (interface theme + syntax), all rendered inline (no footer, no
+// — a one-item accordion is pointless UI). Shortcuts and Debug moved to +// header-triggered modals. // // Section / subgroup open-state is intentionally NOT persisted: when the pane // hides we collapse every
and reset the active subtab to World, so @@ -13,9 +14,10 @@ import './ControlsPane.css'; import { useEffect, useRef, useState } from 'preact/hooks'; -import { Boxes, RefreshCw, Eye } from 'lucide-preact'; +import { Boxes, RefreshCw, Palette } from 'lucide-preact'; import type { LucideIcon } from 'lucide-preact'; import { FilePreviewSection } from './partials/FilePreviewSection'; +import { InterfaceThemeSection } from './partials/InterfaceThemeSection'; import { DynamicSection, type SectionNode } from './partials'; import { CAMERA_SECTION } from './partials/Camera'; import { SCENE_SECTION } from './partials/Scene'; @@ -81,11 +83,14 @@ export function ControlsPane({ onClose, collapsed }: ControlsPaneProps) { sections: [{ ...UPDATES_SECTION, inline: true }], }, { - id: 'preview', - label: 'Preview', - icon: Eye, + id: 'appearance', + label: 'Appearance', + icon: Palette, draftable: false, - sections: [{ key: 'file-preview', render: }], + sections: [ + { key: 'interface-theme', render: }, + { key: 'file-preview', render: }, + ], }, ]; diff --git a/app/src/views/ControlsPane/partials/InterfaceThemeSection.css b/app/src/views/ControlsPane/partials/InterfaceThemeSection.css new file mode 100644 index 00000000..4bf362e5 --- /dev/null +++ b/app/src/views/ControlsPane/partials/InterfaceThemeSection.css @@ -0,0 +1,38 @@ +/* InterfaceThemeSection.css — accent/surface swatch pickers (Appearance tab). */ + +.swatch-row { + display: flex; + align-items: center; + gap: var(--cc-space-4); +} + +.swatch-group { + display: flex; + gap: var(--cc-space-3); + flex-wrap: wrap; +} + +.swatch { + display: inline-flex; + padding: var(--cc-space-px); + border: 1px solid transparent; + border-radius: var(--cc-radius-circle); + background: none; + cursor: pointer; + transition: border-color var(--cc-t-base); +} + +.swatch:hover { + border-color: var(--cc-overlay-border); +} + +.swatch.is-active { + border-color: var(--cc-accent); +} + +.swatch-chip { + width: var(--cc-space-7); + height: var(--cc-space-7); + border-radius: var(--cc-radius-circle); + box-shadow: inset 0 0 0 1px var(--cc-overlay-border); +} diff --git a/app/src/views/ControlsPane/partials/InterfaceThemeSection.tsx b/app/src/views/ControlsPane/partials/InterfaceThemeSection.tsx new file mode 100644 index 00000000..36309859 --- /dev/null +++ b/app/src/views/ControlsPane/partials/InterfaceThemeSection.tsx @@ -0,0 +1,129 @@ +// views/ControlsPane/partials/InterfaceThemeSection.tsx — Accent + surface +// preset pickers for the Appearance tab. Each axis is a radiogroup of color +// swatches; picking one write-through-applies (autosave, no Save step). A chip +// carries the same data-cc-* attribute its preset uses, so its color resolves +// from themes.css via var(--cc-accent) / var(--cc-bg-app) with no duplicated +// hex. Reset is hand-rolled (ResetButton only supports keyed object stores; +// these are scalar signals, so we stageReset with a null key like the syntax +// picker). + +import './InterfaceThemeSection.css'; +import { RotateCcw } from 'lucide-preact'; +import { getEffective, setDraft, stageReset, DRAFTS_REV } from '@/state/settingsDrafts'; +import { + ACCENT_THEME, + ACCENT_THEME_DEFAULT, + ACCENT_PRESETS, + SURFACE_THEME, + SURFACE_THEME_DEFAULT, + SURFACE_PRESETS, + type ThemePresetOption, +} from '@/state/stores/settings/theme'; +import { ThemeRow } from '@/components/ThemeRow/ThemeRow'; + +interface SignalLike { + get value(): string; + set value(v: string); +} + +interface SwatchRowProps { + label: string; + tip: string; + axis: 'accent' | 'surface'; + store: SignalLike; + options: ThemePresetOption[]; + defaultValue: string; +} + +function SwatchRow({ label, tip, axis, store, options, defaultValue }: SwatchRowProps) { + const current = (getEffective(store, null) as string) ?? defaultValue; + const activeIndex = Math.max( + 0, + options.findIndex((o) => o.value === current) + ); + const chipVar = axis === 'accent' ? 'var(--cc-accent)' : 'var(--cc-bg-app)'; + const defaultLabel = options.find((o) => o.value === defaultValue)?.label ?? defaultValue; + + const select = (value: string) => setDraft(store, null, value); + + const onKeyDown = (e: KeyboardEvent) => { + const delta = + e.key === 'ArrowRight' || e.key === 'ArrowDown' + ? 1 + : e.key === 'ArrowLeft' || e.key === 'ArrowUp' + ? -1 + : 0; + if (!delta) return; + e.preventDefault(); + select(options[(activeIndex + delta + options.length) % options.length].value); + }; + + return ( + + + + {options.map((opt, i) => { + const checked = opt.value === current; + return ( + + ); + })} + + + + + ); +} + +export function InterfaceThemeSection() { + void DRAFTS_REV.value; // re-render on write-through / reset + return ( +
+ + +
+ ); +} diff --git a/app/tests/views/ControlsPane/controlsPane.test.tsx b/app/tests/views/ControlsPane/controlsPane.test.tsx index 33800654..9b4005ac 100644 --- a/app/tests/views/ControlsPane/controlsPane.test.tsx +++ b/app/tests/views/ControlsPane/controlsPane.test.tsx @@ -7,6 +7,7 @@ import { ControlsPane } from '@/views/ControlsPane/ControlsPane'; import '@/state/stores/settings/updates'; import '@/state/stores/settings/scene'; import '@/state/stores/settings/syntaxTheme'; +import '@/state/stores/settings/theme'; import '@/state/stores/settings/streets'; import '@/state/stores/settings/buildings'; import '@/state/stores/settings/gem'; @@ -52,7 +53,7 @@ describe('ControlsPane subtabs', () => { it('renders exactly three subtabs, World active by default', () => { const pane = mount(); expect(pane.classList.contains('controls-pane')).toBe(true); - for (const label of ['World', 'Live updates', 'Preview']) { + for (const label of ['World', 'Live updates', 'Appearance']) { expect(tab(pane, label)).toBeTruthy(); } expect(tab(pane, 'Shortcuts')).toBeUndefined(); @@ -60,12 +61,12 @@ describe('ControlsPane subtabs', () => { expect(tab(pane, 'World').getAttribute('aria-selected')).toBe('true'); }); - it('shows the action bar only on World; Updates/Preview autosave with no footer', () => { + it('shows the action bar only on World; Updates/Appearance autosave with no footer', () => { const pane = mount(); expect(pane.querySelector('.controls-actions')).toBeTruthy(); // World clickTab(pane, 'Live updates'); expect(pane.querySelector('.controls-actions')).toBeNull(); - clickTab(pane, 'Preview'); + clickTab(pane, 'Appearance'); expect(pane.querySelector('.controls-actions')).toBeNull(); }); diff --git a/app/tests/views/ControlsPane/interfaceThemeSection.test.tsx b/app/tests/views/ControlsPane/interfaceThemeSection.test.tsx new file mode 100644 index 00000000..d26691a4 --- /dev/null +++ b/app/tests/views/ControlsPane/interfaceThemeSection.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render } from 'preact'; +import { act } from 'preact/test-utils'; +import { InterfaceThemeSection } from '@/views/ControlsPane/partials/InterfaceThemeSection'; +import { + ACCENT_THEME, + ACCENT_THEME_DEFAULT, + SURFACE_THEME, + SURFACE_THEME_DEFAULT, +} from '@/state/stores/settings/theme'; +import { flush } from '../../_helpers/preact'; + +describe('InterfaceThemeSection', () => { + let container: HTMLDivElement; + + const mount = () => { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => render(, container)); + }; + + const radio = (label: string) => + Array.from(container.querySelectorAll('[role="radio"]')).find( + (el) => el.getAttribute('aria-label') === label + )!; + + afterEach(() => { + ACCENT_THEME.value = ACCENT_THEME_DEFAULT; + SURFACE_THEME.value = SURFACE_THEME_DEFAULT; + render(null, container); + container.remove(); + }); + + it('renders two radiogroups (accent + surface)', () => { + mount(); + expect(container.querySelectorAll('[role="radiogroup"]').length).toBe(2); + }); + + it('marks the active accent chip aria-checked', () => { + mount(); + expect(radio('Blue').getAttribute('aria-checked')).toBe('true'); + expect(radio('Cyan').getAttribute('aria-checked')).toBe('false'); + }); + + it('selecting a chip write-through-applies to the store and ', async () => { + mount(); + act(() => radio('Cyan').click()); + await flush(); + expect(ACCENT_THEME.value).toBe('cyan'); + expect(document.documentElement.getAttribute('data-cc-accent')).toBe('cyan'); + expect(radio('Cyan').getAttribute('aria-checked')).toBe('true'); + }); + + it('reset returns the axis to its default', async () => { + mount(); + act(() => radio('Warm').click()); + await flush(); + expect(SURFACE_THEME.value).toBe('warm'); + const surfaceReset = container.querySelectorAll('.theme-row-reset')[1]; + act(() => surfaceReset.click()); + await flush(); + expect(SURFACE_THEME.value).toBe(SURFACE_THEME_DEFAULT); + }); +}); diff --git a/app/tsconfig.json b/app/tsconfig.json index 6887c0f5..add8a3f7 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -55,6 +55,7 @@ // time — checked by tsconfig.node.json which adds @types/node. "tests/city/utils/color/hsl-glsl-parity.test.ts", "tests/city/components/buildings/building-shader.test.ts", - "tests/city/components/sky/skyShader.test.ts" + "tests/city/components/sky/skyShader.test.ts", + "tests/styles/themePresets.test.ts" ] } diff --git a/app/tsconfig.node.json b/app/tsconfig.node.json index 1e8e10b1..85a579d3 100644 --- a/app/tsconfig.node.json +++ b/app/tsconfig.node.json @@ -17,7 +17,8 @@ "tests/visual/**/*", "tests/city/utils/color/hsl-glsl-parity.test.ts", "tests/city/components/buildings/building-shader.test.ts", - "tests/city/components/sky/skyShader.test.ts" + "tests/city/components/sky/skyShader.test.ts", + "tests/styles/themePresets.test.ts" ], "exclude": ["node_modules"] } From 2418f9a41d690e8f692dff6012252c90c2005327 Mon Sep 17 00:00:00 2001 From: Thalida Noel Date: Sun, 12 Jul 2026 10:49:48 -0400 Subject: [PATCH 04/18] fix(theme): correct default swatch color, rainbow order, radiogroup focus - default (blue/cool) swatch chips inherited the active theme; give them explicit themes.css blocks so each chip previews its own preset - reorder accents by hue (Amber, Green, Cyan, Blue, Purple, Pink) - arrow keys now move focus with selection (WAI-ARIA radiogroup) - refresh stale "Preview" tab-name comments to "Appearance" Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/state/settingsSchema.ts | 2 +- app/src/state/stores/settings/theme.ts | 13 +++++----- app/src/styles/themes.css | 24 +++++++++++++++---- .../ControlsPane/ActionsBar/ActionsBar.tsx | 2 +- .../partials/InterfaceThemeSection.tsx | 15 ++++++++++-- app/tests/state/settings/theme.test.ts | 14 ++++++----- app/tests/styles/themePresets.test.ts | 12 ++++------ .../interfaceThemeSection.test.tsx | 14 +++++++++++ 8 files changed, 69 insertions(+), 27 deletions(-) diff --git a/app/src/state/settingsSchema.ts b/app/src/state/settingsSchema.ts index 264c935b..f599f489 100644 --- a/app/src/state/settingsSchema.ts +++ b/app/src/state/settingsSchema.ts @@ -116,7 +116,7 @@ const _AUTOSAVE_STORES = new WeakSet(); /** Mark a settings store as write-through: its widgets apply on change * (bypassing the draft/Save layer) instead of staging drafts. Used by the - * autosave tabs (Updates, Preview) whose settings are cheap and want instant + * autosave tabs (Updates, Appearance) whose settings are cheap and want instant * feedback. */ export function markAutosave(store: object): void { _AUTOSAVE_STORES.add(store); diff --git a/app/src/state/stores/settings/theme.ts b/app/src/state/stores/settings/theme.ts index 2e031624..004c4e80 100644 --- a/app/src/state/stores/settings/theme.ts +++ b/app/src/state/stores/settings/theme.ts @@ -16,18 +16,19 @@ export interface ThemePresetOption { label: string; } -// Accent presets — plain color names. `blue` is the default and has NO block in -// themes.css: absent data-cc-accent, tokens.css stands as its single source. +// Accent presets in rainbow (hue) order. `blue` is the default; the applier +// omits the attribute for it so tokens.css :root stays the page source (the +// picker chip still previews blue via themes.css's [data-cc-accent='blue']). export const ACCENT_PRESETS: ThemePresetOption[] = [ - { value: 'blue', label: 'Blue' }, + { value: 'amber', label: 'Amber' }, + { value: 'green', label: 'Green' }, { value: 'cyan', label: 'Cyan' }, + { value: 'blue', label: 'Blue' }, { value: 'purple', label: 'Purple' }, - { value: 'green', label: 'Green' }, { value: 'pink', label: 'Pink' }, - { value: 'amber', label: 'Amber' }, ]; -// Surface presets — plain tint names. `cool` is the default (no block). +// Surface presets — plain tint names. `cool` is the default. export const SURFACE_PRESETS: ThemePresetOption[] = [ { value: 'cool', label: 'Cool' }, { value: 'neutral', label: 'Neutral' }, diff --git a/app/src/styles/themes.css b/app/src/styles/themes.css index f34a84ec..ca67e8f6 100644 --- a/app/src/styles/themes.css +++ b/app/src/styles/themes.css @@ -1,9 +1,15 @@ /* styles/themes.css — Interface theme presets (issue #87). - Each non-default accent/surface preset overrides ONLY the base token(s); - every color-mix variant in tokens.css cascades from them. The default preset - (blue / cool) has no block here: tokens.css is its single source. Applied via - data-cc-accent / data-cc-surface on (see stores/settings/theme.ts). + Each preset overrides its base token(s); every color-mix variant in + tokens.css cascades from them. Applied via data-cc-accent / data-cc-surface + on (see stores/settings/theme.ts). + + The default preset (blue / cool) has a block too, but only the swatch-picker + chips ever match it: the applier removes the attribute from for the + default, so on the page tokens.css :root stays the source. Each chip carries + its own data-cc-* so it previews its true color even while another preset is + active; without a default block the default chip would inherit the active + theme. The blue/cool values here mirror tokens.css :root. OKLCH only. Accents hold the default blue's L/C profile (accent L.70/C.16, light L.80/C.10, dark L.59/C.20) and vary hue. Surfaces hold the lightness @@ -11,6 +17,11 @@ contrast against the fixed text ladder is preserved by construction. */ /* ── Accent presets ─────────────────────────────────────────────────────── */ +[data-cc-accent='blue'] { + --cc-accent: oklch(0.697 0.16 258.2); + --cc-accent-light: oklch(0.806 0.097 260); + --cc-accent-dark: oklch(0.588 0.196 268.5); +} [data-cc-accent='cyan'] { --cc-accent: oklch(0.697 0.16 200); --cc-accent-light: oklch(0.806 0.097 202); @@ -38,6 +49,11 @@ } /* ── Surface presets ────────────────────────────────────────────────────── */ +[data-cc-surface='cool'] { + --cc-bg-app: oklch(0.151 0.011 276.2); + --cc-bg-chrome: oklch(0.162 0.015 278.3); + --cc-bg-sidebar: oklch(0.181 0.019 279.4); +} [data-cc-surface='neutral'] { --cc-bg-app: oklch(0.151 0.002 277); --cc-bg-chrome: oklch(0.162 0.003 277); diff --git a/app/src/views/ControlsPane/ActionsBar/ActionsBar.tsx b/app/src/views/ControlsPane/ActionsBar/ActionsBar.tsx index e8a20243..53c4b8dd 100644 --- a/app/src/views/ControlsPane/ActionsBar/ActionsBar.tsx +++ b/app/src/views/ControlsPane/ActionsBar/ActionsBar.tsx @@ -3,7 +3,7 @@ // Widgets write to the draft layer; Save commits to stores (triggers // existing persist + commit-reaction subscriptions). Discard drops pending // drafts. Reset all stages every overridden value back to its default, user -// still must Save to apply. Updates and Preview are autosave (write straight +// still must Save to apply. Updates and Appearance are autosave (write straight // to their stores, no draft layer), so they never render this bar and Reset // all only ever covers World's draft-backed settings. diff --git a/app/src/views/ControlsPane/partials/InterfaceThemeSection.tsx b/app/src/views/ControlsPane/partials/InterfaceThemeSection.tsx index 36309859..fe4f3bad 100644 --- a/app/src/views/ControlsPane/partials/InterfaceThemeSection.tsx +++ b/app/src/views/ControlsPane/partials/InterfaceThemeSection.tsx @@ -1,6 +1,8 @@ // views/ControlsPane/partials/InterfaceThemeSection.tsx — Accent + surface // preset pickers for the Appearance tab. Each axis is a radiogroup of color -// swatches; picking one write-through-applies (autosave, no Save step). A chip +// swatches: one tab stop, arrow keys move focus AND selection (the WAI-ARIA +// radio pattern). Picking one write-through-applies (autosave, no Save step). +// A chip // carries the same data-cc-* attribute its preset uses, so its color resolves // from themes.css via var(--cc-accent) / var(--cc-bg-app) with no duplicated // hex. Reset is hand-rolled (ResetButton only supports keyed object stores; @@ -8,6 +10,7 @@ // picker). import './InterfaceThemeSection.css'; +import { useRef } from 'preact/hooks'; import { RotateCcw } from 'lucide-preact'; import { getEffective, setDraft, stageReset, DRAFTS_REV } from '@/state/settingsDrafts'; import { @@ -44,6 +47,7 @@ function SwatchRow({ label, tip, axis, store, options, defaultValue }: SwatchRow const chipVar = axis === 'accent' ? 'var(--cc-accent)' : 'var(--cc-bg-app)'; const defaultLabel = options.find((o) => o.value === defaultValue)?.label ?? defaultValue; + const btnRefs = useRef<(HTMLButtonElement | null)[]>([]); const select = (value: string) => setDraft(store, null, value); const onKeyDown = (e: KeyboardEvent) => { @@ -55,7 +59,11 @@ function SwatchRow({ label, tip, axis, store, options, defaultValue }: SwatchRow : 0; if (!delta) return; e.preventDefault(); - select(options[(activeIndex + delta + options.length) % options.length].value); + // Move selection AND focus together (WAI-ARIA radiogroup): flipping + // tabIndex on re-render doesn't move the browser's focus, so do it here. + const next = (activeIndex + delta + options.length) % options.length; + select(options[next].value); + btnRefs.current[next]?.focus(); }; return ( @@ -67,6 +75,9 @@ function SwatchRow({ label, tip, axis, store, options, defaultValue }: SwatchRow return ( ); })}