diff --git a/docs/architecture/02-persistence.md b/docs/architecture/02-persistence.md index 45878cf..2ccd63a 100644 --- a/docs/architecture/02-persistence.md +++ b/docs/architecture/02-persistence.md @@ -91,6 +91,19 @@ export function openDB(): Promise { } ``` +**Verify the layout, don't trust the version.** An interrupted upgrade can stamp +the new version without creating the new stores (observed in dev: a hot reload +opened a bumped `DB_VERSION` before the store-creation code for it existed) — +after which `onupgradeneeded` never fires again for that version and every +transaction on the missing store throws `NotFoundError`, permanently. The real +`openDB` therefore checks `db.objectStoreNames` against the expected store list +after every successful open and, if anything is missing, closes and reopens at +`db.version + 1` to force another (idempotent) upgrade pass. Two consequences: +the database **self-heals** instead of being stuck until manually deleted, and +the on-disk version may run **ahead of** `DB_VERSION` — so the open also +catches `VersionError` and retries without an explicit version. Covered by +`db.test.ts` (fake-indexeddb). + ### 2.2 Promise-wrapped CRUD helpers Wrap a single IDB request and a whole transaction so callers write linear `async/await` code. @@ -478,5 +491,9 @@ spec §08 "no partial import is committed" contract holds and the user gets an a 3. Add the object store in `openDB`'s `onupgradeneeded`, guarded by `contains(...)`; bump `DB_VERSION` only if you changed store _layout_. 4. Add a `migrate()` function and call it on every read. 5. Expose typed `load*/save*/ensure*` functions from one infrastructure module — and from _only_ there. -6. If the tier has a budget, hook it into the storage monitor and propagate `QuotaExceededError`. -7. Test the adapter against `fake-indexeddb` / a localStorage stub; test the migration with fixtures from each historical version. +6. Add the app layer: a Zustand store whose low-level `add`/`update`/`remove` are the single mutation point for the collection, and a diffing **write-through subscriber** in `orchestration/` (the `dataset-persistence.ts` shape: compare the array against the previous snapshot, upsert changed records, delete missing ones, toast on failure). +7. Hydrate in `orchestration/startup.ts` and wire the subscriber **after** hydrate — wiring first would re-save every loaded record on each startup. +8. If the tier has a budget, hook it into the storage monitor and propagate `QuotaExceededError`. +9. Test the adapter against `fake-indexeddb` / a localStorage stub; test the migration with fixtures from each historical version. + +The stack for one entity is four files with fixed roles: `infrastructure/-store.ts` (typed IDB adapter) + `infrastructure/-migrations.ts` (read-time upgrade) + `stores/Store.ts` (in-memory collection + feature state) + `orchestration/-persistence.ts` (write-through), joined in `startup.ts`. Snippets, datasets, and custom themes each follow it. diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md index fbe60aa..59567bf 100644 --- a/docs/architecture/05-rendering-theming-preview.md +++ b/docs/architecture/05-rendering-theming-preview.md @@ -172,21 +172,62 @@ swapping the expressive one (future custom themes). ### Selectable chart themes On top of the house pair, the user picks a **chart theme** (spec §04 → Chart -theme) — `ChartThemeId = 'astrolabe' | 'stock' | `: +theme) — `ChartThemeSelection = 'astrolabe' | 'stock' | +| 'custom:'`: - `'astrolabe'` resolves via `chartConfigFor(uiTheme)` (follows light/dark); - `'stock'` resolves to `{}` — nothing injected, pure Vega-Lite defaults; - preset ids resolve to the `vega-themes` package's configs verbatim (the same presets as the Vega editor's theme dropdown; the package is already in the - tree as a vega-embed dependency). + tree as a vega-embed dependency); +- `custom:` resolves to a saved `CustomTheme` record's config (spec §09G). + Selection is keyed by record **id**, not name, so a rename never invalidates + the persisted preference; a missing record (themes hydrate async from + IndexedDB; the record may be deleted) resolves to the house config rather + than rendering unstyled, and deleting the actively-selected theme resets + `AppStore.chartTheme` to `'astrolabe'` (CustomThemeStore.remove). -`chartConfigForSelection(selection, uiTheme)` is the only resolver. The choice -lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by -`orchestration/preferences.ts` (the `previewFitMode` pattern), and is surfaced -by a `SelectControl` in the LivePreview header — **not** inside the -PreviewSettings popover: `SelectControl` and `SettingsPopover` share the -one-open-popover registry, so a select nested in the popover would close (and -unmount) its own parent on open. +`chartConfigForSelection(selection, uiTheme, customThemes)` is the only +resolver; `chartThemeOptions(customThemes)` derives the full picker list +(built-ins, customs, presets — memoize the call: it returns a fresh array). The +choice lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by +`orchestration/preferences.ts` (the `previewFitMode` pattern; persistence +validates with `isChartThemeSelection`, which accepts `custom:` on shape +alone), and is surfaced by a `SelectControl` in the LivePreview header — **not** +inside the PreviewSettings popover: `SelectControl` and `SettingsPopover` share +the one-open-popover registry, so a select nested in the popover would close +(and unmount) its own parent on open. The "Edit themes…" action row opens the +Theme Builder without changing the selection (the VS Code theme-picker +pattern); it closes the custom-themes block — after the built-ins, **before** +the long preset roster — so it's visible without scrolling and sits next to +the entries it manages. + +### Custom themes & the Theme Builder + +`CustomTheme` records (`core/custom-theme.ts`) persist in their own IndexedDB +store through the standard stack: `infrastructure/theme-store.ts` (+ read-time +`theme-migrations.ts`), `stores/CustomThemeStore.ts` (the themes array plus the +builder's draft state), and `orchestration/theme-persistence.ts` (diffing +write-through, wired after hydrate in `startup.ts`) — the exact dataset +pattern, one tier each. + +The Theme Builder modal (`ThemeBuilderModal`, registered as `themeBuilder`, +xlarge shell, no backdrop dismissal) edits a **draft** held in the store: +`{ name, configText }` plus `draftConfig` — the last text state that parsed. +The gallery (`core/theme-preview-specs.ts`, fixed inline-data swatch specs) +renders `draftConfig` per card through the shared `renderSpec` with the +**canvas** renderer and a per-card debounce + chain-lock (the LivePreview +serialization pattern, one lock per card) — so invalid JSON mid-edit never +blanks the preview, and seven concurrent embeds never interleave on a node. +`applyFontToConfig(config, family)` is the font control's transform: it sets +the top-level `font` and rewrites every `font`/`*Font` string slot at any +depth — explicit slots would otherwise keep overriding the new default. + +Creation paths: the builder's "New theme" duplicates the currently selected +chart theme's resolved config, and the editor's **Extract Config to New +Theme** action (`runExtractConfigToTheme`, spec §03G) lifts a spec's `config` +block into a theme, selects it, and removes the block — the spec-to-library +direction of the same boundary the merge action crosses the other way. Render-time precedence: vega-lite merges the injected config **under** the spec's own `config` (`mergeConfig(opt.config, spec.config)` — the spec wins diff --git a/docs/chart-theming-scope.md b/docs/chart-theming-scope.md index 1d5dc53..40b506f 100644 --- a/docs/chart-theming-scope.md +++ b/docs/chart-theming-scope.md @@ -142,9 +142,17 @@ ships, it is an explicit per-font user action, never automatic. (spec §03G): bake the active theme into `spec.config` (existing keys win, render-identical), or lift `spec.config` out to the clipboard (copy before remove — a failed copy aborts). -4. **Custom named themes** — new IndexedDB entity `{ name, config, fonts? }` + list UI; - created by duplicating a preset or extract-from-spec; appears in the slice-2 - selector. Export/import as JSON alongside the library. +4. **Custom named themes** ✅ (2026-06-12, except export/import) — IndexedDB entity + `{ id, name, config }` (`core/custom-theme.ts`, themes store @ DB v2) + the **Theme + Builder** modal: theme list, JSON config editor, a font control that populates one + family across every font slot (`applyFontToConfig`), and a live multi-chart gallery + (`core/theme-preview-specs.ts`) so one edit is previewed across titles, axes, + legends, headers, and the major marks. Created by duplicating the currently-selected + theme (house/preset/custom) or via the editor's **Extract Config to New Theme** + action (spec §03G); appears in the selector as `custom:` (the "Edit themes…" + action row sits right after the customs, before the preset roster); deleting the + active one falls back to Astrolabe. **Remaining:** export/import as JSON alongside + the library (touches the §08 envelope + import-normalize atomicity). 5. **Shipped font roster** — fontsource packages, `@font-face` registration, selector metadata (which themes/fonts pair), `document.fonts.load` gate in the render path, precache strategy above. Roster finalized via visual specimen. @@ -158,6 +166,22 @@ it without a second mechanism). ## 5. Status log +- **2026-06-12 (slice 4)** — **custom named themes + Theme Builder shipped.** + `CustomTheme` entity through the full stack (core → theme-store @ DB v2 → + CustomThemeStore → theme-persistence → startup hydrate); selection model extended to + `custom:` with missing-record fallback to the house style; Theme Builder modal + (xlarge, list + name + config JSON + font-apply control + 7-card live gallery, canvas + renderer, per-card chain-lock); picker gains custom entries + an "Edit themes…" + action row. The font control ships with render-safe faces only (Plex + web-safe + stacks) — the roster slice (5) extends `THEME_FONT_OPTIONS` and adds the + `document.fonts.load` gate. Spec updated (§01C, §04 Chart theme + Theme Builder, + §09C/E/G) + architecture 05 §3. Same-day follow-ups from first use: `openDB` now + verifies the store layout and self-heals an interrupted upgrade (arch 02 §2.1, + `db.test.ts` on fake-indexeddb); "Edit themes…" moved before the preset roster + (discoverability); **Extract Config to New Theme** added as the third Config-menu + action (spec §03G) — config block → saved theme, selected, removed from the spec. + Not yet done: themes in the §08 export/import envelope. + - **2026-06-12 (slices 2–3)** — **theme selector + merge/extract shipped.** `ChartThemeId`/`chartConfigForSelection` in core; `ui.chartTheme` persisted via the `previewFitMode` orchestration pattern; `SelectControl` picker in the preview header; diff --git a/docs/spec/01-application-shell.md b/docs/spec/01-application-shell.md index f5b66e8..70cc39f 100644 --- a/docs/spec/01-application-shell.md +++ b/docs/spec/01-application-shell.md @@ -46,12 +46,12 @@ Notes: ## C. Modal System -The app shows at most one modal at a time. The modal set is: Datasets, About & Help, Donate, Chart Builder, and Extract-to-Dataset. (Settings are deliberately _not_ a modal — they are distributed to per-pane controls; see _Settings_.) +The app shows at most one modal at a time. The modal set is: Datasets, About & Help, Donate, Chart Builder, Extract-to-Dataset, and Theme Builder. (Settings are deliberately _not_ a modal — they are distributed to per-pane controls; see _Settings_.) - Opening any modal closes whichever modal was previously open; the two never overlap. -- Every modal can be dismissed by: clicking its close button, pressing **Escape**, or clicking the backdrop outside the modal body. +- Every modal can be dismissed by: clicking its close button, pressing **Escape**, or clicking the backdrop outside the modal body. Exception: modals holding in-progress work (the Chart Builder and Theme Builder) ignore backdrop clicks; Escape and the close button still dismiss them. - Clicking inside the modal body does not dismiss it. -- The Chart Builder and Extract-to-Dataset modals are opened from within the Datasets / snippet workflows (see _Chart Builder_ and _Datasets_), not from the header. +- The Chart Builder, Extract-to-Dataset, and Theme Builder modals are opened from within the Datasets / snippet / preview workflows (see _Chart Builder_, _Datasets_, and _Live Preview_), not from the header. - Dismissing a modal returns the user to the underlying workspace unchanged. ## D. Keyboard Shortcuts diff --git a/docs/spec/04-live-preview.md b/docs/spec/04-live-preview.md index c47bc09..561748d 100644 --- a/docs/spec/04-live-preview.md +++ b/docs/spec/04-live-preview.md @@ -42,14 +42,30 @@ The preview pane header carries a **Chart theme** picker — a value-select disc - **Astrolabe** (default) — the house style; follows the app's light/dark theme. - **Stock Vega-Lite** — injects nothing; charts render exactly as plain Vega-Lite defaults would anywhere else (white background, default palette and fonts). +- **Custom themes** — the user's saved themes (see _Theme Builder_ below), listed by name between the built-ins and the presets. +- **Edit themes…** — closes the custom-themes block (before the long preset roster, so it's visible without scrolling); opens the Theme Builder instead of changing the selection. - **Presets** — the `vega-themes` preset configs (Excel, ggplot2, FiveThirtyEight, LA Times, Power BI, the Carbon family, …), rendered verbatim and independent of the app's light/dark theme. Behavior: -- The choice is a **global preference**, not per-snippet; it persists across sessions, stored in _Settings_ as `ui.chartTheme`. +- The choice is a **global preference**, not per-snippet; it persists across sessions, stored in _Settings_ as `ui.chartTheme` (`custom:` for a custom theme). - The injected config applies at render time only — it is never written into the snippet's stored spec. A spec's own `config` block overrides the injected config property by property, so a snippet can opt out of any part of it locally (see also _Spec Editor → Spec ↔ config actions_). - Image export reflects the selected theme: exports render from the same themed view. - The Chart Builder preview and onboarding thumbnails are app surfaces and stay house-styled regardless of this choice. +- A selected custom theme whose record is missing (still loading, or deleted in another tab) renders as the house style; deleting the actively-selected theme resets the selection to Astrolabe. + +## Theme Builder + +The **Theme Builder** is a full-size modal for creating and editing custom chart themes — named, persistent Vega-Lite configs (see _Data Model → CustomTheme_). It opens from the Chart theme picker's "Edit themes…" entry. + +Layout: a saved-theme list on the left; the open theme's editor on the right. + +- **New theme** creates a theme seeded as a **copy of the chart theme currently selected** in the preview (house style, stock, a preset, or another custom theme), named after its source (e.g. "FiveThirtyEight copy") and auto-suffixed if taken. Duplicating a preset is the expected starting point. The other creation path is the editor's **Extract Config to New Theme** action (see _Spec Editor → Spec ↔ Config Actions_), which turns a pasted spec's `config` block into a theme directly. +- The editor shows the theme's **name** and its **config as editable JSON text**. Invalid JSON is reported inline and blocks saving; the text must parse to a JSON object. +- A **font control** applies a chosen font family across the whole config in one step: it sets the top-level `font` (Vega-Lite's default for every text mark, label, and title) and rewrites every explicit `font`/`labelFont`/`titleFont`/`subtitleFont` slot anywhere in the config — the slots that would otherwise keep overriding the new default. Offered fonts are limited to faces that render without loading (the app's own Plex faces and web-safe/system stacks) until the self-hosted font roster ships. +- A **gallery** of small fixed sample charts (bar with title, multi-series line with subtitle, stacked area, scatter with a gradient legend, heatmap, donut, facets with headers) re-renders live from the draft config — the same config-injection path the preview uses — so one edit is previewed across every chart surface a config styles. While the JSON is invalid, the gallery keeps showing the last valid state. +- **Save** commits the draft (disabled while unchanged or unparseable). Names are unique case-insensitively, like dataset names. **Delete** removes the theme after confirmation. +- Closing with unsaved edits prompts for discard, like other form modals. A backdrop click does not dismiss the builder (Escape and the close button do). ## Export control diff --git a/docs/spec/09-data-model.md b/docs/spec/09-data-model.md index 57dcb63..fcc5d1c 100644 --- a/docs/spec/09-data-model.md +++ b/docs/spec/09-data-model.md @@ -65,21 +65,21 @@ The current **Dataset** version is `2`. The v1→v2 migration reflects the URL-s **UserSettings** holds persisted user preferences as a single structured record. The semantics and UX of each option are covered in _Settings_; the shape below is the storage contract. -| Field | Type | Meaning | -| ----------------------------- | ------- | ---------------------------------------------------------- | -| `version` | number | Schema version of the settings record, used for migration. | -| `editor.fontSize` | number | Editor font size. | -| `editor.theme` | string | Editor color theme identifier. | -| `editor.minimap` | boolean | Whether the editor minimap is shown. | -| `editor.wordWrap` | string | `on` or `off`. | -| `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 `dark`. | -| `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. | -| `ui.chartTheme` | string | Chart theme: `astrolabe`, `stock`, or a preset id. | -| `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. | -| `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. | +| Field | Type | Meaning | +| ----------------------------- | ------- | -------------------------------------------------------------------------------------------- | +| `version` | number | Schema version of the settings record, used for migration. | +| `editor.fontSize` | number | Editor font size. | +| `editor.theme` | string | Editor color theme identifier. | +| `editor.minimap` | boolean | Whether the editor minimap is shown. | +| `editor.wordWrap` | string | `on` or `off`. | +| `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 `dark`. | +| `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. | +| `ui.chartTheme` | string | Chart theme: `astrolabe`, `stock`, a preset id, or `custom:` naming a _CustomTheme_ (G). | +| `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. | +| `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. | A reference shape: @@ -98,6 +98,7 @@ Some preferences persist independently of _UserSettings_ so they can update freq | ---------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | Snippet store | All _Snippet_ records | Local, with a practical budget of about 5 MB. A storage monitor tracks usage and surfaces warnings as the budget fills (see _Snippet Library_). | | Dataset store | All _Dataset_ records | Local, in a separate, much higher-capacity store, suited to larger payloads. | +| Theme store | All _CustomTheme_ records (G) | Local, separate store; records are small (a config object plus metadata). | | Settings & preferences | _UserSettings_ plus the app/UI preferences in (D) | Local, small. | Everything stays in the browser — no server or account is involved. All tiers survive reload and function offline. Because capacity is finite and per-browser, _Import & Export_ is the supported path for backup and for moving data between browsers or devices. @@ -110,3 +111,18 @@ Snippets and datasets are linked **bidirectionally by dataset name**: `snippet.d - From a dataset, scanning snippets for its `name` in `datasetRefs` yields the snippets that reference it. This name-based link is what the _Snippet Library_ and _Datasets_ surfaces use to show linkage in both directions. The actual resolution of a referenced dataset into spec data at render time is covered in _Live Preview_. + +## G. CustomTheme + +A **CustomTheme** is a user-named Vega-Lite config saved in the library and offered by the _Live Preview → Chart theme_ picker alongside the built-in themes and presets. It is created and edited in the _Theme Builder_ (see _Live Preview_). + +| Field | Type | Meaning | +| ---------- | -------------------- | --------------------------------------------------------------------------------------------- | +| `id` | number | Unique numeric identifier. The picker/persistence selection id is the string `custom:`. | +| `version` | number | Schema version of this record, used for read-time migration (see _Schema versioning_ above). | +| `name` | string | Unique, human-readable name shown in the picker (case-insensitive uniqueness, like datasets). | +| `config` | object | The Vega-Lite config injected at render time when this theme is selected. | +| `created` | ISO-timestamp string | When the theme was first created. | +| `modified` | ISO-timestamp string | When the theme was last changed. | + +Selection is keyed by `id` (not name) so renaming a theme never invalidates the persisted `ui.chartTheme`. A persisted `custom:` whose record no longer exists is not an error: charts render with the house style until the record appears (themes hydrate asynchronously), and deleting the actively-selected theme resets the selection to `astrolabe` explicitly. Custom themes are not yet included in the _Import & Export_ envelope (planned; see `docs/chart-theming-scope.md`). diff --git a/docs/ux-second-pass.md b/docs/ux-second-pass.md index 082d9db..819a43e 100644 --- a/docs/ux-second-pass.md +++ b/docs/ux-second-pass.md @@ -17,6 +17,20 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel showcase) or settings-cluster placement (a persistent global pref); should the popover registry learn nesting; 16 flat options — group presets under a heading? +- **"Edit themes…" action row inside the value picker** (`LivePreview.tsx` — + ChartThemeControl). A non-value action lives inside a single-select disclosure (the + VS Code theme-picker pattern), placed after the custom-themes block and before the + preset roster (first-use feedback: at the very bottom it was invisible without + scrolling). Council questions: should an action be visually separated from the + values (divider, distinct styling); is a mid-list row that opens a modal instead of + selecting surprising to AT users? + +- **Theme Builder config editor is a plain textarea** (`ThemeBuilderModal.tsx`). Monaco + (with the Vega-Lite config schema for completions) would match the main editor but is + heavy inside a modal and untested in that mounting. Revisit whether the builder deserves + a Monaco instance, and whether the gallery's canvas charts need text alternatives + beyond the per-card captions. + ## Deferred (not design debts, revisit on demand) - **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would diff --git a/package-lock.json b/package-lock.json index a974b78..f3034c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "eslint": "^10.4.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", + "fake-indexeddb": "^6.2.5", "globals": "^17.6.0", "happy-dom": "^20.0.0", "husky": "^9.1.7", @@ -4829,6 +4830,16 @@ "node": ">=12.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/package.json b/package.json index b757c3c..9cc7b0a 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "eslint": "^10.4.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", + "fake-indexeddb": "^6.2.5", "globals": "^17.6.0", "happy-dom": "^20.0.0", "husky": "^9.1.7", diff --git a/src/app/components/LivePreview.tsx b/src/app/components/LivePreview.tsx index 1602733..158987d 100644 --- a/src/app/components/LivePreview.tsx +++ b/src/app/components/LivePreview.tsx @@ -15,21 +15,27 @@ * (M3) plugs into prepareSpecForRender without changing this component. */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import type { VisualizationSpec } from 'vega-embed'; import type { FitMode } from '@core/rendering'; import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering'; -import { CHART_THEME_OPTIONS, chartConfigForSelection } from '@core/vega-themes'; +import { + chartConfigForSelection, + chartThemeOptions, + type ChartThemeSelection, +} from '@core/vega-themes'; +import { openModal } from '../modals/ModalCoordinator'; import { renderSpec, type RenderHandle } from '../services/chart-renderer'; import { useAppStore } from '../stores/AppStore'; +import { useCustomThemeStore } from '../stores/CustomThemeStore'; import { useDatasetStore } from '../stores/DatasetStore'; import { usePreviewStore } from '../stores/PreviewStore'; import { selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { ChartExport } from './ChartExport'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; -import { SelectControl } from './SelectControl'; +import { SelectControl, type SelectControlOption } from './SelectControl'; import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover'; import styles from './LivePreview.module.css'; @@ -78,16 +84,42 @@ function FitControl() { * (and unmount) its own parent on open. */ // TODO: header placement/crowding parked for the batched council pass (docs/ux-second-pass.md). +/** Sentinel option that opens the Theme Builder instead of selecting a theme. */ +const EDIT_THEMES = 'edit-themes'; +type ThemePickerValue = ChartThemeSelection | typeof EDIT_THEMES; + function ChartThemeControl() { const chartTheme = useAppStore((s) => s.chartTheme); const setChartTheme = useAppStore((s) => s.setChartTheme); + const customThemes = useCustomThemeStore((s) => s.themes); + // Fresh-array derivation — memoize so the picker doesn't re-render the world + // (docs/architecture/01: derive with useMemo, never store). + const options = useMemo>>(() => { + const list: SelectControlOption[] = [...chartThemeOptions(customThemes)]; + // The "manage" entry rides in the value list (the VS Code theme-picker + // pattern); choosing it opens the builder and leaves the selection alone. + // It closes the custom-themes block — right after the built-ins, BEFORE the + // long preset roster — so it is visible without scrolling and sits next to + // the entries it manages. + // TODO: action row inside a value picker (visual separation? AT surprise?) + // parked for the batched council pass (docs/ux-second-pass.md). + list.splice(2 + customThemes.length, 0, { + value: EDIT_THEMES, + label: 'Edit themes…', + detail: 'Create and manage custom themes', + }); + return list; + }, [customThemes]); return ( { + if (value === EDIT_THEMES) openModal('themeBuilder'); + else setChartTheme(value); + }} triggerTitle="Chart theme — how charts are styled when rendered and exported" /> ); @@ -126,6 +158,9 @@ export function LivePreview() { const fitMode = useAppStore((s) => s.previewFitMode); const uiTheme = useAppStore((s) => s.uiTheme); const chartTheme = useAppStore((s) => s.chartTheme); + // Custom themes feed `custom:` selection resolution; re-rendering on a + // change keeps the chart live while a selected theme is edited in the builder. + const customThemes = useCustomThemeStore((s) => s.themes); // Datasets feed reference resolution (spec §04 step 1). Re-rendering on a // dataset change keeps a referencing chart live as its data is edited. const datasets = useDatasetStore(useShallow((s) => s.datasets)); @@ -238,7 +273,7 @@ export function LivePreview() { try { const prepared = prepareSpecForRender(parsed, { fitMode, datasets }); - const config = chartConfigForSelection(chartTheme, uiTheme); + const config = chartConfigForSelection(chartTheme, uiTheme, customThemes); handleRef.current?.destroy(); handleRef.current = null; const handle = await renderSpec(node, prepared as VisualizationSpec, config); @@ -286,6 +321,7 @@ export function LivePreview() { fitMode, uiTheme, chartTheme, + customThemes, datasets, setError, setBusy, diff --git a/src/app/components/ModalShell.tsx b/src/app/components/ModalShell.tsx index 8c53280..089f8db 100644 --- a/src/app/components/ModalShell.tsx +++ b/src/app/components/ModalShell.tsx @@ -23,10 +23,11 @@ export function ModalShell() { const name = useAppStore((s) => s.activeModal); const config = getModalConfig(name); - // The Chart Builder is a near-fullscreen work surface; the Datasets manager is the - // standard large two-pane modal; everything else is a small form. Both large kinds - // get the static-title initial focus (APG dialog-modal) so content isn't skipped. - const isXLarge = name === 'chartBuilder'; + // The Chart Builder and Theme Builder are near-fullscreen work surfaces; the + // Datasets manager is the standard large two-pane modal; everything else is a + // small form. Both large kinds get the static-title initial focus (APG + // dialog-modal) so content isn't skipped. + const isXLarge = name === 'chartBuilder' || name === 'themeBuilder'; const isLarge = name === 'datasets' || isXLarge; // Move focus into the modal on open, return it to the trigger on close. For a diff --git a/src/app/components/ThemeBuilderModal.module.css b/src/app/components/ThemeBuilderModal.module.css new file mode 100644 index 0000000..ceb1937 --- /dev/null +++ b/src/app/components/ThemeBuilderModal.module.css @@ -0,0 +1,299 @@ +/* Theme Builder — list + editor + live gallery inside the xlarge modal shell. */ + +.builder { + display: grid; + grid-template-columns: 240px 1fr; + grid-template-rows: minmax(0, 1fr); + height: 100%; + min-height: 0; + min-width: 0; +} + +/* ── Left: saved-theme list (mirrors the Datasets manager list pane) ───── */ + +.listPane { + display: flex; + flex-direction: column; + min-height: 0; + border-right: var(--border-width) solid var(--border); +} + +.newButton { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + margin: var(--space-4); + height: 40px; + padding: 0 var(--space-5); + border: var(--border-width) solid transparent; + border-radius: var(--radius); + background: var(--accent); + color: var(--accent-contrast); + font: inherit; + font-weight: 600; + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.newButton:hover { + background: var(--accent-hover); +} + +.list { + list-style: none; + margin: 0; + padding: 0; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + border-top: var(--border-width) solid var(--border); +} + +.empty { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.4; + padding: var(--space-5) var(--space-4); +} + +.item { + border-left: 2px solid transparent; + transition: background var(--dur-fast) var(--ease); +} + +.item + .item { + border-top: var(--border-width) solid var(--border); +} + +.item:hover { + background: var(--layer-01); +} + +.itemActive { + background: var(--layer-01); + border-left-color: var(--accent); +} + +.itemButton { + display: block; + width: 100%; + appearance: none; + border: none; + background: none; + padding: var(--space-3) var(--space-4); + font: inherit; + font-size: 13px; + font-weight: 500; + color: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Right: the editor surface ──────────────────────────────────────────── */ + +.detailEmpty { + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + font-size: 13px; + padding: var(--space-5); +} + +.main { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +.toolbar { + flex: 0 0 auto; + display: flex; + align-items: flex-end; + gap: var(--space-4); + padding: var(--space-4) var(--space-5); + border-bottom: var(--border-width) solid var(--border); +} + +.nameField { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 200px; +} + +.label { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); +} + +.input { + width: 100%; + height: 32px; + padding: 0 var(--space-3); + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 13px; +} + +.input:focus-visible { + outline: 2px solid var(--focus); + outline-offset: -1px; +} + +.toolbarEnd { + margin-left: auto; + display: flex; + gap: var(--space-3); +} + +.action { + height: 32px; + padding: 0 var(--space-4); + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius); + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.action:hover:not(:disabled) { + background: var(--layer-01); +} + +.action:disabled { + color: var(--text-placeholder); + border-color: var(--border); + cursor: not-allowed; +} + +.action:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +.primary { + background: var(--accent); + border-color: transparent; + color: var(--accent-contrast); + font-weight: 600; +} + +.primary:hover:not(:disabled) { + background: var(--accent-hover); +} + +.primary:disabled { + background: var(--layer-01); +} + +.danger { + border-color: var(--border-strong); + color: var(--support-error); +} + +.danger:hover:not(:disabled) { + background: var(--support-error); + color: var(--on-status); + border-color: transparent; +} + +.errorMessage { + flex: 0 0 auto; + margin: 0; + padding: var(--space-3) var(--space-5); + font-size: 13px; + color: var(--support-error); + border-bottom: var(--border-width) solid var(--border); +} + +/* ── Editor + gallery split ────────────────────────────────────────────── */ + +.work { + flex: 1 1 auto; + display: grid; + grid-template-columns: minmax(280px, 400px) 1fr; + grid-template-rows: minmax(0, 1fr); + min-height: 0; + min-width: 0; +} + +.editorPane { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-4) var(--space-5); + border-right: var(--border-width) solid var(--border); + min-height: 0; + min-width: 0; +} + +.configText { + flex: 1 1 auto; + width: 100%; + min-height: 0; + padding: var(--space-3); + border: var(--border-width) solid var(--border-strong); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.5; + resize: none; +} + +.configText:focus-visible { + outline: 2px solid var(--focus); + outline-offset: -1px; +} + +/* The gallery wraps fixed-size swatch cards; scrolls when they overflow. */ +.gallery { + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: var(--space-4); + padding: var(--space-4) var(--space-5); + overflow: auto; + min-height: 0; + min-width: 0; +} + +.card { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3); + border: var(--border-width) solid var(--border); + border-radius: var(--radius); +} + +/* Reserve the card's box so the grid doesn't reflow while charts render. */ +.cardHost { + min-width: 260px; + min-height: 180px; + display: flex; + align-items: center; + justify-content: center; +} + +.cardCaption { + font-size: 11px; + color: var(--text-secondary); +} diff --git a/src/app/components/ThemeBuilderModal.test.tsx b/src/app/components/ThemeBuilderModal.test.tsx new file mode 100644 index 0000000..d65247c --- /dev/null +++ b/src/app/components/ThemeBuilderModal.test.tsx @@ -0,0 +1,125 @@ +/** + * ThemeBuilderModal — structure and store wiring. The draft/save/font logic + * lives in CustomThemeStore (tested there); these cover the component's seams: + * the empty state, creation seeded from the active chart theme, the gallery + * cards, and the Save flow. vega-embed is mocked out (integration-heavy). + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { THEME_PREVIEW_SPECS } from '@core/theme-preview-specs'; +import { useAppStore } from '../stores/AppStore'; +import { useCustomThemeStore } from '../stores/CustomThemeStore'; +import { ThemeBuilderModal } from './ThemeBuilderModal'; + +vi.mock('../services/chart-renderer', () => ({ + renderSpec: vi.fn(() => + Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }), + ), +})); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + vi.useFakeTimers(); + useCustomThemeStore.getState().reset(); + useAppStore.getState().setChartTheme('astrolabe'); + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + }); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); +}); + +const renderModal = () => act(() => root.render()); + +describe('ThemeBuilderModal', () => { + test('shows the empty state when there are no themes', () => { + renderModal(); + expect(container.textContent).toContain('No custom themes yet'); + expect(container.textContent).toContain('Create a theme to start editing.'); + }); + + test('New theme creates a copy of the active chart theme and opens the draft', () => { + renderModal(); + const newButton = [...container.querySelectorAll('button')].find( + (b) => b.textContent === 'New theme', + )!; + act(() => newButton.click()); + + const s = useCustomThemeStore.getState(); + expect(s.themes).toHaveLength(1); + expect(s.themes[0].name).toBe('Astrolabe copy'); + // Seeded from the house config, not empty. + expect(s.themes[0].config.font).toContain('IBM Plex'); + + const nameInput = container.querySelector('#theme-name')!; + expect(nameInput.value).toBe('Astrolabe copy'); + // One gallery card per preview spec. + expect(container.querySelectorAll('figure')).toHaveLength(THEME_PREVIEW_SPECS.length); + }); + + test('a preset selection seeds the copy from that preset', () => { + useAppStore.getState().setChartTheme('fivethirtyeight'); + renderModal(); + const newButton = [...container.querySelectorAll('button')].find( + (b) => b.textContent === 'New theme', + )!; + act(() => newButton.click()); + expect(useCustomThemeStore.getState().themes[0].name).toBe('FiveThirtyEight copy'); + }); + + test('Save is disabled until the draft is dirty, then commits', () => { + renderModal(); + act(() => { + useCustomThemeStore.getState().createTheme('Brand', { font: 'Helvetica' }); + }); + + const save = () => + [...container.querySelectorAll('button')].find((b) => b.textContent === 'Save theme')!; + expect(save().disabled).toBe(true); + + act(() => useCustomThemeStore.getState().updateDraft({ name: 'Brand 2026' })); + expect(save().disabled).toBe(false); + + act(() => save().click()); + expect(useCustomThemeStore.getState().themes[0].name).toBe('Brand 2026'); + expect(save().disabled).toBe(true); + }); + + test('Save stays disabled while the config text is invalid JSON', () => { + renderModal(); + act(() => { + useCustomThemeStore.getState().createTheme('Brand', {}); + }); + act(() => useCustomThemeStore.getState().updateDraft({ configText: '{oops' })); + const save = [...container.querySelectorAll('button')].find( + (b) => b.textContent === 'Save theme', + )!; + expect(save.disabled).toBe(true); + expect(container.textContent).toContain('Invalid JSON'); + }); + + test('selecting another theme from the list swaps the draft', () => { + renderModal(); + act(() => { + useCustomThemeStore.getState().createTheme('First', {}); + useCustomThemeStore.getState().createTheme('Second', {}); + }); + const firstItem = [...container.querySelectorAll('button')].find( + (b) => b.textContent === 'First', + )!; + act(() => firstItem.click()); + expect(container.querySelector('#theme-name')!.value).toBe('First'); + }); +}); diff --git a/src/app/components/ThemeBuilderModal.tsx b/src/app/components/ThemeBuilderModal.tsx new file mode 100644 index 0000000..33300bf --- /dev/null +++ b/src/app/components/ThemeBuilderModal.tsx @@ -0,0 +1,264 @@ +/** + * Theme Builder — create and edit custom chart themes (spec §04 → Chart theme; + * docs/chart-theming-scope.md §4.4). + * + * Left pane: the saved-theme list plus "New theme" (seeded from whatever chart + * theme is currently selected, so any preset or the house style can be a + * starting point). Main pane: the draft's name, its config as editable JSON, + * a font control that writes one family across every font slot in the config, + * and a live gallery of small charts re-rendered from the draft config — the + * same config-injection path the Live Preview uses, so what the gallery shows + * is exactly what selecting the theme will do. + */ + +import { useEffect, useRef } from 'react'; +import { THEME_FONT_OPTIONS } from '@core/custom-theme'; +import type { JsonObject } from '@core/spec-config'; +import { THEME_PREVIEW_SPECS, type ThemePreviewSpec } from '@core/theme-preview-specs'; +import { chartConfigForSelection, chartThemeOptions } from '@core/vega-themes'; +import { renderSpec, type RenderHandle } from '../services/chart-renderer'; +import { useAppStore } from '../stores/AppStore'; +import { confirm } from '../stores/ConfirmStore'; +import { + selectIsDraftDirty, + selectSelectedTheme, + useCustomThemeStore, +} from '../stores/CustomThemeStore'; +import { notify } from '../stores/NotificationStore'; +import { resnapshot } from '../modals/ModalCoordinator'; +import { SelectControl } from './SelectControl'; +import styles from './ThemeBuilderModal.module.css'; + +/** Debounce for gallery re-renders while the config text is edited (ms). */ +const GALLERY_DEBOUNCE = 250; + +/** + * One gallery card: a fixed sample spec rendered with the draft config. Canvas + * renderer — seven concurrent SVG charts would put thousands of nodes in a + * modal; raster is invisible at swatch size (same trade-off as the Chart + * Builder preview). Renders are serialized per card with the LivePreview + * chain-lock pattern so a slow embed never interleaves with a newer one on the + * shared host node. Render failures blank the card silently — the gallery is + * a preview aid; the config editor's parse error is the real feedback channel. + */ +function GalleryCard({ card, config }: { card: ThemePreviewSpec; config: JsonObject }) { + const hostRef = useRef(null); + const handleRef = useRef(null); + const generationRef = useRef(0); + const chainRef = useRef>(Promise.resolve()); + + useEffect(() => { + const node = hostRef.current; + if (!node) return; + const timer = setTimeout(() => { + const mine = ++generationRef.current; + const prior = chainRef.current; + let release!: () => void; + chainRef.current = new Promise((r) => { + release = r; + }); + void (async () => { + try { + await prior; + if (mine !== generationRef.current) return; + handleRef.current?.destroy(); + handleRef.current = null; + const handle = await renderSpec(node, card.spec, config, { + renderer: 'canvas', + }); + if (mine !== generationRef.current) { + handle.destroy(); + return; + } + handleRef.current = handle; + } catch { + // Leave the card blank; the config editor reports the actionable error. + } finally { + release(); + } + })(); + }, GALLERY_DEBOUNCE); + return () => clearTimeout(timer); + }, [card, config]); + + useEffect( + () => () => { + generationRef.current++; + handleRef.current?.destroy(); + handleRef.current = null; + }, + [], + ); + + return ( +
+
+
{card.caption}
+
+ ); +} + +export function ThemeBuilderModal() { + const themes = useCustomThemeStore((s) => s.themes); + const selectedId = useCustomThemeStore((s) => s.selectedId); + const draft = useCustomThemeStore((s) => s.draft); + const draftConfig = useCustomThemeStore((s) => s.draftConfig); + const parseError = useCustomThemeStore((s) => s.parseError); + const saveError = useCustomThemeStore((s) => s.saveError); + const dirty = useCustomThemeStore(selectIsDraftDirty); + const selectedTheme = useCustomThemeStore(selectSelectedTheme); + + const handleNew = () => { + const app = useAppStore.getState(); + const store = useCustomThemeStore.getState(); + // Seed from whatever the picker currently shows — duplicating a preset (or + // the house style, or another custom theme) is the creation path (scope §4.4). + const seed = chartConfigForSelection(app.chartTheme, app.uiTheme, store.themes) as JsonObject; + const sourceLabel = + chartThemeOptions(store.themes).find((o) => o.value === app.chartTheme)?.label ?? 'Theme'; + store.createTheme(`${sourceLabel} copy`, seed); + // The fresh draft is the new baseline — creating then closing isn't a loss. + resnapshot(); + }; + + const handleSave = () => { + if (useCustomThemeStore.getState().saveDraft()) { + resnapshot(); + // The saved name in the list is visible, but the commit itself has no + // other on-screen change (the draft stays open) — confirm it. + notify({ kind: 'success', title: 'Theme saved', message: 'Your chart theme was updated.' }); + } + }; + + const handleDelete = async () => { + if (!selectedTheme) return; + const ok = await confirm({ + title: 'Delete theme', + message: `Delete "${selectedTheme.name}"? This cannot be undone.`, + confirmLabel: 'Delete', + danger: true, + }); + if (!ok) return; + const removedName = selectedTheme.name; + useCustomThemeStore.getState().remove(selectedTheme.id); + resnapshot(); + notify({ + kind: 'success', + title: 'Theme deleted', + message: `"${removedName}" was permanently removed.`, + }); + }; + + return ( +
+
+ +
    + {themes.length === 0 && ( +
  • + No custom themes yet. A new theme starts as a copy of the chart theme currently + selected in the preview, so pick a preset you like as the starting point. +
  • + )} + {themes.map((theme) => ( +
  • + +
  • + ))} +
+
+ + {draft === null ? ( +
Create a theme to start editing.
+ ) : ( +
+
+
+ + + useCustomThemeStore.getState().updateDraft({ name: e.target.value }) + } + /> +
+ ({ value, label }))} + onSelect={(family) => useCustomThemeStore.getState().applyDraftFont(family)} + triggerContent={<>Font…} + triggerTitle="Write one font family into every font slot of the config" + /> +
+ + +
+
+ + {(saveError ?? parseError) !== null && ( +

+ {saveError ?? parseError} +

+ )} + +
+
+ + {/* TODO: plain textarea vs a Monaco instance (config-schema completions), and + text alternatives for the canvas gallery beyond the captions — parked for + the batched council pass (docs/ux-second-pass.md). */} +