Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 12 additions & 8 deletions docs/styling/themes-and-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 `<html>`
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 `<html>` 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
Expand Down
24 changes: 16 additions & 8 deletions js/src/20_theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand All @@ -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)}
Comment on lines +103 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Shadow Host Forces Light Palette

When a chart is mounted in a shadow root, ensureChromeStylesheet() inserts these rules inside that root. There, :host always sets the light palette, while .dark cannot match a dark host or an ancestor outside the shadow tree, so charts under a dark page render a light modebar and badges instead of following the nearest scheme.

: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}
Expand Down
22 changes: 14 additions & 8 deletions spec/api/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=)`
Expand Down
6 changes: 5 additions & 1 deletion tests/test_static_client_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions tests/test_ui_issue_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
Loading