Implement M1.5 visual design foundation: tokens, IBM Plex, theme toggle, chart themes

This commit is contained in:
2026-06-05 01:29:10 +03:00
parent 547edb85e4
commit 22f0556ef8
31 changed files with 713 additions and 136 deletions
+3
View File
@@ -11,3 +11,6 @@ coverage
# Claude Code per-project session/memory data — machine-local, not part of the # Claude Code per-project session/memory data — machine-local, not part of the
# codebase. Shared project config (.claude/skills/, .claude/settings.json) stays tracked. # codebase. Shared project config (.claude/skills/, .claude/settings.json) stays tracked.
.claude/projects/ .claude/projects/
# M1.5 visual verification screenshots (local only)
.m15-screenshots/
+22 -5
View File
@@ -46,7 +46,7 @@ doc before implementing.
|---|-----------|---------|------| |---|-----------|---------|------|
| **M0** | Skeleton ✅ | Repo builds, tests run, empty shell renders | — | | **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, §03AC, §04, §09A | | **M1** | **MVP core loop** | Author a Vega-Lite snippet, see it render live, it persists | §02, §03AC, §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 | §03DE, §04, §07(editor) | | **M2** | Editor robustness | Draft/Published, validation, schema autocomplete, fit modes | §03DE, §04, §07(editor) |
| **M3** | Datasets | Named reusable data + reference resolution in preview | §05, §03F, §09B | | **M3** | Datasets | Named reusable data + reference resolution in preview | §05, §03F, §09B |
| **M4** | Chart Builder | No-JSON chart composition from a dataset | §06 | | **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 **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 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. raw hexes, no hardcoded hues in components.
- Establish the reusable component conventions (buttons, fields, list rows, status, - Establish the reusable component conventions (buttons, fields, list rows, status,
focus ring) that M2M6 reuse. focus ring) that M2M6 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 M2M4 beat waiting for the Settings modal.
**Core** **Core**
- Align `src/core/vega-themes.ts`: chart `Config` per theme + a categorical - 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. - Keyboard focus ring visible; text/UI contrast passes AA in light and dark.
- No placeholder styling remains on the M1 surfaces. - 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 ## M2 · Editor robustness
@@ -260,12 +274,15 @@ the reference.
- `export-envelope.ts` — build the `{version, exportedAt, exportedBy, snippets, datasets}` envelope. - `export-envelope.ts` — build the `{version, exportedAt, exportedBy, snippets, datasets}` envelope.
**Infrastructure** **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** **App**
- Settings **modal** (Appearance/Editor/Performance/Formatting), Apply/Cancel/Reset, - Settings **modal** (Appearance/Editor/Performance/Formatting), Apply/Cancel/Reset,
dirty indicator; wire render-debounce + theme + date-format through to the app 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). 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). - Header **Import**/**Export** (direct file dialog / download, no modal).
- Date formatting util (smart/iso/custom) used by the library list. - Date formatting util (smart/iso/custom) used by the library list.
+2 -2
View File
@@ -373,8 +373,8 @@ export function ThemeToggle() {
const theme = useAppStore((s) => s.uiTheme); const theme = useAppStore((s) => s.uiTheme);
const setTheme = useAppStore((s) => s.setTheme); const setTheme = useAppStore((s) => s.setTheme);
return ( return (
<button onClick={() => setTheme(theme === 'experimental' ? 'light' : 'experimental')}> <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'experimental' ? '🌙' : '☀️'} {theme === 'dark' ? '🌙' : '☀️'}
</button> </button>
); );
} }
+4 -2
View File
@@ -240,7 +240,7 @@ export interface UserSettings {
editor: { fontSize: number; theme: string; minimap: boolean; wordWrap: 'on' | 'off'; editor: { fontSize: number; theme: string; minimap: boolean; wordWrap: 'on' | 'off';
lineNumbers: 'on' | 'off'; tabSize: number }; lineNumbers: 'on' | 'off'; tabSize: number };
performance: { renderDebounce: 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 }; 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 // 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. // themes that match the app chrome, unless the user picks an explicit override.
// The explicit-override option set (custom themes; whether to include High // 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 // 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. > **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. > **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 ## 6. Storage Tiers, Budgets & Quota Monitoring
@@ -139,7 +139,7 @@ export const lightChartConfig: Config = {
view: { stroke: 'transparent' }, view: { stroke: 'transparent' },
}; };
export const experimentalChartConfig: Config = { export const darkChartConfig: Config = {
background: 'transparent', background: 'transparent',
font: '"Inter", sans-serif', font: '"Inter", sans-serif',
title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' }, 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<UiTheme, Config> = { const CHART_CONFIG: Record<UiTheme, Config> = {
light: lightChartConfig, light: lightChartConfig,
experimental: experimentalChartConfig, dark: darkChartConfig,
}; };
export function chartConfigFor(theme: UiTheme): Config { 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 - **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. 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
`<html data-theme>` 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 ## 4. Field-Name Escaping
+5 -4
View File
@@ -60,8 +60,9 @@ were choices, not drift:
## 3. Tokens ## 3. Tokens
All tokens are CSS custom properties on `:root`, themed by overriding them on 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 `[data-theme]` (and, for accent, `[data-accent]`). As of M1.5 the settled values
of truth for values until they're ported to `styles/tokens.css`. live in `styles/tokens.css`; the specimen remains the sandbox for trying new
tokens/themes before porting them across.
### 3.1 Typography — IBM Plex ### 3.1 Typography — IBM Plex
@@ -172,7 +173,7 @@ The chart `Config` is themed to match the app, per theme:
| Artifact | Role | | Artifact | Role |
|---|---| |---|---|
| [`visual-specimen.html`](./visual-specimen.html) | Living preview + token sandbox. Iterate here first | | [`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 | | `styles/base.css` | Font wiring (`@fontsource`), reset, reduced-motion |
| component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue | | component `*.module.css` | Consume tokens only; no raw hexes, no hardcoded hue |
| `src/core/vega-themes.ts` | Chart `Config` per theme; categorical palettes | | `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) | | **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` | | **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` | | **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 (§16) and in the > The decisions we made *from* these sources are captured above (§16) and in the
> specimen, so we don't need to re-derive them — only return to the repos to extend > specimen, so we don't need to re-derive them — only return to the repos to extend
+8 -4
View File
@@ -13,11 +13,15 @@ Astrolabe provides a **Settings** modal where users tune appearance, the spec ed
### Appearance ### 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 | | Setting | Options | Default |
| -------- | ----------------------------- | ------- | | -------- | ------------- | ------- |
| UI theme | Light, Experimental Dark | Light | | 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 ### 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 | | Tab size | Integer number of spaces | 2 |
- Font size is chosen along a 1018 range; the current value is shown alongside the control. - Font size is chosen along a 1018 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. - Minimap toggles the condensed overview strip beside the editor.
- Word wrap toggles soft wrapping of long lines. - Word wrap toggles soft wrapping of long lines.
- Line numbers toggles the line-number gutter. - Line numbers toggles the line-number gutter.
+1 -1
View File
@@ -71,7 +71,7 @@ Both **Snippet** and **Dataset** records carry a numeric `version` recording the
| `editor.lineNumbers` | string | `on` or `off`. | | `editor.lineNumbers` | string | `on` or `off`. |
| `editor.tabSize` | number | Spaces per indentation level. | | `editor.tabSize` | number | Spaces per indentation level. |
| `performance.renderDebounce` | number | Delay (ms) before re-rendering the preview after edits. | | `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`. | | `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. |
| `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. | | `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. |
| `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. | | `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. |
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" data-theme="light">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+20
View File
@@ -8,6 +8,8 @@
"name": "astrolabe", "name": "astrolabe",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.2.8",
"monaco-editor": "^0.54.0", "monaco-editor": "^0.54.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
@@ -2061,6 +2063,24 @@
"node": ">=18" "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": { "node_modules/@isaacs/cliui": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
+2
View File
@@ -14,6 +14,8 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/ibm-plex-sans": "^5.2.8",
"monaco-editor": "^0.54.0", "monaco-editor": "^0.54.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
+14 -11
View File
@@ -7,25 +7,27 @@
.header { .header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-3); gap: var(--space-4);
height: var(--header-height); height: var(--header-height);
padding: 0 var(--space-4); padding: 0 var(--space-5);
border-bottom: 1px solid var(--color-border); border-bottom: var(--border-width) solid var(--border);
background: var(--color-surface); background: var(--layer-01);
flex: 0 0 auto; flex: 0 0 auto;
} }
.title { .title {
font-weight: 600; font-weight: 600;
font-size: 16px; font-size: 16px;
letter-spacing: 0.01em;
} }
.version { .version {
font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
color: var(--color-text-muted); color: var(--text-secondary);
border: 1px solid var(--color-border); border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
padding: 1px var(--space-2); padding: var(--space-1) var(--space-3);
} }
.spacer { .spacer {
@@ -41,14 +43,14 @@
.pane { .pane {
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
padding: var(--space-4);
overflow: auto; 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. */ /* Library is a fixed-ish sidebar; editor + preview share the rest. */
.panes > .pane:first-child { .panes > .pane:first-child {
flex: 0 0 260px; flex: 0 0 280px;
} }
/* Editor pane: Monaco manages its own scroll/layout, so no padding. */ /* Editor pane: Monaco manages its own scroll/layout, so no padding. */
@@ -56,7 +58,8 @@
flex: 1 1 0; flex: 1 1 0;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
border-right: 1px solid var(--color-border); border-right: var(--border-width) solid var(--border);
background: var(--bg);
} }
.pane:last-child { .pane:last-child {
+2
View File
@@ -1,6 +1,7 @@
import { LivePreview } from './components/LivePreview'; import { LivePreview } from './components/LivePreview';
import { SnippetLibrary } from './components/SnippetLibrary'; import { SnippetLibrary } from './components/SnippetLibrary';
import { SpecEditor } from './components/SpecEditor'; import { SpecEditor } from './components/SpecEditor';
import { ThemeToggle } from './components/ThemeToggle';
import styles from './App.module.css'; import styles from './App.module.css';
/** /**
@@ -18,6 +19,7 @@ export function App() {
<span className={styles.title}>Astrolabe</span> <span className={styles.title}>Astrolabe</span>
<span className={styles.version}>v{__APP_VERSION__}</span> <span className={styles.version}>v{__APP_VERSION__}</span>
<span className={styles.spacer} /> <span className={styles.spacer} />
<ThemeToggle />
</header> </header>
<main className={styles.panes}> <main className={styles.panes}>
+5 -4
View File
@@ -2,6 +2,7 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
overflow: auto; overflow: auto;
background: var(--bg);
} }
.chart { .chart {
@@ -9,16 +10,16 @@
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
min-height: 100%; min-height: 100%;
padding: var(--space-2); padding: var(--space-5);
} }
.error { .error {
margin: 0; margin: 0;
padding: var(--space-3); padding: var(--space-5);
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.6;
color: var(--color-error); color: var(--support-error);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
+33 -25
View File
@@ -2,24 +2,26 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
gap: var(--space-3);
} }
.createNew { .createNew {
flex: 0 0 auto; flex: 0 0 auto;
padding: var(--space-2) var(--space-3); margin: var(--space-4);
border: 1px solid var(--color-border); height: 40px;
padding: 0 var(--space-5);
border: var(--border-width) solid transparent;
border-radius: var(--radius); border-radius: var(--radius);
background: var(--color-accent); background: var(--accent);
color: var(--color-accent-contrast); color: var(--accent-contrast);
font: inherit; font: inherit;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
text-align: left; text-align: center;
transition: background var(--dur-fast) var(--ease);
} }
.createNew:hover { .createNew:hover {
filter: brightness(1.05); background: var(--accent-hover);
} }
.list { .list {
@@ -29,34 +31,36 @@
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
display: flex; border-top: var(--border-width) solid var(--border);
flex-direction: column;
gap: var(--space-1);
} }
.empty { .empty {
color: var(--color-text-muted); color: var(--text-secondary);
font-size: 13px; font-size: 13px;
padding: var(--space-2); padding: var(--space-5) var(--space-4);
} }
.item { .item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-3);
padding: var(--space-2) var(--space-3); padding: var(--space-3) var(--space-4);
border: 1px solid transparent; border-left: 2px solid transparent;
border-radius: var(--radius);
cursor: pointer; cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.item + .item {
border-top: var(--border-width) solid var(--border);
} }
.item:hover { .item:hover {
background: var(--color-surface); background: var(--layer-01);
} }
.active { .active {
background: var(--color-surface); background: var(--layer-01);
border-color: var(--color-accent); border-left-color: var(--accent);
} }
.itemMain { .itemMain {
@@ -64,7 +68,7 @@
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: var(--space-1);
} }
.name { .name {
@@ -77,26 +81,30 @@
.date { .date {
font-size: 11px; font-size: 11px;
color: var(--color-text-muted); color: var(--text-secondary);
} }
.delete { .delete {
flex: 0 0 auto; flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
border: none; border: none;
background: none; background: none;
color: var(--color-text-muted); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
font-size: 12px; font-size: 12px;
padding: var(--space-1); padding: var(--space-1);
border-radius: var(--radius); border-radius: var(--radius);
opacity: 0; opacity: 0;
transition: opacity var(--dur-fast) var(--ease);
} }
.item:hover .delete { .item:hover .delete,
.delete:focus-visible {
opacity: 1; opacity: 1;
} }
.delete:hover { .delete:hover {
color: var(--color-error); color: var(--support-error);
background: var(--color-bg);
} }
+3 -2
View File
@@ -1,6 +1,7 @@
.editorPane { .editorPane {
position: relative; position: relative;
height: 100%; height: 100%;
background: var(--bg);
} }
.editor { .editor {
@@ -15,8 +16,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--color-text-muted); color: var(--text-secondary);
font-size: 13px; font-size: 13px;
background: var(--color-bg); background: var(--bg);
pointer-events: none; pointer-events: none;
} }
+4 -1
View File
@@ -42,6 +42,9 @@ export function SpecEditor() {
language: 'json', language: 'json',
automaticLayout: true, automaticLayout: true,
minimap: { enabled: false }, 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, fontSize: 13,
tabSize: 2, tabSize: 2,
wordWrap: 'on', wordWrap: 'on',
@@ -78,7 +81,7 @@ export function SpecEditor() {
// Editor theme follows the UI theme (M1: light/dark stock themes). // Editor theme follows the UI theme (M1: light/dark stock themes).
useEffect(() => { useEffect(() => {
monaco.editor.setTheme(uiTheme === 'experimental' ? 'vs-dark' : 'vs'); monaco.editor.setTheme(uiTheme === 'dark' ? 'vs-dark' : 'vs');
}, [uiTheme]); }, [uiTheme]);
return ( return (
+21
View File
@@ -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);
}
+51
View File
@@ -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 (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
);
}
function SunIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="4.5" />
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
);
}
export function ThemeToggle() {
const uiTheme = useAppStore((s) => s.uiTheme);
const toggleTheme = useAppStore((s) => s.toggleTheme);
const target = uiTheme === 'dark' ? 'light' : 'dark';
return (
<button
type="button"
className={styles.toggle}
onClick={toggleTheme}
aria-label={`Switch to ${target} theme`}
title={`Switch to ${target} theme`}
>
{uiTheme === 'dark' ? <SunIcon /> : <MoonIcon />}
</button>
);
}
@@ -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<string, string>();
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');
});
});
});
+76
View File
@@ -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 } });
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Theme orchestration bridges the (browser-free) AppStore to the DOM and to
* localStorage, the same storeadapter 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 `<html
* data-theme>` 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 `<html data-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);
});
}
+20
View File
@@ -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');
});
});
+3
View File
@@ -23,6 +23,8 @@ export interface AppState {
activeModal: ModalName | null; activeModal: ModalName | null;
setTheme: (theme: UiTheme) => void; 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`. * Low-level modal setter the single primitive that mutates `activeModal`.
* High-level open/close (snapshot for unsaved-change detection, URL sync, * High-level open/close (snapshot for unsaved-change detection, URL sync,
@@ -37,5 +39,6 @@ export const useAppStore = create<AppState>((set) => ({
activeModal: null, activeModal: null,
setTheme: (uiTheme) => set({ uiTheme }), setTheme: (uiTheme) => set({ uiTheme }),
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
setActiveModal: (activeModal) => set({ activeModal }), setActiveModal: (activeModal) => set({ activeModal }),
})); }));
+1 -1
View File
@@ -7,4 +7,4 @@
* must never import from `src/app/`. The store and the document `data-theme` * must never import from `src/app/`. The store and the document `data-theme`
* mirror this value; this is its single definition. * mirror this value; this is its single definition.
*/ */
export type UiTheme = 'light' | 'experimental'; export type UiTheme = 'light' | 'dark';
+39
View File
@@ -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);
});
});
+66 -23
View File
@@ -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 * 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 * 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 * 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 * embed time (never baked into the user's stored spec). Adding a UI theme = one
* config object plus one map entry here. * 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 { Config } from 'vega-lite';
import type { UiTheme } from './theme'; 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 = { export const lightChartConfig: Config = {
background: 'transparent', background: 'transparent',
font: '"Inter", system-ui, sans-serif', font: PLEX,
title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' }, title: { fontSize: 16, fontWeight: 600, color: '#161616' },
axis: { axis: {
domainColor: '#1c1c1e', domainColor: '#c6c6c6', // --border-strong (light)
gridColor: '#e4e4e7', gridColor: '#e0e0e0', // --border (light)
gridDash: [3, 3], gridDash: [2, 2],
labelColor: '#52525b', labelColor: '#525252', // --text-secondary (light)
titleColor: '#1c1c1e', titleColor: '#161616', // --text (light)
labelFontSize: 11, labelFontSize: 11,
titleFontSize: 12, titleFontSize: 12,
titleFontWeight: 600,
}, },
range: { range: { category: lightCategory },
category: ['#2f6df6', '#f5a524', '#17b890', '#e5484d', '#8b5cf6', '#0ea5e9'],
},
view: { stroke: 'transparent' }, view: { stroke: 'transparent' },
}; };
export const experimentalChartConfig: Config = { export const darkChartConfig: Config = {
background: 'transparent', background: 'transparent',
font: '"Inter", system-ui, sans-serif', font: PLEX,
title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' }, title: { fontSize: 16, fontWeight: 600, color: '#f4f4f4' },
axis: { axis: {
domainColor: '#a1a1aa', domainColor: '#525252', // --border-strong (dark)
gridColor: '#3f3f46', gridColor: '#393939', // --border (dark)
gridDash: [3, 3], gridDash: [2, 2],
labelColor: '#a1a1aa', labelColor: '#a8a8a8', // --text-secondary (dark)
titleColor: '#f4f4f5', titleColor: '#f4f4f4', // --text (dark)
labelFontSize: 11, labelFontSize: 11,
titleFontSize: 12, titleFontSize: 12,
titleFontWeight: 600,
}, },
range: { range: { category: darkCategory },
category: ['#5b8def', '#f5a524', '#2dd4a7', '#f0666b', '#a78bfa', '#38bdf8'],
},
view: { stroke: 'transparent' }, view: { stroke: 'transparent' },
}; };
const CHART_CONFIG: Record<UiTheme, Config> = { const CHART_CONFIG: Record<UiTheme, Config> = {
light: lightChartConfig, light: lightChartConfig,
experimental: experimentalChartConfig, dark: darkChartConfig,
}; };
/** The Vega-Lite config for a UI theme — the only theme → config mapping. */ /** The Vega-Lite config for a UI theme — the only theme → config mapping. */
+6 -10
View File
@@ -1,18 +1,14 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { App } from './app/App'; import { App } from './app/App';
import { initApp } from './app/orchestration/startup'; import { initApp } from './app/orchestration/startup';
import { useAppStore } from './app/stores/AppStore'; import { initTheme, wireTheme } from './app/orchestration/theme';
import '../styles/base.css'; import '../styles/base.css';
// Mirror the UI theme onto <html data-theme>: apply the initial value before // Hydrate the persisted theme onto <html data-theme> before first paint (no
// first paint, then keep it in sync. (Store stays DOM-free; the adapter is here.) // FOUC), then keep store ↔ DOM ↔ localStorage in sync. Store stays DOM-free;
const applyTheme = (theme: string) => { // all browser access funnels through the orchestration + adapter.
document.documentElement.dataset.theme = theme; initTheme();
}; wireTheme();
applyTheme(useAppStore.getState().uiTheme);
useAppStore.subscribe((state, prev) => {
if (state.uiTheme !== prev.uiTheme) applyTheme(state.uiTheme);
});
// Load the library from IndexedDB (seeding a sample on first run) and wire // 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 // persistence. Fire-and-forget: the UI renders immediately and fills in when
+39 -2
View File
@@ -1,5 +1,26 @@
@import './tokens.css'; @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; box-sizing: border-box;
} }
@@ -14,8 +35,24 @@ body,
body { body {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--font-size-base); font-size: var(--font-size-base);
color: var(--color-text); /* Plex "requires space to breathe" — keep body line-height generous (doc §3.1). */
background: var(--color-bg); 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) { @media (prefers-reduced-motion: reduce) {
+116 -35
View File
@@ -1,47 +1,128 @@
/* /*
* Design tokens. Borrowed in spirit from Syto: a single source of truth for * Design tokens the settled values from docs/architecture/09-visual-design.md
* colors/spacing/typography, themable by overriding the custom properties on * (ported from visual-specimen.html). A single source of truth for type, spacing,
* a [data-theme] root. Concrete values are placeholders pending the design pass. * 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 { :root {
/* Palette */ /* Type — IBM Plex (self-hosted via @fontsource, wired in main.tsx) */
--color-bg: #ffffff; --font-sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
--color-surface: #f7f7fa; --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', 'Cascadia Code', Menlo, monospace;
--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;
--font-size-base: 14px; --font-size-base: 14px;
/* Spacing scale */ /* Spacing — 8px base unit (2/4 as sub-steps) */
--space-1: 4px; --space-1: 2px;
--space-2: 8px; --space-2: 4px;
--space-3: 12px; --space-3: 8px;
--space-4: 16px; --space-4: 12px;
--space-5: 16px;
--space-6: 24px; --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 */ /* Layout */
--header-height: 48px; --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'] { /* --- Neutral roles: LIGHT theme (Carbon gray ramp) --- */
--color-bg: #16161f; [data-theme='light'] {
--color-surface: #1f1f2c; --bg: #ffffff;
--color-border: #2c2c3a; --layer-01: #f4f4f4;
--color-text: #e8e8f0; --layer-02: #e0e0e0;
--color-text-muted: #9a9ab0; --border: #e0e0e0;
--color-accent: #748ffc; --border-strong: #c6c6c6;
--color-accent-contrast: #0b0b12; --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;
} }
+7
View File
@@ -41,9 +41,16 @@ export default defineConfig({
theme_color: '#1a1a2e', theme_color: '#1a1a2e',
background_color: '#ffffff', background_color: '#ffffff',
display: 'standalone', 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: [], icons: [],
}, },
workbox: { 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 // 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. // ceiling so the whole app (offline is a core requirement) is precached.
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024, maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,