Skip to content
Closed
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
19 changes: 17 additions & 2 deletions docs/api-reference/limitations-and-alpha-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,23 @@ and [Benchmarks](/docs/xy/overview/benchmarks/) for scoped evidence.
selectors do not paint mark geometry.
- “Your styles win” applies to themeable browser chrome defaults, not every
structural layout rule, mark renderer, annotation shape, or native export.
- Native PNG cannot apply author `custom_css`. Use renderable chart/mark styles
or `Engine.chromium` for browser CSS fidelity.
- **Styling does not survive every export path equally, and the boundary is
published rather than left to be discovered.** Mark, axis, and chart-level
`style=` reach all three renderers. Per-slot `styles={slot: {...}}` and
`class_names={slot: "..."}` are **browser-only and are dropped, silently, by
the native raster, SVG, and PDF writers** — raising instead would break every
native export of a chart that carries Tailwind classes for its live view, so
the behavior is contracted and tested instead. `xy.legend(style=...)` is the
one slot with a partial native channel (six keys); `xy.colorbar(style=...)`
has none. The full matrix is
[Static export §9](https://github.com/reflex-dev/xy/blob/main/spec/api/export.md),
pinned by `tests/test_export_style_survival.py`.
- Native PNG cannot apply author `custom_css`, and neither can native SVG, PDF,
JPEG, or WebP. Unlike the per-slot case this one *raises* rather than
dropping: an author stylesheet has no honest partial application. Use
`Engine.chromium` for browser CSS fidelity — except for SVG, which rejects
`custom_css` under every engine because a browser screenshot cannot produce
vector output.
- Declarative `colorbar()` derives built-in chrome from supported continuous
marks. It intentionally omits constant/categorical color, truecolor grids,
and density scatter after that tier drops per-row color values.
Expand Down
27 changes: 27 additions & 0 deletions docs/styling/chrome-slots.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,33 @@ XY rejects strings that could break out of that style element. The same option
works for Chromium PNG capture; native PNG has no browser cascade and rejects
`custom_css`.

### What survives which export

Slot styling is a browser mechanism, and the native writers have no cascade to
apply it with. Rather than leave that to be discovered, it is a contract:

| You wrote | Browser (HTML, widget, Chromium capture) | Native PNG/JPEG/WebP | Native SVG/PDF |
| --- | --- | --- | --- |
| mark / axis `style=` | yes | yes | yes |
| chart-level `style=` (design tokens) | yes | yes | yes |
| `styles={slot: {...}}` | yes, all 23 slots | dropped | dropped |
| `class_names={slot: "..."}` | yes, all 23 slots | dropped | dropped |
| `custom_css=` | yes | raises | raises |
| `xy.legend(style=...)` | yes | 6 keys | 6 keys |
| `xy.colorbar(style=...)` | yes | dropped | dropped |

The two "dropped" rows are deliberate. Raising instead would break every native
export of a chart that carries Tailwind classes for its live view, which is the
normal way to use both surfaces together — so the behavior is contracted and
tested rather than enforced. `custom_css` raises because there is no honest
partial application of an author stylesheet, and the error names
`Engine.chromium` as the fix.

A chart that must look identical on screen and in a PNG should carry its design
decisions in chart-level `style=` tokens and mark/axis `style=`, which every
renderer reads, and use slot classes only for things the browser alone shows —
tooltips, the modebar, hover chrome.

## Cascade and structural layout

Built-in visual rules live in the low-priority `base` cascade layer and use
Expand Down
51 changes: 51 additions & 0 deletions spec/api/export.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,54 @@ Writes are atomic per file (same-directory temp file, fsync, `os.replace`), so a
reader never observes a partial image. Failure mid-batch is not transactional:
files already written stay on disk. The return value is the list of written
byte strings, in input order.
## 9. What styling survives which export path

XY has five styling mechanisms (`spec/api/styling.md` § The five ways to style)
and three rendering families. They do not intersect uniformly, and until this
section existed the gaps were discoverable only by exporting and looking. An
undocumented asymmetry reads as a bug; a documented one is a contract.

The families are **browser** (standalone HTML, the notebook iframe, the widget,
the Reflex adapter, and Chromium capture, which screenshots the same document),
**native raster** (`_raster.render_raster` → PNG/JPEG/WebP), and **native
vector** (`_svg.to_svg`, and `_pdf.svg_to_pdf` on top of it).

| Mechanism | Browser | Native raster | Native vector | Enforcement |
| --- | --- | --- | --- | --- |
| `style={...}` on a mark | yes | yes | yes | validated CSS subset, `styles.compile_mark_style` |
| `style={...}` on an axis | yes | yes | yes | validated vocabulary, `styles.compile_axis_style` |
| `style={...}` on the chart (token bag) | yes | yes | yes | `spec["dom"]["style"]`, read at `_svg.py:767,1481` and `_raster.py:662` |
| `styles={slot: {...}}` (per-slot inline) | yes, all 23 slots | **dropped** | **dropped** | silent — see below |
| `class_names={slot: "..."}` | yes, all 23 slots | **dropped** | **dropped** | silent — the SVG writer emits no `class` at all |
| `custom_css="..."` | yes (HTML + Chromium capture) | **raises** | **raises** | `_resolve_image_engine`, `export.py:812` |
| `xy.legend(style=...)` | yes | 6 keys | 6 keys | parallel `legend_options["style"]` channel |
| `xy.colorbar(style=...)` | yes | **dropped** | **dropped** | no native channel exists |

### Why two of those rows are silent, and why that is the right default

`custom_css` raises because it is an author stylesheet: there is no honest
partial application of it, and the caller can switch to `Engine.chromium` in one
edit. The message says so. SVG rejects it for *every* engine, because a browser
screenshot cannot produce vector output — that row is a hard "never", not a
default.

`class_names` and per-slot `styles` are dropped instead, because raising would
break every native export of a chart that carries Tailwind classes for its live
view, which is the normal way to use both surfaces together. The cost of that
choice is exactly this table: the behavior has to be written down and tested,
which is what `tests/test_export_style_survival.py` does — including that the
two native writers agree with each other and not merely with this page.

### The legend's parallel channel

`xy.legend(style=...)` is written twice: into `chrome_styles["legend"]` for the
browser and into `fig.legend_options["style"]`, which the native writers do
read (`_svg.py:2827`, `_raster.py:1970`). Native honors `background`,
`boxShadow`, `borderRadius`, and `--xy-legend-frame-alpha`, plus `padding` and
`rowGap` when they carry an `em` unit. The chart-level `styles={"legend": ...}`
spelling never reaches `legend_options`, so the two spellings that look
equivalent in the browser are not equivalent in a PNG. `colorbar` has no such
channel at all.

This is an asymmetry worth closing, not just documenting; it is tracked as a
row in the capability registry rather than as prose here.
96 changes: 96 additions & 0 deletions tests/test_export_style_survival.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Which styling survives which export path is a published contract, not luck.

Browser chrome is CSS-addressable; the native SVG/raster/PDF writers have no
cascade and read a strict subset of the same spec. Both facts are fine. What is
not fine is leaving the boundary undocumented, so this module pins it: every
assertion here corresponds to a row in `spec/api/export.md` § "What styling
survives which export path" and in
`docs/api-reference/limitations-and-alpha-status.md`.
"""

from __future__ import annotations

import re

import pytest

import xy
from xy import Engine, _raster
from xy.dom import CHART_DOM_SLOTS


def _styled_chart() -> xy.Chart:
return xy.scatter_chart(
xy.scatter(x=[0.0, 1.0], y=[1.0, 2.0], name="series"),
title="title",
class_names={slot: f"cls-{slot}" for slot in CHART_DOM_SLOTS},
styles={slot: {"outline_color": "#123456"} for slot in CHART_DOM_SLOTS},
style={"--chart-bg": "#101820"},
)


def test_browser_spec_carries_every_slot_class_and_style() -> None:
# The browser client applies dom.class_names / dom.styles to all 23 slots
# (js/src/50_chartview.ts _applySlot), so the spec must carry all 23.
spec, _ = _styled_chart().figure().build_payload()
dom = spec["dom"]

assert set(dom["class_names"]) == set(CHART_DOM_SLOTS)
assert set(dom["styles"]) == set(CHART_DOM_SLOTS)


def test_native_writers_read_chart_style_and_nothing_per_slot() -> None:
# python/xy/_svg.py:767,1481 and python/xy/_raster.py:662 read
# spec["dom"]["style"] — the chart-level token bag — and never
# spec["dom"]["styles"] or spec["dom"]["class_names"].
figure = _styled_chart().figure()
svg = figure.to_svg()

assert "#101820" in svg, "chart-level style tokens must reach native output"
assert "#123456" not in svg, "per-slot styles must not reach native output"
assert "class=" not in svg, "the SVG writer emits no class attributes"
for slot in CHART_DOM_SLOTS:
assert f"cls-{slot}" not in svg


def test_legend_is_the_one_slot_with_a_parallel_native_channel() -> None:
# xy.legend(style=...) is written twice: to chrome_styles (browser) and to
# legend_options["style"], which the native writers do read. The
# chart-level styles={"legend": ...} form only reaches the browser.
through_component = xy.scatter_chart(
xy.scatter(x=[0.0, 1.0], y=[1.0, 2.0], name="series"),
xy.legend(style={"background": "#123456"}),
).figure()
assert "#123456" in through_component.to_svg()

through_slot = xy.scatter_chart(
xy.scatter(x=[0.0, 1.0], y=[1.0, 2.0], name="series"),
styles={"legend": {"background": "#123456"}},
).figure()
assert "#123456" not in through_slot.to_svg()


def test_custom_css_is_refused_by_every_native_path() -> None:
# custom_css is a browser stylesheet. Native export rejects it by name
# rather than dropping it, and SVG rejects it for every engine because no
# browser can emit vector SVG.
chart = _styled_chart()

with pytest.raises(ValueError, match=re.escape("custom_css requires engine=Engine.chromium")):
chart.to_image(format="png", engine=Engine.default, custom_css=".x{color:red}")
for engine in (Engine.auto, Engine.default, Engine.chromium):
with pytest.raises(ValueError, match=r"SVG export is native-only|custom_css requires"):
chart.to_image(format="svg", engine=engine, custom_css=".x{color:red}")


def test_native_raster_matches_the_svg_writer_on_slot_styling() -> None:
# The two native writers must agree with each other, not only with the doc:
# neither honors a per-slot style, so the styled and unstyled renders are
# pixel-identical.
styled = _raster.render_raster(*_styled_chart().figure().build_payload(), scale=1)
plain = xy.scatter_chart(
xy.scatter(x=[0.0, 1.0], y=[1.0, 2.0], name="series"),
title="title",
style={"--chart-bg": "#101820"},
).figure()
assert (styled == _raster.render_raster(*plain.build_payload(), scale=1)).all()
Loading