Let charts wear the host's colors: custom colormaps, a chart palette, and axis switches - #298
Conversation
… axis switches XY's chrome is DOM and already reaches the CSS cascade, but the three decisions that dominate how a data-dense chart looks were closed: `colormap=` took one of twenty preset names, a categorical `color=` channel always used the built-in eight-slot palette with no override, and hiding axis chrome took seven transparent-color style properties per axis. `colormap=` now also accepts a custom ramp -- a sequence of 2-256 CSS colors, `(position, color)` pairs, or a CSS `linear-gradient(...)`. Every form resolves once, in Python, to evenly spaced 8-bit RGB stops: the exact shape `js/src/10_colormaps.ts` already stores the built-in tables in, so the WebGL client, the SVG writer, and the native rasterizer keep one LUT interpolation path between them. Positioned stops resample onto the LUT's own 256 texels, so the round trip is exact rather than approximate, and resolution is idempotent. Stops must be colors XY can resolve without a browser and must be opaque: `var()`/`oklch()`/`color-mix()` and translucent stops raise with that reason, because a colormap that painted one ramp on screen and a fallback in `to_png()` is exactly the silent divergence the dossier's no-silent-decisions rule forbids. `xy.theme(palette=[...])` sets the chart's categorical cycle -- unnamed series colors and the colors a categorical channel assigns to its categories. It lands on `Figure.palette` before any mark applies (a trace bakes its color at build) and is carried on each `ColorChannel`, so shipping, density re-binning, the legend, and the static exporters read one source. Entries follow the same literal-color rule as colormap stops, for the same reason plus a sharper one: a palette is indexed, so several browser-only entries would land on one fallback and merge distinct categories. They are normalized to hex on the wire, not merely validated -- the client's only cascade-free decode is `hexColor`, and a probe on a root that is not yet in the document returns "", which would render black permanently since a cached palette LUT is rebuilt only on GL context loss. `x_axis`/`y_axis` take `show`, `line`, `ticks`, `grid`, and `text`. They compile to the same validated axis style properties, so they need no renderer support; `show` is the default for the other four and each overrides it, an explicit `style=` still wins, and unset switches emit nothing so specs stay identical. `ticks=False` sets tick geometry rather than tick paint: every renderer resolves the tick-label color as `tick_label_color` falling back to `tick_color`, so blanking the paint would take the labels with it. Fixes found on the way: the colorbar stringified its colormap, so a custom ramp reached it as an unparseable name and silently painted viridis beside correctly painted marks; the client's categorical LUT builders decoded palette entries as hex only and would throw on anything else; the density plane, the SVG writer, and the rasterizer each resolved categorical palettes differently, one of them mapping every unresolvable entry onto a single shared fallback and merging categories, and all three now share `channels.palette_rows_rgba8`; and `_svg._lut` indexed stops through `uint8`, safe only while every colormap had <= 11 stops -- a 256-stop ramp sits exactly at that limit. Wire protocol 6 -> 7: `colormap` widened from a name to a name-or-stops and the spec gained an optional `palette`. A stale v6 client indexes its built-in table with the stop array, misses, and paints viridis without erroring -- the same silent-misrender case v6 itself was cut for.
|
Warning Review limit reached
Next review available in: 10 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (2)
📒 Files selected for processing (23)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
Merging this PR will not alter performance
Comparing Footnotes
|
…ence (#299) The §36 bullet added in #298 says palette entries "take any CSS color the paint props take, `var()` included." They do not: `components._palette_list` runs each entry through `_validate.resolved_hex_paint`, which refuses `var()`/`oklch()`/ `color-mix()` and normalizes the rest to hex. Every other surface -- `spec/api/ styling.md`, `docs/styling/customize.md`, the CHANGELOG, `theme()`'s docstring, and `test_browser_only_palette_entries_are_refused_like_colormap_stops` -- states the strict rule, so the dossier was the lone dissenter, and it is the file the next change gets read against. Both defects are the same edit's residue: the draft allowed `var()` in palettes, the final rule does not, and these two spots kept the old claim. `styling.md` also kept a fragment of the old sentence ("A palette entry is an ordinary paint color, so / Entries obey the same rule as colormap stops"). The replacement states the rule and the two reasons behind it, neither of which was in the dossier: a palette is an *indexed* lookup, so browser-only entries merge distinct categories onto one fallback rather than mispainting one mark; and entries are normalized to hex on the wire rather than merely validated, because the client's only cascade-free decode is `hexColor` and the `getComputedStyle` fallback returns "" on a root that is not yet in the document. It also records `channels.palette_rows_rgba8` as the single place a palette becomes LUT rows, since "substitute at the same index, never one shared fallback" is exactly the kind of invariant that gets re-broken once it is only in code. Docs only; no behavior change.
A chart has to wear the host application's colors. Today it mostly can — chrome
is DOM, so
data-xy-slotselectors,class_names,styles,--chart-*tokens,and
custom_cssalready reach titles, legends, tooltips, ticks, and controls.But the three decisions that actually dominate how a data-dense chart looks
were closed:
hexbin, heatmap, or contour the colormap is the chart, and no amount of CSS
reaches it. A brand ramp was simply not expressible.
color=labelshad no override at all — not per chart, not per mark.
#00000000/zero-width style properties peraxis. It appears in nearly every example in
docs/styling/, which is the tell.This closes all three.
Before — the best the current API can do: nearest of twenty preset
colormaps, the built-in categorical palette, and the seven-property axis
incantation.
After — same 1.34M points, same
custom_css, same Tailwind chrome classeson
class_names; only the three APIs below differ.colormap=takes a ramp, not just a nameThree input forms, all equivalent:
["#111a3a", "#5b21b6", "#c026d3", "#fde68a"](position, color)pairs[(0.0, "#f8fafc"), (0.2, "#38bdf8"), (1.0, "#0f172a")]"linear-gradient(#0b1220, #2563eb 30%, #fde68a)"channels.resolve_colormapnormalizes every form to evenly spaced 8-bit RGBstops — the exact shape
js/src/10_colormaps.tsalready stores the built-intables in. That is the whole design: it means the WebGL client,
_svg.py, and_raster.pykeep one LUT interpolation path between them instead of three, andcolorbars and legend gradient swatches follow the ramp for free. Uniform input
ships its own stops (4 colors → 4 stops); positioned input resamples onto the
LUT's own 256 texels, so the round trip is exact rather than approximate.
Resolution is idempotent, so a mark that validates its own
colormap=can handthe canonical form straight to
resolve_color.Ramp stops must resolve without a browser — hex,
rgb(),hsl(), or a namedcolor — and must be opaque.
var(),oklch(),color-mix(), and translucentstops raise, naming that reason. A colormap becomes a LUT in three renderers
and only one of them has a cascade, so accepting a browser-only color would mean
the screen showed one ramp and
to_png()silently showed a fallback. Alpha isrefused for the same class of reason: a LUT carries none, so
["transparent", "#00f"]would quietly become an opaque black→blue ramp. Use the mark'sopacity/fill-opacityinstead.var()stays legal oncolor=,stroke, andfill, where one color paints one mark.xy.theme(palette=[...])sets the categorical cycleOne cycle drives both unnamed series colors and the colors a categorical
color=channel assigns to its categories. It lands on
Figure.palettebefore any markapplies — a trace bakes its color at build time, so ordering is load-bearing —
and is carried on each
ColorChannelso shipping, density re-binning, the legend,and the static exporters all read one source instead of reaching for the module
default. It rides the spec as a top-level
palette, the indexed fallback thestatic exporters use. An explicit
color=on a mark still wins; a paletteshorter than the series/category count repeats with a warning, as the built-in
one already did.
Entries follow the same literal-color rule as colormap stops, plus a sharper
reason of their own: a palette is indexed, so several
var()entries would allland on one fallback and merge distinct categories into a single indistinguishable
color. Named colors,
rgb(), andhsl()are all accepted and normalized to hexon the wire — not merely validated. The client's only cascade-free decode is
hexColor; shippingtomatoverbatim sends it to agetComputedStyleprobe,which returns
""while the chart root is still detached (notebook webviewsattach asynchronously), yielding black — and permanently, because a cached palette
LUT is rebuilt only on GL context loss.
Axis visibility switches
show,line,ticks,grid, andtextcompile to the same validated axisstyle properties, so they need no renderer support and work identically in HTML,
SVG, and native PNG.
showis the default for the other four and each overridesit in both directions. An explicit
style=property still outranks a switch — aswitch is a default, not a lock. Unset switches emit no style at all, so specs
that don't use them stay byte-identical.
textis deliberately not namedlabels: that would read as a sibling oftick_labels, which supplies the label strings, and it also collides with alocal inside
x_axis().ticks=Falsesets tick geometry, not tick paint: every renderer resolves thetick-label color as
tick_label_colorfalling back totick_color, so blankingthe paint would take the labels with it — the opposite of what the switch means.
Bugs found on the way
_declarative_colorbar_optionsdid"colormap": str(colormap). A custom rampreached the colorbar as an unparseable name, so the marks painted the brand
ramp while the colorbar beside them silently painted viridis.
_paletteLut,_paletteLutDimmed,_densityCellClasses) decoded palette entries withhexColor()only and wouldthrow on any other CSS color. They now share one
_paletteRgbhelper.resolved categorical palettes differently — and one of them mapped every
unresolvable entry onto a single shared fallback, merging distinct categories
into one color. All three now share
channels.palette_rows_rgba8, whichsubstitutes per index and warns.
_svg._lutindexed colormap stops throughuint8— safe only while everycolormap had ≤ 11 stops. A resampled ramp ships 256, whose top index is exactly
255; one more would have wrapped to 0 and painted the ramp's dark end at its
bright end. Now
int32.chart-kind-contract.mddocumented aship_channels(..., palette)signature that no longer exists, and the dossier'sconstant table called
DEFAULT_PALETTEten entries (it has eight).Wire protocol 6 → 7
colormapwidened from a built-in name to either a name or explicit stops, andthe spec gained an optional
palette. A stale cached v6 client indexes itsbuilt-in table with the stop array, misses, and paints viridis without
erroring — the same silent-misrender case v6 itself was cut for. Bumped in
config.py,00_header.ts, andspec/design/wire-protocol.md§7.Verification
2390 passed, 4 skipped— including 66 new tests intests/test_custom_ramps_and_palette.pycovering resolution, all three inputforms, every failure mode and its message, three-renderer parity, palette
threading and hex normalization, per-index degradation, and every axis switch.
The
ticks=Falsetest compares rendered pixels rather than style dicts,because the defect it pins was invisible at the style layer.
ruff check/ruff format --check/pre-commit run --all-filesclean;ty checkat 13 diagnostics, the same count asorigin/main.docs/apptests: 91 passed.scripts/abi_smoke.py: 133 checks passed.XY_OKon every probe.no console errors, and
to_svg()/to_png()paint the same ramp the canvasdoes.
Spec updated in
design-dossier.md§36 (the palette/colormap token bullet wasmarked pending; the spec-side half is now wired, and the CSS-token half stays
pending),
spec/api/styling.md,spec/api/chart-kind-contract.md, andspec/design/wire-protocol.md§7.The diff was reviewed by an adversarial multi-agent pass over five disjoint
lenses (colormap resolution, palette threading, the JS client, the axis/API
surface, and spec/doc/test truthfulness), each finding independently refuted by
two skeptics. Every defect above except the colorbar
str()came out of thatpass; the surviving high-severity finding was the detached-root palette
resolution, which is what motivated normalizing palettes to hex.