Collapse an axis's layout slot when its text is switched off

SS
9h ago
+1560-95

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.

sparkline before/after

The bug

xy.x_axis(show=False) painted a transparent axis and kept its gutter. At a 1088 px container:

configplot left / width
show=False on both axes25.5 / 1062.5
show=False, tick_values=[]8 / 1080
+ ticks/text/line=False and four zeroed style properties4 / 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_room already had it.

  • _axisTitleVisible / _axis_title_visible — the title answers to label_color alone (no tick_color fallback; neither renderer paints it from that key), is suppressed entirely by tick_label_strategy="none" but not by "off", and an inside_* 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 its tick_length still draws marks keeps its band for them. They do answer to tick_color and to tick_label_strategy="none", and an authored tick_width: 0 draws nothing in any renderer — the browser's 0.5 floor 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:

    majorminor
    drawn forthe computed ticksminor_tick_values only
    drawn onevery tick_sidesside alone
    painted fromstyle.tick_colorminor_style.tick_color
    drawn at all for a named axisyesno

    Both renderers agree on every row (minorTicks/minorSide/tickParts in the client, minor_axis_ticks and the xmstyle loop in the exporter), so one predicate over the pair was wrong whichever way it answered: a minor_style with no values, or a named axis carrying one, reserved a band no renderer paints, while a minor tier on an axis whose tick_sides point 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_id key, and an older payload's axis dict can arrive with no id at all.

_axisGutterVisible / _axis_gutter_visible is their disjunction, and every place that reserves room now reads those helpers rather than its own spelling:

  • _yAxisLeftRoom measures labels and title against their own paints.
  • _xAxisRoom skips text it will not draw. That mattered beyond eligibility: marginBottom takes 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_room asked 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: _positionColorbar reuses the one _rightAxisRoom from _layout.
  • The polar recut derived its disc inset from the raw strategy string and its title gutters from axis.label alone — 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.

configbefore (browser / SVG)after
both axes show=False1062.5 / 10881088 / 1088
right-side axis show=False1034 / 10881088 / 1088
right-side axis, ticks blanked, opaque title1088 / 10881034 / 1034
tick_label_strategy="off", both axes1062.5 / 10881088 / 1088
right axis, strategy="none" + opaque title1034 / 10881088 / 1088
right axis, inside_center title, ticks blanked1034 / 10341088 / 1088
x_axis(show=False, tick_label_angle=45) — height158.3 / 200200 / 200
x_axis(label="Time") — plain, titled — height200 / 159.2157.6 / 159.2
"off" + wrapped x title — height200 / 144.8143.2 / 144.8
show=False, grid=Trueinset1088 / 1088, grid drawn
ordinary untitled axesunchangedunchanged

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_lengthbeforeafter
5178.2 / 191.0191.0 / 191.0
10173.2 / 186.0186.0 / 186.0
40143.2 / 156.0156.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:

configlines drawnbefore → after
primary y, minor values354 → 54
primary y, minor_style but no values054 → 0
primary y, major paint blank, minor drawn30 → 54
primary y, minor paint blank054 → 0
primary y, tick_sides=["right"], minor on left30 → 54 left, 54 → 0 right
named y2, minor values054 → 0
named y2, major 50 px tier354 → 54

Polar, at 420×420. The two bolded rows are browser/export divergences; the others are both renderers agreeing on the wrong answer.

configbefore (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=Falsex 46 / x 30x 8 / x 8
radial title, inside_centerx 46 / x 46x 8 / x 8
theta title, show=Falseh 348 / h 354h 408 / h 408
angular labels drawnunchangedunchanged

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.