# 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** ✅ (2026-06-12) — IndexedDB entity
   `{ id, name, config }` (`core/custom-theme.ts`, themes store @ DB v2) + the **Theme
   Builder** modal: theme list, JSON config editor, a font control that populates one
   family across every font slot (`applyFontToConfig`), and a live multi-chart gallery
   (`core/theme-preview-specs.ts`) so one edit is previewed across titles, axes,
   legends, headers, and the major marks. Created by duplicating the currently-selected
   theme (house/preset/custom) or via the editor's **Extract Config to New Theme**
   action (spec §03G); appears in the selector as `custom:<id>` (the "Edit themes…"
   action row sits right after the customs, before the preset roster); deleting the
   active one falls back to Astrolabe. Custom themes travel in the §08 workspace
   export/import envelope (additive `themes` array, name auto-suffix on clash, ids
   reassigned by the store, rolled back with datasets on a failed import).
5. **Shipped font roster** ✅ (2026-06-14) — 11 self-hosted families via @fontsource
   (`styles/chart-fonts.css`, full subsets bundled) extending `THEME_FONT_OPTIONS` to 17
   entries; `collectFontFamilies` (core) + a `document.fonts.load` gate at the top of
   `renderSpec` (before the layout/probe pass, which measures text regardless of
   renderer); Workbox precaches the `latin` subset of the roster (~520KB) plus every
   subset of the UI Plex Sans/Mono, and runtime-caches the rest (latin-ext + non-latin)
   CacheFirst so a script works offline after first use. Roster picked from a visual
   specimen. Not done here: theme↔font pairing metadata (a suggestion nicety, deferred).
6. **User font upload** ✅ (2026-06-16) — `FontAsset` (`core/font-asset.ts`) + the `fonts`
   store @ DB v3, registered as a `FontFace` at startup so the render gate resolves user
   faces like the roster (full entity-store stack: adapter/migration, `FontStore`,
   `font-persistence`, `services/fonts`; arch 05 → User-uploaded fonts). The Type panel
   offers uploads ahead of the roster. **Variable fonts** are supported: `parseFontAxes`
   reads `fvar` (uncompressed ttf/otf) and the face registers with `wght`/`wdth` ranges, so
   one file drives the whole weight range — only weight/width survive Vega's text rendering
   (no `font-variation-settings` hook). No `CustomTheme.fonts` field: a used face is derived
   by scanning configs/specs (`collectFontFamilies`) — the config is the
   source of truth, and snippets use fonts with no theme to carry a field.
7. **Font export round-trip** ✅ (2026-06-16) — uploaded faces travel base64-encoded in the
   §08 envelope (additive `fonts` array, decoded on import, **skipped** on family clash so a
   self-backup doesn't pile up copies, rolled back with the rest on a failed import, registered
   live so they render without reload), and the per-chart **SVG export embeds** the referenced
   uploaded faces as `@font-face` data-URIs so an exported vector renders the right type off-app.
   Shared base64 + `fontDataUri`/`primaryFamilyName`/`serializeFontAsset` machinery in
   `core/font-asset.ts`; SVG embed + family-matching in `core/chart-export.ts`. Roster and
   system stacks are never embedded (decoration with their own fallbacks; their bytes aren't in
   the font library).
8. **Deferred** — Google Fonts opt-in tier; 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. Structured controls (slice 4b)

The builder today is a raw JSON textarea + one font dropdown + the live gallery. Slice 4b
adds a strip of structured controls above the editor — accelerators that write into the
JSON, never replacing it. The JSON stays the source of truth and the full-power escape
hatch; controls cover the common ~80% (color, type, spacing, grid), not all 72 config
properties (that is the trap vega-editor deliberately avoids by staying JSON).

**Hard constraint — the builder must preserve unknown keys.** Verified by compiling: the
vega-themes presets carry Vega-_layer_ keys (`symbol`, `shape`, `path`, `group`) that are
not in the Vega-Lite `Config` schema, and Vega-Lite forwards the whole config to Vega
unchanged — they take effect. So a structured control must **merge into** the existing
config (immutable path-set that spreads siblings), never rebuild it from a closed
schema-typed model, or it silently drops those keys on a round-trip. Same shape as
`applyFontToConfig`, which walks and rewrites rather than reconstructing.

**Resolved design points:**

- **Surfacing — vertical tab list + per-panel accordion** (not popovers, not sub-modals),
  all in the one xlarge modal. One panel per config domain, switched by a vertical tab rail
  (L1); within a panel, related properties group into a single-expand accordion (L2). No
  nested overlays/focus traps, no contention with the one-open-popover registry; panel + JSON
  - gallery stay visible together. The two-level grouping rule is recorded in arch 10
    (_Organizing a large control surface_).
- **Color model — scheme picker that materializes to swatches.** A `range` family takes
  either an explicit color array or a named scheme written as Vega's range-scheme object
  `{ scheme: name }`. (A bare scheme-name _string_ passes vega-lite compile but Vega
  rejects it at render — "Unrecognized scale range value" — so the controls write the
  object form, read either, and `normalizeRangeSchemes` heals the bare form at render.)
  Pick a named scheme for the quick path; "materialize" expands it to an editable swatch
  array for brand tuning. Catalog ships 15 categorical + 24 sequential + 10 diverging
  schemes; categorical schemes resolve to arrays, continuous ones to interpolators sampled
  into stops for the gradient preview and the materialize action.
- Structured controls are gated on valid JSON (same as the font control): a parse error
  disables them and the textarea is the fix.
- Controls write **minimal** config — clearing a value deletes the key rather than writing
  a default, so a theme stays a diff against stock, not a full dump.

**Panels** (each an accordion of the groups below): Color (`range.category`, `mark.color`,
sequential `range.heatmap`/`ramp`, `range.diverging`) · Marks (per type — bars, lines &
areas, points, arc — plus generic opacity/fill/tooltips) · Type (base `font`, axis
title/label size+weight) · Title (anchor, colour, size/weight/style, offset, subtitle block)
· Layout (`background`, `view` fill/border/radius, `padding`, default size) · Axes & grid
(grid, ticks, domain, labels — base `axis` only; the per-channel variants stay JSON) · Legend
(placement/direction, title, labels, symbols, gradient, box) · Headers (facet title/label
colour/size/weight) · Formats (number/date/normalized formats, count title).

**Build order:** (a) core foundation — scheme catalog + immutable config path get/set +
`schemeColors` materialize, with tests; (b) Color panel (highest payoff); (c) the remaining
per-domain panels; (d) wire the navigation (vertical tabs + per-panel accordion) into the
modal.

## 6. Status log

- **2026-06-21** — **structured-control surface expanded + accordion everywhere.** New panels
  — Marks (per mark type), Title & subtitle, Headers (facets), Formats — and deepened
  Axes/Legend/Layout, covering the brand-tuning bulk of the config; the raw JSON stays the
  escape hatch for the long tail. Every panel is a single-expand accordion with a per-section
  set-count badge, switched by a vertical tab list, and the modal holds one
  `id → label → Panel` registry. Shared primitives extracted: `WeightRow` (UI),
  `enumValue`/`countSet` (core). The gallery is now fully reflective — sample specs use bare
  marks so no control is shadowed (guarded by a test), and a normalized area + a temporal
  facet were added so `normalizedNumberFormat` and `timeFormat` have mirrors; default chart
  size and tooltips are the only non-previewable controls. Recorded in arch 10 (_Organizing a
  large control surface_), arch 05 (_Structured controls_), and spec §04. Remaining in §4:
  Google Fonts opt-in tier; built-in preset gallery; Color-panel swatch reorder.
- **2026-06-16 (slice 7)** — **font export round-trip + SVG embed.** Uploaded faces now
  survive a workspace transfer and travel inside an exported SVG. Core: `serializeFontAsset`/
  `deserializeFontAsset` (+ base64 helpers), `primaryFamilyName`, and `fontDataUri` in
  `font-asset.ts`; the §08 envelope grew an additive `fonts` array (`export-envelope.ts`),
  `normalizeImport` decodes it, and a new `dropClashingFonts` enforces the skip-on-clash merge
  rule (`import-normalize.ts`). Service: `transfer.ts` exports all library fonts, and import
  dedupes by family, commits fonts in the pre-snippet phase (rolled back with datasets/themes on
  a failed snippet write), and registers the new faces on success. SVG: `referencedUploadedFonts`
  - `embedFontsInSvg` in `chart-export.ts`, wired through the renderer's `toImageURL('svg')` with
    the referenced faces resolved in LivePreview's `getImageUrl` (config captured per render).
    Decision (font clash): **skip, not rename** — a font is identified by its family (the key in
    config slots), so an existing same-named face satisfies the reference, and a self-backup
    doesn't accrete "Font 2" copies; recorded in spec §08 → Name conflicts. Verified: typecheck,
    lint, full tests. Remaining in §4: Google Fonts opt-in tier; preset gallery.
- **2026-06-16 (slice 6)** — **user font upload + variable-font weight support.** New
  `FontAsset` entity (instance #4 of the entity-store kind, confirmed by an eng-council
  pre-build consult): `fonts` store @ DB v3, adapter/migration, `FontStore`,
  `font-persistence`, `services/fonts`, and the `font-faces` `FontFace`-registration seam
  wired into `startup`. Type panel gains an upload control + a managed list; user fonts lead
  the dropdown. Variable fonts parse `fvar` and register with `wght`/`wdth` ranges (weight
  is the only axis Vega's text rendering can drive). Font dependencies are derived from the
  config at export, not stored on the theme. Deferred: font bytes in the §08/SVG exports.
- **2026-06-14 (Color panel bugfix)** — **scheme picks rendered blank.** A named scheme
  was written into `config.range.*` as a bare string, which vega-lite compiles but Vega
  rejects at render ("Unrecognized scale range value") — silently caught by the gallery's
  per-card try/catch, so the categorical/sequential/diverging charts blanked the moment a
  scheme was picked. Predates this session's panels/fonts (shipped with the Color panel).
  Fix: the controls write Vega's range-scheme object `{ scheme: name }` and read either
  form; `normalizeRangeSchemes` (core) heals a bare-form config at the render-resolution
  points (`chartConfigForSelection` for the live preview/export, and the builder gallery),
  so themes saved/imported with the old form self-heal. The gallery's catch now surfaces the
  error message in the card (fail-loud, arch 02) so a render failure on valid JSON isn't
  invisible again. Regression cover: a real
  vega-lite→vega compile/parse/run asserting `{ scheme }` renders and the bare string
  throws, plus `normalizeRangeSchemes` unit tests. Verified: typecheck, lint, tests (983).

- **2026-06-14 (slice 5)** — **shipped font roster.** 11 self-hosted families
  (`styles/chart-fonts.css`, imported in main.tsx, separate from the UI Plex in base.css):
  Inter · Libre Franklin · Roboto Condensed · IBM Plex Sans Condensed · IBM Plex Serif ·
  Source Serif 4 · Spectral · Space Grotesk · Playfair Display · Caveat · Space Mono, at
  400 + 600 (Space Mono 400 + 700). `THEME_FONT_OPTIONS` grew to 17 (roster grouped by
  role, then the system stacks); each roster stack carries a category fallback. The render
  path now gates on fonts: `collectFontFamilies` (core, the read-counterpart of
  `applyFontToConfig`; skips `data`/`datasets`) gathers the families a spec+config use and
  `renderSpec` awaits `document.fonts.load` for them before the first layout pass — Vega
  measures text via canvas `measureText` regardless of renderer, so a face loading after
  embed would lay out with fallback metrics. Best-effort + 3s-capped so a slow first fetch
  never freezes the preview. Precache strategy (vite.config Workbox): the `latin` subset
  of every family (~520KB for the roster) + all Plex Sans/Mono subsets (UI capability) are
  precached; latin-ext and non-latin scripts are runtime-cached CacheFirst (`*-latin-[0-9]*`
  excludes latin-ext; the Plex Sans brace-list avoids matching the condensed roster font).
  Verified: typecheck, lint, full tests (976), production build + precache-manifest
  inspection. Note: @fontsource ships legacy `.woff` beside `.woff2`; modern browsers use
  woff2, so the `.woff` sit unused in dist (pre-existing for Plex — neither precached nor
  runtime-cached).

- **2026-06-14 (slice 4b complete)** — **Layout / Axes & grid / Legend panels + Type
  size/weight.** The remaining structured-control panels, built on a small shared
  primitives module `ThemeFields.tsx` (`ControlSection`, `ColorRow`, `NumberRow`,
  `SelectRow`) so the panels read declaratively and match the Color panel's look. Each
  control writes one config path through the same inline `mutateDraftConfig` +
  `setConfigValue` the Color panel uses, with the minimal-diff delete (clearing a value
  removes the key, pruning emptied objects). Leaf coercion (`asString`/`asNumber`/
  `asBoolean`) moved into core `theme-controls.ts` beside the path get/set, tested there.
  Panels: **Layout** (background and `view` fill/border as tri-state default·transparent/
  none·custom, corner radius, scalar padding with a JSON hint when it's a per-side object);
  **Axes & grid** (grid visibility/color/dash-preset, domain/label/title color, label
  angle — base `axis` only); **Legend** (orient, title/label color+size, symbol size);
  **Type** rounded out with title and axis title/label size+weight (font family relocated
  into the extracted `TypeControls`). Resolved while building: each generic row label
  ("Size", "Color", "Weight") repeats across sections, so `ControlSection` is a
  `role="group"` labelled by its heading and rows take an accessible-name override — the
  visible label stays short, the control's announced name is qualified ("Title size"). The
  font-roster decision (slice 5) was teed up with a throwaway visual specimen. Verified:
  typecheck, lint, full tests (969). Remaining in 4b: swatch reorder (Color panel).

- **2026-06-14 (slice 4b, first increment)** — **structured-control foundation + Color
  panel.** Core `theme-controls.ts`: immutable config path get/set (preserves siblings —
  the Vega-layer-key guarantee — and prunes on delete) + the named-scheme catalog (15
  categorical / 24 sequential / 10 diverging) + `schemeColors` resolution (categorical
  arrays passthrough, continuous interpolators sampled to hex), all tested. `vega-scale`
  added as a declared dep (focused sub-package, like `vega-expression`) with a typings
  shim in `vite-env.d.ts` (its package.json `exports` omits `types`). Store gains the
  generic `mutateDraftConfig(fn)` write path; `applyDraftFont` refactored onto it. Modal
  gains an APG tab strip — **Color** (categorical scheme/swatches + materialize, default
  `mark.color`, sequential/diverging gradient pickers) and **Type** (the relocated font
  control). Tabpanel gated on valid JSON. From first-use feedback, same day: the modal
  body is now **controls + JSON on the left, gallery as a full-height right rail** (the
  previews were starved before); `SelectControl` gained an optional per-option `preview`
  so the scheme dropdowns show swatch strips (categorical) / gradient bars (continuous);
  every swatch is a reusable `SwatchRow` (color picker + copyable/editable hex field); and
  sequential/diverging gained **Materialize → editable stops**, so custom gradient colors
  are possible, not just named schemes. Second feedback pass: the raw JSON is now a
  **collapsed disclosure** at the bottom of the controls column (it was eating half the
  first screen), forced open only on a parse error; the structured controls fill the
  column. `SelectControl` options gained a `labelStyle`, so the **font dropdown renders
  each name in its own family** (the type analogue of the color swatches) and its trigger
  shows the current font in-face. Verified: typecheck, lint, full tests (950).
  Remaining: Layout / Axes & grid / Legend panels; swatch reorder.

- **2026-06-12 (slice 4 close-out)** — **custom themes in the §08 envelope.** The
  workspace export now writes a `themes` array (additive — no format bump; importers
  treat it as optional, so pre-theme envelopes stay valid). Import normalizes each
  record (`normalizeCustomTheme`), auto-suffixes name clashes via the generalized
  `dedupeIncomingNames` (the dataset dedupe, now shared), reassigns ids through
  `CustomThemeStore.addThemes` (selection untouched), and rolls themes back together
  with datasets when the atomic snippet write fails. Toast counts gain a theme
  clause. Spec §08 updated ("Dataset conflicts" → "Name conflicts"). Slice 4 is now
  fully done; next is slice 5 (shipped font roster).

- **2026-06-12 (slice 4)** — **custom named themes + Theme Builder shipped.**
  `CustomTheme` entity through the full stack (core → theme-store @ DB v2 →
  CustomThemeStore → theme-persistence → startup hydrate); selection model extended to
  `custom:<id>` with missing-record fallback to the house style; Theme Builder modal
  (xlarge, list + name + config JSON + font-apply control + 7-card live gallery, canvas
  renderer, per-card chain-lock); picker gains custom entries + an "Edit themes…"
  action row. The font control ships with render-safe faces only (Plex + web-safe
  stacks) — the roster slice (5) extends `THEME_FONT_OPTIONS` and adds the
  `document.fonts.load` gate. Spec updated (§01C, §04 Chart theme + Theme Builder,
  §09C/E/G) + architecture 05 §3. Same-day follow-ups from first use: `openDB` now
  verifies the store layout and self-heals an interrupted upgrade (arch 02 §2.1,
  `db.test.ts` on fake-indexeddb); "Edit themes…" moved before the preset roster
  (discoverability); **Extract Config to New Theme** added as the third Config-menu
  action (spec §03G) — config block → saved theme, selected, removed from the spec.
  Not yet done: themes in the §08 export/import envelope.

- **2026-06-12 (slices 2–3)** — **theme selector + merge/extract shipped.**
  `ChartThemeId`/`chartConfigForSelection` in core; `ui.chartTheme` persisted via the
  `previewFitMode` orchestration pattern; `SelectControl` picker in the preview header;
  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.
