mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Implement M1.5 visual design foundation: tokens, IBM Plex, theme toggle, chart themes
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -373,8 +373,8 @@ export function ThemeToggle() {
|
||||
const theme = useAppStore((s) => s.uiTheme);
|
||||
const setTheme = useAppStore((s) => s.setTheme);
|
||||
return (
|
||||
<button onClick={() => setTheme(theme === 'experimental' ? 'light' : 'experimental')}>
|
||||
{theme === 'experimental' ? '🌙' : '☀️'}
|
||||
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
|
||||
{theme === 'dark' ? '🌙' : '☀️'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<UiTheme, Config> = {
|
||||
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
|
||||
`<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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
| -------- | ------------- | ------- |
|
||||
| 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.
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
Generated
+20
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+14
-11
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
<span className={styles.title}>Astrolabe</span>
|
||||
<span className={styles.version}>v{__APP_VERSION__}</span>
|
||||
<span className={styles.spacer} />
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
<main className={styles.panes}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 `<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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<AppState>((set) => ({
|
||||
activeModal: null,
|
||||
|
||||
setTheme: (uiTheme) => set({ uiTheme }),
|
||||
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
||||
setActiveModal: (activeModal) => set({ activeModal }),
|
||||
}));
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
@@ -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
@@ -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<UiTheme, Config> = {
|
||||
light: lightChartConfig,
|
||||
experimental: experimentalChartConfig,
|
||||
dark: darkChartConfig,
|
||||
};
|
||||
|
||||
/** The Vega-Lite config for a UI theme — the only theme → config mapping. */
|
||||
|
||||
+6
-10
@@ -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 <html data-theme>: 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 <html data-theme> 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
|
||||
|
||||
+39
-2
@@ -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) {
|
||||
|
||||
+116
-35
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user