From 22f0556ef80dd51f3fd45df403aefcb3661ccaf8 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Fri, 5 Jun 2026 01:29:10 +0300 Subject: [PATCH] Implement M1.5 visual design foundation: tokens, IBM Plex, theme toggle, chart themes --- .gitignore | 3 + docs/IMPLEMENTATION-PLAN.md | 27 +++- docs/architecture/01-state-and-stores.md | 4 +- docs/architecture/02-persistence.md | 6 +- .../05-rendering-theming-preview.md | 24 ++- docs/architecture/09-visual-design.md | 9 +- docs/spec/07-settings.md | 14 +- docs/spec/09-data-model.md | 2 +- index.html | 2 +- package-lock.json | 20 +++ package.json | 2 + src/app/App.module.css | 25 +-- src/app/App.tsx | 2 + src/app/components/LivePreview.module.css | 9 +- src/app/components/SnippetLibrary.module.css | 58 ++++--- src/app/components/SpecEditor.module.css | 5 +- src/app/components/SpecEditor.tsx | 5 +- src/app/components/ThemeToggle.module.css | 21 +++ src/app/components/ThemeToggle.tsx | 51 ++++++ src/app/infrastructure/settings-store.test.ts | 82 ++++++++++ src/app/infrastructure/settings-store.ts | 76 +++++++++ src/app/orchestration/theme.ts | 34 ++++ src/app/stores/AppStore.test.ts | 20 +++ src/app/stores/AppStore.ts | 3 + src/core/theme.ts | 2 +- src/core/vega-themes.test.ts | 39 +++++ src/core/vega-themes.ts | 89 ++++++++--- src/main.tsx | 16 +- styles/base.css | 41 ++++- styles/tokens.css | 151 ++++++++++++++---- vite.config.ts | 7 + 31 files changed, 713 insertions(+), 136 deletions(-) create mode 100644 src/app/components/ThemeToggle.module.css create mode 100644 src/app/components/ThemeToggle.tsx create mode 100644 src/app/infrastructure/settings-store.test.ts create mode 100644 src/app/infrastructure/settings-store.ts create mode 100644 src/app/orchestration/theme.ts create mode 100644 src/app/stores/AppStore.test.ts create mode 100644 src/core/vega-themes.test.ts diff --git a/.gitignore b/.gitignore index 4a3ac13..ff31088 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ coverage # Claude Code per-project session/memory data — machine-local, not part of the # codebase. Shared project config (.claude/skills/, .claude/settings.json) stays tracked. .claude/projects/ + +# M1.5 visual verification screenshots (local only) +.m15-screenshots/ diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index c23ad8b..2b8c46a 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -46,7 +46,7 @@ doc before implementing. |---|-----------|---------|------| | **M0** | Skeleton ✅ | Repo builds, tests run, empty shell renders | — | | **M1** | **MVP core loop** | Author a Vega-Lite snippet, see it render live, it persists | §02, §03A–C, §04, §09A | -| **M1.5** | Visual design foundation | Apply the design language: tokens, IBM Plex, restyled M1 surfaces, chart theme | [arch 09](architecture/09-visual-design.md) | +| **M1.5** | Visual design foundation ✅ | Apply the design language: tokens, IBM Plex, restyled M1 surfaces, chart theme | [arch 09](architecture/09-visual-design.md) | | **M2** | Editor robustness | Draft/Published, validation, schema autocomplete, fit modes | §03D–E, §04, §07(editor) | | **M3** | Datasets | Named reusable data + reference resolution in preview | §05, §03F, §09B | | **M4** | Chart Builder | No-JSON chart composition from a dataset | §06 | @@ -113,7 +113,7 @@ preview, and have it survive reload. Single source kind: inline-data specs only --- -## M1.5 · Visual design foundation → *make the MVP look like itself* +## M1.5 · Visual design foundation ✅ (done) → *make the MVP look like itself* **Goal:** apply our design language so the running MVP looks deliberate, and every later milestone builds on settled tokens instead of placeholders. The expensive part @@ -134,6 +134,11 @@ and the companion `visual-specimen.html`. raw hexes, no hardcoded hues in components. - Establish the reusable component conventions (buttons, fields, list rows, status, focus ring) that M2–M6 reuse. +- **Header theme toggle** (pulled forward from M5): a one-click light⇄dark control, + persisted via the `ui.theme` settings key (a minimal forward-compatible + `settings-store` adapter the full M5 UserSettings store will absorb). Hydrated + before first paint (no FOUC). Justified: the theme system was already complete, + so dogfooding dark mode through M2–M4 beat waiting for the Settings modal. **Core** - Align `src/core/vega-themes.ts`: chart `Config` per theme + a categorical @@ -148,6 +153,15 @@ and the companion `visual-specimen.html`. - Keyboard focus ring visible; text/UI contrast passes AA in light and dark. - No placeholder styling remains on the M1 surfaces. +**Verified:** `typecheck` + `test` (61 passing, incl. `vega-themes.test.ts`) + +`build` (Plex woff2, all script subsets, bundled & precached via the PWA +`globPatterns`). Both themes screenshotted via the real +app (chrome + Monaco + chart all repaint on theme flip); focus ring visible. +Notes from the build-out: the placeholder `'experimental'` theme was renamed to +`'dark'` (the settled name); the swappable `[data-accent]` layer landed with +indigo as the robust default (no switcher UI until M5); Monaco's `fontFamily` is +set to Plex Mono explicitly since it can't read the CSS token. + --- ## M2 · Editor robustness @@ -260,12 +274,15 @@ the reference. - `export-envelope.ts` — build the `{version, exportedAt, exportedBy, snippets, datasets}` envelope. **Infrastructure** -- `settings-store.ts` (localStorage); `ux-prefs` for sort + panel layout (§09D). +- `settings-store.ts` (localStorage): **extend** the minimal M1.5 adapter (which + already persists `ui.theme`) to the full UserSettings record; `ux-prefs` for sort + + panel layout (§09D). **App** - Settings **modal** (Appearance/Editor/Performance/Formatting), Apply/Cancel/Reset, - dirty indicator; wire render-debounce + theme + date-format through to the app - (the themes themselves already exist from M1.5 — this adds the switcher UI). + dirty indicator; wire render-debounce + theme + date-format through to the app. + Theme already switches (M1.5 header toggle + chart/editor themes) — M5 surfaces + it inside the modal too (sharing the same `ui.theme`) and wires the rest. - Header **Import**/**Export** (direct file dialog / download, no modal). - Date formatting util (smart/iso/custom) used by the library list. diff --git a/docs/architecture/01-state-and-stores.md b/docs/architecture/01-state-and-stores.md index ff8afdb..2d9ef2e 100644 --- a/docs/architecture/01-state-and-stores.md +++ b/docs/architecture/01-state-and-stores.md @@ -373,8 +373,8 @@ export function ThemeToggle() { const theme = useAppStore((s) => s.uiTheme); const setTheme = useAppStore((s) => s.setTheme); return ( - ); } diff --git a/docs/architecture/02-persistence.md b/docs/architecture/02-persistence.md index 25a9afd..03e7a95 100644 --- a/docs/architecture/02-persistence.md +++ b/docs/architecture/02-persistence.md @@ -240,7 +240,7 @@ export interface UserSettings { editor: { fontSize: number; theme: string; minimap: boolean; wordWrap: 'on' | 'off'; lineNumbers: 'on' | 'off'; tabSize: number }; performance: { renderDebounce: number }; - ui: { theme: 'light' | 'experimental'; previewFitMode: 'default' | 'width' | 'height' | 'full' }; + ui: { theme: 'light' | 'dark'; previewFitMode: 'default' | 'width' | 'height' | 'full' }; formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string }; } @@ -256,7 +256,7 @@ const DEFAULTS: UserSettings = { }; // NOTE — editor.theme default is 'auto': the editor theme follows the app UI -// theme (light -> light editor theme, experimental -> dark) via custom Monaco +// theme (light -> light editor theme, dark -> dark) via custom Monaco // themes that match the app chrome, unless the user picks an explicit override. // The explicit-override option set (custom themes; whether to include High // Contrast or the stock Monaco themes) is still TBD — see spec §07's provisional @@ -315,6 +315,8 @@ const SORT_DEFAULTS = { sortBy: 'modified' as const, sortOrder: 'desc' as const > **Do:** keep a single complete `DEFAULTS` object as the source of truth and merge over it. > **Don't:** read individual keys with bespoke `?? fallback` at each call site; one stale default and the shapes drift. +> **Testing:** exercise localStorage adapters against an **injected stub** (`vi.stubGlobal('localStorage', …)`), not the ambient global. Under Node + happy-dom a non-functional Node `localStorage` global shadows happy-dom's, so relying on the ambient one fails with `localStorage.clear is not a function`. Applies to every prefs/settings adapter test (settings-store today; dataset-payload/prefs stores later). + --- ## 6. Storage Tiers, Budgets & Quota Monitoring diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md index d601542..7cbda52 100644 --- a/docs/architecture/05-rendering-theming-preview.md +++ b/docs/architecture/05-rendering-theming-preview.md @@ -139,7 +139,7 @@ export const lightChartConfig: Config = { view: { stroke: 'transparent' }, }; -export const experimentalChartConfig: Config = { +export const darkChartConfig: Config = { background: 'transparent', font: '"Inter", sans-serif', title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' }, @@ -167,7 +167,7 @@ import type { UiTheme } from './theme'; // core-local — never import from src/ const CHART_CONFIG: Record = { light: lightChartConfig, - experimental: experimentalChartConfig, + dark: darkChartConfig, }; export function chartConfigFor(theme: UiTheme): Config { @@ -190,6 +190,26 @@ re-rendering picks up the new config and the chart restyles automatically. - **Don't** let the user's stored spec carry a `config`; the theme config is applied at embed time via the embed options, leaving the spec theme-agnostic. +### Theme flow (end to end) + +Theme spans several layers; the path is: + +`AppStore.uiTheme` (+ `toggleTheme`) → `orchestration/theme.ts` mirrors it onto +`` and writes through to `infrastructure/settings-store.ts` +(localStorage `ui.theme`). On load, `initTheme()` — called from `main.tsx` +**before** `createRoot().render` — hydrates the saved theme. Chart and editor +follow by subscribing to `uiTheme`: `LivePreview` re-embeds with +`chartConfigFor(theme)`, `SpecEditor` sets the Monaco theme. UI chrome repaints +purely from the `[data-theme]` token swap in `styles/tokens.css`. The header +`ThemeToggle` is the user control. + +- **Do** hydrate the theme **synchronously before first paint** — an async + hydrate (e.g. inside `initApp`) flashes the default theme on load. +- **Do** keep the store browser-free: the `data-theme` write and the localStorage + write-through live in `orchestration/theme.ts`, never in the store or a component. +- The control currently lives in the header; spec §07 houses it in the Settings + modal (M5), which will share the same `ui.theme` key. + --- ## 4. Field-Name Escaping diff --git a/docs/architecture/09-visual-design.md b/docs/architecture/09-visual-design.md index 8bc3a80..55f1ee3 100644 --- a/docs/architecture/09-visual-design.md +++ b/docs/architecture/09-visual-design.md @@ -60,8 +60,9 @@ were choices, not drift: ## 3. Tokens All tokens are CSS custom properties on `:root`, themed by overriding them on -`[data-theme]` (and, for accent, `[data-accent]`). The specimen is the live source -of truth for values until they're ported to `styles/tokens.css`. +`[data-theme]` (and, for accent, `[data-accent]`). As of M1.5 the settled values +live in `styles/tokens.css`; the specimen remains the sandbox for trying new +tokens/themes before porting them across. ### 3.1 Typography — IBM Plex @@ -172,7 +173,7 @@ The chart `Config` is themed to match the app, per theme: | Artifact | Role | |---|---| | [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first | -| `styles/tokens.css` | The settled tokens (currently placeholders) — port from the specimen | +| `styles/tokens.css` | The settled tokens — ported from the specimen in M1.5 | | `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion | | component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue | | `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes | @@ -196,7 +197,7 @@ Convention: clone under `/Users/oleh/code/reference/` with | **Principles / the "why"** (philosophy, 2x grid, color rationale, type, motion, icon geometry) | `design-language-website` | `src/pages/`: `philosophy/principles.mdx`, `2x-grid.mdx`, `color.mdx`, `typography/*.mdx`, `animation/overview.mdx`, `iconography/ui-icons/design.mdx` (~1.4 GB clone — image-heavy; the MDX is what we want) | | **Token values** (gray/blue ramps, type scale, font families, motion durations/easings, theme role→value maps) | `carbon` | `packages/colors/src/colors.ts`, `packages/type/src/{scale,fontFamily,fontWeight}.ts`, `packages/motion/src/index.ts`, `packages/themes/src/{white,g100}.ts` | | **Component-level usage guidance** | `carbon-website` | `src/pages/**/*.mdx` | -| **Data-viz categorical chart palette** (for `vega-themes.ts` `range.category`) | `carbon-charts` | *not yet cloned* — clone when we do the chart-theming pass | +| **Data-viz categorical chart palette** (for `vega-themes.ts` `range.category`) | `carbon-charts` | cloned in M1.5 → `packages/core/scss/_color-palette.scss` (the `'14'` pairing, white + g100); token→hex resolved against `carbon` `packages/colors/src/colors.ts` | > The decisions we made *from* these sources are captured above (§1–6) and in the > specimen, so we don't need to re-derive them — only return to the repos to extend diff --git a/docs/spec/07-settings.md b/docs/spec/07-settings.md index b086c4b..2a13690 100644 --- a/docs/spec/07-settings.md +++ b/docs/spec/07-settings.md @@ -13,11 +13,15 @@ Astrolabe provides a **Settings** modal where users tune appearance, the spec ed ### Appearance -Controls the overall UI theme. Choosing the experimental Dark theme switches the whole application chrome to a dark presentation. +Controls the overall UI theme. Choosing the Dark theme switches the whole application chrome to a dark presentation. -| Setting | Options | Default | -| -------- | ----------------------------- | ------- | -| UI theme | Light, Experimental Dark | Light | +| Setting | Options | Default | +| -------- | ------------- | ------- | +| UI theme | Light, Dark | Light | + +The UI theme is also exposed as a **header toggle** for one-click switching; it +reads and writes the same persisted `ui.theme` value as this Appearance control, +so the two always agree. (The toggle shipped in M1.5, ahead of this modal.) ### Editor @@ -33,7 +37,7 @@ These settings configure the spec editor used to edit Vega-Lite specs (see *Spec | Tab size | Integer number of spaces | 2 | - Font size is chosen along a 10–18 range; the current value is shown alongside the control. -- Editor theme controls the syntax/color presentation inside the editor. **Provisional (to be finalized as we implement the editor):** the default is **Auto**, which derives the editor theme from the app UI theme (light app theme → light editor theme, experimental → dark), using custom Monaco themes that match the app chrome. The user may override Auto with an explicit editor theme; the exact override list (custom themes, and whether to include High Contrast or the stock Monaco themes) is deferred. Stored as `editor.theme` with an `'auto'` sentinel for the follow-the-app default. +- Editor theme controls the syntax/color presentation inside the editor. **Provisional (to be finalized as we implement the editor):** the default is **Auto**, which derives the editor theme from the app UI theme (light app theme → light editor theme, dark → dark), using custom Monaco themes that match the app chrome. The user may override Auto with an explicit editor theme; the exact override list (custom themes, and whether to include High Contrast or the stock Monaco themes) is deferred. Stored as `editor.theme` with an `'auto'` sentinel for the follow-the-app default. - Minimap toggles the condensed overview strip beside the editor. - Word wrap toggles soft wrapping of long lines. - Line numbers toggles the line-number gutter. diff --git a/docs/spec/09-data-model.md b/docs/spec/09-data-model.md index 0f0e426..5c964f3 100644 --- a/docs/spec/09-data-model.md +++ b/docs/spec/09-data-model.md @@ -71,7 +71,7 @@ Both **Snippet** and **Dataset** records carry a numeric `version` recording the | `editor.lineNumbers` | string | `on` or `off`. | | `editor.tabSize` | number | Spaces per indentation level. | | `performance.renderDebounce` | number | Delay (ms) before re-rendering the preview after edits. | -| `ui.theme` | string | App theme: `light` or `experimental`. | +| `ui.theme` | string | App theme: `light` or `dark`. | | `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. | | `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. | | `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. | diff --git a/index.html b/index.html index 8a4f130..8c9686c 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/package-lock.json b/package-lock.json index b9c495c..f2b53cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "astrolabe", "version": "0.1.0", "dependencies": { + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", "monaco-editor": "^0.54.0", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -2061,6 +2063,24 @@ "node": ">=18" } }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz", + "integrity": "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.2.8.tgz", + "integrity": "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", diff --git a/package.json b/package.json index fb9519f..1f55ebc 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "format": "prettier --write ." }, "dependencies": { + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", "monaco-editor": "^0.54.0", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/src/app/App.module.css b/src/app/App.module.css index b0b74c0..ccd2207 100644 --- a/src/app/App.module.css +++ b/src/app/App.module.css @@ -7,25 +7,27 @@ .header { display: flex; align-items: center; - gap: var(--space-3); + gap: var(--space-4); height: var(--header-height); - padding: 0 var(--space-4); - border-bottom: 1px solid var(--color-border); - background: var(--color-surface); + padding: 0 var(--space-5); + border-bottom: var(--border-width) solid var(--border); + background: var(--layer-01); flex: 0 0 auto; } .title { font-weight: 600; font-size: 16px; + letter-spacing: 0.01em; } .version { + font-family: var(--font-mono); font-size: 11px; - color: var(--color-text-muted); - border: 1px solid var(--color-border); + color: var(--text-secondary); + border: var(--border-width) solid var(--border-strong); border-radius: var(--radius); - padding: 1px var(--space-2); + padding: var(--space-1) var(--space-3); } .spacer { @@ -41,14 +43,14 @@ .pane { flex: 1 1 0; min-width: 0; - padding: var(--space-4); overflow: auto; - border-right: 1px solid var(--color-border); + border-right: var(--border-width) solid var(--border); + background: var(--bg); } /* Library is a fixed-ish sidebar; editor + preview share the rest. */ .panes > .pane:first-child { - flex: 0 0 260px; + flex: 0 0 280px; } /* Editor pane: Monaco manages its own scroll/layout, so no padding. */ @@ -56,7 +58,8 @@ flex: 1 1 0; min-width: 0; overflow: hidden; - border-right: 1px solid var(--color-border); + border-right: var(--border-width) solid var(--border); + background: var(--bg); } .pane:last-child { diff --git a/src/app/App.tsx b/src/app/App.tsx index 85891a8..f1ea74d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,7 @@ import { LivePreview } from './components/LivePreview'; import { SnippetLibrary } from './components/SnippetLibrary'; import { SpecEditor } from './components/SpecEditor'; +import { ThemeToggle } from './components/ThemeToggle'; import styles from './App.module.css'; /** @@ -18,6 +19,7 @@ export function App() { Astrolabe v{__APP_VERSION__} +
diff --git a/src/app/components/LivePreview.module.css b/src/app/components/LivePreview.module.css index 7887ab7..f606ac6 100644 --- a/src/app/components/LivePreview.module.css +++ b/src/app/components/LivePreview.module.css @@ -2,6 +2,7 @@ height: 100%; width: 100%; overflow: auto; + background: var(--bg); } .chart { @@ -9,16 +10,16 @@ align-items: flex-start; justify-content: center; min-height: 100%; - padding: var(--space-2); + padding: var(--space-5); } .error { margin: 0; - padding: var(--space-3); + padding: var(--space-5); font-family: var(--font-mono); font-size: 12px; - line-height: 1.5; - color: var(--color-error); + line-height: 1.6; + color: var(--support-error); white-space: pre-wrap; word-break: break-word; } diff --git a/src/app/components/SnippetLibrary.module.css b/src/app/components/SnippetLibrary.module.css index 49a75c3..d17521f 100644 --- a/src/app/components/SnippetLibrary.module.css +++ b/src/app/components/SnippetLibrary.module.css @@ -2,24 +2,26 @@ display: flex; flex-direction: column; height: 100%; - gap: var(--space-3); } .createNew { flex: 0 0 auto; - padding: var(--space-2) var(--space-3); - border: 1px solid var(--color-border); + margin: var(--space-4); + height: 40px; + padding: 0 var(--space-5); + border: var(--border-width) solid transparent; border-radius: var(--radius); - background: var(--color-accent); - color: var(--color-accent-contrast); + background: var(--accent); + color: var(--accent-contrast); font: inherit; font-weight: 600; cursor: pointer; - text-align: left; + text-align: center; + transition: background var(--dur-fast) var(--ease); } .createNew:hover { - filter: brightness(1.05); + background: var(--accent-hover); } .list { @@ -29,34 +31,36 @@ flex: 1 1 auto; min-height: 0; overflow: auto; - display: flex; - flex-direction: column; - gap: var(--space-1); + border-top: var(--border-width) solid var(--border); } .empty { - color: var(--color-text-muted); + color: var(--text-secondary); font-size: 13px; - padding: var(--space-2); + padding: var(--space-5) var(--space-4); } .item { display: flex; align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - border: 1px solid transparent; - border-radius: var(--radius); + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-left: 2px solid transparent; cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.item + .item { + border-top: var(--border-width) solid var(--border); } .item:hover { - background: var(--color-surface); + background: var(--layer-01); } .active { - background: var(--color-surface); - border-color: var(--color-accent); + background: var(--layer-01); + border-left-color: var(--accent); } .itemMain { @@ -64,7 +68,7 @@ min-width: 0; display: flex; flex-direction: column; - gap: 2px; + gap: var(--space-1); } .name { @@ -77,26 +81,30 @@ .date { font-size: 11px; - color: var(--color-text-muted); + color: var(--text-secondary); } .delete { flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; border: none; background: none; - color: var(--color-text-muted); + color: var(--text-secondary); cursor: pointer; font-size: 12px; padding: var(--space-1); border-radius: var(--radius); opacity: 0; + transition: opacity var(--dur-fast) var(--ease); } -.item:hover .delete { +.item:hover .delete, +.delete:focus-visible { opacity: 1; } .delete:hover { - color: var(--color-error); - background: var(--color-bg); + color: var(--support-error); } diff --git a/src/app/components/SpecEditor.module.css b/src/app/components/SpecEditor.module.css index 6268d8c..bf33d09 100644 --- a/src/app/components/SpecEditor.module.css +++ b/src/app/components/SpecEditor.module.css @@ -1,6 +1,7 @@ .editorPane { position: relative; height: 100%; + background: var(--bg); } .editor { @@ -15,8 +16,8 @@ display: flex; align-items: center; justify-content: center; - color: var(--color-text-muted); + color: var(--text-secondary); font-size: 13px; - background: var(--color-bg); + background: var(--bg); pointer-events: none; } diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx index 99a5581..81ba87b 100644 --- a/src/app/components/SpecEditor.tsx +++ b/src/app/components/SpecEditor.tsx @@ -42,6 +42,9 @@ export function SpecEditor() { language: 'json', automaticLayout: true, minimap: { enabled: false }, + // The editor is a Plex Mono surface per the design language (doc §3.1). + // Monaco needs an explicit family string — it can't read the CSS token. + fontFamily: "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace", fontSize: 13, tabSize: 2, wordWrap: 'on', @@ -78,7 +81,7 @@ export function SpecEditor() { // Editor theme follows the UI theme (M1: light/dark stock themes). useEffect(() => { - monaco.editor.setTheme(uiTheme === 'experimental' ? 'vs-dark' : 'vs'); + monaco.editor.setTheme(uiTheme === 'dark' ? 'vs-dark' : 'vs'); }, [uiTheme]); return ( diff --git a/src/app/components/ThemeToggle.module.css b/src/app/components/ThemeToggle.module.css new file mode 100644 index 0000000..891b69d --- /dev/null +++ b/src/app/components/ThemeToggle.module.css @@ -0,0 +1,21 @@ +.toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: var(--border-width) solid transparent; + border-radius: var(--radius); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: + background var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} + +.toggle:hover { + background: var(--layer-02); + color: var(--text); +} diff --git a/src/app/components/ThemeToggle.tsx b/src/app/components/ThemeToggle.tsx new file mode 100644 index 0000000..2cfe3fe --- /dev/null +++ b/src/app/components/ThemeToggle.tsx @@ -0,0 +1,51 @@ +/** + * Theme toggle — a header control that flips light ⇄ dark (spec §07 Appearance). + * + * Interim home: the spec houses the UI-theme control inside the Settings modal, + * which arrives in M5. Until then this header button is the control; it persists + * through the same `ui.theme` settings key, so M5 can move it into Settings (or + * keep it as a shortcut) without changing what's stored. + * + * The button shows the icon of the theme you'll switch *to* (moon when light, + * sun when dark) and labels itself for screen readers. The focus ring comes from + * the shared rule in base.css; chart + editor follow the theme via their own + * store subscriptions, so flipping the store repaints everything. + */ + +import { useAppStore } from '../stores/AppStore'; +import styles from './ThemeToggle.module.css'; + +function MoonIcon() { + return ( + + ); +} + +function SunIcon() { + return ( + + ); +} + +export function ThemeToggle() { + const uiTheme = useAppStore((s) => s.uiTheme); + const toggleTheme = useAppStore((s) => s.toggleTheme); + const target = uiTheme === 'dark' ? 'light' : 'dark'; + + return ( + + ); +} diff --git a/src/app/infrastructure/settings-store.test.ts b/src/app/infrastructure/settings-store.test.ts new file mode 100644 index 0000000..b937187 --- /dev/null +++ b/src/app/infrastructure/settings-store.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadUiTheme, saveUiTheme } from './settings-store'; + +const KEY = 'astrolabe:settings'; + +/** + * In-memory localStorage stub. The adapter is tested against a stub rather than + * the ambient global (docs/architecture/02 §5) — doubly necessary here because + * Node ships a non-functional `localStorage` global that shadows happy-dom's. + */ +function makeStorageStub() { + const map = new Map(); + return { + getItem: (k: string) => (map.has(k) ? map.get(k)! : null), + setItem: (k: string, v: string) => void map.set(k, String(v)), + removeItem: (k: string) => void map.delete(k), + clear: () => map.clear(), + key: (i: number) => [...map.keys()][i] ?? null, + get length() { + return map.size; + }, + }; +} + +describe('settings-store · ui.theme', () => { + beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub())); + afterEach(() => vi.unstubAllGlobals()); + + describe('loadUiTheme', () => { + it('defaults to light when nothing is stored', () => { + expect(loadUiTheme()).toBe('light'); + }); + + it('returns a stored valid theme', () => { + localStorage.setItem(KEY, JSON.stringify({ ui: { theme: 'dark' } })); + expect(loadUiTheme()).toBe('dark'); + }); + + it('falls back to light on malformed JSON', () => { + localStorage.setItem(KEY, '{ not valid json'); + expect(loadUiTheme()).toBe('light'); + }); + + it('falls back for an unrecognized/legacy value (e.g. retired "experimental")', () => { + localStorage.setItem(KEY, JSON.stringify({ ui: { theme: 'experimental' } })); + expect(loadUiTheme()).toBe('light'); + }); + + it('tolerates a record with no ui group', () => { + localStorage.setItem(KEY, JSON.stringify({ formatting: { dateFormat: 'iso' } })); + expect(loadUiTheme()).toBe('light'); + }); + }); + + describe('saveUiTheme', () => { + it('round-trips through load', () => { + saveUiTheme('dark'); + expect(loadUiTheme()).toBe('dark'); + }); + + it('preserves other keys already in the settings record (forward-compatible merge)', () => { + // Simulate a future/full UserSettings record written by M5. + localStorage.setItem( + KEY, + JSON.stringify({ + version: 1, + editor: { fontSize: 14 }, + ui: { theme: 'light', previewFitMode: 'width' }, + }), + ); + + saveUiTheme('dark'); + + const stored = JSON.parse(localStorage.getItem(KEY)!); + expect(stored.ui.theme).toBe('dark'); + // Everything else survives — nothing clobbered. + expect(stored.version).toBe(1); + expect(stored.editor.fontSize).toBe(14); + expect(stored.ui.previewFitMode).toBe('width'); + }); + }); +}); diff --git a/src/app/infrastructure/settings-store.ts b/src/app/infrastructure/settings-store.ts new file mode 100644 index 0000000..63a136e --- /dev/null +++ b/src/app/infrastructure/settings-store.ts @@ -0,0 +1,76 @@ +/** + * Settings persistence (localStorage) — docs/architecture/02 §5. + * + * The authoritative home for `ui.theme` is the *UserSettings* record under the + * `astrolabe:settings` key (spec §09C). M1.5 pulls the **theme** slice forward + * (the toggle ships before the Settings modal), so this adapter currently wires + * only `ui.theme`. It reads/writes with **load-with-fallback + write-through + * merge**: a partial record written now is preserved key-for-key, so when M5 + * builds the full UserSettings adapter on this same key it upgrades cleanly + * rather than clobbering anything. + * + * Per the architecture rule, this is one of the only modules that may touch + * `localStorage`; everything else goes through these typed functions. + */ + +import type { UiTheme } from '@core/theme'; + +const KEY = 'astrolabe:settings'; + +/** Spec §07 Appearance default. */ +const DEFAULT_THEME: UiTheme = 'light'; + +/** Loose view of the stored record — M5 will give this its full typed shape. */ +interface StoredSettings { + ui?: { theme?: unknown; [k: string]: unknown }; + [k: string]: unknown; +} + +/** localStorage can be absent or throw (private mode, SSR, blocked storage). */ +function available(): boolean { + try { + return typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function'; + } catch { + return false; + } +} + +function readRaw(): StoredSettings { + if (!available()) return {}; + try { + const raw = localStorage.getItem(KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? (parsed as StoredSettings) : {}; + } catch (err) { + console.warn('[settings] failed to read, using defaults', err); + return {}; + } +} + +function writeRaw(next: StoredSettings): void { + if (!available()) return; + try { + localStorage.setItem(KEY, JSON.stringify(next)); + } catch (err) { + console.warn('[settings] failed to write', err); + } +} + +function isUiTheme(v: unknown): v is UiTheme { + return v === 'light' || v === 'dark'; +} + +/** The persisted UI theme, or the default — unknown/legacy values fall back. */ +export function loadUiTheme(): UiTheme { + const stored = readRaw().ui?.theme; + // An unrecognized value (a future theme, or the retired 'experimental') falls + // back rather than breaking — the load-with-fallback contract (doc §5). + return isUiTheme(stored) ? stored : DEFAULT_THEME; +} + +/** Persist the UI theme, preserving every other key already in the record. */ +export function saveUiTheme(theme: UiTheme): void { + const current = readRaw(); + writeRaw({ ...current, ui: { ...current.ui, theme } }); +} diff --git a/src/app/orchestration/theme.ts b/src/app/orchestration/theme.ts new file mode 100644 index 0000000..5f702fb --- /dev/null +++ b/src/app/orchestration/theme.ts @@ -0,0 +1,34 @@ +/** + * Theme orchestration — bridges the (browser-free) AppStore to the DOM and to + * localStorage, the same store↔adapter pattern as snippet persistence. + * + * `initTheme` runs synchronously from main.tsx *before* first paint so a saved + * dark theme never flashes light on load. `wireTheme` then keeps `` in sync and writes every change through to the settings adapter. + */ + +import { loadUiTheme, saveUiTheme } from '../infrastructure/settings-store'; +import { useAppStore } from '../stores/AppStore'; + +function applyToDocument(theme: string): void { + document.documentElement.dataset.theme = theme; +} + +/** Hydrate the persisted theme into the store + document. Call before render. */ +export function initTheme(): void { + const theme = loadUiTheme(); + useAppStore.getState().setTheme(theme); + applyToDocument(theme); +} + +/** + * Mirror store theme → `` and persist on change. Returns a + * teardown that detaches the subscriber. + */ +export function wireTheme(): () => void { + return useAppStore.subscribe((state, prev) => { + if (state.uiTheme === prev.uiTheme) return; + applyToDocument(state.uiTheme); + saveUiTheme(state.uiTheme); + }); +} diff --git a/src/app/stores/AppStore.test.ts b/src/app/stores/AppStore.test.ts new file mode 100644 index 0000000..a181ce4 --- /dev/null +++ b/src/app/stores/AppStore.test.ts @@ -0,0 +1,20 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { useAppStore } from './AppStore'; + +const store = () => useAppStore.getState(); +beforeEach(() => store().setTheme('light')); + +describe('theme', () => { + test('setTheme sets the theme explicitly', () => { + store().setTheme('dark'); + expect(store().uiTheme).toBe('dark'); + }); + + test('toggleTheme flips light ⇄ dark', () => { + expect(store().uiTheme).toBe('light'); + store().toggleTheme(); + expect(store().uiTheme).toBe('dark'); + store().toggleTheme(); + expect(store().uiTheme).toBe('light'); + }); +}); diff --git a/src/app/stores/AppStore.ts b/src/app/stores/AppStore.ts index a214931..6737d6b 100644 --- a/src/app/stores/AppStore.ts +++ b/src/app/stores/AppStore.ts @@ -23,6 +23,8 @@ export interface AppState { activeModal: ModalName | null; setTheme: (theme: UiTheme) => void; + /** Flip between light and dark — the header ThemeToggle's action. */ + toggleTheme: () => void; /** * Low-level modal setter — the single primitive that mutates `activeModal`. * High-level open/close (snapshot for unsaved-change detection, URL sync, @@ -37,5 +39,6 @@ export const useAppStore = create((set) => ({ activeModal: null, setTheme: (uiTheme) => set({ uiTheme }), + toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })), setActiveModal: (activeModal) => set({ activeModal }), })); diff --git a/src/core/theme.ts b/src/core/theme.ts index 56ac3db..ccdb3a7 100644 --- a/src/core/theme.ts +++ b/src/core/theme.ts @@ -7,4 +7,4 @@ * must never import from `src/app/`. The store and the document `data-theme` * mirror this value; this is its single definition. */ -export type UiTheme = 'light' | 'experimental'; +export type UiTheme = 'light' | 'dark'; diff --git a/src/core/vega-themes.test.ts b/src/core/vega-themes.test.ts new file mode 100644 index 0000000..6d6cdd3 --- /dev/null +++ b/src/core/vega-themes.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { chartConfigFor, darkChartConfig, lightChartConfig } from './vega-themes'; +import type { UiTheme } from './theme'; + +/** + * Light smoke coverage for the chart theme — the design is mostly visual and + * verified by eye (docs/IMPLEMENTATION-PLAN.md M1.5). These guard the contract: + * every theme resolves to a config, charts stay transparent (inherit the + * surface), and the expressive categorical palette is present per theme. + */ +describe('chartConfigFor', () => { + const themes: UiTheme[] = ['light', 'dark']; + + it('maps every UiTheme to its config', () => { + expect(chartConfigFor('light')).toBe(lightChartConfig); + expect(chartConfigFor('dark')).toBe(darkChartConfig); + }); + + it.each(themes)('keeps the chart background transparent (%s)', (theme) => { + expect(chartConfigFor(theme).background).toBe('transparent'); + }); + + it.each(themes)('uses IBM Plex for the chart font (%s)', (theme) => { + expect(chartConfigFor(theme).font).toContain('IBM Plex'); + }); + + it.each(themes)('ships a multi-color categorical palette (%s)', (theme) => { + const category = chartConfigFor(theme).range?.category as string[]; + expect(Array.isArray(category)).toBe(true); + expect(category.length).toBeGreaterThanOrEqual(8); + // All entries are hex colors and distinct (no accidental duplicate slot). + expect(category.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true); + expect(new Set(category).size).toBe(category.length); + }); + + it('uses distinct palettes per theme', () => { + expect(lightChartConfig.range?.category).not.toEqual(darkChartConfig.range?.category); + }); +}); diff --git a/src/core/vega-themes.ts b/src/core/vega-themes.ts index 667b135..3c4df43 100644 --- a/src/core/vega-themes.ts +++ b/src/core/vega-themes.ts @@ -1,57 +1,100 @@ /** - * Vega-Lite chart config per UI theme (docs/architecture/05 §3). + * Vega-Lite chart config per UI theme (docs/architecture/05 §3, 09 §5). * * Portable core: a Vega-Lite `Config` styles every chart globally so charts * visually belong to the app rather than looking like stock Vega-Lite. This is * the single source of truth mapping a `UiTheme` to a config; it is applied at * embed time (never baked into the user's stored spec). Adding a UI theme = one * config object plus one map entry here. + * + * Values track the design language: IBM Plex font, axis/grid colors from the + * Carbon neutral ramp (matching `--text-secondary` / `--border`), and a + * categorical `range.category` palette transcribed from Carbon's data-viz + * 14-color pairing (white theme for light, g100 for dark — see + * carbon-charts `packages/core/scss/_color-palette.scss`). This is the + * expressive "free color" layer (doc §3.5, §5). */ import type { Config } from 'vega-lite'; import type { UiTheme } from './theme'; +const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif'; + +/** Carbon data-viz 14-color categorical palette — white (light) theme. */ +const lightCategory = [ + '#6929c4', // purple 70 + '#1192e8', // cyan 50 + '#005d5d', // teal 70 + '#9f1853', // magenta 70 + '#fa4d56', // red 50 + '#520408', // red 90 + '#198038', // green 60 + '#002d9c', // blue 80 + '#ee5396', // magenta 50 + '#b28600', // yellow 50 + '#009d9a', // teal 50 + '#012749', // cyan 90 + '#8a3800', // orange 70 + '#a56eff', // purple 50 +]; + +/** Carbon data-viz 14-color categorical palette — g100 (dark) theme. */ +const darkCategory = [ + '#8a3ffc', // purple 60 + '#33b1ff', // cyan 40 + '#007d79', // teal 60 + '#ff7eb6', // magenta 40 + '#fa4d56', // red 50 + '#fff1f1', // red 10 + '#6fdc8c', // green 30 + '#4589ff', // blue 50 + '#d02670', // magenta 60 + '#d2a106', // yellow 40 + '#08bdba', // teal 40 + '#bae6ff', // cyan 20 + '#ba4e00', // orange 60 + '#d4bbff', // purple 30 +]; + export const lightChartConfig: Config = { background: 'transparent', - font: '"Inter", system-ui, sans-serif', - title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' }, + font: PLEX, + title: { fontSize: 16, fontWeight: 600, color: '#161616' }, axis: { - domainColor: '#1c1c1e', - gridColor: '#e4e4e7', - gridDash: [3, 3], - labelColor: '#52525b', - titleColor: '#1c1c1e', + domainColor: '#c6c6c6', // --border-strong (light) + gridColor: '#e0e0e0', // --border (light) + gridDash: [2, 2], + labelColor: '#525252', // --text-secondary (light) + titleColor: '#161616', // --text (light) labelFontSize: 11, titleFontSize: 12, + titleFontWeight: 600, }, - range: { - category: ['#2f6df6', '#f5a524', '#17b890', '#e5484d', '#8b5cf6', '#0ea5e9'], - }, + range: { category: lightCategory }, view: { stroke: 'transparent' }, }; -export const experimentalChartConfig: Config = { +export const darkChartConfig: Config = { background: 'transparent', - font: '"Inter", system-ui, sans-serif', - title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' }, + font: PLEX, + title: { fontSize: 16, fontWeight: 600, color: '#f4f4f4' }, axis: { - domainColor: '#a1a1aa', - gridColor: '#3f3f46', - gridDash: [3, 3], - labelColor: '#a1a1aa', - titleColor: '#f4f4f5', + domainColor: '#525252', // --border-strong (dark) + gridColor: '#393939', // --border (dark) + gridDash: [2, 2], + labelColor: '#a8a8a8', // --text-secondary (dark) + titleColor: '#f4f4f4', // --text (dark) labelFontSize: 11, titleFontSize: 12, + titleFontWeight: 600, }, - range: { - category: ['#5b8def', '#f5a524', '#2dd4a7', '#f0666b', '#a78bfa', '#38bdf8'], - }, + range: { category: darkCategory }, view: { stroke: 'transparent' }, }; const CHART_CONFIG: Record = { light: lightChartConfig, - experimental: experimentalChartConfig, + dark: darkChartConfig, }; /** The Vega-Lite config for a UI theme — the only theme → config mapping. */ diff --git a/src/main.tsx b/src/main.tsx index d39cebc..39ed3c2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,18 +1,14 @@ import { createRoot } from 'react-dom/client'; import { App } from './app/App'; import { initApp } from './app/orchestration/startup'; -import { useAppStore } from './app/stores/AppStore'; +import { initTheme, wireTheme } from './app/orchestration/theme'; import '../styles/base.css'; -// Mirror the UI theme onto : apply the initial value before -// first paint, then keep it in sync. (Store stays DOM-free; the adapter is here.) -const applyTheme = (theme: string) => { - document.documentElement.dataset.theme = theme; -}; -applyTheme(useAppStore.getState().uiTheme); -useAppStore.subscribe((state, prev) => { - if (state.uiTheme !== prev.uiTheme) applyTheme(state.uiTheme); -}); +// Hydrate the persisted theme onto before first paint (no +// FOUC), then keep store ↔ DOM ↔ localStorage in sync. Store stays DOM-free; +// all browser access funnels through the orchestration + adapter. +initTheme(); +wireTheme(); // Load the library from IndexedDB (seeding a sample on first run) and wire // persistence. Fire-and-forget: the UI renders immediately and fills in when diff --git a/styles/base.css b/styles/base.css index 1b9c373..b5932ae 100644 --- a/styles/base.css +++ b/styles/base.css @@ -1,5 +1,26 @@ @import './tokens.css'; +/* + * IBM Plex, self-hosted via @fontsource — offline/PWA safe, never a CDN + * (docs/architecture/09 §3.1). Sans: 400 body, 600 emphasis/headings, 300 for + * large display only. Mono: 400 editor/code, 500 for emphasis. Keep weights + * minimal — add one only when a surface needs it. + * + * The family-level import pulls every script subset (latin, latin-ext, cyrillic, + * greek, vietnamese). We keep them all: user data — snippet names, dataset + * values, chart labels — can be in any language, and Plex must render it rather + * than fall back to system-ui. @font-face `unicode-range` means the browser only + * *downloads* the subset a glyph needs, so the unused subsets cost nothing at + * runtime; they only add to the offline precache, which is the price of working + * internationally offline. Vite bundles the woff2 and Workbox precaches them + * (woff2 is in the PWA globPatterns — see vite.config.ts). + */ +@import '@fontsource/ibm-plex-sans/300.css'; +@import '@fontsource/ibm-plex-sans/400.css'; +@import '@fontsource/ibm-plex-sans/600.css'; +@import '@fontsource/ibm-plex-mono/400.css'; +@import '@fontsource/ibm-plex-mono/500.css'; + * { box-sizing: border-box; } @@ -14,8 +35,24 @@ body, body { font-family: var(--font-sans); font-size: var(--font-size-base); - color: var(--color-text); - background: var(--color-bg); + /* Plex "requires space to breathe" — keep body line-height generous (doc §3.1). */ + line-height: 1.45; + color: var(--text); + background: var(--bg); + /* Repaint chrome on theme flip; neutralized under reduced-motion below. */ + transition: + background var(--dur-moderate) var(--ease), + color var(--dur-moderate) var(--ease); +} + +/* + * Shared keyboard focus ring — always visible, accessibility is non-negotiable + * (doc §4, principle 4). Components may override the offset (e.g. inset on + * fields) but should not remove it. + */ +:where(button, input, select, textarea, a, [tabindex]):focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; } @media (prefers-reduced-motion: reduce) { diff --git a/styles/tokens.css b/styles/tokens.css index a86c110..cf49355 100644 --- a/styles/tokens.css +++ b/styles/tokens.css @@ -1,47 +1,128 @@ /* - * Design tokens. Borrowed in spirit from Syto: a single source of truth for - * colors/spacing/typography, themable by overriding the custom properties on - * a [data-theme] root. Concrete values are placeholders pending the design pass. + * Design tokens — the settled values from docs/architecture/09-visual-design.md + * (ported from visual-specimen.html). A single source of truth for type, spacing, + * color, shape, and motion, themed by overriding the role tokens on a + * `[data-theme]` root. Components consume these tokens ONLY — never raw hexes. + * + * Inspired by the IBM Design Language / Carbon (structure borrowed, color free); + * Carbon is not a dependency — the values are transcribed. See doc §2 for the + * deliberate divergences (square chrome, open accent/theming). */ :root { - /* Palette */ - --color-bg: #ffffff; - --color-surface: #f7f7fa; - --color-border: #e2e2ea; - --color-text: #1a1a2e; - --color-text-muted: #6b6b80; - --color-accent: #3b5bdb; - --color-accent-contrast: #ffffff; - - /* Status */ - --color-success: #2f9e44; - --color-error: #e03131; - --color-warning: #f08c00; - --color-info: #1971c2; - - /* Typography */ - --font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; - --font-mono: ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace; + /* Type — IBM Plex (self-hosted via @fontsource, wired in main.tsx) */ + --font-sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace; --font-size-base: 14px; - /* Spacing scale */ - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; + /* Spacing — 8px base unit (2/4 as sub-steps) */ + --space-1: 2px; + --space-2: 4px; + --space-3: 8px; + --space-4: 12px; + --space-5: 16px; --space-6: 24px; + --space-7: 32px; + --space-8: 48px; + --space-9: 64px; + + /* Shape — square chrome (icons keep their own rounded geometry) */ + --radius: 0px; + --border-width: 1px; /* Layout */ --header-height: 48px; - --radius: 6px; + + /* Motion — productive only; neutralized under prefers-reduced-motion in base.css */ + --dur-fast: 70ms; + --dur-fast-2: 110ms; + --dur-moderate: 150ms; + --ease: cubic-bezier(0.2, 0, 0.38, 0.9); + + /* Status — color carries meaning only (light defaults; overridden in dark) */ + --support-error: #da1e28; + --support-success: #198038; + --support-warning: #f1c21b; + --support-info: #0043ce; + --on-status: #ffffff; + + /* Focus ring defaults to the accent. */ + --focus: var(--accent); } -[data-theme='experimental'] { - --color-bg: #16161f; - --color-surface: #1f1f2c; - --color-border: #2c2c3a; - --color-text: #e8e8f0; - --color-text-muted: #9a9ab0; - --color-accent: #748ffc; - --color-accent-contrast: #0b0b12; +/* --- Neutral roles: LIGHT theme (Carbon gray ramp) --- */ +[data-theme='light'] { + --bg: #ffffff; + --layer-01: #f4f4f4; + --layer-02: #e0e0e0; + --border: #e0e0e0; + --border-strong: #c6c6c6; + --text: #161616; + --text-secondary: #525252; + --text-placeholder: #a8a8a8; +} + +/* --- Neutral roles: DARK theme --- */ +[data-theme='dark'] { + --bg: #161616; + --layer-01: #262626; + --layer-02: #393939; + --border: #393939; + --border-strong: #525252; + --text: #f4f4f4; + --text-secondary: #a8a8a8; + --text-placeholder: #6f6f6f; + --support-error: #fa4d56; + --support-success: #42be65; + --support-warning: #f1c21b; + --support-info: #78a9ff; + --on-status: #161616; +} + +/* + * Accent — the FREE, swappable layer (doc §3.3). Indigo is the default and is + * defined on :root / [data-theme='dark'] so `--accent` always resolves even with + * no `[data-accent]` attribute. The alternates below are opt-in; an accent + * switcher arrives with Settings in M5. Brighter values on the dark canvas keep + * accent-on-bg and text-on-accent contrast within AA. + */ +:root { + --accent: #5b54e6; + --accent-hover: #4a43d6; + --accent-contrast: #ffffff; +} +[data-theme='dark'] { + --accent: #8b85f0; + --accent-hover: #a29bf5; + --accent-contrast: #161616; +} + +[data-accent='teal'] { + --accent: #0f766e; + --accent-hover: #0c5f59; + --accent-contrast: #ffffff; +} +[data-accent='amber'] { + --accent: #b45309; + --accent-hover: #92400e; + --accent-contrast: #ffffff; +} +[data-accent='rose'] { + --accent: #be123c; + --accent-hover: #9f1239; + --accent-contrast: #ffffff; +} +[data-theme='dark'][data-accent='teal'] { + --accent: #2dd4bf; + --accent-hover: #5eead4; + --accent-contrast: #161616; +} +[data-theme='dark'][data-accent='amber'] { + --accent: #fbbf24; + --accent-hover: #fcd34d; + --accent-contrast: #161616; +} +[data-theme='dark'][data-accent='rose'] { + --accent: #fb7185; + --accent-hover: #fda4af; + --accent-contrast: #161616; } diff --git a/vite.config.ts b/vite.config.ts index 60f2fbc..500e05a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,9 +41,16 @@ export default defineConfig({ theme_color: '#1a1a2e', background_color: '#ffffff', display: 'standalone', + // TODO: theme_color/background_color are stale placeholders — '#1a1a2e' was + // the old --color-text hex, retired by the M1.5 token rename. Re-pick from + // the settled palette (and reconcile with light/dark) when icons land. icons: [], }, workbox: { + // Workbox's default globPatterns omit woff2; add it so the self-hosted + // Plex fonts are precached too (offline is a core requirement — without + // this the first offline load silently falls back to system fonts). + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], // Monaco and Vega vendor chunks exceed Workbox's 2 MiB default; raise the // ceiling so the whole app (offline is a core requirement) is precached. maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,