From ec6d15b7647c3357d11f839091569b7d2d3fcf44 Mon Sep 17 00:00:00 2001 From: Jeroen Van Der Donckt Date: Wed, 30 Sep 2026 11:39:08 +0200 Subject: [PATCH 01/20] feat(adapters): add a light and dark house theme with a mode button --- flexviz/adapters/base.py | 8 +- flexviz/adapters/js/plotly/hover.js | 4 +- flexviz/adapters/js/plotly/render.js | 2 +- flexviz/adapters/js/plotly/theme.js | 63 ++++++++ flexviz/adapters/js/runtime/state.js | 8 +- flexviz/adapters/js/theme-mode.js | 43 +++++ flexviz/adapters/js/theme.css | 231 ++++++++++++++------------- flexviz/adapters/js/toolbar.css | 2 +- flexviz/adapters/runtime.py | 9 +- tests/test_adapters.py | 7 +- tests/test_browser.py | 78 +++++++++ tests/test_html_adapters.py | 31 ++++ 12 files changed, 366 insertions(+), 120 deletions(-) create mode 100644 flexviz/adapters/js/plotly/theme.js create mode 100644 flexviz/adapters/js/theme-mode.js diff --git a/flexviz/adapters/base.py b/flexviz/adapters/base.py index 1f333ed..75fa93d 100644 --- a/flexviz/adapters/base.py +++ b/flexviz/adapters/base.py @@ -351,7 +351,13 @@ def _group(btns: list) -> str: ) g3.append(' ') - 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 = [ + ' ' + ] + + inner = _group(g1) + _group(g2) + _group(g_grid) + _group(g3) + _group(g_mode) return ( '
\n' '
\n' diff --git a/flexviz/adapters/js/plotly/hover.js b/flexviz/adapters/js/plotly/hover.js index f832df6..a5e2f50 100644 --- a/flexviz/adapters/js/plotly/hover.js +++ b/flexviz/adapters/js/plotly/hover.js @@ -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) ────────────────── diff --git a/flexviz/adapters/js/plotly/render.js b/flexviz/adapters/js/plotly/render.js index 68c1f76..c0b38e6 100644 --- a/flexviz/adapters/js/plotly/render.js +++ b/flexviz/adapters/js/plotly/render.js @@ -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); diff --git a/flexviz/adapters/js/plotly/theme.js b/flexviz/adapters/js/plotly/theme.js new file mode 100644 index 0000000..b5ca49f --- /dev/null +++ b/flexviz/adapters/js/plotly/theme.js @@ -0,0 +1,63 @@ +// === Plotly adapter — theme template === +// Requires: state.js (fvThemeToken, _fvPalette), traces.js +// The Plotly layout template is built from the CSS tokens, so theme.css drives +// the chrome and the plots. A template only fills what the figure layout +// leaves unset: update_layout(...) values still win. + +// A figure layout that brings its own template keeps it. +const _fvFigureHasOwnTemplate = layoutsByFig.map(layout => layout.template !== undefined); + +function fvPlotlyTemplate() { + const text = fvThemeToken('--fv-plot-text'); + const tick = fvThemeToken('--fv-plot-tick'); + const bg = fvThemeToken('--fv-plot-bg'); + const family = fvThemeToken('--fv-plot-font'); + // Tick labels stay at 12px: a smaller size failed the readability check. + const tickfont = { family: fvThemeToken('--fv-plot-tick-font'), size: 12, color: tick }; + const colorbar = { outlinewidth: 0, thickness: 12, tickfont }; + const axis = { + gridcolor: fvThemeToken('--fv-plot-grid'), + linecolor: fvThemeToken('--fv-plot-axis'), + tickcolor: fvThemeToken('--fv-plot-axis'), + zeroline: false, + tickfont, + title: { font: { size: 12, color: tick } }, + }; + return { + layout: { + font: { family, size: 12, color: text }, + paper_bgcolor: bg, + plot_bgcolor: bg, + colorway: _fvPalette, + title: { font: { size: 14, weight: 600, color: text }, x: 0.015, xanchor: 'left' }, + xaxis: { ...axis, showline: true }, + yaxis: axis, + legend: { font: { size: 12, color: text }, bgcolor: 'rgba(0,0,0,0)' }, + newselection: { line: { color: fvThemeToken('--fv-plot-select'), width: 1.5 } }, + }, + data: { + heatmap: [{ colorbar }], + choroplethmap: [{ colorbar }], + pie: [{ marker: { line: { color: bg, width: 1 } } }], + treemap: [{ marker: { line: { color: bg, width: 1 } } }], + box: [{ line: { width: 1.5 } }], + }, + }; +} + +// Read the tokens again and redraw every drawn figure. Runs at load (no +// figure is drawn yet, so it only sets the templates) and on a mode change. +// The redraw is a Plotly.react from state, guarded so no Plotly event from it +// reaches the viewport or selection handlers. +window.fvApplyTheme = function() { + const template = fvPlotlyTemplate(); + layoutsByFig.forEach((layout, figIdx) => { + if (!_fvFigureHasOwnTemplate[figIdx]) layout.template = template; + }); + for (const figUid of _fvAllFigUids) { + if (divs[figUidToIdx[figUid]]?._fullLayout) { + fvRunProgrammaticPlotlyOp(figUid, () => _fvRenderFigure(figUid)); + } + } +}; +window.fvApplyTheme(); diff --git a/flexviz/adapters/js/runtime/state.js b/flexviz/adapters/js/runtime/state.js index 0346782..2095067 100644 --- a/flexviz/adapters/js/runtime/state.js +++ b/flexviz/adapters/js/runtime/state.js @@ -85,7 +85,13 @@ function _fvCompactState() { }; } -const _fvPalette = ['#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b','#e377c2','#7f7f7f','#bcbd22','#17becf']; +function fvThemeToken(name) { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); +} + +// The series colors, read once: the palette is the same in both modes, so the +// hex that state.group_domains stores for a group stays right after a switch. +const _fvPalette = fvThemeToken('--fv-series').split(',').map(c => c.trim()).filter(Boolean); if (!DASHBOARD_SPEC.state) DASHBOARD_SPEC.state = {}; if (!DASHBOARD_SPEC.state.group_domains) DASHBOARD_SPEC.state.group_domains = {}; const figSpecByUid = Object.fromEntries( diff --git a/flexviz/adapters/js/theme-mode.js b/flexviz/adapters/js/theme-mode.js new file mode 100644 index 0000000..f0f0613 --- /dev/null +++ b/flexviz/adapters/js/theme-mode.js @@ -0,0 +1,43 @@ +// === FlexViz light/dark mode (runs in , before the first paint) === +// The mode is a viewer preference, not interaction state: it lives in +// localStorage and never in the spec or a share URL. 'auto' follows the OS. +// A change calls window.fvApplyTheme, which the renderer bundle defines. +(function() { + const root = document.documentElement; + const media = window.matchMedia('(prefers-color-scheme: dark)'); + const MODE_KEY = 'fv-mode'; + const PREFERENCES = ['auto', 'light', 'dark']; + let preference = 'auto'; + try { + const stored = window.localStorage.getItem(MODE_KEY); + if (stored === 'light' || stored === 'dark') preference = stored; + } catch (e) { /* storage blocked: follow the OS */ } + + function applyMode() { + const mode = preference === 'auto' ? (media.matches ? 'dark' : 'light') : preference; + if (root.dataset.fvMode === mode) return; + root.dataset.fvMode = mode; + window.fvApplyTheme?.(); + } + applyMode(); + media.addEventListener('change', applyMode); + + function labelButton(button) { + button.textContent = 'Mode: ' + preference.charAt(0).toUpperCase() + preference.slice(1); + } + + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('fv-btn-mode'); + if (!button) return; + labelButton(button); + button.addEventListener('click', () => { + preference = PREFERENCES[(PREFERENCES.indexOf(preference) + 1) % PREFERENCES.length]; + try { + if (preference === 'auto') window.localStorage.removeItem(MODE_KEY); + else window.localStorage.setItem(MODE_KEY, preference); + } catch (e) { /* storage blocked: the choice lasts for this page only */ } + labelButton(button); + applyMode(); + }); + }); +})(); diff --git a/flexviz/adapters/js/theme.css b/flexviz/adapters/js/theme.css index d016163..eb6c63b 100644 --- a/flexviz/adapters/js/theme.css +++ b/flexviz/adapters/js/theme.css @@ -1,16 +1,20 @@ /* === FlexViz CSS design tokens === */ -/* Override any token with a later :root { --fv-accent: ... } rule in the page head. */ +/* The head script (theme-mode.js) sets data-fv-mode="light|dark" on before */ +/* the first paint. :root holds the light tokens; the dark block below overrides them. */ +/* Override a token with a later rule in the page head. The dark block outranks a */ +/* plain :root rule, so use :root, :root[data-fv-mode="dark"] { ... } for both modes. */ /* The outer panel shell, inner plot surface, and lower control bar intentionally */ /* have separate tokens so demos can either keep them identical or create contrast. */ +/* The plot tokens (--fv-series, --fv-plot-*) drive the renderer's plot template. */ :root { /* Accent / primary action */ - --fv-accent: #2563eb; /* Solid accent fill for active primary controls */ - --fv-accent-dark: #1d4ed8; /* Darker accent used for pressed/hovered active controls */ - --fv-accent-bg: rgba(37, 99, 235, 0.10); /* Low-contrast accent wash for subtle active states */ - --fv-accent-border: rgba(37, 99, 235, 0.26); /* Border color paired with accent-tinted controls */ - --fv-accent-text: rgba(37, 99, 235, 0.75); /* Accent-colored text for selected but lightweight UI */ - --fv-accent-text-strong: rgba(37, 99, 235, 0.78); /* Slightly stronger accent text for emphasized selected UI */ + --fv-accent: #4f46e5; /* Solid accent fill for active primary controls */ + --fv-accent-dark: #4338ca; /* Darker accent used for pressed/hovered active controls */ + --fv-accent-bg: rgba(79, 70, 229, 0.10); /* Low-contrast accent wash for subtle active states */ + --fv-accent-border: rgba(79, 70, 229, 0.28); /* Border color paired with accent-tinted controls */ + --fv-accent-text: #4338ca; /* Accent-colored text for selected but lightweight UI */ + --fv-accent-text-strong: #3730a3; /* Slightly stronger accent text for emphasized selected UI */ /* Surfaces */ --fv-bg: #ffffff; /* Page background behind the dashboard */ @@ -24,32 +28,32 @@ --fv-panel-surface-editing: var(--fv-panel-surface); /* Inner plot area background in grid edit mode */ /* Borders */ - --fv-border: #e0d8ce; /* Standard divider/border tone for layout chrome */ - --fv-border-btn: #cbc1b6; /* Default border for top-toolbar buttons */ - --fv-border-soft: rgba(70, 55, 35, 0.15); /* Soft UI borders such as buttons and bar separators */ + --fv-border: #e2e8f0; /* Standard divider/border tone for layout chrome */ + --fv-border-btn: #cbd5e1; /* Default border for top-toolbar buttons */ + --fv-border-soft: rgba(15, 23, 42, 0.08); /* Soft UI borders such as buttons and bar separators */ --fv-panel-border: rgba(35, 28, 17, 0.3); /* Outer panel border color */ --fv-panel-border-editing: #cfc5b8; /* Outer panel border color in grid edit mode */ /* Text */ - --fv-text: #2f2822; /* Default readable text color across the UI */ - --fv-text-muted: rgba(47, 40, 34, 0.48); /* Muted labels for inactive mode buttons and metadata */ - --fv-text-secondary: rgba(47, 40, 34, 0.62); /* Secondary text for control bars and supporting UI */ - --fv-text-strong: rgba(33, 28, 24, 0.82); /* Strong text used on hover or emphasized controls */ - --fv-text-faint: rgba(47, 40, 34, 0.30); /* Very low-emphasis helper/warning text */ - --fv-text-hover: rgba(33, 28, 24, 0.76); /* Hover-state text color for interactive controls */ + --fv-text: #0f172a; /* Default readable text color across the UI */ + --fv-text-muted: #64748b; /* Muted labels for inactive mode buttons and metadata */ + --fv-text-secondary: #475569; /* Secondary text for control bars and supporting UI */ + --fv-text-strong: #0f172a; /* Strong text used on hover or emphasized controls */ + --fv-text-faint: #94a3b8; /* Very low-emphasis helper/warning text */ + --fv-text-hover: #1e293b; /* Hover-state text color for interactive controls */ /* Danger / destructive */ --fv-danger-bg: #fff0f0; /* Hover/fill color for destructive actions such as reset/deselect */ --fv-danger-border: #ffb3b3; /* Border paired with destructive hover/fill states */ /* Shadows */ - --fv-shadow-sm: 0 1px 3px rgba(72, 53, 33, 0.06); /* Small shadow for top-level chrome such as the header */ - --fv-shadow-xs: 0 1px 2px rgba(72, 53, 33, 0.05); /* Extra-light shadow available for compact surfaces */ + --fv-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06); /* Small shadow for top-level chrome such as the header */ + --fv-shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.05); /* Extra-light shadow available for compact surfaces */ --fv-panel-shadow: none; /* Shadow applied to each figure panel in normal mode */ --fv-panel-shadow-editing: none; /* Shadow applied to each figure panel while grid editing is enabled */ /* Typography */ - --fv-font: sans-serif; /* Base UI font family used by toolbars and panel controls */ + --fv-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; /* Base UI font family */ --fv-font-size-xs: 9px; /* Tiny helper text size */ --fv-font-size-sm: 10px; /* Compact control/button text size */ --fv-font-size-base: 13px; /* Default toolbar button and UI text size */ @@ -71,102 +75,109 @@ --fv-toolbar-height: 44px; /* Shared top toolbar/header height */ --fv-panel-bar-height: 28px; /* Bottom control bar minimum height */ --fv-panel-bar-bg: var(--fv-panel-bg); /* Bottom control bar background */ - --fv-panel-bar-border: var(--fv-border-soft); /* Bottom control bar top border color */ - --fv-filter-strip-bg: rgba(239, 246, 255, 0.96); /* Background for the global active-filter row */ - --fv-filter-strip-border: rgba(37, 99, 235, 0.38); /* Top border for the global active-filter row */ - --fv-filter-strip-text: rgba(29, 78, 216, 0.92); /* Label text for the global active-filter row */ - --fv-filter-chip-bg: rgba(37, 99, 235, 0.08); /* Background for individual active-filter chips */ - --fv-filter-chip-border: rgba(37, 99, 235, 0.18); /* Border color for active-filter chips */ + --fv-panel-bar-border: rgba(70, 55, 35, 0.15); /* Bottom control bar top border color */ + + /* Active-filter strip: built from the tokens above, so it follows the mode */ + --fv-filter-strip-bg: var(--fv-surface-overlay); /* Background for the global active-filter row */ + --fv-filter-strip-border: var(--fv-accent-border); /* Top border for the global active-filter row */ + --fv-filter-strip-text: var(--fv-accent-text); /* Label text for the global active-filter row */ + --fv-filter-chip-bg: var(--fv-accent-bg); /* Background for individual active-filter chips */ + --fv-filter-chip-border: var(--fv-accent-border); /* Border color for active-filter chips */ --fv-filter-chip-text: var(--fv-text-strong); /* Text color inside active-filter chips */ - --fv-filter-chip-source-bg: rgba(37, 99, 235, 0.14); /* Background for the source figure label within an active-filter chip */ - --fv-filter-chip-source-text: rgba(37, 99, 235, 0.92); /* Text color for the source figure label within an active-filter chip */ + --fv-filter-chip-source-bg: var(--fv-accent-bg); /* Background for the source figure label within an active-filter chip */ + --fv-filter-chip-source-text: var(--fv-accent-text); /* Text color for the source figure label within an active-filter chip */ --fv-filter-chip-field-text: var(--fv-text-strong); /* Bold field name color inside active-filter chips */ --fv-filter-chip-value-text: var(--fv-text-secondary); /* Value text color inside active-filter chips */ - --fv-filter-chip-separator-text: rgba(47, 40, 34, 0.42); /* Separator text color inside active-filter chips */ - --fv-filter-chip-joiner-bg: rgba(47, 40, 34, 0.06); /* Background for OR joiners inside active-filter chips */ - --fv-filter-chip-remove-bg: rgba(37, 99, 235, 0.10); /* Default background for the active-filter clear button */ - --fv-filter-chip-remove-border: rgba(37, 99, 235, 0.22); /* Default border for the active-filter clear button */ - --fv-filter-chip-remove-text: rgba(37, 99, 235, 0.92); /* Default text color for the active-filter clear button */ - --fv-filter-chip-remove-hover-bg: rgba(37, 99, 235, 0.14); /* Hover background for the chip clear button */ - --fv-filter-chip-remove-hover-border: rgba(37, 99, 235, 0.34); /* Hover border for the active-filter clear button */ + --fv-filter-chip-separator-text: var(--fv-text-muted); /* Separator text color inside active-filter chips */ + --fv-filter-chip-joiner-bg: var(--fv-border-soft); /* Background for OR joiners inside active-filter chips */ + --fv-filter-chip-remove-bg: var(--fv-accent-bg); /* Default background for the active-filter clear button */ + --fv-filter-chip-remove-border: var(--fv-accent-border); /* Default border for the active-filter clear button */ + --fv-filter-chip-remove-text: var(--fv-accent-text); /* Default text color for the active-filter clear button */ + --fv-filter-chip-remove-hover-bg: var(--fv-accent-border); /* Hover background for the chip clear button */ + --fv-filter-chip-remove-hover-border: var(--fv-accent); /* Hover border for the active-filter clear button */ --fv-filter-chip-remove-hover-text: var(--fv-text-strong); /* Hover text color for the chip clear button */ - /* Hover interaction visuals (teal palette) */ - --fv-hover-color: rgba(13, 148, 136, 0.85); /* point guide line */ - --fv-hover-interval-fill: rgba(13, 148, 136, 0.10); /* interval band fill */ - --fv-hover-interval-edge: rgba(13, 148, 136, 0.60); /* interval band dashed edges */ - --fv-hover-cell-border: rgba(13, 148, 136, 0.80); /* cell rect outline */ + /* Linked hover guides: ink on the plot */ + --fv-hover-color: rgba(27, 35, 44, 0.75); /* point guide line */ + --fv-hover-interval-fill: rgba(27, 35, 44, 0.07); /* interval band fill */ + --fv-hover-interval-edge: rgba(27, 35, 44, 0.50); /* interval band dashed edges */ + --fv-hover-cell-border: rgba(27, 35, 44, 0.85); /* cell rect outline */ + + /* Plots */ + /* Okabe-Ito series colors in slot order. Keep them the same in both modes: */ + /* state.group_domains stores the hex that each group got. */ + --fv-series: #0072b2, #e69f00, #009e73, #cc79a7, #56b4e9, #d55e00, #9a8700, #8a8a8a; + --fv-plot-bg: var(--fv-panel-surface); /* Plot area and figure background */ + --fv-plot-font: var(--fv-font); /* Plot text family */ + --fv-plot-tick-font: var(--fv-plot-font); /* Tick label and colorbar family */ + --fv-plot-text: var(--fv-text); /* Titles and legend */ + --fv-plot-tick: var(--fv-text-secondary); /* Tick labels and axis titles */ + --fv-plot-grid: #edf1f5; /* Grid lines */ + --fv-plot-axis: #cbd5e1; /* Axis lines and tick marks */ + --fv-plot-tooltip-bg: #0f172a; /* Hover label background */ + --fv-plot-tooltip-text: #f8fafc; /* Hover label text */ + --fv-plot-tooltip-border: #0f172a; /* Hover label border, for traces without a series color */ + --fv-plot-select: var(--fv-plot-text); /* Brush outline */ } -@media (prefers-color-scheme: dark) { - :root { - /* Accent / primary action */ - --fv-accent: #93c5fd; /* Lighter accent fill so active toolbar text can stay readable */ - --fv-accent-dark: #60a5fa; /* Pressed/hovered active accent state in dark mode */ - --fv-accent-bg: rgba(147, 197, 253, 0.16); /* Subtle accent wash for selected lightweight controls */ - --fv-accent-border: rgba(147, 197, 253, 0.34); /* Border paired with accent-tinted controls in dark mode */ - --fv-accent-text: rgba(191, 219, 254, 0.92); /* Accent-colored text for active but non-solid controls */ - --fv-accent-text-strong: rgba(219, 234, 254, 0.96); /* Stronger accent text for emphasized active controls */ - - /* Surfaces */ - --fv-bg: #171411; /* Page background behind the dashboard */ - --fv-surface: #24201c; /* Default control surface for buttons and shells */ - --fv-surface-2: #211d19; /* Secondary chrome surface, used by the top header */ - --fv-surface-hover: #2d2823; /* Hover state for general controls */ - --fv-surface-overlay: rgba(31, 27, 24, 0.92); /* Translucent overlays/popovers */ - --fv-panel-bg: var(--fv-surface); /* Outer panel shell around plot + bar */ - --fv-panel-bg-editing: #2a2520; /* Outer panel shell in grid edit mode */ - --fv-panel-surface: #1d1916; /* Inner plot area background */ - --fv-panel-surface-editing: #221d19; /* Inner plot area background in grid edit mode */ - - /* Borders */ - --fv-border: rgba(255, 245, 235, 0.12); /* Standard divider/border tone for layout chrome */ - --fv-border-btn: rgba(255, 245, 235, 0.18); /* Default border for top-toolbar buttons */ - --fv-border-soft: rgba(255, 245, 235, 0.14); /* Soft UI borders such as buttons and bar separators */ - --fv-panel-border: rgba(255, 245, 235, 0.12); /* Outer panel border color */ - --fv-panel-border-editing: rgba(255, 245, 235, 0.20); /* Outer panel border color in grid edit mode */ - - /* Text */ - --fv-text: rgba(255, 248, 242, 0.92); /* Default readable text color across the UI */ - --fv-text-muted: rgba(255, 248, 242, 0.52); /* Muted labels for inactive mode buttons and metadata */ - --fv-text-secondary: rgba(255, 248, 242, 0.68); /* Secondary text for control bars and supporting UI */ - --fv-text-strong: rgba(255, 250, 246, 0.96); /* Strong text used on hover or emphasized controls */ - --fv-text-faint: rgba(255, 248, 242, 0.34); /* Very low-emphasis helper/warning text */ - --fv-text-hover: rgba(255, 250, 246, 0.88); /* Hover-state text color for interactive controls */ - - /* Danger / destructive */ - --fv-danger-bg: rgba(127, 29, 29, 0.34); /* Hover/fill color for destructive actions such as reset/deselect */ - --fv-danger-border: rgba(248, 113, 113, 0.44); /* Border paired with destructive hover/fill states */ - - /* Shadows */ - --fv-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.32); /* Small shadow for top-level chrome such as the header */ - --fv-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.24); /* Extra-light shadow available for compact surfaces */ - --fv-panel-shadow: none; /* Shadow applied to each figure panel in normal mode */ - --fv-panel-shadow-editing: none; /* Shadow applied to each figure panel while grid editing is enabled */ - - /* Typography */ - --fv-brand-image: url("data:image/png;base64,{{WORDMARK_DARK}}"); /* Header wordmark artwork, light ink */ - - /* Toolbar */ - --fv-panel-bar-bg: rgba(36, 32, 28, 0.92); /* Dark-mode-only control bar surface */ - --fv-panel-bar-border: rgba(255, 245, 235, 0.15); /* Dark-mode-only divider above the control bar */ - --fv-filter-strip-bg: rgba(23, 37, 84, 0.94); /* Background for the global active-filter row */ - --fv-filter-strip-border: rgba(147, 197, 253, 0.42); /* Top border for the global active-filter row */ - --fv-filter-strip-text: rgba(191, 219, 254, 0.96); /* Label text for the global active-filter row */ - --fv-filter-chip-bg: rgba(147, 197, 253, 0.14); /* Background for individual active-filter chips */ - --fv-filter-chip-border: rgba(147, 197, 253, 0.24); /* Border color for active-filter chips */ - --fv-filter-chip-text: rgba(255, 250, 246, 0.94); /* Text color inside active-filter chips */ - --fv-filter-chip-source-bg: rgba(147, 197, 253, 0.20); /* Background for the source figure label within an active-filter chip */ - --fv-filter-chip-source-text: rgba(239, 246, 255, 0.98); /* Text color for the source figure label within an active-filter chip */ - --fv-filter-chip-field-text: rgba(255, 250, 246, 0.98); /* Bold field name color inside active-filter chips */ - --fv-filter-chip-value-text: rgba(255, 248, 242, 0.78); /* Value text color inside active-filter chips */ - --fv-filter-chip-separator-text: rgba(255, 248, 242, 0.46); /* Separator text color inside active-filter chips */ - --fv-filter-chip-joiner-bg: rgba(255, 248, 242, 0.08); /* Background for OR joiners inside active-filter chips */ - --fv-filter-chip-remove-bg: rgba(147, 197, 253, 0.16); /* Default background for the active-filter clear button */ - --fv-filter-chip-remove-border: rgba(147, 197, 253, 0.28); /* Default border for the active-filter clear button */ - --fv-filter-chip-remove-text: rgba(239, 246, 255, 0.98); /* Default text color for the active-filter clear button */ - --fv-filter-chip-remove-hover-bg: rgba(147, 197, 253, 0.18); /* Hover background for the chip clear button */ - --fv-filter-chip-remove-hover-border: rgba(191, 219, 254, 0.36); /* Hover border for the active-filter clear button */ - --fv-filter-chip-remove-hover-text: rgba(255, 250, 246, 0.98); /* Hover text color for the chip clear button */ - } +:root[data-fv-mode="dark"] { + /* Accent / primary action */ + --fv-accent: #818cf8; + --fv-accent-dark: #6366f1; + --fv-accent-bg: rgba(129, 140, 248, 0.16); + --fv-accent-border: rgba(129, 140, 248, 0.34); + --fv-accent-text: #c7d2fe; + --fv-accent-text-strong: #e0e7ff; + + /* Surfaces */ + --fv-bg: #07090c; + --fv-surface: #0f1318; + --fv-surface-2: #0f1318; + --fv-surface-hover: #1a2028; + --fv-surface-overlay: rgba(15, 19, 24, 0.94); + --fv-panel-bg-editing: #141a21; + --fv-panel-surface: #0b0e12; + --fv-panel-surface-editing: #10151b; + + /* Borders */ + --fv-border: #1e293b; + --fv-border-btn: #334155; + --fv-border-soft: rgba(148, 163, 184, 0.14); + --fv-panel-border: #1e293b; + --fv-panel-border-editing: #334155; + + /* Text */ + --fv-text: #e2e8f0; + --fv-text-muted: #7c8aa0; + --fv-text-secondary: #94a3b8; + --fv-text-strong: #f8fafc; + --fv-text-faint: #475569; + --fv-text-hover: #f1f5f9; + + /* Danger / destructive */ + --fv-danger-bg: rgba(127, 29, 29, 0.34); + --fv-danger-border: rgba(248, 113, 113, 0.44); + + /* Shadows */ + --fv-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.40); + --fv-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.30); + + /* Typography */ + --fv-brand-image: url("data:image/png;base64,{{WORDMARK_DARK}}"); /* Header wordmark artwork, light ink */ + + /* Toolbar */ + --fv-panel-bar-border: #1e293b; + + /* Linked hover guides: paper on the plot */ + --fv-hover-color: rgba(230, 237, 243, 0.70); + --fv-hover-interval-fill: rgba(230, 237, 243, 0.07); + --fv-hover-interval-edge: rgba(230, 237, 243, 0.45); + --fv-hover-cell-border: rgba(230, 237, 243, 0.85); + + /* Plots */ + --fv-plot-grid: rgba(148, 163, 184, 0.10); + --fv-plot-axis: #334155; + --fv-plot-tooltip-bg: #f8fafc; + --fv-plot-tooltip-text: #0f172a; + --fv-plot-tooltip-border: #f8fafc; } diff --git a/flexviz/adapters/js/toolbar.css b/flexviz/adapters/js/toolbar.css index 3c381eb..584e418 100644 --- a/flexviz/adapters/js/toolbar.css +++ b/flexviz/adapters/js/toolbar.css @@ -330,7 +330,7 @@ color: var(--fv-text-muted); line-height: 1.4; letter-spacing: 0.3px; } .fv-mode-btn + .fv-mode-btn { border-left: 1px solid var(--fv-border-soft); } - .fv-mode-btn:hover:not(:disabled) { background: rgba(0,0,0,0.06); color: var(--fv-text-hover); } + .fv-mode-btn:hover:not(:disabled) { background: var(--fv-surface-hover); color: var(--fv-text-hover); } .fv-mode-btn.mode-active { background: var(--fv-accent-bg); color: var(--fv-accent-text); } .fv-mode-btn:disabled { opacity: 0.28; cursor: not-allowed; } .fv-mode-action-btn { diff --git a/flexviz/adapters/runtime.py b/flexviz/adapters/runtime.py index 0e661fb..060a554 100644 --- a/flexviz/adapters/runtime.py +++ b/flexviz/adapters/runtime.py @@ -39,6 +39,7 @@ _PLOTLY_SOURCES: list[str] = [ "plotly/traces.js", + "plotly/theme.js", "plotly/render.js", "plotly/events.js", "plotly/hover.js", @@ -76,7 +77,8 @@ def _concat(sources: list[str]) -> str: _FAVICON_B64 = _b64("favicon.png") _PAGE_HEAD_HTML = ( "FlexViz\n" - f' ' + f' \n' + f" " ) @@ -119,7 +121,10 @@ def plotly_bundle_js() -> str: def page_head_html() -> str: - """Return the page title and inline favicon link for a dashboard page. + """Return the page title, inline favicon link and mode script for a page. + + The mode script sets light or dark mode on ```` before the first + paint, so a dark page never flashes light. An inline icon stops the browser probing ``GET /favicon.ico``, which 404s under ``/view`` and under a mount prefix, where the browser asks the host diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 929022f..81b1a10 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -6,6 +6,8 @@ from __future__ import annotations +import re + import pytest from flexviz.adapters.base import AbstractAdapter @@ -118,7 +120,7 @@ def test_toolbar_html_omits_empty_group(self): assert "fv-btn-import" not in html assert "fv-btn-reset" in html - def test_toolbar_html_all_hidden_renders_empty_toolbar(self): + def test_toolbar_html_all_hidden_keeps_only_the_mode_button(self): tc = ToolbarConfig( show_reset=False, show_deselect=False, @@ -131,7 +133,8 @@ def test_toolbar_html_all_hidden_renders_empty_toolbar(self): show_import=False, ) html = AbstractAdapter._toolbar_html(tc) - assert "fv-btn-" not in html + # The light/dark mode is a viewer preference, not a dashboard option. + assert re.findall(r'id="(fv-btn-[\w-]+)"', html) == ["fv-btn-mode"] assert "fv-header" in html def test_toolbar_config_roundtrips_via_layout_spec(self): diff --git a/tests/test_browser.py b/tests/test_browser.py index 80e0be2..4e23c77 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -8004,3 +8004,81 @@ def build(dash): assert result["guideLeft"] is not None, "Expected an x-guide on the log target" assert result["guideLeft"] == pytest.approx(result["expected"], abs=0.5) + + +# --------------------------------------------------------------------------- +# Theme: light and dark mode +# --------------------------------------------------------------------------- + +_OKABE_ITO = ["#0072b2", "#e69f00", "#009e73", "#cc79a7"] + + +def _switch_to_dark(page: Page) -> None: + """Click the mode button from Auto (light system) through Light to Dark.""" + page.click("#fv-btn-mode") + page.click("#fv-btn-mode") + page.wait_for_function("() => document.documentElement.dataset.fvMode === 'dark'") + # The switch redraws every figure with the dark template. + page.wait_for_function( + "() => divs.every(gd => gd._fullLayout.paper_bgcolor === '#0b0e12')" + ) + + +@pytest.mark.browser +class TestThemeBrowser: + """The mode is a client-only view preference: it changes only the look.""" + + def test_mode_switch_sends_no_request_and_keeps_the_state( + self, page: Page, server_port: int + ): + page.emulate_media(color_scheme="light") + page.goto(_dashboard_url_grouped(server_port, "plotly", "line", n_figures=2)) + _wait_for_init(page, "plotly") + # A zoom that the redraw must keep. + page.evaluate("() => Plotly.relayout(divs[0], {'xaxis.range': [10, 40]})") + page.wait_for_timeout(1_500) + read = """() => ({ + spec: JSON.stringify([DASHBOARD_SPEC.state, DASHBOARD_SPEC.client_state]), + range: divs[0]._fullLayout.xaxis.range, + colors: divs.map(gd => gd.data.map(t => t.line.color)), + })""" + before = page.evaluate(read) + requests: list[str] = [] + page.on( + "request", + lambda r: requests.append(r.url) if "/dashboard/update" in r.url else None, + ) + + _switch_to_dark(page) + page.evaluate("() => window.fvApplyTheme()") + page.wait_for_timeout(1_000) + + assert requests == [] + assert page.evaluate(read) == before + # Group colors keep their hex: group_domains stores it. The zoom on + # figure 0 leaves only group A in view. + assert before["colors"] == [_OKABE_ITO[:1], _OKABE_ITO[:2]] + assert page.inner_text("#fv-btn-mode") == "Mode: Dark" + assert page.evaluate("() => localStorage.getItem('fv-mode')") == "dark" + + def test_update_layout_wins_over_the_theme(self, page: Page, server_port: int): + df = pl.DataFrame({"x": [float(i) for i in range(50)], "y": [1.0] * 50}) + url = _color_norm_url( + server_port, + "_browser_theme_layout", + df, + lambda d: ( + d.add_figure() + .add_line(x="x", y="y") + .update_layout(plot_bgcolor="#123456", font={"color": "#654321"}) + ), + ) + page.emulate_media(color_scheme="dark") + page.goto(url) + _wait_for_init(page, "plotly") + + layout = page.evaluate("""() => { + const l = divs[0]._fullLayout; + return [l.plot_bgcolor, l.paper_bgcolor, l.font.color]; + }""") + assert layout == ["#123456", "#0b0e12", "#654321"] diff --git a/tests/test_html_adapters.py b/tests/test_html_adapters.py index e8c6aca..3293f33 100644 --- a/tests/test_html_adapters.py +++ b/tests/test_html_adapters.py @@ -10,6 +10,7 @@ from __future__ import annotations +import re from html.parser import HTMLParser import pytest @@ -1057,6 +1058,36 @@ def test_plotly_toolbar_css_uses_var_references(self, plotly_html): assert "var(--fv-border)" in plotly_html assert "var(--fv-text)" in plotly_html + def test_one_dark_block_serves_the_system_and_the_mode_button(self): + from flexviz.adapters.runtime import theme_css + + css = theme_css() + assert ( + len(re.findall(r'^:root\[data-fv-mode="dark"\] \{', css, re.MULTILINE)) == 1 + ) + assert "@media (prefers-color-scheme" not in css + + def test_series_palette_is_the_same_in_both_modes(self): + # state.group_domains stores each group's hex, so the palette must not + # change with the mode. + from flexviz.adapters.runtime import theme_css + + light, dark = theme_css().split('\n:root[data-fv-mode="dark"]') + okabe_ito = ( + "#0072b2, #e69f00, #009e73, #cc79a7, #56b4e9, #d55e00, #9a8700, #8a8a8a" + ) + assert f"--fv-series: {okabe_ito};" in light + assert "--fv-series" not in dark + + def test_page_head_sets_the_mode_before_the_styles(self, plotly_html): + head = plotly_html.split("")[0] + assert "root.dataset.fvMode = mode" in head + assert head.index("root.dataset.fvMode") < head.index(" diff --git a/tests/test_browser.py b/tests/test_browser.py index 4e23c77..705acaf 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -8082,3 +8082,32 @@ def test_update_layout_wins_over_the_theme(self, page: Page, server_port: int): return [l.plot_bgcolor, l.paper_bgcolor, l.font.color]; }""") assert layout == ["#123456", "#0b0e12", "#654321"] + + def test_map_tiles_follow_the_mode(self, page: Page, server_port: int): + def build(dash): + for style in (None, "white-bg"): + fig = dash.add_figure().add_geo_histogram2d( + lat="lat", lon="lon", lat_bins=4, lon_bins=4 + ) + if style: + fig.update_layout(map={"style": style}) + + page.emulate_media(color_scheme="light") + page.goto( + _color_norm_url(server_port, "_browser_theme_map", _geo_browser_df(), build) + ) + _wait_for_init(page, "plotly") + read_styles = """() => divs.map(gd => { + const style = gd._fullLayout.map.style; + return typeof style === 'string' ? style : style.id; + })""" + assert page.evaluate(read_styles) == ["open-street-map", "white-bg"] + + _switch_to_dark(page) + assert page.evaluate(read_styles) == ["fv-osm-dark", "white-bg"] + # The darkened OSM tiles stay under the cells and keep their attribution. + page.wait_for_function("""() => { + const layers = divs[0]._fullLayout.map._subplot.map.getStyle().layers; + return layers.length > 1 && 'raster-saturation' in (layers[0].paint || {}); + }""") + assert "OpenStreetMap" in page.inner_text("#fv-plot-0 .maplibregl-ctrl-attrib") From d00aaa2ca8e1a8a1e84e114d6d300be87892c94d Mon Sep 17 00:00:00 2001 From: Jeroen Van Der Donckt Date: Wed, 30 Sep 2026 11:39:41 +0200 Subject: [PATCH 03/20] feat(adapters): lift the low end of Viridis in dark mode --- flexviz/adapters/js/plotly/theme.js | 12 ++++++++++++ flexviz/adapters/js/plotly/traces.js | 1 + tests/test_browser.py | 26 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/flexviz/adapters/js/plotly/theme.js b/flexviz/adapters/js/plotly/theme.js index f7c08b2..0885e93 100644 --- a/flexviz/adapters/js/plotly/theme.js +++ b/flexviz/adapters/js/plotly/theme.js @@ -43,6 +43,18 @@ const _FV_DARK_MAP_STYLE = { glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf', }; +// Viridis without its three darkest stops, so it starts at #424086: the +// darkest stops almost vanish on a dark plot. The spec stores only the scale +// name, so this also applies to a Viridis that the user picked by name. +const _FV_DARK_VIRIDIS = [ + '#424086', '#3b528b', '#33638d', '#2c728e', '#26828e', '#21918c', '#1fa088', + '#28ae80', '#3fbc73', '#5ec962', '#84d44b', '#addc30', '#d8e219', '#fde725', +].map((color, i, stops) => [i / (stops.length - 1), color]); + +function fvThemeColorScale(colorscale) { + return colorscale === 'Viridis' && fvIsDarkMode() ? _FV_DARK_VIRIDIS : colorscale; +} + function fvPlotlyTemplate() { const text = fvThemeToken('--fv-plot-text'); const tick = fvThemeToken('--fv-plot-tick'); diff --git a/flexviz/adapters/js/plotly/traces.js b/flexviz/adapters/js/plotly/traces.js index 859b385..8ef4b29 100644 --- a/flexviz/adapters/js/plotly/traces.js +++ b/flexviz/adapters/js/plotly/traces.js @@ -453,6 +453,7 @@ function buildTraceFromTemplate(template, logicalUid, renderLayer, updates, opac trace.x = gapped.x; trace.y = gapped.y; } + if (trace.colorscale) trace.colorscale = fvThemeColorScale(trace.colorscale); if (traceSpecByUid[logicalUid]?.display.color_norm === 'log') applyLogColorNorm(trace); if (trace.type === 'choroplethmap') applyChoroplethLayerOpacity(trace); applyHeatmapColorbarPolicy(trace, renderLayer, showForeground); diff --git a/tests/test_browser.py b/tests/test_browser.py index 705acaf..36d540b 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -8111,3 +8111,29 @@ def build(dash): return layers.length > 1 && 'raster-saturation' in (layers[0].paint || {}); }""") assert "OpenStreetMap" in page.inner_text("#fv-plot-0 .maplibregl-ctrl-attrib") + + def test_dark_mode_lifts_the_low_end_of_viridis(self, page: Page, server_port: int): + page.emulate_media(color_scheme="dark") + page.goto(_dashboard_url_hist2d(server_port, "plotly")) + _wait_for_init(page, "plotly") + read = """() => ({ + scale: divs[0]._fullData[0].colorscale, + bar: (() => { + const fill = divs[0].querySelector('.cbfill').style.fill; + const id = fill.match(/#([^"')]+)/)[1]; + return [...document.getElementById(id).querySelectorAll('stop')] + .map(stop => stop.getAttribute('stop-color')); + })(), + })""" + + dark = page.evaluate(read) + assert dark["scale"][0] == [0, "#424086"] + assert dark["scale"][-1] == [1, "#fde725"] + # The colorbar draws the same scale as the cells. + assert "rgb(66, 64, 134)" in dark["bar"] + assert "rgb(68, 1, 84)" not in dark["bar"] + + page.click("#fv-btn-mode") # Auto (dark system) -> Light + page.wait_for_function( + "() => divs[0]._fullData[0].colorscale[0][1] === '#440154'" + ) From 662aaabc7eabad2c4ef70510f4b57023f0592906 Mon Sep 17 00:00:00 2001 From: Jeroen Van Der Donckt Date: Wed, 30 Sep 2026 11:39:41 +0200 Subject: [PATCH 04/20] fix(adapters): show series hover labels in neutral colors with a series border --- flexviz/adapters/js/plotly/theme.js | 7 +++++++ flexviz/adapters/js/plotly/traces.js | 4 ++++ tests/test_browser.py | 12 ++++++++++++ 3 files changed, 23 insertions(+) diff --git a/flexviz/adapters/js/plotly/theme.js b/flexviz/adapters/js/plotly/theme.js index 0885e93..8509d91 100644 --- a/flexviz/adapters/js/plotly/theme.js +++ b/flexviz/adapters/js/plotly/theme.js @@ -81,6 +81,13 @@ function fvPlotlyTemplate() { xaxis: { ...axis, showline: true }, yaxis: axis, legend: { font: { size: 12, color: text }, bgcolor: 'rgba(0,0,0,0)' }, + // One neutral label for every trace. buildTraceFromTemplate frames the + // label of a series in its color. + hoverlabel: { + bgcolor: fvThemeToken('--fv-plot-tooltip-bg'), + bordercolor: fvThemeToken('--fv-plot-tooltip-border'), + font: { family, size: 12, color: fvThemeToken('--fv-plot-tooltip-text') }, + }, newselection: { line: { color: fvThemeToken('--fv-plot-select'), width: 1.5 } }, map: { style: fvIsDarkMode() ? _FV_DARK_MAP_STYLE : 'open-street-map' }, }, diff --git a/flexviz/adapters/js/plotly/traces.js b/flexviz/adapters/js/plotly/traces.js index 8ef4b29..596114f 100644 --- a/flexviz/adapters/js/plotly/traces.js +++ b/flexviz/adapters/js/plotly/traces.js @@ -453,6 +453,10 @@ function buildTraceFromTemplate(template, logicalUid, renderLayer, updates, opac trace.x = gapped.x; trace.y = gapped.y; } + // The template's neutral hover label, framed in the series color: text on + // the series color itself reads at about 3:1. + const seriesColor = !isHeatmapScaledTrace(trace) && (trace.line?.color || trace.marker?.color); + if (seriesColor) trace.hoverlabel = { ...trace.hoverlabel, bordercolor: seriesColor }; if (trace.colorscale) trace.colorscale = fvThemeColorScale(trace.colorscale); if (traceSpecByUid[logicalUid]?.display.color_norm === 'log') applyLogColorNorm(trace); if (trace.type === 'choroplethmap') applyChoroplethLayerOpacity(trace); diff --git a/tests/test_browser.py b/tests/test_browser.py index 36d540b..6f723cf 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -8083,6 +8083,18 @@ def test_update_layout_wins_over_the_theme(self, page: Page, server_port: int): }""") assert layout == ["#123456", "#0b0e12", "#654321"] + def test_series_hover_label_is_neutral_with_a_series_border( + self, page: Page, server_port: int + ): + page.emulate_media(color_scheme="light") + page.goto(_dashboard_url_grouped(server_port, "plotly", "line")) + _wait_for_init(page, "plotly") + + labels = page.evaluate("""() => divs[0]._fullData.map(t => [ + t.hoverlabel.bgcolor, t.hoverlabel.font.color, t.hoverlabel.bordercolor, + ])""") + assert labels == [["#0f172a", "#f8fafc", color] for color in _OKABE_ITO[:2]] + def test_map_tiles_follow_the_mode(self, page: Page, server_port: int): def build(dash): for style in (None, "white-bg"): From 72fde4bfa80fc0fe9f23b607322b815504277e7a Mon Sep 17 00:00:00 2001 From: Jeroen Van Der Donckt Date: Wed, 30 Sep 2026 11:39:41 +0200 Subject: [PATCH 05/20] fix(adapters): keep legend swatches opaque in overlay mode --- flexviz/adapters/plotly_adapter.py | 2 ++ tests/test_browser.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/flexviz/adapters/plotly_adapter.py b/flexviz/adapters/plotly_adapter.py index d80389e..b0ee511 100644 --- a/flexviz/adapters/plotly_adapter.py +++ b/flexviz/adapters/plotly_adapter.py @@ -428,6 +428,8 @@ def _build_dashboard_html(