diff --git a/docs/api-reference/limitations-and-alpha-status.md b/docs/api-reference/limitations-and-alpha-status.md index 9b02faf8..e9261d6e 100644 --- a/docs/api-reference/limitations-and-alpha-status.md +++ b/docs/api-reference/limitations-and-alpha-status.md @@ -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. diff --git a/docs/styling/chrome-slots.md b/docs/styling/chrome-slots.md index 41bc16a6..bbae4f95 100644 --- a/docs/styling/chrome-slots.md +++ b/docs/styling/chrome-slots.md @@ -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 diff --git a/spec/api/export.md b/spec/api/export.md index 870bdfaa..8d7fbab3 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -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. diff --git a/tests/test_export_style_survival.py b/tests/test_export_style_survival.py new file mode 100644 index 00000000..42694fa7 --- /dev/null +++ b/tests/test_export_style_survival.py @@ -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()