Skip to content

feat: Theme Editor, viewer light/dark mode, and chart theming#786

Open
mlennie wants to merge 31 commits into
mainfrom
monty/theming-spike
Open

feat: Theme Editor, viewer light/dark mode, and chart theming#786
mlennie wants to merge 31 commits into
mainfrom
monty/theming-spike

Conversation

@mlennie
Copy link
Copy Markdown
Collaborator

@mlennie mlennie commented May 25, 2026

Summary

Adds an end-to-end theming system for Publisher with three independent surfaces.

Walkthrough video: https://www.loom.com/share/e31e852ca1664526a1e7a355a07bddd6

Screenshot 2026-05-22 at 10 30 46 AM Screenshot 2026-05-22 at 10 31 00 AM Screenshot 2026-05-22 at 10 31 17 AM Screenshot 2026-05-22 at 10 31 37 AM
  1. Server-side Theme schema in publisher.config.json (instance and per-environment), backed by a new SQLite ThemeStore and exposed via GET / PUT / DELETE /api/v0/theme.
  2. In-app Theme Editor at /settings/theme with section-by-section color pickers (background, tables, dashboard tiles, charts, maps, fonts), a Light/Dark variant toggle, and a live preview that renders the
    resolved cascade.
  3. Viewer-facing light/dark/auto toggle in the app header. Honors defaultMode and allowUserToggle from the config, persists the viewer's choice to localStorage, and follows prefers-color-scheme in
    auto.

Cascade: SDK defaults → instance config → environment config → editor (per environment) → per-chart # theme.* annotation. Each layer only overrides the keys it sets.

Per-mode palette keys: background, tableHeader, tableHeaderBackground, tableBody, tile, tileTitle, mapColor. series and font are scalars shared across modes (brand identity).

Paired renderer change

Depends on theme being a real prop on MalloyRenderer. That change lives in malloydata/malloy on monty/explicit-theme-prop. Without it, chart-canvas and
chart-internal colors won't follow the resolved theme. The SDK currently consumes the renderer via bun link against a local malloy checkout; switch to the npm-published version once the renderer PR merges.

Key paths

SDK

  • packages/sdk/src/theme/*defaults, keys, types, resolveTheme, buildTableCssVars, buildVegaThemeOverride, buildMalloyExplicitTheme, readChartAnnotations, ThemeContext
  • packages/sdk/src/components/ServerProvider.tsx — owns mode state, matchMedia subscription, localStorage persistence
  • packages/sdk/src/components/RenderedResult/RenderedResult.tsx — passes the resolved theme to MalloyRenderer and emits scoped CSS vars

Server

  • api-doc.yamlTheme schema + /theme endpoints
  • packages/server/src/service/theme_store.ts — SQLite-backed runtime store
  • packages/server/src/controller/theme.controller.ts — GET / PUT / DELETE
  • packages/server/src/config.ts — instance + per-environment theme parsing with sanitizer
  • packages/server/src/storage/duckdb/schema.tsthemes table migration

App

  • packages/app/src/components/pages/ThemeEditorPage/* — editor page, sections, previews
  • packages/app/src/components/common/ThemeToggle.tsx — header viewer toggle
  • packages/app/src/theme/PublisherMuiThemeProvider.tsx, packages/app/src/theme/index.tsx — MUI palette keyed off SDK mode

README

  • New ### Theming subsection under Configuration covering all seven per-mode palette keys plus the scalar series/font and defaultMode / allowUserToggle semantics. Public-docs walkthrough lives in a
    paired PR on malloydata.github.io.

Test plan

  • bun run typecheck && bun run lint && bun run prettier:check && bun run test from repo root
  • cd packages/app && bun run test:playwright (new theming.spec.ts plus the existing five chromium specs stay green)
  • Editor browser sweep
    • Open /settings/theme, change each picker, confirm the live preview updates and edits auto-save
    • Toggle Light/Dark inside the editor, edit a value, confirm only that variant updates
    • Reset to defaults, confirm pickers return to brand defaults and the saved row is cleared
  • Render surface
    • Open a sample package (e.g. ecommerce), confirm chart, table, and dashboard tile colors follow the resolved theme
    • Open a # shape_map chart (e.g. faa.sales_by_state), confirm palette.mapColor drives the choropleth gradient
  • Viewer mode toggle
    • Click sun/moon/auto in the header, confirm the app shell and rendered charts both flip
    • Reload, confirm the choice persists via localStorage["publisher:themeMode"]
    • Set theme.allowUserToggle: false in config, restart, confirm the toggle is hidden and defaultMode is enforced
  • frozenConfig: true: confirm PUT /api/v0/theme returns an error and the editor surfaces as read-only
  • Per-chart annotation override: add # theme.palette.series = [...] to a view, confirm only that view picks up the override

mlennie added 27 commits May 25, 2026 13:57
Operators can pick brand colors at /settings/theme without editing
publisher.config.json by hand. Saves go to a singleton row in a new
DuckDB themes table and surface on /status so every viewer of the
instance picks them up on next page load. publisher.config.json's
top-level theme block is kept as a boot seed only; the editor wins
once it writes. frozenConfig:true deployments render the page
read-only and reject writes with 403.

Wiring:
- ServerProvider now fetches /status via react-query and wraps the
  app in the SDK's ThemeProvider, so usePublisherTheme() returns the
  live instanceTheme. RenderedResult applies the theme via vegaConfigOverride
  (chart series) and CSS variables (tables, dashboard background).
- buildTableCssVars sets both --malloy-render--* and --malloy-theme--*
  on the wrapper. The renderer re-emits --malloy-render--* on a deeper
  element using var(--malloy-theme--*) fallbacks, so the shadow
  namespace is required for table chrome to pick up the override.
- initializeSchema always runs its CREATE TABLE IF NOT EXISTS pass so
  existing DuckDB files pick up the new themes table without --init.
- ThemeEditorPage debounces auto-save and gates the resync effect on a
  baseline ref so in-flight typing isn't reverted when the prior save
  returns.

Out of scope for v1 (documented inline): per-environment overrides,
per-chart Malloy annotations, light/dark toggle UI, and the renderer's
upcoming theme prop. Body text / borders / dashboard tile colors still
come from the renderer's hardcoded defaults until that prop lands;
the editor's Tables card calls this out.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
…heme prop

Schema bumps:
- palette.series, palette.tableBody, palette.tile, palette.tileTitle each
  become {light, dark}; palette.background and palette.tableHeader were
  already per-mode.
- font.family and font.size become {light, dark}.

Editor:
- Light/Dark toggle picks which variant the section pickers write.
- New TablesSection knobs for tableBody / tile / tileTitle.

SDK:
- New buildMalloyExplicitTheme maps ResolvedTheme onto the upstream
  renderer's explicit `theme` prop so table chrome and dashboard tiles
  pick up operator colors directly, bypassing the renderer's shadow var
  cascade.
- buildTableCssVars emits the --malloy-theme--* shadow namespace in
  addition to --malloy-render--* for keys the renderer re-emits.

This commit is a checkpoint before the follow-up simplification that
will collapse per-mode for series + font (kept shared) and drop the
shadow CSS namespace now that the renderer's theme prop handles tables.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
…espace

Simplifies the render UI theme system based on three observations
surfaced during the in-app editor work:

1. Series palette and font are brand identity, not mode identity. They
   stay consistent when a viewer toggles light/dark. Reverting them to
   scalars (palette.series: string[], font.family: string,
   font.size: number) deletes ~80 lines of per-mode plumbing and
   removes the editor footgun where Light's 8 picks didn't follow into
   Dark. Per-mode stays on the five colour keys that genuinely change
   with mode (background, tableHeader, tableBody, tile, tileTitle).

2. The renderer's explicit `theme` prop handles all table tokens, so
   the --malloy-theme--* shadow namespace we emitted alongside
   --malloy-render--* was redundant. buildTableCssVars now writes a
   single namespace covering only the keys not in MalloyExplicitTheme
   (label-color, value-color) plus a few that downstream override CSS
   rules consume directly.

3. Border, pinned border, value colour, foreground text, and axisFaint
   were each recomputed from `theme.mode` inside three different
   builders with duplicated hex literals. Hoisted onto ResolvedTheme so
   resolveTheme computes them once; buildTableCssVars,
   buildMalloyExplicitTheme, and buildVegaThemeOverride read them
   instead of branching.

Other clean-ups:
- Central PER_MODE_COLOR_KEYS list in packages/sdk/src/theme/keys.ts;
  config.ts mirrors it (deps point server → SDK via the generated API
  types, not the other way). Adding a new per-mode colour is a one-line
  change in each list.
- mergeThemes now walks PER_MODE_COLOR_KEYS. Previously it only merged
  background and tableHeader, so an environment override of tableBody /
  tile / tileTitle silently dropped the instance default for that key.
  Added a regression test.

Manual verification still pending; existing saved themes that use the
old per-mode series/font shape will be coerced through sanitizeTheme
(per-mode object dropped, scalar accepted, otherwise the default
applies). Operators can hit Reset to Defaults if they want a clean
slate.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
… editor

Operators upgrading past the per-mode series + font revert hit
`TypeError: a.map is not a function` opening /settings/theme. The DB
row still held `palette.series = {light: [...], dark: [...]}` from the
previous schema, the controller returned it verbatim, and
SeriesColorsSection blew up on `.map`.

Two-layer fix:

1. ThemeStore.loadLocked now pipes the parsed JSON through
   `sanitizeTheme` before caching. Fields that don't match the
   current shape are dropped silently; fields that do (the 5 per-mode
   colour keys) carry over. This also collapses the
   `initialize` / `initializeLocked` duplication I'd flagged in the
   prior review.

2. SeriesColorsSection guards with `Array.isArray(theme.palette?.series)`
   and TypographySection guards with `typeof === "string"` / "number".
   Belt + suspenders: if a future schema bump produces an unexpected
   shape that the sanitiser misses, the picker still mounts.

No new restart-and-Reset dance for operators with old saved themes;
the migration runs on first read.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
Two follow-ups from the PR review:

1. Add `palette.background` to the editor. Previously the operator
   could only color the dashboard tile wrapper; the chart canvas was
   always white because Vega's background config came from
   palette.background and that key wasn't surfaced anywhere in the
   Theme Editor UI. The new picker lives in the renamed "Charts"
   section (formerly "Chart series colors") and is per-mode like the
   rest of the colour knobs. Wiring through to the renderer is
   unchanged — buildVegaThemeOverride already reads theme.background.

2. ThemeStore.loadLocked now logs which fields the sanitiser dropped
   when reading the stored payload. Operators upgrading past a schema
   change can see in the server log exactly which of their old
   customisations no longer apply (e.g. `palette.series` after the
   per-mode revert). Added a regression test that injects an old-shape
   payload directly into the DB and asserts only current-shape fields
   survive on read.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
Ports the Publisher app shell mode toggle from monty/color-choosing
(commit fcae0a6). The renderer-side parts of that WIP were skipped —
the in-app Theme Editor on this branch already handles chart theming
correctly via the upstream renderer's explicit `theme` prop, so the
toggle's only new responsibility is the MUI palette switch.

Mode state lives in one place:

  ServerProvider (SDK)
   - reads userChoice from localStorage["publisher:themeMode"]
   - subscribes to matchMedia("(prefers-color-scheme: dark)")
   - resolveMode(instanceTheme.defaultMode, userChoice, prefersDark)
   - passes the result into the SDK ThemeProvider that wraps children

Both the new app-level PublisherMuiThemeProvider AND the SDK's renderer
context read mode from that single ThemeProvider via usePublisherTheme().
The MUI palette switches via createPublisherTheme(mode); the renderer's
explicit theme prop already responds (wired earlier on this branch).

Operator-side controls honoured:
- allowUserToggle=false hides the toggle and ignores any stale
  localStorage entry; viewers are locked to defaultMode.
- defaultMode in publisher.config.json picks the boot mode.

Cosmetic SDK component changes (AnalyzePackageButton, Environment cards,
Home env cards, Package row hover, NotebookCell markdown, Workbook /
MutableCell backgrounds, styles.ts styled components) cherry-picked from
the same WIP commit so the app shell renders correctly in dark mode —
all replace hardcoded color literals with theme.palette tokens.

Playwright spec adapted: ported the toggle-cycle and persistence tests;
dropped the renderer CSS-var test that referenced --malloy-render--table-
background, a var we deliberately stopped emitting in the renderer-UI
refactor.

Manual sweep recommended after restart: home → environment → package →
chart, verify everything flips together; check allowUserToggle: false
hides the toggle.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
Two small dark-mode polish fixes from a manual sweep of the ecommerce
package:

1. The dashboard panel (the area between dashboard tiles) was painting
   white in dark mode because we deliberately omitted `background`
   from the renderer's `theme` prop. The original concern was that an
   operator picking a bold accent for `palette.background` would have
   it bleed across the surrounding panel. Resolved by adding a new
   mode-keyed `dashboardRoot` field to `ResolvedTheme` (NOT
   operator-customisable) and passing it as the renderer's
   `background`. Light keeps white (no regression); dark paints slate
   so the panel sits with the page chrome instead of as a bright box.
   Added spec coverage that confirms an operator's
   `palette.background` override does not bleed into `dashboardRoot`.

2. The Search and Code icons inside the white-circle action buttons on
   each NotebookCell were nearly invisible in dark mode. The button
   background was hardcoded `rgba(255, 255, 255, 0.9)` but the icon
   colour was `text.secondary`, which resolves to a light slate in
   dark mode — light glyph on light background. Switched the three
   icon usages to `grey.700` so they stay dark in both modes
   (matching the button background that's hardcoded light). Comment
   captures the invariant.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
The previous fix (a3b8eb9ad070bc) set `MalloyExplicitTheme.background`
to the new mode-keyed `dashboardRoot`, which the renderer writes through
to `--malloy-render--background`. That paints the outer `.malloy-render`
container.

But the inner `.malloy-dashboard` element has dashboard.css's own
hardcoded `background: #f7f9fc`, so the dashboard panel kept reading
white in dark mode. Same for the sticky `.dashboard-row-header` that
the dashboard.css also hardcodes.

Added two rules to injectRendererOverrides that paint both surfaces
from `--malloy-render--background` so they follow the theme's
dashboardRoot. Operator's `palette.background` (the chart canvas via
Vega) still flows separately and doesn't touch the panel.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
Cells with a dashboard result had their top-right action buttons (the
"<>" and search icons) sitting at `top: 8px` of CleanMetricCard,
which placed their bottom ~4px inside the dashboard panel area. In
light mode the panel was white and the overlap was invisible; once
the panel started painting slate in dark mode (ca09b4a), the visual
clip became obvious.

Moved the Stack to `top: -12px` so the buttons sit fully above the
panel's top edge in both modes. Comment captures why.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
The Settings entry controls colours and fonts for the Malloy renderer's
output (charts, tables, dashboards). With the new app-shell light/dark
toggle landed, "Theme" alone was ambiguous: viewers couldn't tell
whether it controlled the chart palette or the MUI palette. Renamed to
"Visualization theme" so the boundary is obvious from the sidebar.

URL stays at /settings/theme so bookmarks keep working. JSDoc on the
page component now distinguishes the two theming surfaces explicitly.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
Tables on the package page rendered with the renderer's hardcoded
white interior in dark mode. Header (#cbd5e1) and body (#e2e8f0) text
flipped to light slate per the operator's tableHeader / tableBody
choices, but the table BG didn't follow — light text on a white panel
read as nearly invisible.

Same pattern as the earlier `dashboardRoot` fix: added a mode-keyed
`tableBackground` derived field on ResolvedTheme (NOT
operator-customizable), and passed it through MalloyExplicitTheme.
Light keeps white (no regression). Dark uses slate so table interior
matches the surrounding chrome.

`palette.background` (the chart canvas, which the operator CAN
customize) stays decoupled — a bold accent there still doesn't bleed
into table interiors. Regression spec confirms.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
The syntax-highlighted Malloy / TypeScript code blocks (package readme,
notebook code cells, workbook source, model annotations) rendered with
github-light shiki regardless of the viewer's mode toggle. In dark mode
the white code island stuck out against the dark page chrome.

Loaded github-dark alongside github-light in the shared highlighter
factory (both lazy-loaded at first call, no eager cost). Added a
`mode: "light" | "dark"` parameter to highlight() that selects which
github theme shiki applies. All four call sites
(NotebookCell.malloy, NotebookCell.embed, MutableCell.malloy,
ModelCell.annotations) now pull mode from usePublisherTheme() and
re-highlight when the viewer toggles.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
The chip hover used `grey.100` which in our greyScale is the off-white
#F4F3F1. In dark mode that's effectively the same colour as the chip
text, so hovering made the label vanish. Switched to a mode-aware
hover background — translucent white in dark, the off-white in light.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
…override

Two changes from a manual sweep of the ecommerce package in light
mode with a custom palette.background (green):

1. Tables were painting white inside green-canvas dashboards because
   tableBackground was hardcoded mode-keyed and decoupled from
   palette.background. The operator's mental model is "Chart
   background covers the chart canvas AND the tables inside tiles",
   not "Chart background is one knob and table interior is another."
   Coupled tableBackground to palette.background so charts and tables
   share a single viz surface colour. dashboardRoot stays decoupled
   (mode-keyed neutral) since it's the chrome between tiles, not a
   viz surface.

2. The renderer's dashboard.css hardcodes `.malloy-dashboard {
   background: #f7f9fc }` AND a matching rule on
   `.dashboard-row-header`. Our existing override used
   `var(--malloy-render--background)` but with single-selector
   specificity. Duplicated the override at higher specificity (three
   variants: bare, self-doubled, and tag-qualified) and added an
   explicit `background-color` alongside `background` so the
   shorthand can't be lost to source order. The comment captures
   that this only beats the renderer's own scoped rules; if the
   panel still shows the accent colour after restart, that's a
   browser cache (hard refresh).

Signed-off-by: Monty Lennie <montylennie@gmail.com>
Operator's mental model is "the padding around the table" vs "the band
at the top of the table". The renderer historically reuses one value
(tablePinnedBackground) for both surfaces, so picking the tile colour
also tinted the preview's header row — and the theme editor preview
showed a band that the actual package page didn't render the same way.

Schema: new `palette.tableHeaderBackground: {light, dark}`. Added to
PER_MODE_COLOR_KEYS (SDK + server mirror), DEFAULT_THEME (#f5fafc
light, #1e293b dark), ResolvedTheme, resolveTheme.

Renderer wiring:
- buildMalloyExplicitTheme.tablePinnedBackground now carries
  theme.tableHeaderBackground (not theme.tile).
- New `--malloy-render--tile-background` var in buildTableCssVars
  carries theme.tile.
- injectRendererOverrides: .dashboard-item now reads tile-background;
  added .malloy-table .th.column-cell rule so non-pinned (i.e. most)
  tables actually show a header band reflecting the operator's choice.

Editor:
- TablesSection: new "Header background" picker. Renamed "Header
  color" to "Header text color" for clarity. Tile picker re-labelled
  "Tile background (around the table)".
- TablePreview takes the new prop and now wraps the inner table in a
  tile-coloured padded box so preview = actual page layout.

OpenAPI types regenerated.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
The MUI outlined-TextField floating label sits inside the input's
border and is clipped to the input width. With the field width fixed
at 110px (so the hex code aligns predictably), labels like "Header
background" and "Tile background (around the table)" rendered as
"Header back..." / "Tile backgro...".

Lifted the label out of the TextField and into its own Typography
above the swatch+input row. The label can now be any width; the
input keeps the 110px hex layout. Accessibility-wise the label still
ties to the input via aria-label on the input element.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
The Charts section's sample bar chart was the only viz visible while
the operator picked colours, but line charts are at least as common
in real dashboards (the by_month chart on the ecommerce page is one).
Added a small static SVG LineChartPreview that sits next to the bar
preview and paints from the same ResolvedTheme: background for the
canvas, series[0] for the line stroke, axisFaint for a baseline. The
two previews wrap so the layout stays readable on narrow widths.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
The single-column picker stacks left ~70% of the editor card empty
on a typical desktop. Switched the Series colours block and the
Tables block to a CSS grid (auto-fill / minmax) so pickers pack
multiple per row on wide viewports and gracefully fall back to a
single column on narrow ones. Minimum cell widths picked to fit the
longest labels without truncation (240px for series, 260px for the
table block which carries "Tile background (around the table)").

Signed-off-by: Monty Lennie <montylennie@gmail.com>
The "Discard changes" and "Reset to defaults" buttons are one-click
destructive: discard throws away unsaved edits, reset wipes the
on-disk theme for every viewer. Either one fired by mistake costs
real time on a brand. Wrapped both in MUI Confirmation Dialogs.

Discard dialog explains that unsaved edits go away but the saved
theme is preserved. Reset dialog spells out that reset clears every
customized colour/font and applies to every viewer immediately. Both
have a Cancel button that defaults to the safe path; the destructive
button is autoFocus'd in error colour so an Enter-press after reading
still requires explicit confirmation but isn't a hidden trap.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
Default MUI Button text color tracks primary.main, which in our
dark palette is #334155 (slate-700). On the confirm dialog's slate
background that produced near-invisible "Cancel" text. Pointed both
Cancel buttons at text.primary so they paint against the dialog
in whatever the active mode resolves to (#30302E light, #f1f5f9
dark).

Signed-off-by: Monty Lennie <montylennie@gmail.com>
…ads; drop Discard button

Addresses the three highest-severity findings from the PR review.

1. Auto-save / Reset race. The debounce timer could fire and put the
   PUT in flight before the operator clicked Reset; the in-flight
   save's onSuccess then stomped resetMutation's setQueryData. Added
   a `saveGenRef` counter that's bumped on Reset (onMutate) and
   captured by each auto-save closure — a save whose generation went
   stale silently drops its onSuccess instead of writing through.
   Reset's onMutate also clears any pending debounce. Auto-save and
   first-sync guards (`hasSyncedOnceRef`) keep the effect from firing
   against the pre-query empty state.

2. Defaults snapshotting in series. SeriesColorsSection materialised
   the SDK default palette on first edit ("Add color" implicitly
   froze 8 defaults + the new color into palette.series). setSeries
   now compares against DEFAULT_THEME.palette.series and, if the
   operator's array equals SDK defaults exactly, drops the explicit
   field so the cascade keeps resolving dynamically. Future Publisher
   releases that change brand defaults reach this user as long as
   they don't manually customise.

3. Legacy non-object spreads. TablesSection.setColor,
   SeriesColorsSection.setBackground, and TypographySection's font
   setters all spread `theme.palette?.[key]` (or `theme.font`)
   without guarding against legacy non-object shapes. A pre-per-mode
   string in those slots would expand into character-indexed garbage
   on edit. All three now check `typeof === "object" && !Array`
   before spreading and fall back to `{}` otherwise.

Also removed the Discard changes button + confirm dialog. The
auto-save model means the button was permanently disabled (draft
converges to saved within 600ms), making it visual noise. Reset to
defaults handles the "start over" case.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
… var

The Business Overview dashboard kept painting the panel between tiles
with the operator's palette.background (chart canvas colour) instead
of the mode-keyed dashboardRoot neutral. Our override on .malloy-dashboard
read var(--malloy-render--background), which we expected to resolve to
the theme prop's `background` field (= theme.dashboardRoot, hardcoded
white in light). On dashboards that carry a `# theme.background`
annotation, that var gets shadowed deeper in the renderer DOM by an
inline-style write sourced from the annotation, defeating our intent.

Switched the override to a dedicated --publisher-dashboard-root var
that's only set on the outer Publisher wrapper. The renderer doesn't
know this var exists so it can't be shadowed by any annotation-driven
write. Also expanded the override to cover .dashboard-row and
.dashboard-row-body alongside .malloy-dashboard and .dashboard-row-header
so no internal surface can leak the annotation colour.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
…hange)

Adds the operator-facing knob that drives the new
MalloyExplicitTheme.mapColor field in @malloydata/render. Schema
addition is a per-mode hex (`palette.mapColor.{light,dark}`) so the
operator can pick a different brand colour for light vs dark.

End-to-end wiring:
- api-doc.yaml: new `palette.mapColor` field, regen api.ts + client.
- PER_MODE_COLOR_KEYS (SDK keys.ts + server config.ts mirror) extended.
- DEFAULT_THEME: light + dark default to MALLOY_BRAND.teal so unbranded
  installs get a brand-coherent ramp instead of the renderer's blue.
- ResolvedTheme + resolveTheme: pick("mapColor") joins the cascade.
- buildMalloyExplicitTheme: passes mapColor through to the renderer.
- SeriesColorsSection: new picker "Map color (gradient saturated end)"
  alongside Chart background in a 2-col grid; new MapPreview shows the
  5-stop gradient so the operator sees the ramp inline.

Per-chart # theme.palette.mapColor.{light,dark} annotations on a Malloy
model still win over the operator's choice for that chart, same as
every other per-mode colour key.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
Two changes in response to UX feedback:

1. Maps get their own section (was previously a picker in Charts).
   The operator's mental model is "one knob for maps", not "another
   thing in the Charts grid"; choropleths consume a sequential scale
   that doesn't compose with the categorical series palette next to
   it. MapsSection sits between Charts and Tables in the page.

2. MapPreview is now an actual stylised US-state choropleth instead
   of a colour bar. 17 hand-traced path tiles arranged loosely as
   the lower-48 + AK/HI, each tinted by an arbitrary intensity along
   the same low-to-high ramp the renderer applies internally. The
   colour bar version misread as "what does my palette look like",
   not "what does a map look like".

Also stripped the now-redundant map picker + preview from
SeriesColorsSection and rolled the per-mode color helper back to a
single-purpose setBackground (had been generalised earlier when the
map picker lived there).

If the actual sales_by_state map on a package page still paints with
the renderer's hardcoded blue ramp, that's a stale
`@malloydata/render` dist — rebuild the renderer (`cd
../malloy/packages/malloy-render && bun run build`) and restart
Publisher. Verified: the rebuilt dist contains `MAP_GRADIENT_LOW`
and the `explicitTheme?.mapColor` branch.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
The previous preview rendered as a rectangular tile grid because the
hand-traced "state" paths were axis-aligned rectangles, which read as
abstract colour blocks rather than a map. Swapped the tiles for a
single simplified lower-48 silhouette path with state-blob circles
overlaid at varying intensities along the gradient. Still not
geographically accurate (would require ~50 paths + a topology dataset
the editor shouldn't pull in), but clearly recognisable as a US map.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
Replaced the hand-traced silhouette + circles (which read as
"weirdly shaped colour blocks") with the actual us-atlas/states-10m
TopoJSON the renderer's shape-map plugin uses, so the editor preview
matches the shape an operator will see on a real choropleth page.

Each state's intensity comes from a stable FIPS-id hash so re-renders
don't reshuffle the gradient assignment. Projection is geoAlbersUsa
(same as Vega's default for `sales_by_state`-style charts) fit to a
380×220 viewport.

us-atlas, topojson-client, and d3-geo are already in node_modules
transitively via @malloydata/render. Added a small local .d.ts stub
for the two that ship without bundled types rather than pulling in
@types/d3-geo and @types/topojson-client as direct deps for one
preview surface.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
Signed-off-by: Monty Lennie <montylennie@gmail.com>
@mlennie mlennie force-pushed the monty/theming-spike branch from bd1a625 to 685dabf Compare May 25, 2026 20:00
mlennie and others added 2 commits May 25, 2026 14:14
…rer publishes

The Publisher SDK consumes a `theme` prop on `MalloyRenderer` and a
`MalloyExplicitTheme` type that ship in `monty/explicit-theme-prop`
on `malloydata/malloy` and have not yet been published to npm. Local
dev works via `bun link` against a checkout that already has these
exports. CI installs `@malloydata/render` from npm at v0.0.394+ which
doesn't have them, so `tsc --noEmit` fails on the SDK with:

  src/theme/buildMalloyExplicitTheme.ts(1,15): error TS2305:
    Module '"@malloydata/render"' has no exported member
    'MalloyExplicitTheme'.

  src/components/RenderedResult/RenderedResult.tsx(62,7): error TS2353:
    Object literal may only specify known properties, and 'theme'
    does not exist in type 'MalloyRendererOptions'.

This change adds `packages/sdk/src/types/malloy-render-augmentation.d.ts`
which type-augments `@malloydata/render` to add `MalloyExplicitTheme`
and the `theme` field on `MalloyRendererOptions`. At runtime the
augmentation is type-only; an older published renderer silently ignores
the unknown `theme` option, so chart-canvas / mapColor theming is a
no-op until the renderer publishes, but table chrome continues to
theme via the existing CSS-var path. The Theme Editor UI itself is
fully functional regardless of which renderer version is installed.

REMOVE this file once the renderer PR (monty/explicit-theme-prop)
merges and Malloy CI publishes the next @malloydata/render. Bump the
dependency in `packages/sdk/package.json` to that version in the same
follow-up commit.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
Comment thread api-doc.yaml Outdated
description: |
Returns the theme used by the Malloy renderer for charts/tables/dashboards.
Reflects edits made via the in-app Theme Editor (persisted to the server's
SQLite database). Empty object when no theme has been configured.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SQLLite DB?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sagarswamirao good catch. I updated to say DuckDB

ThemeStore is backed by the existing StorageManager (DuckDB), not
SQLite. The api-doc.yaml description and the theme_store.ts class
docstring both said "SQLite" as a leftover from an early plan that
predated reusing the shared storage layer.

Fixes Sagar's review comment on PR #786.

Signed-off-by: Monty Lennie <montylennie@gmail.com>
@sagarswamirao
Copy link
Copy Markdown
Collaborator

Can you add a couple of screenshots?

@mlennie
Copy link
Copy Markdown
Collaborator Author

mlennie commented May 28, 2026

Can you add a couple of screenshots?

@sagarswamirao thank you for the reminder. Just added screenshots to PR description.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants