diff --git a/app/src/components/NewProjectForm/NewProjectForm.tsx b/app/src/components/NewProjectForm/NewProjectForm.tsx index ec8b2c34..aca77f5f 100644 --- a/app/src/components/NewProjectForm/NewProjectForm.tsx +++ b/app/src/components/NewProjectForm/NewProjectForm.tsx @@ -106,6 +106,9 @@ export function NewProjectForm({ allowLocalRepos, prefill, onSubmit }: NewProjec aria-invalid={fieldError ? 'true' : undefined} autoComplete="off" spellcheck={false} + placeholder={ + kind === SourceKind.Remote ? 'https://github.com/owner/repo' : '~/projects/my-repo' + } value={source} onInput={(e) => onSourceInput((e.target as HTMLInputElement).value)} /> diff --git a/app/src/components/PaneHeader/PaneHeader.css b/app/src/components/PaneHeader/PaneHeader.css index c20964da..8f8c4b37 100644 --- a/app/src/components/PaneHeader/PaneHeader.css +++ b/app/src/components/PaneHeader/PaneHeader.css @@ -9,7 +9,9 @@ * file-preview — mounts this same DOM so they look identical and * adding a pane is a single buildPaneHeader() call. */ .pane-header { - padding: var(--cc-space-6) var(--cc-space-6) var(--cc-space-6) var(--cc-space-7); + /* Uniform inset on all sides so the leading focus button and the trailing + close button sit equidistant from the pane edges. */ + padding: var(--cc-space-4); border-bottom: 1px solid var(--cc-border-subtle); flex-shrink: 0; display: flex; @@ -24,7 +26,7 @@ * line (their active underline overlaps it). Folding the title bar + separate * strip into one row is the vertical-space win. */ .pane-header--tabs { - padding: 0 var(--cc-space-6) 0 0; /* first tab sits flush with the pane edge */ + padding: 0 var(--cc-space-4) 0 0; /* first tab sits flush with the pane edge */ align-items: stretch; } .pane-header--tabs .pane-tabs { diff --git a/app/src/layout/LeftSidebar/LeftSidebar.css b/app/src/layout/LeftSidebar/LeftSidebar.css index 9d11f263..c00835ec 100644 --- a/app/src/layout/LeftSidebar/LeftSidebar.css +++ b/app/src/layout/LeftSidebar/LeftSidebar.css @@ -7,9 +7,10 @@ position: relative; flex: 0 0 auto; /* Resizable: the drag handle mutates --sidebar-width; CSS owns `width` so the - handle never fights an inline style. Default width = activity bar + - panel. */ - width: var(--sidebar-width, 360px); + handle never fights an inline style. Default is a third of the viewport + (matching the right sidebar) until the user drags a width; min/max below + clamp it on narrow/wide screens. */ + width: var(--sidebar-width, calc(100vw / 3)); min-width: 280px; max-width: 600px; height: 100%; @@ -69,11 +70,9 @@ align-items: center; justify-content: center; cursor: pointer; - border-left: 2px solid transparent; transition: color var(--cc-t-base), - background var(--cc-t-base), - border-color var(--cc-t-base); + background var(--cc-t-base); padding: var(--cc-space-0); } @@ -82,10 +81,11 @@ background: var(--cc-overlay-bg-soft); } +/* Selected tab: accent-colored glyph + a faint accent fill (softer than the + standard -bg-soft tint), no left border. */ .activity-bar-icon.active { - color: var(--cc-text-primary); - border-left-color: var(--cc-accent); - background: var(--cc-accent-bg-soft); + color: var(--cc-accent); + background: color-mix(in oklch, var(--cc-accent) 6%, transparent); } /* Lucide glyph sizing: CSS width/height override the SVG's own size attrs so 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/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/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/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/state/stores/settings/theme.ts b/app/src/state/stores/settings/theme.ts new file mode 100644 index 00000000..004c4e80 --- /dev/null +++ b/app/src/state/stores/settings/theme.ts @@ -0,0 +1,66 @@ +// 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 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: 'amber', label: 'Amber' }, + { value: 'green', label: 'Green' }, + { value: 'cyan', label: 'Cyan' }, + { value: 'blue', label: 'Blue' }, + { value: 'purple', label: 'Purple' }, + { value: 'pink', label: 'Pink' }, +]; + +// Surface presets — plain tint names. `cool` is the default. +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/src/styles/empty-state.css b/app/src/styles/empty-state.css index bc9cb0aa..2a1b8113 100644 --- a/app/src/styles/empty-state.css +++ b/app/src/styles/empty-state.css @@ -15,7 +15,7 @@ align-items: center; justify-content: center; gap: var(--cc-space-4); - padding: var(--cc-space-8) var(--cc-space-7); + padding: var(--cc-space-8) var(--cc-pane-inset); color: var(--cc-text-faint); text-align: center; } @@ -31,7 +31,7 @@ margin-bottom: var(--cc-space-2); } .empty-state--lg { - padding: var(--cc-space-9) var(--cc-space-7); + padding: var(--cc-space-9) var(--cc-pane-inset); } /* The card title + subtitle render tightly inside the state card: kill the margin and cap the subtitle width regardless of which text-* class they use. */ 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/modal.css b/app/src/styles/modal.css index eb1db138..ac035fde 100644 --- a/app/src/styles/modal.css +++ b/app/src/styles/modal.css @@ -12,6 +12,9 @@ position: fixed; inset: 0; background: var(--cc-bg-backdrop); + /* Soft blur of the scene/UI behind the card. */ + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; @@ -26,6 +29,11 @@ flex-direction: column; overflow: hidden; font-family: inherit; + /* A faint full border (header-divider color) lifts the modal off the blurred + backdrop, and an even, slightly darker shadow (no downward offset) rings it + equally rather than casting downward. */ + border: 1px solid var(--cc-border-subtle); + box-shadow: 0 0 40px oklch(0 0 0 / 0.7); } .modal-header { diff --git a/app/src/styles/panes.css b/app/src/styles/panes.css index e1cf3dbd..ba6773c3 100644 --- a/app/src/styles/panes.css +++ b/app/src/styles/panes.css @@ -6,7 +6,9 @@ shells that don't scroll themselves. .pane-body — like .pane, but the body itself scrolls vertically. Used when the contents need to scroll inside the pane. - .pane-body--padded adds inset padding. + .pane-inset — standard content inset (var(--cc-pane-inset)) for any pane or + panel content wrapper. The one shared padding class; every + panel uses it so the inset lives in one place. ═══════════════════════════════════════════════════════════════════════════ */ .pane, @@ -27,7 +29,7 @@ overflow-y: auto; } -.pane-body--padded { - padding: var(--cc-space-7); +.pane-inset { + padding: var(--cc-pane-inset); box-sizing: border-box; } diff --git a/app/src/styles/rows.css b/app/src/styles/rows.css index 72076f04..cba6c236 100644 --- a/app/src/styles/rows.css +++ b/app/src/styles/rows.css @@ -16,7 +16,7 @@ display: flex; align-items: center; gap: var(--cc-space-2); /* default gap; --tight overrides to a tighter one */ - padding: var(--cc-space-2) var(--cc-space-7); + padding: var(--cc-space-2) var(--cc-pane-inset); border-radius: var(--cc-radius-sm); cursor: pointer; user-select: none; @@ -35,8 +35,8 @@ /* Bleed variant — extends hover background to the pane edge. Used by collapsible summaries inside the controls-pane. */ .row--bleed { - margin-left: calc(-1 * var(--cc-space-7)); - margin-right: calc(-1 * var(--cc-space-7)); + margin-left: calc(-1 * var(--cc-pane-inset)); + margin-right: calc(-1 * var(--cc-pane-inset)); margin-bottom: var(--cc-space-2); } diff --git a/app/src/styles/themes.css b/app/src/styles/themes.css new file mode 100644 index 00000000..ca67e8f6 --- /dev/null +++ b/app/src/styles/themes.css @@ -0,0 +1,71 @@ +/* styles/themes.css — Interface theme presets (issue #87). + + 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 + 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='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); + --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='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); + --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..cff7680e 100644 --- a/app/src/styles/tokens.css +++ b/app/src/styles/tokens.css @@ -135,6 +135,10 @@ --cc-space-8: 24px; --cc-space-9: 32px; --cc-space-base: var(--cc-space-4); + /* Semantic: the inset from a pane/panel edge to its content. Pane-body + paddings, row insets, and full-bleed row margins all key off this, so + changing it here reflows every panel's content inset in lockstep. */ + --cc-pane-inset: var(--cc-space-6); /* ── SCALES — Radius ──────────────────────────────────────────────────── */ --cc-radius-0: 0; @@ -183,5 +187,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/src/views/CommitPane/CommitPane.css b/app/src/views/CommitPane/CommitPane.css index 1127463b..186c2aca 100644 --- a/app/src/views/CommitPane/CommitPane.css +++ b/app/src/views/CommitPane/CommitPane.css @@ -15,7 +15,6 @@ at the bottom so body load doesn't shift content above it. */ .commit-body { - padding: var(--cc-space-7); display: flex; flex-direction: column; gap: var(--cc-space-4); diff --git a/app/src/views/CommitPane/CommitPane.tsx b/app/src/views/CommitPane/CommitPane.tsx index 51fb510b..3c5f13ff 100644 --- a/app/src/views/CommitPane/CommitPane.tsx +++ b/app/src/views/CommitPane/CommitPane.tsx @@ -121,7 +121,12 @@ export function CommitPane({ state, onClose, onFocus }: CommitPaneProps) { if (!commit) { return ( - + onFocus(commit) : undefined} focusTitle={`Focus the camera on this commit (${KEY_BINDINGS.FOCUS_SELECTION.label})`} onClose={onClose} - bodyClass="commit-body" + bodyClass="commit-body pane-inset" >
{commit.subject || '(no subject)'}
{(commit.authors ?? []).map((author) => ( diff --git a/app/src/views/ControlsPane/ActionsBar/ActionsBar.css b/app/src/views/ControlsPane/ActionsBar/ActionsBar.css index 0d877c33..e13a19a6 100644 --- a/app/src/views/ControlsPane/ActionsBar/ActionsBar.css +++ b/app/src/views/ControlsPane/ActionsBar/ActionsBar.css @@ -9,7 +9,7 @@ align-items: center; justify-content: space-between; gap: var(--cc-space-4); - padding: var(--cc-space-5) var(--cc-space-7); + padding: var(--cc-space-5) var(--cc-pane-inset); border-top: 1px solid var(--cc-border-subtle); } .controls-actions-left, 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/ControlsPane.css b/app/src/views/ControlsPane/ControlsPane.css index facb3726..02028d1b 100644 --- a/app/src/views/ControlsPane/ControlsPane.css +++ b/app/src/views/ControlsPane/ControlsPane.css @@ -10,11 +10,11 @@ /* Sticky headers pin flush to the very top, so the scroll body can't keep top * padding — scrolled rows would peek above the pinned header through it. The * first child carries the top inset instead. */ -.controls-pane .pane-body--padded { +.controls-pane .pane-inset { padding-top: 0; } -.controls-pane .pane-body--padded > :first-child { - margin-top: var(--cc-space-7); +.controls-pane .pane-inset > :first-child { + margin-top: var(--cc-pane-inset); } /* Accordion header interaction — identical at every level (section + nested diff --git a/app/src/views/ControlsPane/ControlsPane.tsx b/app/src/views/ControlsPane/ControlsPane.tsx index 244c385b..097b0fc6 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: }, + ], }, ]; @@ -109,7 +114,7 @@ export function ControlsPane({ onClose, collapsed }: ControlsPaneProps) { {onClose && } } - bodyClass="pane-body--padded" + bodyClass="pane-inset" footerSlot={active.draftable ? : null} > {active.sections.map((node) => ( diff --git a/app/src/views/ControlsPane/partials/InterfaceThemeSection.css b/app/src/views/ControlsPane/partials/InterfaceThemeSection.css new file mode 100644 index 00000000..7ebec2ca --- /dev/null +++ b/app/src/views/ControlsPane/partials/InterfaceThemeSection.css @@ -0,0 +1,54 @@ +/* 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; +} + +/* Each swatch is a tile: color chip above its preset name. The label carries + the distinction the surface chips can't (their tints are near-black). */ +.swatch { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--cc-space-2); + padding: var(--cc-space-2); + border: none; + border-radius: var(--cc-radius-sm); + background: none; + cursor: pointer; + transition: background var(--cc-t-base); +} + +.swatch:hover { + background: var(--cc-overlay-bg-soft); +} + +.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); +} + +.swatch.is-active .swatch-chip { + outline: 2px solid var(--cc-accent); + outline-offset: 2px; +} + +.swatch-label { + font-size: var(--cc-font-sm); + color: var(--cc-text-faint); + white-space: nowrap; +} + +.swatch.is-active .swatch-label { + color: var(--cc-text-primary); +} diff --git a/app/src/views/ControlsPane/partials/InterfaceThemeSection.tsx b/app/src/views/ControlsPane/partials/InterfaceThemeSection.tsx new file mode 100644 index 00000000..c22ebf55 --- /dev/null +++ b/app/src/views/ControlsPane/partials/InterfaceThemeSection.tsx @@ -0,0 +1,147 @@ +// views/ControlsPane/partials/InterfaceThemeSection.tsx — Accent + surface +// preset pickers for the Appearance tab. Each axis is a radiogroup of color +// 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; +// these are scalar signals, so we stageReset with a null key like the syntax +// picker). + +import './InterfaceThemeSection.css'; +import { useRef } from 'preact/hooks'; +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) + ); + // Accent chips show the flat accent; surface chips show the full surface + // ladder as a gradient (app -> chrome -> sidebar) so the otherwise near-black + // tint reads at the lighter sidebar end and the presets are distinguishable. + const chipVar = + axis === 'accent' + ? 'var(--cc-accent)' + : 'linear-gradient(135deg, var(--cc-bg-app), var(--cc-bg-chrome), var(--cc-bg-sidebar))'; + 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) => { + const delta = + e.key === 'ArrowRight' || e.key === 'ArrowDown' + ? 1 + : e.key === 'ArrowLeft' || e.key === 'ArrowUp' + ? -1 + : 0; + if (!delta) return; + e.preventDefault(); + // 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 ( + + + + {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/src/views/InfoPane/InfoPane.css b/app/src/views/InfoPane/InfoPane.css index 5c6ea74b..5933b796 100644 --- a/app/src/views/InfoPane/InfoPane.css +++ b/app/src/views/InfoPane/InfoPane.css @@ -41,7 +41,6 @@ } .info-markdown { - padding: var(--cc-space-7); color: var(--cc-text-secondary); font-size: var(--cc-font-lg); line-height: 1.6; diff --git a/app/src/views/InfoPane/LegendPane.css b/app/src/views/InfoPane/LegendPane.css index 78b591e6..24cc846e 100644 --- a/app/src/views/InfoPane/LegendPane.css +++ b/app/src/views/InfoPane/LegendPane.css @@ -1,5 +1,4 @@ .legend { - padding: var(--cc-space-7); /* shared pane content inset, all sides */ display: flex; flex-direction: column; gap: 12px; diff --git a/app/src/views/InfoPane/LegendPane.tsx b/app/src/views/InfoPane/LegendPane.tsx index e7602bad..dfac1b75 100644 --- a/app/src/views/InfoPane/LegendPane.tsx +++ b/app/src/views/InfoPane/LegendPane.tsx @@ -60,7 +60,7 @@ function LayerBlock({ layerKey }: { layerKey: (typeof LAYER_LEGEND)[number]['key export function LegendPane() { return ( -
+

How a repo's structure and history become this city.

{LAYER_LEGEND.map((l) => ( diff --git a/app/src/views/InfoPane/OverviewPane.css b/app/src/views/InfoPane/OverviewPane.css index f7195790..5148f621 100644 --- a/app/src/views/InfoPane/OverviewPane.css +++ b/app/src/views/InfoPane/OverviewPane.css @@ -1,5 +1,4 @@ .almanac { - padding: var(--cc-space-7); /* shared pane content inset, all sides */ display: flex; flex-direction: column; gap: 12px; @@ -20,31 +19,32 @@ font-size: var(--cc-font-xl); } +/* Two-column grid: the label column sizes to the widest label (max-content), + * so every value lands on the same rail without a hardcoded width, and a longer + * label like "Last Updated" just widens the rail. align-items: start keeps each + * label top-aligned with a taller two-line value (the Latest cell). */ .almanac-meta { margin: 0; - display: flex; - flex-direction: column; - gap: 4px; + display: grid; + grid-template-columns: max-content 1fr; + column-gap: var(--cc-space-4); + row-gap: var(--cc-space-2); + align-items: start; + font-size: var(--cc-font-lg); } -.almanac-meta div { - display: flex; - gap: 8px; - font-size: var(--cc-font-lg); - min-width: 0; +/* Each conditional row wrapper collapses so its dt/dd become direct grid items + * (one grid row per pair), sharing the rail. */ +.almanac-meta > div { + display: contents; } -/* Fixed label column so every value starts on the same rail (Founded / Latest / - * Remote line up) and the rows top-align with the taller two-line Latest cell. */ .almanac-meta dt { - flex: 0 0 64px; color: var(--cc-text-muted); + white-space: nowrap; } .almanac-meta dd { - /* Fill the rest of the row past the fixed label column, so the Latest cell's - * button spans the full right column (and long values truncate at its edge). */ - flex: 1 1 0; margin: 0; min-width: 0; color: var(--cc-text-secondary); @@ -53,6 +53,17 @@ white-space: nowrap; } +/* Relative age appended after an absolute date (Founded / Last Updated), set + * off by a faint middot. */ +.almanac-rel { + color: var(--cc-text-faint); +} +.almanac-rel::before { + content: '·'; + margin: 0 var(--cc-space-3); + color: var(--cc-text-ghost); +} + /* The sha as plain text (no remote) vs. as the one link in the row. Both stay * monospace and never shrink; link color/underline come from .link--chrome. */ .almanac-sha, diff --git a/app/src/views/InfoPane/OverviewPane.tsx b/app/src/views/InfoPane/OverviewPane.tsx index 7db0ca85..35106a71 100644 --- a/app/src/views/InfoPane/OverviewPane.tsx +++ b/app/src/views/InfoPane/OverviewPane.tsx @@ -16,7 +16,7 @@ import { selectPath, focusPath, selectCommit, focusCommit } from '@/state/stores import { TREES } from '@/state/stores/settings/trees'; import { BUILDINGS } from '@/state/stores/settings/buildings'; import { extHueColor } from '@/city/components/buildings/color'; -import { humanAge, formatRelativeAge } from '@/utils/dates'; +import { humanAge, formatShortDate, formatRelativeAge } from '@/utils/dates'; import { commitUrl } from '@/utils/commit'; import { languageLabelForExt } from '@/utils/syntaxLanguages'; import { formatCount, pluralize } from '@/utils/format'; @@ -213,12 +213,14 @@ export function OverviewPane({ manifest }: OverviewPaneProps) { repo.head_sha && repo.head_subject ? { sha: repo.head_sha.slice(0, 7), subject: repo.head_subject } : null; - const latestAgo = overview.latestDate ? formatRelativeAge(overview.latestDate) : null; + const lastUpdatedOn = overview.latestDate ? formatShortDate(overview.latestDate) : null; + const lastUpdatedAgo = overview.latestDate ? formatRelativeAge(overview.latestDate) : null; + const foundedAgo = overview.foundedISO ? formatRelativeAge(overview.foundedISO) : null; const latestUrl = repo.remote_url && repo.head_sha ? commitUrl(repo.remote_url, repo.head_sha) : null; return ( -
+

{overview.name}

{blurb &&

{blurb}

} @@ -264,16 +266,22 @@ export function OverviewPane({ manifest }: OverviewPaneProps) {
)} - {latestAgo && ( + {lastUpdatedOn && (
-
Updated
-
{latestAgo}
+
Last Updated
+
+ {lastUpdatedOn} + {lastUpdatedAgo && {lastUpdatedAgo}} +
)} {overview.founded && (
Founded
-
{overview.founded}
+
+ {overview.founded} + {foundedAgo && {foundedAgo}} +
)} diff --git a/app/src/views/InfoPane/ReadmePane.tsx b/app/src/views/InfoPane/ReadmePane.tsx index 6d87c0dd..1af0a6df 100644 --- a/app/src/views/InfoPane/ReadmePane.tsx +++ b/app/src/views/InfoPane/ReadmePane.tsx @@ -138,7 +138,7 @@ export function ReadmePane({ manifest }: ReadmePaneProps) { )} {body.kind === InfoBodyKind.Markdown && ( -
+
)} ); diff --git a/app/src/views/ProjectsView/ProjectsView.css b/app/src/views/ProjectsView/ProjectsView.css index e3c08ae4..ffe3380b 100644 --- a/app/src/views/ProjectsView/ProjectsView.css +++ b/app/src/views/ProjectsView/ProjectsView.css @@ -174,7 +174,7 @@ min-width: 0; display: flex; flex-direction: column; - gap: var(--cc-space-6); + gap: var(--cc-space-4); padding: var(--cc-space-7); border: 1px solid var(--cc-border-subtle); border-radius: var(--cc-radius-lg); diff --git a/app/src/views/SearchPane/SearchPane.css b/app/src/views/SearchPane/SearchPane.css index 2a31344f..458b381f 100644 --- a/app/src/views/SearchPane/SearchPane.css +++ b/app/src/views/SearchPane/SearchPane.css @@ -40,11 +40,11 @@ .search-results { list-style: none; margin: var(--cc-space-0); - padding: var(--cc-space-7) 0; /* rows supply the matching horizontal inset */ + padding: var(--cc-pane-inset) 0; /* rows supply the matching horizontal inset */ } .search-result { display: block; - padding: var(--cc-space-2) var(--cc-space-7); + padding: var(--cc-space-2) var(--cc-pane-inset); cursor: pointer; border-left: 2px solid transparent; font-family: var(--cc-font-mono); diff --git a/app/src/views/ShortcutsModal/ShortcutsModal.css b/app/src/views/ShortcutsModal/ShortcutsModal.css index 35113210..7aeb16cc 100644 --- a/app/src/views/ShortcutsModal/ShortcutsModal.css +++ b/app/src/views/ShortcutsModal/ShortcutsModal.css @@ -3,17 +3,17 @@ .shortcuts-list kbd, .shortcuts-list .shortcuts-mouse { display: inline-block; - background: var(--cc-accent-bg-soft); + background: var(--cc-bg-app); border-radius: var(--cc-radius-sm); font-family: var(--cc-font-mono); padding: var(--cc-space-px) var(--cc-space-3); } -/* Shell from .code-inline; this rule adds the kbd-specific border, color, - small font, hairline horizontal margin, and resets italics. */ +/* A key cap reads as neutral chrome, not a themed control: surface fill above + plus a neutral input border here (keeps it off the accent palette). */ .shortcuts-list kbd, .shortcuts-list .shortcuts-mouse { - border: 1px solid var(--cc-accent-bg-strong); + border: 1px solid var(--cc-border-input); color: var(--cc-text-secondary); font-size: var(--cc-font-sm); margin: 0 var(--cc-space-px); diff --git a/app/src/views/StreetPane/StreetPane.css b/app/src/views/StreetPane/StreetPane.css index 6848e040..554521d9 100644 --- a/app/src/views/StreetPane/StreetPane.css +++ b/app/src/views/StreetPane/StreetPane.css @@ -19,7 +19,6 @@ - .street-ext-list — one ranked bar per file extension */ .street-body { - padding: var(--cc-space-7); display: flex; flex-direction: column; gap: var(--cc-space-6); diff --git a/app/src/views/StreetPane/StreetPane.tsx b/app/src/views/StreetPane/StreetPane.tsx index ebecd29a..49c70c2a 100644 --- a/app/src/views/StreetPane/StreetPane.tsx +++ b/app/src/views/StreetPane/StreetPane.tsx @@ -40,7 +40,12 @@ export function StreetPane({ state, onClose, onFocus }: StreetPaneProps) { if (!d) { return ( - + onFocus(d) : undefined} focusTitle={`Focus the camera on this road (${KEY_BINDINGS.FOCUS_SELECTION.label})`} onClose={onClose} - bodyClass="street-body" + bodyClass="street-body pane-inset" > {dateRange && (
diff --git a/app/tests/state/settings/theme.test.ts b/app/tests/state/settings/theme.test.ts new file mode 100644 index 00000000..447a0785 --- /dev/null +++ b/app/tests/state/settings/theme.test.ts @@ -0,0 +1,55 @@ +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; accents are in rainbow order', () => { + expect(ACCENT_THEME_DEFAULT).toBe('blue'); + expect(SURFACE_THEME_DEFAULT).toBe('cool'); + // Accents ordered by hue (rainbow), not with the default first. + expect(ACCENT_PRESETS.map((p) => p.value)).toEqual([ + 'amber', + 'green', + 'cyan', + 'blue', + 'purple', + 'pink', + ]); + expect(SURFACE_PRESETS.map((p) => p.value)).toEqual(['cool', 'neutral', 'green', 'warm']); + // Each default is a real member of its list. + expect(ACCENT_PRESETS.some((p) => p.value === ACCENT_THEME_DEFAULT)).toBe(true); + expect(SURFACE_PRESETS.some((p) => p.value === SURFACE_THEME_DEFAULT)).toBe(true); + }); + + 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(); + }); +}); diff --git a/app/tests/styles/themePresets.test.ts b/app/tests/styles/themePresets.test.ts new file mode 100644 index 00000000..05ab5fdc --- /dev/null +++ b/app/tests/styles/themePresets.test.ts @@ -0,0 +1,35 @@ +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 accent preset', () => { + for (const p of ACCENT_PRESETS) { + expect(css).toContain(`[data-cc-accent='${p.value}']`); + } + }); + + it('defines a data-cc-surface block for every surface preset', () => { + for (const p of SURFACE_PRESETS) { + expect(css).toContain(`[data-cc-surface='${p.value}']`); + } + }); + + it('includes explicit blocks for the default presets so their swatch chips do not inherit the active theme', () => { + expect(css).toContain(`[data-cc-accent='${ACCENT_THEME_DEFAULT}']`); + expect(css).toContain(`[data-cc-surface='${SURFACE_THEME_DEFAULT}']`); + }); +}); 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..93972916 --- /dev/null +++ b/app/tests/views/ControlsPane/interfaceThemeSection.test.tsx @@ -0,0 +1,78 @@ +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('arrow key moves selection and focus together (radiogroup pattern)', async () => { + mount(); + // Rainbow order is [amber, green, cyan, blue, purple, pink]; blue is the + // default/active, so ArrowRight lands on purple. + const blue = radio('Blue'); + blue.focus(); + act(() => { + blue.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + }); + await flush(); + expect(ACCENT_THEME.value).toBe('purple'); + expect(document.activeElement).toBe(radio('Purple')); + }); + + 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"] }