diff --git a/docs/styling/themes-and-tokens.md b/docs/styling/themes-and-tokens.md index cc322b33..2b0dd8b3 100644 --- a/docs/styling/themes-and-tokens.md +++ b/docs/styling/themes-and-tokens.md @@ -296,7 +296,7 @@ move together across the whole chart. | `--chart-legend-bg` | Legend background | faint neutral fill | | `--chart-badge-bg` / `--chart-badge-text` | Reduction badges | light / dark | | `--chart-tick-label-max-width` | Maximum browser width of categorical y-axis tick labels | available edge space | -| `--chart-modebar-bg` / `--chart-modebar-active` | Toolbar and active button | light or dark translucent (follows a `.dark` root class) / neutral | +| `--chart-modebar-bg` / `--chart-modebar-active` | Toolbar and active button | light or dark translucent (follows the nearest `.light`/`.dark` ancestor) / neutral | | `--chart-selection` / `--chart-selection-fill` | Selection outline/fill | blue outline / translucent blue | | `--chart-zoom-selection` / `--chart-zoom-selection-fill` | Box-zoom outline/fill | neutral outline/fill | | `--chart-crosshair` | Crosshair lines | translucent dark | @@ -504,13 +504,17 @@ chart.to_png( ## Automatic dark mode for the toolbar The interactive toolbar (modebar) has no colored default a page can show -through, so it reads the light/dark state straight from your page: when a -`.dark` class is present on the chart root or any ancestor — the convention -Reflex (next-themes), Radix Themes, and Tailwind all set on the root `` -element — its background, border, and shadow switch to a dark palette. Icon -color already follows the inherited text color, so the toolbar stays readable in -both modes with no configuration. An explicit `.light` class (or no class at -all) keeps the light palette. +through, so it reads the light/dark state straight from your page: it follows +the **nearest** `.light` or `.dark` class among the chart root and its +ancestors — the convention Reflex (next-themes), Radix Themes, and Tailwind all +set on the root `` element. When the closest such class is `.dark`, its +background, border, and shadow switch to a dark palette; when it is `.light` +(or when there is no scheme class at all) the toolbar stays light. Because the +*nearest* class wins, nesting composes the way you would expect: a `.dark` card +dropped onto a `.light` page gets a dark toolbar, and a `.light` panel inside a +`.dark` app gets a light one — each chart matches the container it actually sits +in, not some distant ancestor. Icon color already follows the inherited text +color, so the toolbar stays readable in both modes with no configuration. That is only the built-in default. A `--chart-modebar-bg` or `--chart-modebar-active` value you set — through `theme()`, chart `style=`, or a diff --git a/js/src/20_theme.ts b/js/src/20_theme.ts index e6856626..ec4b13b4 100644 --- a/js/src/20_theme.ts +++ b/js/src/20_theme.ts @@ -75,12 +75,20 @@ export function cssColor([r, g, b, a]: any) { // color, padding, border, font, and box-shadow stay overridable here. // All colors flow through --chart-* tokens so container theming still cascades. // -// The reduction badges and modebar's own surface defaults are scheme-aware: -// they read internal --xy-* defaults that flip to a dark palette whenever a -// `.dark` class sits on the chart root or any ancestor — the class-on-root -// convention every host we target uses (Reflex/next-themes, Radix Themes, -// Tailwind). These remain zero-specificity :where() rules, so public -// --chart-badge-* / --chart-modebar-* tokens or utility classes override them. +// The reduction badges and modebar's own surface defaults are scheme-aware. +// The internal --xy-* palette is declared on the scheme-defining containers +// themselves (`.dark` -> dark; `.light`, :root, and :host -> light) rather than +// on the chart, so it rides ordinary custom-property inheritance: a chart picks +// up the value from the NEAREST ancestor carrying a scheme class and only falls +// back to the document root (or shadow host) when no such ancestor exists +// (§36 — "the variables cascade even though the pixels don't"). Because the +// nearest declaration wins, a `.light` island nested inside a `.dark` page (or a +// `.dark` card on a `.light` page) resolves to the class closest to the chart — +// where the older `.dark .xy` descendant rule darkened on ANY dark ancestor and +// ignored a nearer `.light`. `.dark`/`.light` are the class-on-root convention +// every host we target uses (Reflex/next-themes, Radix Themes, Tailwind). These +// remain zero-specificity :where() rules, so public --chart-badge-* / +// --chart-modebar-* tokens or utility classes override them. export const XY_CHROME_CSS = ` @layer base{ :where(.xy [data-xy-slot="title"]){text-align:center;font-size:14px;font-weight:600;color:var(--chart-text,inherit)} @@ -92,8 +100,8 @@ export const XY_CHROME_CSS = ` :where(.xy [data-xy-slot="colorbar_title"]){font-weight:500} :where(.xy [data-xy-slot="badge"]){gap:3px;font-size:11px;line-height:1.2} :where(.xy [data-xy-slot="badge_item"]){padding:3px 6px;border-radius:4px;color:var(--chart-badge-text,var(--xy-badge-text));background:var(--chart-badge-bg,var(--xy-badge-bg));box-shadow:var(--xy-badge-shadow)} -:where(.xy){--xy-badge-text:#0f172a;--xy-badge-bg:rgba(255,255,255,.82);--xy-badge-shadow:0 1px 4px rgba(15,23,42,.14);--xy-modebar-bg:rgba(255,255,255,.78);--xy-modebar-menu-bg:rgba(255,255,255,.94);--xy-modebar-border:rgba(128,128,128,.18);--xy-modebar-menu-border:rgba(128,128,128,.22);--xy-modebar-active:rgba(128,128,128,.2);--xy-modebar-shadow:0 1px 4px rgba(0,0,0,.08);--xy-modebar-menu-shadow:0 5px 18px rgba(15,23,42,.18)} -:where(.dark .xy,.xy.dark){--xy-badge-text:#f8fafc;--xy-badge-bg:rgba(30,35,44,.88);--xy-badge-shadow:0 1px 4px rgba(0,0,0,.5);--xy-modebar-bg:rgba(37,42,52,.9);--xy-modebar-menu-bg:rgba(30,35,44,.97);--xy-modebar-border:rgba(255,255,255,.14);--xy-modebar-menu-border:rgba(255,255,255,.16);--xy-modebar-active:rgba(255,255,255,.16);--xy-modebar-shadow:0 1px 4px rgba(0,0,0,.5);--xy-modebar-menu-shadow:0 8px 24px rgba(0,0,0,.6)} +:where(:root,:host,.light){--xy-badge-text:#0f172a;--xy-badge-bg:rgba(255,255,255,.82);--xy-badge-shadow:0 1px 4px rgba(15,23,42,.14);--xy-modebar-bg:rgba(255,255,255,.78);--xy-modebar-menu-bg:rgba(255,255,255,.94);--xy-modebar-border:rgba(128,128,128,.18);--xy-modebar-menu-border:rgba(128,128,128,.22);--xy-modebar-active:rgba(128,128,128,.2);--xy-modebar-shadow:0 1px 4px rgba(0,0,0,.08);--xy-modebar-menu-shadow:0 5px 18px rgba(15,23,42,.18)} +:where(.dark){--xy-badge-text:#f8fafc;--xy-badge-bg:rgba(30,35,44,.88);--xy-badge-shadow:0 1px 4px rgba(0,0,0,.5);--xy-modebar-bg:rgba(37,42,52,.9);--xy-modebar-menu-bg:rgba(30,35,44,.97);--xy-modebar-border:rgba(255,255,255,.14);--xy-modebar-menu-border:rgba(255,255,255,.16);--xy-modebar-active:rgba(255,255,255,.16);--xy-modebar-shadow:0 1px 4px rgba(0,0,0,.5);--xy-modebar-menu-shadow:0 8px 24px rgba(0,0,0,.6)} :where(.xy [data-xy-slot="modebar"]){gap:1px;background:var(--chart-modebar-bg,var(--xy-modebar-bg));border:1px solid var(--xy-modebar-border);border-radius:4px;padding:1px;box-shadow:var(--xy-modebar-shadow)} :where(.xy [data-xy-slot="modebar_button"]){width:24px;height:24px;padding:0;border:none;background:transparent;border-radius:3px;color:var(--chart-text,currentColor);cursor:pointer} :where(.xy [data-xy-modebar-drag-handle]){position:relative;width:22px;margin-right:4px;cursor:move} diff --git a/spec/api/styling.md b/spec/api/styling.md index 2b5014a1..1955b28c 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -292,14 +292,20 @@ Set them on `.xy` or any ancestor: | `--chart-cursor` / `--chart-cursor-pan` | Plot cursor (box-zoom / pan) | `crosshair` / `grab` | | `--chart-focus` | Keyboard focus ring on the plot canvas and modebar buttons | `#2563eb` | -The modebar and badge defaults are **scheme-aware**: a `.dark` class on the -chart root or any ancestor flips the internal fallbacks — modebar to -`rgba(37,42,52,.9)` / `rgba(255,255,255,.16)`, badges to `rgba(30,35,44,.88)` -bg / `#f8fafc` text. The public `--chart-modebar-*` and `--chart-badge-*` -tokens override both schemes; the modebar's border and shadow and the badge's -shadow have no public token and are internal `--xy-modebar-*` / -`--xy-badge-shadow` defaults only. `--chart-focus` is likewise not carried into -client-side PNG/SVG export, which snapshots the other `--chart-*` tokens. +The modebar and badge defaults are **scheme-aware**, resolved from the +**nearest** `.light`/`.dark` ancestor — not merely "any `.dark` ancestor". The +internal `--xy-*` palette is declared on the scheme-defining containers +themselves (`.dark` → dark; `.light`, the document root, and a shadow host → +light) and inherited by the chart, so the class closest to the chart wins. A +`.dark` card on a `.light` page therefore paints a dark modebar (bg +`rgba(37,42,52,.9)`, active `rgba(255,255,255,.16)`, badges `rgba(30,35,44,.88)` +bg / `#f8fafc` text); a `.light` island nested inside a `.dark` page paints a +light one; with no scheme class on any ancestor the light palette is the +fallback. The public `--chart-modebar-*` and `--chart-badge-*` tokens override +both schemes; the modebar's border and shadow and the badge's shadow have no +public token and are internal `--xy-modebar-*` / `--xy-badge-shadow` defaults +only. `--chart-focus` is likewise not carried into client-side PNG/SVG export, +which snapshots the other `--chart-*` tokens. The **figure background** (matplotlib's `figure.facecolor` — the whole card including margins, title, and tick labels) is not a token: `theme(background=)` diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index d996a2ba..becc4c13 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -114,7 +114,11 @@ def test_normative_styling_spec_tracks_responsive_theme_tokens() -> None: styling = _read(ROOT / "spec/api/styling.md") assert "--chart-tick-label-max-width" in styling normalized_styling = " ".join(styling.split()) - assert "badges to `rgba(30,35,44,.88)` bg / `#f8fafc` text" in normalized_styling + assert "badges `rgba(30,35,44,.88)` bg / `#f8fafc` text" in normalized_styling + assert "bg `rgba(37,42,52,.9)`" in normalized_styling + # The scheme palette resolves from the *nearest* light/dark ancestor, not + # merely any dark ancestor — keep the spec asserting that contract. + assert "**nearest** `.light`/`.dark` ancestor" in normalized_styling def test_client_user_text_surfaces_use_text_nodes_not_html() -> None: diff --git a/tests/test_ui_issue_regressions.py b/tests/test_ui_issue_regressions.py index d987d665..805716d2 100644 --- a/tests/test_ui_issue_regressions.py +++ b/tests/test_ui_issue_regressions.py @@ -72,6 +72,51 @@ def test_density_badges_follow_dark_theme(tmp_path: Path) -> None: assert result["background"] == "rgba(30, 35, 44, 0.88)", result +def test_modebar_scheme_follows_nearest_container_class(tmp_path: Path) -> None: + """The toolbar palette tracks the closest `.light`/`.dark` ancestor. + + A `.dark` card on a `.light` page must render a dark toolbar, and a `.light` + island inside a `.dark` page must render a light toolbar — the nearest class + wins, not merely "any `.dark` ancestor" (the old descendant-selector bug). + With no scheme class anywhere, the toolbar falls back to the light palette. + """ + chart = xy.line_chart(xy.line([0, 1], [0, 1]), width=320, height=220) + script = ( + _PRELUDE + + """ + const modebarBg = (outerClass, innerClass) => { + const outer = document.createElement("div"); + if (outerClass) outer.className = outerClass; + const inner = document.createElement("div"); + if (innerClass) inner.className = innerClass; + document.body.appendChild(outer); + outer.appendChild(inner); + inner.appendChild(view.root); + const bar = document.createElement("div"); + view._applySlot(bar, "modebar"); + view.root.appendChild(bar); + const bg = getComputedStyle(bar).backgroundColor; + bar.remove(); + outer.remove(); + return bg; + }; + document.body.setAttribute("data-xy-issue-probe", JSON.stringify({ + darkCardOnLightPage: modebarBg("light", "dark"), + lightIslandInDarkPage: modebarBg("dark", "light"), + noSchemeClass: modebarBg("", ""), + })); +""" + + _POSTLUDE + ) + result = _probe(chart, script, tmp_path, "modebar nested scheme") + + dark_bg = "rgba(37, 42, 52, 0.9)" + light_bg = "rgba(255, 255, 255, 0.78)" + assert result["darkCardOnLightPage"] == dark_bg, result + assert result["lightIslandInDarkPage"] == light_bg, result + assert result["noSchemeClass"] == light_bg, result + + def test_narrow_annotation_labels_stay_inside_and_do_not_collide(tmp_path: Path) -> None: chart = xy.line_chart( xy.line([0, 25, 50, 75, 100], [0.1, 0.3, 0.55, 0.8, 1.0]),