Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
552db46
Widen the mark CSS subset: stroke-linecap and marker-shape
Alek99 Jul 26, 2026
f996387
Publish and pin which styling survives which export path
Alek99 Jul 26, 2026
497e28d
Mark plugins: contribute a chart kind without forking (§24 v0)
Alek99 Jul 26, 2026
225a15c
A capability registry that proves itself
Alek99 Jul 26, 2026
ad51a26
Commit the customization comparison, losses included
Alek99 Jul 26, 2026
4ea855e
Turn Plotly compat into a number, and correct §24's two wrong premises
Alek99 Jul 26, 2026
1f3ccf6
Fix the retrieval path, and enforce the claim ladder
Alek99 Jul 26, 2026
5d8e9c4
Derive the axis-key count in the registry rather than in prose
Alek99 Jul 26, 2026
f3559ae
Merge branch 'claude/customization-capability-matrix-vckjxg-4-capabil…
Alek99 Jul 26, 2026
fad4989
Pin every count the comparison document states
Alek99 Jul 26, 2026
9a59924
Merge branch 'claude/customization-capability-matrix-vckjxg-5-compari…
Alek99 Jul 26, 2026
4735bbb
Merge branch 'claude/customization-capability-matrix-vckjxg-6-plotly-…
Alek99 Jul 26, 2026
ee6c94c
Show the cap fix rather than describe it
Alek99 Jul 26, 2026
4894e27
Fix three CI failures the sandbox could not reach
Alek99 Jul 26, 2026
16bbfe0
Address the review: one real coverage bug, several sharp edges, one r…
Alek99 Jul 26, 2026
8c393f0
Narrow to the product change: capabilities plus an honest support inv…
Alek99 Jul 26, 2026
d27f979
Revert the CHANGELOG edits
Alek99 Jul 26, 2026
8b95435
Restore CLAUDE.md to its state on main
Alek99 Jul 26, 2026
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ methodology, and raw results.

Customize marks and chart chrome with Python, CSS, or Tailwind. See the [styling guide](docs/styling/index.md).

What each mechanism reaches — per property, per chrome slot, per renderer, and
where it stops — is the [capability matrix](spec/api/capability-matrix.md),
generated from `python/xy/styling/capabilities.py` and checked against the
implementation.

```python
chart = xy.line_chart(
xy.line(x, y, color="#7c3aed", width=3),
Expand Down
108 changes: 108 additions & 0 deletions docs/advanced/custom-marks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
---
title: Custom Marks
description: Add a chart kind XY does not ship by composing its built-in marks, without forking the renderer.
---

# Custom Marks

XY ships twenty mark kinds. When you need one it does not have — a candlestick,
a high-low band, a ribbon, a dumbbell — you can register it instead of waiting
for it or forking the renderer.

A mark plugin is two functions and a name:

- **`calc`** turns your input columns into the columns you want to draw. It runs
once, on arrays, before anything is built.
- **`build`** returns ordinary XY marks. Not shaders, not draw calls — the same
`xy.segments(...)`, `xy.scatter(...)`, `xy.line(...)` you would write by hand.

That second constraint is the point rather than a limitation. Because a plugin's
output is ordinary traces, it reuses the built-in rendering, picking, and export
paths — including native PNG and SVG, which have no browser — rather than
reimplementing them.

## A worked example

~~~python
import numpy as np
import xy


def _calc(columns):
"""Columns in, columns out. Add whatever `build` needs to draw."""
return {**columns, "mid": (columns["low"] + columns["high"]) / 2.0}


def _build(ctx):
"""Return built-in marks. `ctx.columns` is `_calc`'s output."""
return [
xy.segments(
x0=ctx.columns["t"],
x1=ctx.columns["t"],
y0=ctx.columns["low"],
y1=ctx.columns["high"],
name=ctx.name,
style=ctx.style,
),
xy.scatter(
x=ctx.columns["t"],
y=ctx.columns["mid"],
size=ctx.options.get("mid_size", 6),
),
]


xy.register_mark(
xy.MarkPlugin(
name="hilo",
columns=("t", "low", "high"),
calc=_calc,
build=_build,
doc="A high-low band with a midpoint marker.",
)
)
~~~

Use it like any other mark:

~~~python
chart = xy.chart(
xy.mark("hilo", t="day", low="low", high="high", data=frame, name="Range"),
xy.y_axis(label="price"),
)
~~~

Fields you named in `columns` behave exactly like a built-in mark's `x` and `y`:
a string names a column in `data=`, anything else is used as values directly,
and they reach `calc` as arrays. Every other keyword — `mid_size` above — arrives
in `ctx.options` untouched.

## What a plugin can and cannot do

| Can | Cannot |
| --- | --- |
| Compute new columns from its inputs | Reach the `Figure`, the trace list, or the column store |
| Emit any number of built-in marks | Emit another plugin's mark (composition is one level deep) |
| Read the caller's `style`, `name`, and options | Ship its own GLSL or WGSL |
| Draw on a named axis via `xy.mark(..., y_axis="y2")` | Add a new GPU primitive |

The last two are the real boundary, and it is deliberate rather than temporary
scaffolding. A plugin that composes built-in marks cannot draw anything the
engine could not already draw, which is what lets it reuse the engine's
existing paths. A plugin carrying its own shader would reuse none of them and
would have to reimplement decimation, picking, and three export paths itself.
See [§24 of the design dossier](https://github.com/reflex-dev/xy/blob/main/spec/design-dossier.md).

## Registry rules

`register_mark` refuses two things outright, both because the alternative is a
bug someone debugs at runtime:

- **Shadowing a built-in.** `xy.register_mark(MarkPlugin(name="scatter", ...))`
raises. A plugin cannot change what `xy.scatter` means.
- **Silently replacing another plugin.** Two libraries registering
`"candlestick"` is a conflict their user needs to see, not a race that import
order settles. Pass `replace=True` when that is genuinely what you want.

`xy.registered_marks()` lists what is contributed from outside;
`xy.unregister_mark(name)` removes one, which is mostly useful in tests.
24 changes: 22 additions & 2 deletions docs/api-reference/limitations-and-alpha-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,33 @@ and [Benchmarks](/docs/xy/overview/benchmarks/) for scoped evidence.

## Styling and Export Boundaries

The per-renderer inventory of what can be styled, and how far each mechanism
travels, is the [Capability Matrix](/docs/xy/styling/capabilities/) — generated
from `python/xy/styling/capabilities.py` and checked against the
implementation. The bullets below are the boundaries that page's rows imply.

- Browser chrome accepts CSS and Tailwind classes through stable DOM slots.
WebGL/native marks accept a validated CSS subset through `style=`; arbitrary
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
2 changes: 1 addition & 1 deletion docs/app/tests/test_docs_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,7 @@ def test_what_is_xy_restores_the_sdf_hero_and_ends_with_a_short_pitch() -> None:
content = (DOCS_ROOT / "index.md").read_text(encoding="utf-8")

heading = content.index("# What is `xy`?")
styling = content.index("**Completely customizable.**")
styling = content.index("**Styled by your CSS, not ours.**")
hero = content.index("~~~python demo-only exec")
early_alpha = content.index("**Early alpha.**")
start_here = content.index("## Start here")
Expand Down
2 changes: 2 additions & 0 deletions docs/app/xy_docs/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
("Customize Each Part", "/styling/customize/"),
("Animations", "/styling/animations/"),
("Themes and Export", "/styling/themes-and-tokens/"),
("Capability Matrix", "/styling/capabilities/"),
("Advanced Styling Gallery", "/styling/gallery/"),
),
),
Expand Down Expand Up @@ -132,6 +133,7 @@
"network",
(
("XY Architecture", "/advanced/"),
("Custom Marks", "/advanced/custom-marks/"),
(
"Runtime and Deployment",
"/advanced/runtime-and-deployment/",
Expand Down
13 changes: 8 additions & 5 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ description: XY is a fast Python charting library for interactive data visualiza
# What is `xy`?

XY is a Python charting library for interactive 2D visualizations that stay
smooth at millions of points and is completely customizable. Two ideas shape
the library:
smooth at millions of points and take your design system seriously. Two ideas
shape the library:

- **Fast, even with lots of data.** XY draws the detail you can actually see
instead of every row at once, so pan, zoom, and hover stay responsive as
your data grows.
- **Completely customizable.** Style titles, axes, legends, tooltips, and controls
with CSS or Tailwind, and keep the same look in interactive charts, SVGs, and
PNGs.
- **Styled by your CSS, not ours.** Titles, axes, legends, tooltips, and controls
are addressable with plain CSS or Tailwind through 23 stable slots, and your
design tokens reach the marks themselves — in the browser, in SVG, and in
native PNG. What each mechanism reaches is published per renderer in the
[Capability Matrix](/docs/xy/styling/capabilities/), including where it
stops.


~~~python demo-only exec
Expand Down
106 changes: 106 additions & 0 deletions docs/styling/capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
title: Capability Matrix
description: What XY can be styled and extended with, per renderer, generated from the capability registry.
---

# Capability Matrix

<!-- generated by scripts/gen_capability_matrix.py --write; do not edit -->

Every styling question about XY has the same two halves: *can I change this*,
and *does the change survive where I need it*. This page answers both from the
registry the implementation is checked against.

- **10** mark style properties across **20** mark kinds, drawn by all three renderers.
- **23** stable chrome slots for CSS and Tailwind in the browser.
- **1** way to add a mark kind XY does not ship, without forking it.

## Mark style properties

What a mark's `style=` accepts. Anything outside this raises while the chart is
built — one renderer never silently ignores what another draws.

| property | vocabulary | mark kinds | webgl | svg | native | status |
|---|---|---|---|---|---|---|
| `opacity` | css | `area`, `bar`, `box`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `heatmap`, `hexbin`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh`, `violin` | full | full | full | shipped |
| `fill` | svg | `area`, `bar`, `box`, `column`, `error_band`, `hist`, `histogram`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped |
| `fill-opacity` | svg | `area`, `bar`, `box`, `column`, `error_band`, `heatmap`, `hexbin`, `hist`, `histogram`, `scatter`, `triangle_mesh`, `violin` | full | full | full | shipped |
| `stroke` | svg | `area`, `bar`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped |
| `stroke-opacity` | svg | `area`, `bar`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped |
| `stroke-width` | svg | `area`, `bar`, `column`, `contour`, `ecdf`, `error_band`, `errorbar`, `hist`, `histogram`, `line`, `scatter`, `segments`, `stairs`, `stem`, `step`, `triangle_mesh` | full | full | full | shipped |
| `stroke-dasharray` | svg | `area`, `ecdf`, `line`, `stairs`, `step` | full | full | full | shipped |
| `stroke-linecap` | svg | `ecdf`, `line`, `stairs`, `step` | full | full | full | shipped |
| `border-radius` | css | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped |
| `marker-shape` | xy | `scatter` | full | full | full | shipped |

### Notes

- **`opacity`** — Multiplies the mark's own alpha in every renderer.
- **`fill`** — A plain color compiles to the mark's paint; a `linear-gradient(...)` compiles to a gradient and is accepted only by area and rect kinds, which are the ones with a gradient program.
- **`fill-opacity`** — Independent of `opacity`; the two multiply.
- **`stroke`** — The paint for line-like geometry, and the border for filled marks.
- **`stroke-width`** — CSS px; a bare number is px, matching the chrome style convention.
- **`stroke-dasharray`** — 2-8 positive px lengths, or `none`. The WebGL client tracks arc length on the CPU so dashes stay continuous across segments and constant on screen through zoom.
- **`stroke-linecap`** — Line family only — a cap is open-path geometry. XY's default is `round`, not CSS's `butt`, because the native rasterizer has always drawn round and is the reference for static export. Verified per renderer: a Rust coverage test, a rasterized-ink test, and three Chromium screenshots that hash differently per cap.
- **`border-radius`** — Rect kinds only. `corner_radius=(tip, base)` rounds the two ends separately.
- **`marker-shape`** — 17 shapes, drawn as analytic signed-distance fields in all three renderers. An XY vocabulary name: CSS has no shape keyword for a non-DOM point mark, and the CSS spelling and `symbol=` compile to the same value.

## Chrome slots

Stable `data-xy-slot` names that take `class_names=` and `styles=`. The native
raster and vector writers have no cascade, so per-slot styling is a browser
mechanism; put anything that must survive export in the chart-level `style=`
token bag or in mark and axis `style=`, which every renderer reads.

| slot | browser | native raster | native vector |
|---|---|---|---|
| `root` | full | partial | partial |
| `title` | full | none | none |
| `chrome` | full | none | none |
| `canvas` | full | none | none |
| `labels` | full | none | none |
| `legend` | full | partial | partial |
| `legend_item` | full | none | none |
| `legend_swatch` | full | none | none |
| `colorbar` | full | none | none |
| `colorbar_bar` | full | none | none |
| `colorbar_tick` | full | none | none |
| `colorbar_title` | full | none | none |
| `tooltip` | full | none | none |
| `modebar` | full | none | none |
| `modebar_button` | full | none | none |
| `selection` | full | none | none |
| `crosshair_x` | full | none | none |
| `crosshair_y` | full | none | none |
| `badge` | full | none | none |
| `badge_item` | full | none | none |
| `tick_label` | full | none | none |
| `axis_title` | full | none | none |
| `annotation_label` | full | none | none |

### Notes

- **`root`** (via `chart style=`) — `styles={'root': ...}` is browser-only, but the chart-level `style=` token bag targets the same element and every renderer reads it (`spec['dom']['style']`). Prefer it for anything that must survive export.
- **`legend`** (via `xy.legend(style=...)`) — Written twice: to chrome_styles for the browser and to legend_options['style'], which `_svg` and `_raster` do read — but only `background`, `boxShadow`, `borderRadius`, `--xy-legend-frame-alpha`, and `padding`/`rowGap` in `em`. The chart-level styles={'legend': ...} spelling reaches none of it, so two spellings that agree in the browser disagree in a PNG.

## Extension points

See [Custom Marks](/docs/xy/advanced/custom-marks/) for the worked example.

| extension point | status | entry point | limits |
|---|---|---|---|
| mark_plugin_composition | shipped | `xy.register_mark / xy.MarkPlugin / xy.mark` | composes built-in marks only, one level deep; cannot reach the Figure, the trace list, or the column store; cannot add a GPU primitive |
| mark_plugin_shader | planned | `—` | — |
| custom_renderer | planned | `—` | — |

## Where renderers still disagree

Differences in the defaults that no property selects. They are listed because
an undocumented difference reads as a bug; a documented one is a contract.

| what | browser | svg | native png | visible when |
|---|---|---|---|---|
| Interior vertices of a wide polyline | the notch two overlapping segment quads leave | round (the writer names it explicitly) | round (the capsule distance field fills the vertex) | stroke-width above ~4px at a sharp angle |

For what is still alpha, see
[Limitations and Alpha Status](/docs/xy/api-reference/limitations-and-alpha-status/).
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
15 changes: 14 additions & 1 deletion docs/styling/customize.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,20 @@ def customize_mark_paint_preview():
Use `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, and
`opacity` only on mark families that support them. Lines use stroke properties;
areas, points, bars, and columns also support fill properties. Bar-like marks
add `border-radius`, while line-like marks add `stroke-dasharray`.
add `border-radius`. `line`, `step`, `stairs`, and `ecdf` add
`stroke-dasharray` and `stroke-linecap`; `area` adds `stroke-dasharray` only,
because a cap is open-path geometry. `scatter` adds `marker-shape`.

`stroke-linecap` (`butt`, `round`, `square`) carries its standard SVG meaning,
and XY defaults it to `round` rather than to the CSS initial value — the native
rasterizer has always drawn round caps and it is the reference for static
export. `marker-shape` is the CSS spelling of `symbol=` and takes any of the 17
built-in marker names.

~~~python
xy.line(x, y, style={"stroke-width": "6px", "stroke-linecap": "butt"})
xy.scatter(x, y, size=12, style={"marker-shape": "diamond"})
~~~

## Axes, grid, and ticks

Expand Down
Loading
Loading