Collapse an axis's layout slot when its text is switched off
Not stacked — branches from main, independent of #509/#521.
Before / after
A full-width sparkline (show=False on both axes, padding=(8, 0, 28, 0), 1088 px container), run against main and against this branch and screenshotted in headless Chromium. The container's left edge is marked red. The canvas reports left: 22, width: 1066 before and left: 0, width: 1088 after. The script is in this thread.

The bug
xy.x_axis(show=False) painted a transparent axis and kept its gutter. At a 1088 px container:
| config | plot left / width |
|---|---|
show=False on both axes | 25.5 / 1062.5 |
show=False, tick_values=[] | 8 / 1080 |
+ ticks/text/line=False and four zeroed style properties | 4 / 1084 |
show=False, side="right" | 0 / 1034 — a flat 54 px reserved |
So there was no way to get a flush-edge or sparkline chart: padding=0 does not override a measured gutter, and negative padding raises padding[3] must be non-negative.
What it actually was
Not a missing feature — a divergence between the renderers. The visibility shorthands compile to transparent paints rather than to a flag, so layout has to ask the paint whether any text will be seen. The static exporters already do, and say why:
def _axis_text_paint_visible(axis, key, fallback_key=None):
"""Whether an axis text paint can contribute visible ink.
Axis visibility shorthands are compiled to transparent CSS colors. Layout
must not measure that invisible text back into an explicit zero padding,
or ``show=False`` cannot produce the documented edge-to-edge sparkline.
"""
The browser never asked, so the same chart exported flush and rendered inset.
The fix
One rule, mirrored: an axis reserves the room for what it actually draws, and nothing for what it doesn't. Three questions, one helper each, on both sides:
-
_axisTickLabelsVisible/_axis_tick_labels_visible— a paint that shows ink, and a strategy that draws a label."none"and"off"both draw none, as the exporter's_axis_tick_label_roomalready had it. -
_axisTitleVisible/_axis_title_visible— the title answers tolabel_coloralone (notick_colorfallback; neither renderer paints it from that key), is suppressed entirely bytick_label_strategy="none"but not by"off", and aninside_*title is drawn over the plot and claims nothing. -
_axisOutwardTickRoom/_axis_outward_tick_room— tick marks are chrome of their own and answer to no text paint at all, so an axis whose labels are off while itstick_lengthstill draws marks keeps its band for them. They do answer totick_colorand totick_label_strategy="none", and an authoredtick_width: 0draws nothing in any renderer — the browser's0.5floor is for sub-pixel widths, not a way to resurrect a mark the author switched off. An axis kept in the band by its marks alone reserves only those: the tick-label term is gated on a label actually being drawn there, since its offset is measured from the outward end of the mark and would otherwise reserve a whole label's clearance past marks that have no label.The two tick tiers are measured apart, because they are drawn apart. Almost nothing about them is shared:
major minor drawn for the computed ticks minor_tick_valuesonlydrawn on every tick_sidessidealonepainted from style.tick_colorminor_style.tick_colordrawn at all for a named axis yes no Both renderers agree on every row (
minorTicks/minorSide/tickPartsin the client,minor_axis_ticksand thexmstyleloop in the exporter), so one predicate over the pair was wrong whichever way it answered: aminor_stylewith no values, or a named axis carrying one, reserved a band no renderer paints, while a minor tier on an axis whosetick_sidespoint elsewhere had its marks clipped. Which axis has a minor tier comes from the caller rather than from the dict — the exporter's call sites hold the_axes_by_idkey, and an older payload's axis dict can arrive with noidat all.
_axisGutterVisible / _axis_gutter_visible is their disjunction, and every place that reserves room now reads those helpers rather than its own spelling:
_yAxisLeftRoommeasures labels and title against their own paints._xAxisRoomskips text it will not draw. That mattered beyond eligibility:marginBottomtakes the measured room rather than the gated band, so a rotated, wrapped or collision-stacked label reintroduced a gutter that had just been collapsed. The tick-label strategy now decides tick-label room only, and the title's band is measured from where the title is actually drawn instead of only its overflow past one line._colorbar_right_axis_roomasked with a third spelling (!= "none", no paint check), so a vertical colorbar stepped 54 px over a gutter layout had collapsed. The browser never had this:_positionColorbarreuses the one_rightAxisRoomfrom_layout.- The polar recut derived its disc inset from the raw strategy string and its title gutters from
axis.labelalone — in two cases with the renderers disagreeing.
The right side needed the exporter too: its gutter was gated on the tick-label strategy alone, so fixing only the client would have swapped one divergence for another. Its flat 42/54 width is unchanged and stays deliberate — a right title is pinned plot-relative, and spec/api/styling.md records why — but whether it is reserved at all now follows what is drawn, as on the left.
Measured, in the real client
Cartesian, at width=1088, height=200, padding=(0, 0, 0, 0); browser and SVG agree on every "after" row, within the 8 px the two text engines differ by.
| config | before (browser / SVG) | after |
|---|---|---|
both axes show=False | 1062.5 / 1088 | 1088 / 1088 |
right-side axis show=False | 1034 / 1088 | 1088 / 1088 |
| right-side axis, ticks blanked, opaque title | 1088 / 1088 | 1034 / 1034 |
tick_label_strategy="off", both axes | 1062.5 / 1088 | 1088 / 1088 |
right axis, strategy="none" + opaque title | 1034 / 1088 | 1088 / 1088 |
right axis, inside_center title, ticks blanked | 1034 / 1034 | 1088 / 1088 |
x_axis(show=False, tick_label_angle=45) — height | 158.3 / 200 | 200 / 200 |
x_axis(label="Time") — plain, titled — height | 200 / 159.2 | 157.6 / 159.2 |
"off" + wrapped x title — height | 200 / 144.8 | 143.2 / 144.8 |
show=False, grid=True | inset | 1088 / 1088, grid drawn |
| ordinary untitled axes | unchanged | unchanged |
Right axis with tick_length=40, tick_width=2, tick_direction="out" at 560×300: plot right was 442 with the marks reaching 482, over a colorbar at 466; now 388, reaching 428.
An x axis kept in the band by its marks alone — tick_label_strategy="off", height, browser / SVG. The gap was a flat 12.8 px at every length (the tick padding plus the font's descent), so it cleared the 8 px tolerance throughout, not only at short lengths:
tick_length | before | after |
|---|---|---|
| 5 | 178.2 / 191.0 | 191.0 / 191.0 |
| 10 | 173.2 / 186.0 | 186.0 / 186.0 |
| 40 | 143.2 / 156.0 | 156.0 / 156.0 |
The tick tiers, right gutter of a y axis with a 50 px minor tier, against the minor <line>s each config actually emits:
| config | lines drawn | before → after |
|---|---|---|
primary y, minor values | 3 | 54 → 54 |
primary y, minor_style but no values | 0 | 54 → 0 |
primary y, major paint blank, minor drawn | 3 | 0 → 54 |
primary y, minor paint blank | 0 | 54 → 0 |
primary y, tick_sides=["right"], minor on left | 3 | 0 → 54 left, 54 → 0 right |
named y2, minor values | 0 | 54 → 0 |
named y2, major 50 px tier | 3 | 54 → 54 |
Polar, at 420×420. The two bolded rows are browser/export divergences; the others are both renderers agreeing on the wrong answer.
| config | before (browser / SVG) | after |
|---|---|---|
theta_axis(show=False) | (30, 36, 360, 354) / same | (8, 6, 404, 408) / same |
theta_axis(tick_label_strategy="off") | (30, 36, 360, 354) / same | (8, 6, 404, 408) / same |
radial title, show=False | x 46 / x 30 | x 8 / x 8 |
radial title, inside_center | x 46 / x 46 | x 8 / x 8 |
theta title, show=False | h 348 / h 354 | h 408 / h 408 |
| angular labels drawn | unchanged | unchanged |
With no authored padding, show=False on both axes stays at 62, 10, 1012, 148 in both renderers, before and after: the label-aware defaults are untouched, and an edge-to-edge sparkline is still show=False plus padding=0. The news fragment and spec/api/styling.md say so explicitly.
The title-band change has a small blast radius by construction — the measured band only wins above the existing floor, and a one-line 12 px title is ~42 px against a 62 px default bottom margin. Across the full suite it moved exactly one test, this PR's own.
Tests
tests/test_axis_show_layout.py — 33 test cases: the exporter contract without a browser, browser probes reading view.plot per configuration, a parity sweep comparing all four plot coordinates across eight axis configurations, the title band on both sides, the colorbar's right-axis room, outward tick marks on both axes (including the x band an axis keeps for marks with no label, at three tick lengths), the major/minor tier split (values, paint and side per tier, plus primary-versus-named), and the polar recut. Every cross-engine comparison goes through one _assert_parity helper at the file's 8 px tolerance; the exact assertions that remain are single-renderer against the canvas size, or the two cases where both renderers must agree to the pixel. Every test covering a behavior change here was checked to fail without it, against a rebuilt bundle rather than a stashed tree.
Latest validation after review fixes (8ac457aa): the full-suite run had 4,878 passed and 10 skipped; its sole failure was a source-string assertion expecting resize to call _layout() directly. That assertion now follows the shared _layoutDom() routine, and all 86 axis/polar tests pass on rerun (4,879 distinct passing tests across the full run and focused rerun). Rust/client builds, ABI/render/append smoke checks (including runtime DPR behavior), Ruff, formatting, ty, and full-worktree pre-commit hooks pass. PR code ran in an unprivileged, network-disabled sandbox.
The review fixes also cover initial CSS axis paints (named colors, transparent, rgba(), and root CSS variables), hidden top-axis bands, right-facing mark/colorbar clearance on a left axis, and minor ticks whose major tier uses the opposite side. Each regression was reproduced before the fix. Detached charts also remeasure paint-dependent gutters and update canvas/legend geometry when their styled root first connects, before the shared GL host selects its first connected-frame extent. A browser regression covers transparent and CSS-variable paints, backing-store dimensions, legend bounds, and first-frame pixel stability.
Scope
Reported alongside two others. This is the first; the third (tooltip time formatting) is #521. The second — no first-class way to show only one crosshair axis — is deferred for now.
Summary by CodeRabbit
-
Bug Fixes
- Hidden axes no longer reserve unnecessary layout space, allowing zero-padding plots to reach container edges.
- Browser and SVG renderers now consistently account for visible labels, titles, outward tick marks, transparent styling, and inside-positioned titles.
tick_label_strategy="off"no longer reserves tick-label room while retaining space for visible titles.- Visible titles reserve the band where they are drawn.
- Polar charts and vertical colorbars now apply consistent gutter and inset rules.
-
Documentation
- Clarified axis gutter behavior, padding, title positioning, tick marks, polar insets, and right-side axis sizing.