mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart theming: selectable chart theme + spec↔config merge/extract
This commit is contained in:
@@ -376,7 +376,22 @@ useAppStore.subscribe((s, prev) => {
|
|||||||
|
|
||||||
The store stays DOM-free; the adapter (the `applyTheme` subscriber) lives at the edge.
|
The store stays DOM-free; the adapter (the `applyTheme` subscriber) lives at the edge.
|
||||||
|
|
||||||
### Debounced auto-save of the draft spec
|
### Adding a persisted UI preference (the established chain)
|
||||||
|
|
||||||
|
A small global preference (preview fit mode, chart theme) follows one chain — five touch
|
||||||
|
points, in order:
|
||||||
|
|
||||||
|
1. `core/settings.ts` — field on `UserSettings` + `defaultSettings()` + `loadSettings`
|
||||||
|
validation (an unknown stored value falls back to the default, never breaks the app).
|
||||||
|
2. `infrastructure/settings-store.ts` — slice loader/saver pair using the per-slice
|
||||||
|
write-through merge (doc 02 §5), so other writers of the shared record survive.
|
||||||
|
3. `stores/AppStore.ts` — field + setter (the store stays browser-free).
|
||||||
|
4. `orchestration/preferences.ts` — `initX()` hydrates the store from the adapter;
|
||||||
|
`wireX()` subscribes store → adapter.
|
||||||
|
5. `main.tsx` — call both before `createRoot().render` (the store is the single source
|
||||||
|
of truth from first paint).
|
||||||
|
|
||||||
|
The UI control only calls the AppStore setter; persistence follows from the subscriber.
|
||||||
|
|
||||||
The Monaco editor writes every keystroke into `draftSpec`. We do **not** persist on
|
The Monaco editor writes every keystroke into `draftSpec`. We do **not** persist on
|
||||||
every keystroke. A startup subscriber observes the draft and debounces the expensive
|
every keystroke. A startup subscriber observes the draft and debounces the expensive
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ Small, frequently-read structured records live in `localStorage`, not IndexedDB:
|
|||||||
The pattern is **load-with-fallback, per-slice write-through merge.**
|
The pattern is **load-with-fallback, per-slice write-through merge.**
|
||||||
|
|
||||||
- **Load-with-fallback:** merge the parsed stored object over a complete `DEFAULTS` constant. Missing keys (a setting added in a later build) and malformed JSON silently fall back to defaults — the app always gets a fully-populated object and never `undefined`-crashes on a new field. For `UserSettings`, the normalization is **pure and lives in `@core/settings` (`loadSettings(raw)` / `defaultSettings()`)** — it clamps ranges and validates enums; the infra adapter is just the thin localStorage reader (`loadUserSettings()` = `loadSettings(readRaw())`).
|
- **Load-with-fallback:** merge the parsed stored object over a complete `DEFAULTS` constant. Missing keys (a setting added in a later build) and malformed JSON silently fall back to defaults — the app always gets a fully-populated object and never `undefined`-crashes on a new field. For `UserSettings`, the normalization is **pure and lives in `@core/settings` (`loadSettings(raw)` / `defaultSettings()`)** — it clamps ranges and validates enums; the infra adapter is just the thin localStorage reader (`loadUserSettings()` = `loadSettings(readRaw())`).
|
||||||
- **Per-slice write-through merge:** every update reads current, merges in **just its slice**, and writes back. **The one `astrolabe:settings` record has multiple independent writers**, because settings are distributed and live-applied (spec §07 — no central save, no Apply step): the header theme toggle writes `ui.theme`, the preview Fit control writes `ui.previewFitMode`, and the per-pane settings clusters write `editor`/`performance`/`formatting`. **A writer that replaced the whole record would clobber the slices it doesn't own** — so each must field-merge. (There is deliberately no whole-record `saveSettings`.)
|
- **Per-slice write-through merge:** every update reads current, merges in **just its slice**, and writes back. **The one `astrolabe:settings` record has multiple independent writers**, because settings are distributed and live-applied (spec §07 — no central save, no Apply step): the header theme toggle writes `ui.theme`, the preview Fit control writes `ui.previewFitMode`, the preview Chart-theme picker writes `ui.chartTheme`, and the per-pane settings clusters write `editor`/`performance`/`formatting`. **A writer that replaced the whole record would clobber the slices it doesn't own** — so each must field-merge. (There is deliberately no whole-record `saveSettings`.)
|
||||||
- **Environment-guarded:** `localStorage` is absent or throws in some test/SSR contexts; guard access and degrade to defaults rather than throwing.
|
- **Environment-guarded:** `localStorage` is absent or throws in some test/SSR contexts; guard access and degrade to defaults rather than throwing.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -263,7 +263,11 @@ export interface UserSettings {
|
|||||||
tabSize: number;
|
tabSize: number;
|
||||||
};
|
};
|
||||||
performance: { renderDebounce: number };
|
performance: { renderDebounce: number };
|
||||||
ui: { theme: 'light' | 'dark'; previewFitMode: 'default' | 'width' | 'height' | 'full' };
|
ui: {
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
previewFitMode: 'default' | 'width' | 'height' | 'full';
|
||||||
|
chartTheme: ChartThemeId; // 'astrolabe' | 'stock' | vega-themes preset id
|
||||||
|
};
|
||||||
formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string };
|
formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +284,7 @@ const DEFAULTS: UserSettings = {
|
|||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
},
|
},
|
||||||
performance: { renderDebounce: 1500 },
|
performance: { renderDebounce: 1500 },
|
||||||
ui: { theme: 'light', previewFitMode: 'default' },
|
ui: { theme: 'light', previewFitMode: 'default', chartTheme: 'astrolabe' },
|
||||||
formatting: { dateFormat: 'smart', customDateFormat: '' },
|
formatting: { dateFormat: 'smart', customDateFormat: '' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -324,6 +328,7 @@ export function loadSettings(): UserSettings {
|
|||||||
// record, so the others survive (see the per-slice rule above):
|
// record, so the others survive (see the per-slice rule above):
|
||||||
// saveUiTheme(theme) -> { ...current, ui: { ...current.ui, theme } }
|
// saveUiTheme(theme) -> { ...current, ui: { ...current.ui, theme } }
|
||||||
// savePreviewFitMode(mode) -> { ...current, ui: { ...current.ui, previewFitMode } }
|
// savePreviewFitMode(mode) -> { ...current, ui: { ...current.ui, previewFitMode } }
|
||||||
|
// saveChartTheme(chartTheme) -> { ...current, ui: { ...current.ui, chartTheme } }
|
||||||
// saveManagedSettings(managed) -> { ...current, editor, performance, formatting }
|
// saveManagedSettings(managed) -> { ...current, editor, performance, formatting }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -150,83 +150,66 @@ async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) {
|
|||||||
## 3. Theme Follows the UI Theme
|
## 3. Theme Follows the UI Theme
|
||||||
|
|
||||||
A Vega-Lite **config** object styles every chart globally — fonts, axis colors,
|
A Vega-Lite **config** object styles every chart globally — fonts, axis colors,
|
||||||
background, the categorical color range, default mark colors. Astrolabe ships one
|
background, the categorical color range. Astrolabe ships one config per UI theme
|
||||||
config per UI theme so charts visually belong to the app rather than looking like
|
so charts visually belong to the app rather than looking like stock Vega-Lite.
|
||||||
stock Vega-Lite.
|
`src/core/vega-themes.ts` is the single source of truth; each house config is
|
||||||
|
**two merged layers** (the full audit and forward plan live in
|
||||||
|
[`docs/chart-theming-scope.md`](../chart-theming-scope.md)):
|
||||||
|
|
||||||
```ts
|
- **Base** (`lightBaseConfig`/`darkBaseConfig`) — the legibility minimum:
|
||||||
// src/core/vega-themes.ts (sketch)
|
`background: 'transparent'` (the pane shows through) plus guide colors on the
|
||||||
import type { Config } from 'vega-lite';
|
app's text/border tokens. Without it, stock black-on-white chart text is
|
||||||
|
illegible on the dark pane.
|
||||||
|
- **Expressive** (`lightExpressiveConfig`/`darkExpressiveConfig`) — the house
|
||||||
|
style: IBM Plex, the Carbon data-viz 14-color categorical palette, dotted
|
||||||
|
grid, bumped guide sizes/weights, no plot border.
|
||||||
|
|
||||||
export const lightChartConfig: Config = {
|
`mergeChartLayers(base, expressive)` produces `lightChartConfig`/
|
||||||
background: 'transparent',
|
`darkChartConfig`, and `chartConfigFor(uiTheme)` is the one UI-theme → config
|
||||||
font: '"Inter", sans-serif',
|
mapping. The split exists so a non-house style can keep the base layer while
|
||||||
title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' },
|
swapping the expressive one (future custom themes).
|
||||||
axis: {
|
|
||||||
domainColor: '#1c1c1e',
|
|
||||||
gridColor: '#e4e4e7',
|
|
||||||
gridDash: [3, 3],
|
|
||||||
labelColor: '#52525b',
|
|
||||||
titleColor: '#1c1c1e',
|
|
||||||
labelFontSize: 11,
|
|
||||||
titleFontSize: 12,
|
|
||||||
},
|
|
||||||
range: {
|
|
||||||
category: ['#2f6df6', '#f5a524', '#17b890', '#e5484d', '#8b5cf6', '#0ea5e9'],
|
|
||||||
},
|
|
||||||
view: { stroke: 'transparent' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const darkChartConfig: Config = {
|
### Selectable chart themes
|
||||||
background: 'transparent',
|
|
||||||
font: '"Inter", sans-serif',
|
|
||||||
title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' },
|
|
||||||
axis: {
|
|
||||||
domainColor: '#a1a1aa',
|
|
||||||
gridColor: '#3f3f46',
|
|
||||||
gridDash: [3, 3],
|
|
||||||
labelColor: '#a1a1aa',
|
|
||||||
titleColor: '#f4f4f5',
|
|
||||||
labelFontSize: 11,
|
|
||||||
titleFontSize: 12,
|
|
||||||
},
|
|
||||||
range: {
|
|
||||||
category: ['#5b8def', '#f5a524', '#2dd4a7', '#f0666b', '#a78bfa', '#38bdf8'],
|
|
||||||
},
|
|
||||||
view: { stroke: 'transparent' },
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
One mapping, in one place, is the single source of truth for theme → config:
|
On top of the house pair, the user picks a **chart theme** (spec §04 → Chart
|
||||||
|
theme) — `ChartThemeId = 'astrolabe' | 'stock' | <vega-themes preset id>`:
|
||||||
|
|
||||||
```ts
|
- `'astrolabe'` resolves via `chartConfigFor(uiTheme)` (follows light/dark);
|
||||||
// src/core/vega-themes.ts
|
- `'stock'` resolves to `{}` — nothing injected, pure Vega-Lite defaults;
|
||||||
import type { UiTheme } from './theme'; // core-local — never import from src/app
|
- 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).
|
||||||
|
|
||||||
const CHART_CONFIG: Record<UiTheme, Config> = {
|
`chartConfigForSelection(selection, uiTheme)` is the only resolver. The choice
|
||||||
light: lightChartConfig,
|
lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by
|
||||||
dark: darkChartConfig,
|
`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.
|
||||||
|
|
||||||
export function chartConfigFor(theme: UiTheme): Config {
|
Render-time precedence: vega-lite merges the injected config **under** the
|
||||||
return CHART_CONFIG[theme];
|
spec's own `config` (`mergeConfig(opt.config, spec.config)` — the spec wins
|
||||||
}
|
key-by-key), so a snippet can always override or opt out locally. The
|
||||||
```
|
`core/spec-config.ts` merge/extract operations (spec §03G) move styling across
|
||||||
|
that boundary deliberately: merge bakes the selected theme into `spec.config`
|
||||||
The renderer reads the active UI theme (from the store) and passes the matching config into
|
(spec keys win — rendering unchanged), extract lifts `spec.config` out.
|
||||||
`renderSpec`. When the theme changes, the same subscriber that drives
|
|
||||||
re-rendering picks up the new config and the chart restyles automatically.
|
|
||||||
|
|
||||||
### Rules
|
### Rules
|
||||||
|
|
||||||
- **Do** keep `chartConfigFor` as the _only_ place that maps a UI theme to a Vega
|
- **Do** keep `chartConfigForSelection` as the _only_ place that maps the user's
|
||||||
config. Adding a UI theme = adding one config and one map entry.
|
selection (and UI theme) to a Vega config.
|
||||||
- **Do** set chart `background: 'transparent'` so the pane's own background shows
|
- **Do** set chart `background: 'transparent'` in the house configs so the
|
||||||
through and theme switches look seamless.
|
pane's own background shows through and theme switches look seamless. Preset
|
||||||
|
themes carry their own backgrounds (often white) and render as their authors
|
||||||
|
intended — honest preview beats pane-matching.
|
||||||
|
- **Do** keep the Chart Builder preview and onboarding thumbnails on
|
||||||
|
`chartConfigFor(uiTheme)` — they are app surfaces, not destination previews.
|
||||||
- **Don't** inline colors or fonts into individual specs to "match the theme" —
|
- **Don't** inline colors or fonts into individual specs to "match the theme" —
|
||||||
that is the config's job, and per-spec styling drifts from the app.
|
that is the config's job, and per-spec styling drifts from the app.
|
||||||
- **Don't** let the user's stored spec carry a `config`; the theme config is
|
- **Don't** write the injected config into the user's stored spec implicitly;
|
||||||
applied at embed time via the embed options, leaving the spec theme-agnostic.
|
it is applied at embed time, leaving the spec theme-agnostic. Baking it in is
|
||||||
|
the explicit, user-invoked merge action only.
|
||||||
|
|
||||||
### Theme flow (end to end)
|
### Theme flow (end to end)
|
||||||
|
|
||||||
@@ -237,7 +220,8 @@ Theme spans several layers; the path is:
|
|||||||
(localStorage `ui.theme`). On load, `initTheme()` — called from `main.tsx`
|
(localStorage `ui.theme`). On load, `initTheme()` — called from `main.tsx`
|
||||||
**before** `createRoot().render` — hydrates the saved theme. Chart and editor
|
**before** `createRoot().render` — hydrates the saved theme. Chart and editor
|
||||||
follow by subscribing to `uiTheme`: `LivePreview` re-embeds with
|
follow by subscribing to `uiTheme`: `LivePreview` re-embeds with
|
||||||
`chartConfigFor(theme)`, `SpecEditor` sets the Monaco theme. UI chrome repaints
|
`chartConfigForSelection(chartTheme, uiTheme)`, `SpecEditor` sets the Monaco
|
||||||
|
theme. UI chrome repaints
|
||||||
purely from the `[data-theme]` token swap in `styles/tokens.css`. The header
|
purely from the `[data-theme]` token swap in `styles/tokens.css`. The header
|
||||||
`ThemeToggle` is the user control.
|
`ThemeToggle` is the user control.
|
||||||
|
|
||||||
|
|||||||
@@ -429,9 +429,25 @@ Arrow/Home/End rove). The selected option carries `aria-current` and a visible
|
|||||||
colour alone. The same control doubles as an **action picker** (no `value`; e.g. "Add field
|
colour alone. The same control doubles as an **action picker** (no `value`; e.g. "Add field
|
||||||
to which channel?"). A custom `triggerClassName` _replaces_ the default trigger styling, so
|
to which channel?"). A custom `triggerClassName` _replaces_ the default trigger styling, so
|
||||||
chip-styled triggers (the pill's type chip, the shelf's field chips) stay chips.
|
chip-styled triggers (the pill's type chip, the shelf's field chips) stay chips.
|
||||||
|
The single-open registry means **disclosures cannot nest**: a SelectControl inside a
|
||||||
|
settings popover would close — and unmount — its own parent on open. A control that needs
|
||||||
|
its own popover sits beside the gear in the pane header, never inside the panel.
|
||||||
_(Consulted via /council → WAI-ARIA APG disclosure/menu-button/radio, Carbon, NN/g #4. This
|
_(Consulted via /council → WAI-ARIA APG disclosure/menu-button/radio, Carbon, NN/g #4. This
|
||||||
bullet is the contract; cite it, not the source.)_
|
bullet is the contract; cite it, not the source.)_
|
||||||
|
|
||||||
|
**Resolved — editor commands need a visible home; hidden surfaces are accelerators only.**
|
||||||
|
A command that exists _only_ in Monaco's right-click context menu or F1 palette is
|
||||||
|
undiscoverable (NN/g #6 recognition-over-recall — those surfaces demand the user already
|
||||||
|
know the command exists). Every editor command gets a **visible toolbar home**; when the
|
||||||
|
toolbar can't afford a dedicated button (Carbon menu-buttons: "use an overflow menu when
|
||||||
|
additional options are available and there is a space constraint"), the home is a
|
||||||
|
SelectControl **action picker** grouping related commands (e.g. the spec editor's _Config_
|
||||||
|
menu: merge chart theme / extract config), with `detail` lines saying what each does.
|
||||||
|
Context-menu and palette registrations stay, as the NN/g #7 expert accelerators, but they
|
||||||
|
call the same functions as the visible control — one code path, two doors.
|
||||||
|
_(Consulted via /council → NN/g #6/#7, Carbon menu-buttons/overflow-menu. This bullet is
|
||||||
|
the contract; cite it, not the source.)_
|
||||||
|
|
||||||
**Resolved — field→channel assignment: explicit choice, visible armed state.** Clicking a
|
**Resolved — field→channel assignment: explicit choice, visible armed state.** Clicking a
|
||||||
shelf field with no channel armed opens an explicit **channel chooser** (the channels that
|
shelf field with no channel armed opens an explicit **channel chooser** (the channels that
|
||||||
accept the field; an occupied one is labelled with what it replaces) — never a silent
|
accept the field; an occupied one is labelled with what it replaces) — never a silent
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Chart Theming — Enhancement Scope
|
||||||
|
|
||||||
|
> **Status:** scope consolidated 2026-06-12. Single forward-looking home for chart-theme
|
||||||
|
> work: separating the opinionated house style from the legibility minimum, a preview
|
||||||
|
> theme selector, config merge/extract, custom named themes, and fonts (shipped roster +
|
||||||
|
> user-loaded). Read against `src/core/vega-themes.ts`,
|
||||||
|
> `src/app/services/chart-renderer.ts`, and `docs/architecture/05` §3.
|
||||||
|
>
|
||||||
|
> **Goal (the brief):** "here's how you can easily transform your Vega-Lite charts to not
|
||||||
|
> look like stock Vega-Lite charts" — make the house style one option among several,
|
||||||
|
> let a user apply custom branding (colors **and fonts**) quickly, and keep every byte
|
||||||
|
> self-hosted and offline-capable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Where we stand (the audit)
|
||||||
|
|
||||||
|
Everything opinionated lives in **one file** — `src/core/vega-themes.ts` — injected as
|
||||||
|
vega-embed's `config` option at embed time (`chart-renderer.ts`). It is never baked into
|
||||||
|
the stored spec; pasting a snippet into the Vega editor renders stock. No CSS reaches
|
||||||
|
into the chart DOM. Exports (PNG/SVG) render through the same view, so they **carry the
|
||||||
|
theme**.
|
||||||
|
|
||||||
|
Merge precedence (verified in `vega-lite/src/compile/compile.ts`):
|
||||||
|
`mergeConfig(opt.config, spec.config)` — **the spec's own `config` wins** over our
|
||||||
|
injected theme, property by property. A snippet can already opt out of any of it.
|
||||||
|
|
||||||
|
Exact diff vs. stock Vega-Lite (defaults read from `vega-parser/src/config.js`):
|
||||||
|
|
||||||
|
| Property | Astrolabe (light / dark) | Stock Vega-Lite |
|
||||||
|
| -------------------------- | --------------------------- | -------------------- |
|
||||||
|
| `background` | transparent | `white` |
|
||||||
|
| `font` | IBM Plex Sans stack | `sans-serif` |
|
||||||
|
| `title` | 16px / 600 / app text color | 13px / bold / black |
|
||||||
|
| `axis.domainColor` | `#c6c6c6` / `#525252` | `#888` |
|
||||||
|
| `axis.gridColor` | `#e0e0e0` / `#393939` | `#ddd` |
|
||||||
|
| `axis.gridDash` | `[2,2]` | solid |
|
||||||
|
| `axis.labelColor` | `#525252` / `#a8a8a8` | black |
|
||||||
|
| `axis.titleColor` | `#161616` / `#f4f4f4` | black |
|
||||||
|
| `axis.label/titleFontSize` | 11 / 12 | 10 / 11 |
|
||||||
|
| `axis.titleFontWeight` | 600 | bold (700) |
|
||||||
|
| `range.category` | Carbon data-viz 14-color | tableau10 (10-color) |
|
||||||
|
| `view.stroke` | transparent | `#ddd` plot border |
|
||||||
|
|
||||||
|
Untouched: everything else — notably the **default mark color stays Vega blue
|
||||||
|
`#4c78a8`**; the Carbon palette only kicks in once a color encoding exists.
|
||||||
|
|
||||||
|
The config splits into two layers with different standing:
|
||||||
|
|
||||||
|
- **Base (legibility/integration)** — required for charts to be readable on our panes at
|
||||||
|
all, dark mode especially: `background: transparent` + the guide _colors_ (stock black
|
||||||
|
text on a dark pane is illegible). Structurally the same job `[data-theme]` does for
|
||||||
|
the rest of the app.
|
||||||
|
- **Expressive (house style)** — genuinely opinionated: Plex, the Carbon categorical
|
||||||
|
palette, dotted grid, bumped guide sizes/weights, 16px title, no plot border. Strip it
|
||||||
|
and charts still work in both UI themes; they just look like Vega-Lite.
|
||||||
|
|
||||||
|
## 2. What vega-editor does (and what we take)
|
||||||
|
|
||||||
|
Read from the local clone (`reference/vega-editor`, `components/config-editor/`):
|
||||||
|
|
||||||
|
- **Theme dropdown = the `vega-themes` npm package** (~14 preset configs: excel,
|
||||||
|
ggplot2, fivethirtyeight, latimes, powerbi, googlecharts, urbaninstitute, dark, four
|
||||||
|
Carbon themes) + a `custom` sentinel. Already in our tree — vega-embed depends on it.
|
||||||
|
- **Picking a theme is a one-shot copy** of the preset JSON into a config editor pane;
|
||||||
|
any hand-edit flips back to `custom`. No live binding.
|
||||||
|
- **The config pane** feeds `opt.config` at compile — the slot we already use. Two
|
||||||
|
Monaco context-menu commands bridge pane ↔ spec: **Merge Config Into Spec** (pane →
|
||||||
|
`spec.config`, spec's existing keys win, pane empties) and **Extract Config From
|
||||||
|
Spec** (the inverse).
|
||||||
|
- **No custom-theme saving.** One global localStorage state blob; `custom` is "whatever
|
||||||
|
is in the pane". Nothing to borrow for named themes — that part is our own design.
|
||||||
|
|
||||||
|
The structural mismatch: vega-editor is a scratchpad for one transient document;
|
||||||
|
Astrolabe is a library. **Decided 2026-06-12:** theme choice is **not per-snippet** —
|
||||||
|
`spec.config` _is_ the per-snippet mechanism, and merge/extract makes it ergonomic. The
|
||||||
|
app-level selector is a global preference.
|
||||||
|
|
||||||
|
## 3. Fonts (researched 2026-06-12)
|
||||||
|
|
||||||
|
**The hard constraint:** `vega-scenegraph/src/util/text.js` measures every label via
|
||||||
|
canvas `measureText` **regardless of renderer**. A font that finishes loading after
|
||||||
|
embed leaves the whole layout measured with fallback metrics. Any custom-font path must
|
||||||
|
`await document.fonts.load('<weight> 11px "Family"')` per used face **before**
|
||||||
|
`renderSpec`. Once loaded, SVG view, canvas view, and PNG export all work for free.
|
||||||
|
**Known limitation:** SVG _export_ carries only the family name — a viewer without the
|
||||||
|
font sees fallback (industry standard; data-URI `@font-face` embedding is a heavy
|
||||||
|
maybe-later).
|
||||||
|
|
||||||
|
**Shipped roster (self-hosted, no CDN — same `@fontsource` mechanism as Plex).**
|
||||||
|
Measured latin woff2 sizes (jsdelivr, 2026-06-12): regular text faces run **13–25KB per
|
||||||
|
weight**; handwriting (Caveat) ~50KB. A ~9-family roster at ~2 weights ≈ **400–450KB
|
||||||
|
latin**. All-subsets multiplier ≈ 3–4× (Inter: 87KB all-subsets vs 23KB latin, one
|
||||||
|
weight). Current dist is 7.8MB with 412KB of Plex — the roster roughly doubles font
|
||||||
|
payload; acceptable.
|
||||||
|
|
||||||
|
Candidate roster (final pick deserves a visual specimen pass, not a chat decision):
|
||||||
|
|
||||||
|
| Role | Faces (weights) |
|
||||||
|
| --------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| Already shipped, free | IBM Plex Sans, IBM Plex Mono |
|
||||||
|
| Dataviz sans | Inter (400/600), Roboto Condensed (400/600), Libre Franklin (400/600) |
|
||||||
|
| Brand-coherent | IBM Plex Serif (400/600), IBM Plex Sans Condensed (400/600) |
|
||||||
|
| Editorial serif | Source Serif 4 _or_ Spectral (400/600) |
|
||||||
|
| Exotic / display | Space Grotesk (400/600), Playfair Display (400/700), Caveat (400/600, "sketch"), Space Mono (400/700) |
|
||||||
|
|
||||||
|
**Subset/precache strategy:** chart fonts are decoration with automatic per-glyph
|
||||||
|
fallback (`unicode-range`), not app capability — non-latin data labels falling back to
|
||||||
|
the system font is degraded styling, not a broken app (contrast the Plex Cyrillic
|
||||||
|
lesson, which was UI capability). Plan: **ship all subsets in dist** (~1.2–1.5MB dist
|
||||||
|
growth), **precache latin only** (~+400KB), runtime-cache the remaining subsets
|
||||||
|
same-origin (CacheFirst) so a used subset persists offline after first render.
|
||||||
|
|
||||||
|
**User-loaded fonts (the branding case — primary).** Real brand fonts are licensed and
|
||||||
|
usually _not_ on Google Fonts. Path: upload woff2/ttf → bytes in IndexedDB (the
|
||||||
|
datasets persistence pattern) → `new FontFace(family, bytes)` + `document.fonts.add()`
|
||||||
|
at startup and before render. Fully local, offline-native, no privacy question.
|
||||||
|
|
||||||
|
**Google Fonts CDN tier — deferred, opt-in only.** Verified: keyless catalog at
|
||||||
|
`fonts.google.com/metadata/fonts` (1,936 families; a names-only list is ~30KB raw, so
|
||||||
|
the _picker_ can ship static and offline), CSS2 endpoint live, Workbox CacheFirst on
|
||||||
|
`fonts.gstatic.com` makes a chosen font offline after first use. Tension: `base.css`
|
||||||
|
says fonts are "never a CDN", and font requests expose the user's IP to Google. If this
|
||||||
|
ships, it is an explicit per-font user action, never automatic.
|
||||||
|
|
||||||
|
## 4. Build order
|
||||||
|
|
||||||
|
1. **Layer split** ✅ (refactor, no visible change) — `vega-themes.ts` is base +
|
||||||
|
expressive per UI theme, merged into the existing exports via `mergeChartLayers`.
|
||||||
|
2. **Preview theme selector** ✅ — global pref `ui.chartTheme`: **Astrolabe** (follows
|
||||||
|
UI theme, default) · **Stock Vega-Lite** (empty config) · all 14 vega-themes presets ·
|
||||||
|
(later) custom themes. Governs LivePreview **and export** (same view). Resolved
|
||||||
|
design points: the control is a `SelectControl` in the **preview header** (a select
|
||||||
|
nested inside PreviewSettings would close its own parent — SelectControl and
|
||||||
|
SettingsPopover share the one-open-popover registry); Onboarding/Chart-Builder
|
||||||
|
previews stay house-styled; preset/stock backgrounds render verbatim (a white chart
|
||||||
|
card on the dark pane is an honest destination preview).
|
||||||
|
3. **Merge/extract config** ✅ — `core/spec-config.ts` (`mergeConfigIntoSpec`,
|
||||||
|
`extractConfigFromSpec`), surfaced as the editor toolbar's **Config** menu
|
||||||
|
(SelectControl action picker — council: NN/g #6, Carbon overflow; arch 10 §5 records
|
||||||
|
the rule) with Monaco context-menu/palette as accelerators on the same functions
|
||||||
|
(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.
|
||||||
|
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.
|
||||||
|
6. **User font upload** — FontFace-from-IndexedDB tier; theme entity's `fonts` field
|
||||||
|
carries `{ family, source: 'file' }`.
|
||||||
|
7. **Deferred** — Google Fonts opt-in tier; SVG export font embedding; built-in
|
||||||
|
expressive preset gallery ("Editorial", "Terminal", "Sketch") showcasing the roster.
|
||||||
|
|
||||||
|
**Rejected:** per-snippet theme field (2026-06-12 — `spec.config` + merge/extract covers
|
||||||
|
it without a second mechanism).
|
||||||
|
|
||||||
|
## 5. Status log
|
||||||
|
|
||||||
|
- **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;
|
||||||
|
LivePreview renders (and therefore exports) with the selection; `core/spec-config.ts`
|
||||||
|
merge/extract behind two Monaco editor actions. Spec updated (§03G, §04 Chart theme,
|
||||||
|
§07, §09C) + architecture 05 §3 rewritten to the layered/selectable model. Verified:
|
||||||
|
typecheck, eslint, full tests (802), build. Custom named themes (slice 4) and fonts
|
||||||
|
(5–6) remain.
|
||||||
|
- **2026-06-12** — scope written; audit, vega-editor read, font research done (numbers
|
||||||
|
above). Slice 1 (layer split) implemented.
|
||||||
@@ -76,3 +76,10 @@ When a snippet's spec embeds its data inline, the user can lift that data out in
|
|||||||
- A toast confirms the dataset was created, and the modal closes.
|
- A toast confirms the dataset was created, and the modal closes.
|
||||||
- The user can cancel the modal at any time, leaving the spec unchanged.
|
- The user can cancel the modal at any time, leaving the spec unchanged.
|
||||||
- Dataset-side specifics (formats, storage, the bidirectional snippet↔dataset link) are described in _Datasets_.
|
- Dataset-side specifics (formats, storage, the bidirectional snippet↔dataset link) are described in _Datasets_.
|
||||||
|
|
||||||
|
## G. Spec ↔ Config Actions
|
||||||
|
|
||||||
|
Two editor actions make the injected chart theme portable (see _Live Preview → Chart theme_). Their visible home is a **Config** menu in the editor toolbar (a value-select disclosure listing both actions with a one-line description each), disabled when no snippet is active or the read-only published view is shown; the editor's right-click context menu and F1 command palette offer the same actions as expert accelerators. Both actions replace the document as a single undoable edit (⌘/Ctrl+Z restores), reformatted in the app's JSON style, and both refuse with a clear toast when the document is not a valid JSON object.
|
||||||
|
|
||||||
|
- **Merge Chart Theme into Spec** — bakes the currently selected chart theme into the spec's own `config` block, deep-merging under any existing `config` so the spec's own keys win and the rendered result is unchanged. Use it before publishing a spec somewhere the app's theme won't follow. When the selected theme injects nothing (Stock Vega-Lite), the action explains there is nothing to merge.
|
||||||
|
- **Extract Config from Spec** — removes the spec's `config` block and copies it to the clipboard, for cleaning baked-in styling out of a pasted spec. The clipboard copy happens **before** the removal; if the copy fails, the spec is left unchanged so the config is never lost. A spec with no config block reports that and changes nothing.
|
||||||
|
|||||||
@@ -36,6 +36,21 @@ Behavior of the selected mode:
|
|||||||
- The selected mode persists across sessions, stored in _Settings_ as `previewFitMode`.
|
- The selected mode persists across sessions, stored in _Settings_ as `previewFitMode`.
|
||||||
- The default is the natural Original mode.
|
- The default is the natural Original mode.
|
||||||
|
|
||||||
|
## Chart theme
|
||||||
|
|
||||||
|
The preview pane header carries a **Chart theme** picker — a value-select disclosure choosing which Vega-Lite config is injected when charts render:
|
||||||
|
|
||||||
|
- **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).
|
||||||
|
- **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 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.
|
||||||
|
|
||||||
## Export control
|
## Export control
|
||||||
|
|
||||||
The preview pane header also carries a per-chart **Export** control — a disclosure for copying or downloading the current chart's spec, or downloading its rendered image (PNG/SVG). It exports what the preview shows. The behavior is specified in _Import & Export → Per-chart export_; it lives in this header because the image formats are produced from the live rendered view.
|
The preview pane header also carries a per-chart **Export** control — a disclosure for copying or downloading the current chart's spec, or downloading its rendered image (PNG/SVG). It exports what the preview shows. The behavior is specified in _Import & Export → Per-chart export_; it lives in this header because the image formats are produced from the live rendered view.
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ Governs how dates are rendered throughout the app, for example the timestamps sh
|
|||||||
The following preferences also persist locally across sessions and, like the clusters above, are managed by controls in the pane they affect; they are documented in their own sections:
|
The following preferences also persist locally across sessions and, like the clusters above, are managed by controls in the pane they affect; they are documented in their own sections:
|
||||||
|
|
||||||
- **Preview fit mode** — how the preview is sized/fit; see _Live Preview_.
|
- **Preview fit mode** — how the preview is sized/fit; see _Live Preview_.
|
||||||
|
- **Chart theme** — which config charts render and export with (`ui.chartTheme`); see _Live Preview → Chart theme_.
|
||||||
- **Snippet sort preference** — the snippet list's sort field and direction; see _Snippet Library_.
|
- **Snippet sort preference** — the snippet list's sort field and direction; see _Snippet Library_.
|
||||||
|
|
||||||
## Behaviors
|
## Behaviors
|
||||||
|
|||||||
@@ -77,12 +77,13 @@ The current **Dataset** version is `2`. The v1→v2 migration reflects the URL-s
|
|||||||
| `performance.renderDebounce` | number | Delay (ms) before re-rendering the preview after edits. |
|
| `performance.renderDebounce` | number | Delay (ms) before re-rendering the preview after edits. |
|
||||||
| `ui.theme` | string | App theme: `light` or `dark`. |
|
| `ui.theme` | string | App theme: `light` or `dark`. |
|
||||||
| `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. |
|
| `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. |
|
||||||
|
| `ui.chartTheme` | string | Chart theme: `astrolabe`, `stock`, or a preset id. |
|
||||||
| `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. |
|
| `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. |
|
||||||
| `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. |
|
| `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. |
|
||||||
|
|
||||||
A reference shape:
|
A reference shape:
|
||||||
|
|
||||||
UserSettings = { version, editor: { fontSize, theme, minimap, wordWrap, lineNumbers, tabSize }, performance: { renderDebounce }, ui: { theme, previewFitMode }, formatting: { dateFormat, customDateFormat } }
|
UserSettings = { version, editor: { fontSize, theme, minimap, wordWrap, lineNumbers, tabSize }, performance: { renderDebounce }, ui: { theme, previewFitMode, chartTheme }, formatting: { dateFormat, customDateFormat } }
|
||||||
|
|
||||||
## D. App / UI preferences (persisted separately)
|
## D. App / UI preferences (persisted separately)
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,14 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
|
|||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
_(none — the 2026-06-12 batch resolved all parked items: type-cycle chip → direct-pick
|
- **Chart theme picker placement & header crowding** (`LivePreview.tsx` — ChartThemeControl).
|
||||||
SelectControl; field-assignment flow → explicit channel chooser + visible armed state, drag
|
The picker sits in the preview header because nesting a SelectControl inside the
|
||||||
still deferred; "or constant" → "Use a constant" ghost button; chart-level controls →
|
PreviewSettings popover is impossible today (one-open-popover registry: the select would
|
||||||
properties strip under the preview. Resolutions recorded in `architecture/10` §5 and
|
close/unmount its own parent). Header now holds Fit + theme + export + gear; at narrow
|
||||||
`spec/06`.)_
|
pane widths the long trigger labels ("FiveThirtyEight", "Urban Institute") may crowd it.
|
||||||
|
Council questions: does the picker deserve header prominence (the "transform your chart"
|
||||||
|
showcase) or settings-cluster placement (a persistent global pref); should the popover
|
||||||
|
registry learn nesting; 16 flat options — group presets under a heading?
|
||||||
|
|
||||||
## Deferred (not design debts, revisit on demand)
|
## Deferred (not design debts, revisit on demand)
|
||||||
|
|
||||||
|
|||||||
Generated
+1
@@ -17,6 +17,7 @@
|
|||||||
"vega": "^6.2.0",
|
"vega": "^6.2.0",
|
||||||
"vega-embed": "^7.1.0",
|
"vega-embed": "^7.1.0",
|
||||||
"vega-lite": "^6.4.2",
|
"vega-lite": "^6.4.2",
|
||||||
|
"vega-themes": "3.0.0",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"vega": "^6.2.0",
|
"vega": "^6.2.0",
|
||||||
"vega-embed": "^7.1.0",
|
"vega-embed": "^7.1.0",
|
||||||
"vega-lite": "^6.4.2",
|
"vega-lite": "^6.4.2",
|
||||||
|
"vega-themes": "3.0.0",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { act } from 'react';
|
import { act } from 'react';
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { chartConfigForSelection } from '@core/vega-themes';
|
||||||
|
import { useAppStore } from '../stores/AppStore';
|
||||||
import { usePreviewStore } from '../stores/PreviewStore';
|
import { usePreviewStore } from '../stores/PreviewStore';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
import { useDatasetStore } from '../stores/DatasetStore';
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
@@ -24,10 +26,12 @@ const H = vi.hoisted(() => ({
|
|||||||
calls: 0,
|
calls: 0,
|
||||||
pending: [] as Array<() => void>,
|
pending: [] as Array<() => void>,
|
||||||
destroyed: [] as number[],
|
destroyed: [] as number[],
|
||||||
|
configs: [] as unknown[],
|
||||||
}));
|
}));
|
||||||
vi.mock('../services/chart-renderer', () => ({
|
vi.mock('../services/chart-renderer', () => ({
|
||||||
renderSpec: (node: HTMLElement) => {
|
renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
|
||||||
const id = ++H.calls;
|
const id = ++H.calls;
|
||||||
|
H.configs.push(config);
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
H.pending.push(() => {
|
H.pending.push(() => {
|
||||||
node.replaceChildren(); // a real embed wipes then rebuilds the host
|
node.replaceChildren(); // a real embed wipes then rebuilds the host
|
||||||
@@ -64,6 +68,7 @@ beforeEach(() => {
|
|||||||
H.calls = 0;
|
H.calls = 0;
|
||||||
H.pending.length = 0;
|
H.pending.length = 0;
|
||||||
H.destroyed.length = 0;
|
H.destroyed.length = 0;
|
||||||
|
H.configs.length = 0;
|
||||||
usePreviewStore.setState({ error: null, busy: false });
|
usePreviewStore.setState({ error: null, busy: false });
|
||||||
useSnippetStore.getState().reset();
|
useSnippetStore.getState().reset();
|
||||||
useDatasetStore.getState().reset();
|
useDatasetStore.getState().reset();
|
||||||
@@ -82,21 +87,27 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('LivePreview busy overlay', () => {
|
describe('LivePreview busy overlay', () => {
|
||||||
|
// The overlay is the aria-hidden element carrying the "Rendering…" label — a
|
||||||
|
// bare [aria-hidden] query would also match decorative bits of the header
|
||||||
|
// controls (e.g. the chart-theme select's caret).
|
||||||
|
const overlay = () =>
|
||||||
|
[...container.querySelectorAll('[aria-hidden="true"]')].find((el) =>
|
||||||
|
/rendering/i.test(el.textContent ?? ''),
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
test('does not render the busy overlay when busy=false', () => {
|
test('does not render the busy overlay when busy=false', () => {
|
||||||
// The overlay element should not be in the DOM at all during normal operation.
|
// The overlay element should not be in the DOM at all during normal operation.
|
||||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
expect(overlay()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders the busy overlay when PreviewStore.busy=true', () => {
|
test('renders the busy overlay when PreviewStore.busy=true', () => {
|
||||||
act(() => usePreviewStore.setState({ busy: true }));
|
act(() => usePreviewStore.setState({ busy: true }));
|
||||||
const overlay = container.querySelector('[aria-hidden="true"]');
|
expect(overlay()).not.toBeNull();
|
||||||
expect(overlay).not.toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('overlay carries a visible label for sighted users', () => {
|
test('overlay carries a visible label for sighted users', () => {
|
||||||
act(() => usePreviewStore.setState({ busy: true }));
|
act(() => usePreviewStore.setState({ busy: true }));
|
||||||
const label = container.querySelector('[aria-hidden="true"]')?.textContent;
|
expect(overlay()?.textContent).toMatch(/rendering/i);
|
||||||
expect(label).toMatch(/rendering/i);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the preview body carries aria-busy=true when busy', () => {
|
test('the preview body carries aria-busy=true when busy', () => {
|
||||||
@@ -113,9 +124,9 @@ describe('LivePreview busy overlay', () => {
|
|||||||
|
|
||||||
test('overlay disappears when busy returns to false', () => {
|
test('overlay disappears when busy returns to false', () => {
|
||||||
act(() => usePreviewStore.setState({ busy: true }));
|
act(() => usePreviewStore.setState({ busy: true }));
|
||||||
expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull();
|
expect(overlay()).not.toBeNull();
|
||||||
act(() => usePreviewStore.setState({ busy: false }));
|
act(() => usePreviewStore.setState({ busy: false }));
|
||||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
expect(overlay()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,3 +197,32 @@ describe('LivePreview render serialization', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('LivePreview chart theme', () => {
|
||||||
|
const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));
|
||||||
|
|
||||||
|
test('the selected chart theme decides the config passed to renderSpec', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
act(() => {
|
||||||
|
useAppStore.setState({ chartTheme: 'stock', uiTheme: 'dark' });
|
||||||
|
useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
|
||||||
|
});
|
||||||
|
await tick();
|
||||||
|
act(() => H.pending[0]());
|
||||||
|
await tick(0);
|
||||||
|
// Stock = inject nothing; vega-lite's own defaults apply.
|
||||||
|
expect(H.configs[0]).toEqual({});
|
||||||
|
|
||||||
|
// Switching the theme re-renders with the new config (no text change needed).
|
||||||
|
act(() => useAppStore.setState({ chartTheme: 'astrolabe' }));
|
||||||
|
await tick();
|
||||||
|
act(() => H.pending[1]());
|
||||||
|
await tick(0);
|
||||||
|
expect(H.configs[1]).toEqual(chartConfigForSelection('astrolabe', 'dark'));
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
act(() => useAppStore.setState({ chartTheme: 'astrolabe', uiTheme: 'light' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||||||
import type { VisualizationSpec } from 'vega-embed';
|
import type { VisualizationSpec } from 'vega-embed';
|
||||||
import type { FitMode } from '@core/rendering';
|
import type { FitMode } from '@core/rendering';
|
||||||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||||
import { chartConfigFor } from '@core/vega-themes';
|
import { CHART_THEME_OPTIONS, chartConfigForSelection } from '@core/vega-themes';
|
||||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
import { useDatasetStore } from '../stores/DatasetStore';
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
@@ -29,6 +29,7 @@ import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
|||||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||||
import { ChartExport } from './ChartExport';
|
import { ChartExport } from './ChartExport';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
|
import { SelectControl } from './SelectControl';
|
||||||
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
|
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
|
||||||
import styles from './LivePreview.module.css';
|
import styles from './LivePreview.module.css';
|
||||||
|
|
||||||
@@ -68,6 +69,30 @@ function FitControl() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chart theme picker (spec §04; docs/chart-theming-scope.md §4.2) — which config
|
||||||
|
* charts render (and export) with. A global preference, not per-snippet: a
|
||||||
|
* snippet's own `config` still overrides it property by property. Lives in the
|
||||||
|
* header, not inside PreviewSettings: 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.
|
||||||
|
*/
|
||||||
|
// TODO: header placement/crowding parked for the batched council pass (docs/ux-second-pass.md).
|
||||||
|
function ChartThemeControl() {
|
||||||
|
const chartTheme = useAppStore((s) => s.chartTheme);
|
||||||
|
const setChartTheme = useAppStore((s) => s.setChartTheme);
|
||||||
|
return (
|
||||||
|
<SelectControl
|
||||||
|
id="preview-chart-theme"
|
||||||
|
label="Chart theme"
|
||||||
|
options={CHART_THEME_OPTIONS}
|
||||||
|
value={chartTheme}
|
||||||
|
onSelect={setChartTheme}
|
||||||
|
triggerTitle="Chart theme — how charts are styled when rendered and exported"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Preview settings cluster (spec §07 → Performance), disclosed beside Fit. */
|
/** Preview settings cluster (spec §07 → Performance), disclosed beside Fit. */
|
||||||
function PreviewSettings() {
|
function PreviewSettings() {
|
||||||
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
|
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
|
||||||
@@ -100,6 +125,7 @@ export function LivePreview() {
|
|||||||
const shownText = useSnippetStore(selectShownText);
|
const shownText = useSnippetStore(selectShownText);
|
||||||
const fitMode = useAppStore((s) => s.previewFitMode);
|
const fitMode = useAppStore((s) => s.previewFitMode);
|
||||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||||
|
const chartTheme = useAppStore((s) => s.chartTheme);
|
||||||
// Datasets feed reference resolution (spec §04 step 1). Re-rendering on a
|
// Datasets feed reference resolution (spec §04 step 1). Re-rendering on a
|
||||||
// dataset change keeps a referencing chart live as its data is edited.
|
// dataset change keeps a referencing chart live as its data is edited.
|
||||||
const datasets = useDatasetStore(useShallow((s) => s.datasets));
|
const datasets = useDatasetStore(useShallow((s) => s.datasets));
|
||||||
@@ -212,7 +238,7 @@ export function LivePreview() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
|
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
|
||||||
const config = chartConfigFor(uiTheme);
|
const config = chartConfigForSelection(chartTheme, uiTheme);
|
||||||
handleRef.current?.destroy();
|
handleRef.current?.destroy();
|
||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
const handle = await renderSpec(node, prepared as VisualizationSpec, config);
|
const handle = await renderSpec(node, prepared as VisualizationSpec, config);
|
||||||
@@ -259,6 +285,7 @@ export function LivePreview() {
|
|||||||
shownText,
|
shownText,
|
||||||
fitMode,
|
fitMode,
|
||||||
uiTheme,
|
uiTheme,
|
||||||
|
chartTheme,
|
||||||
datasets,
|
datasets,
|
||||||
setError,
|
setError,
|
||||||
setBusy,
|
setBusy,
|
||||||
@@ -321,8 +348,9 @@ export function LivePreview() {
|
|||||||
<div className={styles.preview}>
|
<div className={styles.preview}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<FitControl />
|
<FitControl />
|
||||||
{/* Right cluster: export this chart, then the preview settings gear. */}
|
{/* Right cluster: chart theme, export this chart, then the settings gear. */}
|
||||||
<div className={styles.headerEnd}>
|
<div className={styles.headerEnd}>
|
||||||
|
<ChartThemeControl />
|
||||||
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
|
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
|
||||||
<PreviewSettings />
|
<PreviewSettings />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* inline near the editor (spec §03E), mirroring the preview via PreviewStore.
|
* inline near the editor (spec §03E), mirroring the preview via PreviewStore.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, type RefObject } from 'react';
|
||||||
// `edcore.main` is the full standalone editor — every feature contribution
|
// `edcore.main` is the full standalone editor — every feature contribution
|
||||||
// (folding, suggest widget, word operations like Cmd+Backspace, find, bracket
|
// (folding, suggest widget, word operations like Cmd+Backspace, find, bracket
|
||||||
// colorization, multi-cursor, …) — but WITHOUT the `monaco-editor` barrel's
|
// colorization, multi-cursor, …) — but WITHOUT the `monaco-editor` barrel's
|
||||||
@@ -25,6 +25,11 @@ import '../infrastructure/monaco-env'; // side-effect: wire workers before creat
|
|||||||
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
|
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
|
||||||
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
|
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
|
||||||
import { openModal } from '../modals/ModalCoordinator';
|
import { openModal } from '../modals/ModalCoordinator';
|
||||||
|
import {
|
||||||
|
installSpecConfigActions,
|
||||||
|
runExtractConfig,
|
||||||
|
runMergeChartTheme,
|
||||||
|
} from '../services/spec-config-actions';
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
import { confirm } from '../stores/ConfirmStore';
|
import { confirm } from '../stores/ConfirmStore';
|
||||||
import { hasInlineData } from '../stores/ExtractStore';
|
import { hasInlineData } from '../stores/ExtractStore';
|
||||||
@@ -35,6 +40,7 @@ import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores
|
|||||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
|
import { SelectControl } from './SelectControl';
|
||||||
import {
|
import {
|
||||||
NumberControl,
|
NumberControl,
|
||||||
RangeControl,
|
RangeControl,
|
||||||
@@ -139,7 +145,30 @@ configureVegaLiteJson();
|
|||||||
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
|
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
|
||||||
configureJsonFormatter();
|
configureJsonFormatter();
|
||||||
|
|
||||||
function EditorToolbar() {
|
/** The two spec↔config operations, surfaced as an overflow menu (council:
|
||||||
|
* Carbon menu-buttons — overflow for additional options under space
|
||||||
|
* constraint; NN/g #6 — a visible home, with the Monaco context menu and F1
|
||||||
|
* palette as the #7 accelerators on the same code paths). */
|
||||||
|
const CONFIG_ACTIONS = [
|
||||||
|
{
|
||||||
|
value: 'merge',
|
||||||
|
label: 'Merge chart theme into spec',
|
||||||
|
detail: 'Write the active chart theme into the config block',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'extract',
|
||||||
|
label: 'Extract config from spec',
|
||||||
|
detail: 'Remove the config block and copy it to the clipboard',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value'];
|
||||||
|
|
||||||
|
function EditorToolbar({
|
||||||
|
editorRef,
|
||||||
|
}: {
|
||||||
|
editorRef: RefObject<monaco.editor.IStandaloneCodeEditor | null>;
|
||||||
|
}) {
|
||||||
const activeId = useSnippetStore((s) => s.activeSnippetId);
|
const activeId = useSnippetStore((s) => s.activeSnippetId);
|
||||||
const editorView = useSnippetStore((s) => s.editorView);
|
const editorView = useSnippetStore((s) => s.editorView);
|
||||||
const setEditorView = useSnippetStore((s) => s.setEditorView);
|
const setEditorView = useSnippetStore((s) => s.setEditorView);
|
||||||
@@ -161,6 +190,13 @@ function EditorToolbar() {
|
|||||||
// the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically.
|
// the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically.
|
||||||
const handlePublish = publishActiveSnippet;
|
const handlePublish = publishActiveSnippet;
|
||||||
|
|
||||||
|
const handleConfigAction = (action: ConfigActionId) => {
|
||||||
|
const editor = editorRef.current;
|
||||||
|
if (!editor) return;
|
||||||
|
if (action === 'merge') runMergeChartTheme(editor);
|
||||||
|
else void runExtractConfig(editor);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRevert = async () => {
|
const handleRevert = async () => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: 'Revert draft',
|
title: 'Revert draft',
|
||||||
@@ -207,6 +243,17 @@ function EditorToolbar() {
|
|||||||
<span className={styles.actionLabel}>Extract to Dataset</span>
|
<span className={styles.actionLabel}>Extract to Dataset</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<SelectControl
|
||||||
|
id="editor-config-actions"
|
||||||
|
label="Spec config actions"
|
||||||
|
heading="Spec config"
|
||||||
|
options={CONFIG_ACTIONS}
|
||||||
|
onSelect={handleConfigAction}
|
||||||
|
triggerClassName={styles.action}
|
||||||
|
triggerContent="Config"
|
||||||
|
triggerTitle="Spec config actions — merge the chart theme in, or extract the config out"
|
||||||
|
disabled={activeId === null || editorView === 'published'}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.action} ${styles.collapsible}`}
|
className={`${styles.action} ${styles.collapsible}`}
|
||||||
@@ -286,6 +333,11 @@ export function SpecEditor() {
|
|||||||
// up the formatted text. No-op on the read-only published view / invalid JSON.
|
// up the formatted text. No-op on the read-only published view / invalid JSON.
|
||||||
const pasteSub = installFormatOnPaste(editor);
|
const pasteSub = installFormatOnPaste(editor);
|
||||||
|
|
||||||
|
// Merge-chart-theme / extract-config actions (context menu + F1 palette,
|
||||||
|
// docs/chart-theming-scope.md §4.3). Edits land via onDidChangeModelContent
|
||||||
|
// above, so the draft buffer stays in sync like any other edit.
|
||||||
|
const configActionsSub = installSpecConfigActions(editor);
|
||||||
|
|
||||||
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
|
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
|
||||||
// "bind listeners in exactly one place"), which publishes before the
|
// "bind listeners in exactly one place"), which publishes before the
|
||||||
// interactive-context gate so it works while the editor has focus. Monaco
|
// interactive-context gate so it works while the editor has focus. Monaco
|
||||||
@@ -294,6 +346,7 @@ export function SpecEditor() {
|
|||||||
return () => {
|
return () => {
|
||||||
sub.dispose();
|
sub.dispose();
|
||||||
pasteSub.dispose();
|
pasteSub.dispose();
|
||||||
|
configActionsSub.dispose();
|
||||||
editor.dispose();
|
editor.dispose();
|
||||||
editorRef.current = null;
|
editorRef.current = null;
|
||||||
};
|
};
|
||||||
@@ -336,7 +389,7 @@ export function SpecEditor() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.editorPane}>
|
<div className={styles.editorPane}>
|
||||||
<EditorToolbar />
|
<EditorToolbar editorRef={editorRef} />
|
||||||
<div className={styles.editorWrap}>
|
<div className={styles.editorWrap}>
|
||||||
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
|
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
|
||||||
<div className={styles.editor} ref={hostRef} />
|
<div className={styles.editor} ref={hostRef} />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { defaultSettings } from '@core/settings';
|
import { defaultSettings } from '@core/settings';
|
||||||
import {
|
import {
|
||||||
|
loadChartTheme,
|
||||||
loadPreviewFitMode,
|
loadPreviewFitMode,
|
||||||
loadUiTheme,
|
loadUiTheme,
|
||||||
loadUserSettings,
|
loadUserSettings,
|
||||||
|
saveChartTheme,
|
||||||
saveManagedSettings,
|
saveManagedSettings,
|
||||||
savePreviewFitMode,
|
savePreviewFitMode,
|
||||||
saveUiTheme,
|
saveUiTheme,
|
||||||
@@ -119,6 +121,34 @@ describe('settings-store · ui.previewFitMode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('settings-store · ui.chartTheme', () => {
|
||||||
|
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('defaults to astrolabe when nothing is stored', () => {
|
||||||
|
expect(loadChartTheme()).toBe('astrolabe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a stored valid chart theme', () => {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify({ ui: { chartTheme: 'stock' } }));
|
||||||
|
expect(loadChartTheme()).toBe('stock');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to astrolabe for an unrecognized id', () => {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify({ ui: { chartTheme: 'neon' } }));
|
||||||
|
expect(loadChartTheme()).toBe('astrolabe');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips and preserves the other ui slices', () => {
|
||||||
|
saveUiTheme('dark');
|
||||||
|
savePreviewFitMode('height');
|
||||||
|
saveChartTheme('powerbi');
|
||||||
|
expect(loadChartTheme()).toBe('powerbi');
|
||||||
|
expect(loadUiTheme()).toBe('dark');
|
||||||
|
expect(loadPreviewFitMode()).toBe('height');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('settings-store · full UserSettings record', () => {
|
describe('settings-store · full UserSettings record', () => {
|
||||||
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import type { FitMode } from '@core/rendering';
|
import type { FitMode } from '@core/rendering';
|
||||||
import { loadSettings, type UserSettings } from '@core/settings';
|
import { loadSettings, type UserSettings } from '@core/settings';
|
||||||
import type { UiTheme } from '@core/theme';
|
import type { UiTheme } from '@core/theme';
|
||||||
|
import { isChartThemeId, type ChartThemeId } from '@core/vega-themes';
|
||||||
|
|
||||||
const KEY = 'astrolabe:settings';
|
const KEY = 'astrolabe:settings';
|
||||||
|
|
||||||
@@ -28,12 +29,15 @@ const DEFAULT_THEME: UiTheme = 'light';
|
|||||||
/** Spec §04 — the Fit control defaults to Original. */
|
/** Spec §04 — the Fit control defaults to Original. */
|
||||||
const DEFAULT_FIT_MODE: FitMode = 'default';
|
const DEFAULT_FIT_MODE: FitMode = 'default';
|
||||||
|
|
||||||
|
/** Spec §04 — the Chart theme picker defaults to the house style. */
|
||||||
|
const DEFAULT_CHART_THEME: ChartThemeId = 'astrolabe';
|
||||||
|
|
||||||
/** The managed slice the per-pane settings clusters own (theme + fit live in their own slices). */
|
/** The managed slice the per-pane settings clusters own (theme + fit live in their own slices). */
|
||||||
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
|
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
|
||||||
|
|
||||||
/** Loose view of the stored record for the per-slice write-through merges. */
|
/** Loose view of the stored record for the per-slice write-through merges. */
|
||||||
interface StoredSettings {
|
interface StoredSettings {
|
||||||
ui?: { theme?: unknown; previewFitMode?: unknown; [k: string]: unknown };
|
ui?: { theme?: unknown; previewFitMode?: unknown; chartTheme?: unknown; [k: string]: unknown };
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +122,18 @@ export function loadPreviewFitMode(): FitMode {
|
|||||||
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
|
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The persisted chart theme, or the default — unknown ids fall back. */
|
||||||
|
export function loadChartTheme(): ChartThemeId {
|
||||||
|
const stored = readRaw().ui?.chartTheme;
|
||||||
|
return isChartThemeId(stored) ? stored : DEFAULT_CHART_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the chart theme, preserving every other key already in the record. */
|
||||||
|
export function saveChartTheme(chartTheme: ChartThemeId): void {
|
||||||
|
const current = readRaw();
|
||||||
|
writeRaw({ ...current, ui: { ...current.ui, chartTheme } });
|
||||||
|
}
|
||||||
|
|
||||||
/** Persist the preview fit mode, preserving every other key already in the record. */
|
/** Persist the preview fit mode, preserving every other key already in the record. */
|
||||||
export function savePreviewFitMode(fitMode: FitMode): void {
|
export function savePreviewFitMode(fitMode: FitMode): void {
|
||||||
const current = readRaw();
|
const current = readRaw();
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Preference orchestration — bridges the (browser-free) AppStore to the settings
|
* Preference orchestration — bridges the (browser-free) AppStore to the settings
|
||||||
* adapter for the small UI preferences pulled forward ahead of the M5 Settings
|
* adapter for the small UI preferences pulled forward ahead of the M5 Settings
|
||||||
* modal. Same store↔adapter pattern as theme orchestration; currently the only
|
* modal. Same store↔adapter pattern as theme orchestration: the Live Preview
|
||||||
* such preference is the Live Preview fit mode (spec §04, `previewFitMode`).
|
* fit mode (spec §04, `previewFitMode`) and the chart theme (`chartTheme`).
|
||||||
*
|
*
|
||||||
* Unlike theme there is no FOUC concern (the preview renders after hydration
|
* Unlike theme there is no FOUC concern (the preview renders after hydration
|
||||||
* anyway), but hydrating early keeps the store the single source of truth from
|
* anyway), but hydrating early keeps the store the single source of truth from
|
||||||
* the first render.
|
* the first render.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { loadPreviewFitMode, savePreviewFitMode } from '../infrastructure/settings-store';
|
import {
|
||||||
|
loadChartTheme,
|
||||||
|
loadPreviewFitMode,
|
||||||
|
saveChartTheme,
|
||||||
|
savePreviewFitMode,
|
||||||
|
} from '../infrastructure/settings-store';
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
|
|
||||||
/** Hydrate the persisted fit mode into the store. Call before render. */
|
/** Hydrate the persisted fit mode into the store. Call before render. */
|
||||||
@@ -24,3 +29,16 @@ export function wirePreviewFitMode(): () => void {
|
|||||||
savePreviewFitMode(state.previewFitMode);
|
savePreviewFitMode(state.previewFitMode);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hydrate the persisted chart theme into the store. Call before render. */
|
||||||
|
export function initChartTheme(): void {
|
||||||
|
useAppStore.getState().setChartTheme(loadChartTheme());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the chart theme on change. Returns a teardown that detaches the subscriber. */
|
||||||
|
export function wireChartTheme(): () => void {
|
||||||
|
return useAppStore.subscribe((state, prev) => {
|
||||||
|
if (state.chartTheme === prev.chartTheme) return;
|
||||||
|
saveChartTheme(state.chartTheme);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Spec ↔ config editor actions (docs/chart-theming-scope.md §4.3) — the Monaco
|
||||||
|
* command-palette / context-menu pair over the portable core operations:
|
||||||
|
*
|
||||||
|
* - **Merge chart theme into spec** bakes the *currently selected* chart theme
|
||||||
|
* (the preview's Chart theme control) into the draft's `config` block, so the
|
||||||
|
* styling travels when the spec is published outside Astrolabe. The spec's
|
||||||
|
* existing `config` keys win — rendering is unchanged.
|
||||||
|
* - **Extract config from spec** removes the draft's `config` block and copies
|
||||||
|
* it to the clipboard — for cleaning baked-in styling out of a pasted spec.
|
||||||
|
* The clipboard write happens *before* the edit, so a clipboard failure
|
||||||
|
* never destroys the only copy.
|
||||||
|
*
|
||||||
|
* Both replace the document via `executeEdits`, so ⌘Z restores the previous
|
||||||
|
* text. Both no-op (with a toast naming the reason) on invalid JSON; Monaco's
|
||||||
|
* `!editorReadonly` precondition hides them on the published view.
|
||||||
|
*
|
||||||
|
* Surfacing (council: NN/g #6 recognition-over-recall, #7 accelerators; Carbon
|
||||||
|
* menu-buttons "use an overflow menu when additional options are available and
|
||||||
|
* there is a space constraint"): the visible home is the editor toolbar's
|
||||||
|
* **Config menu** (SpecEditor), which calls `runMergeChartTheme` /
|
||||||
|
* `runExtractConfig` directly; the context-menu/palette registrations here are
|
||||||
|
* the expert accelerators on the same code paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||||
|
import { formatJson } from '@core/json-format';
|
||||||
|
import { extractConfigFromSpec, isJsonObject, mergeConfigIntoSpec } from '@core/spec-config';
|
||||||
|
import { CHART_THEME_OPTIONS, chartConfigForSelection } from '@core/vega-themes';
|
||||||
|
import { copyText } from '../infrastructure/file-transfer';
|
||||||
|
import { useAppStore } from '../stores/AppStore';
|
||||||
|
import { notify } from '../stores/NotificationStore';
|
||||||
|
|
||||||
|
/** Parse the model's JSON, or toast (and return null) when it isn't a JSON object. */
|
||||||
|
function parseSpecObject(model: monaco.editor.ITextModel): Record<string, unknown> | null {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(model.getValue());
|
||||||
|
} catch {
|
||||||
|
notify({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Spec is not valid JSON',
|
||||||
|
message: 'Fix the JSON syntax first, then try again.',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isJsonObject(parsed)) {
|
||||||
|
notify({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Spec is not a JSON object',
|
||||||
|
message: 'Config actions need a top-level { … } Vega-Lite spec.',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the whole document as one undoable edit, in the app's JSON style. */
|
||||||
|
function replaceDocument(
|
||||||
|
editor: monaco.editor.IStandaloneCodeEditor,
|
||||||
|
model: monaco.editor.ITextModel,
|
||||||
|
source: string,
|
||||||
|
next: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
const raw = JSON.stringify(next);
|
||||||
|
const text = formatJson(raw, { indent: model.getOptions().tabSize }) ?? raw;
|
||||||
|
// Undo stops on both sides keep the replacement its own undo step — without
|
||||||
|
// the leading stop it can coalesce with the user's preceding typing, and ⌘Z
|
||||||
|
// would revert that too (Monaco's built-in actions bracket the same way).
|
||||||
|
editor.pushUndoStop();
|
||||||
|
editor.executeEdits(source, [{ range: model.getFullModelRange(), text }]);
|
||||||
|
editor.pushUndoStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bake the currently selected chart theme into the draft's `config` block. */
|
||||||
|
export function runMergeChartTheme(editor: monaco.editor.IStandaloneCodeEditor): void {
|
||||||
|
const model = editor.getModel();
|
||||||
|
if (!model) return;
|
||||||
|
const spec = parseSpecObject(model);
|
||||||
|
if (!spec) return;
|
||||||
|
|
||||||
|
const { chartTheme, uiTheme } = useAppStore.getState();
|
||||||
|
// A Config is plain JSON data; the cast bridges its closed vega-lite type
|
||||||
|
// to the JsonObject the portable merge operates on.
|
||||||
|
const themeConfig = chartConfigForSelection(chartTheme, uiTheme) as Record<string, unknown>;
|
||||||
|
if (Object.keys(themeConfig).length === 0) {
|
||||||
|
notify({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'Nothing to merge',
|
||||||
|
message: 'The Stock Vega-Lite chart theme injects no config.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeLabel = CHART_THEME_OPTIONS.find((o) => o.value === chartTheme)?.label ?? chartTheme;
|
||||||
|
replaceDocument(editor, model, 'merge-chart-theme', mergeConfigIntoSpec(spec, themeConfig));
|
||||||
|
notify({
|
||||||
|
kind: 'success',
|
||||||
|
title: 'Chart theme merged',
|
||||||
|
message: `The ${themeLabel} theme now travels in the spec’s config; existing keys were kept. Undo with ⌘/Ctrl+Z.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the draft's `config` block, copying it to the clipboard first. */
|
||||||
|
export async function runExtractConfig(editor: monaco.editor.IStandaloneCodeEditor): Promise<void> {
|
||||||
|
const model = editor.getModel();
|
||||||
|
if (!model) return;
|
||||||
|
const spec = parseSpecObject(model);
|
||||||
|
if (!spec) return;
|
||||||
|
|
||||||
|
const { spec: rest, config } = extractConfigFromSpec(spec);
|
||||||
|
if (config === null) {
|
||||||
|
notify({
|
||||||
|
kind: 'info',
|
||||||
|
title: 'No config to extract',
|
||||||
|
message: 'This spec has no config block.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy before removing — if the clipboard write fails, the spec keeps its
|
||||||
|
// config and nothing is lost.
|
||||||
|
try {
|
||||||
|
await copyText(JSON.stringify(config, null, 2));
|
||||||
|
} catch {
|
||||||
|
notify({
|
||||||
|
kind: 'error',
|
||||||
|
title: 'Could not copy the config',
|
||||||
|
message: 'Clipboard access failed, so the spec was left unchanged.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceDocument(editor, model, 'extract-config', rest);
|
||||||
|
notify({
|
||||||
|
kind: 'success',
|
||||||
|
title: 'Config extracted',
|
||||||
|
message: 'The config block was removed and copied to the clipboard. Undo with ⌘/Ctrl+Z.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register both actions on the editor (context menu + F1 palette — the expert
|
||||||
|
* accelerators; the toolbar Config menu is the discoverable home). Returns a
|
||||||
|
* disposable that detaches them (dispose on editor unmount, like the
|
||||||
|
* format-on-paste hook).
|
||||||
|
*/
|
||||||
|
export function installSpecConfigActions(
|
||||||
|
editor: monaco.editor.IStandaloneCodeEditor,
|
||||||
|
): monaco.IDisposable {
|
||||||
|
const merge = editor.addAction({
|
||||||
|
id: 'astrolabe.merge-chart-theme',
|
||||||
|
label: 'Merge Chart Theme into Spec',
|
||||||
|
contextMenuGroupId: 'astrolabe',
|
||||||
|
contextMenuOrder: 1,
|
||||||
|
precondition: '!editorReadonly',
|
||||||
|
run: () => runMergeChartTheme(editor),
|
||||||
|
});
|
||||||
|
|
||||||
|
const extract = editor.addAction({
|
||||||
|
id: 'astrolabe.extract-config',
|
||||||
|
label: 'Extract Config from Spec',
|
||||||
|
contextMenuGroupId: 'astrolabe',
|
||||||
|
contextMenuOrder: 2,
|
||||||
|
precondition: '!editorReadonly',
|
||||||
|
run: () => runExtractConfig(editor),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispose() {
|
||||||
|
merge.dispose();
|
||||||
|
extract.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { FitMode } from '@core/rendering';
|
import type { FitMode } from '@core/rendering';
|
||||||
import type { UiTheme } from '@core/theme';
|
import type { UiTheme } from '@core/theme';
|
||||||
|
import type { ChartThemeId } from '@core/vega-themes';
|
||||||
import type { ModalName } from '../modals/types';
|
import type { ModalName } from '../modals/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +24,8 @@ export interface AppState {
|
|||||||
uiTheme: UiTheme;
|
uiTheme: UiTheme;
|
||||||
/** Preview sizing mode (spec §04); persisted to Settings as `previewFitMode`. */
|
/** Preview sizing mode (spec §04); persisted to Settings as `previewFitMode`. */
|
||||||
previewFitMode: FitMode;
|
previewFitMode: FitMode;
|
||||||
|
/** Chart theme selection (spec §04); persisted to Settings as `chartTheme`. */
|
||||||
|
chartTheme: ChartThemeId;
|
||||||
/** The currently open modal, or null. */
|
/** The currently open modal, or null. */
|
||||||
activeModal: ModalName | null;
|
activeModal: ModalName | null;
|
||||||
|
|
||||||
@@ -31,6 +34,8 @@ export interface AppState {
|
|||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
/** Set the preview fit mode — the Live Preview Fit control's action. */
|
/** Set the preview fit mode — the Live Preview Fit control's action. */
|
||||||
setPreviewFitMode: (mode: FitMode) => void;
|
setPreviewFitMode: (mode: FitMode) => void;
|
||||||
|
/** Set the chart theme — the Live Preview settings cluster's action. */
|
||||||
|
setChartTheme: (theme: ChartThemeId) => void;
|
||||||
/**
|
/**
|
||||||
* Low-level modal setter — the single primitive that mutates `activeModal`.
|
* Low-level modal setter — the single primitive that mutates `activeModal`.
|
||||||
* High-level open/close (snapshot for unsaved-change detection, URL sync,
|
* High-level open/close (snapshot for unsaved-change detection, URL sync,
|
||||||
@@ -43,10 +48,12 @@ export interface AppState {
|
|||||||
export const useAppStore = create<AppState>((set) => ({
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
uiTheme: 'light',
|
uiTheme: 'light',
|
||||||
previewFitMode: 'default',
|
previewFitMode: 'default',
|
||||||
|
chartTheme: 'astrolabe',
|
||||||
activeModal: null,
|
activeModal: null,
|
||||||
|
|
||||||
setTheme: (uiTheme) => set({ uiTheme }),
|
setTheme: (uiTheme) => set({ uiTheme }),
|
||||||
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
||||||
setPreviewFitMode: (previewFitMode) => set({ previewFitMode }),
|
setPreviewFitMode: (previewFitMode) => set({ previewFitMode }),
|
||||||
|
setChartTheme: (chartTheme) => set({ chartTheme }),
|
||||||
setActiveModal: (activeModal) => set({ activeModal }),
|
setActiveModal: (activeModal) => set({ activeModal }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe('defaultSettings', () => {
|
|||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
},
|
},
|
||||||
performance: { renderDebounce: 1500 },
|
performance: { renderDebounce: 1500 },
|
||||||
ui: { theme: 'light', previewFitMode: 'default' },
|
ui: { theme: 'light', previewFitMode: 'default', chartTheme: 'astrolabe' },
|
||||||
formatting: { dateFormat: 'smart', customDateFormat: '' },
|
formatting: { dateFormat: 'smart', customDateFormat: '' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -64,7 +64,7 @@ describe('loadSettings — valid records', () => {
|
|||||||
tabSize: 4,
|
tabSize: 4,
|
||||||
},
|
},
|
||||||
performance: { renderDebounce: 2500 },
|
performance: { renderDebounce: 2500 },
|
||||||
ui: { theme: 'dark', previewFitMode: 'full' },
|
ui: { theme: 'dark', previewFitMode: 'full', chartTheme: 'fivethirtyeight' },
|
||||||
formatting: { dateFormat: 'custom', customDateFormat: 'yyyy-MM-dd' },
|
formatting: { dateFormat: 'custom', customDateFormat: 'yyyy-MM-dd' },
|
||||||
};
|
};
|
||||||
expect(loadSettings(record)).toEqual(record);
|
expect(loadSettings(record)).toEqual(record);
|
||||||
@@ -160,6 +160,7 @@ describe('loadSettings — enum validation', () => {
|
|||||||
expect(loadSettings({ ui: { previewFitMode: 'tall' } }).ui.previewFitMode).toBe(
|
expect(loadSettings({ ui: { previewFitMode: 'tall' } }).ui.previewFitMode).toBe(
|
||||||
d.ui.previewFitMode,
|
d.ui.previewFitMode,
|
||||||
);
|
);
|
||||||
|
expect(loadSettings({ ui: { chartTheme: 'comic-sans' } }).ui.chartTheme).toBe(d.ui.chartTheme);
|
||||||
expect(loadSettings({ formatting: { dateFormat: 'relative' } }).formatting.dateFormat).toBe(
|
expect(loadSettings({ formatting: { dateFormat: 'relative' } }).formatting.dateFormat).toBe(
|
||||||
d.formatting.dateFormat,
|
d.formatting.dateFormat,
|
||||||
);
|
);
|
||||||
@@ -171,6 +172,8 @@ describe('loadSettings — enum validation', () => {
|
|||||||
expect(loadSettings({ ui: { theme: 'dark' } }).ui.theme).toBe('dark');
|
expect(loadSettings({ ui: { theme: 'dark' } }).ui.theme).toBe('dark');
|
||||||
expect(loadSettings({ ui: { previewFitMode: 'width' } }).ui.previewFitMode).toBe('width');
|
expect(loadSettings({ ui: { previewFitMode: 'width' } }).ui.previewFitMode).toBe('width');
|
||||||
expect(loadSettings({ ui: { previewFitMode: 'height' } }).ui.previewFitMode).toBe('height');
|
expect(loadSettings({ ui: { previewFitMode: 'height' } }).ui.previewFitMode).toBe('height');
|
||||||
|
expect(loadSettings({ ui: { chartTheme: 'stock' } }).ui.chartTheme).toBe('stock');
|
||||||
|
expect(loadSettings({ ui: { chartTheme: 'latimes' } }).ui.chartTheme).toBe('latimes');
|
||||||
expect(loadSettings({ formatting: { dateFormat: 'iso' } }).formatting.dateFormat).toBe('iso');
|
expect(loadSettings({ formatting: { dateFormat: 'iso' } }).formatting.dateFormat).toBe('iso');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
* guarantees that (the read-time migration, mirroring `migrateSnippet`).
|
* guarantees that (the read-time migration, mirroring `migrateSnippet`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { isChartThemeId, type ChartThemeId } from './vega-themes';
|
||||||
|
|
||||||
/** Current schema version for a UserSettings record (read-time migration target). */
|
/** Current schema version for a UserSettings record (read-time migration target). */
|
||||||
export const CURRENT_SETTINGS_VERSION = 1;
|
export const CURRENT_SETTINGS_VERSION = 1;
|
||||||
|
|
||||||
@@ -57,6 +59,13 @@ export interface UserSettings {
|
|||||||
* Preview_). Default `'default'`.
|
* Preview_). Default `'default'`.
|
||||||
*/
|
*/
|
||||||
previewFitMode: 'default' | 'width' | 'height' | 'full';
|
previewFitMode: 'default' | 'width' | 'height' | 'full';
|
||||||
|
/**
|
||||||
|
* Chart theme — which config is injected when charts render (spec §04;
|
||||||
|
* docs/chart-theming-scope.md §4.2). `'astrolabe'` (default) is the house
|
||||||
|
* style following the UI theme; `'stock'` injects nothing; other ids are
|
||||||
|
* vega-themes presets. Set by the preview's settings cluster.
|
||||||
|
*/
|
||||||
|
chartTheme: ChartThemeId;
|
||||||
};
|
};
|
||||||
/** Date-rendering preferences (spec §07 → Formatting). */
|
/** Date-rendering preferences (spec §07 → Formatting). */
|
||||||
formatting: {
|
formatting: {
|
||||||
@@ -97,6 +106,7 @@ export function defaultSettings(): UserSettings {
|
|||||||
ui: {
|
ui: {
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
previewFitMode: 'default',
|
previewFitMode: 'default',
|
||||||
|
chartTheme: 'astrolabe',
|
||||||
},
|
},
|
||||||
formatting: {
|
formatting: {
|
||||||
dateFormat: 'smart',
|
dateFormat: 'smart',
|
||||||
@@ -194,6 +204,7 @@ export function loadSettings(raw: unknown): UserSettings {
|
|||||||
['default', 'width', 'height', 'full'] as const,
|
['default', 'width', 'height', 'full'] as const,
|
||||||
d.ui.previewFitMode,
|
d.ui.previewFitMode,
|
||||||
),
|
),
|
||||||
|
chartTheme: isChartThemeId(ui.chartTheme) ? ui.chartTheme : d.ui.chartTheme,
|
||||||
},
|
},
|
||||||
formatting: {
|
formatting: {
|
||||||
dateFormat: asEnum(
|
dateFormat: asEnum(
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { extractConfigFromSpec, isJsonObject, mergeConfigIntoSpec } from './spec-config';
|
||||||
|
|
||||||
|
describe('mergeConfigIntoSpec', () => {
|
||||||
|
const theme = {
|
||||||
|
background: 'transparent',
|
||||||
|
font: 'IBM Plex Sans',
|
||||||
|
axis: { labelColor: '#525252', gridDash: [2, 2] },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('adds the config block to a spec without one', () => {
|
||||||
|
const spec = { mark: 'bar', data: { values: [] } };
|
||||||
|
const out = mergeConfigIntoSpec(spec, theme);
|
||||||
|
expect(out.config).toEqual(theme);
|
||||||
|
expect(out.mark).toBe('bar');
|
||||||
|
expect(spec).not.toHaveProperty('config'); // input untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the spec’s existing config wins key-by-key, deep', () => {
|
||||||
|
const spec = {
|
||||||
|
mark: 'bar',
|
||||||
|
config: { font: 'Georgia', axis: { labelColor: 'red' } },
|
||||||
|
};
|
||||||
|
const out = mergeConfigIntoSpec(spec, theme);
|
||||||
|
expect(out.config).toEqual({
|
||||||
|
background: 'transparent', // from the theme
|
||||||
|
font: 'Georgia', // spec wins
|
||||||
|
axis: { labelColor: 'red', gridDash: [2, 2] }, // merged: spec wins inside
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('arrays are replaced, not merged', () => {
|
||||||
|
const spec = { config: { axis: { gridDash: [8] } } };
|
||||||
|
const out = mergeConfigIntoSpec(spec, theme) as { config: { axis: { gridDash: number[] } } };
|
||||||
|
expect(out.config.axis.gridDash).toEqual([8]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty config into a config-less spec stays config-less', () => {
|
||||||
|
expect(mergeConfigIntoSpec({ mark: 'bar' }, {})).toEqual({ mark: 'bar' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a non-object spec config is replaced by the merge', () => {
|
||||||
|
const out = mergeConfigIntoSpec({ config: 'junk' }, theme);
|
||||||
|
expect(out.config).toEqual(theme);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractConfigFromSpec', () => {
|
||||||
|
it('removes and returns the config block', () => {
|
||||||
|
const spec = { mark: 'bar', config: { font: 'Georgia' } };
|
||||||
|
const out = extractConfigFromSpec(spec);
|
||||||
|
expect(out.config).toEqual({ font: 'Georgia' });
|
||||||
|
expect(out.spec).toEqual({ mark: 'bar' });
|
||||||
|
expect(spec).toHaveProperty('config'); // input untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null config when the spec has none', () => {
|
||||||
|
const out = extractConfigFromSpec({ mark: 'bar' });
|
||||||
|
expect(out.config).toBeNull();
|
||||||
|
expect(out.spec).toEqual({ mark: 'bar' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an empty or non-object config extracts as null but is still removed', () => {
|
||||||
|
expect(extractConfigFromSpec({ mark: 'bar', config: {} })).toEqual({
|
||||||
|
spec: { mark: 'bar' },
|
||||||
|
config: null,
|
||||||
|
});
|
||||||
|
expect(extractConfigFromSpec({ mark: 'bar', config: 7 })).toEqual({
|
||||||
|
spec: { mark: 'bar' },
|
||||||
|
config: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isJsonObject', () => {
|
||||||
|
it('accepts plain objects only', () => {
|
||||||
|
expect(isJsonObject({})).toBe(true);
|
||||||
|
expect(isJsonObject([])).toBe(false);
|
||||||
|
expect(isJsonObject(null)).toBe(false);
|
||||||
|
expect(isJsonObject('x')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Spec ↔ config operations (docs/chart-theming-scope.md §4.3).
|
||||||
|
*
|
||||||
|
* The two halves of making the injected chart theme portable, mirroring the
|
||||||
|
* Vega editor's "Merge Config Into Spec" / "Extract Config From Spec" pair:
|
||||||
|
*
|
||||||
|
* - **Merge** bakes a config into the spec's own `config` block — for
|
||||||
|
* publishing a snippet somewhere the app's theme won't follow it. The spec's
|
||||||
|
* existing `config` wins on conflicts, matching the render-time precedence
|
||||||
|
* (vega-lite layers `spec.config` over the injected config), so baking never
|
||||||
|
* changes how the chart looks.
|
||||||
|
* - **Extract** lifts the `config` block out of a spec — for cleaning styling
|
||||||
|
* out of a pasted-in spec (the caller decides where the extracted config
|
||||||
|
* goes: clipboard today, a saved theme later).
|
||||||
|
*
|
||||||
|
* Pure object-in/object-out; JSON text handling (parse, format, undo) is the
|
||||||
|
* editor integration's job.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A parsed JSON object (the only spec shape these operations accept). */
|
||||||
|
export type JsonObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** Is the value a plain JSON object (not an array, not null)? */
|
||||||
|
export function isJsonObject(value: unknown): value is JsonObject {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-merge `upper` over `lower`: plain objects merge recursively, everything
|
||||||
|
* else (arrays, scalars) is replaced by the upper value. The same shape of
|
||||||
|
* merge vega-lite applies between the embed-time config and `spec.config`.
|
||||||
|
*/
|
||||||
|
function deepMerge(lower: JsonObject, upper: JsonObject): JsonObject {
|
||||||
|
const out: JsonObject = { ...lower };
|
||||||
|
for (const [key, upperValue] of Object.entries(upper)) {
|
||||||
|
const lowerValue = out[key];
|
||||||
|
out[key] =
|
||||||
|
isJsonObject(lowerValue) && isJsonObject(upperValue)
|
||||||
|
? deepMerge(lowerValue, upperValue)
|
||||||
|
: upperValue;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bake `config` into the spec's `config` block. The spec's existing `config`
|
||||||
|
* takes precedence key-by-key (deep), so the rendered result is unchanged —
|
||||||
|
* the theme just travels with the spec now. Returns a new object; the input
|
||||||
|
* is not mutated. An empty merge result still writes `config: {}` only when
|
||||||
|
* the spec already had one; baking an empty config into a config-less spec is
|
||||||
|
* a no-op.
|
||||||
|
*/
|
||||||
|
export function mergeConfigIntoSpec(spec: JsonObject, config: JsonObject): JsonObject {
|
||||||
|
const specConfig = isJsonObject(spec.config) ? spec.config : {};
|
||||||
|
const merged = deepMerge(config, specConfig);
|
||||||
|
if (Object.keys(merged).length === 0 && !('config' in spec)) return { ...spec };
|
||||||
|
return { ...spec, config: merged };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of `extractConfigFromSpec`. */
|
||||||
|
export interface ExtractedConfig {
|
||||||
|
/** The spec without its `config` block (new object; input not mutated). */
|
||||||
|
spec: JsonObject;
|
||||||
|
/** The removed `config`, or null when the spec had none worth extracting. */
|
||||||
|
config: JsonObject | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the spec's `config` block and hand it back separately. A missing,
|
||||||
|
* empty, or non-object `config` extracts as `null` (an empty/junk block is
|
||||||
|
* still removed from the spec — there is nothing to keep).
|
||||||
|
*/
|
||||||
|
export function extractConfigFromSpec(spec: JsonObject): ExtractedConfig {
|
||||||
|
if (!('config' in spec)) return { spec: { ...spec }, config: null };
|
||||||
|
const { config, ...rest } = spec;
|
||||||
|
const extracted = isJsonObject(config) && Object.keys(config).length > 0 ? config : null;
|
||||||
|
return { spec: rest, config: extracted };
|
||||||
|
}
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { chartConfigFor, darkChartConfig, lightChartConfig } from './vega-themes';
|
import {
|
||||||
|
CHART_THEME_OPTIONS,
|
||||||
|
chartConfigFor,
|
||||||
|
chartConfigForSelection,
|
||||||
|
isChartThemeId,
|
||||||
|
darkBaseConfig,
|
||||||
|
darkChartConfig,
|
||||||
|
darkExpressiveConfig,
|
||||||
|
lightBaseConfig,
|
||||||
|
lightChartConfig,
|
||||||
|
lightExpressiveConfig,
|
||||||
|
mergeChartLayers,
|
||||||
|
} from './vega-themes';
|
||||||
import type { UiTheme } from './theme';
|
import type { UiTheme } from './theme';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,3 +49,77 @@ describe('chartConfigFor', () => {
|
|||||||
expect(lightChartConfig.range?.category).not.toEqual(darkChartConfig.range?.category);
|
expect(lightChartConfig.range?.category).not.toEqual(darkChartConfig.range?.category);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layer split (docs/chart-theming-scope.md §1): base carries only the
|
||||||
|
* legibility minimum (background + guide colors); everything brand-flavored
|
||||||
|
* (font, palette, grid dash, sizes/weights, view stroke) lives in expressive.
|
||||||
|
* A future stock/custom chart style keeps base and swaps expressive.
|
||||||
|
*/
|
||||||
|
describe('chart config layers', () => {
|
||||||
|
const layers = [
|
||||||
|
{ base: lightBaseConfig, expressive: lightExpressiveConfig, full: lightChartConfig },
|
||||||
|
{ base: darkBaseConfig, expressive: darkExpressiveConfig, full: darkChartConfig },
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(layers)('base stays free of house style', ({ base }) => {
|
||||||
|
expect(base.font).toBeUndefined();
|
||||||
|
expect(base.range).toBeUndefined();
|
||||||
|
expect(base.view).toBeUndefined();
|
||||||
|
expect(base.axis?.gridDash).toBeUndefined();
|
||||||
|
expect(base.title).not.toHaveProperty('fontSize');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(layers)('expressive stays free of legibility colors', ({ expressive }) => {
|
||||||
|
expect(expressive.background).toBeUndefined();
|
||||||
|
expect(expressive.axis?.labelColor).toBeUndefined();
|
||||||
|
expect(expressive.title).not.toHaveProperty('color');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(layers)('layers merge into the full theme config', ({ base, expressive, full }) => {
|
||||||
|
expect(mergeChartLayers(base, expressive)).toEqual(full);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges the nested title and axis groups instead of replacing them', () => {
|
||||||
|
const merged = mergeChartLayers(lightBaseConfig, lightExpressiveConfig);
|
||||||
|
// One property from each layer survives in the same nested object.
|
||||||
|
expect(merged.title).toMatchObject({ color: '#161616', fontSize: 16 });
|
||||||
|
expect(merged.axis).toMatchObject({ labelColor: '#525252', gridDash: [2, 2] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chartConfigForSelection', () => {
|
||||||
|
it('astrolabe follows the UI theme', () => {
|
||||||
|
expect(chartConfigForSelection('astrolabe', 'light')).toBe(lightChartConfig);
|
||||||
|
expect(chartConfigForSelection('astrolabe', 'dark')).toBe(darkChartConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stock injects nothing (vega-lite defaults apply)', () => {
|
||||||
|
expect(chartConfigForSelection('stock', 'light')).toEqual({});
|
||||||
|
expect(chartConfigForSelection('stock', 'dark')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('presets resolve to a non-empty config independent of UI theme', () => {
|
||||||
|
const light = chartConfigForSelection('fivethirtyeight', 'light');
|
||||||
|
expect(Object.keys(light).length).toBeGreaterThan(0);
|
||||||
|
expect(chartConfigForSelection('fivethirtyeight', 'dark')).toBe(light);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every option id resolves to a config', () => {
|
||||||
|
for (const { value } of CHART_THEME_OPTIONS) {
|
||||||
|
expect(chartConfigForSelection(value, 'light')).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isChartThemeId', () => {
|
||||||
|
it('accepts every option id', () => {
|
||||||
|
for (const { value } of CHART_THEME_OPTIONS) expect(isChartThemeId(value)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown and non-string values', () => {
|
||||||
|
expect(isChartThemeId('comic-sans')).toBe(false);
|
||||||
|
expect(isChartThemeId(undefined)).toBe(false);
|
||||||
|
expect(isChartThemeId(7)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+149
-21
@@ -5,17 +5,29 @@
|
|||||||
* visually belong to the app rather than looking like stock Vega-Lite. This is
|
* visually belong to the app rather than looking like stock Vega-Lite. This is
|
||||||
* the single source of truth mapping a `UiTheme` to a config; it is applied at
|
* the single source of truth mapping a `UiTheme` to a config; it is applied at
|
||||||
* embed time (never baked into the user's stored spec). Adding a UI theme = one
|
* embed time (never baked into the user's stored spec). Adding a UI theme = one
|
||||||
* config object plus one map entry here.
|
* base + expressive pair plus one map entry here.
|
||||||
*
|
*
|
||||||
* Values track the design language: IBM Plex font, axis/grid colors from the
|
* Each theme's config is two layers (docs/chart-theming-scope.md §1):
|
||||||
* Carbon neutral ramp (matching `--text-secondary` / `--border`), and a
|
*
|
||||||
* categorical `range.category` palette transcribed from Carbon's data-viz
|
* - **Base** — the legibility/integration minimum: transparent background (the
|
||||||
* 14-color pairing (white theme for light, g100 for dark — see
|
* pane color shows through) and guide colors readable on the app's surfaces.
|
||||||
* carbon-charts `packages/core/scss/_color-palette.scss`). This is the
|
* Without this layer, stock black-on-white chart text is illegible on the
|
||||||
* expressive "free color" layer (doc §3.5, §5).
|
* dark pane. Colors match the app tokens (`--text`, `--text-secondary`,
|
||||||
|
* `--border`, `--border-strong`).
|
||||||
|
* - **Expressive** — the house style: IBM Plex, the Carbon data-viz categorical
|
||||||
|
* palette (white theme for light, g100 for dark — see carbon-charts
|
||||||
|
* `packages/core/scss/_color-palette.scss`), dotted grid, bumped guide
|
||||||
|
* sizes/weights, no plot border. This is the "free color" layer (doc §3.5,
|
||||||
|
* §5); charts render fine without it, just stock-looking.
|
||||||
|
*
|
||||||
|
* The split exists so a non-house chart style (stock preview, future custom
|
||||||
|
* themes) can keep the base layer while replacing the expressive one.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Config } from 'vega-lite';
|
import type { Config } from 'vega-lite';
|
||||||
|
// Preset chart styles from the vega-themes package (already in the dependency
|
||||||
|
// tree via vega-embed). Pure data — config objects only — so portable for core.
|
||||||
|
import * as presets from 'vega-themes';
|
||||||
import type { UiTheme } from './theme';
|
import type { UiTheme } from './theme';
|
||||||
|
|
||||||
const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif';
|
const PLEX = '"IBM Plex Sans", system-ui, -apple-system, sans-serif';
|
||||||
@@ -56,42 +68,74 @@ const darkCategory = [
|
|||||||
'#d4bbff', // purple 30
|
'#d4bbff', // purple 30
|
||||||
];
|
];
|
||||||
|
|
||||||
export const lightChartConfig: Config = {
|
/** Base layer — light: transparent background + guide colors on app tokens. */
|
||||||
|
export const lightBaseConfig: Config = {
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
font: PLEX,
|
title: { color: '#161616' }, // --text (light)
|
||||||
title: { fontSize: 16, fontWeight: 600, color: '#161616' },
|
|
||||||
axis: {
|
axis: {
|
||||||
domainColor: '#c6c6c6', // --border-strong (light)
|
domainColor: '#c6c6c6', // --border-strong (light)
|
||||||
gridColor: '#e0e0e0', // --border (light)
|
gridColor: '#e0e0e0', // --border (light)
|
||||||
gridDash: [2, 2],
|
|
||||||
labelColor: '#525252', // --text-secondary (light)
|
labelColor: '#525252', // --text-secondary (light)
|
||||||
titleColor: '#161616', // --text (light)
|
titleColor: '#161616', // --text (light)
|
||||||
labelFontSize: 11,
|
|
||||||
titleFontSize: 12,
|
|
||||||
titleFontWeight: 600,
|
|
||||||
},
|
},
|
||||||
range: { category: lightCategory },
|
|
||||||
view: { stroke: 'transparent' },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const darkChartConfig: Config = {
|
/** Base layer — dark: transparent background + guide colors on app tokens. */
|
||||||
|
export const darkBaseConfig: Config = {
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
font: PLEX,
|
title: { color: '#f4f4f4' }, // --text (dark)
|
||||||
title: { fontSize: 16, fontWeight: 600, color: '#f4f4f4' },
|
|
||||||
axis: {
|
axis: {
|
||||||
domainColor: '#525252', // --border-strong (dark)
|
domainColor: '#525252', // --border-strong (dark)
|
||||||
gridColor: '#393939', // --border (dark)
|
gridColor: '#393939', // --border (dark)
|
||||||
gridDash: [2, 2],
|
|
||||||
labelColor: '#a8a8a8', // --text-secondary (dark)
|
labelColor: '#a8a8a8', // --text-secondary (dark)
|
||||||
titleColor: '#f4f4f4', // --text (dark)
|
titleColor: '#f4f4f4', // --text (dark)
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Expressive layer parts shared by both themes (everything but the palette). */
|
||||||
|
const sharedExpressive: Config = {
|
||||||
|
font: PLEX,
|
||||||
|
title: { fontSize: 16, fontWeight: 600 },
|
||||||
|
axis: {
|
||||||
|
gridDash: [2, 2],
|
||||||
labelFontSize: 11,
|
labelFontSize: 11,
|
||||||
titleFontSize: 12,
|
titleFontSize: 12,
|
||||||
titleFontWeight: 600,
|
titleFontWeight: 600,
|
||||||
},
|
},
|
||||||
range: { category: darkCategory },
|
|
||||||
view: { stroke: 'transparent' },
|
view: { stroke: 'transparent' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Expressive layer — light: house style + the light categorical palette. */
|
||||||
|
export const lightExpressiveConfig: Config = {
|
||||||
|
...sharedExpressive,
|
||||||
|
range: { category: lightCategory },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Expressive layer — dark: house style + the dark categorical palette. */
|
||||||
|
export const darkExpressiveConfig: Config = {
|
||||||
|
...sharedExpressive,
|
||||||
|
range: { category: darkCategory },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a base and an expressive layer into one chart config. Shallow spread
|
||||||
|
* plus the two nested objects both layers contribute to (`title`, `axis`);
|
||||||
|
* the expressive layer wins on conflicts (there are none today — the layers
|
||||||
|
* own disjoint properties).
|
||||||
|
*/
|
||||||
|
export function mergeChartLayers(base: Config, expressive: Config): Config {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...expressive,
|
||||||
|
title: { ...base.title, ...expressive.title },
|
||||||
|
axis: { ...base.axis, ...expressive.axis },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lightChartConfig: Config = mergeChartLayers(lightBaseConfig, lightExpressiveConfig);
|
||||||
|
|
||||||
|
export const darkChartConfig: Config = mergeChartLayers(darkBaseConfig, darkExpressiveConfig);
|
||||||
|
|
||||||
const CHART_CONFIG: Record<UiTheme, Config> = {
|
const CHART_CONFIG: Record<UiTheme, Config> = {
|
||||||
light: lightChartConfig,
|
light: lightChartConfig,
|
||||||
dark: darkChartConfig,
|
dark: darkChartConfig,
|
||||||
@@ -101,3 +145,87 @@ const CHART_CONFIG: Record<UiTheme, Config> = {
|
|||||||
export function chartConfigFor(theme: UiTheme): Config {
|
export function chartConfigFor(theme: UiTheme): Config {
|
||||||
return CHART_CONFIG[theme];
|
return CHART_CONFIG[theme];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selectable chart themes (docs/chart-theming-scope.md §4.2) — the user-facing
|
||||||
|
* choice of how charts render, distinct from (and composed with) the UI theme:
|
||||||
|
*
|
||||||
|
* - `'astrolabe'` — the house style above; resolves per UI theme. Default.
|
||||||
|
* - `'stock'` — no injected config at all: charts render exactly as Vega-Lite
|
||||||
|
* defaults would anywhere else (white background, tableau10, sans-serif).
|
||||||
|
* - a `vega-themes` preset id — that preset's config verbatim, UI-theme
|
||||||
|
* independent, exactly as it would render in the Vega editor's theme dropdown.
|
||||||
|
*
|
||||||
|
* The spec's own `config` overrides whatever is selected, property by property
|
||||||
|
* (vega-lite merges `opt.config` under `spec.config`), so a snippet can always
|
||||||
|
* opt out locally.
|
||||||
|
*/
|
||||||
|
export type ChartThemeId = 'astrolabe' | 'stock' | ChartThemePresetId;
|
||||||
|
|
||||||
|
/** The vega-themes presets we surface, in display order. */
|
||||||
|
const PRESET_IDS = [
|
||||||
|
'excel',
|
||||||
|
'ggplot2',
|
||||||
|
'quartz',
|
||||||
|
'vox',
|
||||||
|
'fivethirtyeight',
|
||||||
|
'latimes',
|
||||||
|
'urbaninstitute',
|
||||||
|
'googlecharts',
|
||||||
|
'powerbi',
|
||||||
|
'carbonwhite',
|
||||||
|
'carbong10',
|
||||||
|
'carbong90',
|
||||||
|
'carbong100',
|
||||||
|
'dark',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ChartThemePresetId = (typeof PRESET_IDS)[number];
|
||||||
|
|
||||||
|
/** Empty config — the stock sentinel resolves to "inject nothing". */
|
||||||
|
const STOCK_CONFIG: Config = {};
|
||||||
|
|
||||||
|
export interface ChartThemeOption {
|
||||||
|
value: ChartThemeId;
|
||||||
|
label: string;
|
||||||
|
/** Secondary line for pickers (what the choice means). */
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display metadata for every selectable chart theme, in display order. */
|
||||||
|
export const CHART_THEME_OPTIONS: ReadonlyArray<ChartThemeOption> = [
|
||||||
|
{ value: 'astrolabe', label: 'Astrolabe', detail: 'House style, follows light/dark' },
|
||||||
|
{ value: 'stock', label: 'Stock Vega-Lite', detail: 'No theme applied' },
|
||||||
|
{ value: 'excel', label: 'Excel' },
|
||||||
|
{ value: 'ggplot2', label: 'ggplot2' },
|
||||||
|
{ value: 'quartz', label: 'Quartz' },
|
||||||
|
{ value: 'vox', label: 'Vox' },
|
||||||
|
{ value: 'fivethirtyeight', label: 'FiveThirtyEight' },
|
||||||
|
{ value: 'latimes', label: 'LA Times' },
|
||||||
|
{ value: 'urbaninstitute', label: 'Urban Institute' },
|
||||||
|
{ value: 'googlecharts', label: 'Google Charts' },
|
||||||
|
{ value: 'powerbi', label: 'Power BI' },
|
||||||
|
{ value: 'carbonwhite', label: 'Carbon — White' },
|
||||||
|
{ value: 'carbong10', label: 'Carbon — G10' },
|
||||||
|
{ value: 'carbong90', label: 'Carbon — G90' },
|
||||||
|
{ value: 'carbong100', label: 'Carbon — G100' },
|
||||||
|
{ value: 'dark', label: 'Vega Dark' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CHART_THEME_IDS: ReadonlySet<string> = new Set(['astrolabe', 'stock', ...PRESET_IDS]);
|
||||||
|
|
||||||
|
/** Type guard for persisted values (load-with-fallback; unknown ids fall back). */
|
||||||
|
export function isChartThemeId(value: unknown): value is ChartThemeId {
|
||||||
|
return typeof value === 'string' && CHART_THEME_IDS.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the user's chart-theme selection to the config to inject at embed
|
||||||
|
* time. `'astrolabe'` follows the UI theme; presets ignore it (their look is
|
||||||
|
* fixed — that's the point of previewing a destination style).
|
||||||
|
*/
|
||||||
|
export function chartConfigForSelection(selection: ChartThemeId, uiTheme: UiTheme): Config {
|
||||||
|
if (selection === 'astrolabe') return CHART_CONFIG[uiTheme];
|
||||||
|
if (selection === 'stock') return STOCK_CONFIG;
|
||||||
|
return presets[selection] as Config;
|
||||||
|
}
|
||||||
|
|||||||
+11
-3
@@ -1,7 +1,12 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { App } from './app/App';
|
import { App } from './app/App';
|
||||||
import { initPanes, wirePanes } from './app/orchestration/panes';
|
import { initPanes, wirePanes } from './app/orchestration/panes';
|
||||||
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
|
import {
|
||||||
|
initChartTheme,
|
||||||
|
initPreviewFitMode,
|
||||||
|
wireChartTheme,
|
||||||
|
wirePreviewFitMode,
|
||||||
|
} from './app/orchestration/preferences';
|
||||||
import { initSettings, wireSettings } from './app/orchestration/settings';
|
import { initSettings, wireSettings } from './app/orchestration/settings';
|
||||||
import { initSnippetSort, wireSnippetSort } from './app/orchestration/snippet-sort';
|
import { initSnippetSort, wireSnippetSort } from './app/orchestration/snippet-sort';
|
||||||
import { initPersistentStorage, registerServiceWorker } from './app/orchestration/pwa';
|
import { initPersistentStorage, registerServiceWorker } from './app/orchestration/pwa';
|
||||||
@@ -15,10 +20,13 @@ import '../styles/base.css';
|
|||||||
initTheme();
|
initTheme();
|
||||||
wireTheme();
|
wireTheme();
|
||||||
|
|
||||||
// Hydrate + persist the small UI preferences (preview fit mode, pane widths) the
|
// Hydrate + persist the small UI preferences (preview fit mode, chart theme,
|
||||||
// same way. Pane widths hydrate before render so the layout opens as left.
|
// pane widths) the same way. Pane widths hydrate before render so the layout
|
||||||
|
// opens as left.
|
||||||
initPreviewFitMode();
|
initPreviewFitMode();
|
||||||
wirePreviewFitMode();
|
wirePreviewFitMode();
|
||||||
|
initChartTheme();
|
||||||
|
wireChartTheme();
|
||||||
initPanes();
|
initPanes();
|
||||||
wirePanes();
|
wirePanes();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user