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.
|
||||
|
||||
### 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
|
||||
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.**
|
||||
|
||||
- **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.
|
||||
|
||||
```ts
|
||||
@@ -263,7 +263,11 @@ export interface UserSettings {
|
||||
tabSize: 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 };
|
||||
}
|
||||
|
||||
@@ -280,7 +284,7 @@ const DEFAULTS: UserSettings = {
|
||||
tabSize: 2,
|
||||
},
|
||||
performance: { renderDebounce: 1500 },
|
||||
ui: { theme: 'light', previewFitMode: 'default' },
|
||||
ui: { theme: 'light', previewFitMode: 'default', chartTheme: 'astrolabe' },
|
||||
formatting: { dateFormat: 'smart', customDateFormat: '' },
|
||||
};
|
||||
|
||||
@@ -324,6 +328,7 @@ export function loadSettings(): UserSettings {
|
||||
// record, so the others survive (see the per-slice rule above):
|
||||
// saveUiTheme(theme) -> { ...current, ui: { ...current.ui, theme } }
|
||||
// savePreviewFitMode(mode) -> { ...current, ui: { ...current.ui, previewFitMode } }
|
||||
// saveChartTheme(chartTheme) -> { ...current, ui: { ...current.ui, chartTheme } }
|
||||
// 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
|
||||
|
||||
A Vega-Lite **config** object styles every chart globally — fonts, axis colors,
|
||||
background, the categorical color range, default mark colors. Astrolabe ships one
|
||||
config per UI theme so charts visually belong to the app rather than looking like
|
||||
stock Vega-Lite.
|
||||
background, the categorical color range. Astrolabe ships one config per UI theme
|
||||
so charts visually belong to the app rather than looking like 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
|
||||
// src/core/vega-themes.ts (sketch)
|
||||
import type { Config } from 'vega-lite';
|
||||
- **Base** (`lightBaseConfig`/`darkBaseConfig`) — the legibility minimum:
|
||||
`background: 'transparent'` (the pane shows through) plus guide colors on the
|
||||
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 = {
|
||||
background: 'transparent',
|
||||
font: '"Inter", sans-serif',
|
||||
title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' },
|
||||
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' },
|
||||
};
|
||||
`mergeChartLayers(base, expressive)` produces `lightChartConfig`/
|
||||
`darkChartConfig`, and `chartConfigFor(uiTheme)` is the one UI-theme → config
|
||||
mapping. The split exists so a non-house style can keep the base layer while
|
||||
swapping the expressive one (future custom themes).
|
||||
|
||||
export const darkChartConfig: Config = {
|
||||
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' },
|
||||
};
|
||||
```
|
||||
### Selectable chart themes
|
||||
|
||||
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
|
||||
// src/core/vega-themes.ts
|
||||
import type { UiTheme } from './theme'; // core-local — never import from src/app
|
||||
- `'astrolabe'` resolves via `chartConfigFor(uiTheme)` (follows light/dark);
|
||||
- `'stock'` resolves to `{}` — nothing injected, pure Vega-Lite defaults;
|
||||
- preset ids resolve to the `vega-themes` package's configs verbatim (the same
|
||||
presets as the Vega editor's theme dropdown; the package is already in the
|
||||
tree as a vega-embed dependency).
|
||||
|
||||
const CHART_CONFIG: Record<UiTheme, Config> = {
|
||||
light: lightChartConfig,
|
||||
dark: darkChartConfig,
|
||||
};
|
||||
`chartConfigForSelection(selection, uiTheme)` is the only resolver. The choice
|
||||
lives in `AppStore.chartTheme`, persisted as `ui.chartTheme` by
|
||||
`orchestration/preferences.ts` (the `previewFitMode` pattern), and is surfaced
|
||||
by a `SelectControl` in the LivePreview header — **not** inside the
|
||||
PreviewSettings popover: `SelectControl` and `SettingsPopover` share the
|
||||
one-open-popover registry, so a select nested in the popover would close (and
|
||||
unmount) its own parent on open.
|
||||
|
||||
export function chartConfigFor(theme: UiTheme): Config {
|
||||
return CHART_CONFIG[theme];
|
||||
}
|
||||
```
|
||||
|
||||
The renderer reads the active UI theme (from the store) and passes the matching config into
|
||||
`renderSpec`. When the theme changes, the same subscriber that drives
|
||||
re-rendering picks up the new config and the chart restyles automatically.
|
||||
Render-time precedence: vega-lite merges the injected config **under** the
|
||||
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`
|
||||
(spec keys win — rendering unchanged), extract lifts `spec.config` out.
|
||||
|
||||
### Rules
|
||||
|
||||
- **Do** keep `chartConfigFor` as the _only_ place that maps a UI theme to a Vega
|
||||
config. Adding a UI theme = adding one config and one map entry.
|
||||
- **Do** set chart `background: 'transparent'` so the pane's own background shows
|
||||
through and theme switches look seamless.
|
||||
- **Do** keep `chartConfigForSelection` as the _only_ place that maps the user's
|
||||
selection (and UI theme) to a Vega config.
|
||||
- **Do** set chart `background: 'transparent'` in the house configs so the
|
||||
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" —
|
||||
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
|
||||
applied at embed time via the embed options, leaving the spec theme-agnostic.
|
||||
- **Don't** write the injected config into the user's stored spec implicitly;
|
||||
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)
|
||||
|
||||
@@ -237,7 +220,8 @@ Theme spans several layers; the path is:
|
||||
(localStorage `ui.theme`). On load, `initTheme()` — called from `main.tsx`
|
||||
**before** `createRoot().render` — hydrates the saved theme. Chart and editor
|
||||
follow by subscribing to `uiTheme`: `LivePreview` re-embeds with
|
||||
`chartConfigFor(theme)`, `SpecEditor` sets the Monaco theme. UI chrome repaints
|
||||
`chartConfigForSelection(chartTheme, uiTheme)`, `SpecEditor` sets the Monaco
|
||||
theme. UI chrome repaints
|
||||
purely from the `[data-theme]` token swap in `styles/tokens.css`. The header
|
||||
`ThemeToggle` is the user control.
|
||||
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
- 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_.
|
||||
|
||||
## 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 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
|
||||
|
||||
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:
|
||||
|
||||
- **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_.
|
||||
|
||||
## 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. |
|
||||
| `ui.theme` | string | App theme: `light` or `dark`. |
|
||||
| `ui.previewFitMode` | string | Preview sizing: `default`, `width`, `height`, or `full`. |
|
||||
| `ui.chartTheme` | string | Chart theme: `astrolabe`, `stock`, or a preset id. |
|
||||
| `formatting.dateFormat` | string | Date display mode: `smart`, `iso`, or `custom`. |
|
||||
| `formatting.customDateFormat` | string | Pattern used when `dateFormat = custom`. |
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -8,11 +8,14 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
|
||||
|
||||
## Open
|
||||
|
||||
_(none — the 2026-06-12 batch resolved all parked items: type-cycle chip → direct-pick
|
||||
SelectControl; field-assignment flow → explicit channel chooser + visible armed state, drag
|
||||
still deferred; "or constant" → "Use a constant" ghost button; chart-level controls →
|
||||
properties strip under the preview. Resolutions recorded in `architecture/10` §5 and
|
||||
`spec/06`.)_
|
||||
- **Chart theme picker placement & header crowding** (`LivePreview.tsx` — ChartThemeControl).
|
||||
The picker sits in the preview header because nesting a SelectControl inside the
|
||||
PreviewSettings popover is impossible today (one-open-popover registry: the select would
|
||||
close/unmount its own parent). Header now holds Fit + theme + export + gear; at narrow
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user