Skip to content

Let charts wear the host's colors: custom colormaps, a chart palette, and axis switches - #298

Merged
Alek99 merged 1 commit into
mainfrom
alek/customizability-gaps
Jul 25, 2026
Merged

Let charts wear the host's colors: custom colormaps, a chart palette, and axis switches#298
Alek99 merged 1 commit into
mainfrom
alek/customizability-gaps

Conversation

@Alek99

@Alek99 Alek99 commented Jul 25, 2026

Copy link
Copy Markdown
Member

A chart has to wear the host application's colors. Today it mostly can — chrome
is DOM, so data-xy-slot selectors, class_names, styles, --chart-* tokens,
and custom_css already reach titles, legends, tooltips, ticks, and controls.
But the three decisions that actually dominate how a data-dense chart looks
were closed:

  • Continuous color was one of twenty preset names. For a density scatter,
    hexbin, heatmap, or contour the colormap is the chart, and no amount of CSS
    reaches it. A brand ramp was simply not expressible.
  • Categorical color always used the built-in eight-slot palette. color=labels
    had no override at all — not per chart, not per mark.
  • Hiding axis chrome took seven #00000000/zero-width style properties per
    axis. 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.

Dashboard of four dense charts using preset colormaps and the built-in palette

After — same 1.34M points, same custom_css, same Tailwind chrome classes
on class_names; only the three APIs below differ.

The same four charts wearing brand colormaps and a chart-level palette

colormap= takes a ramp, not just a name

BRAND = "linear-gradient(#4338ca, #2563eb 28%, #22d3ee 62%, #fde68a)"

xy.scatter_chart(
    xy.scatter(x, y, color=energy, colormap=BRAND),
    xy.colorbar(title="Energy"),
)

Three input forms, all equivalent:

Form Example
A sequence of CSS colors, evenly spaced ["#111a3a", "#5b21b6", "#c026d3", "#fde68a"]
(position, color) pairs [(0.0, "#f8fafc"), (0.2, "#38bdf8"), (1.0, "#0f172a")]
A CSS gradient "linear-gradient(#0b1220, #2563eb 30%, #fde68a)"

channels.resolve_colormap normalizes every form to evenly spaced 8-bit RGB
stops
— the exact shape js/src/10_colormaps.ts already stores the built-in
tables in. That is the whole design: it means the WebGL client, _svg.py, and
_raster.py keep one LUT interpolation path between them instead of three, and
colorbars 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 hand
the canonical form straight to resolve_color.

Ramp stops must resolve without a browser — hex, rgb(), hsl(), or a named
color — and must be opaque. var(), oklch(), color-mix(), and translucent
stops 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 is
refused for the same class of reason: a LUT carries none, so ["transparent", "#00f"] would quietly become an opaque black→blue ramp. Use the mark's
opacity/fill-opacity instead. var() stays legal on color=, stroke, and
fill, where one color paints one mark.

xy.theme(palette=[...]) sets the categorical cycle

xy.theme(palette=["#38bdf8", "#e879f9", "#fbbf24", "#34d399", "#a78bfa", "#fb7185"])

One cycle drives both unnamed series colors and the colors a categorical color=
channel assigns to its categories. It lands on Figure.palette before any mark
applies — a trace bakes its color at build time, so ordering is load-bearing —
and is carried on each ColorChannel so 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 the
static exporters use. An explicit color= on a mark still wins; a palette
shorter 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 all
land on one fallback and merge distinct categories into a single indistinguishable
color. Named colors, rgb(), and hsl() are all accepted and normalized to hex
on the wire
— not merely validated. The client's only cascade-free decode is
hexColor; shipping tomato verbatim sends it to a getComputedStyle probe,
which returns "" while the chart root is still detached (notebook webviews
attach asynchronously), yielding black — and permanently, because a cached palette
LUT is rebuilt only on GL context loss.

Axis visibility switches

xy.x_axis(show=False)                      # was: seven style properties
xy.y_axis(show=False, grid=True)           # horizontal guides only
xy.x_axis(line=False, ticks=False)         # labels, no baseline or ticks

show, line, ticks, grid, and text compile to the same validated axis
style properties, so they need no renderer support and work identically in HTML,
SVG, and native PNG. show is the default for the other four and each overrides
it in both directions. An explicit style= property still outranks a switch — a
switch is a default, not a lock. Unset switches emit no style at all, so specs
that don't use them stay byte-identical.

text is deliberately not named labels: that would read as a sibling of
tick_labels, which supplies the label strings, and it also collides with a
local inside x_axis().

ticks=False sets tick geometry, not 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 — the opposite of what the switch means.

Bugs found on the way

  • _declarative_colorbar_options did "colormap": str(colormap). A custom ramp
    reached the colorbar as an unparseable name, so the marks painted the brand
    ramp while the colorbar beside them silently painted viridis.
  • The client's categorical LUT builders (_paletteLut, _paletteLutDimmed,
    _densityCellClasses) decoded palette entries with hexColor() only and would
    throw on any other CSS color. They now share one _paletteRgb helper.
  • The density mean-color plane, the SVG writer, and the native rasterizer each
    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, which
    substitutes per index and warns.
  • _svg._lut indexed colormap stops through uint8 — safe only while every
    colormap 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.
  • Two stale spec claims corrected: chart-kind-contract.md documented a
    ship_channels(..., palette) signature that no longer exists, and the dossier's
    constant table called DEFAULT_PALETTE ten entries (it has eight).

Wire protocol 6 → 7

colormap widened from a built-in name to either a name or explicit stops, and
the spec gained an optional palette. A stale cached 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. Bumped in
config.py, 00_header.ts, and spec/design/wire-protocol.md §7.

Verification

  • 2390 passed, 4 skipped — including 66 new tests in
    tests/test_custom_ramps_and_palette.py covering resolution, all three input
    forms, every failure mode and its message, three-renderer parity, palette
    threading and hex normalization, per-index degradation, and every axis switch.
    The ticks=False test 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-files clean;
    ty check at 13 diagnostics, the same count as origin/main.
  • docs/app tests: 91 passed. scripts/abi_smoke.py: 133 checks passed.
  • The WebGL smoke reports XY_OK on every probe.
  • Checked by hand in a browser: a custom ramp survives the drill/re-bin path with
    no console errors, and to_svg() / to_png() paint the same ramp the canvas
    does.

Spec updated in design-dossier.md §36 (the palette/colormap token bullet was
marked 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, and
spec/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 that
pass; the surviving high-severity finding was the detached-root palette
resolution, which is what motivated normalizing palettes to hex.

… 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.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@Alek99, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6caf64b6-f34e-415f-8843-797fd96222f9

📥 Commits

Reviewing files that changed from the base of the PR and between a7239b9 and 1c9bd38.

⛔ Files ignored due to path filters (2)
  • spec/assets/customizability-after.png is excluded by !**/*.png
  • spec/assets/customizability-before.png is excluded by !**/*.png
📒 Files selected for processing (23)
  • CHANGELOG.md
  • docs/components/axes.md
  • docs/styling/customize.md
  • js/src/00_header.ts
  • js/src/10_colormaps.ts
  • js/src/50_chartview.ts
  • python/xy/_figure.py
  • python/xy/_hosts.py
  • python/xy/_payload.py
  • python/xy/_raster.py
  • python/xy/_svg.py
  • python/xy/_validate.py
  • python/xy/channels.py
  • python/xy/components.py
  • python/xy/config.py
  • python/xy/interaction.py
  • python/xy/marks.py
  • spec/api/chart-kind-contract.md
  • spec/api/styling.md
  • spec/design-dossier.md
  • spec/design/wire-protocol.md
  • tests/test_custom_ramps_and_palette.py
  • tests/test_density_mean_color.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch alek/customizability-gaps

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Jul 25, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 102 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing alek/customizability-gaps (1c9bd38) with main (a7239b9)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@Alek99
Alek99 merged commit 8935f49 into main Jul 25, 2026
28 checks passed
Alek99 added a commit that referenced this pull request Jul 25, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant