Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ec6d15b
feat(adapters): add a light and dark house theme with a mode button
jvdd Sep 30, 2026
0117ca1
feat(adapters): darken the OpenStreetMap tiles in dark mode
jvdd Sep 30, 2026
d00aaa2
feat(adapters): lift the low end of Viridis in dark mode
jvdd Sep 30, 2026
662aaab
fix(adapters): show series hover labels in neutral colors with a seri…
jvdd Sep 30, 2026
72fde4b
fix(adapters): keep legend swatches opaque in overlay mode
jvdd Sep 30, 2026
b366801
fix(adapters): widen the left margin for long y-axis tick labels
jvdd Sep 30, 2026
a3d803a
fix(adapters): use the page font in toolbar and panel buttons
jvdd Sep 30, 2026
4b1c3e2
docs: document the light and dark mode and the new default look
jvdd Sep 30, 2026
edd6193
test: drop the renderer argument from the theme browser tests
jvdd Sep 30, 2026
988ad95
fix(adapters): keep OpenStreetMap on a map with its own template
jvdd Sep 30, 2026
8969d0b
fix(adapters): let a layout or template hover border win over the ser…
jvdd Sep 30, 2026
38db4e4
fix(adapters): let the figure font reach every text of the theme
jvdd Sep 30, 2026
80fe9e3
docs: describe the per-figure theme template and hover border
jvdd Sep 30, 2026
d1caf0d
fix(adapters): read a null or invalid font or template as unset in th…
jvdd Sep 30, 2026
201e833
fix(adapters): keep the dark Viridis lift off a figure with its own t…
jvdd Sep 30, 2026
25b8dca
docs: describe the null rule and the per-trace theme parts
jvdd Sep 30, 2026
ac00739
fix(adapters): draw linked-hover guides in the mode of their own plot…
jvdd Oct 1, 2026
280ddc3
fix(adapters): let a null font key keep the theme default
jvdd Oct 1, 2026
6e1a74f
docs: describe the hover guide surface mode and null font keys
jvdd Oct 1, 2026
cca4e35
test: drop the unused renderer argument of _wait_for_init
jvdd Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,7 @@ fig.add_corr_heatmap(columns=["a", "b"], color_scale="RdBu", color_range="auto")
- A figure holds at most one heatmap-like trace (`histogram2d`, `corr_heatmap`, `geo_histogram2d`): each one is opaque and draws its own figure-wide colorbar, so a second one hides the first and duplicates the colorbar. `FigureSpec` checks this when it is built or parsed, so the builder, a decoded spec and every request get the same rule. A heatmap together with non-heatmap traces, such as a line over a `histogram2d`, stays allowed.
- Renderer split:
- Plotly receives `color_scale` in the Plotly spelling that the trace stored. The server rebuilds a decoded spec, so an older lowercase name still renders its scale. With a linear norm, it applies `zmin` / `zmax` only when `color_range` is fixed.
- Plotly dark mode: the client draws `"Viridis"` without its three darkest stops (see [Theme and light/dark mode](#theme-and-lightdark-mode)). Other scales do not change.
- Plotly log norm: Plotly has no log color axis, so the client colors by `log10(z)`, pins the color range in log10 space and labels the colorbar ticks with round data values. Only the colors change: the hover shows the raw values, and a cell at or below 0, which has no log, has no color but keeps its hover and its selection. The transform (`applyLogColorNorm` in `plotly/traces.js`) runs when a trace is built from its template, so it covers every delta, from the server or the cube.

### Dtype-Aware Filtering
Expand Down Expand Up @@ -1513,7 +1514,8 @@ bundle composition (which sources go into `shared` / `plotly`) is declared in

```
adapters/js/
├── theme.css ← CSS custom properties (design tokens)
├── theme.css ← CSS custom properties (design tokens), light and dark
├── theme-mode.js ← light/dark mode, inlined in <head> by page_head_html()
├── toolbar.css ← toolbar and header styles
├── toolbar.js ← shared toolbar hooks + state helpers
├── gridstack-bridge.js ← GridStack.init() IIFE + change/resizestop handlers
Expand All @@ -1529,6 +1531,7 @@ adapters/js/
│ └── hover.js ← linked-hover dispatch
└── plotly/
├── traces.js ← configsByFig, trace template builders
├── theme.js ← Plotly layout template from the tokens, fvApplyTheme
├── render.js ← _fvRenderFigure, axis helpers, lock/capture
├── events.js ← relayout/selected/deselect/click handlers
├── hover.js ← Plotly crosshair helpers
Expand All @@ -1537,12 +1540,27 @@ adapters/js/

`runtime.py` assembles two bundles from these sources at import: `shared` (panel.js +
runtime/*.js + toolbar.js) and `plotly` (plotly/*.js); `theme.css`
and `gridstack-bridge.js` are served verbatim.
and `gridstack-bridge.js` are served verbatim, and `page_head_html()` inlines
`theme-mode.js`.

Each adapter's `<style>` block starts with `theme_css()` so all CSS custom properties
are available. `_toolbar_css()` uses `var(--fv-*)` references throughout — no hardcoded
color or spacing values remain.

### Theme and light/dark mode

- `theme.css` holds one house theme as tokens: chrome (`--fv-accent`, `--fv-bg`, `--fv-text`, ...) and plots (`--fv-series`, `--fv-plot-*`). The base `:root` block is light mode. `:root[data-fv-mode="dark"]` overrides it, so it also outranks a later plain `:root` override.
- `theme-mode.js` runs in `<head>` before the first paint. It sets `data-fv-mode` on `<html>` from localStorage `fv-mode`, else from the OS (`prefers-color-scheme`, kept live with a `matchMedia` listener). The toolbar button `#fv-btn-mode` cycles Auto, Light and Dark. `ToolbarConfig` cannot hide it. The mode is a viewer preference: it is not in the spec, a share URL or the server.
- `plotly/theme.js` builds one Plotly `layout.template` per figure from the tokens (`fvPlotlyTemplate`). A template fills only unset keys, so `update_layout(...)` wins. A template value beats inheritance from `layout.font`, so the template leaves out each key that the figure's own `font` sets, and that key reaches every text except the hover label.
- A figure layout with its own `template` keeps it and gets no house theme. Its map still gets `open-street-map` if neither the layout nor that template sets `map.style`, because Plotly's default style loads CARTO tiles.
- Python `None` reaches the page as `null`, and Plotly reads `null` as unset. The theme does the same: `template=None` is no own template, and `font=None`, a `font` key set to `None` or `hoverlabel.bordercolor=None` sets nothing.
- On a mode change, `window.fvApplyTheme()` rebuilds the template and redraws each drawn themed figure from state inside `fvRunProgrammaticPlotlyOp`. No part of a figure with its own template follows the mode, so it is not redrawn. The switch sends no request and changes no state.
- `--fv-series` (Okabe-Ito) is read once and is the same in both modes, because `state.group_domains` stores each group's hex and share URLs carry it.
- Dark mode choices in `plotly/theme.js`: the template's `map.style` is a MapLibre style object with the OpenStreetMap raster tiles of Plotly's `open-street-map` style, darkened by raster paint. The trace colors use `"Viridis"` without its three darkest stops, so the cells and the colorbar match. The spec stores only the scale name, so a `"Viridis"` that the user set by name gets the lift too.
- `fvApplyThemeToTraces`, called by `buildTracesForFigure`, applies the theme parts that a template cannot hold, because they are per trace: the dark `"Viridis"` and a hover label border in the series color. The label itself uses the neutral tooltip tokens. A trace value beats the layout, so a figure that sets `hoverlabel.bordercolor` gets no series border. A figure with its own template gets neither part.
- The linked-hover overlay of a figure carries `data-fv-mode` from `fvPlotSurfaceMode`: the mode of the figure's own plot background (the plot color, else the paper color, else the page mode). `theme.css` declares its two token blocks for the overlay too, so the guides use ink on a light plot and paper on a dark one, also when a figure's own template or `plot_bgcolor` differs from the page mode.
- The y axis uses `automargin`. The margin grows past `margin.l` only when the tick labels do not fit, so the plot area moves on a zoom only when the labels would otherwise be cut off.

### Shared Runtime (`adapters/runtime.py`)

The Plotly adapter embeds `shared_runtime_js()` which provides renderer-agnostic logic.
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,43 @@ independently. `flexviz` pins a compatible `flexviz-polars` range.
loopback address also refuses a request whose `Host` is not `localhost`, a
loopback IP or the bind host, which blocks DNS rebinding.

### Added

- A light and a dark mode. The new **Mode** button in the toolbar cycles
Auto, Light and Dark. Auto follows the system setting, and the browser
remembers a Light or Dark choice. The mode is not part of the spec or a
share URL, and a switch sends no request to the server.

### Changed

- `flexviz` requires the `flexviz-polars` release that ships with it, because
the line envelope kernel now bins the free axis like the display kernel.
- A new default look for the page and the plots: system fonts, slate text,
an indigo accent, left-aligned titles, and theme colors for the plot
background, grid, axes, hover labels and active-filter strip. Before, the
plots used the Plotly defaults, and a dark system showed white plots in a
dark page. `update_layout()` options still win over the theme.
- The default series colors are the Okabe-Ito palette (`#0072b2`, `#e69f00`,
`#009e73`, ...) instead of the Plotly colors, and they are the same in both
modes.
- A series hover label uses the neutral theme colors with a border in the
series color. Before, its text on the series color had a contrast of
about 3:1.
- In dark mode, maps darken the OpenStreetMap tiles, and the `"Viridis"`
color scale starts at `#424086` instead of `#440154`, so sparse cells stay
visible on a dark plot. This also applies to a `"Viridis"` that you set by
name. Light mode and the other scales do not change.
- A notebook `show()` displays the server's own `/view` page in its iframe,
like the browser path, instead of a `data:` page that called the server from
another origin.

### Fixed

- Long y-axis tick labels, such as the column names of a correlation
heatmap, are no longer cut off: the left margin grows to fit them.
- In overlay mode, the legend no longer fades with the background layer.
- Toolbar and panel buttons use the page font.

### Removed

- `flexviz.server.show_server`. Use `Figure.show()`, `Dashboard.show()` or
Expand Down
49 changes: 48 additions & 1 deletion docs/guides/customizing.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ dash.show(

The fields are `show_reset`, `show_deselect`, `show_cfmode`, `show_hover`,
`show_lock_all_axes`, `show_grid`, `show_share`, `show_export` and
`show_import`. An empty button group disappears with its divider.
`show_import`. An empty button group disappears with its divider. The
**Mode** button always shows: see [Light and dark mode](#light-and-dark-mode).

Most of these hide a button whose state you can reach another way.
`show_grid` is different: it hides the only built-in button for changing
Expand All @@ -200,6 +201,52 @@ Each panel also has its own bottom bar with Zoom, Pan, CF, Reset and axis
lock. These follow what the traces in that panel support, and they are not
configurable today.

## Light and dark mode

The page has a light and a dark mode. The **Mode** button in the toolbar
cycles **Auto**, **Light** and **Dark**. Auto follows the system setting and
changes with it. The browser remembers a Light or Dark choice for the server
address.

The mode is a viewer preference. It is not part of the spec, so a share URL
or an exported spec does not carry it. A switch sends no request to the
server and keeps your zoom and selections.

Both modes use the Okabe-Ito series colors, which stay apart for the common
types of color blindness. The colors do not change with the mode, so a group
keeps its color after a switch. In dark mode, maps darken the OpenStreetMap
tiles, and the `"Viridis"` scale starts at a lighter blue, so sparse cells
stay visible. Other color scales do not change.

### Override the theme

`update_layout()` wins over the theme in both modes:

```python
fig.update_layout(plot_bgcolor="#f5f5f5") # a cartesian figure
map_fig.update_layout(map={"style": "white-bg"}) # a map figure
```

A `font` from `update_layout()` reaches every text of the figure except the
hover label. A figure that sets its own `template` in `update_layout()` does
not get the theme. `template=None` counts as unset.

The page look comes from CSS custom properties (`--fv-*`). FlexViz has no
Python option for them. If you serve the `/view` page through your own
proxy, add a style rule after the FlexViz styles:

```css
/* Both modes */
:root, :root[data-fv-mode="dark"] { --fv-accent: #0d9488; }
/* Dark mode only */
:root[data-fv-mode="dark"] { --fv-accent: #2dd4bf; }
```

The dark block outranks a plain `:root` rule. To change a token in both
modes, name both selectors. Keep `--fv-series` the same in both modes,
because the spec stores each group's color as a hex value. The full token
list is in `flexviz/adapters/js/theme.css`.

## Precedence

`layout` owns every field it sets. `rows`, `cols` and `draggable` are
Expand Down
8 changes: 7 additions & 1 deletion flexviz/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,13 @@ def _group(btns: list) -> str:
)
g3.append(' <button id="fv-btn-import">Import</button>')

inner = _group(g1) + _group(g2) + _group(g_grid) + _group(g3)
# The light/dark switch is a viewer preference, so ToolbarConfig
# cannot hide it. theme-mode.js wires it and sets its label.
g_mode = [
' <button id="fv-btn-mode" title="Auto follows the system setting">Mode: Auto</button>'
]

inner = _group(g1) + _group(g2) + _group(g_grid) + _group(g3) + _group(g_mode)
return (
'<header id="fv-header">\n'
' <div id="fv-header-main">\n'
Expand Down
5 changes: 3 additions & 2 deletions flexviz/adapters/js/plotly/hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ let _hoverSuspendedForDrag = false;
const hoverGuidesByFig = {};
window.__fvHoverGuidesByFig = hoverGuidesByFig;

// Teal color system — matches spec CSS tokens
const LINKED_HOVER_LINE_STYLE = { color: 'rgba(13, 148, 136, 0.85)', width: 2, dash: 'solid' };
// The guides are DOM overlays, so the theme tokens reach them as CSS variables.
const LINKED_HOVER_LINE_STYLE = { color: 'var(--fv-hover-color)', width: 2, dash: 'solid' };

// ── Overlay DOM helpers (unchanged from previous version) ──────────────────

Expand Down Expand Up @@ -58,6 +58,7 @@ function ensureHoverOverlay(figUid) {
});
div.appendChild(overlay);
}
if (div._fullLayout) overlay.dataset.fvMode = fvPlotSurfaceMode(div._fullLayout);
return overlay;
}

Expand Down
2 changes: 1 addition & 1 deletion flexviz/adapters/js/plotly/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ function _fvRenderFigure(figUid) {

layoutsByFig[figIdx].selections = selectionBoxesForFigure(figUid);
const renderPromise = Plotly.react(divs[figIdx], tracesByFig[figIdx], layoutsByFig[figIdx], configsByFig[figIdx]);
Promise.resolve(renderPromise).then(() => {
return Promise.resolve(renderPromise).then(() => {
bindFigure(figUid);
applyCategorySelectionStyles(figUid);
renderHoverOverlay(figUid);
Expand Down
Loading
Loading