From 552db467f7aab50209efb72e027a86e1a75dde23 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 01:44:40 +0000 Subject: [PATCH 01/15] Widen the mark CSS subset: stroke-linecap and marker-shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `styles.py` accepted nine mark properties and no way to add a tenth without touching three renderers by hand. This adds the two the renderers can honestly agree on today and, in doing so, settles a cap disagreement they had been hiding. `marker-shape` was free: all three renderers have drawn 17 marker symbols for some time and `symbol=` already reached every one of them. Only the CSS spelling was missing, and both spellings compile to the same trace-style value, so the two build byte-identical specs. `stroke-linecap` was not free, and the reason is worth recording. The three renderers disagreed and none of them said so: the native rasterizer capped round (its clamped segment distance field is a capsule), the WebGL client capped butt with a half-pixel bleed, and the SVG writer hardcoded `round` on line paths while the area outline inherited SVG's `butt` — which `_pdf` then read back as `butt` as well. XY's default is now `round` everywhere, chosen because the rasterizer is the reference for static export, and every stroked SVG path names both cap and join instead of letting the format decide. The rasterizer keeps its existing fast path untouched for round/round and routes anything else through `stroke_shaped`, which decomposes a polyline into oriented-box segments plus join primitives (disc, bevel triangle, miter wedge with SVG's limit of 4) max-combined into one scratch buffer, so overlapping join geometry cannot double-darken a translucent stroke. Join geometry is not optional there: butt-capped segments leave a notch at every interior vertex that something has to fill. `stroke-linejoin` is deliberately NOT shipped. SVG emits it and the rasterizer implements all three joins, but the WebGL client draws a polyline as one instanced quad per segment with no join geometry at all — an attempt at a second instanced pass over interior vertices did not survive verification, so the property would have been honored by two renderers out of three. That is precisely the failure the mark style subset exists to prevent, so it waits for the client. The default-join divergence it leaves behind (rasterizer fills the vertex, WebGL leaves the notch) predates this change and is now written down rather than left to be discovered. Caps are verified end to end, not just at the seam: a Rust unit test on coverage past the endpoint, a Python test on rasterized ink, and three Chromium screenshots that hash differently per cap. Absent keys stay off the wire, so specs built before this change are unchanged byte for byte. ABI_VERSION 41 -> 42: OP_STROKE and OP_SMOOTH_STROKE carry two more bytes. --- CHANGELOG.md | 18 ++ docs/styling/customize.md | 14 +- docs/styling/mark-styles.md | 38 ++- js/src/40_gl.ts | 56 +++- js/src/50_chartview.ts | 10 +- python/xy/_native.py | 2 +- python/xy/_raster.py | 19 +- python/xy/_svg.py | 24 +- python/xy/marks.py | 18 +- python/xy/styles.py | 38 ++- spec/api/styling.md | 49 +++- src/lib.rs | 2 +- src/raster.rs | 423 ++++++++++++++++++++++++++- tests/test_css_mark_styles.py | 118 ++++++++ tests/test_static_client_security.py | 4 +- 15 files changed, 803 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b64223e4..9bc3ea4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ in the README). ## [Unreleased] ### Added +- Mark `style=` accepts **`stroke-linecap`** (`butt`/`round`/`square`) on the + line family, with the standard SVG semantics: it shapes the polyline's two + ends and each dash end. Only the line family takes it, because it describes + stroked open-path geometry. XY's default is `round`, not CSS's `butt`, so + existing specs are byte-identical. `stroke-linejoin` is deliberately not + offered yet — the SVG and native-raster writers implement it but the WebGL + client has no join geometry, and a property two renderers honor and one + ignores is exactly what the mark style subset exists to prevent. +- Mark `style=` accepts **`marker-shape`** on `scatter`, the CSS spelling of the + existing `symbol=` argument. Both resolve to the same trace-style value, so + the two spellings build identical specs. - `colormap=` accepts a **custom ramp** built from your own colors, not only one of the twenty built-in names: a sequence of 2–256 CSS colors, `(position, color)` pairs, or a CSS `linear-gradient(...)`. Every form resolves once, in @@ -37,6 +48,13 @@ in the README). that don't use them are byte-identical. ### Fixed +- The three mark renderers disagreed about line caps and never said so: the + native rasterizer capped round, the WebGL client capped butt with a + half-pixel bleed, and the SVG writer hardcoded `round` on line paths while + the area outline silently inherited SVG's `butt` — which the PDF exporter + then read back as `butt` too. All three now draw XY's documented `round` + default, and the SVG writer names both cap and join on every stroked path + instead of letting the format's defaults decide. - The colorbar stringified its colormap, so a custom ramp reached it as an unparseable name and silently painted viridis while the marks beside it painted the ramp correctly. diff --git a/docs/styling/customize.md b/docs/styling/customize.md index dfc444a2..6d65163d 100644 --- a/docs/styling/customize.md +++ b/docs/styling/customize.md @@ -95,7 +95,19 @@ 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-like marks add `stroke-dasharray` and +`stroke-linecap`; and `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 diff --git a/docs/styling/mark-styles.md b/docs/styling/mark-styles.md index 0750e5ea..4f9f97ec 100644 --- a/docs/styling/mark-styles.md +++ b/docs/styling/mark-styles.md @@ -14,9 +14,9 @@ renderer cannot silently ignore a declaration that another honors. | Mark family | Supported properties in `style=` | | --- | --- | -| `line`, `step`, `stairs`, `ecdf` | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `opacity` | +| `line`, `step`, `stairs`, `ecdf` | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `stroke-linecap`, `opacity` | | `area`, `error_band` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity`; `area` also supports `stroke-dasharray` | -| `scatter` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity` | +| `scatter` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `marker-shape`, `opacity` | | `histogram`, `bar`, `column` | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `border-radius`, `opacity` | | `segments`, `errorbar`, `contour`, `stem` | `stroke`, `stroke-width`, `stroke-opacity`, `opacity` | | `box`, `violin` | `fill`, `fill-opacity`, `opacity` | @@ -58,6 +58,40 @@ surfaces set the same rendered property. Inside `style`, use `stroke` for line-like geometry and `fill` for filled geometry; `color` is deliberately not a CSS paint alias there. +## Stroke geometry: line caps + +`stroke-linecap` (`butt`, `round`, `square`) shapes the two ends of a polyline +and each dash end. It is polyline geometry, so only the line family accepts it — +a `bar` or a `scatter` raises rather than accepting a declaration no renderer +can draw. + +XY defaults to `round`, **not** to the CSS initial value `butt`: the native +rasterizer has always drawn round caps and it is the reference for static +export. Set the property to opt into the CSS initial value. + +~~~python +xy.line(x, y, style={"stroke-width": "6px", "stroke-linecap": "butt"}) +~~~ + +`stroke-linejoin` is **not** available yet. The SVG and native-raster writers +both implement it, but the browser client draws polylines as one quad per +segment with no join geometry, so honoring it in two renderers out of three +would break the rule that every accepted declaration is drawn everywhere. See +the capability matrix for the current state of that row. + +## Marker shape + +`marker-shape` picks one of the 17 renderer-backed scatter symbols — `circle`, +`square`, `diamond`, `triangle`, `cross`, `hexagon`, `pentagon`, `star`, +`triangle_down`, `triangle_left`, `triangle_right`, `x`, `point`, `pixel`, +`thin_diamond`, `plus_line`, `x_line` — and is the CSS spelling of the existing +`symbol=` argument. It is an XY vocabulary name rather than a standard CSS +property: CSS has no shape keyword for a non-DOM point mark. + +~~~python +xy.scatter(x, y, size=12, style={"marker-shape": "diamond", "fill": "#22c55e"}) +~~~ + ## Combine mark styles This example combines the main paint paths in one chart: a gradient area, a diff --git a/js/src/40_gl.ts b/js/src/40_gl.ts index 782e7f16..719076eb 100644 --- a/js/src/40_gl.ts +++ b/js/src/40_gl.ts @@ -469,16 +469,21 @@ void main() { outColor = vec4(rgb * u_opacity, u_opacity); }`; +// Polylines: one instanced quad per segment. `u_cap` is the compiled +// stroke-linecap value (see LINE_CAP_MODES); XY defaults it to round, which is +// what the native rasterizer's clamped segment distance field draws +// (src/raster.rs), so all three renderers agree. export const LINE_VS = `#version 300 es in float ax0; in float ay0; in float ax1; in float ay1; in float a_prevx; in float a_prevy; in float a_prevx1; in float a_prevy1; uniform vec2 u_xmap; uniform vec2 u_ymap; uniform vec2 u_res; uniform float u_width; uniform int u_colorMode; +uniform int u_cap; uniform int u_capSegments; uniform float u_transitionProgress; uniform int u_transitionActive; uniform float u_revealProgress; uniform float u_revealSegments; uniform vec2 u_xmeta; uniform vec2 u_ymeta; uniform int u_xmode; uniform float u_xconstant; uniform int u_ymode; uniform float u_yconstant; in float a_len0; in float a_len1; -out float v_off; out float v_dash; +out float v_off; out float v_dash; out vec2 v_cap; const vec2 corners[4] = vec2[4](vec2(0.,-1.), vec2(0.,1.), vec2(1.,-1.), vec2(1.,1.)); ${AXIS_GLSL} void main() { @@ -498,45 +503,74 @@ void main() { vec2 n = vec2(-dir.y, dir.x); vec2 c = corners[gl_VertexID]; float half_w = u_width * 0.5 + 0.5; - vec2 pos = mix(pix0, pix1, c.x) + dir * (c.x * 2.0 - 1.0) * 0.5 + n * c.y * half_w; + // Along-segment coordinate in px. Interior joints keep the 0.5px overlap that + // hides the seam between consecutive quads; the polyline's two outer ends + // instead grow by half_w when the cap reaches past them (round semicircle, + // square extension), giving LINE_FS room to shape it. + bool first = gl_InstanceID == 0; + bool last = gl_InstanceID == u_capSegments - 1; + float capExt = u_cap == 0 ? 0.5 : half_w; + float t = mix(first ? -capExt : -0.5, len + (last ? capExt : 0.5), c.x); + vec2 pos = pix0 + dir * t + n * c.y * half_w; gl_Position = vec4(pos / u_res * 2.0 - 1.0, 0.0, 1.0); v_off = c.y * half_w; + // Signed px overrun past the polyline's start/end, far negative where that + // end is an interior joint instead; LINE_FS takes the max of the two. + v_cap = vec2(first ? -t : -1e4, last ? t - len : -1e4); // Cumulative screen-space arc length at this fragment (device px), fed from // CPU-computed per-vertex lengths so dashes stay continuous across segments - // and constant on screen through zoom. - v_dash = mix(a_len0, mix(a_len0, a_len1, reveal), c.x); + // and constant on screen through zoom. Driven off t rather than c.x so the + // overhang carries the pattern on at 1px of arc per px of screen instead of + // stretching the segment's slice of it over the widened quad. + float dashEnd = mix(a_len0, a_len1, reveal); + v_dash = a_len0 + t * (len > 1e-3 ? (dashEnd - a_len0) / len : 1.0); }`; export const LINE_FS = `#version 300 es precision highp float; precision highp int; -uniform vec4 u_color; uniform float u_width; +uniform vec4 u_color; uniform float u_width; uniform int u_cap; uniform int u_dashCount; uniform float u_dashArr[8]; uniform float u_dashPeriod; -in float v_off; in float v_dash; +in float v_off; in float v_dash; in vec2 v_cap; out vec4 outColor; void main() { float half_w = u_width * 0.5; - float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, abs(v_off))) * u_color.a; + // How far past the nearest end of painted stroke this fragment lies, along + // the path. Two kinds of end contribute and the cap shapes both: the + // polyline's own ends (v_cap) and, when dashed, the ends of the dash run — + // the distance to the nearer dash boundary is exactly the overrun a cap has + // to bridge, so the greater of the two is the one that governs. + float axial = max(v_cap.x, v_cap.y); if (u_dashCount > 0) { float m = mod(v_dash, u_dashPeriod); float acc = 0.0; - float on = 0.0; + float sd = 0.0; for (int i = 0; i < 8; i++) { if (i >= u_dashCount) break; float next = acc + u_dashArr[i]; if (m < next) { - // 0.6px feather at each dash start/end so edges aren't aliased. float d = min(m - acc, next - m); - on = (i % 2 == 0) ? clamp(d + 0.6, 0.0, 1.0) : 1.0 - clamp(d + 0.6, 0.0, 1.0); + sd = (i % 2 == 0) ? d : -d; // + inside an "on" run, - inside a gap break; } acc = next; } - alpha *= on; + axial = max(axial, -sd); } + // round = semicircle of radius half_w about the end, so the distance field + // picks up the overrun; butt = flush; square = flush pushed out by half_w. + float radial = u_cap == 1 ? length(vec2(max(axial, 0.0), v_off)) : abs(v_off); + float alpha = (1.0 - smoothstep(half_w - 0.5, half_w + 0.5, radial)) * u_color.a; + if (u_cap == 0) alpha *= 1.0 - smoothstep(-0.5, 0.5, axial); + else if (u_cap == 2) alpha *= 1.0 - smoothstep(half_w - 0.5, half_w + 0.5, axial); if (alpha <= 0.001) discard; outColor = vec4(u_color.rgb * alpha, alpha); }`; +// Wire spellings of stroke-linecap → the u_cap int LINE_FS switches on. A +// trace omits the key at XY's default (round), so an unset style must resolve +// to `round`, not to the CSS initial value `butt`. +export const LINE_CAP_MODES = { butt: 0, round: 1, square: 2 }; + // Segment marks (errorbar/stem/box whiskers/contour isolines): independent // endpoint pairs with per-column axis metas and an optional per-segment LUT // color. A separate program keeps the polyline path (LINE_VS/LINE_FS) free of diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index 420ae75f..68f2a40d 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -2,7 +2,7 @@ import { PROTOCOL, xyByteSpan } from "./00_header"; import { buildLutData, colormapKey, colormapStops } from "./10_colormaps"; import { chartBackdrop, cssColor, ensureChromeStylesheet, hexColor, parseColor, readTheme, safeCssPaint } from "./20_theme"; import { categoryTicks, fmtAxis, fmtGeneral, fmtLinear, fmtValue, linearTicks, logTicks, timeTicks } from "./30_ticks"; -import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; +import { AREA_FS, AREA_VS, ATTR_SLOTS, BAR_VS, DENSITY_FS, GRID_VS, HEATMAP_FS, LINE_CAP_MODES, LINE_FS, LINE_VS, MESH_FS, MESH_VS, PICK_FS, PICK_VS, POINT_FS, POINT_SIMPLE_FS, POINT_SIMPLE_VS, POINT_VS, RECT_FS, RECT_VS, SEGMENT_FS, SEGMENT_VS, makeProgram, uniformOf, xySmoothResample } from "./40_gl"; import { lodCopyGrid, lodDecodeLogU8, lodDrawDensityTier, lodDropDensityCache, lodDropPointCache, lodRememberDensity, lodSampleForView, lodWriteGridTexture } from "./45_lod"; import { markOf } from "./55_marks"; @@ -4031,7 +4031,12 @@ export class ChartView { const reveal = Math.max(0, Math.min(1, g._transitionReveal ?? 1)); gl.uniform1f(u("u_revealProgress"), reveal); gl.uniform1f(u("u_revealSegments"), g.n - 1); - gl.uniform1f(u("u_width"), (width ?? g.trace.style.width ?? 1.5) * this.dpr); + const lineWidth = (width ?? g.trace.style.width ?? 1.5) * this.dpr; + gl.uniform1f(u("u_width"), lineWidth); + // Absent cap/join keys mean XY's default, which is round for both — the + // trace only carries them when they differ from it (marks._stroke_geometry). + const cap = LINE_CAP_MODES[g.trace.style.linecap] ?? LINE_CAP_MODES.round; + gl.uniform1i(u("u_cap"), cap); const [r, gg, b, a] = color || g.color; const strokeOpacity = this._strokeOpacity(g.trace.style) * (opacity ?? 1) * (g._transitionOpacity ?? 1) * (g._legendDim ?? 1); gl.uniform4f(u("u_color"), r, gg, b, a * strokeOpacity); @@ -4060,6 +4065,7 @@ export class ChartView { } ); const segments = Math.max(0, Math.min(g.n - 1, Math.ceil((g.n - 1) * reveal))); + gl.uniform1i(u("u_capSegments"), segments); gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, segments); } diff --git a/python/xy/_native.py b/python/xy/_native.py index b9c07c41..c2cc5e31 100644 --- a/python/xy/_native.py +++ b/python/xy/_native.py @@ -24,7 +24,7 @@ from .config import MAX_CONTOUR_WORK, MAX_SCREEN_DIM -ABI_VERSION = 41 +ABI_VERSION = 42 # Rust reports invalid arguments (and, via the ffi_guard panic shield, any # internal panic) by returning `usize::MAX` from size-returning entry points. diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 5733b54a..abf8e9e8 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -80,6 +80,11 @@ # (right-margin titles, matplotlib rotation=270). _TEXT_ROT_CCW = 0x80 _TEXT_ROT_CW = 0x40 +# stroke-linecap / stroke-linejoin — must match CAP_*/JOIN_* in src/raster.rs. +# XY's default is round for both, which is the geometry the rasterizer's +# capsule distance field has always drawn. +_CAP_CODES = {"butt": 0, "round": 1, "square": 2} +_JOIN_CODES = {"miter": 0, "round": 1, "bevel": 2} _SYMBOLS = { "circle": 0, "square": 1, @@ -223,6 +228,8 @@ def stroke( color: tuple[int, ...], closed: bool = False, dash: Sequence[float] | None = None, + cap: str = "round", + join: str = "round", ) -> None: if len(pts) < 2 or width <= 0: return @@ -242,6 +249,8 @@ def stroke( self._u32(len(dash)) for d in dash: self._f(d) + self.buf.append(_CAP_CODES[cap]) + self.buf.append(_JOIN_CODES[join]) def point( self, @@ -489,6 +498,8 @@ def smooth_stroke( width: float, color: tuple[int, ...], dash: Sequence[float] | None = None, + cap: str = "round", + join: str = "round", ) -> None: """Native monotone-Hermite flattening + stroke for affine axes.""" n = len(xv) @@ -515,6 +526,8 @@ def smooth_stroke( self._u32(len(dash)) for value in dash: self._f(value) + self.buf.append(_CAP_CODES[cap]) + self.buf.append(_JOIN_CODES[join]) def image( self, @@ -1052,11 +1065,13 @@ def _emit_line( xv, yv = _step_arrays(xv, yv, style["step"]) c = _rgba(style.get("color"), color, _stroke_opacity(style)) width = float(style.get("width", 1.5)) + cap = str(style.get("linecap", "round")) + join = str(style.get("linejoin", "round")) if style.get("curve") == "smooth" and len(xv) >= 3 and sx.affine and sy.affine: - cmd.smooth_stroke(xv, yv, sx, sy, width, c, dash=style.get("dash")) + cmd.smooth_stroke(xv, yv, sx, sy, width, c, dash=style.get("dash"), cap=cap, join=join) else: pts = _scene.curve_points(xv, yv, sx, sy, False) - cmd.stroke(pts, width, c, dash=style.get("dash")) + cmd.stroke(pts, width, c, dash=style.get("dash"), cap=cap, join=join) def _annotation_point( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 1227afa2..35fbdb36 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1076,6 +1076,22 @@ def _star_path(cx: float, cy: float, r: float, points: int, inner: float, start_ return f' str: + """Polyline stroke geometry, always written out rather than inherited. + + SVG's initial values are `butt`/`miter`; XY's are `round`/`round`, and the + trace only carries a key when it differs (`marks._stroke_geometry`). Naming + both attributes on every stroked path is what keeps the SVG agreeing with + the native rasterizer — and with `_pdf`, which reads them straight back out + of this markup and would otherwise fall through to SVG's defaults. + """ + cap = style.get("linecap", "round") + attrs = f' stroke-linecap="{escape(str(cap))}"' + if join: + attrs = f' stroke-linejoin="{escape(str(style.get("linejoin", "round")))}"' + attrs + return attrs + + def _dash_attr(style: dict[str, Any]) -> str: dash = style.get("dash") if not dash: @@ -1591,7 +1607,7 @@ def line_attrs(style: dict[str, Any], color: str) -> str: op = _stroke_opacity(style) return ( f'stroke="{escape(color)}" stroke-width="{_num(w)}" fill="none" ' - f'stroke-linejoin="round" stroke-linecap="round"' + + _cap_join_attrs(style) + (f' stroke-opacity="{_num(op)}"' if op < 1 else "") + _dash_attr(style) ) @@ -1640,7 +1656,11 @@ def line_attrs(style: dict[str, Any], color: str) -> str: outline_path = joined if style.get("stroke_perimeter") else top_path marks.append( f'" diff --git a/python/xy/marks.py b/python/xy/marks.py index e77331f7..6c0c5a13 100644 --- a/python/xy/marks.py +++ b/python/xy/marks.py @@ -12,7 +12,7 @@ from __future__ import annotations import warnings -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import TYPE_CHECKING, Any, Optional, Union import numpy as np @@ -93,6 +93,19 @@ def _direct_symbols(value: Any, n: int, style_channels: dict[str, channels.Style return "circle" +def _stroke_geometry(css: Mapping[str, Any]) -> dict[str, str]: + """The polyline cap key from compiled CSS, omitted at its default. + + Every renderer already draws XY's default `round`, so a spec that never + asks for another cap stays byte-identical to one built before the property + existed. + """ + value = css.get("linecap") + if value is None or value == styles.DEFAULT_LINE_CAP: + return {} + return {"linecap": str(value)} + + def _stroke_channel( value: Any, n: int, label: str ) -> tuple[Optional[str], Optional[channels.ColorChannel]]: @@ -816,6 +829,7 @@ def line( yc = self.store.ingest(yc.values[order]) style: dict[str, Any] = {"color": color, "width": width, "opacity": opacity} style.update(styles._opacity_channels(css)) + style.update(_stroke_geometry(css)) if curve != "linear": style["curve"] = curve if dash_spec is not None: @@ -1149,6 +1163,7 @@ def step( ) self.traces[-1].style["step"] = where self.traces[-1].style.update(styles._opacity_channels(css)) + self.traces[-1].style.update(_stroke_geometry(css)) return self @@ -1373,6 +1388,7 @@ def scatter( opacity = css.get("opacity", opacity) stroke = css.get("stroke", stroke) stroke_width = css.get("stroke_width", stroke_width) + symbol = css.get("symbol", symbol) name = self._optional_text(name, "scatter name") zoom_size_factor = self._nonnegative_scalar(zoom_size_factor, "scatter zoom_size_factor") if zoom_size_factor == 0.0: diff --git a/python/xy/styles.py b/python/xy/styles.py index c24a1515..be59d2d9 100644 --- a/python/xy/styles.py +++ b/python/xy/styles.py @@ -46,6 +46,23 @@ _AXIS_DASH_STYLES = frozenset({"solid", "dashed", "dotted", "dashdot"}) _AXIS_DIRECTIONS = frozenset({"in", "out", "inout"}) +# Polyline stroke geometry. All three mark renderers (WebGL, SVG, native +# rasterizer) draw these caps identically; XY defaults to `round` rather than +# the CSS initial value `butt` because the native rasterizer is the reference +# for static export and has always drawn round. Set the property to opt into +# the CSS initial value. +# +# `stroke-linejoin` is deliberately absent. SVG and the native rasterizer both +# implement all three joins, but the WebGL client draws polylines as one +# instanced quad per segment with no join geometry at all, so a `miter` there +# would render as the overlap the segments already produce. Accepting a +# declaration one renderer silently ignores is exactly what this module exists +# to prevent, so the property waits for the client. The gap — including the +# joins the three renderers already disagree on by default — is a row in +# `xy.styling.capabilities`. +LINE_CAPS = frozenset({"butt", "round", "square"}) +DEFAULT_LINE_CAP = "round" + _MARK_KINDS = tuple( sorted( _LINE_KINDS @@ -170,6 +187,12 @@ def _dasharray(value: StyleValue, label: str) -> list[float] | None: return lengths +def _keyword(value: StyleValue, allowed: frozenset[str], label: str) -> str: + if not isinstance(value, str) or value not in allowed: + raise ValueError(f"{label} must be one of {sorted(allowed)}") + return value + + def _paint(value: StyleValue, label: str) -> str: return _validate.css_color(value, label) @@ -195,7 +218,13 @@ def _supported_mark_style_properties(kind: str) -> tuple[str, ...]: raise ValueError(f"unknown mark kind {kind!r}; expected one of {_MARK_KINDS}") props = {"opacity"} if kind in _LINE_KINDS: - props |= {"stroke", "stroke-width", "stroke-opacity", "stroke-dasharray"} + props |= { + "stroke", + "stroke-width", + "stroke-opacity", + "stroke-dasharray", + "stroke-linecap", + } elif kind in _SIMPLE_STROKE_KINDS: props |= {"stroke", "stroke-width", "stroke-opacity"} elif kind in _AREA_KINDS: @@ -216,6 +245,7 @@ def _supported_mark_style_properties(kind: str) -> tuple[str, ...]: "stroke", "stroke-width", "stroke-opacity", + "marker-shape", } elif kind in _RECT_KINDS: props |= { @@ -302,6 +332,10 @@ def _compile_mark_style(kind: str, value: StyleMapping | None, label: str) -> di _set(out, target, _px(raw, f"{label}['stroke-width']"), prop, seen) elif prop == "stroke-dasharray": _set(out, "dash", _dasharray(raw, f"{label}['stroke-dasharray']"), prop, seen) + elif prop == "stroke-linecap": + _set(out, "linecap", _keyword(raw, LINE_CAPS, f"{label}['stroke-linecap']"), prop, seen) + elif prop == "marker-shape": + _set(out, "symbol", _validate.point_symbol(raw, f"{label}['marker-shape']"), prop, seen) elif prop == "border-radius": _set(out, "corner_radius", _px(raw, f"{label}['border-radius']"), prop, seen) return out @@ -371,6 +405,8 @@ def _opacity_channels(compiled: Mapping[str, Any]) -> dict[str, float]: __all__ = [ + "DEFAULT_LINE_CAP", + "LINE_CAPS", "StyleMapping", "StyleValue", "compile_axis_style", diff --git a/spec/api/styling.md b/spec/api/styling.md index 0f33fc28..e9ff75e1 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -83,9 +83,9 @@ xy.bar( | Mark family | Supported CSS properties | | --- | --- | -| line, step, stairs, ECDF | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `opacity` | +| line, step, stairs, ECDF | `stroke`, `stroke-width`, `stroke-opacity`, `stroke-dasharray`, `stroke-linecap`, `opacity` | | area, error band | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity`; area also supports `stroke-dasharray` | -| scatter | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `opacity` | +| scatter | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `marker-shape`, `opacity` | | histogram, bar, column | `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-opacity`, `border-radius`, `opacity` | | segments, error bars, contour, stem | `stroke`, `stroke-width`, `stroke-opacity`, `opacity` | | box, violin | `fill`, `fill-opacity`, `opacity` | @@ -103,6 +103,51 @@ A mark's `class_name` is adapter-only trace metadata. It does not create a DOM node and is not interpreted as a paint selector by the shipped browser, Reflex, SVG, or native renderers. +### Polyline stroke geometry + +`stroke-linecap` (`butt` | `round` | `square`) carries its standard SVG +semantics: it shapes the two ends of an open polyline and each dash end. It is +accepted **only** by the line family, because it describes stroked open-path +geometry; every other mark rejects it at build time rather than accepting a +declaration no renderer would draw. + +XY's default is `round`, deliberately not the CSS initial value `butt`. Before +this vocabulary existed the three renderers silently disagreed — the native +rasterizer capped round from its clamped segment distance field +(`src/raster.rs`), the WebGL client capped butt with a half-pixel bleed, and +the SVG writer hardcoded `round` on line paths while the area outline inherited +SVG's `butt`. Round is now the contract in all three, because the native +rasterizer is the reference for static export. + +`styles.DEFAULT_LINE_CAP` names that default and `marks._stroke_geometry` omits +a key that equals it, so a spec that never asks for another cap stays +byte-identical to one built before the change. + +**`stroke-linejoin` is not offered yet, and the reason is a renderer, not an +oversight.** SVG emits the attribute and the native rasterizer implements all +three joins (`join_shape` in `src/raster.rs`, exercised whenever a non-round cap +forces the shaped path). The WebGL client draws a polyline as one instanced +quad per segment with *no join geometry whatsoever* — adjacent quads simply +overlap by half a pixel — so it has no way to distinguish a miter from a bevel. +Shipping the property would mean two renderers honoring it and one ignoring it, +which is the failure this module exists to prevent. It waits for the client. + +That leaves a real cross-renderer difference in the *default*: the rasterizer +fills interior vertices with a round join and the WebGL client leaves the notch +two overlapping quads produce. That predates this vocabulary, is visible only on +wide strokes at sharp angles, and is recorded as a row in +`xy.styling.capabilities` rather than left for a reader to discover. + +### Marker shape + +`marker-shape` selects one of the 17 renderer-backed scatter symbols and is the +CSS spelling of the existing `symbol=` argument — both resolve to the same +`symbol` trace-style value, so the two spellings produce identical specs. It is +an **XY vocabulary name, not a standard CSS property**: CSS has no shape keyword +for a non-DOM point mark, and the alternative (a `-xy-` vendor prefix) would +force an unusable `_xy_marker_shape` Python alias. The distinction is recorded +per property rather than encoded in the name. + ### Reflex integration boundary Reflex owns reactive `Var` values, conditions, application state, event diff --git a/src/lib.rs b/src/lib.rs index 0d893374..2dd2c048 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -82,7 +82,7 @@ unsafe fn borrowed_byte_spans<'a>( /// ABI version — bumped on any signature change. The Python wrapper checks this /// at load time and refuses a mismatched library loudly (§33 comm-versioning /// rule, applied to the in-process boundary). -pub const ABI_VERSION: u32 = 41; +pub const ABI_VERSION: u32 = 42; const FACTORIZE_CAPACITY_EXCEEDED: usize = usize::MAX - 1; #[no_mangle] diff --git a/src/raster.rs b/src/raster.rs index 5da85840..7c308b6d 100644 --- a/src/raster.rs +++ b/src/raster.rs @@ -427,10 +427,162 @@ fn fill_poly(cv: &mut Canvas, pts: &[(f32, f32)], mut color_at: impl FnMut(f32, } } -// ---- stroke (distance field, round caps/joins) ------------------------------ +// ---- stroke (distance field, round caps/joins by default) ------------------- type StrokeSegment = ((f32, f32), (f32, f32)); +// stroke-linecap / stroke-linejoin, in the wire order python/xy/styles.py +// compiles: butt/round/square and miter/round/bevel. XY's default is round for +// both, which is what the clamped segment distance field below has always +// drawn, so `stroke` stays the fast path and byte-for-byte unchanged; anything +// else routes through `stroke_shaped`. +pub const CAP_BUTT: u8 = 0; +pub const CAP_ROUND: u8 = 1; +pub const CAP_SQUARE: u8 = 2; +pub const JOIN_MITER: u8 = 0; +pub const JOIN_ROUND: u8 = 1; +pub const JOIN_BEVEL: u8 = 2; + +/// SVG's default miter limit: past it a miter degrades to a bevel, so a near +/// reversal in the data cannot grow a spike that is not in the data. +const MITER_LIMIT: f32 = 4.0; + +#[inline] +fn cov_from_sd(sd: f32) -> f32 { + // Same 1px ramp `seg_coverage` uses, expressed on a signed distance so the + // box, disc, and wedge primitives below all antialias identically. + (0.5 - sd).clamp(0.0, 1.0) +} + +/// Signed distance to an oriented box of half-width `hw` around segment `a`-`b` +/// — a capsule with both ends cut flush, i.e. a butt cap. +fn box_sd(p: (f32, f32), a: (f32, f32), b: (f32, f32), hw: f32) -> f32 { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if len <= 1e-6 { + return f32::INFINITY; + } + let (ux, uy) = (dx / len, dy / len); + let (mx, my) = ((a.0 + b.0) * 0.5, (a.1 + b.1) * 0.5); + let (rx, ry) = (p.0 - mx, p.1 - my); + let along = (rx * ux + ry * uy).abs() - len * 0.5; + let across = (rx * -uy + ry * ux).abs() - hw; + let outside = (along.max(0.0).powi(2) + across.max(0.0).powi(2)).sqrt(); + outside + along.max(across).min(0.0) +} + +/// Signed distance to the intersection of the half-planes bounded by each +/// directed edge of a convex polygon wound consistently. Exact on the edges and +/// conservative at the corners, which is all a 1px ramp needs — the corners of +/// a bevel or miter sit under the segment boxes anyway. +fn convex_sd(p: (f32, f32), poly: &[(f32, f32)]) -> f32 { + let mut sd = f32::NEG_INFINITY; + for i in 0..poly.len() { + let (a, b) = (poly[i], poly[(i + 1) % poly.len()]); + let (ex, ey) = (b.0 - a.0, b.1 - a.1); + let len = (ex * ex + ey * ey).sqrt(); + if len <= 1e-6 { + continue; + } + // Outward normal for clockwise winding in this frame; `join_shape` + // winds every polygon it builds the same way. + let (nx, ny) = (ey / len, -ex / len); + sd = sd.max((p.0 - a.0) * nx + (p.1 - a.1) * ny); + } + sd +} + +/// The filler for the notch two segment boxes leave on the outside of a turn. +/// Returns `None` for a straight or degenerate joint, which needs nothing. +fn join_shape( + prev: (f32, f32), + at: (f32, f32), + next: (f32, f32), + hw: f32, + join: u8, +) -> Option> { + let (d0x, d0y) = (at.0 - prev.0, at.1 - prev.1); + let (d1x, d1y) = (next.0 - at.0, next.1 - at.1); + let l0 = (d0x * d0x + d0y * d0y).sqrt(); + let l1 = (d1x * d1x + d1y * d1y).sqrt(); + if l0 <= 1e-6 || l1 <= 1e-6 { + return None; + } + let (d0x, d0y) = (d0x / l0, d0y / l0); + let (d1x, d1y) = (d1x / l1, d1y / l1); + let cross = d0x * d1y - d0y * d1x; + if cross.abs() <= 1e-6 { + return None; // collinear: the boxes already meet flush + } + // Outward side is the one the path turns away from. + let side = if cross > 0.0 { -1.0 } else { 1.0 }; + let n0 = (side * -d0y, side * d0x); + let n1 = (side * -d1y, side * d1x); + let c0 = (at.0 + n0.0 * hw, at.1 + n0.1 * hw); + let c1 = (at.0 + n1.0 * hw, at.1 + n1.1 * hw); + // Wind so `convex_sd`'s outward normals point away from the interior. + let wound = |a: (f32, f32), b: (f32, f32), c: (f32, f32)| -> bool { + (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0) > 0.0 + }; + let mut poly = vec![at, c0, c1]; + if join == JOIN_MITER { + let (sx, sy) = (n0.0 + n1.0, n0.1 + n1.1); + let slen = (sx * sx + sy * sy).sqrt(); + if slen > 1e-4 { + let (mx, my) = (sx / slen, sy / slen); + let mcos = mx * n0.0 + my * n0.1; + if mcos >= 1.0 / MITER_LIMIT { + let reach = hw / mcos; + poly = vec![at, c0, (at.0 + mx * reach, at.1 + my * reach), c1]; + } + } + } + if !wound(poly[0], poly[1], poly[2]) { + poly.reverse(); + } + Some(poly) +} + +/// One coverage primitive of a shaped stroke, in the order they are painted. +enum StrokePiece { + Box(StrokeSegment), + Disc((f32, f32)), + Convex(Vec<(f32, f32)>), +} + +impl StrokePiece { + fn bounds(&self, hw: f32) -> ((f32, f32), (f32, f32)) { + let pad = hw + 1.0; + let (mut x0, mut y0, mut x1, mut y1) = + (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + let mut extend = |p: (f32, f32)| { + x0 = x0.min(p.0); + y0 = y0.min(p.1); + x1 = x1.max(p.0); + y1 = y1.max(p.1); + }; + match self { + StrokePiece::Box((a, b)) => { + extend(*a); + extend(*b); + } + StrokePiece::Disc(c) => extend(*c), + StrokePiece::Convex(poly) => poly.iter().copied().for_each(&mut extend), + } + ((x0 - pad, y0 - pad), (x1 + pad, y1 + pad)) + } + + fn coverage(&self, p: (f32, f32), hw: f32) -> f32 { + match self { + StrokePiece::Box(( a, b)) => cov_from_sd(box_sd(p, *a, *b, hw)), + StrokePiece::Disc(c) => { + cov_from_sd(((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt() - hw) + } + StrokePiece::Convex(poly) => cov_from_sd(convex_sd(p, poly)), + } + } +} + #[inline] fn seg_dist2(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 { let (px, py) = p; @@ -474,6 +626,184 @@ fn stroke( stroke_with_threads(cv, pts, width, rgba, closed, dash, None); } +/// Stroke honoring an explicit cap and join. Round/round is XY's default and +/// is exactly what the capsule field above draws, so it delegates and the +/// common path keeps its banding, its scratch-buffer reuse, and its bytes. +fn stroke_shaped( + cv: &mut Canvas, + pts: &[(f32, f32)], + width: f32, + rgba: [f32; 4], + closed: bool, + dash: &[f32], + cap: u8, + join: u8, +) { + if (cap == CAP_ROUND && join == JOIN_ROUND) || pts.len() < 2 || width <= 0.0 { + stroke(cv, pts, width, rgba, closed, dash); + return; + } + let hw = width * 0.5; + let n = pts.len(); + let last = if closed { n } else { n - 1 }; + let raw: Vec = (0..last).map(|i| (pts[i], pts[(i + 1) % n])).collect(); + + // Each run is a maximal stretch of painted stroke: the whole polyline when + // undashed, one per "on" interval otherwise. Caps shape a run's two ends; + // joins shape the vertices inside it. SVG caps every dash the same way, so + // a dashed line gets one pair of caps per run rather than one per line. + let mut runs: Vec> = Vec::new(); + if dash.is_empty() { + let mut run: Vec<(f32, f32)> = vec![raw[0].0]; + run.extend(raw.iter().map(|(_, b)| *b)); + runs.push(run); + } else { + let total: f32 = dash.iter().sum(); + if total <= 0.0 { + let mut run: Vec<(f32, f32)> = vec![raw[0].0]; + run.extend(raw.iter().map(|(_, b)| *b)); + runs.push(run); + } else { + let (mut di, mut drem, mut on) = (0usize, dash[0], true); + let mut current: Vec<(f32, f32)> = Vec::new(); + for (a, b) in &raw { + let (mut ax, mut ay) = *a; + let seglen = ((b.0 - ax).powi(2) + (b.1 - ay).powi(2)).sqrt(); + if seglen <= 1e-9 { + continue; + } + let (ux, uy) = ((b.0 - a.0) / seglen, (b.1 - a.1) / seglen); + let mut remain = seglen; + while remain > 1e-6 { + let step = drem.min(remain); + let next = (ax + ux * step, ay + uy * step); + if on { + if current.is_empty() { + current.push((ax, ay)); + } + current.push(next); + } + ax = next.0; + ay = next.1; + remain -= step; + drem -= step; + if drem <= 1e-6 { + di = (di + 1) % dash.len(); + drem = dash[di]; + on = !on; + if !on && !current.is_empty() { + runs.push(std::mem::take(&mut current)); + } + } + } + } + if !current.is_empty() { + runs.push(current); + } + } + } + + let mut pieces: Vec = Vec::new(); + for run in &runs { + if run.len() < 2 { + continue; + } + let mut ends = run.clone(); + if cap == CAP_SQUARE { + // A square cap is a butt cap on a segment pushed out by hw, which + // is the whole difference between the two. + let extend = |from: (f32, f32), to: (f32, f32)| -> (f32, f32) { + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + let len = (dx * dx + dy * dy).sqrt(); + if len <= 1e-6 { + return to; + } + (to.0 + dx / len * hw, to.1 + dy / len * hw) + }; + let head = extend(run[1], run[0]); + let tail = extend(run[run.len() - 2], run[run.len() - 1]); + ends[0] = head; + let end = ends.len() - 1; + ends[end] = tail; + } + for pair in ends.windows(2) { + pieces.push(StrokePiece::Box((pair[0], pair[1]))); + } + for i in 1..run.len() - 1 { + match join { + JOIN_ROUND => pieces.push(StrokePiece::Disc(run[i])), + _ => { + if let Some(poly) = join_shape(run[i - 1], run[i], run[i + 1], hw, join) { + pieces.push(StrokePiece::Convex(poly)); + } + } + } + } + if closed && runs.len() == 1 && run.len() > 2 { + let joint = match join { + JOIN_ROUND => Some(StrokePiece::Disc(run[0])), + _ => join_shape(run[run.len() - 2], run[0], run[1], hw, join) + .map(StrokePiece::Convex), + }; + pieces.extend(joint); + } + } + paint_stroke_pieces(cv, &pieces, hw, rgba); +} + +/// Max-combine every piece's coverage into one scratch buffer, then composite +/// each touched pixel once — the same contract `stroke_with_threads` keeps, so +/// overlapping joins never double-darken a translucent stroke. +fn paint_stroke_pieces(cv: &mut Canvas, pieces: &[StrokePiece], hw: f32, rgba: [f32; 4]) { + if pieces.is_empty() { + return; + } + let (mut x0, mut y0, mut x1, mut y1) = + (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY); + for piece in pieces { + let ((px0, py0), (px1, py1)) = piece.bounds(hw); + x0 = x0.min(px0); + y0 = y0.min(py0); + x1 = x1.max(px1); + y1 = y1.max(py1); + } + let (bx0, by0, bx1, by1) = cv.bbox(x0, y0, x1, y1); + if bx1 <= bx0 || by1 <= by0 { + return; + } + let (sw, sh) = (bx1 - bx0, by1 - by0); + let mut scratch = vec![0u8; sw * sh]; + let mut touched = Vec::::with_capacity(pieces.len().saturating_mul(8)); + for piece in pieces { + let ((px0, py0), (px1, py1)) = piece.bounds(hw); + let sx0 = (px0.floor().max(bx0 as f32) as usize).max(bx0); + let sy0 = (py0.floor().max(by0 as f32) as usize).max(by0); + let sx1 = (px1.ceil().max(0.0) as usize).min(bx1); + let sy1 = (py1.ceil().max(0.0) as usize).min(by1); + for y in sy0..sy1 { + for x in sx0..sx1 { + let c = piece.coverage((x as f32 + 0.5, y as f32 + 0.5), hw); + if c <= 0.0 { + continue; + } + let index = (y - by0) * sw + (x - bx0); + let slot = &mut scratch[index]; + if *slot == 0 { + touched.push(index); + } + let coverage = to_u8(c); + if coverage > *slot { + *slot = coverage; + } + } + } + } + for index in touched { + let (row, col) = (index / sw, index % sw); + cv.blend(bx0 + col, by0 + row, rgba, scratch[index] as f32 / 255.0); + } +} + fn stroke_with_threads( cv: &mut Canvas, pts: &[(f32, f32)], @@ -1940,7 +2270,9 @@ fn rasterize_with_spans( for _ in 0..nd { dash.push(r.f32()?); } - stroke(&mut cv, &pts, width, c, closed, &dash); + let cap = r.u8()?; + let join = r.u8()?; + stroke_shaped(&mut cv, &pts, width, c, closed, &dash, cap, join); } OP_POINT => { let (cx, cy, rr) = (r.f32()?, r.f32()?, r.f32()?); @@ -2385,8 +2717,10 @@ fn rasterize_with_spans( for _ in 0..nd { dash.push(r.f32()?); } + let cap = r.u8()?; + let join = r.u8()?; let points = smooth_points(xs, ys, n, x_scale, y_scale); - stroke(&mut cv, &points, width, color, false, &dash); + stroke_shaped(&mut cv, &points, width, color, false, &dash, cap, join); } _ => return None, } @@ -2563,12 +2897,94 @@ mod tests { cmd.extend([0, 0, 0, 255]); cmd.push(0); // not closed cmd.extend(u32le(0)); // no dash + cmd.extend([CAP_ROUND, JOIN_ROUND]); let mut out = vec![0u8; 10 * 10 * 4]; assert!(rasterize_into(&cmd, 10, 10, &mut out)); assert!(px(&out, 10, 5, 5)[3] > 200); // on the line assert_eq!(px(&out, 10, 5, 0)[3], 0); // far from it } + /// stroke-linecap changes what is painted past the endpoint, and round — + /// XY's default — must keep drawing exactly what it drew before caps + /// existed, because it is the geometry every committed PNG expectation + /// was rendered with. + #[test] + fn stroke_caps_shape_the_ends() { + let ink = |cap: u8| { + let mut cmd = vec![OP_STROKE]; + cmd.extend(u32le(2)); + for (x, y) in [(10.0f32, 20.0f32), (30.0, 20.0)] { + cmd.extend(f32le(x)); + cmd.extend(f32le(y)); + } + cmd.extend(f32le(8.0)); // width + cmd.extend([0, 0, 0, 255]); + cmd.push(0); // not closed + cmd.extend(u32le(0)); // no dash + cmd.extend([cap, JOIN_ROUND]); + let mut out = vec![0u8; 40 * 40 * 4]; + assert!(rasterize_into(&cmd, 40, 40, &mut out)); + (0..40 * 40).filter(|i| out[i * 4 + 3] > 128).count() + }; + // A square cap adds a half-width block at each end; a round cap adds a + // semicircle, which is less; a butt cap adds nothing. + assert!(ink(CAP_BUTT) < ink(CAP_ROUND)); + assert!(ink(CAP_ROUND) < ink(CAP_SQUARE)); + + // Butt clips at the end plane: the pixel a half-width past the last + // point is painted for square, bare for butt. + let probe = |cap: u8| { + let mut cmd = vec![OP_STROKE]; + cmd.extend(u32le(2)); + for (x, y) in [(10.0f32, 20.0f32), (30.0, 20.0)] { + cmd.extend(f32le(x)); + cmd.extend(f32le(y)); + } + cmd.extend(f32le(8.0)); + cmd.extend([0, 0, 0, 255]); + cmd.push(0); + cmd.extend(u32le(0)); + cmd.extend([cap, JOIN_ROUND]); + let mut out = vec![0u8; 40 * 40 * 4]; + assert!(rasterize_into(&cmd, 40, 40, &mut out)); + px(&out, 40, 32, 20)[3] + }; + assert_eq!(probe(CAP_BUTT), 0); + assert!(probe(CAP_SQUARE) > 200); + } + + /// stroke-linejoin fills the notch on the outside of a turn. A miter + /// reaches furthest, a bevel cuts the corner off, and round is the disc + /// the capsule field already drew. + #[test] + fn stroke_joins_shape_the_corner() { + let corner = |join: u8| { + let mut cmd = vec![OP_STROKE]; + cmd.extend(u32le(3)); + for (x, y) in [(10.0f32, 10.0f32), (30.0, 10.0), (30.0, 30.0)] { + cmd.extend(f32le(x)); + cmd.extend(f32le(y)); + } + cmd.extend(f32le(10.0)); // width + cmd.extend([0, 0, 0, 255]); + cmd.push(0); + cmd.extend(u32le(0)); + cmd.extend([CAP_BUTT, join]); + let mut out = vec![0u8; 50 * 50 * 4]; + assert!(rasterize_into(&cmd, 50, 50, &mut out)); + // The outside of this right-angle turn is up and to the right of + // the vertex; the miter apex is the only shape that reaches it. + (px(&out, 50, 34, 6)[3], (0..50 * 50).filter(|i| out[i * 4 + 3] > 128).count()) + }; + let (miter_apex, miter_ink) = corner(JOIN_MITER); + let (bevel_apex, bevel_ink) = corner(JOIN_BEVEL); + let (_, round_ink) = corner(JOIN_ROUND); + + assert!(miter_apex > 200, "a miter fills its apex"); + assert_eq!(bevel_apex, 0, "a bevel cuts the apex off"); + assert!(bevel_ink < round_ink && round_ink < miter_ink); + } + #[test] fn prepared_segment_path_matches_float_coverage_reference() { let cases = [ @@ -3091,6 +3507,7 @@ mod tests { expanded.extend(stroke_color); expanded.push(1); // closed expanded.extend(u32le(0)); // no dash + expanded.extend([CAP_ROUND, JOIN_ROUND]); } for opaque in [false, true] { diff --git a/tests/test_css_mark_styles.py b/tests/test_css_mark_styles.py index 156d1e35..202c5ea3 100644 --- a/tests/test_css_mark_styles.py +++ b/tests/test_css_mark_styles.py @@ -307,3 +307,121 @@ def test_faceting_preserves_concrete_css_style() -> None: grid = chart.figure() assert all(figure.traces[0].style["color"] == "#7c3aed" for figure in grid.figures) + + +def test_line_cap_compiles_to_the_polyline_contract() -> None: + assert compile_mark_style("line", {"stroke-linecap": "butt"}) == {"linecap": "butt"} + + with pytest.raises(ValueError, match=r"must be one of \['butt', 'round', 'square'\]"): + compile_mark_style("line", {"stroke-linecap": "flat"}) + + +def test_line_cap_is_a_polyline_only_property() -> None: + # A cap is polyline geometry. Marks that are not stroked open paths reject + # it rather than accepting a declaration no renderer would draw. + for kind in ("bar", "scatter", "heatmap", "box", "segments"): + with pytest.raises(ValueError, match="unsupported CSS property"): + compile_mark_style(kind, {"stroke-linecap": "butt"}) + + +def test_stroke_linejoin_is_not_offered_until_the_client_draws_joins() -> None: + # SVG and the native rasterizer implement all three joins; the WebGL client + # has no join geometry at all. Until it does, the property is refused + # rather than honored by two renderers out of three. + with pytest.raises(ValueError, match="unsupported CSS property"): + compile_mark_style("line", {"stroke-linejoin": "miter"}) + + +def test_default_cap_stays_off_the_wire() -> None: + # XY's default is round in every renderer, so a spec that asks for it + # explicitly must stay byte-identical to one that never mentions it. + plain = xy.chart(xy.line(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.0])).figure() + explicit = xy.chart( + xy.line(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.0], style={"stroke-linecap": "round"}) + ).figure() + + assert "linecap" not in explicit.traces[0].style + assert plain.build_payload()[0] == explicit.build_payload()[0] + + +def test_cap_rides_the_spec_for_the_line_family() -> None: + for mark in ( + xy.line(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.0], style={"stroke-linecap": "square"}), + xy.step(x=[0.0, 1.0, 2.0], y=[1.0, 2.0, 1.0], style={"stroke-linecap": "square"}), + xy.ecdf(values=[0.0, 1.0, 2.0], style={"stroke-linecap": "square"}), + ): + spec, _ = xy.chart(mark).figure().build_payload() + assert spec["traces"][0]["style"]["linecap"] == "square" + + +def test_cap_reaches_svg_and_native_renderers() -> None: + fig = xy.chart( + xy.line( + x=[0.0, 1.0, 2.0], + y=[1.0, 2.0, 1.0], + style={"stroke-width": "9px", "stroke-linecap": "butt"}, + ) + ).figure() + + svg = fig.to_svg() + assert 'stroke-linecap="butt"' in svg + # The SVG writer names the join too, so the PDF exporter (which reads these + # attributes straight back out) cannot fall through to SVG's `miter`. + assert 'stroke-linejoin="round"' in svg + + # The native rasterizer must actually draw a different shape, not just + # accept the keyword: a square cap paints past the endpoint, butt does not. + def _ink(cap: str) -> int: + figure = xy.chart( + xy.line( + x=[0.0, 1.0, 2.0], + y=[1.0, 2.0, 1.0], + style={"stroke": "#ff0000", "stroke-width": "9px", "stroke-linecap": cap}, + ) + ).figure() + image = _raster.render_raster(*figure.build_payload(), scale=1) + return int(np.count_nonzero(image[:, :, 0] > image[:, :, 2])) + + assert _ink("square") > _ink("butt") + + +def test_marker_shape_css_selects_the_scatter_symbol() -> None: + assert compile_mark_style("scatter", {"marker-shape": "diamond"}) == {"symbol": "diamond"} + with pytest.raises(ValueError, match="must be one of"): + compile_mark_style("scatter", {"marker-shape": "blob"}) + with pytest.raises(ValueError, match="unsupported CSS property"): + compile_mark_style("line", {"marker-shape": "diamond"}) + + fig = xy.chart( + xy.scatter( + x=[0.0, 1.0], + y=[1.0, 2.0], + size=12, + style={"marker-shape": "square", "fill": "#22c55e"}, + ) + ).figure() + spec, blob = fig.build_payload() + assert spec["traces"][0]["style"]["symbol"] == "square" + + # A square marker fills its bounding box; a circle of the same size cannot. + def _ink(shape: str) -> int: + figure = xy.chart( + xy.scatter( + x=[0.0, 1.0], + y=[1.0, 2.0], + size=24, + style={"marker-shape": shape, "fill": "#22c55e", "opacity": 1}, + ) + ).figure() + image = _raster.render_raster(*figure.build_payload(), scale=1) + return int(np.count_nonzero(image[:, :, 1] > image[:, :, 2])) + + assert _ink("square") > _ink("circle") + + +def test_marker_shape_css_loses_to_no_one_but_agrees_with_the_symbol_argument() -> None: + css = xy.chart( + xy.scatter(x=[0.0], y=[1.0], symbol="circle", style={"marker-shape": "triangle"}) + ).figure() + argument = xy.chart(xy.scatter(x=[0.0], y=[1.0], symbol="triangle")).figure() + assert css.build_payload()[0] == argument.build_payload()[0] diff --git a/tests/test_static_client_security.py b/tests/test_static_client_security.py index 2bd2f76b..b1dd0e1a 100644 --- a/tests/test_static_client_security.py +++ b/tests/test_static_client_security.py @@ -803,7 +803,9 @@ def test_client_renders_mark_level_styling() -> None: "xyMarkerSdf(d, u_symbol)", # scatter symbol shapes (circle/square/diamond/triangle/cross) "_pointMarkStyle(", # point stroke + symbol resolution "rgb = mix(rgb, sc.rgb, sc.a);", # selected/unselected recolor (mark_style) - "v_dash = mix(a_len0, mix(a_len0, a_len1, reveal), c.x);", # fractional reveal preserves line dashes + "float dashEnd = mix(a_len0, a_len1, reveal);", # fractional reveal preserves line dashes + "uniform int u_cap; uniform int u_capSegments;", # stroke-linecap on the polyline's ends + "LINE_CAP_MODES", # cap keywords resolve to the shared wire codes "_lineDash(g)", "_resolveMarkFill(", "_setRectStyleUniforms(", From f9963879037e0f4a459bf203fe190fc429f4ab16 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 01:46:28 +0000 Subject: [PATCH 02/15] Publish and pin which styling survives which export path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `custom_css` was the only export asymmetry the docs named, and it is the one that was already bounded: it raises, by name, and the message says to use `Engine.chromium`. The unbounded one went unmentioned. `class_names={slot: ...}` and `styles={slot: {...}}` are read only by the browser client's `_applySlot`; `_svg.py` and `_raster.py` read `spec["dom"]["style"]` and nothing else, and the SVG writer emits no `class` attribute anywhere. A Tailwind-styled chart exports to PNG with none of it applied and no diagnostic. Dropping is the right behavior — raising would break every native export of a chart that carries slot classes for its live view, which is the normal way to use both surfaces — but only if it is written down. So it is now a contract in three places a reader might actually land: `spec/api/export.md` §9 for the engineering matrix, `docs/styling/chrome-slots.md` for the task-oriented one, and `limitations-and-alpha-status.md` for the auditor. `tests/test_export_style_survival.py` pins every row, including that the two native writers agree with each other and not merely with the page: the styled and unstyled renders of the same chart are asserted pixel-identical, so a future change that teaches one writer about slot styles fails until the other learns too. Two asymmetries surfaced while mapping this and are now stated rather than hidden. `xy.legend(style=...)` reaches native through a second channel (`legend_options["style"]`, six keys) that the chart-level `styles={"legend": ...}` spelling never touches, so two spellings that are equivalent in the browser are not equivalent in a PNG. `xy.colorbar(style=...)` has no native channel at all. --- .../limitations-and-alpha-status.md | 19 +++- docs/styling/chrome-slots.md | 27 ++++++ spec/api/export.md | 51 ++++++++++ tests/test_export_style_survival.py | 96 +++++++++++++++++++ 4 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 tests/test_export_style_survival.py diff --git a/docs/api-reference/limitations-and-alpha-status.md b/docs/api-reference/limitations-and-alpha-status.md index 9b02faf8..e9261d6e 100644 --- a/docs/api-reference/limitations-and-alpha-status.md +++ b/docs/api-reference/limitations-and-alpha-status.md @@ -58,8 +58,23 @@ and [Benchmarks](/docs/xy/overview/benchmarks/) for scoped evidence. 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. diff --git a/docs/styling/chrome-slots.md b/docs/styling/chrome-slots.md index 41bc16a6..bbae4f95 100644 --- a/docs/styling/chrome-slots.md +++ b/docs/styling/chrome-slots.md @@ -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 diff --git a/spec/api/export.md b/spec/api/export.md index 870bdfaa..8d7fbab3 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -227,3 +227,54 @@ Writes are atomic per file (same-directory temp file, fsync, `os.replace`), so a reader never observes a partial image. Failure mid-batch is not transactional: files already written stay on disk. The return value is the list of written byte strings, in input order. +## 9. What styling survives which export path + +XY has five styling mechanisms (`spec/api/styling.md` § The five ways to style) +and three rendering families. They do not intersect uniformly, and until this +section existed the gaps were discoverable only by exporting and looking. An +undocumented asymmetry reads as a bug; a documented one is a contract. + +The families are **browser** (standalone HTML, the notebook iframe, the widget, +the Reflex adapter, and Chromium capture, which screenshots the same document), +**native raster** (`_raster.render_raster` → PNG/JPEG/WebP), and **native +vector** (`_svg.to_svg`, and `_pdf.svg_to_pdf` on top of it). + +| Mechanism | Browser | Native raster | Native vector | Enforcement | +| --- | --- | --- | --- | --- | +| `style={...}` on a mark | yes | yes | yes | validated CSS subset, `styles.compile_mark_style` | +| `style={...}` on an axis | yes | yes | yes | validated vocabulary, `styles.compile_axis_style` | +| `style={...}` on the chart (token bag) | yes | yes | yes | `spec["dom"]["style"]`, read at `_svg.py:767,1481` and `_raster.py:662` | +| `styles={slot: {...}}` (per-slot inline) | yes, all 23 slots | **dropped** | **dropped** | silent — see below | +| `class_names={slot: "..."}` | yes, all 23 slots | **dropped** | **dropped** | silent — the SVG writer emits no `class` at all | +| `custom_css="..."` | yes (HTML + Chromium capture) | **raises** | **raises** | `_resolve_image_engine`, `export.py:812` | +| `xy.legend(style=...)` | yes | 6 keys | 6 keys | parallel `legend_options["style"]` channel | +| `xy.colorbar(style=...)` | yes | **dropped** | **dropped** | no native channel exists | + +### Why two of those rows are silent, and why that is the right default + +`custom_css` raises because it is an author stylesheet: there is no honest +partial application of it, and the caller can switch to `Engine.chromium` in one +edit. The message says so. SVG rejects it for *every* engine, because a browser +screenshot cannot produce vector output — that row is a hard "never", not a +default. + +`class_names` and per-slot `styles` are dropped instead, because raising 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. The cost of that +choice is exactly this table: the behavior has to be written down and tested, +which is what `tests/test_export_style_survival.py` does — including that the +two native writers agree with each other and not merely with this page. + +### The legend's parallel channel + +`xy.legend(style=...)` is written twice: into `chrome_styles["legend"]` for the +browser and into `fig.legend_options["style"]`, which the native writers do +read (`_svg.py:2827`, `_raster.py:1970`). Native honors `background`, +`boxShadow`, `borderRadius`, and `--xy-legend-frame-alpha`, plus `padding` and +`rowGap` when they carry an `em` unit. The chart-level `styles={"legend": ...}` +spelling never reaches `legend_options`, so the two spellings that look +equivalent in the browser are not equivalent in a PNG. `colorbar` has no such +channel at all. + +This is an asymmetry worth closing, not just documenting; it is tracked as a +row in the capability registry rather than as prose here. diff --git a/tests/test_export_style_survival.py b/tests/test_export_style_survival.py new file mode 100644 index 00000000..42694fa7 --- /dev/null +++ b/tests/test_export_style_survival.py @@ -0,0 +1,96 @@ +"""Which styling survives which export path is a published contract, not luck. + +Browser chrome is CSS-addressable; the native SVG/raster/PDF writers have no +cascade and read a strict subset of the same spec. Both facts are fine. What is +not fine is leaving the boundary undocumented, so this module pins it: every +assertion here corresponds to a row in `spec/api/export.md` § "What styling +survives which export path" and in +`docs/api-reference/limitations-and-alpha-status.md`. +""" + +from __future__ import annotations + +import re + +import pytest + +import xy +from xy import Engine, _raster +from xy.dom import CHART_DOM_SLOTS + + +def _styled_chart() -> xy.Chart: + return xy.scatter_chart( + xy.scatter(x=[0.0, 1.0], y=[1.0, 2.0], name="series"), + title="title", + class_names={slot: f"cls-{slot}" for slot in CHART_DOM_SLOTS}, + styles={slot: {"outline_color": "#123456"} for slot in CHART_DOM_SLOTS}, + style={"--chart-bg": "#101820"}, + ) + + +def test_browser_spec_carries_every_slot_class_and_style() -> None: + # The browser client applies dom.class_names / dom.styles to all 23 slots + # (js/src/50_chartview.ts _applySlot), so the spec must carry all 23. + spec, _ = _styled_chart().figure().build_payload() + dom = spec["dom"] + + assert set(dom["class_names"]) == set(CHART_DOM_SLOTS) + assert set(dom["styles"]) == set(CHART_DOM_SLOTS) + + +def test_native_writers_read_chart_style_and_nothing_per_slot() -> None: + # python/xy/_svg.py:767,1481 and python/xy/_raster.py:662 read + # spec["dom"]["style"] — the chart-level token bag — and never + # spec["dom"]["styles"] or spec["dom"]["class_names"]. + figure = _styled_chart().figure() + svg = figure.to_svg() + + assert "#101820" in svg, "chart-level style tokens must reach native output" + assert "#123456" not in svg, "per-slot styles must not reach native output" + assert "class=" not in svg, "the SVG writer emits no class attributes" + for slot in CHART_DOM_SLOTS: + assert f"cls-{slot}" not in svg + + +def test_legend_is_the_one_slot_with_a_parallel_native_channel() -> None: + # xy.legend(style=...) is written twice: to chrome_styles (browser) and to + # legend_options["style"], which the native writers do read. The + # chart-level styles={"legend": ...} form only reaches the browser. + through_component = xy.scatter_chart( + xy.scatter(x=[0.0, 1.0], y=[1.0, 2.0], name="series"), + xy.legend(style={"background": "#123456"}), + ).figure() + assert "#123456" in through_component.to_svg() + + through_slot = xy.scatter_chart( + xy.scatter(x=[0.0, 1.0], y=[1.0, 2.0], name="series"), + styles={"legend": {"background": "#123456"}}, + ).figure() + assert "#123456" not in through_slot.to_svg() + + +def test_custom_css_is_refused_by_every_native_path() -> None: + # custom_css is a browser stylesheet. Native export rejects it by name + # rather than dropping it, and SVG rejects it for every engine because no + # browser can emit vector SVG. + chart = _styled_chart() + + with pytest.raises(ValueError, match=re.escape("custom_css requires engine=Engine.chromium")): + chart.to_image(format="png", engine=Engine.default, custom_css=".x{color:red}") + for engine in (Engine.auto, Engine.default, Engine.chromium): + with pytest.raises(ValueError, match=r"SVG export is native-only|custom_css requires"): + chart.to_image(format="svg", engine=engine, custom_css=".x{color:red}") + + +def test_native_raster_matches_the_svg_writer_on_slot_styling() -> None: + # The two native writers must agree with each other, not only with the doc: + # neither honors a per-slot style, so the styled and unstyled renders are + # pixel-identical. + styled = _raster.render_raster(*_styled_chart().figure().build_payload(), scale=1) + plain = xy.scatter_chart( + xy.scatter(x=[0.0, 1.0], y=[1.0, 2.0], name="series"), + title="title", + style={"--chart-bg": "#101820"}, + ).figure() + assert (styled == _raster.render_raster(*plain.build_payload(), scale=1)).all() From 497e28d8eee1dedaf19bdfec30e24abd70676939 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 01:57:24 +0000 Subject: [PATCH 03/15] =?UTF-8?q?Mark=20plugins:=20contribute=20a=20chart?= =?UTF-8?q?=20kind=20without=20forking=20(=C2=A724=20v0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dossier has listed "no extensibility story" as an open risk since the audit, and it was the one dimension where XY lost outright rather than traded: Matplotlib has custom Artists, Bokeh has custom models, and XY had a hardcoded dict of nineteen appliers. This ships the half of §24 that can be shipped honestly. A plugin declares columns, a `calc` over them, and a `build` that returns built-in `Mark` objects. `build` never receives the `Figure`, the trace list, or the column store — it returns marks, and `_plugin_applier` runs them through the same appliers, the same axis assignment, and the same post-processing as marks written by hand. That containment is the design, not a safety rail bolted on. Because a plugin's output is ordinary traces, it inherits decimation on large inputs, picking, hover, the a11y summary, and all three export paths without writing a line for any of them — and it cannot draw anything the engine could not already draw. §24's third requirement, hover/a11y descriptors, turns out to need nothing at all for the same reason. The custom-shader half of §24 stays deferred, and the argument above is exactly why: a plugin carrying its own GLSL inherits none of that and has to re-earn all of it. Deferring is the position, not the backlog. Composition is one level deep. Plugins compose built-ins, not each other, which keeps the registry a lookup instead of a dependency graph with cycles. The registry also refuses to shadow a built-in kind and refuses to silently replace another plugin — two libraries registering "candlestick" is a conflict their user needs to see, not a race that import order settles. Also records the dividing line in the chart-kind contract: a kind that needs a new GPU primitive is a core kind and pays the six-touch-point checklist; a kind that only composes existing ones is plugin territory and pays nothing. --- CHANGELOG.md | 10 ++ docs/advanced/custom-marks.md | 110 ++++++++++++++++++ docs/app/xy_docs/config.py | 1 + python/xy/__init__.py | 12 ++ python/xy/components.py | 135 +++++++++++++++++++++- python/xy/plugins.py | 147 +++++++++++++++++++++++ spec/api/chart-kind-contract.md | 18 +++ spec/design-dossier.md | 15 ++- tests/test_mark_plugins.py | 199 ++++++++++++++++++++++++++++++++ 9 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 docs/advanced/custom-marks.md create mode 100644 python/xy/plugins.py create mode 100644 tests/test_mark_plugins.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc3ea4b..4b19e8b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ in the README). ## [Unreleased] ### Added +- **Mark plugins**: `xy.register_mark(xy.MarkPlugin(...))` adds a chart kind XY + does not ship, and `xy.mark("name", ...)` uses it. A plugin supplies a `calc` + over its declared columns and a `build` that returns *built-in* marks — the + composition half of dossier §24, with the custom-shader half deliberately + deferred. Because a plugin's output is ordinary traces it inherits decimation, + hover, picking, the a11y summary, and every export path including the two with + no browser; `build` never sees the `Figure`, the trace list, or the column + store, so it cannot draw anything the engine could not already draw. The + registry refuses to shadow a built-in kind and refuses to silently replace + another plugin. - Mark `style=` accepts **`stroke-linecap`** (`butt`/`round`/`square`) on the line family, with the standard SVG semantics: it shapes the polyline's two ends and each dash end. Only the line family takes it, because it describes diff --git a/docs/advanced/custom-marks.md b/docs/advanced/custom-marks.md new file mode 100644 index 00000000..e7519287 --- /dev/null +++ b/docs/advanced/custom-marks.md @@ -0,0 +1,110 @@ +--- +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 gets decimation on large inputs, hover and +picking, the accessibility summary, and every export path — including native PNG +and SVG, which have no browser — without writing a line of code for any of 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 exactly why it inherits everything the +engine already guarantees. A plugin carrying its own shader would inherit none +of it and would have to re-implement decimation, picking, accessibility, and +three export paths correctly on its own. See +[§24 of the design dossier](https://github.com/reflex-dev/xy/blob/main/spec/design-dossier.md) +for the full argument. + +## 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. diff --git a/docs/app/xy_docs/config.py b/docs/app/xy_docs/config.py index 36006525..4b58cfa4 100644 --- a/docs/app/xy_docs/config.py +++ b/docs/app/xy_docs/config.py @@ -132,6 +132,7 @@ "network", ( ("XY Architecture", "/advanced/"), + ("Custom Marks", "/advanced/custom-marks/"), ( "Runtime and Deployment", "/advanced/runtime-and-deployment/", diff --git a/python/xy/__init__.py b/python/xy/__init__.py index b6f81c3d..de2b35cf 100644 --- a/python/xy/__init__.py +++ b/python/xy/__init__.py @@ -41,6 +41,8 @@ "Interaction": ".components", "Legend": ".components", "Mark": ".components", + "MarkContext": ".plugins", + "MarkPlugin": ".plugins", "Modebar": ".components", "Selection": "._figure", "Spring": ".components", @@ -73,6 +75,7 @@ "hexbin": ".components", "hexbin_chart": ".components", "heatmap": ".components", + "mark": ".components", "heatmap_chart": ".components", "hline": ".components", "hist": ".components", @@ -81,6 +84,9 @@ "interaction_config": ".components", "label": ".components", "legend": ".components", + "register_mark": ".plugins", + "registered_marks": ".plugins", + "unregister_mark": ".plugins", "line": ".components", "line_chart": ".components", "marker": ".components", @@ -129,6 +135,8 @@ "Interaction", "Legend", "Mark", + "MarkContext", + "MarkPlugin", "Modebar", "Selection", "Spring", @@ -172,8 +180,11 @@ "legend", "line", "line_chart", + "mark", "marker", "modebar", + "register_mark", + "registered_marks", "scatter", "scatter_chart", "segments", @@ -192,6 +203,7 @@ "tooltip", "triangle_mesh", "triangle_mesh_chart", + "unregister_mark", "violin", "violin_chart", "vline", diff --git a/python/xy/components.py b/python/xy/components.py index 21b82c63..2e2749f1 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -43,7 +43,7 @@ import numpy as np -from . import _validate, channels, export, styles +from . import _validate, channels, export, plugins, styles from ._figure import Figure, Selection from ._typing import ArrayLike, ColorLike, Scalar, TableLike from .dom import CHART_DOM_SLOTS, validate_dom_slots @@ -113,6 +113,7 @@ "legend", "line", "line_chart", + "mark", "marker", "modebar", "scatter", @@ -3260,7 +3261,7 @@ def figure(self) -> Figure: colorbar_candidates: list[dict[str, Any]] = [] for m in marks: data = m.data if m.data is not None else self.data - applier = _MARK_APPLIERS.get(m.kind) + applier = _MARK_APPLIERS.get(m.kind) or _plugin_applier(m.kind) if applier is None: raise TypeError(f"no applier registered for mark kind {m.kind!r}") x_axis_id, y_axis_id = _mark_axis_ids(m, axes) @@ -5199,6 +5200,85 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None: } +def _plugin_column(values: Any) -> Any: + """A plugin's declared column, as an array when it can be one.""" + if values is None or isinstance(values, np.ndarray): + return values + try: + return np.asarray(values) + except (TypeError, ValueError): # pragma: no cover - exotic column objects + return values + + +def _plugin_applier(kind: str) -> Optional[Callable[[Figure, Mark, Any], None]]: + """An applier for a registered mark plugin, or None if `kind` is unknown. + + Resolved per compile rather than folded into `_MARK_APPLIERS` so a plugin + can never shadow a built-in, and so `registered_marks()` stays the single + answer to what is contributed from outside. + """ + resolved = plugins.get_mark_plugin(kind) + if resolved is None: + return None + plugin = resolved + + def apply(fig: Figure, m: Mark, data: Any) -> None: + # Declared columns arrive as arrays, never as the raw list a caller + # happened to pass: a calc function is arithmetic over columns (§24), + # and making every plugin author write np.asarray first would be a + # papercut that shows up as a TypeError in their code, not ours. + columns = { + column: _plugin_column(_resolve(data, m.props.get(column), context=f"{kind}.{column}")) + for column in plugin.columns + } + if plugin.calc is not None: + calculated = plugin.calc(columns) + if not isinstance(calculated, Mapping): + raise TypeError( + f"mark plugin {kind!r} calc must return a mapping of columns, " + f"got {type(calculated).__name__}" + ) + columns = dict(calculated) + # Axis ids are chart plumbing, already applied by the compile loop. + options = { + k: v + for k, v in m.props.items() + if k not in plugin.columns and k not in {"x_axis", "y_axis"} + } + built = plugin.build( + plugins.MarkContext( + columns=columns, + options=options, + name=m.name, + style=dict(m.style or {}), + class_name=m.class_name, + ) + ) + if isinstance(built, (Mark, str, bytes)) or not isinstance(built, Sequence): + raise TypeError( + f"mark plugin {kind!r} build must return a sequence of marks, " + f"got {type(built).__name__}" + ) + for child in built: + if not isinstance(child, Mark): + raise TypeError( + f"mark plugin {kind!r} build returned {type(child).__name__}, " + "expected marks built by xy's mark constructors" + ) + child_applier = _MARK_APPLIERS.get(child.kind) + if child_applier is None: + # One level only: a plugin composes built-in primitives. Letting + # plugins compose each other turns a registry into a dependency + # graph with cycles, for a case nothing has asked for yet. + raise TypeError( + f"mark plugin {kind!r} build returned mark kind {child.kind!r}, " + f"which is not a built-in; plugins compose built-in marks only" + ) + child_applier(fig, child, child.data if child.data is not None else data) + + return apply + + _ANNOTATION_APPLIERS: dict[str, Callable[[Figure, Annotation], None]] = { "arrow": _apply_arrow_annotation, "band": _apply_band_annotation, @@ -5209,6 +5289,57 @@ def _apply_callout_annotation(fig: Figure, annotation: Annotation) -> None: } +def mark( + kind: str, + *, + data: TableLike = None, + name: Optional[str] = None, + style: Optional[dict[str, StyleValue]] = None, + class_name: Optional[str] = None, + key: Any = None, + animation: "Animation | bool | None" = None, + x_axis: str = "x", + y_axis: str = "y", + **fields: Any, +) -> Mark: + """A mark contributed by a registered plugin (`xy.register_mark`). + + `kind` names the plugin. Fields it declared in `MarkPlugin.columns` accept a + column name resolved from ``data`` or values directly, exactly like a + built-in mark's ``x``/``y``; every other keyword reaches the plugin as an + option untouched. + + Args: + kind: Registered plugin name. + data: Table used to resolve column-name inputs. + name: Series label used by legends and tooltips. + style: Mark style overrides, passed to the plugin verbatim. + class_name: Adapter-only trace metadata. + key: Stable row identities, or a column name resolved from ``data``. + animation: Per-mark animation override; ``False`` disables animation. + x_axis: Identifier of the x axis used by this mark. + y_axis: Identifier of the y axis used by this mark. + **fields: The plugin's declared columns and options. + """ + if plugins.get_mark_plugin(kind) is None: + known = ", ".join(plugins.registered_marks()) or "none registered" + raise ValueError(f"unknown mark plugin {kind!r}; registered plugins: {known}") + return Mark( + kind=kind, + data=data, + name=_optional_string(name, "mark name"), + class_name=_optional_string(class_name, "mark class_name"), + style=styles.normalize_css_style(style, "mark style"), + key=key, + animation=animation, + props={ + **fields, + "x_axis": _axis_id(x_axis, "mark x_axis"), + "y_axis": _axis_id(y_axis, "mark y_axis"), + }, + ) + + def chart(*children: Component, **props: Any) -> Chart: """A neutral single-panel chart for overlays and mixed mark composition.""" return Chart("chart", children, **props) diff --git a/python/xy/plugins.py b/python/xy/plugins.py new file mode 100644 index 00000000..954d5967 --- /dev/null +++ b/python/xy/plugins.py @@ -0,0 +1,147 @@ +"""Third-party mark plugins — the v0 of the dossier's §24 extensibility story. + +§24 describes a registered mark plugin as three things: a calc function over +columns, *either* a composition of built-in GPU primitives *or* a WGSL/GLSL +snippet pair, and hover/a11y descriptors. This module ships the first and the +composition half of the second, and deliberately not the shader half. + +The reason is not schedule. A plugin that composes built-in marks cannot draw +anything the engine could not already draw, which means it inherits — for free, +and without a way to get them wrong — LOD and decimation (§28), picking and +hover, the a11y summary (§20), the wire protocol's f32 discipline (§29), and +every export path including the two that have no browser. A plugin carrying its +own shader inherits none of that and has to re-earn all of it. Composition is +where the breadth-without-forking argument actually holds; shaders are a second +system, and they can wait until something real needs one. + + import numpy as np + import xy + + def _calc(columns): + low, high = columns["low"], columns["high"] + return {**columns, "mid": (low + high) / 2.0} + + def _build(ctx): + return [ + xy.segments( + x0=ctx.columns["t"], x1=ctx.columns["t"], + y0=ctx.columns["low"], y1=ctx.columns["high"], + style=ctx.style, + ), + xy.scatter(x=ctx.columns["t"], y=ctx.columns["mid"], size=4), + ] + + xy.register_mark( + xy.MarkPlugin(name="hilo", columns=("t", "low", "high"), calc=_calc, build=_build) + ) + + chart = xy.chart(xy.mark("hilo", t=ts, low=lows, high=highs, data=frame)) + +A plugin's `build` returns built-in `Mark` objects and nothing else; it never +sees the `Figure`, the trace list, or the column store. That is the whole +containment argument, and `tests/test_mark_plugins.py` holds it: the composed +marks go through the same appliers, the same axis assignment, and the same +post-processing as marks written by hand, because they *are* marks written by +hand — just written by someone else's function. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover - import cycle at runtime + from .components import Mark + + +@dataclass(frozen=True) +class MarkContext: + """Everything a plugin's `build` is allowed to see. + + Deliberately not a `Figure`: a plugin composes marks, it does not drive the + engine. `columns` holds the plugin's declared columns after `calc` has run, + with any string values already resolved against the chart's `data=`. + """ + + columns: Mapping[str, Any] + options: Mapping[str, Any] + name: str | None = None + style: Mapping[str, Any] = field(default_factory=dict) + class_name: str | None = None + + +@dataclass(frozen=True) +class MarkPlugin: + """A mark kind contributed from outside the core. + + `columns` names the fields `xy.mark(...)` will resolve against `data=`; + everything else a caller passes lands in `MarkContext.options` untouched. + `calc` runs once over the resolved columns and returns the columns `build` + sees — this is §24's "calc function over columns → columns", running in + Python today rather than in the worker. + """ + + name: str + build: Callable[[MarkContext], "Sequence[Mark]"] + columns: tuple[str, ...] = () + calc: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None + doc: str = "" + + +_REGISTRY: dict[str, MarkPlugin] = {} + + +def register_mark(plugin: MarkPlugin, *, replace: bool = False) -> MarkPlugin: + """Register a mark plugin under its name. + + Refuses to shadow a built-in kind, and refuses to silently replace another + plugin: two libraries registering `"candlestick"` is a conflict their user + needs to know about, not a race the import order settles. + """ + from .components import _MARK_APPLIERS + + if not isinstance(plugin, MarkPlugin): + raise TypeError(f"register_mark expects a MarkPlugin, got {type(plugin).__name__}") + if not plugin.name or not plugin.name.isidentifier(): + raise ValueError(f"mark plugin name must be an identifier, got {plugin.name!r}") + if plugin.name in _MARK_APPLIERS: + raise ValueError(f"{plugin.name!r} is a built-in mark kind and cannot be replaced") + if plugin.name in _REGISTRY and not replace: + raise ValueError( + f"mark plugin {plugin.name!r} is already registered; " + "pass replace=True if that is intended" + ) + if not callable(plugin.build): + raise TypeError(f"mark plugin {plugin.name!r} build must be callable") + if plugin.calc is not None and not callable(plugin.calc): + raise TypeError(f"mark plugin {plugin.name!r} calc must be callable or None") + duplicates = sorted({c for c in plugin.columns if plugin.columns.count(c) > 1}) + if duplicates: + raise ValueError(f"mark plugin {plugin.name!r} repeats column(s) {duplicates}") + _REGISTRY[plugin.name] = plugin + return plugin + + +def unregister_mark(name: str) -> None: + """Remove a registered plugin. Unknown names are a no-op.""" + _REGISTRY.pop(name, None) + + +def registered_marks() -> tuple[str, ...]: + """Names of every registered mark plugin, sorted.""" + return tuple(sorted(_REGISTRY)) + + +def get_mark_plugin(name: str) -> MarkPlugin | None: + return _REGISTRY.get(name) + + +__all__ = [ + "MarkContext", + "MarkPlugin", + "get_mark_plugin", + "register_mark", + "registered_marks", + "unregister_mark", +] diff --git a/spec/api/chart-kind-contract.md b/spec/api/chart-kind-contract.md index a662c694..b9452ebe 100644 --- a/spec/api/chart-kind-contract.md +++ b/spec/api/chart-kind-contract.md @@ -199,6 +199,24 @@ this contract): `pointPick` (participates in the point-geometry GPU pick pass), colors, §36). The registry and `markOf()` are exported (`xy.MARK_KINDS`) — it is the public extension surface. +## Contributing a kind from outside the repo + +The checklist above is for kinds that join the core: six touch points across +Python, the client, and the docs. A kind that only needs to *compose* existing +primitives does not have to pay it. `xy.register_mark` (`python/xy/plugins.py`, +dossier §24) takes a `calc` over declared columns plus a `build` that returns +built-in `Mark` objects, and `components._plugin_applier` runs the result +through the same appliers, axis assignment, and post-processing as a hand-built +mark. `_MARK_APPLIERS` is consulted first, so a plugin can never shadow a +built-in. + +The dividing line is whether the kind needs a **new primitive**. A candlestick, +a dumbbell, a ribbon, a high-low band — all compositions, all plugin territory. +A kind that needs geometry no shader draws yet is a core kind and takes the +checklist. Composition is one level deep on purpose: plugins compose built-ins, +not each other, which keeps the registry a lookup rather than a dependency +graph. + ## Extension points not yet generalized (do it when the case lands) These are still shaped for the marks that exist. Generalize them when a real new diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 313aadf4..2e0e225b 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -652,7 +652,7 @@ missing entirely.* | 12 | No bundle-size budget — a fat WASM blob forfeits a real Plotly pain point (3.5 MB+) | Moderate | Feature-gated modules + CI size budget (§23) | | 13 | Compat shim scope unquantified (~3,000 Plotly schema attributes) | Moderate | Generated conformance suite + explicit degradation contract (§24) | | 14 | Benchmarks measured throughput but not interaction latency; "60fps" undefined | Minor | Latency budgets + p99 framing added (§17, §12) | -| 15 | No extensibility story (Plotly has custom traces) | Minor | Custom-mark API sketch (§24) | +| 15 | No extensibility story (Plotly has custom traces) | Minor | **Shipped v0**: composition mark plugins, `xy.register_mark` (§24). Custom shaders still deferred. | ## 16. Numeric precision & deep zoom @@ -909,6 +909,19 @@ partial-bundle pain). Neither exists today. (c) hover/a11y descriptors so §17/§20 work uncalled-for. Plotly's moat is breadth; a plugin API is how breadth arrives without the core team writing all 40 traces. + **Shipped (v0):** `xy.register_mark` / `xy.MarkPlugin` / `xy.mark` in + `python/xy/plugins.py`. It ships (a) and the composition half of (b); the + shader half is deliberately deferred, and (c) turns out to need nothing — + a plugin that composes built-in marks inherits hover, picking, the a11y + summary, LOD, and every export path *by construction*, because its output is + ordinary traces. `build` returns `Mark` objects and cannot reach the `Figure`, + the trace list, or the column store, so a plugin cannot draw anything the + engine could not already draw. Composition is one level deep: plugins compose + built-ins, not each other. That is what makes the containment argument hold, + and it is the reason the shader half should stay deferred until something + real needs it — a plugin carrying its own shader inherits none of the above + and has to re-earn all of it. + ## 25. Milestone amendments (audit-driven) - **Phase 0** additionally proves: offset-encoding precision on ms-timestamp data diff --git a/tests/test_mark_plugins.py b/tests/test_mark_plugins.py new file mode 100644 index 00000000..53f74a10 --- /dev/null +++ b/tests/test_mark_plugins.py @@ -0,0 +1,199 @@ +"""A mark plugin composes built-in marks and gets the engine for free (§24). + +The containment argument is the whole design: a plugin returns `Mark` objects +and never touches the `Figure`, the trace list, or the column store, so its +traces cannot differ from hand-written ones in decimation, picking, hover, a11y, +or any export path. These tests hold that argument rather than merely checking +that the registry accepts a callable. +""" + +from __future__ import annotations + +import re + +import numpy as np +import pytest + +import xy + + +def _hilo_plugin(name: str = "hilo") -> xy.MarkPlugin: + def calc(columns): + return {**columns, "mid": (columns["low"] + columns["high"]) / 2.0} + + def build(ctx): + 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), + ), + ] + + return xy.MarkPlugin(name=name, columns=("t", "low", "high"), calc=calc, build=build) + + +@pytest.fixture +def hilo(): + plugin = xy.register_mark(_hilo_plugin()) + try: + yield plugin + finally: + xy.unregister_mark(plugin.name) + + +def test_a_plugin_mark_compiles_to_ordinary_traces(hilo) -> None: + chart = xy.chart( + xy.mark("hilo", t=[0.0, 1.0, 2.0], low=[1.0, 2.0, 1.5], high=[3.0, 4.0, 3.5], name="band") + ) + fig = chart.figure() + + # One plugin mark, two primitives, and nothing about them marks them as + # second-class: they are the same kinds the built-in constructors produce. + assert [t.kind for t in fig.traces] == ["segments", "scatter"] + spec, _ = fig.build_payload() + assert {t["kind"] for t in spec["traces"]} == {"segments", "scatter"} + + +def test_calc_runs_before_build_and_its_columns_are_what_build_sees(hilo) -> None: + fig = xy.chart(xy.mark("hilo", t=[0.0, 1.0], low=[1.0, 3.0], high=[3.0, 5.0])).figure() + scatter = next(t for t in fig.traces if t.kind == "scatter") + assert list(scatter.y.values) == [2.0, 4.0] + + +def test_declared_columns_resolve_from_data_like_a_built_in_mark(hilo) -> None: + frame = {"t": [0.0, 1.0], "lo": [1.0, 2.0], "hi": [3.0, 4.0]} + fig = xy.chart(xy.mark("hilo", t="t", low="lo", high="hi", data=frame)).figure() + assert [t.kind for t in fig.traces] == ["segments", "scatter"] + + with pytest.raises(ValueError, match=r"hilo.low column 'nope' not found"): + xy.chart(xy.mark("hilo", t="t", low="nope", high="hi", data=frame)).figure() + + +def test_undeclared_keywords_reach_the_plugin_as_options(hilo) -> None: + fig = xy.chart( + xy.mark("hilo", t=[0.0, 1.0], low=[1.0, 2.0], high=[3.0, 4.0], mid_size=14) + ).figure() + scatter = next(t for t in fig.traces if t.kind == "scatter") + assert scatter.size_ch is not None and scatter.size_ch.constant == pytest.approx(14.0) + + +def test_plugin_traces_reach_every_renderer(hilo) -> None: + fig = xy.chart( + xy.mark( + "hilo", + t=[0.0, 1.0, 2.0], + low=[1.0, 2.0, 1.5], + high=[3.0, 4.0, 3.5], + style={"stroke": "#ff0000"}, + ) + ).figure() + + assert "#ff0000" in fig.to_svg() + from xy import _raster + + image = _raster.render_raster(*fig.build_payload(), scale=1) + assert np.any(image[:, :, 0] > image[:, :, 2]) + + +def test_a_plugin_mark_honors_named_axes(hilo) -> None: + fig = xy.chart( + xy.mark( + "hilo", + t=[0.0, 1.0], + low=[1.0, 2.0], + high=[3.0, 4.0], + y_axis="y2", + ), + xy.y_axis(id="y2"), + ).figure() + assert all(t.y_axis == "y2" for t in fig.traces) + + +def test_the_registry_refuses_to_shadow_or_silently_replace() -> None: + with pytest.raises(ValueError, match="built-in mark kind"): + xy.register_mark(xy.MarkPlugin(name="scatter", build=lambda ctx: [])) + + first = xy.register_mark(_hilo_plugin("twice")) + try: + with pytest.raises(ValueError, match="already registered"): + xy.register_mark(_hilo_plugin("twice")) + assert xy.register_mark(_hilo_plugin("twice"), replace=True) is not first + finally: + xy.unregister_mark("twice") + + with pytest.raises(ValueError, match="must be an identifier"): + xy.register_mark(xy.MarkPlugin(name="not a name", build=lambda ctx: [])) + + +def test_registered_marks_lists_only_contributed_kinds(hilo) -> None: + assert "hilo" in xy.registered_marks() + assert "scatter" not in xy.registered_marks() + xy.unregister_mark("hilo") + assert "hilo" not in xy.registered_marks() + xy.register_mark(_hilo_plugin()) + + +def test_an_unknown_plugin_name_fails_at_construction_not_at_compile() -> None: + with pytest.raises(ValueError, match="unknown mark plugin 'nope'"): + xy.mark("nope", x=[0.0]) + + +def test_build_must_return_built_in_marks() -> None: + def bad_type(ctx): + return "not marks" + + def nested(ctx): + return [xy.mark("inner", x=[0.0])] + + xy.register_mark(xy.MarkPlugin(name="badtype", build=bad_type)) + xy.register_mark(xy.MarkPlugin(name="inner", build=lambda ctx: [])) + xy.register_mark(xy.MarkPlugin(name="nested", build=nested)) + try: + with pytest.raises(TypeError, match="must return a sequence of marks"): + xy.chart(xy.mark("badtype")).figure() + # One level only: plugins compose built-ins, not each other. + with pytest.raises(TypeError, match=re.escape("plugins compose built-in marks only")): + xy.chart(xy.mark("nested")).figure() + finally: + for name in ("badtype", "inner", "nested"): + xy.unregister_mark(name) + + +def test_calc_must_return_a_mapping() -> None: + xy.register_mark( + xy.MarkPlugin( + name="badcalc", columns=("x",), calc=lambda cols: [1, 2], build=lambda ctx: [] + ) + ) + try: + with pytest.raises(TypeError, match="calc must return a mapping"): + xy.chart(xy.mark("badcalc", x=[0.0])).figure() + finally: + xy.unregister_mark("badcalc") + + +def test_a_plugin_cannot_reach_the_figure(hilo) -> None: + seen = {} + + def build(ctx): + seen["ctx"] = ctx + return [xy.line(x=ctx.columns["t"], y=ctx.columns["low"])] + + xy.register_mark(xy.MarkPlugin(name="probe", columns=("t", "low"), build=build)) + try: + xy.chart(xy.mark("probe", t=[0.0, 1.0], low=[1.0, 2.0])).figure() + finally: + xy.unregister_mark("probe") + + ctx = seen["ctx"] + assert set(vars(ctx)) == {"columns", "options", "name", "style", "class_name"} + assert not any(hasattr(ctx, attr) for attr in ("figure", "traces", "store", "fig")) From 225a15c2b9369421a8aca7a2d1d486f3e9e5e496 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 02:04:49 +0000 Subject: [PATCH 04/15] A capability registry that proves itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `benchmarks/categories.py` is the pattern this repo already got right: one committed registry, stable ids, every harness keying off it. It left one gap — nothing asserts that the committed markdown matches the registry, so `spec/benchmarks/results.md` is hand-maintained and can drift. Customization had neither half. `python/xy/styling/capabilities.py` is the registry: one entry per mark style property and per chrome slot, each with its support level in the WebGL client, the SVG writer, and the native rasterizer, plus the extension points and the divergences no style property selects. Two rules make it evidence rather than prose, and both are enforced: It cannot drift from the code. `tests/test_capability_registry.py` asserts the registry covers exactly `CHART_DOM_SLOTS` and exactly the property set `styles.py` compiles — a new property with no entry fails, and so does an entry for a property that was removed. A `planned` row additionally has to still be *refused* by `compile_mark_style`, so a property cannot ship while the matrix keeps understating it. It does not restate what the code already knows. Which mark kinds accept a property is read from `styles.py` at access time, never typed out, so there is no second list to keep in sync. `scripts/gen_capability_matrix.py` generates both the spec page and the public one from the same registry; `--check` fails when either is stale, and the suite runs it. That closes the loop the benchmark side left open. The registry also carries what a flattering table would omit: `stroke-linejoin` sits at `planned` with the WebGL blocker written out, 22 of 23 slots are `none` outside the browser, two of three extension points are `planned`, and the polyline join default is recorded as a live divergence. Those rows are the reason to believe the rest. --- CHANGELOG.md | 10 + docs/app/xy_docs/config.py | 1 + docs/styling/capabilities.md | 111 +++++++++ python/xy/styling/__init__.py | 13 ++ python/xy/styling/capabilities.py | 377 ++++++++++++++++++++++++++++++ scripts/gen_capability_matrix.py | 256 ++++++++++++++++++++ scripts/verify_local.py | 6 + scripts/verify_sdist.py | 4 + spec/README.md | 4 + spec/api/capability-matrix.md | 124 ++++++++++ tests/test_capability_registry.py | 121 ++++++++++ 11 files changed, 1027 insertions(+) create mode 100644 docs/styling/capabilities.md create mode 100644 python/xy/styling/__init__.py create mode 100644 python/xy/styling/capabilities.py create mode 100644 scripts/gen_capability_matrix.py create mode 100644 spec/api/capability-matrix.md create mode 100644 tests/test_capability_registry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b19e8b4..ae0734ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ in the README). ## [Unreleased] ### Added +- **A capability registry** at `python/xy/styling/capabilities.py`: one entry per + mark style property and per chrome slot, each carrying its support level in + the WebGL client, the SVG writer, and the native rasterizer, plus the + extension points and the renderer divergences that no property selects. + `scripts/gen_capability_matrix.py` generates `spec/api/capability-matrix.md` + and the public Capability Matrix page from it, and the test suite fails if + either is stale or if the registry falls out of step with what `styles.py` + actually compiles. `benchmarks/categories.py` made performance claims + auditable and left the generated-table half open; this closes it for + customization. - **Mark plugins**: `xy.register_mark(xy.MarkPlugin(...))` adds a chart kind XY does not ship, and `xy.mark("name", ...)` uses it. A plugin supplies a `calc` over its declared columns and a `build` that returns *built-in* marks — the diff --git a/docs/app/xy_docs/config.py b/docs/app/xy_docs/config.py index 4b58cfa4..199132c1 100644 --- a/docs/app/xy_docs/config.py +++ b/docs/app/xy_docs/config.py @@ -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/"), ), ), diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md new file mode 100644 index 00000000..70493b1a --- /dev/null +++ b/docs/styling/capabilities.md @@ -0,0 +1,111 @@ +--- +title: Capability Matrix +description: What XY can be styled and extended with, per renderer, generated from the capability registry. +--- + + + +# Capability Matrix + +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, so it cannot promise a property +the code does not compile. + +- **10** mark style properties across **20** mark kinds, drawn identically by WebGL, SVG, and the native rasterizer. +- **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 | +| `stroke-linejoin` | svg | — | none | full | full | planned | +| `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`** — XY's default is `round`, not CSS's `butt`. Verified per renderer, not assumed: a Rust coverage test, a rasterized-ink test, and three Chromium screenshots that hash differently per cap. +- **`stroke-linejoin`** — NOT ACCEPTED by `style=` today, deliberately. The SVG writer emits the attribute and `src/raster.rs` implements miter/round/bevel with SVG's miter limit of 4, but the WebGL client draws a polyline as one instanced quad per segment with no join geometry at all, so it cannot tell a miter from a bevel. Two renderers out of three is what `styles.py` exists to prevent. Unblocking it means giving the client a join pass. +- **`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 how this compares to Plotly, Vega-Lite, Bokeh, and Matplotlib — including +the columns XY loses — see the comparison in the project specification. +For what is still alpha, see +[Limitations and Alpha Status](/docs/xy/api-reference/limitations-and-alpha-status/). diff --git a/python/xy/styling/__init__.py b/python/xy/styling/__init__.py new file mode 100644 index 00000000..dc644318 --- /dev/null +++ b/python/xy/styling/__init__.py @@ -0,0 +1,13 @@ +"""Machine-checkable records about XY's styling surface. + +`capabilities` is the one that matters: what can be styled, in which renderer, +and how far it travels. It is imported by the docs generator and pinned by +`tests/test_capability_registry.py`, so a claim about customization can be +checked against it rather than against a reading of `styles.py`. +""" + +from __future__ import annotations + +from . import capabilities + +__all__ = ["capabilities"] diff --git a/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py new file mode 100644 index 00000000..12d9cb0e --- /dev/null +++ b/python/xy/styling/capabilities.py @@ -0,0 +1,377 @@ +"""What XY can be styled with, per renderer — the machine-checkable record. + +`benchmarks/categories.py` made performance claims auditable by publishing one +committed registry of categories that every harness and report keys off. This +module does the same job for customization: one entry per CSS-addressable DOM +slot and one per mark style property, each carrying its support level in each +renderer, so "how customizable is XY" is a table someone can read rather than a +claim someone has to take on faith. + +Two rules keep it honest, both enforced by `tests/test_capability_registry.py`: + +1. **It cannot drift.** The registry must cover exactly `dom.CHART_DOM_SLOTS` + and exactly the property set `styles._supported_mark_style_properties` + compiles — no more, no fewer. Adding a property without a registry entry + fails the suite, and so does keeping an entry for a property that was + removed. +2. **It does not restate what code already knows.** Which mark kinds accept a + property is *derived* from `styles.py` at import, never typed out here, so + the two cannot disagree. + +Support levels are deliberately coarse, because a finer scale would invite +optimism: `full` means the renderer draws the property as specified, `partial` +means it draws something the docs have to qualify, and `none` means it does not +draw it at all. `none` is not a bug report — several of them are deliberate, +and the `notes` field says which. + +Keep `id` values stable: they are the join key for the generated docs table and +for anything downstream that audits coverage. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass, field + +from .. import styles +from ..dom import CHART_DOM_SLOTS + +#: The three mark renderers. Native PDF is intentionally absent: it is produced +#: from the SVG writer's output (`_pdf.svg_to_pdf`), so it inherits the `svg` +#: column exactly and a fourth column would imply an independent implementation +#: that does not exist. +RENDERERS: tuple[str, ...] = ("webgl", "svg", "native") + +#: Where chrome slots can be styled. `browser` covers standalone HTML, the +#: notebook iframe, the widget, the Reflex adapter, and Chromium capture — all +#: of them render the same document with the same client. +SURFACES: tuple[str, ...] = ("browser", "native_raster", "native_vector") + +SUPPORT_LEVELS: frozenset[str] = frozenset({"full", "partial", "none"}) +STATUSES: frozenset[str] = frozenset({"shipped", "partial", "planned"}) +VOCABULARIES: frozenset[str] = frozenset({"css", "svg", "xy"}) + + +@dataclass(frozen=True) +class MarkStyleProperty: + """One property accepted by a mark's `style=` mapping.""" + + id: str + vocabulary: str + compiles_to: str + support: dict[str, str] + status: str + notes: str + + @property + def kinds(self) -> tuple[str, ...]: + """Mark kinds that accept this property, read from `styles.py`.""" + return tuple( + kind + for kind in styles._MARK_KINDS + if self.id in styles._supported_mark_style_properties(kind) + ) + + +@dataclass(frozen=True) +class SlotCapability: + """One stable DOM slot and how far its styling travels.""" + + id: str + support: dict[str, str] + notes: str + channel: str = "" + + +@dataclass(frozen=True) +class ExtensionPoint: + """A way to add behavior XY does not ship, without forking it.""" + + id: str + status: str + entry_point: str + notes: str + limits: tuple[str, ...] = field(default_factory=tuple) + + +MARK_STYLE_PROPERTIES: tuple[MarkStyleProperty, ...] = ( + MarkStyleProperty( + id="opacity", + vocabulary="css", + compiles_to="opacity", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes="Multiplies the mark's own alpha in every renderer.", + ), + MarkStyleProperty( + id="fill", + vocabulary="svg", + compiles_to="color / fill", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes=( + "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." + ), + ), + MarkStyleProperty( + id="fill-opacity", + vocabulary="svg", + compiles_to="fill_opacity", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes="Independent of `opacity`; the two multiply.", + ), + MarkStyleProperty( + id="stroke", + vocabulary="svg", + compiles_to="color / line_color / stroke", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes="The paint for line-like geometry, and the border for filled marks.", + ), + MarkStyleProperty( + id="stroke-opacity", + vocabulary="svg", + compiles_to="stroke_opacity", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes="", + ), + MarkStyleProperty( + id="stroke-width", + vocabulary="svg", + compiles_to="width / line_width / stroke_width", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes="CSS px; a bare number is px, matching the chrome style convention.", + ), + MarkStyleProperty( + id="stroke-dasharray", + vocabulary="svg", + compiles_to="dash", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes=( + "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." + ), + ), + MarkStyleProperty( + id="stroke-linecap", + vocabulary="svg", + compiles_to="linecap", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes=( + "XY's default is `round`, not CSS's `butt`. Verified per renderer, " + "not assumed: a Rust coverage test, a rasterized-ink test, and three " + "Chromium screenshots that hash differently per cap." + ), + ), + MarkStyleProperty( + id="stroke-linejoin", + vocabulary="svg", + compiles_to="", + support={"webgl": "none", "svg": "full", "native": "full"}, + status="planned", + notes=( + "NOT ACCEPTED by `style=` today, deliberately. The SVG writer emits " + "the attribute and `src/raster.rs` implements miter/round/bevel with " + "SVG's miter limit of 4, but the WebGL client draws a polyline as one " + "instanced quad per segment with no join geometry at all, so it " + "cannot tell a miter from a bevel. Two renderers out of three is what " + "`styles.py` exists to prevent. Unblocking it means giving the client " + "a join pass." + ), + ), + MarkStyleProperty( + id="border-radius", + vocabulary="css", + compiles_to="corner_radius", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes="Rect kinds only. `corner_radius=(tip, base)` rounds the two ends separately.", + ), + MarkStyleProperty( + id="marker-shape", + vocabulary="xy", + compiles_to="symbol", + support={"webgl": "full", "svg": "full", "native": "full"}, + status="shipped", + notes=( + "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." + ), + ), +) + + +#: Cross-renderer differences that exist in the *defaults* — no style property +#: selects them, so they are invisible until someone diffs two exports. They +#: belong in the registry for exactly that reason. +KNOWN_RENDERER_DIVERGENCES: tuple[dict[str, str], ...] = ( + { + "id": "polyline_join_default", + "what": "Interior vertices of a wide polyline", + "webgl": "the notch two overlapping segment quads leave", + "svg": "round (the writer names it explicitly)", + "native": "round (the capsule distance field fills the vertex)", + "visible_when": "stroke-width above ~4px at a sharp angle", + "tracked_by": "the `stroke-linejoin` row above", + }, +) + + +#: Slots whose styling reaches the native writers through some channel other +#: than `styles={slot: ...}`, which none of them read. Everything else is +#: browser-only, and that is the answer for 22 of the 23. +_SLOT_EXCEPTIONS: dict[str, tuple[str, str, str]] = { + "legend": ( + "partial", + "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.", + ), + "root": ( + "partial", + "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.", + ), +} + +CHART_SLOTS: tuple[SlotCapability, ...] = tuple( + SlotCapability( + id=slot, + support={ + "browser": "full", + "native_raster": _SLOT_EXCEPTIONS.get(slot, ("none",))[0], + "native_vector": _SLOT_EXCEPTIONS.get(slot, ("none",))[0], + }, + channel=_SLOT_EXCEPTIONS[slot][1] if slot in _SLOT_EXCEPTIONS else "", + notes=_SLOT_EXCEPTIONS[slot][2] if slot in _SLOT_EXCEPTIONS else "", + ) + for slot in CHART_DOM_SLOTS +) + + +#: Ways to add behavior the core does not ship. This is the leg XY lost +#: outright before `xy.register_mark` existed, and the honest entry is still +#: narrower than Matplotlib's custom `Artist`. +EXTENSION_POINTS: tuple[ExtensionPoint, ...] = ( + ExtensionPoint( + id="mark_plugin_composition", + status="shipped", + entry_point="xy.register_mark / xy.MarkPlugin / xy.mark", + notes=( + "A calc over declared columns plus a build that returns built-in " + "marks. Its traces are ordinary traces, so it inherits decimation, " + "picking, hover, a11y, and every export path." + ), + limits=( + "composes built-in marks only, one level deep", + "cannot reach the Figure, the trace list, or the column store", + "cannot add a GPU primitive", + ), + ), + ExtensionPoint( + id="mark_plugin_shader", + status="planned", + entry_point="", + notes=( + "§24's WGSL/GLSL snippet pair. Deferred on purpose: a plugin with " + "its own shader inherits none of what composition gets for free and " + "has to re-earn LOD, picking, a11y, and three export paths." + ), + limits=(), + ), + ExtensionPoint( + id="custom_renderer", + status="planned", + entry_point="", + notes="No way to add a fourth renderer or replace one of the three.", + limits=(), + ), +) + + +def markdown_mark_property_table( + properties: Iterable[MarkStyleProperty] = MARK_STYLE_PROPERTIES, +) -> list[str]: + lines = [ + "| property | vocabulary | mark kinds | webgl | svg | native | status |", + "|---|---|---|---|---|---|---|", + ] + for prop in properties: + kinds = ", ".join(f"`{kind}`" for kind in prop.kinds) or "—" + lines.append( + f"| `{prop.id}` | {prop.vocabulary} | {kinds} | " + f"{prop.support['webgl']} | {prop.support['svg']} | " + f"{prop.support['native']} | {prop.status} |" + ) + return lines + + +def markdown_slot_table(slots: Iterable[SlotCapability] = CHART_SLOTS) -> list[str]: + lines = [ + "| slot | browser | native raster | native vector |", + "|---|---|---|---|", + ] + for slot in slots: + lines.append( + f"| `{slot.id}` | {slot.support['browser']} | " + f"{slot.support['native_raster']} | {slot.support['native_vector']} |" + ) + return lines + + +def markdown_extension_table(points: Iterable[ExtensionPoint] = EXTENSION_POINTS) -> list[str]: + lines = ["| extension point | status | entry point | limits |", "|---|---|---|---|"] + for point in points: + limits = "; ".join(point.limits) or "—" + lines.append(f"| {point.id} | {point.status} | `{point.entry_point or '—'}` | {limits} |") + return lines + + +def summary() -> dict[str, object]: + """Counts a release note can quote without anyone recounting by hand.""" + shipped = [p for p in MARK_STYLE_PROPERTIES if p.status == "shipped"] + return { + "mark_style_properties": len(MARK_STYLE_PROPERTIES), + "mark_style_properties_shipped": len(shipped), + "mark_kinds": len(styles._MARK_KINDS), + "chart_slots": len(CHART_SLOTS), + "slots_styleable_natively": sum( + 1 for s in CHART_SLOTS if s.support["native_raster"] != "none" + ), + "extension_points_shipped": sum(1 for e in EXTENSION_POINTS if e.status == "shipped"), + "known_renderer_divergences": len(KNOWN_RENDERER_DIVERGENCES), + } + + +__all__ = [ + "CHART_SLOTS", + "EXTENSION_POINTS", + "KNOWN_RENDERER_DIVERGENCES", + "MARK_STYLE_PROPERTIES", + "RENDERERS", + "SURFACES", + "ExtensionPoint", + "MarkStyleProperty", + "SlotCapability", + "markdown_extension_table", + "markdown_mark_property_table", + "markdown_slot_table", + "summary", +] diff --git a/scripts/gen_capability_matrix.py b/scripts/gen_capability_matrix.py new file mode 100644 index 00000000..5ae43351 --- /dev/null +++ b/scripts/gen_capability_matrix.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Generate `spec/api/capability-matrix.md` from `xy.styling.capabilities`. + +The committed table is an artifact, never hand-edited. `--check` fails when it +has fallen behind the registry, and `tests/test_capability_registry.py` runs +that check — so the document cannot drift from the registry, and the registry +cannot drift from `styles.py`. That is the whole chain, and it is what makes a +customization claim checkable rather than assertable. + + uv run python scripts/gen_capability_matrix.py --write + uv run python scripts/gen_capability_matrix.py --check +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +from xy.styling import capabilities as caps # noqa: E402 + +REPORT = ROOT / "spec" / "api" / "capability-matrix.md" +PUBLIC = ROOT / "docs" / "styling" / "capabilities.md" + + +def render() -> str: + counts = caps.summary() + lines = [ + "# Capability matrix", + "", + "", + "", + "What XY can be styled and extended with, per renderer. Generated from", + "`python/xy/styling/capabilities.py`, which `tests/test_capability_registry.py`", + "pins to `styles.py` and `dom.py` — so this page cannot claim a property the", + "implementation does not compile, and cannot omit one it does.", + "", + "`full` means the renderer draws the property as specified. `partial` means it", + "draws something the notes have to qualify. `none` means it does not draw it —", + "which is sometimes deliberate, and the notes say which.", + "", + "## In one line", + "", + f"- **{counts['mark_style_properties_shipped']}** mark style properties across " + f"**{counts['mark_kinds']}** mark kinds, drawn by all three renderers.", + f"- **{counts['chart_slots']}** stable chrome slots, CSS- and Tailwind-addressable " + "in the browser; " + f"**{counts['slots_styleable_natively']}** of them reach the native writers, " + "through a channel other than per-slot styles.", + f"- **{counts['extension_points_shipped']}** shipped extension point.", + f"- **{counts['known_renderer_divergences']}** known default divergence between " + "renderers, listed below rather than left to be discovered.", + "", + "## Mark style properties", + "", + "The subset a mark's `style=` mapping accepts. Anything outside it raises", + "before data is ingested, so no renderer silently drops a declaration another", + "one honors.", + "", + ] + lines += caps.markdown_mark_property_table() + lines += ["", "### Notes", ""] + for prop in caps.MARK_STYLE_PROPERTIES: + if prop.notes: + lines.append(f"- **`{prop.id}`** — {prop.notes}") + lines += [ + "", + "## Chrome slots", + "", + "Stable `data-xy-slot` names that accept `class_names=` and `styles=` in the", + "browser. The native raster and vector writers have no cascade: they read the", + "chart-level `style=` token bag and nothing per-slot. That boundary is", + "contracted in [export.md](export.md) §9 and pinned by", + "`tests/test_export_style_survival.py`.", + "", + ] + lines += caps.markdown_slot_table() + lines += ["", "### Notes", ""] + for slot in caps.CHART_SLOTS: + if slot.notes: + lines.append(f"- **`{slot.id}`** (via `{slot.channel}`) — {slot.notes}") + lines += [ + "", + "## Extension points", + "", + "Ways to add behavior the core does not ship, without forking it.", + "", + ] + lines += caps.markdown_extension_table() + lines += ["", "### Notes", ""] + for point in caps.EXTENSION_POINTS: + lines.append(f"- **{point.id}** — {point.notes}") + lines += [ + "", + "## Known renderer divergences", + "", + "Differences in the *defaults*, which no style property selects — invisible", + "until someone diffs two exports. Listed here for that reason.", + "", + "| what | webgl | svg | native | visible when | tracked by |", + "|---|---|---|---|---|---|", + ] + for div in caps.KNOWN_RENDERER_DIVERGENCES: + lines.append( + f"| {div['what']} | {div['webgl']} | {div['svg']} | {div['native']} | " + f"{div['visible_when']} | {div['tracked_by']} |" + ) + lines += [ + "", + "## Regenerating", + "", + "```bash", + "uv run python scripts/gen_capability_matrix.py --write", + "```", + "", + "`--check` fails if this file is stale, and the test suite runs it.", + "", + ] + return "\n".join(lines) + + +def render_public() -> str: + """The same tables, as a public docs page. + + Generated from the same registry as the spec version rather than written + twice: a hand-kept public copy is exactly the drift this whole chain exists + to prevent. + """ + counts = caps.summary() + lines = [ + "---", + "title: Capability Matrix", + "description: What XY can be styled and extended with, per renderer, " + "generated from the capability registry.", + "---", + "", + "", + "", + "# Capability Matrix", + "", + "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, so it cannot promise a property", + "the code does not compile.", + "", + f"- **{counts['mark_style_properties_shipped']}** mark style properties across " + f"**{counts['mark_kinds']}** mark kinds, drawn identically by WebGL, SVG, and the " + "native rasterizer.", + f"- **{counts['chart_slots']}** stable chrome slots for CSS and Tailwind in the browser.", + f"- **{counts['extension_points_shipped']}** 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.", + "", + ] + lines += caps.markdown_mark_property_table() + lines += ["", "### Notes", ""] + for prop in caps.MARK_STYLE_PROPERTIES: + if prop.notes: + lines.append(f"- **`{prop.id}`** — {prop.notes}") + lines += [ + "", + "## 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.", + "", + ] + lines += caps.markdown_slot_table() + lines += ["", "### Notes", ""] + for slot in caps.CHART_SLOTS: + if slot.notes: + lines.append(f"- **`{slot.id}`** (via `{slot.channel}`) — {slot.notes}") + lines += [ + "", + "## Extension points", + "", + "See [Custom Marks](/docs/xy/advanced/custom-marks/) for the worked example.", + "", + ] + lines += caps.markdown_extension_table() + lines += [ + "", + "## 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 |", + "|---|---|---|---|---|", + ] + for div in caps.KNOWN_RENDERER_DIVERGENCES: + lines.append( + f"| {div['what']} | {div['webgl']} | {div['svg']} | {div['native']} | " + f"{div['visible_when']} |" + ) + lines += [ + "", + "For how this compares to Plotly, Vega-Lite, Bokeh, and Matplotlib — including", + "the columns XY loses — see the comparison in the project specification.", + "For what is still alpha, see", + "[Limitations and Alpha Status](/docs/xy/api-reference/limitations-and-alpha-status/).", + "", + ] + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="regenerate the committed matrix") + parser.add_argument( + "--check", action="store_true", help="fail if the committed matrix is stale" + ) + parser.add_argument("--json", action="store_true", help="print the summary counts as JSON") + args = parser.parse_args(argv) + + if args.json: + print(json.dumps(caps.summary(), indent=2)) + return 0 + + targets = ((REPORT, render()), (PUBLIC, render_public())) + if args.write: + for path, document in targets: + path.write_text(document, encoding="utf-8") + print(f"wrote {path.relative_to(ROOT)}") + return 0 + if args.check: + stale = [ + path + for path, document in targets + if (path.read_text(encoding="utf-8") if path.exists() else "") != document + ] + if stale: + names = ", ".join(str(p.relative_to(ROOT)) for p in stale) + print( + f"{names} is stale; run scripts/gen_capability_matrix.py --write", + file=sys.stderr, + ) + return 1 + print("capability matrix is current") + return 0 + print(targets[0][1]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_local.py b/scripts/verify_local.py index e4acb0cf..b59303fe 100644 --- a/scripts/verify_local.py +++ b/scripts/verify_local.py @@ -80,6 +80,11 @@ def _base_checks( "public performance-claim guardrails", (py, "scripts/check_claim_guardrails.py"), ), + Check( + "capability_matrix", + "generated capability matrix is current", + (py, "scripts/gen_capability_matrix.py", "--check"), + ), Check( "benchmark_harness", "benchmark metadata, report, regression, and claim guardrail tests", @@ -284,6 +289,7 @@ def _base_checks( "python_floor", "public_api", "claim_guardrails", + "capability_matrix", "ci_workflow", "ruff_check", "ruff_format", diff --git a/scripts/verify_sdist.py b/scripts/verify_sdist.py index 40587cd1..e0fdb2ab 100644 --- a/scripts/verify_sdist.py +++ b/scripts/verify_sdist.py @@ -88,7 +88,10 @@ "python/xy/interaction.py", "python/xy/kernels.py", "python/xy/lod.py", + "python/xy/plugins.py", "python/xy/py.typed", + "python/xy/styling/__init__.py", + "python/xy/styling/capabilities.py", "python/xy/static/index.js", "python/xy/static/standalone.js", "python/xy/widget.py", @@ -102,6 +105,7 @@ "examples/reflex/xy_reflex_demo/xy_reflex_demo.py", "scripts/check_public_api.py", "scripts/check_claim_guardrails.py", + "scripts/gen_capability_matrix.py", "scripts/check_python_floor.py", "scripts/check_regressions.py", "scripts/bench_dashboard.py", diff --git a/spec/README.md b/spec/README.md index e8b85104..a9cc49f5 100644 --- a/spec/README.md +++ b/spec/README.md @@ -24,6 +24,10 @@ The public surface: what callers can build, style, export, and interact with. `tests/test_docs_examples.py`, so API drift fails the suite. - [`chart-kind-contract.md`](api/chart-kind-contract.md) — how to add a 2D chart type: what the shared machinery provides and what a new kind must supply. +- [`capability-matrix.md`](api/capability-matrix.md) — **generated**: what can be + styled and extended, per renderer, from `python/xy/styling/capabilities.py`. + Regenerate with `scripts/gen_capability_matrix.py --write`; the test suite + fails if it is stale. - [`chart-roadmap.md`](api/chart-roadmap.md) — the single 2D-first chart-type coverage backlog, prioritized by popularity and primitive reuse. - [`export.md`](api/export.md) — how a figure becomes bytes: one entry point diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md new file mode 100644 index 00000000..458ebe6b --- /dev/null +++ b/spec/api/capability-matrix.md @@ -0,0 +1,124 @@ +# Capability matrix + + + +What XY can be styled and extended with, per renderer. Generated from +`python/xy/styling/capabilities.py`, which `tests/test_capability_registry.py` +pins to `styles.py` and `dom.py` — so this page cannot claim a property the +implementation does not compile, and cannot omit one it does. + +`full` means the renderer draws the property as specified. `partial` means it +draws something the notes have to qualify. `none` means it does not draw it — +which is sometimes deliberate, and the notes say which. + +## In one line + +- **10** mark style properties across **20** mark kinds, drawn by all three renderers. +- **23** stable chrome slots, CSS- and Tailwind-addressable in the browser; **2** of them reach the native writers, through a channel other than per-slot styles. +- **1** shipped extension point. +- **1** known default divergence between renderers, listed below rather than left to be discovered. + +## Mark style properties + +The subset a mark's `style=` mapping accepts. Anything outside it raises +before data is ingested, so no renderer silently drops a declaration another +one honors. + +| 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 | +| `stroke-linejoin` | svg | — | none | full | full | planned | +| `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`** — XY's default is `round`, not CSS's `butt`. Verified per renderer, not assumed: a Rust coverage test, a rasterized-ink test, and three Chromium screenshots that hash differently per cap. +- **`stroke-linejoin`** — NOT ACCEPTED by `style=` today, deliberately. The SVG writer emits the attribute and `src/raster.rs` implements miter/round/bevel with SVG's miter limit of 4, but the WebGL client draws a polyline as one instanced quad per segment with no join geometry at all, so it cannot tell a miter from a bevel. Two renderers out of three is what `styles.py` exists to prevent. Unblocking it means giving the client a join pass. +- **`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 accept `class_names=` and `styles=` in the +browser. The native raster and vector writers have no cascade: they read the +chart-level `style=` token bag and nothing per-slot. That boundary is +contracted in [export.md](export.md) §9 and pinned by +`tests/test_export_style_survival.py`. + +| 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 + +Ways to add behavior the core does not ship, without forking it. + +| 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 | `—` | — | + +### Notes + +- **mark_plugin_composition** — A calc over declared columns plus a build that returns built-in marks. Its traces are ordinary traces, so it inherits decimation, picking, hover, a11y, and every export path. +- **mark_plugin_shader** — §24's WGSL/GLSL snippet pair. Deferred on purpose: a plugin with its own shader inherits none of what composition gets for free and has to re-earn LOD, picking, a11y, and three export paths. +- **custom_renderer** — No way to add a fourth renderer or replace one of the three. + +## Known renderer divergences + +Differences in the *defaults*, which no style property selects — invisible +until someone diffs two exports. Listed here for that reason. + +| what | webgl | svg | native | visible when | tracked by | +|---|---|---|---|---|---| +| 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 | the `stroke-linejoin` row above | + +## Regenerating + +```bash +uv run python scripts/gen_capability_matrix.py --write +``` + +`--check` fails if this file is stale, and the test suite runs it. diff --git a/tests/test_capability_registry.py b/tests/test_capability_registry.py new file mode 100644 index 00000000..00cb90b4 --- /dev/null +++ b/tests/test_capability_registry.py @@ -0,0 +1,121 @@ +"""The capability registry has to be checkable, or it is just a nicer README. + +`benchmarks/categories.py` set the pattern for performance and left one gap: +nothing asserts that the committed markdown table matches the registry, so +`spec/benchmarks/results.md` is hand-maintained and can drift. This module +closes that gap for customization — the registry is pinned to `styles.py` and +`dom.py`, and the generated document is pinned to the registry. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from xy import styles +from xy.dom import CHART_DOM_SLOTS +from xy.styling import capabilities as caps + +ROOT = Path(__file__).resolve().parents[1] +REPORT = ROOT / "spec" / "api" / "capability-matrix.md" + + +def _compiled_property_names() -> set[str]: + return { + prop + for kind in styles._MARK_KINDS + for prop in styles._supported_mark_style_properties(kind) + } + + +def test_registry_covers_exactly_the_properties_styles_compiles() -> None: + # The point of the registry is that it cannot quietly fall behind the + # implementation. A new property with no entry fails here; so does an entry + # for a property that was removed. + registered = {p.id for p in caps.MARK_STYLE_PROPERTIES if p.status != "planned"} + assert registered == _compiled_property_names() + + +def test_planned_properties_are_exactly_the_ones_style_still_refuses() -> None: + # A `planned` row is a promise that the property is NOT accepted yet. If one + # ships without its status changing, the matrix would understate the library + # — which is the failure mode that is easy to miss. + for prop in caps.MARK_STYLE_PROPERTIES: + if prop.status != "planned": + continue + assert prop.id not in _compiled_property_names(), ( + f"{prop.id!r} is marked planned but styles.py now compiles it" + ) + with pytest.raises(ValueError, match="unsupported CSS property"): + styles.compile_mark_style("line", {prop.id: "miter"}) + + +def test_registry_covers_exactly_the_public_dom_slots() -> None: + assert tuple(slot.id for slot in caps.CHART_SLOTS) == CHART_DOM_SLOTS + + +def test_property_kinds_are_derived_not_restated() -> None: + # `kinds` reads from styles.py at access time rather than being typed out, + # so there is no second list to keep in sync. Guard that it stays that way. + for prop in caps.MARK_STYLE_PROPERTIES: + for kind in prop.kinds: + assert prop.id in styles._supported_mark_style_properties(kind) + if prop.status != "planned": + assert prop.kinds, f"{prop.id!r} is shipped but no mark kind accepts it" + + +def test_support_and_status_vocabularies_are_closed() -> None: + for prop in caps.MARK_STYLE_PROPERTIES: + assert set(prop.support) == set(caps.RENDERERS) + assert set(prop.support.values()) <= caps.SUPPORT_LEVELS + assert prop.status in caps.STATUSES + assert prop.vocabulary in caps.VOCABULARIES + for slot in caps.CHART_SLOTS: + assert set(slot.support) == set(caps.SURFACES) + assert set(slot.support.values()) <= caps.SUPPORT_LEVELS + for point in caps.EXTENSION_POINTS: + assert point.status in caps.STATUSES + + +def test_every_partial_or_missing_entry_explains_itself() -> None: + # "none" is allowed and several are deliberate — but an unexplained gap is + # indistinguishable from a bug, so anything short of `full` owes a reason. + for prop in caps.MARK_STYLE_PROPERTIES: + if any(level != "full" for level in prop.support.values()): + assert prop.notes.strip(), f"{prop.id!r} is not full everywhere and says nothing" + for slot in caps.CHART_SLOTS: + if slot.support["native_raster"] == "partial": + assert slot.notes.strip() and slot.channel.strip() + + +def test_the_shipped_extension_point_is_the_one_that_exists() -> None: + import xy + + shipped = {e.id for e in caps.EXTENSION_POINTS if e.status == "shipped"} + assert shipped == {"mark_plugin_composition"} + assert callable(xy.register_mark) + for point in caps.EXTENSION_POINTS: + if point.status == "shipped": + assert point.entry_point and point.limits, f"{point.id!r} claims no limits" + + +def test_the_committed_matrix_is_regenerated_not_hand_edited() -> None: + # The gap `benchmarks/categories.py` left open: a committed table nothing + # checks. Running the generator must be a no-op on a clean tree. + result = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "gen_capability_matrix.py"), "--check"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_the_matrix_names_every_property_and_slot() -> None: + text = REPORT.read_text(encoding="utf-8") + for prop in caps.MARK_STYLE_PROPERTIES: + assert f"`{prop.id}`" in text + for slot in caps.CHART_SLOTS: + assert f"`{slot.id}`" in text From ad51a263a1af35975e908fb0b019dc84afecdfae Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 02:09:21 +0000 Subject: [PATCH 05/15] Commit the customization comparison, losses included Performance claims in this repo are backed by a committed methodology and a harness anyone can rerun. Customization claims had nothing, which is why "more customizable than Plotly" was unanswerable rather than merely unproven. `spec/api/customization-vs-alternatives.md` gives it the same treatment: pinned competitor versions taken from `benchmarks/requirements-ci.in`, a named method on every row (`schema` for anything counted from a machine-readable schema, `code` for anything read from source with the symbol named, `docs` for intended-contract questions where the library's own documentation is the right source), and a copyable claim taxonomy modelled on the benchmark one. The loss table is the point, not an appendix. XY loses on total attribute surface by roughly three orders of magnitude, on chart families, on writing a genuinely new mark, on custom rendering primitives, on a style-defaults file format, and on two export paths. A matrix where one library wins everything is evidence of nothing, so `tests/test_customization_comparison.py` fails if the loss table is emptied, if a loss stops naming who wins, if a quoted version drifts from the pins, or if the "do not claim breadth" rule disappears. The XY column is not typed in either: the countable parts are checked against `xy.styling.capabilities`, so the comparison cannot quietly overstate what the registry knows. `spec/benchmarks/methodology.md` promised a standing "where XY loses" table and never grew one. This is that table, for the dimension that needed it most. --- CHANGELOG.md | 7 ++ spec/README.md | 4 ++ spec/api/customization-vs-alternatives.md | 80 +++++++++++++++++++++++ tests/test_customization_comparison.py | 75 +++++++++++++++++++++ 4 files changed, 166 insertions(+) create mode 100644 spec/api/customization-vs-alternatives.md create mode 100644 tests/test_customization_comparison.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ae0734ba..77a1bf6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ in the README). ## [Unreleased] ### Added +- A **committed customization comparison** against Plotly, Vega-Lite, Bokeh, and + Matplotlib (`spec/api/customization-vs-alternatives.md`), with the same + discipline as the benchmark harness: pinned competitor versions, a named + method per row (`schema`, `code`, or `docs`), a claim taxonomy, and a loss + table that `tests/test_customization_comparison.py` refuses to let anyone + empty. XY's own numbers in it are checked against the capability registry + rather than typed in. - **A capability registry** at `python/xy/styling/capabilities.py`: one entry per mark style property and per chrome slot, each carrying its support level in the WebGL client, the SVG writer, and the native rasterizer, plus the diff --git a/spec/README.md b/spec/README.md index a9cc49f5..cb5a6ce0 100644 --- a/spec/README.md +++ b/spec/README.md @@ -30,6 +30,10 @@ The public surface: what callers can build, style, export, and interact with. fails if it is stale. - [`chart-roadmap.md`](api/chart-roadmap.md) — the single 2D-first chart-type coverage backlog, prioritized by popularity and primitive reuse. +- [`customization-vs-alternatives.md`](api/customization-vs-alternatives.md) — + the committed comparison of customization and extensibility against Plotly, + Vega-Lite, Bokeh, and Matplotlib, with its method, its pinned versions, and + the rows XY loses. Pinned by `tests/test_customization_comparison.py`. - [`export.md`](api/export.md) — how a figure becomes bytes: one entry point across five image formats, deterministic engine choice, browser-free default. - [`interaction.md`](api/interaction.md) — the authority on which browser diff --git a/spec/api/customization-vs-alternatives.md b/spec/api/customization-vs-alternatives.md new file mode 100644 index 00000000..75c75860 --- /dev/null +++ b/spec/api/customization-vs-alternatives.md @@ -0,0 +1,80 @@ +# Customization versus the alternatives + +`spec/benchmarks/methodology.md` exists because performance claims are worthless +without a committed harness. Customization claims have the same problem and had +no equivalent, so this document is that equivalent: the question asked in each +row, the method used to answer it, the version of every library compared, and — +the part that makes it evidence rather than marketing — the rows XY loses. + +A matrix where one library wins everything is evidence of nothing. If a future +edit removes the losses, it has removed the reason to believe the wins. + +## Method + +Same rules as the benchmark harness, adapted to a capability question. + +1. **Versions are pinned and recorded.** The comparison is against the versions + in `benchmarks/requirements-ci.in`: Plotly 6.9.0, Bokeh 3.9.1, Altair 6.2.2 + (Vega-Lite 6), Matplotlib 3.11.1. A row that changes when a competitor + releases is a row that has to be re-checked, not silently inherited. +2. **Every row names its method.** One of: `schema` (counted from a machine + -readable schema — reproducible by running the script named in the row), + `code` (read from the library's source, with the symbol named), or `docs` + (taken from the library's own documentation, which is the right source for + an intended-contract question and the wrong one for a coverage count). +3. **Same-work comparison.** XY's per-slot DOM styling is not compared against + Matplotlib's rcParams as if they answered the same question. Where two + libraries solve a problem in incomparable ways the row says so instead of + picking the framing that flatters XY. +4. **The XY column is generated, not asserted.** Everything in the XY column + that is countable comes from `xy.styling.capabilities`, which + `tests/test_capability_registry.py` pins to the actual implementation, and + `tests/test_customization_comparison.py` re-checks the counts quoted below + against it. A number here that the registry does not know about fails the + suite rather than sitting in prose. +5. **Losses ship.** The loss table below is not an appendix. It is the reason + the win table is credible, and it is maintained with the same care. + +## What XY is genuinely better at + +| Question | XY | Plotly | Vega-Lite | Bokeh | Matplotlib | Method | +| --- | --- | --- | --- | --- | --- | --- | +| Can host design tokens (`var(--brand)`) paint marks? | yes, in every renderer — resolved in Python for static export | no | no | no | no | code | +| Stable, documented DOM slot contract for chrome | 23 named slots, `data-xy-slot` | no published contract | no | partial (CSS classes, undocumented as contract) | n/a — no DOM | code | +| Tailwind classes on chart chrome | yes, per slot | no | no | partial | n/a | docs | +| Same style declaration honored by GPU, vector, and raster output | yes, by construction: one validated subset compiled once | no — the browser and Kaleido share a renderer, but there is no native vector/raster path to agree with | no | no | n/a — one renderer family | code | +| Unsupported style declaration fails loudly | yes, before ingest | no — unknown keys are dropped | schema-validated | partial | partial | code | +| Per-property record of which renderer draws what | yes, `xy.styling.capabilities`, generated and drift-tested | no | no | no | no | code | + +## What XY is genuinely worse at + +| Question | XY | Best alternative | Method | +| --- | --- | --- | --- | +| Total styleable attribute surface | 10 shipped mark style properties, 15 axis keys, 23 chrome slots | **Plotly**: 9,472 non-`src` leaf attributes across 49 trace types and `layout` (plotly 6.9.0) | schema | +| Chart families you can style at all | 20 mark kinds | **Plotly**: 49 trace types, including 3-D, geo, and financial families XY does not implement | schema | +| Writing a genuinely new mark | `xy.register_mark` composes existing primitives only; no custom shader | **Matplotlib**: a custom `Artist` can draw anything the backend can | docs | +| Custom rendering primitives | none — deferred from §24 v0 | **Bokeh**: custom models ship their own TypeScript | docs | +| Global style defaults as a first-class file format | theme object only | **Matplotlib**: `matplotlibrc` plus ~300 rcParams | docs | +| Per-slot styles and classes in static export | dropped — browser only | **Matplotlib**: no split to have, every style path is the export path | code | +| Colorbar styling in static export | no native channel at all | **Plotly**: colorbar attributes apply in Kaleido export | code | +| Author stylesheet in native PNG | rejected; needs `Engine.chromium` | **Plotly**: n/a — Kaleido is always a browser | code | + +## Copyable claim taxonomy + +The performance version of this table is at `spec/benchmarks/results.md` +§ Copyable claim taxonomy, and the rule is the same: a claim is publishable only +if it names the dimension it is true on. + +| Claim shape | Safe wording | Required context | +| --- | --- | --- | +| Token themeability | "XY resolves host CSS variables into mark paint in the browser, SVG, and native PNG; Plotly, Bokeh, Vega-Lite, and Matplotlib do not." | the mechanism, the renderers, the named alternatives | +| Slot contract | "XY publishes 23 stable chrome slots as a supported contract; the alternatives compared expose no equivalent published contract." | slot count, "published contract" not "styleable" | +| Cross-renderer fidelity | "A mark style declaration XY accepts is drawn by all three renderers or rejected at build time." | the subset is validated; this is not a claim about arbitrary CSS | +| Breadth | **Do not claim breadth.** Plotly's attribute surface is roughly three orders of magnitude larger. | — | +| Extensibility | "XY marks can be extended by composing built-in primitives without forking; it does not offer custom shaders or a custom-artist API." | what the plugin can and cannot do | +| Cap/join fidelity | "`stroke-linecap` is drawn identically by all three renderers, verified per renderer; `stroke-linejoin` is not offered because the WebGL client has no join geometry." | the specific property, the specific blocker | + +Claims that are never defensible, regardless of context: "most customizable", +"most themeable charting library", "more extensible than Matplotlib", "as +customizable as Plotly". `scripts/check_claim_guardrails.py` rejects the first +two shapes mechanically; the other two are judgment and belong in review. diff --git a/tests/test_customization_comparison.py b/tests/test_customization_comparison.py new file mode 100644 index 00000000..21ee7d95 --- /dev/null +++ b/tests/test_customization_comparison.py @@ -0,0 +1,75 @@ +"""The comparison matrix quotes numbers; those numbers have to still be true. + +`spec/benchmarks/methodology.md` bans numbers in prose that no artifact backs. +The customization comparison is prose by necessity — most of its rows are +capability questions, not measurements — so the parts that *are* countable are +checked here against the registry they came from. The rest is held to a +different standard: it must name its method, and it must keep its losses. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from xy.styling import capabilities as caps + +DOC = Path(__file__).resolve().parents[1] / "spec" / "api" / "customization-vs-alternatives.md" + + +def _text() -> str: + return DOC.read_text(encoding="utf-8") + + +def test_the_xy_counts_match_the_registry() -> None: + text = _text() + counts = caps.summary() + + assert f"{counts['mark_style_properties_shipped']} shipped mark style properties" in text + assert f"{counts['chart_slots']} chrome slots" in text + + +def test_every_row_names_its_method() -> None: + # A row without a method is an assertion, and this document exists to not be + # a pile of assertions. Both tables end in a method column. + rows = [ + line + for line in _text().splitlines() + if line.startswith("| ") and "---" not in line and not line.startswith("| Question") + ] + matrix_rows = [r for r in rows if r.rstrip().endswith(("| schema |", "| code |", "| docs |"))] + assert len(matrix_rows) >= 12, "expected the win and loss tables to carry method columns" + + +def test_the_loss_table_is_not_empty_and_names_who_wins() -> None: + # The single most important property of this document. A matrix where XY + # wins every row is evidence of nothing, so an edit that empties the loss + # table has to fail rather than read as an improvement. + section = _text().split("## What XY is genuinely worse at", 1) + assert len(section) == 2, "the loss section must exist" + body = section[1].split("## ", 1)[0] + rows = [line for line in body.splitlines() if line.startswith("| ") and "---" not in line] + data_rows = [r for r in rows if not r.startswith("| Question")] + assert len(data_rows) >= 6, "the loss table must keep its rows" + assert all("**" in row for row in data_rows), "each loss must name the library that wins" + + +def test_pinned_competitor_versions_match_the_benchmark_constraints() -> None: + # Same rule as the benchmark harness: a comparison against an unpinned + # version is a comparison against nothing in particular. + text = _text() + pins = Path(__file__).resolve().parents[1] / "benchmarks" / "requirements-ci.in" + pinned = dict( + re.findall(r"^([a-z0-9-]+)==([0-9.]+)", pins.read_text(encoding="utf-8"), re.MULTILINE) + ) + for package, label in (("plotly", "Plotly"), ("bokeh", "Bokeh"), ("matplotlib", "Matplotlib")): + assert f"{label} {pinned[package]}" in text, ( + f"{label} is pinned at {pinned[package]} but the comparison quotes another version" + ) + + +def test_breadth_is_named_as_a_loss_not_a_win() -> None: + # The one claim shape most likely to creep in and least defensible. + text = _text() + assert "**Do not claim breadth.**" in text + assert "most customizable" in text.split("never defensible", 1)[1] From 4ea855ece8d4392ef453fdff15a017f91b50a1ae Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 02:13:39 +0000 Subject: [PATCH 06/15] =?UTF-8?q?Turn=20Plotly=20compat=20into=20a=20numbe?= =?UTF-8?q?r,=20and=20correct=20=C2=A724's=20two=20wrong=20premises?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §24 has asked since the audit for compat to be "a measured number" instead of vibes. Implementing it turned up two things the plan assumed that are not true. **No published Plotly wheel ships `plot-schema.json`.** Checked against 4.14.3, 5.24.1, and 6.9.0: it is a plotly.js artifact, and the Python package generates validator classes from it. The equivalent — and a better shape for this job, being one flat entry per dotted path — is `plotly/validators/_validators.json`. The real surface is 9,472 leaf attributes once compound namespaces and `*src` column-reference companions come out, three times the ~3,000 the dossier estimated. **There is no Plotly shim to warn.** The shipped shim is `xy.pyplot`, which is Matplotlib-flavored, so "supported" cannot mean "the shim accepts this attribute" — nothing would be accepting it. It means XY can express the same visual outcome, decided by a committed rule table in the script. 9,472 attributes cannot be audited one at a time, and a table implying they were would be fiction; every rule names the XY surface that answers it, so any row can be traced to the claim that produced it. The number, scoped to the trace types XY implements plus `layout`: 344 supported and 126 mapped-with-difference of 3,387. Whole-schema: 345 and 126 of 9,472, where the denominator is dominated by 3-D, geo, polar, and financial traces XY does not implement. Both are published because either alone misleads — the first overstates by hiding its scope, the second understates by counting work never attempted. 2,190 attributes land in `unsupported/unclassified`: no committed rule claims them. That bucket is reported rather than folded into a friendlier one, and `tests/test_plotly_schema_coverage.py` fails if it disappears, if the report is hand-edited, if `*src` companions pad the denominator, or if the scoped number loses the sentence that scopes it. --- CHANGELOG.md | 10 + scripts/plotly_schema_coverage.py | 306 +++++++++++++++++++++++++++ scripts/verify_sdist.py | 1 + spec/README.md | 4 + spec/api/plotly-coverage.md | 62 ++++++ spec/design-dossier.md | 25 ++- tests/test_plotly_schema_coverage.py | 99 +++++++++ 7 files changed, 506 insertions(+), 1 deletion(-) create mode 100644 scripts/plotly_schema_coverage.py create mode 100644 spec/api/plotly-coverage.md create mode 100644 tests/test_plotly_schema_coverage.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 77a1bf6c..4119dfe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ in the README). ## [Unreleased] ### Added +- **Plotly attribute coverage is now a number** (§24): + `scripts/plotly_schema_coverage.py` ingests Plotly's schema and classifies + every attribute `supported | mapped-with-difference | unsupported` by a + committed rule table, writing `spec/api/plotly-coverage.md`. Two corrections + to the plan it implements: no published Plotly wheel ships `plot-schema.json` + (the equivalent is `validators/_validators.json`, and the real surface is + 9,472 leaf attributes rather than ~3,000), and there is no Plotly shim to warn + — so "supported" means XY can express the same visual outcome, not that a shim + accepts the key. Scoped to the trace types XY implements plus `layout`: 344 + supported and 126 mapped-with-difference of 3,387. - A **committed customization comparison** against Plotly, Vega-Lite, Bokeh, and Matplotlib (`spec/api/customization-vs-alternatives.md`), with the same discipline as the benchmark harness: pinned competitor versions, a named diff --git a/scripts/plotly_schema_coverage.py b/scripts/plotly_schema_coverage.py new file mode 100644 index 00000000..93d01e97 --- /dev/null +++ b/scripts/plotly_schema_coverage.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Classify Plotly's attribute schema against what XY can express (§24). + +§24 says compat should be "a measured number", and names Plotly's +`plot-schema.json` as the thing to ingest. Two facts about the world have +changed since that was written, and this script is written to both of them: + +1. **No published Plotly wheel ships `plot-schema.json`.** Verified against + 4.14.3, 5.24.1, and 6.9.0 — the file is a plotly.js artifact and the Python + package generates validator classes from it instead. The equivalent, and a + better shape for this job, is `plotly/validators/_validators.json`: one flat + entry per dotted attribute path with its validator class. +2. **XY has no Plotly shim.** The shipped shim is `xy.pyplot`, which is + Matplotlib-flavored. So "supported" here cannot mean "the shim accepts the + attribute" — there is nothing to accept it. It means *XY can express the + same visual outcome*, and the rule that says so is committed below where it + can be argued with. + +Classification is rule-driven, not hand-enumerated: 9,472 attributes cannot be +audited one at a time, and a table that claims otherwise would be fiction. Each +rule names the XY surface that answers the attribute, so an unfamiliar reader +can check any row by following the pointer. Attributes no rule claims land in +`unsupported/unclassified`, which is the honest bucket — it is reported, never +folded into a friendlier one. + + uv run --extra bench python scripts/plotly_schema_coverage.py --check + uv run --extra bench python scripts/plotly_schema_coverage.py --write +""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import sys +from collections import Counter +from pathlib import Path +from typing import NamedTuple + +ROOT = Path(__file__).resolve().parents[1] +REPORT = ROOT / "spec" / "api" / "plotly-coverage.md" + +SUPPORTED = "supported" +MAPPED = "mapped-with-difference" +UNSUPPORTED = "unsupported" + +#: Trace types XY implements, mapped to the XY mark that answers them. Every +#: other Plotly trace type is `unsupported/no-such-trace` — a truthful bucket, +#: and by far the largest one. +TRACE_EQUIVALENTS: dict[str, str] = { + "scatter": "xy.scatter / xy.line", + "scattergl": "xy.scatter", + "bar": "xy.bar / xy.column", + "histogram": "xy.histogram", + "histogram2d": "xy.heatmap", + "heatmap": "xy.heatmap", + "box": "xy.box", + "violin": "xy.violin", + "contour": "xy.contour", + "mesh3d": "", # deliberately absent: 3-D is out of v1 +} + +#: Roots that are not trace types at all. +META_ROOTS = frozenset({"data", "frame", "frames"}) + + +class Rule(NamedTuple): + """One committed claim about a family of Plotly attributes.""" + + pattern: str + verdict: str + surface: str + note: str + + +#: Order matters: the first matching rule wins, so specific patterns precede +#: general ones. `*` matches within a path segment and across segments alike +#: (fnmatch semantics), which is what makes `*.marker.symbol` reach both +#: `scatter.marker.symbol` and `scatter.selected.marker.symbol`. +RULES: tuple[Rule, ...] = ( + # --- paint and geometry XY draws exactly ------------------------------- + Rule("*.marker.symbol", SUPPORTED, "style={'marker-shape': ...}", "17 shared shapes"), + Rule("*.marker.color", SUPPORTED, "color= / style={'fill': ...}", ""), + Rule("*.marker.opacity", SUPPORTED, "style={'fill-opacity': ...}", ""), + Rule("*.marker.size", SUPPORTED, "size=", ""), + Rule("*.marker.line.color", SUPPORTED, "style={'stroke': ...}", ""), + Rule("*.marker.line.width", SUPPORTED, "style={'stroke-width': ...}", ""), + Rule("*.line.dash", SUPPORTED, "style={'stroke-dasharray': ...}", "named presets and px lists"), + Rule("*.line.color", SUPPORTED, "style={'stroke': ...}", ""), + Rule("*.line.width", SUPPORTED, "style={'stroke-width': ...}", ""), + Rule("*.line.shape", MAPPED, "curve=", "XY has linear and monotone-cubic, not all six"), + Rule("*.opacity", SUPPORTED, "style={'opacity': ...}", ""), + Rule("*.name", SUPPORTED, "name=", ""), + Rule("*.visible", MAPPED, "compose the chart without the mark", "no per-trace toggle prop"), + Rule("*.colorscale", SUPPORTED, "colormap=", "custom ramps accepted"), + Rule("*.showscale", SUPPORTED, "xy.colorbar()", ""), + Rule("*.cmin", SUPPORTED, "color_domain=", ""), + Rule("*.cmax", SUPPORTED, "color_domain=", ""), + # --- layout XY answers -------------------------------------------------- + Rule("layout.xaxis.type", SUPPORTED, "xy.x_axis(scale=...)", ""), + Rule("layout.yaxis.type", SUPPORTED, "xy.y_axis(scale=...)", ""), + Rule("layout.*axis.title.text", SUPPORTED, "xy.x_axis(label=...)", ""), + Rule("layout.*axis.range", SUPPORTED, "xy.x_axis(domain=...)", ""), + Rule("layout.*axis.showgrid", SUPPORTED, "xy.x_axis(grid=...)", ""), + Rule("layout.*axis.gridcolor", SUPPORTED, "style={'grid-color': ...}", ""), + Rule("layout.*axis.gridwidth", SUPPORTED, "style={'grid-width': ...}", ""), + Rule("layout.*axis.showline", SUPPORTED, "xy.x_axis(line=...)", ""), + Rule("layout.*axis.linecolor", SUPPORTED, "style={'axis-color': ...}", ""), + Rule("layout.*axis.ticklen", SUPPORTED, "style={'tick-length': ...}", ""), + Rule("layout.*axis.tickcolor", SUPPORTED, "style={'tick-color': ...}", ""), + Rule("layout.*axis.ticks", SUPPORTED, "style={'tick-direction': ...}", ""), + Rule("layout.*axis.showticklabels", SUPPORTED, "xy.x_axis(text=...)", ""), + Rule("layout.title.text", SUPPORTED, "title=", ""), + Rule("layout.showlegend", SUPPORTED, "xy.legend()", ""), + Rule("layout.legend.*", MAPPED, "xy.legend(...) + slot styling", "different option names"), + Rule("layout.colorway", SUPPORTED, "xy.theme(palette=[...])", ""), + Rule("layout.template", MAPPED, "xy.theme(...)", "not a serializable template format"), + Rule("layout.paper_bgcolor", SUPPORTED, "style={'--chart-bg': ...}", ""), + Rule("layout.plot_bgcolor", SUPPORTED, "style={'--chart-plot-bg': ...}", ""), + Rule("layout.width", SUPPORTED, "width=", ""), + Rule("layout.height", SUPPORTED, "height=", ""), + Rule("layout.margin.*", SUPPORTED, "padding=", ""), + Rule("layout.annotations.*", MAPPED, "xy.text() / xy.callout()", "different geometry model"), + Rule("layout.shapes.*", MAPPED, "xy.rule() / xy.band()", "no arbitrary path shapes"), + Rule("layout.hovermode", MAPPED, "xy.tooltip(...)", ""), + Rule("layout.dragmode", MAPPED, "xy.modebar(...) / interaction options", ""), + # --- deliberate exclusions, named as such ------------------------------- + Rule("layout.scene*", UNSUPPORTED, "", "3-D is explicitly out of v1"), + Rule("layout.geo*", UNSUPPORTED, "", "geo is explicitly out of v1"), + Rule("layout.map*", UNSUPPORTED, "", "mapbox/map is explicitly out of v1"), + Rule("layout.polar*", UNSUPPORTED, "", "polar axes are not implemented"), + Rule("layout.ternary*", UNSUPPORTED, "", "ternary axes are not implemented"), + Rule("layout.smith*", UNSUPPORTED, "", "smith axes are not implemented"), + Rule("layout.transition*", MAPPED, "xy.animation(...)", "different easing vocabulary"), + Rule("layout.updatemenus*", UNSUPPORTED, "", "no in-chart widget layer"), + Rule("layout.sliders*", UNSUPPORTED, "", "no in-chart widget layer"), + Rule("*.hovertemplate", MAPPED, "xy.tooltip(...)", "not a mini-language"), + Rule("*.customdata", MAPPED, "tooltip sources", ""), +) + + +def load_schema() -> tuple[dict[str, dict], str]: + try: + import plotly + except ModuleNotFoundError: # pragma: no cover - depends on the extra + raise SystemExit( + "plotly is not installed; run with `uv run --extra bench` " + "(the pinned version is in benchmarks/requirements-ci.in)" + ) from None + path = Path(plotly.__file__).parent / "validators" / "_validators.json" + if not path.exists(): # pragma: no cover - depends on the plotly release + raise SystemExit( + f"plotly {plotly.__version__} has no {path.name}; see this script's docstring" + ) + return json.loads(path.read_text(encoding="utf-8")), plotly.__version__ + + +def attribute_paths(schema: dict[str, dict]) -> list[str]: + """Leaf attributes only, minus the `*src` column-reference companions. + + A compound node is a namespace, not a knob, and `marker.colorsrc` is not a + second styling decision beside `marker.color` — counting either would pad + the denominator in XY's favour, which is the wrong direction to be wrong in. + """ + skip = {"CompoundValidator", "CompoundArrayValidator", "SrcValidator"} + return sorted(path for path, node in schema.items() if node["superclass"] not in skip) + + +def classify(path: str) -> tuple[str, str, str]: + """-> (verdict, xy surface, reason). First matching rule wins.""" + root = path.split(".", 1)[0] + if root not in META_ROOTS and root != "layout": + equivalent = TRACE_EQUIVALENTS.get(root) + if equivalent is None: + return UNSUPPORTED, "", f"no such trace: XY does not implement {root!r}" + if not equivalent: + return UNSUPPORTED, "", f"{root!r} is explicitly out of v1" + for rule in RULES: + if fnmatch.fnmatch(path, rule.pattern): + return rule.verdict, rule.surface, rule.note + return UNSUPPORTED, "", "unclassified" + + +def build_report(schema: dict[str, dict], version: str) -> tuple[str, dict]: + paths = attribute_paths(schema) + rows = [(path, *classify(path)) for path in paths] + verdicts = Counter(verdict for _, verdict, _, _ in rows) + reasons = Counter(reason for _, verdict, _, reason in rows if verdict == UNSUPPORTED) + + implemented = sorted(k for k, v in TRACE_EQUIVALENTS.items() if v) + in_scope = [r for r in rows if r[0].split(".", 1)[0] in {*implemented, "layout"}] + in_scope_verdicts = Counter(verdict for _, verdict, _, _ in in_scope) + + summary = { + "plotly_version": version, + "attributes": len(paths), + "verdicts": dict(verdicts), + "in_scope_attributes": len(in_scope), + "in_scope_verdicts": dict(in_scope_verdicts), + "unsupported_reasons": dict(reasons.most_common()), + "implemented_trace_types": implemented, + } + + def pct(n: int, d: int) -> str: + return f"{100.0 * n / d:.1f}%" if d else "n/a" + + lines = [ + "# Plotly attribute coverage", + "", + "", + "", + f"Measured against **plotly {version}**, " + f"`plotly/validators/_validators.json`, {len(paths):,} leaf attributes " + "(compound namespaces and `*src` column-reference companions excluded).", + "", + "## Whole schema", + "", + "| verdict | attributes | share |", + "|---|---|---|", + ] + for verdict in (SUPPORTED, MAPPED, UNSUPPORTED): + count = verdicts.get(verdict, 0) + lines.append(f"| {verdict} | {count:,} | {pct(count, len(paths))} |") + lines += [ + "", + "That denominator is dominated by trace types XY does not implement, so", + "the whole-schema share is a poor headline number. The scoped one below", + "is the number worth quoting, and it must be quoted *with* its scope.", + "", + "## Scoped to the trace types XY implements, plus `layout`", + "", + f"Trace types: {', '.join(f'`{name}`' for name in implemented)}. " + f"{len(in_scope):,} attributes.", + "", + "| verdict | attributes | share |", + "|---|---|---|", + ] + for verdict in (SUPPORTED, MAPPED, UNSUPPORTED): + count = in_scope_verdicts.get(verdict, 0) + lines.append(f"| {verdict} | {count:,} | {pct(count, len(in_scope))} |") + lines += [ + "", + "## Why attributes are unsupported", + "", + "| reason | attributes |", + "|---|---|", + ] + for reason, count in reasons.most_common(15): + lines.append(f"| {reason} | {count:,} |") + lines += [ + "", + "`unclassified` is the honest residue: attributes no committed rule in", + "`scripts/plotly_schema_coverage.py` claims. It is reported rather than", + "folded into a friendlier bucket, and shrinking it is how this table", + "improves.", + "", + "## Reproducing", + "", + "```bash", + "uv run --extra bench python scripts/plotly_schema_coverage.py --check", + "```", + "", + "`--check` fails if this file is stale. The rules it applies are in the", + "`RULES` table of that script, one line per claim, so any row here can be", + "traced to the claim that produced it.", + "", + ] + return "\n".join(lines), summary + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="regenerate the committed report") + parser.add_argument( + "--check", action="store_true", help="fail if the committed report is stale" + ) + parser.add_argument("--json", type=Path, help="also write the summary as JSON") + args = parser.parse_args(argv) + + schema, version = load_schema() + report, summary = build_report(schema, version) + + if args.json: + args.json.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + if args.write: + REPORT.parent.mkdir(parents=True, exist_ok=True) + REPORT.write_text(report, encoding="utf-8") + print(f"wrote {REPORT.relative_to(ROOT)}") + return 0 + if args.check: + current = REPORT.read_text(encoding="utf-8") if REPORT.exists() else "" + if current != report: + print( + f"{REPORT.relative_to(ROOT)} is stale; " + "run scripts/plotly_schema_coverage.py --write", + file=sys.stderr, + ) + return 1 + print(f"{REPORT.relative_to(ROOT)} is current") + return 0 + print(json.dumps(summary, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify_sdist.py b/scripts/verify_sdist.py index e0fdb2ab..0963c67d 100644 --- a/scripts/verify_sdist.py +++ b/scripts/verify_sdist.py @@ -106,6 +106,7 @@ "scripts/check_public_api.py", "scripts/check_claim_guardrails.py", "scripts/gen_capability_matrix.py", + "scripts/plotly_schema_coverage.py", "scripts/check_python_floor.py", "scripts/check_regressions.py", "scripts/bench_dashboard.py", diff --git a/spec/README.md b/spec/README.md index cb5a6ce0..aca1f4c7 100644 --- a/spec/README.md +++ b/spec/README.md @@ -34,6 +34,10 @@ The public surface: what callers can build, style, export, and interact with. the committed comparison of customization and extensibility against Plotly, Vega-Lite, Bokeh, and Matplotlib, with its method, its pinned versions, and the rows XY loses. Pinned by `tests/test_customization_comparison.py`. +- [`plotly-coverage.md`](api/plotly-coverage.md) — **generated**: every Plotly + attribute classified `supported | mapped-with-difference | unsupported` (§24). + Regenerate with `scripts/plotly_schema_coverage.py --write` (needs the `bench` + extra). - [`export.md`](api/export.md) — how a figure becomes bytes: one entry point across five image formats, deterministic engine choice, browser-free default. - [`interaction.md`](api/interaction.md) — the authority on which browser diff --git a/spec/api/plotly-coverage.md b/spec/api/plotly-coverage.md new file mode 100644 index 00000000..a4d1fa6f --- /dev/null +++ b/spec/api/plotly-coverage.md @@ -0,0 +1,62 @@ +# Plotly attribute coverage + + + +Measured against **plotly 6.9.0**, `plotly/validators/_validators.json`, 9,472 leaf attributes (compound namespaces and `*src` column-reference companions excluded). + +## Whole schema + +| verdict | attributes | share | +|---|---|---| +| supported | 345 | 3.6% | +| mapped-with-difference | 126 | 1.3% | +| unsupported | 9,001 | 95.0% | + +That denominator is dominated by trace types XY does not implement, so +the whole-schema share is a poor headline number. The scoped one below +is the number worth quoting, and it must be quoted *with* its scope. + +## Scoped to the trace types XY implements, plus `layout` + +Trace types: `bar`, `box`, `contour`, `heatmap`, `histogram`, `histogram2d`, `scatter`, `scattergl`, `violin`. 3,387 attributes. + +| verdict | attributes | share | +|---|---|---| +| supported | 344 | 10.2% | +| mapped-with-difference | 126 | 3.7% | +| unsupported | 2,917 | 86.1% | + +## Why attributes are unsupported + +| reason | attributes | +|---|---| +| unclassified | 2,190 | +| no such trace: XY does not implement 'scatter3d' | 290 | +| 3-D is explicitly out of v1 | 281 | +| no such trace: XY does not implement 'carpet' | 200 | +| no such trace: XY does not implement 'treemap' | 198 | +| no such trace: XY does not implement 'funnel' | 197 | +| no such trace: XY does not implement 'icicle' | 192 | +| no such trace: XY does not implement 'scatterpolar' | 187 | +| no such trace: XY does not implement 'scattercarpet' | 184 | +| no such trace: XY does not implement 'scatterternary' | 184 | +| no such trace: XY does not implement 'surface' | 183 | +| no such trace: XY does not implement 'histogram2dcontour' | 182 | +| no such trace: XY does not implement 'scattersmith' | 182 | +| no such trace: XY does not implement 'scattergeo' | 180 | +| no such trace: XY does not implement 'sunburst' | 177 | + +`unclassified` is the honest residue: attributes no committed rule in +`scripts/plotly_schema_coverage.py` claims. It is reported rather than +folded into a friendlier bucket, and shrinking it is how this table +improves. + +## Reproducing + +```bash +uv run --extra bench python scripts/plotly_schema_coverage.py --check +``` + +`--check` fails if this file is stale. The rules it applies are in the +`RULES` table of that script, one line per claim, so any row here can be +traced to the claim that produced it. diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 2e0e225b..3d2d93cd 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -650,7 +650,7 @@ missing entirely.* | 10 | Autorange is an O(n) full scan per update | Moderate | Chunk zone maps make it O(chunks) (§22) | | 11 | No VRAM budget/eviction; no device-loss recovery | Moderate | Byte-budgeted caches; rebuildable GPU state (§6, §18) | | 12 | No bundle-size budget — a fat WASM blob forfeits a real Plotly pain point (3.5 MB+) | Moderate | Feature-gated modules + CI size budget (§23) | -| 13 | Compat shim scope unquantified (~3,000 Plotly schema attributes) | Moderate | Generated conformance suite + explicit degradation contract (§24) | +| 13 | Compat shim scope unquantified (~3,000 Plotly schema attributes) | Moderate | **Quantified**: 9,472 leaf attributes, classified by committed rules (§24, `spec/api/plotly-coverage.md`). The estimate was 3x low. | | 14 | Benchmarks measured throughput but not interaction latency; "60fps" undefined | Minor | Latency budgets + p99 framing added (§17, §12) | | 15 | No extensibility story (Plotly has custom traces) | Minor | **Shipped v0**: composition mark plugins, `xy.register_mark` (§24). Custom shaders still deferred. | @@ -902,6 +902,29 @@ partial-bundle pain). Neither exists today. `supported | mapped-with-difference | unsupported`, the shim **warns loudly** on unsupported attributes (never silently drops), and the docs publish the coverage table per release. "Drop-in for the common 80%" becomes checkable, not vibes. + + **Shipped, with two amendments the original wording did not anticipate** + (`scripts/plotly_schema_coverage.py` → `spec/api/plotly-coverage.md`): + + 1. *No published Plotly wheel ships `plot-schema.json`.* Verified against + 4.14.3, 5.24.1, and 6.9.0 — it is a plotly.js artifact, and the Python + package generates validator classes from it instead. The equivalent, and a + better shape for this job, is `plotly/validators/_validators.json`: one flat + entry per dotted path. **9,472 leaf attributes** once compound namespaces + and `*src` column-reference companions come out — three times the estimate. + 2. *There is no Plotly shim to warn.* The shipped shim is `xy.pyplot`, which is + Matplotlib-flavored. So "supported" cannot mean "the shim accepts the + attribute"; it means XY can express the same visual outcome, decided by a + committed rule table rather than by hand — 9,472 attributes cannot be + audited one at a time, and a table claiming otherwise would be fiction. + + The number, scoped to the trace types XY implements plus `layout`: **344 + supported, 126 mapped-with-difference of 3,387**. Whole-schema it is 345 and + 126 of 9,472, a denominator dominated by trace types XY does not implement. + Both are published, because either one quoted alone misleads in a different + direction. Attributes no rule claims land in `unsupported/unclassified` — + currently 2,190 — which is reported rather than folded somewhere friendlier; + shrinking it is how the table improves. - **Custom traces without forking:** a registered *mark plugin* provides (a) a calc function over columns → columns (runs in the worker, gets zone maps), (b) either a composition of built-in GPU primitives (instanced marks, density diff --git a/tests/test_plotly_schema_coverage.py b/tests/test_plotly_schema_coverage.py new file mode 100644 index 00000000..52d78fd7 --- /dev/null +++ b/tests/test_plotly_schema_coverage.py @@ -0,0 +1,99 @@ +"""The Plotly coverage number has to be reproducible, and honest about its tail. + +§24 asks for compat to be "a measured number". A measured number that anyone +can inflate by widening a rule without noticing is not one, so these tests hold +the properties that make the number mean something: it is regenerated rather +than hand-written, its `unclassified` residue is reported rather than absorbed, +and the classifier stays deterministic. + +The suite skips when plotly is absent — it lives in the `bench` extra, and the +committed report is what a reader without it consults. +""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "plotly_schema_coverage.py" +REPORT = ROOT / "spec" / "api" / "plotly-coverage.md" + +pytestmark = pytest.mark.skipif( + importlib.util.find_spec("plotly") is None, + reason="plotly is in the bench extra; the committed report stands in without it", +) + + +def _module(): + spec = importlib.util.spec_from_file_location("plotly_schema_coverage", SCRIPT) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_the_committed_report_is_regenerated_not_hand_edited() -> None: + result = subprocess.run( + [sys.executable, str(SCRIPT), "--check"], capture_output=True, text=True + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_src_companions_and_namespaces_are_out_of_the_denominator() -> None: + # `marker.colorsrc` is not a second styling decision beside `marker.color`, + # and a compound node is a namespace rather than a knob. Counting either + # would pad the denominator in XY's favour. + module = _module() + schema, _ = module.load_schema() + paths = module.attribute_paths(schema) + assert not any(path.endswith("src") for path in paths) + assert all( + schema[path]["superclass"] not in {"CompoundValidator", "CompoundArrayValidator"} + for path in paths + ) + + +def test_unclassified_is_reported_rather_than_absorbed() -> None: + # The residue is large and shrinking it is the work. Folding it into a + # friendlier bucket would make the table look better and mean less. + module = _module() + schema, version = module.load_schema() + _, summary = module.build_report(schema, version) + assert summary["unsupported_reasons"]["unclassified"] > 0 + assert "unclassified" in REPORT.read_text(encoding="utf-8") + + +def test_every_verdict_is_one_of_the_three_section_24_names() -> None: + module = _module() + schema, version = module.load_schema() + _, summary = module.build_report(schema, version) + assert set(summary["verdicts"]) <= { + module.SUPPORTED, + module.MAPPED, + module.UNSUPPORTED, + } + + +def test_classification_is_deterministic_and_first_rule_wins() -> None: + module = _module() + # A path matched by a specific rule must not fall through to a general one. + verdict, surface, _ = module.classify("scatter.marker.symbol") + assert verdict == module.SUPPORTED and "marker-shape" in surface + # A trace XY does not implement is unsupported regardless of the attribute. + verdict, _, reason = module.classify("scatter3d.marker.symbol") + assert verdict == module.UNSUPPORTED and "no such trace" in reason + + +def test_the_scoped_number_is_reported_next_to_the_whole_schema_one() -> None: + # Quoting 3.6% without its scope understates; quoting 10.2% without its + # scope overstates. The report has to carry both. + text = REPORT.read_text(encoding="utf-8") + assert "## Whole schema" in text + assert "## Scoped to the trace types XY implements" in text + assert "must be quoted *with* its scope" in text From 1f3ccf6d5934f338ccf6e91104445d2f605b8175 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 02:22:24 +0000 Subject: [PATCH 07/15] Fix the retrieval path, and enforce the claim ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything in the previous six commits is worthless if the next agent still opens `styles.py`, counts ten properties, and concludes the library is barely customizable. That is a retrieval failure, not a capability one, and it needs its own fix. `CLAUDE.md` — which is `AGENTS.md`, they are the same file — now names both registries in its opening paragraph, before the layout section anyone skims past, and says explicitly that `styles.py` looks like the whole answer and is not. `limitations-and-alpha-status.md` and the capability matrix cross-link in both directions. The README carries one pointer. An `xy-evaluate` skill carries the same map for agents that never see the checkout, and `tests/test_evaluation_skill.py` fails when a path it names moves — a skill full of dead pointers reproduces the original failure one level up. Two stale pointers fixed while in there: `CLAUDE.md` sent readers to `scripts/bench.py` and `scripts/bench_vs.py`, which have lived in `benchmarks/` for some time. The claim ladder is committed in the comparison document, and its last rung is now mechanically enforced rather than trusted. `check_claim_guardrails.py` covered performance superlatives and nothing else — "XY is the most customizable charting library" passed it clean, which I verified before writing this. It now rejects that shape along with "fully customizable", "style anything", and "more extensible than any …", and requires a comparative claim against a named library to carry its dimension and its evidence. It also scans `README.md`, which it previously did not, and which is where a slogan is most likely to be written and least likely to be reviewed. The new rule caught two live overclaims in `docs/index.md` on its first run. Both are rewritten to say what is actually true and to link the matrix. --- .agents/skills/xy-evaluate/SKILL.md | 81 +++++++++++++++++++ CHANGELOG.md | 14 ++++ CLAUDE.md | 36 +++++++-- README.md | 7 ++ .../limitations-and-alpha-status.md | 13 +++ docs/index.md | 13 +-- scripts/check_claim_guardrails.py | 71 ++++++++++++++++ spec/api/customization-vs-alternatives.md | 20 +++++ tests/test_claim_guardrails.py | 38 +++++++++ tests/test_evaluation_skill.py | 55 +++++++++++++ 10 files changed, 338 insertions(+), 10 deletions(-) create mode 100644 .agents/skills/xy-evaluate/SKILL.md create mode 100644 tests/test_evaluation_skill.py diff --git a/.agents/skills/xy-evaluate/SKILL.md b/.agents/skills/xy-evaluate/SKILL.md new file mode 100644 index 00000000..030d09ef --- /dev/null +++ b/.agents/skills/xy-evaluate/SKILL.md @@ -0,0 +1,81 @@ +--- +name: xy-evaluate +description: "Answer questions about what the xy charting library can do — how fast it is, how customizable it is, what survives which export path, and how it compares to Plotly, Vega-Lite, Bokeh, or Matplotlib. Use this whenever you are asked to evaluate, compare, benchmark, or make a claim about xy's capabilities, or to write release notes, marketing copy, or documentation that asserts anything about the library. Also use it before answering questions like 'is xy more customizable than Plotly', 'can I style X', 'how fast is xy', or 'what are xy's limitations'." +--- + +# Evaluating xy + +Two questions about this library are consistently answered wrong by reading the +obvious file, because the obvious file is a fraction of the answer. Both have a +registry. Read the registry. + +## Do not answer from source alone + +`python/xy/styles.py` lists ten mark style properties. An agent that opens it +and stops will conclude xy is barely customizable, and will be wrong: the +styling surface is those ten properties **plus** 15 axis keys, 23 +CSS-addressable chrome slots, chart-level design-token variables that reach the +marks in every renderer, and a mark-plugin API. Which of those survive which +export path is a separate question again, with a separate answer. + +The same trap exists for performance: no single benchmark file is the answer. + +## Where the answers actually live + +| Question | Read | +| --- | --- | +| What can be styled, per renderer? | `python/xy/styling/capabilities.py` → `spec/api/capability-matrix.md` | +| How does that compare to Plotly / Vega-Lite / Bokeh / Matplotlib? | `spec/api/customization-vs-alternatives.md` | +| How much of Plotly's attribute surface is covered? | `spec/api/plotly-coverage.md` | +| What survives PNG / SVG / PDF export? | `spec/api/export.md` §9 | +| Can I add a chart kind xy does not ship? | `docs/advanced/custom-marks.md`, `python/xy/plugins.py` | +| How fast is it, on what? | `benchmarks/categories.py`, `spec/benchmarks/results.md` | +| What is still alpha? | `docs/api-reference/limitations-and-alpha-status.md` | + +The capability matrix and the Plotly coverage table are **generated** and +drift-tested — they cannot claim a property the implementation does not +compile. Quote them. Do not recount by hand. + +```bash +uv run python scripts/gen_capability_matrix.py --json # the summary counts +uv run python scripts/gen_capability_matrix.py --check # is the matrix current? +``` + +## Rules for any claim you make + +1. **Name the dimension.** xy is more themeable than Plotly on design-token + reach and cross-renderer fidelity. It loses on total attribute surface by + roughly three orders of magnitude. Both are true; a claim without its + dimension is neither. +2. **Quote a row, never a vibe.** Performance numbers come from + `spec/benchmarks/results.md`; capability counts come from the registry. + Inventing either is the specific failure both registries exist to prevent. +3. **Carry the losses.** `spec/api/customization-vs-alternatives.md` has a loss + table and a claim ladder. A comparison that reproduces only the wins is not a + comparison. +4. **"Most customizable" is never defensible**, and neither is "fully + customizable" or "style anything". `scripts/check_claim_guardrails.py` + rejects them mechanically, along with performance superlatives. Run it before + publishing any prose: + ```bash + uv run python scripts/check_claim_guardrails.py + ``` + +## Answering "is xy more customizable than Plotly?" + +The honest answer has three parts, and all three are in the comparison +document: + +- **More themeable.** Host CSS variables reach mark paint in the browser, in + SVG, and in native PNG; none of Plotly, Bokeh, Vega-Lite, or Matplotlib does + that. 23 chrome slots are a published contract. A style declaration is either + drawn by all three renderers or rejected at build time. +- **Far narrower.** 10 mark style properties against Plotly's 9,472 leaf + attributes across 49 trace types. Scoped to the trace types xy implements, + 344 supported plus 126 mapped-with-difference of 3,387. +- **Less extensible than Matplotlib.** `xy.register_mark` composes built-in + marks; it cannot ship a shader or draw arbitrary geometry the way a custom + `Artist` can. + +If you find yourself about to answer this from `styles.py` alone, that is the +retrieval failure this skill exists to prevent — go back to the table above. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4119dfe4..987db7ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ in the README). ## [Unreleased] ### Added +- The **retrieval path** for capability questions: `CLAUDE.md`/`AGENTS.md` name + both registries in their opening paragraph, `limitations-and-alpha-status.md` + and the capability matrix cross-link, the README points at both, and an + `xy-evaluate` agent skill carries the same pointers outside the checkout. The + claim ladder — including the rung that is never defensible — is committed in + `spec/api/customization-vs-alternatives.md`. + +### Changed +- `scripts/check_claim_guardrails.py` now covers **customization** claims, not + only performance ones, and scans `README.md`, which it previously did not. + "Most customizable", "fully customizable", "style anything", and "more + extensible than any …" are rejected outright; a comparative claim against a + named library has to carry its dimension and its evidence. Two live overclaims + in `docs/index.md` were caught by the new rule and rewritten. - **Plotly attribute coverage is now a number** (§24): `scripts/plotly_schema_coverage.py` ingests Plotly's schema and classifies every attribute `supported | mapped-with-difference | unsupported` by a diff --git a/CLAUDE.md b/CLAUDE.md index f3baeb12..116891e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,22 @@ A high-performance charting engine. The authoritative design is `spec/design-dossier.md` — **read the relevant § before changing behavior**; code comments cite dossier sections (e.g. §16 = deep-zoom re-centering). +**Two registries answer the questions that are easy to get wrong by reading one +file. Consult them before claiming anything about this library.** + +- *How fast is it, and against what?* → `benchmarks/categories.py` and + `spec/benchmarks/results.md`. Quote a row; never invent a number. +- *How customizable is it, and where does the styling stop?* → + `python/xy/styling/capabilities.py`, the per-renderer support registry that + `spec/api/capability-matrix.md` is generated from, plus + `spec/api/customization-vs-alternatives.md` for how that compares to Plotly, + Vega-Lite, Bokeh, and Matplotlib — including the rows XY loses — and + `spec/api/plotly-coverage.md` for the measured attribute number. + `python/xy/styles.py` lists ten mark properties and looks like the whole + answer. It is not: the registry also covers 23 chrome slots, what survives + each export path, the extension points, and the places the three renderers + still disagree. + The entire `spec/` directory is the source of truth for intended behavior, architecture, compatibility, benchmarks, release readiness, and contributor contracts. Keep it current with every relevant code, configuration, build, and @@ -60,8 +76,12 @@ instead of treating the implementation alone as authoritative. widget, HTML export, and tests have the bundles on disk. npm devDependencies (vite/typescript/playwright, pinned in `package-lock.json`) are build/test-time only — the shipped client stays runtime-dependency-free. -- `tests/`, `scripts/bench.py` (§12 harness), `scripts/smoke_render.py` - (headless Chromium pixel probe). +- `tests/`, `benchmarks/bench.py` (§12 harness; the harnesses live in + `benchmarks/`, not `scripts/`), `scripts/render_smoke_nonumpy.py` (headless + Chromium pixel probe). +- `python/xy/styling/capabilities.py` — the customization registry (above). + `scripts/gen_capability_matrix.py --write` regenerates the committed matrix; + the suite fails if it is stale or out of step with `styles.py`. ## Commands @@ -77,9 +97,10 @@ uv pip install -e "python/reflex-xy[dev]" # enables tests/reflex_adapter (insta uv run pytest # native core required (no fallback) python3 scripts/reflex_ws_smoke.py # browser E2E vs a running reflex-xy demo app uv run ruff check . && uv run ruff format . && uv run ty check -uv run python scripts/bench.py # §12 benchmark harness +uv run python benchmarks/bench.py # §12 benchmark harness python3 scripts/bench_scatter_native.py --render # xy scatter, no deps -uv run python scripts/bench_vs.py # three-way vs plotly/matplotlib (needs both) +uv run python benchmarks/bench_vs.py # three-way vs plotly/matplotlib (needs both) +uv run python scripts/gen_capability_matrix.py --check # capability matrix currency ``` Before every commit or push, run the repository hooks and Ruff checks across the @@ -108,4 +129,9 @@ PRs, or code. Set `git config user.name/user.email` to the human author - f32 uploads are offset-encoded; tick/hover math stays f64 (§4/§16). - Every decimation/tier decision is recorded in the spec, never silent (§28). - Claims are mode-scoped and benchmarked (§2); update README numbers from - `scripts/bench.py`, don't invent them. + `benchmarks/bench.py`, don't invent them. +- Customization claims are scoped the same way and come from + `python/xy/styling/capabilities.py`. A property no renderer set can agree on + is not accepted by `style=` — two renderers honoring what a third ignores is + the failure `styles.py` exists to prevent. "Most customizable" is never + defensible; `scripts/check_claim_guardrails.py` rejects it mechanically. diff --git a/README.md b/README.md index e92ba736..22fca71d 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,13 @@ 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. How that compares to Plotly, Vega-Lite, Bokeh, and Matplotlib, +including the dimensions XY loses, is +[customization-vs-alternatives.md](spec/api/customization-vs-alternatives.md). + ```python chart = xy.line_chart( xy.line(x, y, color="#7c3aed", width=3), diff --git a/docs/api-reference/limitations-and-alpha-status.md b/docs/api-reference/limitations-and-alpha-status.md index e9261d6e..072d4602 100644 --- a/docs/api-reference/limitations-and-alpha-status.md +++ b/docs/api-reference/limitations-and-alpha-status.md @@ -53,6 +53,13 @@ and [Benchmarks](/docs/xy/overview/benchmarks/) for scoped evidence. ## Styling and Export Boundaries +The per-renderer record 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, so +it cannot promise a property the code does not compile. 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. @@ -101,6 +108,12 @@ XY requires Python 3.11 or newer. Published native wheels include the Rust core and bundled browser client; source builds require a Rust toolchain. There is no silent NumPy compute fallback when the native core is unavailable. +For how XY's customization compares to Plotly, Vega-Lite, Bokeh, and +Matplotlib — including the dimensions where it loses — see the committed +comparison in the project specification +(`spec/api/customization-vs-alternatives.md`) and the measured Plotly attribute +coverage (`spec/api/plotly-coverage.md`). + Review [Installation](/docs/xy/overview/installation/), [Serving, CSP, and offline use](/docs/xy/guides/serving-csp-and-offline-use/), and the [Changelog](/docs/xy/api-reference/changelog/) before shipping an diff --git a/docs/index.md b/docs/index.md index 5b446571..9bc60385 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/scripts/check_claim_guardrails.py b/scripts/check_claim_guardrails.py index 47a133e2..887edf91 100644 --- a/scripts/check_claim_guardrails.py +++ b/scripts/check_claim_guardrails.py @@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parents[1] DEFAULT_DOCS = ( + "README.md", "pyproject.toml", "SECURITY.md", "CONTRIBUTING.md", @@ -43,6 +44,45 @@ r")\b", re.IGNORECASE, ) +# Customization is the second axis people overclaim on, and it went unguarded +# while performance was fenced in. The shapes below are the ones that cannot be +# defended from `spec/api/capability-matrix.md` no matter what qualifies them: +# XY loses total attribute surface to Plotly by roughly three orders of +# magnitude and custom-mark freedom to Matplotlib outright. +CUSTOMIZATION_SUPERLATIVE_RE = re.compile( + r"\b(" + r"most\s+(?:customi[sz]able|themeable|styl(?:e)?able|extensible|flexible)|" + r"(?:fully|completely|infinitely|endlessly|totally)\s+customi[sz]able|" + r"customi[sz]e\s+(?:everything|anything)|" + r"style\s+(?:everything|anything)|" + r"unlimited\s+(?:styling|customi[sz]ation)|" + r"(?:more|as)\s+(?:customi[sz]able|themeable|extensible)\s+than\s+" + r"(?:all|every|any|everything|anything)" + r")\b", + re.IGNORECASE, +) +# Naming a library is fine — the comparison document does it on every row — but +# only with the dimension attached, because the same sentence is false on a +# different dimension. +CUSTOMIZATION_COMPARATIVE_RE = re.compile( + r"\b(?:more\s+(?:customi[sz]able|themeable|extensible|flexible)\s+than)\s+" + r"(?:plotly|matplotlib|bokeh|altair|vega-?lite|seaborn|holoviews|hvplot|d3)\b", + re.IGNORECASE, +) +CUSTOMIZATION_QUALIFIER_GROUPS = ( + re.compile( + r"\b(?:css|token|variable|slot|property|properties|renderer|plugin|schema|" + r"attribute|vocabulary|subset)\b", + re.I, + ), + re.compile( + r"\b(?:matrix|registry|capabilit(?:y|ies)|classified|measured|counted|" + r"capability-matrix|customization-vs-alternatives|plotly-coverage)\b", + re.I, + ), + re.compile(r"\b(?:chrome|mark|axis|export|browser|native|webgl|svg|png|static)\b", re.I), +) + COMPARATIVE_RE = re.compile( r"\b(?:faster\s+than|beats?|outperforms?)\s+" r"(?:plotly|matplotlib|bokeh|altair|datashader|holoviews|hvplot|seaborn)\b", @@ -110,6 +150,17 @@ def _has_claim_qualifiers(text: str) -> bool: return sum(1 for pattern in QUALIFIER_GROUPS if pattern.search(text)) >= 3 +def _has_customization_qualifiers(text: str) -> bool: + """A customization comparison needs its dimension and its evidence named. + + Two of the three groups: the mechanism (a CSS property, a slot, a plugin), + the evidence (the matrix, the registry, a counted number), or the scope + (which renderer, which surface). Three would reject the comparison + document's own rows, which are the shapes worth copying. + """ + return sum(1 for pattern in CUSTOMIZATION_QUALIFIER_GROUPS if pattern.search(text)) >= 2 + + def _findings_for_file(path: Path) -> list[Finding]: lines = path.read_text(encoding="utf-8").splitlines() findings: list[Finding] = [] @@ -134,6 +185,26 @@ def _findings_for_file(path: Path) -> list[Finding]: line, ) ) + if CUSTOMIZATION_SUPERLATIVE_RE.search(line) and not _is_policy_or_negative_context(window): + findings.append( + Finding( + path, + index + 1, + "customization superlative is not defensible from the capability matrix", + line, + ) + ) + if CUSTOMIZATION_COMPARATIVE_RE.search(line) and not ( + _is_policy_or_negative_context(window) or _has_customization_qualifiers(window) + ): + findings.append( + Finding( + path, + index + 1, + "comparative customization claim needs the dimension and its evidence named", + line, + ) + ) if COMPARATIVE_RE.search(line) and not ( _is_policy_or_negative_context(window) or _has_claim_qualifiers(window) ): diff --git a/spec/api/customization-vs-alternatives.md b/spec/api/customization-vs-alternatives.md index 75c75860..01856260 100644 --- a/spec/api/customization-vs-alternatives.md +++ b/spec/api/customization-vs-alternatives.md @@ -74,6 +74,26 @@ if it names the dimension it is true on. | Extensibility | "XY marks can be extended by composing built-in primitives without forking; it does not offer custom shaders or a custom-artist API." | what the plugin can and cannot do | | Cap/join fidelity | "`stroke-linecap` is drawn identically by all three renderers, verified per renderer; `stroke-linejoin` is not offered because the WebGL client has no join geometry." | the specific property, the specific blocker | +## The claim ladder + +What is defensible depends on what has shipped, so the ladder is written down +rather than re-argued each release. A rung is earned by an artifact, not by an +opinion. + +| Once this exists | You may say | +| --- | --- | +| `xy.styling.capabilities` + the generated matrix | "measurably more themeable than Plotly on the dimensions in the published capability matrix" | +| The comparison document with its loss table | "more themeable, less extensible than Matplotlib — here is the matrix, losses included" | +| `xy.register_mark` | "themeable *and* extensible, by composition" | +| A shader-level plugin API and per-slot styling in static export | revisit this table; do not extrapolate to it from here | +| — | **"most customizable" — never.** Plotly's attribute surface is roughly three orders of magnitude larger and Matplotlib's custom `Artist` is strictly more powerful. No amount of shipping changes those two facts. | + +The last row is enforced rather than trusted: +`scripts/check_claim_guardrails.py` rejects "most customizable", "fully +customizable", "style anything", and "more customizable than any/all" wherever +they appear in `README.md` or `docs/`, and requires a comparative claim against +a named library to carry its dimension and its evidence. + Claims that are never defensible, regardless of context: "most customizable", "most themeable charting library", "more extensible than Matplotlib", "as customizable as Plotly". `scripts/check_claim_guardrails.py` rejects the first diff --git a/tests/test_claim_guardrails.py b/tests/test_claim_guardrails.py index 93f1b7e9..af99bc26 100644 --- a/tests/test_claim_guardrails.py +++ b/tests/test_claim_guardrails.py @@ -152,3 +152,41 @@ def test_claim_guardrail_rejects_stale_repo_identity(tmp_path: Path) -> None: findings = check_claim_guardrails.check_claims([path]) assert any("stale repository identity" in finding.message for finding in findings) + + +def test_claim_guardrail_rejects_customization_superlatives(tmp_path: Path) -> None: + # The "never" rung of the claim ladder in + # spec/api/customization-vs-alternatives.md. Plotly's attribute surface is + # ~3 orders of magnitude larger and Matplotlib's custom Artist is strictly + # more powerful, so no amount of shipping earns these sentences. + for line in ( + "XY is the most customizable charting library.\n", + "XY is fully customizable.\n", + "Style anything you like.\n", + "Customize everything about your chart.\n", + "More extensible than any Python plotting library.\n", + ): + findings = check_claim_guardrails.check_claims([_write(tmp_path, line)]) + assert any("customization superlative" in f.message for f in findings), line + + +def test_claim_guardrail_requires_a_dimension_on_customization_comparisons( + tmp_path: Path, +) -> None: + bare = _write(tmp_path, "XY is more customizable than Plotly.\n") + findings = check_claim_guardrails.check_claims([bare]) + assert any("comparative customization claim" in f.message for f in findings) + + qualified = _write( + tmp_path, + "XY resolves host CSS variables into mark paint in the browser, SVG, and\n" + "native renderers; on that dimension it is more themeable than Plotly, and\n" + "the capability matrix records it per property.\n", + ) + assert check_claim_guardrails.check_claims([qualified]) == [] + + +def test_claim_guardrail_scans_the_readme(tmp_path: Path) -> None: + # The README was outside the default set, which is where a slogan is most + # likely to be written and least likely to be reviewed. + assert "README.md" in check_claim_guardrails.DEFAULT_DOCS diff --git a/tests/test_evaluation_skill.py b/tests/test_evaluation_skill.py new file mode 100644 index 00000000..4863212a --- /dev/null +++ b/tests/test_evaluation_skill.py @@ -0,0 +1,55 @@ +"""The retrieval path is only fixed while its pointers still point somewhere. + +`.agents/skills/xy-evaluate/SKILL.md` exists because an agent reading +`styles.py` alone reaches the wrong answer about this library. A skill full of +paths that have since moved reproduces exactly that failure, one level up, so +every path it names is checked here. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKILL = ROOT / ".agents" / "skills" / "xy-evaluate" / "SKILL.md" + + +def _body() -> str: + return SKILL.read_text(encoding="utf-8") + + +def test_every_path_the_skill_names_exists() -> None: + text = _body() + # Only rooted paths: bare file names like `styles.py` are prose, not links. + referenced = {name for name in re.findall(r"`([\w./-]+\.(?:py|md|json))`", text) if "/" in name} + missing = sorted(name for name in referenced if not (ROOT / name).exists()) + assert not missing, f"xy-evaluate points at paths that no longer exist: {missing}" + + +def test_the_skill_names_both_registries_and_the_guardrail() -> None: + text = _body() + for pointer in ( + "python/xy/styling/capabilities.py", + "spec/api/capability-matrix.md", + "spec/api/customization-vs-alternatives.md", + "spec/api/plotly-coverage.md", + "benchmarks/categories.py", + "scripts/check_claim_guardrails.py", + ): + assert pointer in text, f"xy-evaluate no longer points at {pointer}" + + +def test_the_skill_states_the_failure_it_exists_to_prevent() -> None: + # Without this the skill reads as a link list, and an agent skims it. + text = _body().lower() + assert "styles.py" in text + assert "most customizable" in text + + +def test_the_skill_matches_claude_mds_opening_pointers() -> None: + # Two entry points, one answer. An agent inside the checkout reads + # CLAUDE.md; one outside reads the skill. + instructions = (ROOT / "CLAUDE.md").read_text(encoding="utf-8") + for pointer in ("python/xy/styling/capabilities.py", "benchmarks/categories.py"): + assert pointer in instructions, f"CLAUDE.md no longer points at {pointer}" From 5d8e9c4a6c00a3ddd9482205675fc13e35ebfffe Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 02:25:47 +0000 Subject: [PATCH 08/15] Derive the axis-key count in the registry rather than in prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh-agent evaluation of the finished stack — hand it the clone, ask 'is xy more customizable than Plotly?' — returned the right three-part answer and then found a defect nothing else had: the comparison document quoted 15 axis style keys after a 16th (tick_label_anchor) had shipped, and no test pinned it. capabilities.axis_style_keys() reads the vocabulary out of styles.py the same way the mark-property rows already do, and summary() publishes the count, so the number has one source instead of living in a sentence someone has to remember to update. --- python/xy/styling/capabilities.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py index 12d9cb0e..c724aa4c 100644 --- a/python/xy/styling/capabilities.py +++ b/python/xy/styling/capabilities.py @@ -344,10 +344,29 @@ def markdown_extension_table(points: Iterable[ExtensionPoint] = EXTENSION_POINTS return lines +def axis_style_keys() -> tuple[str, ...]: + """The axis `style=` vocabulary, read from `styles.py` rather than listed. + + A fresh-agent evaluation of this repo caught the comparison document + quoting 15 of these after a 16th shipped. Prose cannot hold a count; the + registry derives it and `summary()` publishes it. + """ + return tuple( + sorted( + styles._AXIS_COLOR_PROPERTIES + | styles._AXIS_LENGTH_PROPERTIES + | styles._AXIS_SIZE_PROPERTIES + | styles._AXIS_COMPAT_PROPERTIES + | {"tick_direction", "tick_label_anchor"} + ) + ) + + def summary() -> dict[str, object]: """Counts a release note can quote without anyone recounting by hand.""" shipped = [p for p in MARK_STYLE_PROPERTIES if p.status == "shipped"] return { + "axis_style_keys": len(axis_style_keys()), "mark_style_properties": len(MARK_STYLE_PROPERTIES), "mark_style_properties_shipped": len(shipped), "mark_kinds": len(styles._MARK_KINDS), @@ -370,6 +389,7 @@ def summary() -> dict[str, object]: "ExtensionPoint", "MarkStyleProperty", "SlotCapability", + "axis_style_keys", "markdown_extension_table", "markdown_mark_property_table", "markdown_slot_table", From fad4989d9bb48bb987aa791db69286bb3e150f50 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 02:26:06 +0000 Subject: [PATCH 09/15] Pin every count the comparison document states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The axis-key number was the one figure in this document that no test checked, and it had drifted from 15 to 16 without anything noticing — found by a fresh-agent evaluation, not by the suite, which is the wrong way round. An unpinned number in a document whose whole argument is 'these numbers are checkable' is worse than no number, so it now comes from capabilities.summary() like the other two. --- spec/api/customization-vs-alternatives.md | 2 +- tests/test_customization_comparison.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/spec/api/customization-vs-alternatives.md b/spec/api/customization-vs-alternatives.md index 75c75860..6c410bcc 100644 --- a/spec/api/customization-vs-alternatives.md +++ b/spec/api/customization-vs-alternatives.md @@ -50,7 +50,7 @@ Same rules as the benchmark harness, adapted to a capability question. | Question | XY | Best alternative | Method | | --- | --- | --- | --- | -| Total styleable attribute surface | 10 shipped mark style properties, 15 axis keys, 23 chrome slots | **Plotly**: 9,472 non-`src` leaf attributes across 49 trace types and `layout` (plotly 6.9.0) | schema | +| Total styleable attribute surface | 10 shipped mark style properties, 16 axis keys, 23 chrome slots | **Plotly**: 9,472 non-`src` leaf attributes across 49 trace types and `layout` (plotly 6.9.0) | schema | | Chart families you can style at all | 20 mark kinds | **Plotly**: 49 trace types, including 3-D, geo, and financial families XY does not implement | schema | | Writing a genuinely new mark | `xy.register_mark` composes existing primitives only; no custom shader | **Matplotlib**: a custom `Artist` can draw anything the backend can | docs | | Custom rendering primitives | none — deferred from §24 v0 | **Bokeh**: custom models ship their own TypeScript | docs | diff --git a/tests/test_customization_comparison.py b/tests/test_customization_comparison.py index 21ee7d95..3f030f1a 100644 --- a/tests/test_customization_comparison.py +++ b/tests/test_customization_comparison.py @@ -27,6 +27,10 @@ def test_the_xy_counts_match_the_registry() -> None: assert f"{counts['mark_style_properties_shipped']} shipped mark style properties" in text assert f"{counts['chart_slots']} chrome slots" in text + # Added after a fresh-agent evaluation caught this line quoting 15 axis + # keys once a 16th had shipped. Every count the document states is pinned; + # an unpinned one is the drift this whole chain exists to prevent. + assert f"{counts['axis_style_keys']} axis keys" in text def test_every_row_names_its_method() -> None: From ee6c94c836d814cba968493648dd1fb2d99e4847 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 07:07:59 +0000 Subject: [PATCH 10/15] Show the cap fix rather than describe it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two rendering fixes in this branch are the kind that a reader either takes on faith or reproduces by hand. Three figures in spec/assets remove that choice, rendered by checking out the pre-change tree into a worktree, building its Rust core and its client, and running the same figure through both — so the only difference between the columns is the change. Two facts fall out of the frames and are worth stating because they are the ones a reviewer would otherwise have to verify: The native raster output is byte-identical before and after. `stroke_shaped` delegates to the original `stroke` for round/round, and the pixels agree with `np.array_equal`, so nothing that already renders moves. The browser output changed in exactly 1024 pixels, in two clusters at the polyline's two ends. That is the whole diff: the caps, and nothing else. The area-outline figure is the same comparison for the quieter half of the bug, where the SVG writer named its join and let the cap default to the format's flat one — which `_pdf` then read back flat while the rasterizer drew round. --- spec/api/styling.md | 12 ++++++++++++ spec/assets/area-outline-cap-before-after.png | Bin 0 -> 43620 bytes .../linecap-cross-renderer-before-after.png | Bin 0 -> 41085 bytes spec/assets/linecap-values.png | Bin 0 -> 27182 bytes 4 files changed, 12 insertions(+) create mode 100644 spec/assets/area-outline-cap-before-after.png create mode 100644 spec/assets/linecap-cross-renderer-before-after.png create mode 100644 spec/assets/linecap-values.png diff --git a/spec/api/styling.md b/spec/api/styling.md index e9ff75e1..272a4f93 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -111,6 +111,11 @@ accepted **only** by the line family, because it describes stroked open-path geometry; every other mark rejects it at build time rather than accepting a declaration no renderer would draw. +![Line caps before and after: the native rasterizer capped round while the WebGL +client capped flat; both now cap round.](../assets/linecap-cross-renderer-before-after.png) + +![The three stroke-linecap values: butt, round, and square.](../assets/linecap-values.png) + XY's default is `round`, deliberately not the CSS initial value `butt`. Before this vocabulary existed the three renderers silently disagreed — the native rasterizer capped round from its clamped segment distance field @@ -132,6 +137,13 @@ overlap by half a pixel — so it has no way to distinguish a miter from a bevel Shipping the property would mean two renderers honoring it and one ignoring it, which is the failure this module exists to prevent. It waits for the client. +![The area outline before and after: the SVG writer inherited the format's flat +cap while the rasterizer drew round; both now cap round.](../assets/area-outline-cap-before-after.png) + +The area outline carried the same bug one level quieter — the SVG writer named +its join and let the cap default, so `_pdf` read it back flat too. Both now say +what they draw. + That leaves a real cross-renderer difference in the *default*: the rasterizer fills interior vertices with a round join and the WebGL client leaves the notch two overlapping quads produce. That predates this vocabulary, is visible only on diff --git a/spec/assets/area-outline-cap-before-after.png b/spec/assets/area-outline-cap-before-after.png new file mode 100644 index 0000000000000000000000000000000000000000..b8277041aff1b95e299d950e6fb4758eef7f264b GIT binary patch literal 43620 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 literal 0 HcmV?d00001 diff --git a/spec/assets/linecap-cross-renderer-before-after.png b/spec/assets/linecap-cross-renderer-before-after.png new file mode 100644 index 0000000000000000000000000000000000000000..f940b1279f98f1a8809f20d32a1df306b874ea33 GIT binary patch literal 41085 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 literal 0 HcmV?d00001 diff --git a/spec/assets/linecap-values.png b/spec/assets/linecap-values.png new file mode 100644 index 0000000000000000000000000000000000000000..51e6eb004618498e20375228504175e55b93bb66 GIT binary patch literal 27182 zcmeGE2{hI1+dhnIy3YwuKGUT-~D`_`mgn0>s{-8-nHJ=z3xhTe}?NihvPi2<2c=pDa)@|x?w2; z1H%f1!w1zE82(z$z%W08aXx->Q{ePL28Iix3J3SA+Xd5_ZJpFL8ut8P-MsLQ)j{Tk z`xh=c*tc!%YDeLV`i`}_mDP)^Ypt78U;1mXSzk(2)Yx%n>7AuwaciOuGsvx0eR9); z<**C2bXu=Fz_sX!^5)h7PABDa{lU{slD46a-CvtTFDzQLXddo+_0coUxCQtF!{*V| zZshl4Jmh)y`7q-DSHll4B!Bt!v(4LB$XA$ueU8x`56r-@P;LwP?fRBgowuOs z7S>lJ=2K&tp37usV%}UPe=Xy&$Q^pwdt}NuEK7GIa3vu*2alVycQr(*r5oaz3o*p>6f#s566w6BHHc(W<9MgP!s~To^|W zUC3~CLrfgsbC=#{=`xcY_xbg+j`{T-;QH_egaxgqoPtxs!j4{$0WOhEyr;G^GQd8#H?uzyCo$h$GR`2 zH``QeXcaVMWMqWwwJt6zTftTJTz9%RG0&{-#hJcX@+QL6Nu>ncM7^AZgoI4%-doK6 zLb?r+^4>AS^A`MdiaI-5R5;V(IFK_lRHE-RSm4X6TEivty*(o8ZF zU!=$CB5i05TBoiQTujW-{C0DCAdgzr|0${jf8e}0PHVKSNjRA_wtw-aT^1c526CtS zAK&J1pwEs|v6?M8j(HPp9+k_Ilap^Van8+64^#w-@}(6%+I_Jm$8j9i5QL? z3xT=0@jS}hM0;geAt!a_thcm(Xeh_DXwi7ojJuiR_2{k2!4osX71W_3zIe@NCZC=t z;Ps|Pduwqa14p5ETRS>Bvd0=Onq%bx^j$KXCWo@KvumE|*na!?_2F4BiLb7#ZMRJm zbhB)xM!w(QdUR^!vq~Ozbd7+fX+z3(QBlijT6!xMKzZ-?CwMgfS5fBKgYQ2+K44t> zFzH;`BY#1ivG1R|8!jeZYRz@NFn;C&lbc0UxI%IuJ7%_Aa=>b!C1<((ImXAf11-ze?zofI58Gb1(;)xH*LQhyQ@t}YGs+=* zL#%5~c85`2Xbq|2W=(qxKiu=-mOp1?)Y;i--B&mG{mK0Z3f9}@+vD3?`&k(nwms=_KZtGlmhZ-Tfbl+` zO6Z;|2EG9U=DCwyE5w^#m{iA2EXK0IF=uV^@2oNozVaS#z$VZO!^*-VpM8JVO;FUR z_(9C!ks`jZeEx%6uCU1hmnpun?vA4Sk!ItqRQd_6PAgau6Q_*SoDWtt;Y5-_d9Xx# zp*QEA%U`~{x#7L1?aokw!TY=Gjq$3*egbEyYgey6sN%U{cN~>I7q6A*r9Ve@f3T1f zU!uZ{C^J9aIZusjzp^I1#i94Uip=`i$>~3|(4S?FKh-dx5ebZXC)wtQmc zvC*OK9GrijeqAD*cj|mp{m);B3ig8qY)ldz_l!gBw~C4;*Oe6&6=ihzDz!Kbmwodo z@L*%ww08uzBay>nc0-;-rxcH3)>#3?*czSYl^=RGI!Sn!GRrC}m)m!Tuu=2s?yeU- zSNd?UM#mgB{|#Yfw#`F0BKjP>4bJbz%ICFFLWF;3z?;eLF#4ApOwaZ6WL)nB1c)2t z-?{iaW2?(G)yP9~Gh2WF4~%UO_>@n%v&!S#7-=f<#TKVw8k)Qa;*7 z+X?4RwE1c2t+%%R@n*5S3ieEpLZQ@nB`z$WpFDPqnXZ1*so5FfYN^H@{-%c1i^5rb zFJoj^x49oo+N2hO1_TfOVo*pph!U@sF(D>>(W+=kx>$1(ejImd2*$$8L~ zImp^zeZ?;|tfrq{FLtJ{E~z`z(d_Y3Rr_rE-1LIKme(~Ue~wo1PGpuN!A40a!w@E` z@H}ef_Q6;i(?xKh>K|8|B5tTS8Qx-ksuHHxcUN`*+ct0UMrM63_ah;DN2zn&)M8eR z_V=C@d)n`Qt9Wx`apAyyc#n9C9rx5Q+`&Ux9C>so?)l*qtVgIt@%E%KEq&+Fsoq3B z#Q&jAeDdtb?(i$^@0p}WJ_buzVu?KPQ6X*9(uX^Rvwz4YY|`f`E-&}$H+s11f|^r> z>X~gey+=nTxNdJ$n=0eqA*Ribr<37y9;u9w^LY94rOec~$W`2jPC3$2v5zFeFn-OW z&km?iX02eug?UcJ54KZg!^)h^=?GzYSPn$%x#S8NofoIyz;|C^Id3x~I|T%p=Spbr z5EUJ!&?hUdFIv`B9^#1guZbPbi+n#cH1s^HKeKFB)@J|yH%M)5dF8V-bLw=jK7GUN z$y?)5#k($i>Od}=gD;aHq-#^p>o9Yh(`90!-8+&}@?wdA*7NNC%-*)VMtZ+(o+CmY zo1pfti!}*JXA6m4KhsI~VtR_zA!+2Z3Pl<|5-4iyg7+XE@jWVlgo*QkT6*@p`>SKE zdu!g^X3|eNUr7QN64@bdI+mI~QAqKv=UQWN*!796X;QIMFkeQCiQ_&^=O0-Ab}Wqg zw{tDnJ^8G?Hqknh#7zplHsL=KgI8|YwQX*&udnaDCp#0nnCaKIH@Oepy~D|%5tnt> z!y3yZ9~A}<1|+y$d7OlDIH3V;WMm|{d~bYk?|h)GSVg|)9DkqQXO;Dacs^ce;WBb9 z#Ma6%60HJ_L+ev6eAaxX-E8&wD58$jNENrY6w5^`Snj39w95A5Pme6&>gein8LKzu zaz&~se7a?-CqB_Yx{%HLk+^wF>cv_MbAvn0D^?`~I9T^o#hNu|BADS1$HQct%`!Uu zao&aky;oP~SH97|v38qOm(@iNY6K|c#YMm8Apuv{{PFWNc zu<378y?S+%UAt!mh3?c>0Y9Zoe7OO$ww@6&veTDkoShyw*9dl>ZE=CI zaM(6oL0%lFdDhrEH~RpdlQQ09d6u?l+1k77c5Zp|7OtsUW%lVwQBolXJKc1~ z)*Jui1QSlOnGrmjwCVtC`-176d_bU0LG#7L1XU;K<_9FPrtRh`=j&f~s$B66OLf3n zdNp`U4Smpaq*?c8bkB_@Qk`K8l0gdD;3zw~U$Uj2;4#1{76L!<= zjR(iZy;kQFbkoh6`9kg5`jRVzQjXNO1n_@+b)_9yOkyhe!0nZT74HM=ieD_@bo#M> zES?==vph)EvIP$@uY1y@IBXFWx0Ku8(#!F=UQRCY`ej>Z}nS`KAHgpq{9YJeY@?V z7Fe{X;nW0iju<}>+5W#_9E#qB1`D5#T`-S@J@fX1uj&-1W2 zMV&SE`H7@0HFr3fQ-Hhbla1xuxmWJ_a`oHobO19cMB<4egZ6Gwes8>V-egx049FlU zuRR%`rq6U!29rG>?4KD(C={sX5V)dQ%NOP}gh;WYTRUPrYH2=o_X@6js+{nXs1QKUqD|^?O5#)C(^asn^Rr_ zs1x~g!m$jnHG8Q(Hv>b}Mh#saEVgCY?#g`+m#}U%HA|BH%)qzSdCm4+sUJxtkR=eU zm*e2rpJD9HA#HGDdoJQ6ELcF>6ziclL!F5F1SCn`q`xUcIA`P&VvKdYQ2>dJ2vy!| z*X=e@7hx?nE$_;`{C*&*vm~If!kR_)M9O*9nXqTtsr(HG;7bwT<%Wib6A_gnD1N$W z<3@milr?;FYvcHjW4kF*E_0LJEb@c-^p_eA4pY+OO~c3xLVDQ~$Y$IW#imzRk|;pjM^3I_ zVCYn>P7k`tyg}S-rjRl>=q1^IIJ=H`*U^QakxGak?!WO@(Fm0|Lf*x(j{7QySXl~g zjrC;>W$7k;00AF) zj`bJWReUOB#E(DF)}h{u(pJhwQB|lCA__zZkhg2f5;fo8GS17d=gB#quSuvs zz$9%2&wAgp3PHM7&+!r#FCr{zS*M+ToVSSS+c%3jH_kF@0Y5BYlDZ6c;5i{PCl-N1 zk9#h?h(vC~>!)&^Ch^4Vl%oS5e7GZR5Sb7GH4OMVDLnj&i#WP(npQ40tX`}?$1U24 z+&}`vH_gtu*=dwyduwD+X)X2q3LjA9T||5!K+oZV+q<>pGShwMm-?Z`C9n(_ZW@~d zO!G21nQh03zwL)QhHa0eTdM#}Q9?Mn~xxH6IX}M!=kWJs|O4*~`MmnPs-NCH%6~nlyJ%B@P zqJCIN$ZV--h177VXytLh9U+_C$INemYAC2+X=#l@W8Em}fpIB!Ii&Y$hnEg0jjG-{ zx(7kOz5AL2;(ub!SOYLoS7+z5QyD`j8fI$q=B!Hu(+@7o=fAp>>>qGk#+i4wqLcz; z&=5hU)$zSDH(5b-0id<8v?SFraJvyop~~aV(_^0c1zqYNxKz3Lf9w%9^pM#cFEg=J zLgaY=+hxLcpIUoty}FDm7-?abbTfp{}e zEcYBGr@g>Cjgf1lr#c>>vY{>Ac)%wpNbs-1g?lP}(3^S+Z&Q@$(0D052V3(TuU;31riI{`hY?&1jhct2 zyZTmc+M5j+jaX!n;FX4UO?+Z*CZJUkXNS=6w3_pSGpwljZIFTWuN17Q6r`XqF?cKy z)JA7#A|fNvdEXWphfb&kB{m%zH0I4h?Q%gt$Zq&;Gx;AdJF1+Zeor_(MrP%{GuxxgRl`5i71Jdou^UG!3050PRgxAfSyUG*kWCe4|nRQh!5w~Dob+Uj5EE@4;Rug5>0 z07?^2f1TE7CTaKMi;VLWPk%IWCMg!GDgaUwvxkazo_(J`x1(`d?HOLy3!MxAVwnwR z{p#e{bgfW{uXWBwei~fh#Zrg>ST^n3f|vX^gy}vfk6Iylq3~1PigZ6W;4+?7 zbqhPAb1lVvHtw$nC-J0_617ZwzlBc(#$A);aO_JWtj|LRp|;qDed_2bvwcUzBhT_xt1>XW)_i+-3q#u9Xsk8@USUIC#XrxCL-`Q9axsY$Tc7`hUh!+%Inpxz z|F@}p_>#|g)UC5qqdG|ja*u6%VPYTg<}e%$?W@%VD4kIgiBsoh)EE0E^sK!+Dxt0@ zRUUu3*6(Yw{}IZX>Pu_3(Uj+4rJVL1KFAK^C`u3*_`4g@!#8R8#cjRtgnx~C*lP7n z>R~ym&Rb#)bnc{3dlZ7TuHj12=-lx9XVZtdX@txFxwo7)kfzGEQs^+Mw#6 zI{Pduq_q$cN#hHB%LddH{At4$x$-(qI;V`(UKz(#1ieHf5iOocz@ZsnQ>V{I#hYQ- z-11(hvhA**o9M`hnq!&aj~dbZ;&k}-7;aVlW1UzxdVeM@ z*^~FU4j_A+^-ciVn)vZ`lcbGQ+Z}FMM)cD(4;^}s9^3HnaHJMG6Y~1#fnA6W(Ol1- ziUfdyC)RPiA6P5#NBQHS(qIXa5l_A}EP^*)0fue@mX8j$DE<_sgg2oUu6+oPCDA=B zjEcNTnhB^$70?k!Sja{%pC#;Ov1W*hhToIF%tRQfk9V$h^B2|+_4BhPb<4MpXNEe8 z{4#}3D`~+|Tj_J-Xb2LN$M`rr{Pc%=T!1I2uk-*bwQC>Mv~u1~`)Oz=sa22VfN zm@jp1I1CU$FX_x}W^~(v2!b1IE5HJg{w=B*w!BY@4Y!GHhpy@W`6tPW#H}4!sN#?Zqkw$x#~U)p760<}ax zk_?i6tX5(RIyr&i6(ZDdRAMqnRm^Yi;NkABt9)_h9YP}apqYKApBCy%(u`wkU`Isj zMN{GNYjZ%NA?k+9ve-;W!Km@B&V3JxawmkN1~_h$_N4~CF_V~+`spdjsAq_MX6j<1X>LUAf1 zS6AAo8?n&Gl}Bh@vA>kk470`ug=k+HMmaw_C;zXXH-yBYc%G>`uA=MkJ!;T)8aWSb zh)b2hsSmx)VS_ly7tkK*0z5pZv>+~iQUyEb3=2Tn|^zkllV*D-U`J&5YJ4K)*v zD<+KsbZcNO=;Q%Ej-W)#1cp>{vaxK_@-bvu@>UN?8AvqKhUNehz*>$Y_5`CRJMxxi z`j-!&g_wv7eo_){Hp`-EChAF}OG%CkQCP}}Q?9%Y>dgaEK&$uI&%gsiM-Q4)%=83K z>g4vRm$o$4?cmpbW zDnj!h+jWw_V24Uhml(<6R3L&a4w{ew2t;KQ*F3WqqnjP0cB!KC5q?`yUsH= zhIQsyC~Z8`X8WXWqQoY4ZLEHt%Z`U6BS~SIabR{a@{nh7tG(O6;m5p6)(ZZw8d(xo zCLQx`kdE-711lvAEu$;) z=b1mJAJ*2^#v82!xW+x?$9ZhNf8rGoh({aE^=}wq2%snYC8P3C! z&0V1QqC%;OvSjQ~y;mTQt*)>6sW$I@1~}>kP|Ct-A+pXx)H}^KjUjezZa3#xbL+;p zT}w$xK_8(aNPGZQikGx+NQgA>IU41XfzxRwIFhb8Y5eSD@>Xu{cASz5XYOmxv?hm{ zF!Vg6T#`oGZakKiITqAdsz&qY;iXp!)Ucaplk$?BXQ2Cwl!b%4df*s7<=}^QngbE* zBbSG)rX`!66e`-x9p%hje19p6t7_=z)(k-fmpT9!B#LI3K>pxHpkm!jtMxb_#o2x> zDGAUJC3)Gm;uiB5id|&GZQR`4W5yS}QJFT2r3s>CpY!7_Ici3iayEaSmhVCz9vKdP zZu>+0-=DB126MWqS+CpvZe^@Sd=1X2C6S=c%OtE|7b69;m|~7pr9XezgC0iV#whJ1 zpsX;gS9QFWK7uPglu%dBYHFS>a>{p2!nO3A4= z{8iMre8)R_Iv+vsF<8D{=C_D7ZH4YQPnC9=ME`(PelAV>QB@sa9PX}Y1q@@QWM!=T z8U2EmWdSF_P;jDjmoAip@}(?LG+KQ&z$D(oxrnqY&@idn*0fMxOmVrLB^Sya5x}aDbzVcKi@pR&AwlbZNEb2 zsbe>iJSS^GVw=(oBaayBHzD23d?D#&3NswMHSi+TT*{AjeMj0^!FA-(uI&^`9`;S) z&~EHD^IN1=yiXS1pG`L$jSal`G@uhHO%~3oS1sn(v$s(a@tyg$Mj$N*|46$1BVUj_ zWzde89PZ|OrSbP%`^hflrQ0q2m9~C3<|Q5>6&rqjU6Im@7M}E|V9qgFgow+d)mr2z z4V`cuU|7__QaD%zEYP$2dUO5y^*~e&Ixvk1RW6u^Zi&C(SxOW-m2L9AtBA^RKeI)9{x?Rpk;S?*@q9? zs-rGf#w?``vy4}CjU5W~4!oEDT^hi@S8+o|bSjnVgT}Xw717W|Fi_01VuvBMg$c^5>d^-KX{y2++*j9F*03 zIdcp^b+}~acvkeXx$?};W4Dyuo8P}Q%HX-NymOCC$^E77w|ue}a#r)C^A<5VqI5v& zNU`WFHjHRM_K#Tdm?TTGBAX;h^HsU4-Y!boY}qCTIPfW8;jXnpwA(T2Vr&X-Y|GaK zNiDl{)+pF&>Gtl$T$3vgOqOruaz*3E3f&GI;U#UUL?Z+*!5$P6J)>o&aOmO8D!nwN zRD&FJx0C40+|06`$CN9Ldht*dG!4?-eq|u14}RTJ%Z{BV8P1`lbM;LvhvznJkIUB$FzoJ}OnMA7 zgvj+=J2kG(m^DwH7rl)eGJqJQ)eR$s_sR@?;J$BPjTQ>BUJYn7VA`DPEcQbv8UPF@ zEv$}O_QVL8V}mRM{s<4|XsLrTP|5WuBt$pv;r*YXK<;Lm_?U59No&MwiM;5z) z8_a}Na%s;osm7WL`LYPFE50wK)bzf1&-c}8!J9Ow!U+s0pDAb``u7+Em9o``*h*V$(-x3_72e43d=H~AA zJ;o^)e&X3$hlJoCO&>Z4`*;T5;jpe!1)rc+yw&OWNS-C@;@}fA>+b#H#!%6+M9UMk zpE>FM)&1Rtee&8Qsf<1KzB6;%GsTOO&6S$0J1N16O=~V(xB$FUR$ScB zME}a?;>~gq9V;Yk;-2X15mOxWepG;yGPIy}fmeqBn~29jkW~RC>SXe{qk$s4lcVqB z?=q<$ex+j&Sx?wB(jr*&g}RserTGUVwfagvfBIyDP95wEeUI~jpuFUd9k5{6vfztq zejhO#AaP-}?-$Q)?@mU}guN0;-1}f4SyLTPg5pyjZ!9aXjKc}kXJ=_>(Mit_`3M< zJqs6Y_`*qai_q0XaN0fRs&=h%>+t#=V~oO&ZkKR=Dh(9XM}>$y2j0{AL2s?Wu|wEq zfg}PdeO0(Sn_jzXuX zF;v#JZ>}$rkdR19dP$BNu)fFIk~;?|>kBtpmcpkD^0Hr`AB4sIs>gZjRR-KCqfHtZh!I-j=j46>`HB_En(wX4_EovYC1B1ftyvp~EdC>x-m z8`Qs!4nrd&X;?Bk;dcZ@Q3aCINR(oTcqL_Jy00#OMZF#3?{9;Sx&MSI7ZSq((rKDS zq7XB!LzP02!m$D3^Rna<@GP(G0}XE?P`=C_E5jHIP$=`lRQMZSG$ zA5-`Dy%Blh^YYT^wl#iskLM$46w}UupZ6SIHC$qDvMoJrQ^0;sp z+th5M&KI_+?ridZNjNTMncttH$CrzO1B5EV_yW4vYtv^}ygfR9Elx26NS0K%zZLz! zi(nm{uY2(oMsG)|WgI%>8!y|APEZ%Rx*I4iGdR@9#xkSyBV^o4+Bsg1(*?O1a-43S zuI_n$2`QwI`R{he6;?wsK8yhbU_mR{*n8($YWyalzE?xOh`I|Kwk5FS(Gy593X zb>6<+S|{_&{;Il+SGNPZ5QzOFc%x_O3kI8Xq(plnP>hh&U(S3i~;uzDH(x?kIz0C3X z$>&$^!r2YK;+yxLs*aty3nIr7k3W67Wg`RsO;aWWf^JjuUswIb{{PqEO{v1dW0x*z z=SrCrw#ElbIx6$Oofoe5VT@t-8-HIK4wi7v^Gi{{@GrT5gd0PgU$%L!TZ6#BlvY*C zE4wTI_|C&WeMe2GJ6SkltT?ORvthEgCTjBr2L9XR!A!r`x8T9?>z^uy%@6N0Zsrv` z;zTh?+-OH#$~`Z9=;u?_JowWc;z~1BBO`6bs9}m{co@_<$YR!MC;w}8e!cI%R1tsk zt=bo*J=^ppCdVfK!|Frx_jUTv=9yf7bnj0W=WmL;{g)hVw7up3Ts-E&pPoFtbg7ux zT`bUsb*a=4k=<*)G924(=y>(fzBKQhzC@q#uhsmQ6?XB=-{yC;BtV`v;pk^+T%jPh zhe3TQd4YR050O87LA6D^^&rj9c2>Z|{8aXyf80Z*^!E!IenKM5c~z=bi?w-GxqiCP zqDqEi62BfxCG?jO{f}ibe3w5SIaYjaJo;}I27Yg{V;{zh{$-&5X-ZX>dk=YfHPq9c z`EU9#9JBv*ZD9)c|J0|z4a5KXpIb1$KG$Hq0uDhLuKv=c{7b6>-`$=6Keyl>O~Cv2 z5B|b$d)=#@CW+Y>Pgl)Z7gwpfF4z}!51WiT^!(utI@kWN2ggf(zo2!pozBBYtMu$^ z^CHIH7kC_BwUWV47OuI(aJuK+AHH+F!)Z*8d?!a$lG>cP`rv|no&S0;hd=CfT*eC4 zR__LZ#VuiP4>QC_5y#)$s6{>WGot-6>VK`*o8W`{cBhzCeTxa1ZH-o2w}in@{?`Ku zdi;K+jvY;KF}qx)Cuv~#Z#TPUn%DV>{cc{P}tVMrs+5AOMJ4+&MW^#%%lDnV$JA!=}+kN{FZ*XlWcXBKCx>sM- zvAwMzmT~b_MXtsLRSRD_ml^&(>ODAKZmdt-rNxu<{U7`5sQ3pX zHA%Sc$qpCJ`s)x)Kl;egf5ev9ZNImmW09u6O9|IZRgC`r?VeqKruWBa6#eq|?)FA= z`yW!mhcR{D-(he`7VZ2z|JPD{Exk^f3)tmadp36(PW=T zOwQ1A$4HyGOzZ7;{<>QH%Xy@A1b)8_F(SRKE_^0gT7hxu>AlY(VN0`MZ=wGU7wp#G ze%JNUk+j>zX=mD|@hGpSVbZ%#8W}tO=~_n@BSH)(Cr8?pcZ;g6TB(!aFg@)`Q+1rY zns<0A#)q+QOs(}=7x?yY zOp%)3AHcw7UX$IX>r#VSL^hiUb6!@eYDqCL@_V1}e<3JY*}K70XpitspM6JP^iaIz zXxBZ1;H0}fO_}^I{UdOiyzKcC*4W(HlX8hQwY{59TkMN)o?uE*Dcj7`!8HMgXr=d6 zj-25+#+tBRY>P>^QJK;io~m6hrPXi!r-vV-{TC|y&xEcnV4Y>{*-&QI&zY*#bj6nc z?c)s>8tAuTbm<=t;7O|%P6}wr(Nf>(AJ*MmwTE2%S5maUWAPK19DDXBNTseb>M+2W z%1$5bLGt+!8OwN^Urj(%)I>bTXkS%%U9$1gh-;5bn%T|4GXJ?OIs)|^{5t;`IgTB; zB#Z zK4o-rDfgL+TN)1-HU2#~8BZu)-v`wfau=OCjZ`Hml9DFob^|xmT@Nhj zp|uM9MBbY6{I{w~W`|1DZ@<=*CEoLo6u!H@=FuN)@pOEp)oV{s@LeK;D~ypW=d^A_ zdc%39bnm}Cd^O)9xhrb#!nD)a$F%B-6<2W51WpMve{5qq;TN zw+bEEziW4em~jY4D!cAwJw!U+^ugPl$2ZN_S%=MQ{0An(MFszSQO$R^RI|PdE{MRD z(+|>)3TSFxIHHsFUhC9`R8bX~odAqR_w&>=pBR#7ZOS;Q>iEvpjVx)x)<4CLx}NF_ zjay9$D^yZUU+cT*od5hZZuy1y$`twv)vUS4x1apY56r`P=KXWw=d-(h0QFY=!eI>df7G3NrGl!C} zNE?SjFMyaBs|zdG5kmX~5JEK+(rydN-QZ^OZkVjES9Kf}t%%yPG41b{&O^V?9#@%V zyOQKdNs(PEMcK9H>ysnR&$ABEo~Dm@H>3uMtH4wI;yycM#e`f`j(GOSlu0M^k}^fm zq)_`XT<5Ur_RAB6+| z4O~3_P=MaX)M2ez8(QXE^AfDj>ie{kir-)UaQL8KfE+xrFDB<}4wKp{<(n5SkEp6< zDdb&5lse{IMQaf>Npw7W1Suc_IpOoJjKR#9obCM$ySwkJ9A{pkA(xhT;`g4o&-c+v z)CMq{2g8$h)m@L9GzMEcRGC@5u}9*M3GJaJYsDQ1pZ{TO-;reE`9=%oSW-#XT5gZ} z-}Sjezt5!ej6RNZwe}C{aZtU-)0Z7*F6-Kz+x96Y#C`~Nyf9|&(}rDl8b+CVSyRQ2 z*d_Mdc&u*d7#@={H5xhA)y3D4G7`Y=={Dp;i@)`IqdE3h>ISj9NQ~>5Qv;Y@@tT-x zC%sfWJ4HD<-O8NW%js}f(SRY%>Su&3Q&5*K8rwHyl5Vmlm7aI8b`IQxwH;k>Iv)A7 zM2A1DjKnX8YHiQ?FNa#HZ~m;b)Sxyf$+%*vKHIU+RkWJjCbg6aG$g*>U^>1^*>h{- zf;I6!T}viDVPcwsGKj8}mMHTFyIi`O;vt_LG%ZhCW80+5HCuaul|rxlW%X-5QTC~> zsGx|WTClgM5~@2fnEY%NkeEhfi_7#u+SXcq_EatDiOc+NW3QmdD*CjqP#D}84pIP8ybxOlZM?OK(|<&LkvqI9B5SH`cO#{a2= zU24eTXjSEvSLj$ae859Dw`DFTf>bUSte@=I?6+Z~rTWhfNtNF8P|WIs$({FU*Lp4l zeOB>k7_5E%(z*V_n9kbYN1$-`-BQz~F(F|&drjCnIu6mc&OVR*^pHU@u`-GcUcEf6 z-*aayqp?Tjmv60I)_C$BVVTD6#3@>X^=uZa+N$!k2hv(CeviH9ZLgXrEeIY`-z5CWqPmN|=7{)iq4a8@HH<=gxeJ(H(fb0BCO?uZfKkJ%-p@ zgbnamEF+)M2K_;uven)Sx#wmeOH1v~tVp<0Ee z`HVo5zYfwmXU*Lj7G-2;Y&b6WE0LrNoUI7me&<%4!5{8>Pj=99%G9!9dqJ8wsRM?) zN@t(toUb%5nHg#nS=3w0mi0X4uN4tCv`azqv=JMc%f*3)&UB+k^t9y+MKO(0mQ`M1 zg~q>fo#Hd)ATg4Fsa_3;%m7YmuYd15=*W76vB!*ETYQk_@bzlj+Qs48F%X%mW))$g z15JZTCfNP5Sp2_n(IwH()kl6GPSD0V7Q11iyl${Kp$^?)-12o}xax>%YQxrca9S5FgbUnh^+k<`REK5~K9A4d74}zX>b<&GY1*e%knc-7E>=&X5 z?s!#DEA>|g;-0p(+NRev1K=hT(Ye~DMGSsQKO57Ir+YQObGyCw+T>U+(cMQhaeXh| zVJ-z3NYjGzMnY|NadBb%3g)Oe#A7KxKyPAOp`!cJP~owp3q-HZRjTqtW+l&IZ#d!$ z){L%owFamkM2bTWQ6ZUVb?AMD!2|p|Uw}P;>#s~IC!{+tO6uAP#gWV)frf&w<|~;u z1Q~{qPFjMLWB5nslA9cpn65yE_<&VTWFb&G1c5nQsqA~#q^mTL3;=!1&_? z&xU9app%R=)+wlm3?|F!y9G2XglY_Civsg7^cr5!(Lp4Wgk$(x2%#YsXlIid%x68T z1bM$(RDj>$ zT>{0k>kd4B>&;yENu&R81Yv_e4Kj0|JCy+@oSy&)S0&_Um?zPMBU#^p9_S=?Q2Bv)=kN4d!2z>Bh?uQzgE+_(co`~&=YYyUW>Tdb z@l}-us{r(h3eaRC*vfJ44(TiCV_sCYEl>j0*Vl&;LJ$52D!=D?(Wf{&mt!N!k1&X< zPE6zU-x)F_!b>1T05D5C>wk1)9fg>u!u=}$=Ei`S_vfaU+8G}g2VR>U89P>ND{;()5AneI9g7iQ(D-R4agGhIZM=%&GL~Po( zLj-Ea#T<6w_=VemN@os+IvKD6vvg&KIK}48BDNWjH`nCcehUrTI5;d&f6TUX((gzU zLGp6UVA9@P6azt;C)`HVW^Fxe0ZR^>h_qzxk$^xi{ND_8e#SFk89~G++jS%XocS_0+fi^sB9>NS?2rVASC|<=1+xR{gR>H8EGA3Ihxr==?4(?oL$TvAvJaeU zjs}mFq)5P@na_C`a?&C6SJ-!*e^DmZXhmxny}nF{$j2}$NvXP;5N^Qp zNIPG-#ervPhK%@v$q@<(7$pcmU$s+BZZeZGLqt__o-%0cBk=rH8>qY9f}FB;h*Soi zP~ww|p8v&)#ICl~-5>+VV4I3-EJcJcrGW5RF>#3qTHuyvnaYF2g~_tc5rrHF7|TVF z7>&WIA>Qx95EGE{Fw{U-voDT)w}};|*=@R#zbc8+3ca8KTNWlqkuU;b86wFQHa)vD zeQ(G}4bX-JhBX7?bhzEZr|#2~)Xye6-^_j-wRaUyOLZlcdjO%5D9AxE0a=QLX1sal zuT|u;lv>UqwWnsAMvb6GgNnsF4-Sj%60+e_@l9J)YoF_$^@JQU7up24OAfRE#Fa#v zI_&Xa1$3gIhUUAl0fu(^fjp=|t?#F9Eb~so94WX#(3rtQ*o1#0z)T5e%KVS~r>Qs!1*>LgT67iy#n&-Og9)hly zAqkP^dlCTw>1FI1B2X>}U}qEJA=Ana)y}lKkR&XDXd7vRxEwi#DOm=dVDT2lO@PX) zY!Y<8cWvGE4`aM>N%grAEgw60>#FVT?JFCYFgD{njVRc_Bri!;i{UbyW~q)b)o&X- zW`8n$IlJDsdrEVR*B(8!@Hj{`We=)|Nq}GmmRAqtNcHeT3?)cbv#tg=e}V$>K%~tB z>e+}@56O6wQ|6#d-ZaZP`r@)7?!dcPTXC?U@BE2#JVh~WY!}yvpFGV4o%~+tdx@iw z(S@6&0)v8d`r_Mc&9PmIYjoxt^@?SlSr?;89<9z)Kgs<3;kJYKPHQDXUh!yAf zZeE|~&Wiwc$Y`3y`!=YA2NkkPPo|%55N^E1wzosg+I8QkRtBrq0CFt|>eO~mal$Ub z^tN-*2PEyC0-cb|79gyrB3q5~rDvS>OUIQJ9=!jRP4a9#m|jv);UW0L8f2j80PBc9 z^9LsgQh76-!bOFe9BzebTh~`(tPna|F216menO#jC)E~wAmr#L;7vkS_Z@25Wy0C_ zHs6|PW^YygLETVvIJfb9W-gMlEae1)Ix7jRrfG@y#8cGyt}o2WgsdAHboXkvrOwjZ zRxdxcEN6a6FVw4PlI^hVaq~O$BsLSn;`rMxNKDzjPy`+t4-(a0| zO48WGU_0x~hJGlPU8d-MJ<%H#d5dDw>dysXj!R(!h}X(9)PjSUk+`AyEvU^SaN{UY zMUj}0knG#mZB+@~n96tb(dyJlNl&Fw(LeBNri`h`Od49!hnKJ3g3r_h&{#Q{@UO5a4{0@nnxi7L0Q-0N z-oFf$s8$K74eU}(F4=?4K;+lM+{?b$i9wgj7ewB+CTO9@n$B=Czz=axAM-k3-i;76 zop`nvI}BmD&{P~~m6(9XW5dz_00SD~931a`=+ryTIp&KH0E|PIp@;byW@h~mhwZu4 zkOEMcXx^H;ebW3rPh=+6*}ww~&K@$S4pd;0*D<|{%;@o)?cay&fw?FKCE1`MEBWs@ z301yedXul8pWmLV5bT(Mg$?wCO~CLY8Ft4XWDsAs_ZGhXH1M4(eKA&vuoU6zVp?BR zoMs--zRg#ZOB2p}w!4wduLHS+b^nkHL~Ml{Aj+ZkX6YOlF>Q)86eC)H1cloPC@OIb%@6j=>k{Mp$3v7 z9DLv)X7J^*nb{>BK(-cy31AAq%}m8VtYkTbK7)yfg)ZKcmGYdPH?Kg32;t>KrG4=&(%h*1)ln?5}bf72FHeo?FPXNP}(++@=D8wxrd!V|ZpThrv$|1qdf< zXUr&I794|3%;IrOVbhiM%`DSo@EFW1)asK0V68Jm3?OnsA8J*^8*n6e&mhFH%0J!$ z$dKWCN%mjBC51ZsI!yi+^IsTn0WlAzOo*<%Ou#WR6Uc*LD%-c^yA2-C(um@ToC4I@ z10^uPk1d9L#6f%q!s}<`i#$xktE84RS(FG8*bLbo83O>pUF&SWEpj2ziLxXTgQjAX z3z>WdJ=AWD%>(-rdU{i+Q;IWGd2ObNKFYFO0>Z0Hcz&pw76St(QfK4qp0jXLjASye zb`*`RF^Df6D^MI-adhtBy=?E-9{i)%;k$r!tDFc)FZ#5grCKg7|5=^;d?xLHa)iJNzH69H}?o{OU6O|C$!8zTf9Oh`Us57MUvOLg(Y-i!RMs!67w) zv1#!!b$TG9qPU6?snJNf;@^szXL_`E9MY43===Fu%fg3v?o*5XBjaR}3GW&xdmI$> zNO>~(7O21s>~t{h>aN|@Ibm;g@GSXacy_K zj7e(H+Vp#hx7_g$!>lqOeSm5*4^!*pJyLU^z$Wzs>a!n&0DVs;WJCD-p+VuG{8&4W zx_C@;*vCc22_Ri$fb|YLN~lVGP){bE^f0IK*s$H>FX{*r_k*A$(CkS7#%ZZjk5v&p{tdW*d+%o)6GaHy@Y=knNrPM5P z=3y0;0nAf?F1QXf@;YAv;7|yniu8vv4CsP92QcJK%^b+%$p{zJMUH^y2Y!x(gSO!^ zDsX58HoIO1tC|M$Au0zV1O;&$K{N+mPR6;(k}B|QZEsP6fa|f5PTt%xSQ8cEC+JAX zRGvZ($6&-2B*%jO_ho_-7^DFa9rkQ(mOJnZ;K9*QDSJE-k`=*j5i1unab!blJw|J+ zKzS!AnFZAuAio1%A%ZOzl@%!lP!*>m*^_Z#fYKh%b~fm`)#he|EHN!$;h`;5AR}&Y zMToF9VHu=$g=B695dv&_{WMeH#WH1BHIzIBd)+E1NitBhqF|FhD6X4i}QCyV64#&A~hT7DEKc$TuJ|GBsD05s)DW+AT7a(Gh1y_OR_t@Z(Wj zkh%iZW|uj2TIccp2~r(koJmuLrQ>K%Dhvo?+b}iL65oMWOhZ{f2E7qssxa9qjBh47 z3+hZeXFI3LIo*O>rypP5EYU;4srvfvHp%B&XfZ*f25^~ynmrV8UCvgnjh8i{4Rs&N zvvD%*j>xQ05i04A{!X_q{J6u^$EqKe^?j=(n<(Ce`?FtV*B;b88gHE5pMbDaUH{!_D`as z$S45PV8KvF3nceK{qe$Jj5#7(UXk*O7uI#BT4Io1VZ1YBYAKcrk3(=B>8qd?c^VzfHa#fZ z$ZJ+_p}lSU`n4}JdOJ`d8lF3+480hClL}-QkZlO3$v!wOK*o0(ikMWYZ%p*fb$&1= zsov(SQ*M4@Uz~cOzW6jmuw+6CW+kAchHBt6f`fP7k4uz43MDep43bNjDZfoOgUk)+@Sb1+uU|8dxhNsm&I?v#AC_cu-Z_huktEylxR z=r?+72N)snn83wWqFGutdb};l;-+!c5OsRnJKGpFT4Vl|`PBzI%N5kcu$grT*kte) zW?lEAR|0)M8Yl`Qq=Ovf@4xn9;oNN!+T|pX;7U-j0Z8W}M#9T6KT{})bJ0UIaiN9t zP0--Wc&?UXgC-H_Aof3IJ?G(vli^J0Tfn3rL0R0uRa0Th!Sq@w&#)QXxxSVTahmotolqqw8f);Xf%e^4G~Sl9{}fbX!Q{AO8Infz=F-PhdJl za~n(iiZwc1OL;@Kon;AI=G$7w{1z5M8VB(0XBeC~M%$LDus%jYv6LOKmkbz2Vpxgl z0xRa95oRDPQWE?b34m399UJj6W?8gLNO6QV4wQKC%J1k`kYLs|Ro@h4(P>1sf-!1l zrAAwTms-IRb=K;r!|G)0cQ>XYT7KZVO=N;ZbA(iF_kT5a{y$OIaU6$WhT$wBK^H_g z4x*h$Vc&2JihzXVyBCN+0euqf1_VNighpwDYUczG87FO*1nLaJDNYiCX>~xC5g7ps zzW^F32@Y}r(c^W~KcOvt<#u;`-mll|{d~RNJRfiR=x_Hbdml0x_a{|pQq!Y!?%UgHd?w+7!lwmV)uyz!-5VZ)88G4h{foLbA^$|r*PXwYR zr3y2XX14bc3n->Ay0o&Li%tcp#y%ls7`spm-Xdy9aHVa9W@e9K*|Pj#C9mwGAz-@S zK+MBO8lXCx^5Wxa2Iu0HzIfk-7JY2jpRvkdOB?@%U)8)Sjvak&*D@<6>^?S9I_)SL zpJ3#z0DI7r>YtAvW!s{=on!(*$l;f&C#YoShyTPj?K=}(Ks34^8?B0(^2?!c(U|0S zF7^==qsgE?OfJL5fImM(b_=$1k{~#F8VmZPWLkY%wKun2{`&Zjd2IOpQ?`W|L4=@ahpXYkYh*b6o`r zB75!x-dy(UZj1ap`cy_Jt11}Y{)Cs^3;IOkqNXvHs*{>7kt4%IXZdgE_az0qo)qDfiN{z9p9QDFU|{$7>k5mB<%@)G8H5! zm)mpMDY~sSu_ISs8+&8Zn9*>j^YlLZU(B8#2%Lfv2c!;|dFIOUS=F0;Bie6T^atbL z{V?*Y9Hpe+sZQthBj5EeIWAy|?B~}=V+72oQReH`_} z=;f}eKe41eKE;BZ--w*|_xaKuOjs(0ZxJXEG7z-`)E6o9-c7JpI}sLQ%)WkY z9K*VxY2UNEzl{SyRc+9!h}y@UttFbec!ygh{)VMgOE-_IU{5)}>sUl?i2hn1Fvssl zW$Y_J2#RE~&if=Ikw0WqfsC5PcZpyzS@TA=P(QU-M?qc>vsDDTpUZ6Pw0WH|t|BpF zI4H!|s;K>gb!ELh?y*0yBHM`tWUKb5ovqF8)jc%HYz6O&$PM#px9fl4@QuPX_^wue z$LLyq1NnEu9*6#9b2k0Iiiy!BAPGo_657VPwJy86uE^_cY=*K03VGLm^gp~uNWSBL faM_W=!tq!>%CkP literal 0 HcmV?d00001 From 4894e27ab6e8860bc6d8b9c5d03879aa6a8e7c01 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 07:19:33 +0000 Subject: [PATCH 11/15] Fix three CI failures the sandbox could not reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three were mine, and all three were invisible locally for the same reason: the checks that catch them do not run in this sandbox by default. clippy rejected `stroke_shaped` at 8 arguments against a threshold of 7. Cap and join are decided together and travel together, so they become one `StrokeGeometry`, which reads better than the pair did and puts the entry point back at seven. The docs suite pinned the exact sentence the new claim guardrail rejects: `test_what_is_xy_restores_the_sdf_hero_and_ends_with_a_short_pitch` used "**Completely customizable.**" as an ordering anchor. The test is about ordering, not that slogan, so the anchor moves to the replacement bullet — and the two checks now agree instead of contradicting each other. `llms-full.txt` must contain exactly one H1, and the generated capability page put its `` comment between the frontmatter and its heading, which defeated the builder's leading-H1 strip. The heading goes first. The docs suite needed `uv sync --project docs/app` to run at all here, which is why it was not part of the earlier verification. It is now: 91 passed. --- docs/app/tests/test_docs_site.py | 2 +- docs/styling/capabilities.md | 4 ++-- scripts/gen_capability_matrix.py | 4 ++-- src/raster.rs | 37 ++++++++++++++++++++++++-------- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/docs/app/tests/test_docs_site.py b/docs/app/tests/test_docs_site.py index cd96e971..8fbee70e 100644 --- a/docs/app/tests/test_docs_site.py +++ b/docs/app/tests/test_docs_site.py @@ -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") diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md index 70493b1a..e0bad023 100644 --- a/docs/styling/capabilities.md +++ b/docs/styling/capabilities.md @@ -3,10 +3,10 @@ title: Capability Matrix description: What XY can be styled and extended with, per renderer, generated from the capability registry. --- - - # Capability Matrix + + 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, so it cannot promise a property diff --git a/scripts/gen_capability_matrix.py b/scripts/gen_capability_matrix.py index 5ae43351..f8a9a7b4 100644 --- a/scripts/gen_capability_matrix.py +++ b/scripts/gen_capability_matrix.py @@ -138,10 +138,10 @@ def render_public() -> str: "generated from the capability registry.", "---", "", - "", - "", "# Capability Matrix", "", + "", + "", "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, so it cannot promise a property", diff --git a/src/raster.rs b/src/raster.rs index 7c308b6d..dccbc168 100644 --- a/src/raster.rs +++ b/src/raster.rs @@ -447,6 +447,21 @@ pub const JOIN_BEVEL: u8 = 2; /// reversal in the data cannot grow a spike that is not in the data. const MITER_LIMIT: f32 = 4.0; +/// A stroke's cap and join, carried together because they are decided together +/// and always travel together — and because a stroke entry point already takes +/// six arguments without them. +#[derive(Clone, Copy)] +struct StrokeGeometry { + cap: u8, + join: u8, +} + +impl StrokeGeometry { + fn is_default(self) -> bool { + self.cap == CAP_ROUND && self.join == JOIN_ROUND + } +} + #[inline] fn cov_from_sd(sd: f32) -> f32 { // Same 1px ramp `seg_coverage` uses, expressed on a signed distance so the @@ -636,13 +651,13 @@ fn stroke_shaped( rgba: [f32; 4], closed: bool, dash: &[f32], - cap: u8, - join: u8, + geometry: StrokeGeometry, ) { - if (cap == CAP_ROUND && join == JOIN_ROUND) || pts.len() < 2 || width <= 0.0 { + if geometry.is_default() || pts.len() < 2 || width <= 0.0 { stroke(cv, pts, width, rgba, closed, dash); return; } + let StrokeGeometry { cap, join } = geometry; let hw = width * 0.5; let n = pts.len(); let last = if closed { n } else { n - 1 }; @@ -2270,9 +2285,11 @@ fn rasterize_with_spans( for _ in 0..nd { dash.push(r.f32()?); } - let cap = r.u8()?; - let join = r.u8()?; - stroke_shaped(&mut cv, &pts, width, c, closed, &dash, cap, join); + let geometry = StrokeGeometry { + cap: r.u8()?, + join: r.u8()?, + }; + stroke_shaped(&mut cv, &pts, width, c, closed, &dash, geometry); } OP_POINT => { let (cx, cy, rr) = (r.f32()?, r.f32()?, r.f32()?); @@ -2717,10 +2734,12 @@ fn rasterize_with_spans( for _ in 0..nd { dash.push(r.f32()?); } - let cap = r.u8()?; - let join = r.u8()?; + let geometry = StrokeGeometry { + cap: r.u8()?, + join: r.u8()?, + }; let points = smooth_points(xs, ys, n, x_scale, y_scale); - stroke_shaped(&mut cv, &points, width, color, false, &dash, cap, join); + stroke_shaped(&mut cv, &points, width, color, false, &dash, geometry); } _ => return None, } From 16bbfe0c0189bab4018b4e62e8470d1e8be4d3a7 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 07:35:59 +0000 Subject: [PATCH 12/15] Address the review: one real coverage bug, several sharp edges, one rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most consequential finding was a rule-ordering bug in the Plotly ingest. `fnmatch` lets `*` span dots and the first matching rule wins, so `layout.*axis.showgrid` was claiming `layout.polar.angularaxis.showgrid` before the exclusion below it could reject it — silently counting 3-D, geo, polar, ternary, and smith axes as supported. The headline number was overstated by 127 attributes: 345 supported, not 218. The exclusions now sort first, a test pins that ordering, and the dossier, changelog, and skill carry the corrected figures. A number this PR exists to make trustworthy was wrong; that is the right thing for a review to catch. Second-worst: `.agents/skills/xy-evaluate/SKILL.md` still said "15 axis keys" after the 16th shipped — stale drift inside the document whose entire job is to send agents to registries instead of to stale prose. Every count the skill states is now pinned to `capabilities.summary()`. Also fixed, all verified rather than taken on faith: - Plugin columns normalize to contiguous f64 before `calc`, so a float32 input cannot round the arithmetic between two f64 endpoints (§27). - A plugin declaring `name`, `style`, `data`, `x_axis` … is rejected at registration: `xy.mark()` binds those itself, so the column could never have been passed and would have resolved to None at compile time. - The generated matrix labels `planned` rows as *not accepted*; the table is registry coverage, and calling it "what `style=` accepts" contradicted the `stroke-linejoin` row two lines below. - "drawn by all three renderers" is derived from `prop.support` instead of hardcoded, so a future `partial` cannot leave the prose overclaiming. - The unsupported-reason table folds its tail into one row and prints the total, so the column sums instead of trailing off 3,994 short. - `KNOWN_RENDERER_DIVERGENCES` is a typed dataclass like every other registry entry; a mistyped key now raises instead of rendering blank. - The guardrail scans `CLAUDE.md`, the skill, and the comparison document — the three places that actually make customization claims. Its comparative rule uses the same ±10-line window the numeric-multiplier rule already uses, because a claim taxonomy states its scope above the table. - `_emit_line` falls back to the documented default for an unknown cap/join rather than raising `KeyError` from inside the byte packer. - Dash patterns are validated finite-and-positive before either stroke walker. I could not construct a hang, so this is hardening of a byte-decoder boundary, not a fix for a reproduced defect. - PDF/JPEG/WebP added to the "every native path" custom_css test, Altair to the pinned-version test, the line family enumerated in the styling docs, a stray space and a placeholderless f-string removed. Rejected: the miter-limit change. SVG defines the limit as miterLength/strokeWidth = 1/cos(phi/2), so the guard bevels exactly when cos(phi/2) < 1/4 — which is `1.0 / MITER_LIMIT`, what the code already had. Guarding on `1/(2*MITER_LIMIT)` would admit ratio-8 spikes, twice what SVG and PDF draw. `miter_joins_bevel_past_svgs_four_to_one_limit` pins the threshold at three angles and fails under the suggested value; I checked that it does. --- .agents/skills/xy-evaluate/SKILL.md | 5 +-- CHANGELOG.md | 4 +-- docs/styling/capabilities.md | 8 +++-- docs/styling/customize.md | 5 +-- python/xy/_raster.py | 6 ++++ python/xy/_svg.py | 2 +- python/xy/components.py | 17 +++++++-- python/xy/plugins.py | 15 ++++++++ python/xy/styling/capabilities.py | 41 +++++++++++++++------ scripts/check_claim_guardrails.py | 33 +++++++++++------ scripts/gen_capability_matrix.py | 44 ++++++++++++++++------- scripts/plotly_schema_coverage.py | 40 +++++++++++++++------ spec/api/capability-matrix.md | 8 +++-- spec/api/plotly-coverage.md | 20 ++++++----- spec/design-dossier.md | 12 ++++--- src/raster.rs | 50 +++++++++++++++++++++++++- tests/test_customization_comparison.py | 7 +++- tests/test_evaluation_skill.py | 14 ++++++++ tests/test_export_style_survival.py | 7 ++-- tests/test_mark_plugins.py | 37 +++++++++++++++++++ tests/test_plotly_schema_coverage.py | 40 +++++++++++++++++++++ 21 files changed, 340 insertions(+), 75 deletions(-) diff --git a/.agents/skills/xy-evaluate/SKILL.md b/.agents/skills/xy-evaluate/SKILL.md index 030d09ef..e06a6dbd 100644 --- a/.agents/skills/xy-evaluate/SKILL.md +++ b/.agents/skills/xy-evaluate/SKILL.md @@ -13,7 +13,7 @@ registry. Read the registry. `python/xy/styles.py` lists ten mark style properties. An agent that opens it and stops will conclude xy is barely customizable, and will be wrong: the -styling surface is those ten properties **plus** 15 axis keys, 23 +styling surface is those ten properties **plus** 16 axis keys, 23 CSS-addressable chrome slots, chart-level design-token variables that reach the marks in every renderer, and a mark-plugin API. Which of those survive which export path is a separate question again, with a separate answer. @@ -72,7 +72,8 @@ document: drawn by all three renderers or rejected at build time. - **Far narrower.** 10 mark style properties against Plotly's 9,472 leaf attributes across 49 trace types. Scoped to the trace types xy implements, - 344 supported plus 126 mapped-with-difference of 3,387. + 217 supported plus 115 mapped-with-difference of 3,387. Do not retype these — + regenerate them, they move. - **Less extensible than Matplotlib.** `xy.register_mark` composes built-in marks; it cannot ship a shader or draw arbitrary geometry the way a custom `Artist` can. diff --git a/CHANGELOG.md b/CHANGELOG.md index 987db7ee..0484b47d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,8 @@ in the README). (the equivalent is `validators/_validators.json`, and the real surface is 9,472 leaf attributes rather than ~3,000), and there is no Plotly shim to warn — so "supported" means XY can express the same visual outcome, not that a shim - accepts the key. Scoped to the trace types XY implements plus `layout`: 344 - supported and 126 mapped-with-difference of 3,387. + accepts the key. Scoped to the trace types XY implements plus `layout`: 217 + supported and 115 mapped-with-difference of 3,387. - A **committed customization comparison** against Plotly, Vega-Lite, Bokeh, and Matplotlib (`spec/api/customization-vs-alternatives.md`), with the same discipline as the benchmark harness: pinned competitor versions, a named diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md index e0bad023..1c9adf9b 100644 --- a/docs/styling/capabilities.md +++ b/docs/styling/capabilities.md @@ -12,14 +12,16 @@ and *does the change survive where I need it*. This page answers both from the registry the implementation is checked against, so it cannot promise a property the code does not compile. -- **10** mark style properties across **20** mark kinds, drawn identically by WebGL, SVG, and the native rasterizer. +- **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. +Every property the registry tracks. A **`shipped`** row is accepted by a mark's +`style=` today; a **`planned`** row is *not* accepted yet, and its note says what +blocks it. Anything outside the shipped set raises while the chart is built — one +renderer never silently ignores what another draws. | property | vocabulary | mark kinds | webgl | svg | native | status | |---|---|---|---|---|---|---| diff --git a/docs/styling/customize.md b/docs/styling/customize.md index 6d65163d..3e3dc3e4 100644 --- a/docs/styling/customize.md +++ b/docs/styling/customize.md @@ -95,8 +95,9 @@ 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`; line-like marks add `stroke-dasharray` and -`stroke-linecap`; and `scatter` adds `marker-shape`. +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 diff --git a/python/xy/_raster.py b/python/xy/_raster.py index abf8e9e8..56ffba54 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -1065,8 +1065,14 @@ def _emit_line( xv, yv = _step_arrays(xv, yv, style["step"]) c = _rgba(style.get("color"), color, _stroke_opacity(style)) width = float(style.get("width", 1.5)) + # `render_raster` takes a plain spec dict, so a hand-built or round-tripped + # one can carry a value `compile_mark_style` would have rejected. Fall back + # to the documented default rather than raising a bare KeyError from inside + # the byte packer. cap = str(style.get("linecap", "round")) join = str(style.get("linejoin", "round")) + cap = cap if cap in _CAP_CODES else "round" + join = join if join in _JOIN_CODES else "round" if style.get("curve") == "smooth" and len(xv) >= 3 and sx.affine and sy.affine: cmd.smooth_stroke(xv, yv, sx, sy, width, c, dash=style.get("dash"), cap=cap, join=join) else: diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 35fbdb36..3da22e15 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1656,7 +1656,7 @@ def line_attrs(style: dict[str, Any], color: str) -> str: outline_path = joined if style.get("stroke_perimeter") else top_path marks.append( f' None: def _plugin_column(values: Any) -> Any: - """A plugin's declared column, as an array when it can be one.""" - if values is None or isinstance(values, np.ndarray): + """A plugin's declared column, as canonical f64 when it is numeric. + + `np.asarray` alone preserves float32, which would let a plugin's `calc` do + its arithmetic at f32 and hand the rounded result to a built-in mark that + then widens it back to f64 — precision lost between two f64 endpoints for + no reason. Canonical data is CPU-side f64 (§27), and the conversion is not + extra work: the column store would do it at ingest anyway. Non-numeric + columns (categorical strings, datetimes) pass through untouched. + """ + if values is None: return values try: - return np.asarray(values) + array = np.asarray(values) + if np.issubdtype(array.dtype, np.number) and not np.issubdtype(array.dtype, np.bool_): + return np.ascontiguousarray(array, dtype=np.float64) + return array except (TypeError, ValueError): # pragma: no cover - exotic column objects return values diff --git a/python/xy/plugins.py b/python/xy/plugins.py index 954d5967..71dad5fb 100644 --- a/python/xy/plugins.py +++ b/python/xy/plugins.py @@ -89,6 +89,14 @@ class MarkPlugin: doc: str = "" +#: `xy.mark()` binds these itself, so a column with one of these names could +#: never be passed: the caller's value would land on the `mark()` parameter and +#: the column would silently resolve to None. Rejecting the schema at +#: registration turns a confusing runtime result into an import-time error. +_MARK_CONTROL_FIELDS: frozenset[str] = frozenset( + {"kind", "data", "name", "style", "class_name", "key", "animation", "x_axis", "y_axis"} +) + _REGISTRY: dict[str, MarkPlugin] = {} @@ -119,6 +127,12 @@ def register_mark(plugin: MarkPlugin, *, replace: bool = False) -> MarkPlugin: duplicates = sorted({c for c in plugin.columns if plugin.columns.count(c) > 1}) if duplicates: raise ValueError(f"mark plugin {plugin.name!r} repeats column(s) {duplicates}") + reserved = sorted(set(plugin.columns) & _MARK_CONTROL_FIELDS) + if reserved: + raise ValueError( + f"mark plugin {plugin.name!r} declares column(s) {reserved}, which " + f"xy.mark() binds itself; rename them" + ) _REGISTRY[plugin.name] = plugin return plugin @@ -134,6 +148,7 @@ def registered_marks() -> tuple[str, ...]: def get_mark_plugin(name: str) -> MarkPlugin | None: + """The registered plugin for `name`, or None if nothing claims it.""" return _REGISTRY.get(name) diff --git a/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py index c724aa4c..5fc402c3 100644 --- a/python/xy/styling/capabilities.py +++ b/python/xy/styling/capabilities.py @@ -83,6 +83,23 @@ class SlotCapability: channel: str = "" +@dataclass(frozen=True) +class RendererDivergence: + """A default the three renderers do not agree on. + + Typed like every other registry entry rather than left as a bare dict: a + mistyped key in a dict fails silently at render time instead of raising. + """ + + id: str + what: str + webgl: str + svg: str + native: str + visible_when: str + tracked_by: str + + @dataclass(frozen=True) class ExtensionPoint: """A way to add behavior XY does not ship, without forking it.""" @@ -214,16 +231,16 @@ class ExtensionPoint: #: Cross-renderer differences that exist in the *defaults* — no style property #: selects them, so they are invisible until someone diffs two exports. They #: belong in the registry for exactly that reason. -KNOWN_RENDERER_DIVERGENCES: tuple[dict[str, str], ...] = ( - { - "id": "polyline_join_default", - "what": "Interior vertices of a wide polyline", - "webgl": "the notch two overlapping segment quads leave", - "svg": "round (the writer names it explicitly)", - "native": "round (the capsule distance field fills the vertex)", - "visible_when": "stroke-width above ~4px at a sharp angle", - "tracked_by": "the `stroke-linejoin` row above", - }, +KNOWN_RENDERER_DIVERGENCES: tuple[RendererDivergence, ...] = ( + RendererDivergence( + id="polyline_join_default", + what="Interior vertices of a wide polyline", + webgl="the notch two overlapping segment quads leave", + svg="round (the writer names it explicitly)", + native="round (the capsule distance field fills the vertex)", + visible_when="stroke-width above ~4px at a sharp angle", + tracked_by="the `stroke-linejoin` row above", + ), ) @@ -309,6 +326,7 @@ class ExtensionPoint: def markdown_mark_property_table( properties: Iterable[MarkStyleProperty] = MARK_STYLE_PROPERTIES, ) -> list[str]: + """One row per style property, with its per-renderer support.""" lines = [ "| property | vocabulary | mark kinds | webgl | svg | native | status |", "|---|---|---|---|---|---|---|", @@ -324,6 +342,7 @@ def markdown_mark_property_table( def markdown_slot_table(slots: Iterable[SlotCapability] = CHART_SLOTS) -> list[str]: + """One row per chrome slot, with how far its styling travels.""" lines = [ "| slot | browser | native raster | native vector |", "|---|---|---|---|", @@ -337,6 +356,7 @@ def markdown_slot_table(slots: Iterable[SlotCapability] = CHART_SLOTS) -> list[s def markdown_extension_table(points: Iterable[ExtensionPoint] = EXTENSION_POINTS) -> list[str]: + """One row per extension point, with its declared limits.""" lines = ["| extension point | status | entry point | limits |", "|---|---|---|---|"] for point in points: limits = "; ".join(point.limits) or "—" @@ -388,6 +408,7 @@ def summary() -> dict[str, object]: "SURFACES", "ExtensionPoint", "MarkStyleProperty", + "RendererDivergence", "SlotCapability", "axis_style_keys", "markdown_extension_table", diff --git a/scripts/check_claim_guardrails.py b/scripts/check_claim_guardrails.py index 887edf91..11bb1e08 100644 --- a/scripts/check_claim_guardrails.py +++ b/scripts/check_claim_guardrails.py @@ -18,6 +18,9 @@ ROOT = Path(__file__).resolve().parents[1] DEFAULT_DOCS = ( "README.md", + "CLAUDE.md", + ".agents/skills/xy-evaluate/SKILL.md", + "spec/api/customization-vs-alternatives.md", "pyproject.toml", "SECURITY.md", "CONTRIBUTING.md", @@ -102,7 +105,7 @@ STALE_REPO_RE = re.compile(r"github\.com/Alek99|app\.codspeed\.io/Alek99|charts-exp", re.IGNORECASE) POLICY_WORDS = re.compile( - r"\b(do not|don't|must|should|guardrail|policy|claim|goal|planned|target|" + r"\b(do not|don't|never|no amount of|must|should|guardrail|policy|claim|goal|planned|target|" r"blurry|rather than|not\s+(?:a|the|safe|same|one)|without naming|" r"needs qualification)\b", re.IGNORECASE, @@ -194,17 +197,25 @@ def _findings_for_file(path: Path) -> list[Finding]: line, ) ) - if CUSTOMIZATION_COMPARATIVE_RE.search(line) and not ( - _is_policy_or_negative_context(window) or _has_customization_qualifiers(window) - ): - findings.append( - Finding( - path, - index + 1, - "comparative customization claim needs the dimension and its evidence named", - line, + if CUSTOMIZATION_COMPARATIVE_RE.search(line): + # Same reasoning as the numeric-multiplier rule below: a claim + # taxonomy states its scope in the prose above the table, and a + # section heading is answered by the paragraphs under it. A + # sentence-level window would reject the very wording these + # documents exist to model. + claim_window = _line_window(lines, index, radius=10) + if not ( + _is_policy_or_negative_context(claim_window) + or _has_customization_qualifiers(claim_window) + ): + findings.append( + Finding( + path, + index + 1, + "comparative customization claim needs the dimension and its evidence named", + line, + ) ) - ) if COMPARATIVE_RE.search(line) and not ( _is_policy_or_negative_context(window) or _has_claim_qualifiers(window) ): diff --git a/scripts/gen_capability_matrix.py b/scripts/gen_capability_matrix.py index f8a9a7b4..f94cc212 100644 --- a/scripts/gen_capability_matrix.py +++ b/scripts/gen_capability_matrix.py @@ -27,8 +27,24 @@ PUBLIC = ROOT / "docs" / "styling" / "capabilities.md" +def _shipped_renderer_claim() -> str: + """How the summary may describe shipped properties, read from the registry. + + The sentence used to hard-code "drawn by all three renderers". If a shipped + property ever lands `partial` somewhere, that prose would overclaim in the + generated document — the exact failure the registry exists to prevent. + """ + shipped = [p for p in caps.MARK_STYLE_PROPERTIES if p.status == "shipped"] + if all(level == "full" for prop in shipped for level in prop.support.values()): + return "drawn by all three renderers" + partial = sorted(prop.id for prop in shipped if any(v != "full" for v in prop.support.values())) + return "not all drawn identically everywhere — see " + ", ".join(f"`{p}`" for p in partial) + + def render() -> str: + """The engineering-facing capability matrix, as markdown.""" counts = caps.summary() + claim = _shipped_renderer_claim() lines = [ "# Capability matrix", "", @@ -46,7 +62,7 @@ def render() -> str: "## In one line", "", f"- **{counts['mark_style_properties_shipped']}** mark style properties across " - f"**{counts['mark_kinds']}** mark kinds, drawn by all three renderers.", + f"**{counts['mark_kinds']}** mark kinds, {claim}.", f"- **{counts['chart_slots']}** stable chrome slots, CSS- and Tailwind-addressable " "in the browser; " f"**{counts['slots_styleable_natively']}** of them reach the native writers, " @@ -57,9 +73,11 @@ def render() -> str: "", "## Mark style properties", "", - "The subset a mark's `style=` mapping accepts. Anything outside it raises", - "before data is ingested, so no renderer silently drops a declaration another", - "one honors.", + "Every property the registry tracks. A **`shipped`** row is accepted by a", + "mark's `style=` mapping today; a **`planned`** row is recorded but is *not*", + "accepted, and its note says what blocks it. Anything outside the shipped set", + "raises before data is ingested, so no renderer silently drops a declaration", + "another one honors.", "", ] lines += caps.markdown_mark_property_table() @@ -106,8 +124,8 @@ def render() -> str: ] for div in caps.KNOWN_RENDERER_DIVERGENCES: lines.append( - f"| {div['what']} | {div['webgl']} | {div['svg']} | {div['native']} | " - f"{div['visible_when']} | {div['tracked_by']} |" + f"| {div.what} | {div.webgl} | {div.svg} | {div.native} | " + f"{div.visible_when} | {div.tracked_by} |" ) lines += [ "", @@ -131,6 +149,7 @@ def render_public() -> str: to prevent. """ counts = caps.summary() + claim = _shipped_renderer_claim() lines = [ "---", "title: Capability Matrix", @@ -148,16 +167,17 @@ def render_public() -> str: "the code does not compile.", "", f"- **{counts['mark_style_properties_shipped']}** mark style properties across " - f"**{counts['mark_kinds']}** mark kinds, drawn identically by WebGL, SVG, and the " - "native rasterizer.", + f"**{counts['mark_kinds']}** mark kinds, {claim}.", f"- **{counts['chart_slots']}** stable chrome slots for CSS and Tailwind in the browser.", f"- **{counts['extension_points_shipped']}** 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.", + "Every property the registry tracks. A **`shipped`** row is accepted by a mark's", + "`style=` today; a **`planned`** row is *not* accepted yet, and its note says what", + "blocks it. Anything outside the shipped set raises while the chart is built — one", + "renderer never silently ignores what another draws.", "", ] lines += caps.markdown_mark_property_table() @@ -200,8 +220,7 @@ def render_public() -> str: ] for div in caps.KNOWN_RENDERER_DIVERGENCES: lines.append( - f"| {div['what']} | {div['webgl']} | {div['svg']} | {div['native']} | " - f"{div['visible_when']} |" + f"| {div.what} | {div.webgl} | {div.svg} | {div.native} | {div.visible_when} |" ) lines += [ "", @@ -215,6 +234,7 @@ def render_public() -> str: def main(argv: list[str] | None = None) -> int: + """Print, write, or check the generated capability documents.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--write", action="store_true", help="regenerate the committed matrix") parser.add_argument( diff --git a/scripts/plotly_schema_coverage.py b/scripts/plotly_schema_coverage.py index 93d01e97..56bf0a25 100644 --- a/scripts/plotly_schema_coverage.py +++ b/scripts/plotly_schema_coverage.py @@ -78,6 +78,20 @@ class Rule(NamedTuple): #: (fnmatch semantics), which is what makes `*.marker.symbol` reach both #: `scatter.marker.symbol` and `scatter.selected.marker.symbol`. RULES: tuple[Rule, ...] = ( + # --- excluded subtrees, first because `*` spans dots --------------------- + # `fnmatch` treats `*` as matching across `.`, and the first matching rule + # wins, so `layout.*axis.showgrid` would otherwise claim + # `layout.polar.angularaxis.showgrid` before the exclusion below could + # reject it — silently counting 3-D, geo, polar, ternary, and smith axes as + # supported and inflating the headline number. Order is load-bearing here. + Rule("layout.scene*", UNSUPPORTED, "", "3-D is explicitly out of v1"), + Rule("layout.geo*", UNSUPPORTED, "", "geo is explicitly out of v1"), + Rule("layout.map*", UNSUPPORTED, "", "mapbox/map is explicitly out of v1"), + Rule("layout.polar*", UNSUPPORTED, "", "polar axes are not implemented"), + Rule("layout.ternary*", UNSUPPORTED, "", "ternary axes are not implemented"), + Rule("layout.smith*", UNSUPPORTED, "", "smith axes are not implemented"), + Rule("layout.updatemenus*", UNSUPPORTED, "", "no in-chart widget layer"), + Rule("layout.sliders*", UNSUPPORTED, "", "no in-chart widget layer"), # --- paint and geometry XY draws exactly ------------------------------- Rule("*.marker.symbol", SUPPORTED, "style={'marker-shape': ...}", "17 shared shapes"), Rule("*.marker.color", SUPPORTED, "color= / style={'fill': ...}", ""), @@ -124,22 +138,14 @@ class Rule(NamedTuple): Rule("layout.shapes.*", MAPPED, "xy.rule() / xy.band()", "no arbitrary path shapes"), Rule("layout.hovermode", MAPPED, "xy.tooltip(...)", ""), Rule("layout.dragmode", MAPPED, "xy.modebar(...) / interaction options", ""), - # --- deliberate exclusions, named as such ------------------------------- - Rule("layout.scene*", UNSUPPORTED, "", "3-D is explicitly out of v1"), - Rule("layout.geo*", UNSUPPORTED, "", "geo is explicitly out of v1"), - Rule("layout.map*", UNSUPPORTED, "", "mapbox/map is explicitly out of v1"), - Rule("layout.polar*", UNSUPPORTED, "", "polar axes are not implemented"), - Rule("layout.ternary*", UNSUPPORTED, "", "ternary axes are not implemented"), - Rule("layout.smith*", UNSUPPORTED, "", "smith axes are not implemented"), Rule("layout.transition*", MAPPED, "xy.animation(...)", "different easing vocabulary"), - Rule("layout.updatemenus*", UNSUPPORTED, "", "no in-chart widget layer"), - Rule("layout.sliders*", UNSUPPORTED, "", "no in-chart widget layer"), Rule("*.hovertemplate", MAPPED, "xy.tooltip(...)", "not a mini-language"), Rule("*.customdata", MAPPED, "tooltip sources", ""), ) def load_schema() -> tuple[dict[str, dict], str]: + """The installed plotly's flat validator schema, and its version.""" try: import plotly except ModuleNotFoundError: # pragma: no cover - depends on the extra @@ -182,6 +188,7 @@ def classify(path: str) -> tuple[str, str, str]: def build_report(schema: dict[str, dict], version: str) -> tuple[str, dict]: + """Classify every attribute and render the report plus its summary counts.""" paths = attribute_paths(schema) rows = [(path, *classify(path)) for path in paths] verdicts = Counter(verdict for _, verdict, _, _ in rows) @@ -242,11 +249,23 @@ def pct(n: int, d: int) -> str: "", "## Why attributes are unsupported", "", + "Every unsupported attribute is accounted for: the table lists the " + "fifteen largest reasons and folds the rest into one row, so the column " + "sums to the unsupported total above rather than trailing off.", + "", "| reason | attributes |", "|---|---|", ] - for reason, count in reasons.most_common(15): + top = reasons.most_common(15) + for reason, count in top: lines.append(f"| {reason} | {count:,} |") + remainder = sum(reasons.values()) - sum(count for _, count in top) + if remainder: + lines.append( + f"| all other reasons ({len(reasons) - len(top)} more, " + f"each smaller than the rows above) | {remainder:,} |" + ) + lines.append(f"| **total unsupported** | **{sum(reasons.values()):,}** |") lines += [ "", "`unclassified` is the honest residue: attributes no committed rule in", @@ -269,6 +288,7 @@ def pct(n: int, d: int) -> str: def main(argv: list[str] | None = None) -> int: + """Print, write, or check the committed coverage report.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--write", action="store_true", help="regenerate the committed report") parser.add_argument( diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md index 458ebe6b..8538c401 100644 --- a/spec/api/capability-matrix.md +++ b/spec/api/capability-matrix.md @@ -20,9 +20,11 @@ which is sometimes deliberate, and the notes say which. ## Mark style properties -The subset a mark's `style=` mapping accepts. Anything outside it raises -before data is ingested, so no renderer silently drops a declaration another -one honors. +Every property the registry tracks. A **`shipped`** row is accepted by a +mark's `style=` mapping today; a **`planned`** row is recorded but is *not* +accepted, and its note says what blocks it. Anything outside the shipped set +raises before data is ingested, so no renderer silently drops a declaration +another one honors. | property | vocabulary | mark kinds | webgl | svg | native | status | |---|---|---|---|---|---|---| diff --git a/spec/api/plotly-coverage.md b/spec/api/plotly-coverage.md index a4d1fa6f..e37442a5 100644 --- a/spec/api/plotly-coverage.md +++ b/spec/api/plotly-coverage.md @@ -8,9 +8,9 @@ Measured against **plotly 6.9.0**, `plotly/validators/_validators.json`, 9,472 l | verdict | attributes | share | |---|---|---| -| supported | 345 | 3.6% | -| mapped-with-difference | 126 | 1.3% | -| unsupported | 9,001 | 95.0% | +| supported | 218 | 2.3% | +| mapped-with-difference | 115 | 1.2% | +| unsupported | 9,139 | 96.5% | That denominator is dominated by trace types XY does not implement, so the whole-schema share is a poor headline number. The scoped one below @@ -22,21 +22,24 @@ Trace types: `bar`, `box`, `contour`, `heatmap`, `histogram`, `histogram2d`, `sc | verdict | attributes | share | |---|---|---| -| supported | 344 | 10.2% | -| mapped-with-difference | 126 | 3.7% | -| unsupported | 2,917 | 86.1% | +| supported | 217 | 6.4% | +| mapped-with-difference | 115 | 3.4% | +| unsupported | 3,055 | 90.2% | ## Why attributes are unsupported +Every unsupported attribute is accounted for: the table lists the fifteen largest reasons and folds the rest into one row, so the column sums to the unsupported total above rather than trailing off. + | reason | attributes | |---|---| | unclassified | 2,190 | +| 3-D is explicitly out of v1 | 323 | | no such trace: XY does not implement 'scatter3d' | 290 | -| 3-D is explicitly out of v1 | 281 | | no such trace: XY does not implement 'carpet' | 200 | | no such trace: XY does not implement 'treemap' | 198 | | no such trace: XY does not implement 'funnel' | 197 | | no such trace: XY does not implement 'icicle' | 192 | +| ternary axes are not implemented | 187 | | no such trace: XY does not implement 'scatterpolar' | 187 | | no such trace: XY does not implement 'scattercarpet' | 184 | | no such trace: XY does not implement 'scatterternary' | 184 | @@ -44,7 +47,8 @@ Trace types: `bar`, `box`, `contour`, `heatmap`, `histogram`, `histogram2d`, `sc | no such trace: XY does not implement 'histogram2dcontour' | 182 | | no such trace: XY does not implement 'scattersmith' | 182 | | no such trace: XY does not implement 'scattergeo' | 180 | -| no such trace: XY does not implement 'sunburst' | 177 | +| all other reasons (32 more, each smaller than the rows above) | 4,080 | +| **total unsupported** | **9,139** | `unclassified` is the honest residue: attributes no committed rule in `scripts/plotly_schema_coverage.py` claims. It is reported rather than diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 3d2d93cd..3012203c 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -918,13 +918,17 @@ partial-bundle pain). Neither exists today. committed rule table rather than by hand — 9,472 attributes cannot be audited one at a time, and a table claiming otherwise would be fiction. - The number, scoped to the trace types XY implements plus `layout`: **344 - supported, 126 mapped-with-difference of 3,387**. Whole-schema it is 345 and - 126 of 9,472, a denominator dominated by trace types XY does not implement. + The number, scoped to the trace types XY implements plus `layout`: **217 + supported, 115 mapped-with-difference of 3,387**. Whole-schema it is 218 and + 115 of 9,472, a denominator dominated by trace types XY does not implement. Both are published, because either one quoted alone misleads in a different direction. Attributes no rule claims land in `unsupported/unclassified` — currently 2,190 — which is reported rather than folded somewhere friendlier; - shrinking it is how the table improves. + shrinking it is how the table improves. Quote these from + `spec/api/plotly-coverage.md` rather than from here: rule order is + load-bearing (`*` spans dots in `fnmatch`), and an earlier ordering silently + counted 3-D, geo, polar, ternary, and smith axes as supported, overstating + the figure by 127 attributes. - **Custom traces without forking:** a registered *mark plugin* provides (a) a calc function over columns → columns (runs in the worker, gets zone maps), (b) either a composition of built-in GPU primitives (instanced marks, density diff --git a/src/raster.rs b/src/raster.rs index dccbc168..b75c0fda 100644 --- a/src/raster.rs +++ b/src/raster.rs @@ -589,7 +589,7 @@ impl StrokePiece { fn coverage(&self, p: (f32, f32), hw: f32) -> f32 { match self { - StrokePiece::Box(( a, b)) => cov_from_sd(box_sd(p, *a, *b, hw)), + StrokePiece::Box((a, b)) => cov_from_sd(box_sd(p, *a, *b, hw)), StrokePiece::Disc(c) => { cov_from_sd(((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt() - hw) } @@ -628,6 +628,18 @@ fn seg_coverage(p: (f32, f32), a: (f32, f32), b: (f32, f32), hw: f32) -> f32 { outer - distance2.sqrt() } +/// Dash lengths a stroke walker can safely step through. +/// +/// Both stroke paths advance by `drem.min(remain)` and subtract, which assumes +/// every entry is finite and positive. The public API cannot produce anything +/// else (`_validate.dash` requires positive px lengths), but `rasterize_into` +/// decodes arbitrary bytes, and this is where the rest of that validation +/// lives. A pattern with any non-positive or non-finite entry is treated as +/// solid rather than half-walked. +fn usable_dash(dash: &[f32]) -> bool { + !dash.is_empty() && dash.iter().all(|d| d.is_finite() && *d > 0.0) +} + /// Rasterize on-segments into a scratch coverage buffer (max-combined so /// overlapping joins don't double-darken), then composite once. fn stroke( @@ -659,6 +671,7 @@ fn stroke_shaped( } let StrokeGeometry { cap, join } = geometry; let hw = width * 0.5; + let dash: &[f32] = if usable_dash(dash) { dash } else { &[] }; let n = pts.len(); let last = if closed { n } else { n - 1 }; let raw: Vec = (0..last).map(|i| (pts[i], pts[(i + 1) % n])).collect(); @@ -831,6 +844,7 @@ fn stroke_with_threads( if pts.len() < 2 || width <= 0.0 { return; } + let dash: &[f32] = if usable_dash(dash) { dash } else { &[] }; if pts.len() == 2 && !closed && dash.is_empty() { stroke_segment(cv, pts[0], pts[1], width, rgba); return; @@ -2972,6 +2986,40 @@ mod tests { assert!(probe(CAP_SQUARE) > 200); } + /// SVG's miter limit is a ratio of miter length to stroke *width*: + /// `miterLength / strokeWidth = 1 / cos(phi/2)`, where `phi` is the turn + /// angle. So the join degrades to a bevel exactly when `cos(phi/2)` drops + /// below `1 / MITER_LIMIT`. This pins that threshold: halving it — guarding + /// on `1 / (2 * MITER_LIMIT)` — would admit ratio-8 spikes, twice what SVG + /// and PDF draw for the same path. + #[test] + fn miter_joins_bevel_past_svgs_four_to_one_limit() { + let corners = |phi_deg: f32| -> usize { + let phi = phi_deg.to_radians(); + let at = (0.0f32, 0.0f32); + let prev = (-10.0f32, 0.0f32); + let next = (10.0 * phi.cos(), 10.0 * phi.sin()); + join_shape(prev, at, next, 5.0, JOIN_MITER) + .expect("a turned joint has a shape") + .len() + }; + // ratio = 1/cos(phi/2): 90° -> 1.41, 150° -> 3.86, 155° -> 4.62. + assert_eq!(corners(90.0), 4, "a right angle miters (ratio 1.41)"); + assert_eq!(corners(150.0), 4, "ratio 3.86 is inside the 4:1 limit"); + assert_eq!(corners(155.0), 3, "ratio 4.62 degrades to a bevel"); + } + + /// A dash pattern the public API cannot produce must not steer the walker. + #[test] + fn malformed_dash_patterns_fall_back_to_solid() { + assert!(usable_dash(&[6.0, 4.0])); + assert!(!usable_dash(&[])); + assert!(!usable_dash(&[6.0, -4.0]), "a negative entry would rewind the walker"); + assert!(!usable_dash(&[6.0, f32::NAN])); + assert!(!usable_dash(&[f32::INFINITY, 4.0])); + assert!(!usable_dash(&[0.0, 4.0])); + } + /// stroke-linejoin fills the notch on the outside of a turn. A miter /// reaches furthest, a bevel cuts the corner off, and round is the disc /// the capsule field already drew. diff --git a/tests/test_customization_comparison.py b/tests/test_customization_comparison.py index 3f030f1a..2e5cc46e 100644 --- a/tests/test_customization_comparison.py +++ b/tests/test_customization_comparison.py @@ -66,7 +66,12 @@ def test_pinned_competitor_versions_match_the_benchmark_constraints() -> None: pinned = dict( re.findall(r"^([a-z0-9-]+)==([0-9.]+)", pins.read_text(encoding="utf-8"), re.MULTILINE) ) - for package, label in (("plotly", "Plotly"), ("bokeh", "Bokeh"), ("matplotlib", "Matplotlib")): + for package, label in ( + ("plotly", "Plotly"), + ("bokeh", "Bokeh"), + ("matplotlib", "Matplotlib"), + ("altair", "Altair"), + ): assert f"{label} {pinned[package]}" in text, ( f"{label} is pinned at {pinned[package]} but the comparison quotes another version" ) diff --git a/tests/test_evaluation_skill.py b/tests/test_evaluation_skill.py index 4863212a..d801360b 100644 --- a/tests/test_evaluation_skill.py +++ b/tests/test_evaluation_skill.py @@ -40,6 +40,20 @@ def test_the_skill_names_both_registries_and_the_guardrail() -> None: assert pointer in text, f"xy-evaluate no longer points at {pointer}" +def test_every_count_the_skill_states_matches_its_registry() -> None: + # The skill shipped saying "15 axis keys" the day after a 16th landed — + # stale drift inside the document whose whole job is to stop stale drift. + # Prose cannot hold a number; a test can. + from xy.styling import capabilities as caps + + counts = caps.summary() + # Collapse wrapping so a reflowed paragraph does not read as a changed count. + text = re.sub(r"\s+", " ", _body()) + assert f"{counts['axis_style_keys']} axis keys" in text + assert f"{counts['chart_slots']} CSS-addressable chrome slots" in text + assert f"{counts['mark_style_properties_shipped']} mark style properties" in text + + def test_the_skill_states_the_failure_it_exists_to_prevent() -> None: # Without this the skill reads as a link list, and an agent skims it. text = _body().lower() diff --git a/tests/test_export_style_survival.py b/tests/test_export_style_survival.py index 42694fa7..77682bc4 100644 --- a/tests/test_export_style_survival.py +++ b/tests/test_export_style_survival.py @@ -76,8 +76,11 @@ def test_custom_css_is_refused_by_every_native_path() -> None: # browser can emit vector SVG. chart = _styled_chart() - with pytest.raises(ValueError, match=re.escape("custom_css requires engine=Engine.chromium")): - chart.to_image(format="png", engine=Engine.default, custom_css=".x{color:red}") + for fmt in ("png", "pdf", "jpeg", "webp"): + with pytest.raises( + ValueError, match=re.escape("custom_css requires engine=Engine.chromium") + ): + chart.to_image(format=fmt, engine=Engine.default, custom_css=".x{color:red}") for engine in (Engine.auto, Engine.default, Engine.chromium): with pytest.raises(ValueError, match=r"SVG export is native-only|custom_css requires"): chart.to_image(format="svg", engine=engine, custom_css=".x{color:red}") diff --git a/tests/test_mark_plugins.py b/tests/test_mark_plugins.py index 53f74a10..527fe2fc 100644 --- a/tests/test_mark_plugins.py +++ b/tests/test_mark_plugins.py @@ -197,3 +197,40 @@ def build(ctx): ctx = seen["ctx"] assert set(vars(ctx)) == {"columns", "options", "name", "style", "class_name"} assert not any(hasattr(ctx, attr) for attr in ("figure", "traces", "store", "fig")) + + +def test_a_plugin_cannot_claim_a_name_that_xy_mark_binds_itself() -> None: + # `xy.mark(name=...)` is the series label, so a plugin column called `name` + # could never be passed — the value would bind to the parameter and the + # column would resolve to None. Reject the schema, not the call site. + for reserved in ("name", "style", "data", "x_axis", "key"): + with pytest.raises(ValueError, match=re.escape("xy.mark() binds itself")): + xy.register_mark( + xy.MarkPlugin(name="clashing", columns=(reserved,), build=lambda ctx: []) + ) + assert "clashing" not in xy.registered_marks() + + +def test_declared_columns_reach_calc_as_canonical_f64(hilo) -> None: + # Canonical data is CPU-side f64 (§27). A float32 input must widen before + # `calc` runs, or the arithmetic rounds at f32 and hands the loss to a + # built-in mark that stores f64 anyway. + seen = {} + + def build(ctx): + seen["dtypes"] = {k: v.dtype for k, v in ctx.columns.items()} + return [xy.line(x=ctx.columns["t"], y=ctx.columns["low"])] + + xy.register_mark(xy.MarkPlugin(name="dtypes", columns=("t", "low"), build=build)) + try: + xy.chart( + xy.mark( + "dtypes", + t=np.array([0.0, 1.0], dtype=np.float32), + low=np.array([1, 2], dtype=np.int16), + ) + ).figure() + finally: + xy.unregister_mark("dtypes") + + assert all(dtype == np.float64 for dtype in seen["dtypes"].values()) diff --git a/tests/test_plotly_schema_coverage.py b/tests/test_plotly_schema_coverage.py index 52d78fd7..a26a2659 100644 --- a/tests/test_plotly_schema_coverage.py +++ b/tests/test_plotly_schema_coverage.py @@ -97,3 +97,43 @@ def test_the_scoped_number_is_reported_next_to_the_whole_schema_one() -> None: assert "## Whole schema" in text assert "## Scoped to the trace types XY implements" in text assert "must be quoted *with* its scope" in text + + +def test_excluded_subtrees_are_not_stolen_by_the_wildcard_axis_rules() -> None: + # `fnmatch` lets `*` span dots and the first matching rule wins, so + # `layout.*axis.showgrid` will happily claim `layout.polar.angularaxis. + # showgrid` unless the exclusions are ordered ahead of it. That ordering is + # load-bearing: getting it wrong silently counted 3-D, geo, polar, ternary, + # and smith axes as supported and overstated the headline number by 127 + # attributes. This pins the ordering, not the prose about it. + module = _module() + for path in ( + "layout.scene.xaxis.title.text", + "layout.scene.yaxis.showgrid", + "layout.polar.angularaxis.showgrid", + "layout.polar.radialaxis.range", + "layout.geo.lataxis.showgrid", + "layout.ternary.aaxis.ticks", + "layout.smith.realaxis.linecolor", + ): + verdict, _, _ = module.classify(path) + assert verdict == module.UNSUPPORTED, f"{path} is inside an excluded subtree" + + # The genuine top-level axes still resolve. + for path in ("layout.xaxis.showgrid", "layout.yaxis.type", "layout.xaxis.range"): + verdict, surface, _ = module.classify(path) + assert verdict == module.SUPPORTED and surface, path + + +def test_the_unsupported_reason_table_accounts_for_every_attribute() -> None: + # A table whose rows trail off short of the total implies accounting it does + # not do. The generated report folds the tail into one row and prints the + # total, so the column sums. + module = _module() + schema, version = module.load_schema() + _, summary = module.build_report(schema, version) + assert sum(summary["unsupported_reasons"].values()) == summary["verdicts"]["unsupported"] + + text = REPORT.read_text(encoding="utf-8") + assert "all other reasons" in text + assert f"**{summary['verdicts']['unsupported']:,}**" in text From 8c393f0bc0969aa83742c65221401dcdbc6e74f9 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 07:56:29 +0000 Subject: [PATCH 13/15] Narrow to the product change: capabilities plus an honest support inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This branch had grown a second argument alongside the code — a Plotly schema ingest, a competitor comparison, a claim ladder, an evaluation skill — and that apparatus was making a case the changes do not need to make. Removed entirely: the coverage script and its report, the comparison document, the evaluation skill, and the three test files that pinned them, along with every Plotly count, competitor claim, and "more themeable than" phrasing in the README, CLAUDE.md, the changelog, the dossier, and the docs. The capability matrix stays, reframed as what it actually is: an inventory of which style properties and chrome slots each renderer draws and how far each survives export. Same generator, same drift tests, no argument attached. The unshipped line-join work goes too. Native miter and bevel are gone, along with the join wire byte — `OP_STROKE` and `OP_SMOOTH_STROKE` carry one cap byte now — and the `planned` registry row with them. Joins are always round, which is what the capsule distance field produced before caps became selectable, so `stroke_shaped` fills interior vertices with a disc and nothing selects otherwise. What stays is the half that fixed a real defect: the SVG writer still names `stroke-linejoin="round"` on every stroked path, because leaving it unnamed let the format's `miter` default through and `_pdf` read that back as a mismatch with the rasterizer. `stroke-linecap` (butt/round/square) and the cross-renderer consistency fix are unchanged, as are `marker-shape`, the export-survival contract and its tests, the before/after renders, and `MarkPlugin` with its reserved-column and f64 fixes. The claim guardrail keeps the plain ban on unqualifiable superlatives about the styling surface and loses the comparative-claim regexes and their qualifier scoring — that machinery existed to police a comparison document that no longer exists. Restored `check_claim_guardrails.py` from HEAD and reapplied the edits by exact match after an index-based removal silently overwrote the *performance* comparative rule with the customization one; both rules are verified firing. Plugin prose no longer says correct hover and a11y semantics arrive "for free". It says the plugin reuses the built-in rendering, picking, and export paths, which is the accurate and narrower claim. --- .agents/skills/xy-evaluate/SKILL.md | 82 ----- CHANGELOG.md | 77 ++--- CLAUDE.md | 33 +- README.md | 4 +- docs/advanced/custom-marks.md | 16 +- .../limitations-and-alpha-status.md | 16 +- docs/styling/capabilities.md | 15 +- docs/styling/mark-styles.md | 6 +- python/xy/_raster.py | 17 +- python/xy/_svg.py | 11 +- python/xy/plugins.py | 22 +- python/xy/styling/capabilities.py | 62 ++-- scripts/check_claim_guardrails.py | 61 +--- scripts/gen_capability_matrix.py | 26 +- scripts/plotly_schema_coverage.py | 326 ------------------ scripts/verify_sdist.py | 1 - spec/README.md | 13 +- spec/api/capability-matrix.md | 22 +- spec/api/customization-vs-alternatives.md | 100 ------ spec/api/plotly-coverage.md | 66 ---- spec/api/styling.md | 27 +- spec/design-dossier.md | 46 +-- src/raster.rs | 221 ++---------- tests/test_capability_registry.py | 11 +- tests/test_claim_guardrails.py | 23 +- tests/test_customization_comparison.py | 84 ----- tests/test_evaluation_skill.py | 69 ---- tests/test_plotly_schema_coverage.py | 139 -------- 28 files changed, 176 insertions(+), 1420 deletions(-) delete mode 100644 .agents/skills/xy-evaluate/SKILL.md delete mode 100644 scripts/plotly_schema_coverage.py delete mode 100644 spec/api/customization-vs-alternatives.md delete mode 100644 spec/api/plotly-coverage.md delete mode 100644 tests/test_customization_comparison.py delete mode 100644 tests/test_evaluation_skill.py delete mode 100644 tests/test_plotly_schema_coverage.py diff --git a/.agents/skills/xy-evaluate/SKILL.md b/.agents/skills/xy-evaluate/SKILL.md deleted file mode 100644 index e06a6dbd..00000000 --- a/.agents/skills/xy-evaluate/SKILL.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -name: xy-evaluate -description: "Answer questions about what the xy charting library can do — how fast it is, how customizable it is, what survives which export path, and how it compares to Plotly, Vega-Lite, Bokeh, or Matplotlib. Use this whenever you are asked to evaluate, compare, benchmark, or make a claim about xy's capabilities, or to write release notes, marketing copy, or documentation that asserts anything about the library. Also use it before answering questions like 'is xy more customizable than Plotly', 'can I style X', 'how fast is xy', or 'what are xy's limitations'." ---- - -# Evaluating xy - -Two questions about this library are consistently answered wrong by reading the -obvious file, because the obvious file is a fraction of the answer. Both have a -registry. Read the registry. - -## Do not answer from source alone - -`python/xy/styles.py` lists ten mark style properties. An agent that opens it -and stops will conclude xy is barely customizable, and will be wrong: the -styling surface is those ten properties **plus** 16 axis keys, 23 -CSS-addressable chrome slots, chart-level design-token variables that reach the -marks in every renderer, and a mark-plugin API. Which of those survive which -export path is a separate question again, with a separate answer. - -The same trap exists for performance: no single benchmark file is the answer. - -## Where the answers actually live - -| Question | Read | -| --- | --- | -| What can be styled, per renderer? | `python/xy/styling/capabilities.py` → `spec/api/capability-matrix.md` | -| How does that compare to Plotly / Vega-Lite / Bokeh / Matplotlib? | `spec/api/customization-vs-alternatives.md` | -| How much of Plotly's attribute surface is covered? | `spec/api/plotly-coverage.md` | -| What survives PNG / SVG / PDF export? | `spec/api/export.md` §9 | -| Can I add a chart kind xy does not ship? | `docs/advanced/custom-marks.md`, `python/xy/plugins.py` | -| How fast is it, on what? | `benchmarks/categories.py`, `spec/benchmarks/results.md` | -| What is still alpha? | `docs/api-reference/limitations-and-alpha-status.md` | - -The capability matrix and the Plotly coverage table are **generated** and -drift-tested — they cannot claim a property the implementation does not -compile. Quote them. Do not recount by hand. - -```bash -uv run python scripts/gen_capability_matrix.py --json # the summary counts -uv run python scripts/gen_capability_matrix.py --check # is the matrix current? -``` - -## Rules for any claim you make - -1. **Name the dimension.** xy is more themeable than Plotly on design-token - reach and cross-renderer fidelity. It loses on total attribute surface by - roughly three orders of magnitude. Both are true; a claim without its - dimension is neither. -2. **Quote a row, never a vibe.** Performance numbers come from - `spec/benchmarks/results.md`; capability counts come from the registry. - Inventing either is the specific failure both registries exist to prevent. -3. **Carry the losses.** `spec/api/customization-vs-alternatives.md` has a loss - table and a claim ladder. A comparison that reproduces only the wins is not a - comparison. -4. **"Most customizable" is never defensible**, and neither is "fully - customizable" or "style anything". `scripts/check_claim_guardrails.py` - rejects them mechanically, along with performance superlatives. Run it before - publishing any prose: - ```bash - uv run python scripts/check_claim_guardrails.py - ``` - -## Answering "is xy more customizable than Plotly?" - -The honest answer has three parts, and all three are in the comparison -document: - -- **More themeable.** Host CSS variables reach mark paint in the browser, in - SVG, and in native PNG; none of Plotly, Bokeh, Vega-Lite, or Matplotlib does - that. 23 chrome slots are a published contract. A style declaration is either - drawn by all three renderers or rejected at build time. -- **Far narrower.** 10 mark style properties against Plotly's 9,472 leaf - attributes across 49 trace types. Scoped to the trace types xy implements, - 217 supported plus 115 mapped-with-difference of 3,387. Do not retype these — - regenerate them, they move. -- **Less extensible than Matplotlib.** `xy.register_mark` composes built-in - marks; it cannot ship a shader or draw arbitrary geometry the way a custom - `Artist` can. - -If you find yourself about to answer this from `styles.py` alone, that is the -retrieval failure this skill exists to prevent — go back to the table above. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0484b47d..6b8eeefa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,65 +9,30 @@ in the README). ## [Unreleased] ### Added -- The **retrieval path** for capability questions: `CLAUDE.md`/`AGENTS.md` name - both registries in their opening paragraph, `limitations-and-alpha-status.md` - and the capability matrix cross-link, the README points at both, and an - `xy-evaluate` agent skill carries the same pointers outside the checkout. The - claim ladder — including the rung that is never defensible — is committed in - `spec/api/customization-vs-alternatives.md`. - -### Changed -- `scripts/check_claim_guardrails.py` now covers **customization** claims, not - only performance ones, and scans `README.md`, which it previously did not. - "Most customizable", "fully customizable", "style anything", and "more - extensible than any …" are rejected outright; a comparative claim against a - named library has to carry its dimension and its evidence. Two live overclaims - in `docs/index.md` were caught by the new rule and rewritten. -- **Plotly attribute coverage is now a number** (§24): - `scripts/plotly_schema_coverage.py` ingests Plotly's schema and classifies - every attribute `supported | mapped-with-difference | unsupported` by a - committed rule table, writing `spec/api/plotly-coverage.md`. Two corrections - to the plan it implements: no published Plotly wheel ships `plot-schema.json` - (the equivalent is `validators/_validators.json`, and the real surface is - 9,472 leaf attributes rather than ~3,000), and there is no Plotly shim to warn - — so "supported" means XY can express the same visual outcome, not that a shim - accepts the key. Scoped to the trace types XY implements plus `layout`: 217 - supported and 115 mapped-with-difference of 3,387. -- A **committed customization comparison** against Plotly, Vega-Lite, Bokeh, and - Matplotlib (`spec/api/customization-vs-alternatives.md`), with the same - discipline as the benchmark harness: pinned competitor versions, a named - method per row (`schema`, `code`, or `docs`), a claim taxonomy, and a loss - table that `tests/test_customization_comparison.py` refuses to let anyone - empty. XY's own numbers in it are checked against the capability registry - rather than typed in. -- **A capability registry** at `python/xy/styling/capabilities.py`: one entry per - mark style property and per chrome slot, each carrying its support level in - the WebGL client, the SVG writer, and the native rasterizer, plus the - extension points and the renderer divergences that no property selects. +- **A capability inventory** at `python/xy/styling/capabilities.py`: one entry + per mark style property and per chrome slot, each carrying its support level + in the WebGL client, the SVG writer, and the native rasterizer, plus the + extension points and the renderer differences that no property selects. `scripts/gen_capability_matrix.py` generates `spec/api/capability-matrix.md` and the public Capability Matrix page from it, and the test suite fails if - either is stale or if the registry falls out of step with what `styles.py` - actually compiles. `benchmarks/categories.py` made performance claims - auditable and left the generated-table half open; this closes it for - customization. + either is stale or if the inventory falls out of step with what `styles.py` + actually compiles. - **Mark plugins**: `xy.register_mark(xy.MarkPlugin(...))` adds a chart kind XY does not ship, and `xy.mark("name", ...)` uses it. A plugin supplies a `calc` over its declared columns and a `build` that returns *built-in* marks — the - composition half of dossier §24, with the custom-shader half deliberately - deferred. Because a plugin's output is ordinary traces it inherits decimation, - hover, picking, the a11y summary, and every export path including the two with - no browser; `build` never sees the `Figure`, the trace list, or the column - store, so it cannot draw anything the engine could not already draw. The - registry refuses to shadow a built-in kind and refuses to silently replace - another plugin. + composition half of dossier §24, with the custom-shader half deferred. Its + output is ordinary traces, so it reuses the built-in rendering, picking, and + export paths rather than reimplementing them; `build` never sees the + `Figure`, the trace list, or the column store, so it cannot draw anything the + engine could not already draw. Declared columns arrive as canonical f64. The + registry refuses to shadow a built-in kind, refuses to silently replace + another plugin, and rejects column names `xy.mark()` binds itself. - Mark `style=` accepts **`stroke-linecap`** (`butt`/`round`/`square`) on the line family, with the standard SVG semantics: it shapes the polyline's two ends and each dash end. Only the line family takes it, because it describes stroked open-path geometry. XY's default is `round`, not CSS's `butt`, so - existing specs are byte-identical. `stroke-linejoin` is deliberately not - offered yet — the SVG and native-raster writers implement it but the WebGL - client has no join geometry, and a property two renderers honor and one - ignores is exactly what the mark style subset exists to prevent. + existing specs are byte-identical. Joins are always round and are not + selectable. - Mark `style=` accepts **`marker-shape`** on `scatter`, the CSS spelling of the existing `symbol=` argument. Both resolve to the same trace-style value, so the two spellings build identical specs. @@ -98,14 +63,22 @@ in the README). same validated style properties, so an explicit `style=` still wins and specs that don't use them are byte-identical. +### Changed +- `scripts/check_claim_guardrails.py` now also rejects unqualifiable + superlatives about the styling surface, and scans `README.md` and `CLAUDE.md`, + which it previously did not. The styling surface is a bounded, enumerated + subset, so those shapes are wrong however they are framed. Two live overclaims + in `docs/index.md` were caught by the new rule and rewritten. + ### Fixed - The three mark renderers disagreed about line caps and never said so: the native rasterizer capped round, the WebGL client capped butt with a half-pixel bleed, and the SVG writer hardcoded `round` on line paths while the area outline silently inherited SVG's `butt` — which the PDF exporter then read back as `butt` too. All three now draw XY's documented `round` - default, and the SVG writer names both cap and join on every stroked path - instead of letting the format's defaults decide. + default. The SVG writer also names the join on every stroked path instead of + letting the format's `miter` default through, which `_pdf` had been reading + back as a mismatch with the rasterizer. - The colorbar stringified its colormap, so a custom ramp reached it as an unparseable name and silently painted viridis while the marks beside it painted the ramp correctly. diff --git a/CLAUDE.md b/CLAUDE.md index 116891e2..1a117342 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,21 +4,17 @@ A high-performance charting engine. The authoritative design is `spec/design-dossier.md` — **read the relevant § before changing behavior**; code comments cite dossier sections (e.g. §16 = deep-zoom re-centering). -**Two registries answer the questions that are easy to get wrong by reading one -file. Consult them before claiming anything about this library.** +**Two registries answer questions that are easy to get wrong by reading one +file.** -- *How fast is it, and against what?* → `benchmarks/categories.py` and +- *How fast is it, and on what?* → `benchmarks/categories.py` and `spec/benchmarks/results.md`. Quote a row; never invent a number. -- *How customizable is it, and where does the styling stop?* → - `python/xy/styling/capabilities.py`, the per-renderer support registry that - `spec/api/capability-matrix.md` is generated from, plus - `spec/api/customization-vs-alternatives.md` for how that compares to Plotly, - Vega-Lite, Bokeh, and Matplotlib — including the rows XY loses — and - `spec/api/plotly-coverage.md` for the measured attribute number. - `python/xy/styles.py` lists ten mark properties and looks like the whole - answer. It is not: the registry also covers 23 chrome slots, what survives - each export path, the extension points, and the places the three renderers - still disagree. +- *What can be styled, and where does the styling stop?* → + `python/xy/styling/capabilities.py`, the per-renderer support inventory that + `spec/api/capability-matrix.md` is generated from. `python/xy/styles.py` + lists the mark properties and looks like the whole answer; the inventory also + covers the 23 chrome slots, what survives each export path, the extension + points, and where the three renderers still differ. The entire `spec/` directory is the source of truth for intended behavior, architecture, compatibility, benchmarks, release readiness, and contributor @@ -130,8 +126,9 @@ PRs, or code. Set `git config user.name/user.email` to the human author - Every decimation/tier decision is recorded in the spec, never silent (§28). - Claims are mode-scoped and benchmarked (§2); update README numbers from `benchmarks/bench.py`, don't invent them. -- Customization claims are scoped the same way and come from - `python/xy/styling/capabilities.py`. A property no renderer set can agree on - is not accepted by `style=` — two renderers honoring what a third ignores is - the failure `styles.py` exists to prevent. "Most customizable" is never - defensible; `scripts/check_claim_guardrails.py` rejects it mechanically. +- A mark style property no renderer set can agree on is not accepted by + `style=` — two renderers honoring what a third ignores is the failure + `styles.py` exists to prevent. The styling surface is a bounded, enumerated + subset, so superlatives about it are never defensible however they are + qualified; `scripts/check_claim_guardrails.py` rejects them mechanically + alongside the performance ones. diff --git a/README.md b/README.md index 22fca71d..2bfcd053 100644 --- a/README.md +++ b/README.md @@ -140,9 +140,7 @@ Customize marks and chart chrome with Python, CSS, or Tailwind. See the [styling 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. How that compares to Plotly, Vega-Lite, Bokeh, and Matplotlib, -including the dimensions XY loses, is -[customization-vs-alternatives.md](spec/api/customization-vs-alternatives.md). +implementation. ```python chart = xy.line_chart( diff --git a/docs/advanced/custom-marks.md b/docs/advanced/custom-marks.md index e7519287..905ef335 100644 --- a/docs/advanced/custom-marks.md +++ b/docs/advanced/custom-marks.md @@ -17,9 +17,9 @@ A mark plugin is two functions and a name: `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 gets decimation on large inputs, hover and -picking, the accessibility summary, and every export path — including native PNG -and SVG, which have no browser — without writing a line of code for any of them. +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 @@ -88,12 +88,10 @@ in `ctx.options` untouched. 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 exactly why it inherits everything the -engine already guarantees. A plugin carrying its own shader would inherit none -of it and would have to re-implement decimation, picking, accessibility, and -three export paths correctly on its own. See -[§24 of the design dossier](https://github.com/reflex-dev/xy/blob/main/spec/design-dossier.md) -for the full argument. +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 diff --git a/docs/api-reference/limitations-and-alpha-status.md b/docs/api-reference/limitations-and-alpha-status.md index 072d4602..07d76c35 100644 --- a/docs/api-reference/limitations-and-alpha-status.md +++ b/docs/api-reference/limitations-and-alpha-status.md @@ -53,12 +53,10 @@ and [Benchmarks](/docs/xy/overview/benchmarks/) for scoped evidence. ## Styling and Export Boundaries -The per-renderer record 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, so -it cannot promise a property the code does not compile. The bullets below are -the boundaries that page's rows imply. +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 @@ -108,12 +106,6 @@ XY requires Python 3.11 or newer. Published native wheels include the Rust core and bundled browser client; source builds require a Rust toolchain. There is no silent NumPy compute fallback when the native core is unavailable. -For how XY's customization compares to Plotly, Vega-Lite, Bokeh, and -Matplotlib — including the dimensions where it loses — see the committed -comparison in the project specification -(`spec/api/customization-vs-alternatives.md`) and the measured Plotly attribute -coverage (`spec/api/plotly-coverage.md`). - Review [Installation](/docs/xy/overview/installation/), [Serving, CSP, and offline use](/docs/xy/guides/serving-csp-and-offline-use/), and the [Changelog](/docs/xy/api-reference/changelog/) before shipping an diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md index 1c9adf9b..1eb417b2 100644 --- a/docs/styling/capabilities.md +++ b/docs/styling/capabilities.md @@ -9,8 +9,7 @@ description: What XY can be styled and extended with, per renderer, generated fr 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, so it cannot promise a property -the code does not compile. +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. @@ -18,10 +17,8 @@ the code does not compile. ## Mark style properties -Every property the registry tracks. A **`shipped`** row is accepted by a mark's -`style=` today; a **`planned`** row is *not* accepted yet, and its note says what -blocks it. Anything outside the shipped set raises while the chart is built — one -renderer never silently ignores what another draws. +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 | |---|---|---|---|---|---|---| @@ -33,7 +30,6 @@ renderer never silently ignores what another draws. | `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 | -| `stroke-linejoin` | svg | — | none | full | full | planned | | `border-radius` | css | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped | | `marker-shape` | xy | `scatter` | full | full | full | shipped | @@ -45,8 +41,7 @@ renderer never silently ignores what another draws. - **`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`** — XY's default is `round`, not CSS's `butt`. Verified per renderer, not assumed: a Rust coverage test, a rasterized-ink test, and three Chromium screenshots that hash differently per cap. -- **`stroke-linejoin`** — NOT ACCEPTED by `style=` today, deliberately. The SVG writer emits the attribute and `src/raster.rs` implements miter/round/bevel with SVG's miter limit of 4, but the WebGL client draws a polyline as one instanced quad per segment with no join geometry at all, so it cannot tell a miter from a bevel. Two renderers out of three is what `styles.py` exists to prevent. Unblocking it means giving the client a join pass. +- **`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. @@ -107,7 +102,5 @@ an undocumented difference reads as a bug; a documented one is a contract. |---|---|---|---|---| | 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 how this compares to Plotly, Vega-Lite, Bokeh, and Matplotlib — including -the columns XY loses — see the comparison in the project specification. For what is still alpha, see [Limitations and Alpha Status](/docs/xy/api-reference/limitations-and-alpha-status/). diff --git a/docs/styling/mark-styles.md b/docs/styling/mark-styles.md index 4f9f97ec..266fb7c9 100644 --- a/docs/styling/mark-styles.md +++ b/docs/styling/mark-styles.md @@ -73,11 +73,7 @@ export. Set the property to opt into the CSS initial value. xy.line(x, y, style={"stroke-width": "6px", "stroke-linecap": "butt"}) ~~~ -`stroke-linejoin` is **not** available yet. The SVG and native-raster writers -both implement it, but the browser client draws polylines as one quad per -segment with no join geometry, so honoring it in two renderers out of three -would break the rule that every accepted declaration is drawn everywhere. See -the capability matrix for the current state of that row. +Joins are always round and are not selectable. ## Marker shape diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 56ffba54..a0ccc3c4 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -80,11 +80,10 @@ # (right-margin titles, matplotlib rotation=270). _TEXT_ROT_CCW = 0x80 _TEXT_ROT_CW = 0x40 -# stroke-linecap / stroke-linejoin — must match CAP_*/JOIN_* in src/raster.rs. -# XY's default is round for both, which is the geometry the rasterizer's -# capsule distance field has always drawn. +# stroke-linecap — must match CAP_* in src/raster.rs. XY's default is round, +# which is the geometry the rasterizer's capsule distance field has always +# drawn. Joins are always round and carry no wire field. _CAP_CODES = {"butt": 0, "round": 1, "square": 2} -_JOIN_CODES = {"miter": 0, "round": 1, "bevel": 2} _SYMBOLS = { "circle": 0, "square": 1, @@ -229,7 +228,6 @@ def stroke( closed: bool = False, dash: Sequence[float] | None = None, cap: str = "round", - join: str = "round", ) -> None: if len(pts) < 2 or width <= 0: return @@ -250,7 +248,6 @@ def stroke( for d in dash: self._f(d) self.buf.append(_CAP_CODES[cap]) - self.buf.append(_JOIN_CODES[join]) def point( self, @@ -499,7 +496,6 @@ def smooth_stroke( color: tuple[int, ...], dash: Sequence[float] | None = None, cap: str = "round", - join: str = "round", ) -> None: """Native monotone-Hermite flattening + stroke for affine axes.""" n = len(xv) @@ -527,7 +523,6 @@ def smooth_stroke( for value in dash: self._f(value) self.buf.append(_CAP_CODES[cap]) - self.buf.append(_JOIN_CODES[join]) def image( self, @@ -1070,14 +1065,12 @@ def _emit_line( # to the documented default rather than raising a bare KeyError from inside # the byte packer. cap = str(style.get("linecap", "round")) - join = str(style.get("linejoin", "round")) cap = cap if cap in _CAP_CODES else "round" - join = join if join in _JOIN_CODES else "round" if style.get("curve") == "smooth" and len(xv) >= 3 and sx.affine and sy.affine: - cmd.smooth_stroke(xv, yv, sx, sy, width, c, dash=style.get("dash"), cap=cap, join=join) + cmd.smooth_stroke(xv, yv, sx, sy, width, c, dash=style.get("dash"), cap=cap) else: pts = _scene.curve_points(xv, yv, sx, sy, False) - cmd.stroke(pts, width, c, dash=style.get("dash"), cap=cap, join=join) + cmd.stroke(pts, width, c, dash=style.get("dash"), cap=cap) def _annotation_point( diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 3da22e15..50ab8d28 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -1080,15 +1080,16 @@ def _cap_join_attrs(style: dict[str, Any], *, join: bool = True) -> str: """Polyline stroke geometry, always written out rather than inherited. SVG's initial values are `butt`/`miter`; XY's are `round`/`round`, and the - trace only carries a key when it differs (`marks._stroke_geometry`). Naming - both attributes on every stroked path is what keeps the SVG agreeing with - the native rasterizer — and with `_pdf`, which reads them straight back out - of this markup and would otherwise fall through to SVG's defaults. + trace only carries `linecap` when it differs (`marks._stroke_geometry`). + The join is not selectable, but it is still named on every stroked path: + leaving it out let the format's `miter` default through, and `_pdf` reads + these attributes straight back out of this markup, so an unnamed join meant + SVG and PDF disagreeing with the rasterizer for free. """ cap = style.get("linecap", "round") attrs = f' stroke-linecap="{escape(str(cap))}"' if join: - attrs = f' stroke-linejoin="{escape(str(style.get("linejoin", "round")))}"' + attrs + attrs = ' stroke-linejoin="round"' + attrs return attrs diff --git a/python/xy/plugins.py b/python/xy/plugins.py index 71dad5fb..68227055 100644 --- a/python/xy/plugins.py +++ b/python/xy/plugins.py @@ -6,13 +6,12 @@ composition half of the second, and deliberately not the shader half. The reason is not schedule. A plugin that composes built-in marks cannot draw -anything the engine could not already draw, which means it inherits — for free, -and without a way to get them wrong — LOD and decimation (§28), picking and -hover, the a11y summary (§20), the wire protocol's f32 discipline (§29), and -every export path including the two that have no browser. A plugin carrying its -own shader inherits none of that and has to re-earn all of it. Composition is -where the breadth-without-forking argument actually holds; shaders are a second -system, and they can wait until something real needs one. +anything the engine could not already draw, so its traces move through the +paths that already exist: LOD and decimation (§28), picking and hover, the wire +protocol's f32 discipline (§29), and every export path including the two that +have no browser. It reuses them rather than reimplementing them. A plugin +carrying its own shader would reuse none of that, so shaders are a second +system and can wait until something real needs one. import numpy as np import xy @@ -38,11 +37,10 @@ def _build(ctx): chart = xy.chart(xy.mark("hilo", t=ts, low=lows, high=highs, data=frame)) A plugin's `build` returns built-in `Mark` objects and nothing else; it never -sees the `Figure`, the trace list, or the column store. That is the whole -containment argument, and `tests/test_mark_plugins.py` holds it: the composed -marks go through the same appliers, the same axis assignment, and the same -post-processing as marks written by hand, because they *are* marks written by -hand — just written by someone else's function. +sees the `Figure`, the trace list, or the column store. `tests/test_mark_plugins.py` +holds that boundary: the composed marks go through the same appliers, the same +axis assignment, and the same post-processing as marks written by hand, because +they *are* marks written by hand — just written by someone else's function. """ from __future__ import annotations diff --git a/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py index 5fc402c3..1b4aed19 100644 --- a/python/xy/styling/capabilities.py +++ b/python/xy/styling/capabilities.py @@ -1,13 +1,12 @@ -"""What XY can be styled with, per renderer — the machine-checkable record. +"""An inventory of what XY can be styled with, and where each thing reaches. -`benchmarks/categories.py` made performance claims auditable by publishing one -committed registry of categories that every harness and report keys off. This -module does the same job for customization: one entry per CSS-addressable DOM -slot and one per mark style property, each carrying its support level in each -renderer, so "how customizable is XY" is a table someone can read rather than a -claim someone has to take on faith. +One entry per CSS-addressable DOM slot and one per mark style property, each +carrying its support level in the WebGL client, the SVG writer, and the native +rasterizer. The styling surface is spread across `styles.py`, `dom.py`, the +three renderers, and the export paths, so answering "can I change this, and +will it survive `to_png()`" otherwise means reading all of them. -Two rules keep it honest, both enforced by `tests/test_capability_registry.py`: +Two rules keep it accurate, both enforced by `tests/test_capability_registry.py`: 1. **It cannot drift.** The registry must cover exactly `dom.CHART_DOM_SLOTS` and exactly the property set `styles._supported_mark_style_properties` @@ -18,14 +17,12 @@ property is *derived* from `styles.py` at import, never typed out here, so the two cannot disagree. -Support levels are deliberately coarse, because a finer scale would invite -optimism: `full` means the renderer draws the property as specified, `partial` -means it draws something the docs have to qualify, and `none` means it does not -draw it at all. `none` is not a bug report — several of them are deliberate, -and the `notes` field says which. +Support levels are deliberately coarse: `full` means the renderer draws the +property as specified, `partial` means it draws something the notes have to +qualify, and `none` means it does not draw it at all. `none` is not a bug +report — several are deliberate, and the `notes` field says which. -Keep `id` values stable: they are the join key for the generated docs table and -for anything downstream that audits coverage. +Keep `id` values stable: they are the join key for the generated table. """ from __future__ import annotations @@ -183,27 +180,13 @@ class ExtensionPoint: support={"webgl": "full", "svg": "full", "native": "full"}, status="shipped", notes=( - "XY's default is `round`, not CSS's `butt`. Verified per renderer, " - "not assumed: a Rust coverage test, a rasterized-ink test, and three " + "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." ), ), - MarkStyleProperty( - id="stroke-linejoin", - vocabulary="svg", - compiles_to="", - support={"webgl": "none", "svg": "full", "native": "full"}, - status="planned", - notes=( - "NOT ACCEPTED by `style=` today, deliberately. The SVG writer emits " - "the attribute and `src/raster.rs` implements miter/round/bevel with " - "SVG's miter limit of 4, but the WebGL client draws a polyline as one " - "instanced quad per segment with no join geometry at all, so it " - "cannot tell a miter from a bevel. Two renderers out of three is what " - "`styles.py` exists to prevent. Unblocking it means giving the client " - "a join pass." - ), - ), MarkStyleProperty( id="border-radius", vocabulary="css", @@ -239,7 +222,7 @@ class ExtensionPoint: svg="round (the writer names it explicitly)", native="round (the capsule distance field fills the vertex)", visible_when="stroke-width above ~4px at a sharp angle", - tracked_by="the `stroke-linejoin` row above", + tracked_by="no style property selects a join; the default is the whole contract", ), ) @@ -293,8 +276,9 @@ class ExtensionPoint: entry_point="xy.register_mark / xy.MarkPlugin / xy.mark", notes=( "A calc over declared columns plus a build that returns built-in " - "marks. Its traces are ordinary traces, so it inherits decimation, " - "picking, hover, a11y, and every export path." + "marks. Its output is ordinary traces, so it reuses the built-in " + "rendering, picking, and export paths rather than reimplementing " + "them." ), limits=( "composes built-in marks only, one level deep", @@ -307,9 +291,9 @@ class ExtensionPoint: status="planned", entry_point="", notes=( - "§24's WGSL/GLSL snippet pair. Deferred on purpose: a plugin with " - "its own shader inherits none of what composition gets for free and " - "has to re-earn LOD, picking, a11y, and three export paths." + "§24's WGSL/GLSL snippet pair. Deferred: a plugin with its own " + "shader reuses none of the built-in rendering, picking, or export " + "paths and would have to reimplement them." ), limits=(), ), diff --git a/scripts/check_claim_guardrails.py b/scripts/check_claim_guardrails.py index 11bb1e08..f7d4f4d2 100644 --- a/scripts/check_claim_guardrails.py +++ b/scripts/check_claim_guardrails.py @@ -19,8 +19,6 @@ DEFAULT_DOCS = ( "README.md", "CLAUDE.md", - ".agents/skills/xy-evaluate/SKILL.md", - "spec/api/customization-vs-alternatives.md", "pyproject.toml", "SECURITY.md", "CONTRIBUTING.md", @@ -48,10 +46,9 @@ re.IGNORECASE, ) # Customization is the second axis people overclaim on, and it went unguarded -# while performance was fenced in. The shapes below are the ones that cannot be -# defended from `spec/api/capability-matrix.md` no matter what qualifies them: -# XY loses total attribute surface to Plotly by roughly three orders of -# magnitude and custom-mark freedom to Matplotlib outright. +# while performance was fenced in. These shapes are unqualifiable: the styling +# surface is a bounded, enumerated subset (`spec/api/capability-matrix.md`), so +# "anything" and "most" are wrong however the sentence is framed. CUSTOMIZATION_SUPERLATIVE_RE = re.compile( r"\b(" r"most\s+(?:customi[sz]able|themeable|styl(?:e)?able|extensible|flexible)|" @@ -64,28 +61,6 @@ r")\b", re.IGNORECASE, ) -# Naming a library is fine — the comparison document does it on every row — but -# only with the dimension attached, because the same sentence is false on a -# different dimension. -CUSTOMIZATION_COMPARATIVE_RE = re.compile( - r"\b(?:more\s+(?:customi[sz]able|themeable|extensible|flexible)\s+than)\s+" - r"(?:plotly|matplotlib|bokeh|altair|vega-?lite|seaborn|holoviews|hvplot|d3)\b", - re.IGNORECASE, -) -CUSTOMIZATION_QUALIFIER_GROUPS = ( - re.compile( - r"\b(?:css|token|variable|slot|property|properties|renderer|plugin|schema|" - r"attribute|vocabulary|subset)\b", - re.I, - ), - re.compile( - r"\b(?:matrix|registry|capabilit(?:y|ies)|classified|measured|counted|" - r"capability-matrix|customization-vs-alternatives|plotly-coverage)\b", - re.I, - ), - re.compile(r"\b(?:chrome|mark|axis|export|browser|native|webgl|svg|png|static)\b", re.I), -) - COMPARATIVE_RE = re.compile( r"\b(?:faster\s+than|beats?|outperforms?)\s+" r"(?:plotly|matplotlib|bokeh|altair|datashader|holoviews|hvplot|seaborn)\b", @@ -153,17 +128,6 @@ def _has_claim_qualifiers(text: str) -> bool: return sum(1 for pattern in QUALIFIER_GROUPS if pattern.search(text)) >= 3 -def _has_customization_qualifiers(text: str) -> bool: - """A customization comparison needs its dimension and its evidence named. - - Two of the three groups: the mechanism (a CSS property, a slot, a plugin), - the evidence (the matrix, the registry, a counted number), or the scope - (which renderer, which surface). Three would reject the comparison - document's own rows, which are the shapes worth copying. - """ - return sum(1 for pattern in CUSTOMIZATION_QUALIFIER_GROUPS if pattern.search(text)) >= 2 - - def _findings_for_file(path: Path) -> list[Finding]: lines = path.read_text(encoding="utf-8").splitlines() findings: list[Finding] = [] @@ -197,25 +161,6 @@ def _findings_for_file(path: Path) -> list[Finding]: line, ) ) - if CUSTOMIZATION_COMPARATIVE_RE.search(line): - # Same reasoning as the numeric-multiplier rule below: a claim - # taxonomy states its scope in the prose above the table, and a - # section heading is answered by the paragraphs under it. A - # sentence-level window would reject the very wording these - # documents exist to model. - claim_window = _line_window(lines, index, radius=10) - if not ( - _is_policy_or_negative_context(claim_window) - or _has_customization_qualifiers(claim_window) - ): - findings.append( - Finding( - path, - index + 1, - "comparative customization claim needs the dimension and its evidence named", - line, - ) - ) if COMPARATIVE_RE.search(line) and not ( _is_policy_or_negative_context(window) or _has_claim_qualifiers(window) ): diff --git a/scripts/gen_capability_matrix.py b/scripts/gen_capability_matrix.py index f94cc212..7f21b903 100644 --- a/scripts/gen_capability_matrix.py +++ b/scripts/gen_capability_matrix.py @@ -4,8 +4,7 @@ The committed table is an artifact, never hand-edited. `--check` fails when it has fallen behind the registry, and `tests/test_capability_registry.py` runs that check — so the document cannot drift from the registry, and the registry -cannot drift from `styles.py`. That is the whole chain, and it is what makes a -customization claim checkable rather than assertable. +cannot drift from `styles.py`. uv run python scripts/gen_capability_matrix.py --write uv run python scripts/gen_capability_matrix.py --check @@ -52,8 +51,8 @@ def render() -> str: "", "What XY can be styled and extended with, per renderer. Generated from", "`python/xy/styling/capabilities.py`, which `tests/test_capability_registry.py`", - "pins to `styles.py` and `dom.py` — so this page cannot claim a property the", - "implementation does not compile, and cannot omit one it does.", + "pins to `styles.py` and `dom.py`, so it cannot list a property the", + "implementation does not compile or omit one it does.", "", "`full` means the renderer draws the property as specified. `partial` means it", "draws something the notes have to qualify. `none` means it does not draw it —", @@ -73,11 +72,9 @@ def render() -> str: "", "## Mark style properties", "", - "Every property the registry tracks. A **`shipped`** row is accepted by a", - "mark's `style=` mapping today; a **`planned`** row is recorded but is *not*", - "accepted, and its note says what blocks it. Anything outside the shipped set", - "raises before data is ingested, so no renderer silently drops a declaration", - "another one honors.", + "The subset a mark's `style=` mapping accepts. Anything outside it raises", + "before data is ingested, so no renderer silently drops a declaration another", + "one honors.", "", ] lines += caps.markdown_mark_property_table() @@ -163,8 +160,7 @@ def render_public() -> str: "", "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, so it cannot promise a property", - "the code does not compile.", + "registry the implementation is checked against.", "", f"- **{counts['mark_style_properties_shipped']}** mark style properties across " f"**{counts['mark_kinds']}** mark kinds, {claim}.", @@ -174,10 +170,8 @@ def render_public() -> str: "", "## Mark style properties", "", - "Every property the registry tracks. A **`shipped`** row is accepted by a mark's", - "`style=` today; a **`planned`** row is *not* accepted yet, and its note says what", - "blocks it. Anything outside the shipped set raises while the chart is built — one", - "renderer never silently ignores what another draws.", + "What a mark's `style=` accepts. Anything outside this raises while the chart is", + "built — one renderer never silently ignores what another draws.", "", ] lines += caps.markdown_mark_property_table() @@ -224,8 +218,6 @@ def render_public() -> str: ) lines += [ "", - "For how this compares to Plotly, Vega-Lite, Bokeh, and Matplotlib — including", - "the columns XY loses — see the comparison in the project specification.", "For what is still alpha, see", "[Limitations and Alpha Status](/docs/xy/api-reference/limitations-and-alpha-status/).", "", diff --git a/scripts/plotly_schema_coverage.py b/scripts/plotly_schema_coverage.py deleted file mode 100644 index 56bf0a25..00000000 --- a/scripts/plotly_schema_coverage.py +++ /dev/null @@ -1,326 +0,0 @@ -#!/usr/bin/env python3 -"""Classify Plotly's attribute schema against what XY can express (§24). - -§24 says compat should be "a measured number", and names Plotly's -`plot-schema.json` as the thing to ingest. Two facts about the world have -changed since that was written, and this script is written to both of them: - -1. **No published Plotly wheel ships `plot-schema.json`.** Verified against - 4.14.3, 5.24.1, and 6.9.0 — the file is a plotly.js artifact and the Python - package generates validator classes from it instead. The equivalent, and a - better shape for this job, is `plotly/validators/_validators.json`: one flat - entry per dotted attribute path with its validator class. -2. **XY has no Plotly shim.** The shipped shim is `xy.pyplot`, which is - Matplotlib-flavored. So "supported" here cannot mean "the shim accepts the - attribute" — there is nothing to accept it. It means *XY can express the - same visual outcome*, and the rule that says so is committed below where it - can be argued with. - -Classification is rule-driven, not hand-enumerated: 9,472 attributes cannot be -audited one at a time, and a table that claims otherwise would be fiction. Each -rule names the XY surface that answers the attribute, so an unfamiliar reader -can check any row by following the pointer. Attributes no rule claims land in -`unsupported/unclassified`, which is the honest bucket — it is reported, never -folded into a friendlier one. - - uv run --extra bench python scripts/plotly_schema_coverage.py --check - uv run --extra bench python scripts/plotly_schema_coverage.py --write -""" - -from __future__ import annotations - -import argparse -import fnmatch -import json -import sys -from collections import Counter -from pathlib import Path -from typing import NamedTuple - -ROOT = Path(__file__).resolve().parents[1] -REPORT = ROOT / "spec" / "api" / "plotly-coverage.md" - -SUPPORTED = "supported" -MAPPED = "mapped-with-difference" -UNSUPPORTED = "unsupported" - -#: Trace types XY implements, mapped to the XY mark that answers them. Every -#: other Plotly trace type is `unsupported/no-such-trace` — a truthful bucket, -#: and by far the largest one. -TRACE_EQUIVALENTS: dict[str, str] = { - "scatter": "xy.scatter / xy.line", - "scattergl": "xy.scatter", - "bar": "xy.bar / xy.column", - "histogram": "xy.histogram", - "histogram2d": "xy.heatmap", - "heatmap": "xy.heatmap", - "box": "xy.box", - "violin": "xy.violin", - "contour": "xy.contour", - "mesh3d": "", # deliberately absent: 3-D is out of v1 -} - -#: Roots that are not trace types at all. -META_ROOTS = frozenset({"data", "frame", "frames"}) - - -class Rule(NamedTuple): - """One committed claim about a family of Plotly attributes.""" - - pattern: str - verdict: str - surface: str - note: str - - -#: Order matters: the first matching rule wins, so specific patterns precede -#: general ones. `*` matches within a path segment and across segments alike -#: (fnmatch semantics), which is what makes `*.marker.symbol` reach both -#: `scatter.marker.symbol` and `scatter.selected.marker.symbol`. -RULES: tuple[Rule, ...] = ( - # --- excluded subtrees, first because `*` spans dots --------------------- - # `fnmatch` treats `*` as matching across `.`, and the first matching rule - # wins, so `layout.*axis.showgrid` would otherwise claim - # `layout.polar.angularaxis.showgrid` before the exclusion below could - # reject it — silently counting 3-D, geo, polar, ternary, and smith axes as - # supported and inflating the headline number. Order is load-bearing here. - Rule("layout.scene*", UNSUPPORTED, "", "3-D is explicitly out of v1"), - Rule("layout.geo*", UNSUPPORTED, "", "geo is explicitly out of v1"), - Rule("layout.map*", UNSUPPORTED, "", "mapbox/map is explicitly out of v1"), - Rule("layout.polar*", UNSUPPORTED, "", "polar axes are not implemented"), - Rule("layout.ternary*", UNSUPPORTED, "", "ternary axes are not implemented"), - Rule("layout.smith*", UNSUPPORTED, "", "smith axes are not implemented"), - Rule("layout.updatemenus*", UNSUPPORTED, "", "no in-chart widget layer"), - Rule("layout.sliders*", UNSUPPORTED, "", "no in-chart widget layer"), - # --- paint and geometry XY draws exactly ------------------------------- - Rule("*.marker.symbol", SUPPORTED, "style={'marker-shape': ...}", "17 shared shapes"), - Rule("*.marker.color", SUPPORTED, "color= / style={'fill': ...}", ""), - Rule("*.marker.opacity", SUPPORTED, "style={'fill-opacity': ...}", ""), - Rule("*.marker.size", SUPPORTED, "size=", ""), - Rule("*.marker.line.color", SUPPORTED, "style={'stroke': ...}", ""), - Rule("*.marker.line.width", SUPPORTED, "style={'stroke-width': ...}", ""), - Rule("*.line.dash", SUPPORTED, "style={'stroke-dasharray': ...}", "named presets and px lists"), - Rule("*.line.color", SUPPORTED, "style={'stroke': ...}", ""), - Rule("*.line.width", SUPPORTED, "style={'stroke-width': ...}", ""), - Rule("*.line.shape", MAPPED, "curve=", "XY has linear and monotone-cubic, not all six"), - Rule("*.opacity", SUPPORTED, "style={'opacity': ...}", ""), - Rule("*.name", SUPPORTED, "name=", ""), - Rule("*.visible", MAPPED, "compose the chart without the mark", "no per-trace toggle prop"), - Rule("*.colorscale", SUPPORTED, "colormap=", "custom ramps accepted"), - Rule("*.showscale", SUPPORTED, "xy.colorbar()", ""), - Rule("*.cmin", SUPPORTED, "color_domain=", ""), - Rule("*.cmax", SUPPORTED, "color_domain=", ""), - # --- layout XY answers -------------------------------------------------- - Rule("layout.xaxis.type", SUPPORTED, "xy.x_axis(scale=...)", ""), - Rule("layout.yaxis.type", SUPPORTED, "xy.y_axis(scale=...)", ""), - Rule("layout.*axis.title.text", SUPPORTED, "xy.x_axis(label=...)", ""), - Rule("layout.*axis.range", SUPPORTED, "xy.x_axis(domain=...)", ""), - Rule("layout.*axis.showgrid", SUPPORTED, "xy.x_axis(grid=...)", ""), - Rule("layout.*axis.gridcolor", SUPPORTED, "style={'grid-color': ...}", ""), - Rule("layout.*axis.gridwidth", SUPPORTED, "style={'grid-width': ...}", ""), - Rule("layout.*axis.showline", SUPPORTED, "xy.x_axis(line=...)", ""), - Rule("layout.*axis.linecolor", SUPPORTED, "style={'axis-color': ...}", ""), - Rule("layout.*axis.ticklen", SUPPORTED, "style={'tick-length': ...}", ""), - Rule("layout.*axis.tickcolor", SUPPORTED, "style={'tick-color': ...}", ""), - Rule("layout.*axis.ticks", SUPPORTED, "style={'tick-direction': ...}", ""), - Rule("layout.*axis.showticklabels", SUPPORTED, "xy.x_axis(text=...)", ""), - Rule("layout.title.text", SUPPORTED, "title=", ""), - Rule("layout.showlegend", SUPPORTED, "xy.legend()", ""), - Rule("layout.legend.*", MAPPED, "xy.legend(...) + slot styling", "different option names"), - Rule("layout.colorway", SUPPORTED, "xy.theme(palette=[...])", ""), - Rule("layout.template", MAPPED, "xy.theme(...)", "not a serializable template format"), - Rule("layout.paper_bgcolor", SUPPORTED, "style={'--chart-bg': ...}", ""), - Rule("layout.plot_bgcolor", SUPPORTED, "style={'--chart-plot-bg': ...}", ""), - Rule("layout.width", SUPPORTED, "width=", ""), - Rule("layout.height", SUPPORTED, "height=", ""), - Rule("layout.margin.*", SUPPORTED, "padding=", ""), - Rule("layout.annotations.*", MAPPED, "xy.text() / xy.callout()", "different geometry model"), - Rule("layout.shapes.*", MAPPED, "xy.rule() / xy.band()", "no arbitrary path shapes"), - Rule("layout.hovermode", MAPPED, "xy.tooltip(...)", ""), - Rule("layout.dragmode", MAPPED, "xy.modebar(...) / interaction options", ""), - Rule("layout.transition*", MAPPED, "xy.animation(...)", "different easing vocabulary"), - Rule("*.hovertemplate", MAPPED, "xy.tooltip(...)", "not a mini-language"), - Rule("*.customdata", MAPPED, "tooltip sources", ""), -) - - -def load_schema() -> tuple[dict[str, dict], str]: - """The installed plotly's flat validator schema, and its version.""" - try: - import plotly - except ModuleNotFoundError: # pragma: no cover - depends on the extra - raise SystemExit( - "plotly is not installed; run with `uv run --extra bench` " - "(the pinned version is in benchmarks/requirements-ci.in)" - ) from None - path = Path(plotly.__file__).parent / "validators" / "_validators.json" - if not path.exists(): # pragma: no cover - depends on the plotly release - raise SystemExit( - f"plotly {plotly.__version__} has no {path.name}; see this script's docstring" - ) - return json.loads(path.read_text(encoding="utf-8")), plotly.__version__ - - -def attribute_paths(schema: dict[str, dict]) -> list[str]: - """Leaf attributes only, minus the `*src` column-reference companions. - - A compound node is a namespace, not a knob, and `marker.colorsrc` is not a - second styling decision beside `marker.color` — counting either would pad - the denominator in XY's favour, which is the wrong direction to be wrong in. - """ - skip = {"CompoundValidator", "CompoundArrayValidator", "SrcValidator"} - return sorted(path for path, node in schema.items() if node["superclass"] not in skip) - - -def classify(path: str) -> tuple[str, str, str]: - """-> (verdict, xy surface, reason). First matching rule wins.""" - root = path.split(".", 1)[0] - if root not in META_ROOTS and root != "layout": - equivalent = TRACE_EQUIVALENTS.get(root) - if equivalent is None: - return UNSUPPORTED, "", f"no such trace: XY does not implement {root!r}" - if not equivalent: - return UNSUPPORTED, "", f"{root!r} is explicitly out of v1" - for rule in RULES: - if fnmatch.fnmatch(path, rule.pattern): - return rule.verdict, rule.surface, rule.note - return UNSUPPORTED, "", "unclassified" - - -def build_report(schema: dict[str, dict], version: str) -> tuple[str, dict]: - """Classify every attribute and render the report plus its summary counts.""" - paths = attribute_paths(schema) - rows = [(path, *classify(path)) for path in paths] - verdicts = Counter(verdict for _, verdict, _, _ in rows) - reasons = Counter(reason for _, verdict, _, reason in rows if verdict == UNSUPPORTED) - - implemented = sorted(k for k, v in TRACE_EQUIVALENTS.items() if v) - in_scope = [r for r in rows if r[0].split(".", 1)[0] in {*implemented, "layout"}] - in_scope_verdicts = Counter(verdict for _, verdict, _, _ in in_scope) - - summary = { - "plotly_version": version, - "attributes": len(paths), - "verdicts": dict(verdicts), - "in_scope_attributes": len(in_scope), - "in_scope_verdicts": dict(in_scope_verdicts), - "unsupported_reasons": dict(reasons.most_common()), - "implemented_trace_types": implemented, - } - - def pct(n: int, d: int) -> str: - return f"{100.0 * n / d:.1f}%" if d else "n/a" - - lines = [ - "# Plotly attribute coverage", - "", - "", - "", - f"Measured against **plotly {version}**, " - f"`plotly/validators/_validators.json`, {len(paths):,} leaf attributes " - "(compound namespaces and `*src` column-reference companions excluded).", - "", - "## Whole schema", - "", - "| verdict | attributes | share |", - "|---|---|---|", - ] - for verdict in (SUPPORTED, MAPPED, UNSUPPORTED): - count = verdicts.get(verdict, 0) - lines.append(f"| {verdict} | {count:,} | {pct(count, len(paths))} |") - lines += [ - "", - "That denominator is dominated by trace types XY does not implement, so", - "the whole-schema share is a poor headline number. The scoped one below", - "is the number worth quoting, and it must be quoted *with* its scope.", - "", - "## Scoped to the trace types XY implements, plus `layout`", - "", - f"Trace types: {', '.join(f'`{name}`' for name in implemented)}. " - f"{len(in_scope):,} attributes.", - "", - "| verdict | attributes | share |", - "|---|---|---|", - ] - for verdict in (SUPPORTED, MAPPED, UNSUPPORTED): - count = in_scope_verdicts.get(verdict, 0) - lines.append(f"| {verdict} | {count:,} | {pct(count, len(in_scope))} |") - lines += [ - "", - "## Why attributes are unsupported", - "", - "Every unsupported attribute is accounted for: the table lists the " - "fifteen largest reasons and folds the rest into one row, so the column " - "sums to the unsupported total above rather than trailing off.", - "", - "| reason | attributes |", - "|---|---|", - ] - top = reasons.most_common(15) - for reason, count in top: - lines.append(f"| {reason} | {count:,} |") - remainder = sum(reasons.values()) - sum(count for _, count in top) - if remainder: - lines.append( - f"| all other reasons ({len(reasons) - len(top)} more, " - f"each smaller than the rows above) | {remainder:,} |" - ) - lines.append(f"| **total unsupported** | **{sum(reasons.values()):,}** |") - lines += [ - "", - "`unclassified` is the honest residue: attributes no committed rule in", - "`scripts/plotly_schema_coverage.py` claims. It is reported rather than", - "folded into a friendlier bucket, and shrinking it is how this table", - "improves.", - "", - "## Reproducing", - "", - "```bash", - "uv run --extra bench python scripts/plotly_schema_coverage.py --check", - "```", - "", - "`--check` fails if this file is stale. The rules it applies are in the", - "`RULES` table of that script, one line per claim, so any row here can be", - "traced to the claim that produced it.", - "", - ] - return "\n".join(lines), summary - - -def main(argv: list[str] | None = None) -> int: - """Print, write, or check the committed coverage report.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--write", action="store_true", help="regenerate the committed report") - parser.add_argument( - "--check", action="store_true", help="fail if the committed report is stale" - ) - parser.add_argument("--json", type=Path, help="also write the summary as JSON") - args = parser.parse_args(argv) - - schema, version = load_schema() - report, summary = build_report(schema, version) - - if args.json: - args.json.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") - if args.write: - REPORT.parent.mkdir(parents=True, exist_ok=True) - REPORT.write_text(report, encoding="utf-8") - print(f"wrote {REPORT.relative_to(ROOT)}") - return 0 - if args.check: - current = REPORT.read_text(encoding="utf-8") if REPORT.exists() else "" - if current != report: - print( - f"{REPORT.relative_to(ROOT)} is stale; " - "run scripts/plotly_schema_coverage.py --write", - file=sys.stderr, - ) - return 1 - print(f"{REPORT.relative_to(ROOT)} is current") - return 0 - print(json.dumps(summary, indent=2)) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/verify_sdist.py b/scripts/verify_sdist.py index 0963c67d..e0fdb2ab 100644 --- a/scripts/verify_sdist.py +++ b/scripts/verify_sdist.py @@ -106,7 +106,6 @@ "scripts/check_public_api.py", "scripts/check_claim_guardrails.py", "scripts/gen_capability_matrix.py", - "scripts/plotly_schema_coverage.py", "scripts/check_python_floor.py", "scripts/check_regressions.py", "scripts/bench_dashboard.py", diff --git a/spec/README.md b/spec/README.md index aca1f4c7..bde52d7d 100644 --- a/spec/README.md +++ b/spec/README.md @@ -24,20 +24,13 @@ The public surface: what callers can build, style, export, and interact with. `tests/test_docs_examples.py`, so API drift fails the suite. - [`chart-kind-contract.md`](api/chart-kind-contract.md) — how to add a 2D chart type: what the shared machinery provides and what a new kind must supply. -- [`capability-matrix.md`](api/capability-matrix.md) — **generated**: what can be - styled and extended, per renderer, from `python/xy/styling/capabilities.py`. +- [`capability-matrix.md`](api/capability-matrix.md) — **generated**: the + inventory of what can be styled and extended, per renderer, from + `python/xy/styling/capabilities.py`. Regenerate with `scripts/gen_capability_matrix.py --write`; the test suite fails if it is stale. - [`chart-roadmap.md`](api/chart-roadmap.md) — the single 2D-first chart-type coverage backlog, prioritized by popularity and primitive reuse. -- [`customization-vs-alternatives.md`](api/customization-vs-alternatives.md) — - the committed comparison of customization and extensibility against Plotly, - Vega-Lite, Bokeh, and Matplotlib, with its method, its pinned versions, and - the rows XY loses. Pinned by `tests/test_customization_comparison.py`. -- [`plotly-coverage.md`](api/plotly-coverage.md) — **generated**: every Plotly - attribute classified `supported | mapped-with-difference | unsupported` (§24). - Regenerate with `scripts/plotly_schema_coverage.py --write` (needs the `bench` - extra). - [`export.md`](api/export.md) — how a figure becomes bytes: one entry point across five image formats, deterministic engine choice, browser-free default. - [`interaction.md`](api/interaction.md) — the authority on which browser diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md index 8538c401..66a9a863 100644 --- a/spec/api/capability-matrix.md +++ b/spec/api/capability-matrix.md @@ -4,8 +4,8 @@ What XY can be styled and extended with, per renderer. Generated from `python/xy/styling/capabilities.py`, which `tests/test_capability_registry.py` -pins to `styles.py` and `dom.py` — so this page cannot claim a property the -implementation does not compile, and cannot omit one it does. +pins to `styles.py` and `dom.py`, so it cannot list a property the +implementation does not compile or omit one it does. `full` means the renderer draws the property as specified. `partial` means it draws something the notes have to qualify. `none` means it does not draw it — @@ -20,11 +20,9 @@ which is sometimes deliberate, and the notes say which. ## Mark style properties -Every property the registry tracks. A **`shipped`** row is accepted by a -mark's `style=` mapping today; a **`planned`** row is recorded but is *not* -accepted, and its note says what blocks it. Anything outside the shipped set -raises before data is ingested, so no renderer silently drops a declaration -another one honors. +The subset a mark's `style=` mapping accepts. Anything outside it raises +before data is ingested, so no renderer silently drops a declaration another +one honors. | property | vocabulary | mark kinds | webgl | svg | native | status | |---|---|---|---|---|---|---| @@ -36,7 +34,6 @@ another one honors. | `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 | -| `stroke-linejoin` | svg | — | none | full | full | planned | | `border-radius` | css | `bar`, `column`, `hist`, `histogram` | full | full | full | shipped | | `marker-shape` | xy | `scatter` | full | full | full | shipped | @@ -48,8 +45,7 @@ another one honors. - **`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`** — XY's default is `round`, not CSS's `butt`. Verified per renderer, not assumed: a Rust coverage test, a rasterized-ink test, and three Chromium screenshots that hash differently per cap. -- **`stroke-linejoin`** — NOT ACCEPTED by `style=` today, deliberately. The SVG writer emits the attribute and `src/raster.rs` implements miter/round/bevel with SVG's miter limit of 4, but the WebGL client draws a polyline as one instanced quad per segment with no join geometry at all, so it cannot tell a miter from a bevel. Two renderers out of three is what `styles.py` exists to prevent. Unblocking it means giving the client a join pass. +- **`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. @@ -104,8 +100,8 @@ Ways to add behavior the core does not ship, without forking it. ### Notes -- **mark_plugin_composition** — A calc over declared columns plus a build that returns built-in marks. Its traces are ordinary traces, so it inherits decimation, picking, hover, a11y, and every export path. -- **mark_plugin_shader** — §24's WGSL/GLSL snippet pair. Deferred on purpose: a plugin with its own shader inherits none of what composition gets for free and has to re-earn LOD, picking, a11y, and three export paths. +- **mark_plugin_composition** — A calc over declared columns plus a build that returns built-in marks. Its output is ordinary traces, so it reuses the built-in rendering, picking, and export paths rather than reimplementing them. +- **mark_plugin_shader** — §24's WGSL/GLSL snippet pair. Deferred: a plugin with its own shader reuses none of the built-in rendering, picking, or export paths and would have to reimplement them. - **custom_renderer** — No way to add a fourth renderer or replace one of the three. ## Known renderer divergences @@ -115,7 +111,7 @@ until someone diffs two exports. Listed here for that reason. | what | webgl | svg | native | visible when | tracked by | |---|---|---|---|---|---| -| 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 | the `stroke-linejoin` row above | +| 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 | no style property selects a join; the default is the whole contract | ## Regenerating diff --git a/spec/api/customization-vs-alternatives.md b/spec/api/customization-vs-alternatives.md deleted file mode 100644 index 2dbf9b53..00000000 --- a/spec/api/customization-vs-alternatives.md +++ /dev/null @@ -1,100 +0,0 @@ -# Customization versus the alternatives - -`spec/benchmarks/methodology.md` exists because performance claims are worthless -without a committed harness. Customization claims have the same problem and had -no equivalent, so this document is that equivalent: the question asked in each -row, the method used to answer it, the version of every library compared, and — -the part that makes it evidence rather than marketing — the rows XY loses. - -A matrix where one library wins everything is evidence of nothing. If a future -edit removes the losses, it has removed the reason to believe the wins. - -## Method - -Same rules as the benchmark harness, adapted to a capability question. - -1. **Versions are pinned and recorded.** The comparison is against the versions - in `benchmarks/requirements-ci.in`: Plotly 6.9.0, Bokeh 3.9.1, Altair 6.2.2 - (Vega-Lite 6), Matplotlib 3.11.1. A row that changes when a competitor - releases is a row that has to be re-checked, not silently inherited. -2. **Every row names its method.** One of: `schema` (counted from a machine - -readable schema — reproducible by running the script named in the row), - `code` (read from the library's source, with the symbol named), or `docs` - (taken from the library's own documentation, which is the right source for - an intended-contract question and the wrong one for a coverage count). -3. **Same-work comparison.** XY's per-slot DOM styling is not compared against - Matplotlib's rcParams as if they answered the same question. Where two - libraries solve a problem in incomparable ways the row says so instead of - picking the framing that flatters XY. -4. **The XY column is generated, not asserted.** Everything in the XY column - that is countable comes from `xy.styling.capabilities`, which - `tests/test_capability_registry.py` pins to the actual implementation, and - `tests/test_customization_comparison.py` re-checks the counts quoted below - against it. A number here that the registry does not know about fails the - suite rather than sitting in prose. -5. **Losses ship.** The loss table below is not an appendix. It is the reason - the win table is credible, and it is maintained with the same care. - -## What XY is genuinely better at - -| Question | XY | Plotly | Vega-Lite | Bokeh | Matplotlib | Method | -| --- | --- | --- | --- | --- | --- | --- | -| Can host design tokens (`var(--brand)`) paint marks? | yes, in every renderer — resolved in Python for static export | no | no | no | no | code | -| Stable, documented DOM slot contract for chrome | 23 named slots, `data-xy-slot` | no published contract | no | partial (CSS classes, undocumented as contract) | n/a — no DOM | code | -| Tailwind classes on chart chrome | yes, per slot | no | no | partial | n/a | docs | -| Same style declaration honored by GPU, vector, and raster output | yes, by construction: one validated subset compiled once | no — the browser and Kaleido share a renderer, but there is no native vector/raster path to agree with | no | no | n/a — one renderer family | code | -| Unsupported style declaration fails loudly | yes, before ingest | no — unknown keys are dropped | schema-validated | partial | partial | code | -| Per-property record of which renderer draws what | yes, `xy.styling.capabilities`, generated and drift-tested | no | no | no | no | code | - -## What XY is genuinely worse at - -| Question | XY | Best alternative | Method | -| --- | --- | --- | --- | -| Total styleable attribute surface | 10 shipped mark style properties, 16 axis keys, 23 chrome slots | **Plotly**: 9,472 non-`src` leaf attributes across 49 trace types and `layout` (plotly 6.9.0) | schema | -| Chart families you can style at all | 20 mark kinds | **Plotly**: 49 trace types, including 3-D, geo, and financial families XY does not implement | schema | -| Writing a genuinely new mark | `xy.register_mark` composes existing primitives only; no custom shader | **Matplotlib**: a custom `Artist` can draw anything the backend can | docs | -| Custom rendering primitives | none — deferred from §24 v0 | **Bokeh**: custom models ship their own TypeScript | docs | -| Global style defaults as a first-class file format | theme object only | **Matplotlib**: `matplotlibrc` plus ~300 rcParams | docs | -| Per-slot styles and classes in static export | dropped — browser only | **Matplotlib**: no split to have, every style path is the export path | code | -| Colorbar styling in static export | no native channel at all | **Plotly**: colorbar attributes apply in Kaleido export | code | -| Author stylesheet in native PNG | rejected; needs `Engine.chromium` | **Plotly**: n/a — Kaleido is always a browser | code | - -## Copyable claim taxonomy - -The performance version of this table is at `spec/benchmarks/results.md` -§ Copyable claim taxonomy, and the rule is the same: a claim is publishable only -if it names the dimension it is true on. - -| Claim shape | Safe wording | Required context | -| --- | --- | --- | -| Token themeability | "XY resolves host CSS variables into mark paint in the browser, SVG, and native PNG; Plotly, Bokeh, Vega-Lite, and Matplotlib do not." | the mechanism, the renderers, the named alternatives | -| Slot contract | "XY publishes 23 stable chrome slots as a supported contract; the alternatives compared expose no equivalent published contract." | slot count, "published contract" not "styleable" | -| Cross-renderer fidelity | "A mark style declaration XY accepts is drawn by all three renderers or rejected at build time." | the subset is validated; this is not a claim about arbitrary CSS | -| Breadth | **Do not claim breadth.** Plotly's attribute surface is roughly three orders of magnitude larger. | — | -| Extensibility | "XY marks can be extended by composing built-in primitives without forking; it does not offer custom shaders or a custom-artist API." | what the plugin can and cannot do | -| Cap/join fidelity | "`stroke-linecap` is drawn identically by all three renderers, verified per renderer; `stroke-linejoin` is not offered because the WebGL client has no join geometry." | the specific property, the specific blocker | - -## The claim ladder - -What is defensible depends on what has shipped, so the ladder is written down -rather than re-argued each release. A rung is earned by an artifact, not by an -opinion. - -| Once this exists | You may say | -| --- | --- | -| `xy.styling.capabilities` + the generated matrix | "measurably more themeable than Plotly on the dimensions in the published capability matrix" | -| The comparison document with its loss table | "more themeable, less extensible than Matplotlib — here is the matrix, losses included" | -| `xy.register_mark` | "themeable *and* extensible, by composition" | -| A shader-level plugin API and per-slot styling in static export | revisit this table; do not extrapolate to it from here | -| — | **"most customizable" — never.** Plotly's attribute surface is roughly three orders of magnitude larger and Matplotlib's custom `Artist` is strictly more powerful. No amount of shipping changes those two facts. | - -The last row is enforced rather than trusted: -`scripts/check_claim_guardrails.py` rejects "most customizable", "fully -customizable", "style anything", and "more customizable than any/all" wherever -they appear in `README.md` or `docs/`, and requires a comparative claim against -a named library to carry its dimension and its evidence. - -Claims that are never defensible, regardless of context: "most customizable", -"most themeable charting library", "more extensible than Matplotlib", "as -customizable as Plotly". `scripts/check_claim_guardrails.py` rejects the first -two shapes mechanically; the other two are judgment and belong in review. diff --git a/spec/api/plotly-coverage.md b/spec/api/plotly-coverage.md deleted file mode 100644 index e37442a5..00000000 --- a/spec/api/plotly-coverage.md +++ /dev/null @@ -1,66 +0,0 @@ -# Plotly attribute coverage - - - -Measured against **plotly 6.9.0**, `plotly/validators/_validators.json`, 9,472 leaf attributes (compound namespaces and `*src` column-reference companions excluded). - -## Whole schema - -| verdict | attributes | share | -|---|---|---| -| supported | 218 | 2.3% | -| mapped-with-difference | 115 | 1.2% | -| unsupported | 9,139 | 96.5% | - -That denominator is dominated by trace types XY does not implement, so -the whole-schema share is a poor headline number. The scoped one below -is the number worth quoting, and it must be quoted *with* its scope. - -## Scoped to the trace types XY implements, plus `layout` - -Trace types: `bar`, `box`, `contour`, `heatmap`, `histogram`, `histogram2d`, `scatter`, `scattergl`, `violin`. 3,387 attributes. - -| verdict | attributes | share | -|---|---|---| -| supported | 217 | 6.4% | -| mapped-with-difference | 115 | 3.4% | -| unsupported | 3,055 | 90.2% | - -## Why attributes are unsupported - -Every unsupported attribute is accounted for: the table lists the fifteen largest reasons and folds the rest into one row, so the column sums to the unsupported total above rather than trailing off. - -| reason | attributes | -|---|---| -| unclassified | 2,190 | -| 3-D is explicitly out of v1 | 323 | -| no such trace: XY does not implement 'scatter3d' | 290 | -| no such trace: XY does not implement 'carpet' | 200 | -| no such trace: XY does not implement 'treemap' | 198 | -| no such trace: XY does not implement 'funnel' | 197 | -| no such trace: XY does not implement 'icicle' | 192 | -| ternary axes are not implemented | 187 | -| no such trace: XY does not implement 'scatterpolar' | 187 | -| no such trace: XY does not implement 'scattercarpet' | 184 | -| no such trace: XY does not implement 'scatterternary' | 184 | -| no such trace: XY does not implement 'surface' | 183 | -| no such trace: XY does not implement 'histogram2dcontour' | 182 | -| no such trace: XY does not implement 'scattersmith' | 182 | -| no such trace: XY does not implement 'scattergeo' | 180 | -| all other reasons (32 more, each smaller than the rows above) | 4,080 | -| **total unsupported** | **9,139** | - -`unclassified` is the honest residue: attributes no committed rule in -`scripts/plotly_schema_coverage.py` claims. It is reported rather than -folded into a friendlier bucket, and shrinking it is how this table -improves. - -## Reproducing - -```bash -uv run --extra bench python scripts/plotly_schema_coverage.py --check -``` - -`--check` fails if this file is stale. The rules it applies are in the -`RULES` table of that script, one line per claim, so any row here can be -traced to the claim that produced it. diff --git a/spec/api/styling.md b/spec/api/styling.md index 272a4f93..aaff7c5a 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -128,27 +128,12 @@ rasterizer is the reference for static export. a key that equals it, so a spec that never asks for another cap stays byte-identical to one built before the change. -**`stroke-linejoin` is not offered yet, and the reason is a renderer, not an -oversight.** SVG emits the attribute and the native rasterizer implements all -three joins (`join_shape` in `src/raster.rs`, exercised whenever a non-round cap -forces the shaped path). The WebGL client draws a polyline as one instanced -quad per segment with *no join geometry whatsoever* — adjacent quads simply -overlap by half a pixel — so it has no way to distinguish a miter from a bevel. -Shipping the property would mean two renderers honoring it and one ignoring it, -which is the failure this module exists to prevent. It waits for the client. - -![The area outline before and after: the SVG writer inherited the format's flat -cap while the rasterizer drew round; both now cap round.](../assets/area-outline-cap-before-after.png) - -The area outline carried the same bug one level quieter — the SVG writer named -its join and let the cap default, so `_pdf` read it back flat too. Both now say -what they draw. - -That leaves a real cross-renderer difference in the *default*: the rasterizer -fills interior vertices with a round join and the WebGL client leaves the notch -two overlapping quads produce. That predates this vocabulary, is visible only on -wide strokes at sharp angles, and is recorded as a row in -`xy.styling.capabilities` rather than left for a reader to discover. +Joins are always round and are not selectable. That was already the geometry +the native rasterizer produced, so nothing changed there; what did change is +that the SVG writer now *names* the join on every stroked path instead of +letting the format's `miter` default through — `_pdf` reads these attributes +straight back out of that markup, so an unnamed join meant SVG and PDF +disagreeing with the rasterizer at no benefit. ### Marker shape diff --git a/spec/design-dossier.md b/spec/design-dossier.md index 3012203c..b5b951c0 100644 --- a/spec/design-dossier.md +++ b/spec/design-dossier.md @@ -650,7 +650,7 @@ missing entirely.* | 10 | Autorange is an O(n) full scan per update | Moderate | Chunk zone maps make it O(chunks) (§22) | | 11 | No VRAM budget/eviction; no device-loss recovery | Moderate | Byte-budgeted caches; rebuildable GPU state (§6, §18) | | 12 | No bundle-size budget — a fat WASM blob forfeits a real Plotly pain point (3.5 MB+) | Moderate | Feature-gated modules + CI size budget (§23) | -| 13 | Compat shim scope unquantified (~3,000 Plotly schema attributes) | Moderate | **Quantified**: 9,472 leaf attributes, classified by committed rules (§24, `spec/api/plotly-coverage.md`). The estimate was 3x low. | +| 13 | Compat shim scope unquantified (~3,000 Plotly schema attributes) | Moderate | Generated conformance suite + explicit degradation contract (§24) | | 14 | Benchmarks measured throughput but not interaction latency; "60fps" undefined | Minor | Latency budgets + p99 framing added (§17, §12) | | 15 | No extensibility story (Plotly has custom traces) | Minor | **Shipped v0**: composition mark plugins, `xy.register_mark` (§24). Custom shaders still deferred. | @@ -903,32 +903,6 @@ partial-bundle pain). Neither exists today. unsupported attributes (never silently drops), and the docs publish the coverage table per release. "Drop-in for the common 80%" becomes checkable, not vibes. - **Shipped, with two amendments the original wording did not anticipate** - (`scripts/plotly_schema_coverage.py` → `spec/api/plotly-coverage.md`): - - 1. *No published Plotly wheel ships `plot-schema.json`.* Verified against - 4.14.3, 5.24.1, and 6.9.0 — it is a plotly.js artifact, and the Python - package generates validator classes from it instead. The equivalent, and a - better shape for this job, is `plotly/validators/_validators.json`: one flat - entry per dotted path. **9,472 leaf attributes** once compound namespaces - and `*src` column-reference companions come out — three times the estimate. - 2. *There is no Plotly shim to warn.* The shipped shim is `xy.pyplot`, which is - Matplotlib-flavored. So "supported" cannot mean "the shim accepts the - attribute"; it means XY can express the same visual outcome, decided by a - committed rule table rather than by hand — 9,472 attributes cannot be - audited one at a time, and a table claiming otherwise would be fiction. - - The number, scoped to the trace types XY implements plus `layout`: **217 - supported, 115 mapped-with-difference of 3,387**. Whole-schema it is 218 and - 115 of 9,472, a denominator dominated by trace types XY does not implement. - Both are published, because either one quoted alone misleads in a different - direction. Attributes no rule claims land in `unsupported/unclassified` — - currently 2,190 — which is reported rather than folded somewhere friendlier; - shrinking it is how the table improves. Quote these from - `spec/api/plotly-coverage.md` rather than from here: rule order is - load-bearing (`*` spans dots in `fnmatch`), and an earlier ordering silently - counted 3-D, geo, polar, ternary, and smith axes as supported, overstating - the figure by 127 attributes. - **Custom traces without forking:** a registered *mark plugin* provides (a) a calc function over columns → columns (runs in the worker, gets zone maps), (b) either a composition of built-in GPU primitives (instanced marks, density @@ -938,16 +912,14 @@ partial-bundle pain). Neither exists today. **Shipped (v0):** `xy.register_mark` / `xy.MarkPlugin` / `xy.mark` in `python/xy/plugins.py`. It ships (a) and the composition half of (b); the - shader half is deliberately deferred, and (c) turns out to need nothing — - a plugin that composes built-in marks inherits hover, picking, the a11y - summary, LOD, and every export path *by construction*, because its output is - ordinary traces. `build` returns `Mark` objects and cannot reach the `Figure`, - the trace list, or the column store, so a plugin cannot draw anything the - engine could not already draw. Composition is one level deep: plugins compose - built-ins, not each other. That is what makes the containment argument hold, - and it is the reason the shader half should stay deferred until something - real needs it — a plugin carrying its own shader inherits none of the above - and has to re-earn all of it. + shader half is deferred. `build` returns built-in `Mark` objects and cannot + reach the `Figure`, the trace list, or the column store, so a plugin cannot + draw anything the engine could not already draw — and its output being + ordinary traces is what lets it reuse the built-in rendering, picking, and + export paths instead of reimplementing them. Composition is one level deep: + plugins compose built-ins, not each other. The shader half stays deferred for + the same reason the composition half works: a plugin carrying its own shader + reuses none of that. ## 25. Milestone amendments (audit-driven) diff --git a/src/raster.rs b/src/raster.rs index b75c0fda..38954295 100644 --- a/src/raster.rs +++ b/src/raster.rs @@ -431,36 +431,17 @@ fn fill_poly(cv: &mut Canvas, pts: &[(f32, f32)], mut color_at: impl FnMut(f32, type StrokeSegment = ((f32, f32), (f32, f32)); -// stroke-linecap / stroke-linejoin, in the wire order python/xy/styles.py -// compiles: butt/round/square and miter/round/bevel. XY's default is round for -// both, which is what the clamped segment distance field below has always -// drawn, so `stroke` stays the fast path and byte-for-byte unchanged; anything -// else routes through `stroke_shaped`. +// stroke-linecap, in the wire order python/xy/styles.py compiles: +// butt/round/square. XY's default is round, which is what the clamped segment +// distance field below has always drawn, so `stroke` stays the fast path and +// byte-for-byte unchanged; the other two route through `stroke_shaped`. +// +// Joins are always round. That is what the capsule field produced before caps +// were selectable, and it is what every renderer draws today; `stroke-linejoin` +// is not part of the style vocabulary, so there is nothing to select. pub const CAP_BUTT: u8 = 0; pub const CAP_ROUND: u8 = 1; pub const CAP_SQUARE: u8 = 2; -pub const JOIN_MITER: u8 = 0; -pub const JOIN_ROUND: u8 = 1; -pub const JOIN_BEVEL: u8 = 2; - -/// SVG's default miter limit: past it a miter degrades to a bevel, so a near -/// reversal in the data cannot grow a spike that is not in the data. -const MITER_LIMIT: f32 = 4.0; - -/// A stroke's cap and join, carried together because they are decided together -/// and always travel together — and because a stroke entry point already takes -/// six arguments without them. -#[derive(Clone, Copy)] -struct StrokeGeometry { - cap: u8, - join: u8, -} - -impl StrokeGeometry { - fn is_default(self) -> bool { - self.cap == CAP_ROUND && self.join == JOIN_ROUND - } -} #[inline] fn cov_from_sd(sd: f32) -> f32 { @@ -486,83 +467,10 @@ fn box_sd(p: (f32, f32), a: (f32, f32), b: (f32, f32), hw: f32) -> f32 { outside + along.max(across).min(0.0) } -/// Signed distance to the intersection of the half-planes bounded by each -/// directed edge of a convex polygon wound consistently. Exact on the edges and -/// conservative at the corners, which is all a 1px ramp needs — the corners of -/// a bevel or miter sit under the segment boxes anyway. -fn convex_sd(p: (f32, f32), poly: &[(f32, f32)]) -> f32 { - let mut sd = f32::NEG_INFINITY; - for i in 0..poly.len() { - let (a, b) = (poly[i], poly[(i + 1) % poly.len()]); - let (ex, ey) = (b.0 - a.0, b.1 - a.1); - let len = (ex * ex + ey * ey).sqrt(); - if len <= 1e-6 { - continue; - } - // Outward normal for clockwise winding in this frame; `join_shape` - // winds every polygon it builds the same way. - let (nx, ny) = (ey / len, -ex / len); - sd = sd.max((p.0 - a.0) * nx + (p.1 - a.1) * ny); - } - sd -} - -/// The filler for the notch two segment boxes leave on the outside of a turn. -/// Returns `None` for a straight or degenerate joint, which needs nothing. -fn join_shape( - prev: (f32, f32), - at: (f32, f32), - next: (f32, f32), - hw: f32, - join: u8, -) -> Option> { - let (d0x, d0y) = (at.0 - prev.0, at.1 - prev.1); - let (d1x, d1y) = (next.0 - at.0, next.1 - at.1); - let l0 = (d0x * d0x + d0y * d0y).sqrt(); - let l1 = (d1x * d1x + d1y * d1y).sqrt(); - if l0 <= 1e-6 || l1 <= 1e-6 { - return None; - } - let (d0x, d0y) = (d0x / l0, d0y / l0); - let (d1x, d1y) = (d1x / l1, d1y / l1); - let cross = d0x * d1y - d0y * d1x; - if cross.abs() <= 1e-6 { - return None; // collinear: the boxes already meet flush - } - // Outward side is the one the path turns away from. - let side = if cross > 0.0 { -1.0 } else { 1.0 }; - let n0 = (side * -d0y, side * d0x); - let n1 = (side * -d1y, side * d1x); - let c0 = (at.0 + n0.0 * hw, at.1 + n0.1 * hw); - let c1 = (at.0 + n1.0 * hw, at.1 + n1.1 * hw); - // Wind so `convex_sd`'s outward normals point away from the interior. - let wound = |a: (f32, f32), b: (f32, f32), c: (f32, f32)| -> bool { - (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0) > 0.0 - }; - let mut poly = vec![at, c0, c1]; - if join == JOIN_MITER { - let (sx, sy) = (n0.0 + n1.0, n0.1 + n1.1); - let slen = (sx * sx + sy * sy).sqrt(); - if slen > 1e-4 { - let (mx, my) = (sx / slen, sy / slen); - let mcos = mx * n0.0 + my * n0.1; - if mcos >= 1.0 / MITER_LIMIT { - let reach = hw / mcos; - poly = vec![at, c0, (at.0 + mx * reach, at.1 + my * reach), c1]; - } - } - } - if !wound(poly[0], poly[1], poly[2]) { - poly.reverse(); - } - Some(poly) -} - /// One coverage primitive of a shaped stroke, in the order they are painted. enum StrokePiece { Box(StrokeSegment), Disc((f32, f32)), - Convex(Vec<(f32, f32)>), } impl StrokePiece { @@ -582,7 +490,6 @@ impl StrokePiece { extend(*b); } StrokePiece::Disc(c) => extend(*c), - StrokePiece::Convex(poly) => poly.iter().copied().for_each(&mut extend), } ((x0 - pad, y0 - pad), (x1 + pad, y1 + pad)) } @@ -593,7 +500,6 @@ impl StrokePiece { StrokePiece::Disc(c) => { cov_from_sd(((p.0 - c.0).powi(2) + (p.1 - c.1).powi(2)).sqrt() - hw) } - StrokePiece::Convex(poly) => cov_from_sd(convex_sd(p, poly)), } } } @@ -653,9 +559,11 @@ fn stroke( stroke_with_threads(cv, pts, width, rgba, closed, dash, None); } -/// Stroke honoring an explicit cap and join. Round/round is XY's default and -/// is exactly what the capsule field above draws, so it delegates and the -/// common path keeps its banding, its scratch-buffer reuse, and its bytes. +/// Stroke honoring an explicit cap. `round` is XY's default and is exactly what +/// the capsule field above draws, so it delegates and the common path keeps its +/// banding, its scratch-buffer reuse, and its bytes. Interior vertices always +/// get a round join, which is what the capsule field produced before the cap +/// became selectable. fn stroke_shaped( cv: &mut Canvas, pts: &[(f32, f32)], @@ -663,13 +571,12 @@ fn stroke_shaped( rgba: [f32; 4], closed: bool, dash: &[f32], - geometry: StrokeGeometry, + cap: u8, ) { - if geometry.is_default() || pts.len() < 2 || width <= 0.0 { + if cap == CAP_ROUND || pts.len() < 2 || width <= 0.0 { stroke(cv, pts, width, rgba, closed, dash); return; } - let StrokeGeometry { cap, join } = geometry; let hw = width * 0.5; let dash: &[f32] = if usable_dash(dash) { dash } else { &[] }; let n = pts.len(); @@ -757,23 +664,14 @@ fn stroke_shaped( for pair in ends.windows(2) { pieces.push(StrokePiece::Box((pair[0], pair[1]))); } - for i in 1..run.len() - 1 { - match join { - JOIN_ROUND => pieces.push(StrokePiece::Disc(run[i])), - _ => { - if let Some(poly) = join_shape(run[i - 1], run[i], run[i + 1], hw, join) { - pieces.push(StrokePiece::Convex(poly)); - } - } - } + // Butt- and square-capped boxes meet edge-to-edge and leave a notch on + // the outside of every turn; a disc at the shared vertex is the round + // join that fills it. + for vertex in run.iter().take(run.len() - 1).skip(1) { + pieces.push(StrokePiece::Disc(*vertex)); } if closed && runs.len() == 1 && run.len() > 2 { - let joint = match join { - JOIN_ROUND => Some(StrokePiece::Disc(run[0])), - _ => join_shape(run[run.len() - 2], run[0], run[1], hw, join) - .map(StrokePiece::Convex), - }; - pieces.extend(joint); + pieces.push(StrokePiece::Disc(run[0])); } } paint_stroke_pieces(cv, &pieces, hw, rgba); @@ -2299,11 +2197,8 @@ fn rasterize_with_spans( for _ in 0..nd { dash.push(r.f32()?); } - let geometry = StrokeGeometry { - cap: r.u8()?, - join: r.u8()?, - }; - stroke_shaped(&mut cv, &pts, width, c, closed, &dash, geometry); + let cap = r.u8()?; + stroke_shaped(&mut cv, &pts, width, c, closed, &dash, cap); } OP_POINT => { let (cx, cy, rr) = (r.f32()?, r.f32()?, r.f32()?); @@ -2748,12 +2643,9 @@ fn rasterize_with_spans( for _ in 0..nd { dash.push(r.f32()?); } - let geometry = StrokeGeometry { - cap: r.u8()?, - join: r.u8()?, - }; + let cap = r.u8()?; let points = smooth_points(xs, ys, n, x_scale, y_scale); - stroke_shaped(&mut cv, &points, width, color, false, &dash, geometry); + stroke_shaped(&mut cv, &points, width, color, false, &dash, cap); } _ => return None, } @@ -2930,7 +2822,7 @@ mod tests { cmd.extend([0, 0, 0, 255]); cmd.push(0); // not closed cmd.extend(u32le(0)); // no dash - cmd.extend([CAP_ROUND, JOIN_ROUND]); + cmd.push(CAP_ROUND); let mut out = vec![0u8; 10 * 10 * 4]; assert!(rasterize_into(&cmd, 10, 10, &mut out)); assert!(px(&out, 10, 5, 5)[3] > 200); // on the line @@ -2954,7 +2846,7 @@ mod tests { cmd.extend([0, 0, 0, 255]); cmd.push(0); // not closed cmd.extend(u32le(0)); // no dash - cmd.extend([cap, JOIN_ROUND]); + cmd.push(cap); let mut out = vec![0u8; 40 * 40 * 4]; assert!(rasterize_into(&cmd, 40, 40, &mut out)); (0..40 * 40).filter(|i| out[i * 4 + 3] > 128).count() @@ -2977,7 +2869,7 @@ mod tests { cmd.extend([0, 0, 0, 255]); cmd.push(0); cmd.extend(u32le(0)); - cmd.extend([cap, JOIN_ROUND]); + cmd.push(cap); let mut out = vec![0u8; 40 * 40 * 4]; assert!(rasterize_into(&cmd, 40, 40, &mut out)); px(&out, 40, 32, 20)[3] @@ -2986,29 +2878,6 @@ mod tests { assert!(probe(CAP_SQUARE) > 200); } - /// SVG's miter limit is a ratio of miter length to stroke *width*: - /// `miterLength / strokeWidth = 1 / cos(phi/2)`, where `phi` is the turn - /// angle. So the join degrades to a bevel exactly when `cos(phi/2)` drops - /// below `1 / MITER_LIMIT`. This pins that threshold: halving it — guarding - /// on `1 / (2 * MITER_LIMIT)` — would admit ratio-8 spikes, twice what SVG - /// and PDF draw for the same path. - #[test] - fn miter_joins_bevel_past_svgs_four_to_one_limit() { - let corners = |phi_deg: f32| -> usize { - let phi = phi_deg.to_radians(); - let at = (0.0f32, 0.0f32); - let prev = (-10.0f32, 0.0f32); - let next = (10.0 * phi.cos(), 10.0 * phi.sin()); - join_shape(prev, at, next, 5.0, JOIN_MITER) - .expect("a turned joint has a shape") - .len() - }; - // ratio = 1/cos(phi/2): 90° -> 1.41, 150° -> 3.86, 155° -> 4.62. - assert_eq!(corners(90.0), 4, "a right angle miters (ratio 1.41)"); - assert_eq!(corners(150.0), 4, "ratio 3.86 is inside the 4:1 limit"); - assert_eq!(corners(155.0), 3, "ratio 4.62 degrades to a bevel"); - } - /// A dash pattern the public API cannot produce must not steer the walker. #[test] fn malformed_dash_patterns_fall_back_to_solid() { @@ -3020,38 +2889,6 @@ mod tests { assert!(!usable_dash(&[0.0, 4.0])); } - /// stroke-linejoin fills the notch on the outside of a turn. A miter - /// reaches furthest, a bevel cuts the corner off, and round is the disc - /// the capsule field already drew. - #[test] - fn stroke_joins_shape_the_corner() { - let corner = |join: u8| { - let mut cmd = vec![OP_STROKE]; - cmd.extend(u32le(3)); - for (x, y) in [(10.0f32, 10.0f32), (30.0, 10.0), (30.0, 30.0)] { - cmd.extend(f32le(x)); - cmd.extend(f32le(y)); - } - cmd.extend(f32le(10.0)); // width - cmd.extend([0, 0, 0, 255]); - cmd.push(0); - cmd.extend(u32le(0)); - cmd.extend([CAP_BUTT, join]); - let mut out = vec![0u8; 50 * 50 * 4]; - assert!(rasterize_into(&cmd, 50, 50, &mut out)); - // The outside of this right-angle turn is up and to the right of - // the vertex; the miter apex is the only shape that reaches it. - (px(&out, 50, 34, 6)[3], (0..50 * 50).filter(|i| out[i * 4 + 3] > 128).count()) - }; - let (miter_apex, miter_ink) = corner(JOIN_MITER); - let (bevel_apex, bevel_ink) = corner(JOIN_BEVEL); - let (_, round_ink) = corner(JOIN_ROUND); - - assert!(miter_apex > 200, "a miter fills its apex"); - assert_eq!(bevel_apex, 0, "a bevel cuts the apex off"); - assert!(bevel_ink < round_ink && round_ink < miter_ink); - } - #[test] fn prepared_segment_path_matches_float_coverage_reference() { let cases = [ @@ -3574,7 +3411,7 @@ mod tests { expanded.extend(stroke_color); expanded.push(1); // closed expanded.extend(u32le(0)); // no dash - expanded.extend([CAP_ROUND, JOIN_ROUND]); + expanded.push(CAP_ROUND); } for opaque in [false, true] { diff --git a/tests/test_capability_registry.py b/tests/test_capability_registry.py index 00cb90b4..690ab9ed 100644 --- a/tests/test_capability_registry.py +++ b/tests/test_capability_registry.py @@ -13,8 +13,6 @@ import sys from pathlib import Path -import pytest - from xy import styles from xy.dom import CHART_DOM_SLOTS from xy.styling import capabilities as caps @@ -40,17 +38,16 @@ def test_registry_covers_exactly_the_properties_styles_compiles() -> None: def test_planned_properties_are_exactly_the_ones_style_still_refuses() -> None: - # A `planned` row is a promise that the property is NOT accepted yet. If one - # ships without its status changing, the matrix would understate the library - # — which is the failure mode that is easy to miss. + # There are no planned rows today; the registry lists what ships. The guard + # stays so that if one is ever added, it has to actually be refused — + # a `planned` row the implementation quietly accepts would understate the + # library, which is the direction that is easy to miss. for prop in caps.MARK_STYLE_PROPERTIES: if prop.status != "planned": continue assert prop.id not in _compiled_property_names(), ( f"{prop.id!r} is marked planned but styles.py now compiles it" ) - with pytest.raises(ValueError, match="unsupported CSS property"): - styles.compile_mark_style("line", {prop.id: "miter"}) def test_registry_covers_exactly_the_public_dom_slots() -> None: diff --git a/tests/test_claim_guardrails.py b/tests/test_claim_guardrails.py index af99bc26..3ae5024b 100644 --- a/tests/test_claim_guardrails.py +++ b/tests/test_claim_guardrails.py @@ -155,10 +155,9 @@ def test_claim_guardrail_rejects_stale_repo_identity(tmp_path: Path) -> None: def test_claim_guardrail_rejects_customization_superlatives(tmp_path: Path) -> None: - # The "never" rung of the claim ladder in - # spec/api/customization-vs-alternatives.md. Plotly's attribute surface is - # ~3 orders of magnitude larger and Matplotlib's custom Artist is strictly - # more powerful, so no amount of shipping earns these sentences. + # The styling surface is a bounded, enumerated subset — see + # spec/api/capability-matrix.md — so these shapes are wrong however the + # sentence is framed, and no amount of shipping earns them. for line in ( "XY is the most customizable charting library.\n", "XY is fully customizable.\n", @@ -170,22 +169,6 @@ def test_claim_guardrail_rejects_customization_superlatives(tmp_path: Path) -> N assert any("customization superlative" in f.message for f in findings), line -def test_claim_guardrail_requires_a_dimension_on_customization_comparisons( - tmp_path: Path, -) -> None: - bare = _write(tmp_path, "XY is more customizable than Plotly.\n") - findings = check_claim_guardrails.check_claims([bare]) - assert any("comparative customization claim" in f.message for f in findings) - - qualified = _write( - tmp_path, - "XY resolves host CSS variables into mark paint in the browser, SVG, and\n" - "native renderers; on that dimension it is more themeable than Plotly, and\n" - "the capability matrix records it per property.\n", - ) - assert check_claim_guardrails.check_claims([qualified]) == [] - - def test_claim_guardrail_scans_the_readme(tmp_path: Path) -> None: # The README was outside the default set, which is where a slogan is most # likely to be written and least likely to be reviewed. diff --git a/tests/test_customization_comparison.py b/tests/test_customization_comparison.py deleted file mode 100644 index 2e5cc46e..00000000 --- a/tests/test_customization_comparison.py +++ /dev/null @@ -1,84 +0,0 @@ -"""The comparison matrix quotes numbers; those numbers have to still be true. - -`spec/benchmarks/methodology.md` bans numbers in prose that no artifact backs. -The customization comparison is prose by necessity — most of its rows are -capability questions, not measurements — so the parts that *are* countable are -checked here against the registry they came from. The rest is held to a -different standard: it must name its method, and it must keep its losses. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -from xy.styling import capabilities as caps - -DOC = Path(__file__).resolve().parents[1] / "spec" / "api" / "customization-vs-alternatives.md" - - -def _text() -> str: - return DOC.read_text(encoding="utf-8") - - -def test_the_xy_counts_match_the_registry() -> None: - text = _text() - counts = caps.summary() - - assert f"{counts['mark_style_properties_shipped']} shipped mark style properties" in text - assert f"{counts['chart_slots']} chrome slots" in text - # Added after a fresh-agent evaluation caught this line quoting 15 axis - # keys once a 16th had shipped. Every count the document states is pinned; - # an unpinned one is the drift this whole chain exists to prevent. - assert f"{counts['axis_style_keys']} axis keys" in text - - -def test_every_row_names_its_method() -> None: - # A row without a method is an assertion, and this document exists to not be - # a pile of assertions. Both tables end in a method column. - rows = [ - line - for line in _text().splitlines() - if line.startswith("| ") and "---" not in line and not line.startswith("| Question") - ] - matrix_rows = [r for r in rows if r.rstrip().endswith(("| schema |", "| code |", "| docs |"))] - assert len(matrix_rows) >= 12, "expected the win and loss tables to carry method columns" - - -def test_the_loss_table_is_not_empty_and_names_who_wins() -> None: - # The single most important property of this document. A matrix where XY - # wins every row is evidence of nothing, so an edit that empties the loss - # table has to fail rather than read as an improvement. - section = _text().split("## What XY is genuinely worse at", 1) - assert len(section) == 2, "the loss section must exist" - body = section[1].split("## ", 1)[0] - rows = [line for line in body.splitlines() if line.startswith("| ") and "---" not in line] - data_rows = [r for r in rows if not r.startswith("| Question")] - assert len(data_rows) >= 6, "the loss table must keep its rows" - assert all("**" in row for row in data_rows), "each loss must name the library that wins" - - -def test_pinned_competitor_versions_match_the_benchmark_constraints() -> None: - # Same rule as the benchmark harness: a comparison against an unpinned - # version is a comparison against nothing in particular. - text = _text() - pins = Path(__file__).resolve().parents[1] / "benchmarks" / "requirements-ci.in" - pinned = dict( - re.findall(r"^([a-z0-9-]+)==([0-9.]+)", pins.read_text(encoding="utf-8"), re.MULTILINE) - ) - for package, label in ( - ("plotly", "Plotly"), - ("bokeh", "Bokeh"), - ("matplotlib", "Matplotlib"), - ("altair", "Altair"), - ): - assert f"{label} {pinned[package]}" in text, ( - f"{label} is pinned at {pinned[package]} but the comparison quotes another version" - ) - - -def test_breadth_is_named_as_a_loss_not_a_win() -> None: - # The one claim shape most likely to creep in and least defensible. - text = _text() - assert "**Do not claim breadth.**" in text - assert "most customizable" in text.split("never defensible", 1)[1] diff --git a/tests/test_evaluation_skill.py b/tests/test_evaluation_skill.py deleted file mode 100644 index d801360b..00000000 --- a/tests/test_evaluation_skill.py +++ /dev/null @@ -1,69 +0,0 @@ -"""The retrieval path is only fixed while its pointers still point somewhere. - -`.agents/skills/xy-evaluate/SKILL.md` exists because an agent reading -`styles.py` alone reaches the wrong answer about this library. A skill full of -paths that have since moved reproduces exactly that failure, one level up, so -every path it names is checked here. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -SKILL = ROOT / ".agents" / "skills" / "xy-evaluate" / "SKILL.md" - - -def _body() -> str: - return SKILL.read_text(encoding="utf-8") - - -def test_every_path_the_skill_names_exists() -> None: - text = _body() - # Only rooted paths: bare file names like `styles.py` are prose, not links. - referenced = {name for name in re.findall(r"`([\w./-]+\.(?:py|md|json))`", text) if "/" in name} - missing = sorted(name for name in referenced if not (ROOT / name).exists()) - assert not missing, f"xy-evaluate points at paths that no longer exist: {missing}" - - -def test_the_skill_names_both_registries_and_the_guardrail() -> None: - text = _body() - for pointer in ( - "python/xy/styling/capabilities.py", - "spec/api/capability-matrix.md", - "spec/api/customization-vs-alternatives.md", - "spec/api/plotly-coverage.md", - "benchmarks/categories.py", - "scripts/check_claim_guardrails.py", - ): - assert pointer in text, f"xy-evaluate no longer points at {pointer}" - - -def test_every_count_the_skill_states_matches_its_registry() -> None: - # The skill shipped saying "15 axis keys" the day after a 16th landed — - # stale drift inside the document whose whole job is to stop stale drift. - # Prose cannot hold a number; a test can. - from xy.styling import capabilities as caps - - counts = caps.summary() - # Collapse wrapping so a reflowed paragraph does not read as a changed count. - text = re.sub(r"\s+", " ", _body()) - assert f"{counts['axis_style_keys']} axis keys" in text - assert f"{counts['chart_slots']} CSS-addressable chrome slots" in text - assert f"{counts['mark_style_properties_shipped']} mark style properties" in text - - -def test_the_skill_states_the_failure_it_exists_to_prevent() -> None: - # Without this the skill reads as a link list, and an agent skims it. - text = _body().lower() - assert "styles.py" in text - assert "most customizable" in text - - -def test_the_skill_matches_claude_mds_opening_pointers() -> None: - # Two entry points, one answer. An agent inside the checkout reads - # CLAUDE.md; one outside reads the skill. - instructions = (ROOT / "CLAUDE.md").read_text(encoding="utf-8") - for pointer in ("python/xy/styling/capabilities.py", "benchmarks/categories.py"): - assert pointer in instructions, f"CLAUDE.md no longer points at {pointer}" diff --git a/tests/test_plotly_schema_coverage.py b/tests/test_plotly_schema_coverage.py deleted file mode 100644 index a26a2659..00000000 --- a/tests/test_plotly_schema_coverage.py +++ /dev/null @@ -1,139 +0,0 @@ -"""The Plotly coverage number has to be reproducible, and honest about its tail. - -§24 asks for compat to be "a measured number". A measured number that anyone -can inflate by widening a rule without noticing is not one, so these tests hold -the properties that make the number mean something: it is regenerated rather -than hand-written, its `unclassified` residue is reported rather than absorbed, -and the classifier stays deterministic. - -The suite skips when plotly is absent — it lives in the `bench` extra, and the -committed report is what a reader without it consults. -""" - -from __future__ import annotations - -import importlib.util -import subprocess -import sys -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[1] -SCRIPT = ROOT / "scripts" / "plotly_schema_coverage.py" -REPORT = ROOT / "spec" / "api" / "plotly-coverage.md" - -pytestmark = pytest.mark.skipif( - importlib.util.find_spec("plotly") is None, - reason="plotly is in the bench extra; the committed report stands in without it", -) - - -def _module(): - spec = importlib.util.spec_from_file_location("plotly_schema_coverage", SCRIPT) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -def test_the_committed_report_is_regenerated_not_hand_edited() -> None: - result = subprocess.run( - [sys.executable, str(SCRIPT), "--check"], capture_output=True, text=True - ) - assert result.returncode == 0, result.stdout + result.stderr - - -def test_src_companions_and_namespaces_are_out_of_the_denominator() -> None: - # `marker.colorsrc` is not a second styling decision beside `marker.color`, - # and a compound node is a namespace rather than a knob. Counting either - # would pad the denominator in XY's favour. - module = _module() - schema, _ = module.load_schema() - paths = module.attribute_paths(schema) - assert not any(path.endswith("src") for path in paths) - assert all( - schema[path]["superclass"] not in {"CompoundValidator", "CompoundArrayValidator"} - for path in paths - ) - - -def test_unclassified_is_reported_rather_than_absorbed() -> None: - # The residue is large and shrinking it is the work. Folding it into a - # friendlier bucket would make the table look better and mean less. - module = _module() - schema, version = module.load_schema() - _, summary = module.build_report(schema, version) - assert summary["unsupported_reasons"]["unclassified"] > 0 - assert "unclassified" in REPORT.read_text(encoding="utf-8") - - -def test_every_verdict_is_one_of_the_three_section_24_names() -> None: - module = _module() - schema, version = module.load_schema() - _, summary = module.build_report(schema, version) - assert set(summary["verdicts"]) <= { - module.SUPPORTED, - module.MAPPED, - module.UNSUPPORTED, - } - - -def test_classification_is_deterministic_and_first_rule_wins() -> None: - module = _module() - # A path matched by a specific rule must not fall through to a general one. - verdict, surface, _ = module.classify("scatter.marker.symbol") - assert verdict == module.SUPPORTED and "marker-shape" in surface - # A trace XY does not implement is unsupported regardless of the attribute. - verdict, _, reason = module.classify("scatter3d.marker.symbol") - assert verdict == module.UNSUPPORTED and "no such trace" in reason - - -def test_the_scoped_number_is_reported_next_to_the_whole_schema_one() -> None: - # Quoting 3.6% without its scope understates; quoting 10.2% without its - # scope overstates. The report has to carry both. - text = REPORT.read_text(encoding="utf-8") - assert "## Whole schema" in text - assert "## Scoped to the trace types XY implements" in text - assert "must be quoted *with* its scope" in text - - -def test_excluded_subtrees_are_not_stolen_by_the_wildcard_axis_rules() -> None: - # `fnmatch` lets `*` span dots and the first matching rule wins, so - # `layout.*axis.showgrid` will happily claim `layout.polar.angularaxis. - # showgrid` unless the exclusions are ordered ahead of it. That ordering is - # load-bearing: getting it wrong silently counted 3-D, geo, polar, ternary, - # and smith axes as supported and overstated the headline number by 127 - # attributes. This pins the ordering, not the prose about it. - module = _module() - for path in ( - "layout.scene.xaxis.title.text", - "layout.scene.yaxis.showgrid", - "layout.polar.angularaxis.showgrid", - "layout.polar.radialaxis.range", - "layout.geo.lataxis.showgrid", - "layout.ternary.aaxis.ticks", - "layout.smith.realaxis.linecolor", - ): - verdict, _, _ = module.classify(path) - assert verdict == module.UNSUPPORTED, f"{path} is inside an excluded subtree" - - # The genuine top-level axes still resolve. - for path in ("layout.xaxis.showgrid", "layout.yaxis.type", "layout.xaxis.range"): - verdict, surface, _ = module.classify(path) - assert verdict == module.SUPPORTED and surface, path - - -def test_the_unsupported_reason_table_accounts_for_every_attribute() -> None: - # A table whose rows trail off short of the total implies accounting it does - # not do. The generated report folds the tail into one row and prints the - # total, so the column sums. - module = _module() - schema, version = module.load_schema() - _, summary = module.build_report(schema, version) - assert sum(summary["unsupported_reasons"].values()) == summary["verdicts"]["unsupported"] - - text = REPORT.read_text(encoding="utf-8") - assert "all other reasons" in text - assert f"**{summary['verdicts']['unsupported']:,}**" in text From d27f979fb6c9983593f0af0dd4b97e1492063f6a Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 08:04:38 +0000 Subject: [PATCH 14/15] Revert the CHANGELOG edits The changelog is not mine to write. Restored to the branch point, so this branch no longer touches it at all. --- CHANGELOG.md | 42 ------------------------------------------ 1 file changed, 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8eeefa..b64223e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,33 +9,6 @@ in the README). ## [Unreleased] ### Added -- **A capability inventory** at `python/xy/styling/capabilities.py`: one entry - per mark style property and per chrome slot, each carrying its support level - in the WebGL client, the SVG writer, and the native rasterizer, plus the - extension points and the renderer differences that no property selects. - `scripts/gen_capability_matrix.py` generates `spec/api/capability-matrix.md` - and the public Capability Matrix page from it, and the test suite fails if - either is stale or if the inventory falls out of step with what `styles.py` - actually compiles. -- **Mark plugins**: `xy.register_mark(xy.MarkPlugin(...))` adds a chart kind XY - does not ship, and `xy.mark("name", ...)` uses it. A plugin supplies a `calc` - over its declared columns and a `build` that returns *built-in* marks — the - composition half of dossier §24, with the custom-shader half deferred. Its - output is ordinary traces, so it reuses the built-in rendering, picking, and - export paths rather than reimplementing them; `build` never sees the - `Figure`, the trace list, or the column store, so it cannot draw anything the - engine could not already draw. Declared columns arrive as canonical f64. The - registry refuses to shadow a built-in kind, refuses to silently replace - another plugin, and rejects column names `xy.mark()` binds itself. -- Mark `style=` accepts **`stroke-linecap`** (`butt`/`round`/`square`) on the - line family, with the standard SVG semantics: it shapes the polyline's two - ends and each dash end. Only the line family takes it, because it describes - stroked open-path geometry. XY's default is `round`, not CSS's `butt`, so - existing specs are byte-identical. Joins are always round and are not - selectable. -- Mark `style=` accepts **`marker-shape`** on `scatter`, the CSS spelling of the - existing `symbol=` argument. Both resolve to the same trace-style value, so - the two spellings build identical specs. - `colormap=` accepts a **custom ramp** built from your own colors, not only one of the twenty built-in names: a sequence of 2–256 CSS colors, `(position, color)` pairs, or a CSS `linear-gradient(...)`. Every form resolves once, in @@ -63,22 +36,7 @@ in the README). same validated style properties, so an explicit `style=` still wins and specs that don't use them are byte-identical. -### Changed -- `scripts/check_claim_guardrails.py` now also rejects unqualifiable - superlatives about the styling surface, and scans `README.md` and `CLAUDE.md`, - which it previously did not. The styling surface is a bounded, enumerated - subset, so those shapes are wrong however they are framed. Two live overclaims - in `docs/index.md` were caught by the new rule and rewritten. - ### Fixed -- The three mark renderers disagreed about line caps and never said so: the - native rasterizer capped round, the WebGL client capped butt with a - half-pixel bleed, and the SVG writer hardcoded `round` on line paths while - the area outline silently inherited SVG's `butt` — which the PDF exporter - then read back as `butt` too. All three now draw XY's documented `round` - default. The SVG writer also names the join on every stroked path instead of - letting the format's `miter` default through, which `_pdf` had been reading - back as a mismatch with the rasterizer. - The colorbar stringified its colormap, so a custom ramp reached it as an unparseable name and silently painted viridis while the marks beside it painted the ramp correctly. From 8b95435d3e73ed42e0a366888aff1ef0bb46c4d3 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Sun, 26 Jul 2026 08:11:22 +0000 Subject: [PATCH 15/15] Restore CLAUDE.md to its state on main The contributor contract is not this PR's to edit. Reverting the capability-registry pointer, the opening-paragraph mention, and the customization-claims invariant leaves CLAUDE.md byte-identical to the merge base; the guardrail script and the registry still ship. --- CLAUDE.md | 33 +++++---------------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1a117342..f3baeb12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,18 +4,6 @@ A high-performance charting engine. The authoritative design is `spec/design-dossier.md` — **read the relevant § before changing behavior**; code comments cite dossier sections (e.g. §16 = deep-zoom re-centering). -**Two registries answer questions that are easy to get wrong by reading one -file.** - -- *How fast is it, and on what?* → `benchmarks/categories.py` and - `spec/benchmarks/results.md`. Quote a row; never invent a number. -- *What can be styled, and where does the styling stop?* → - `python/xy/styling/capabilities.py`, the per-renderer support inventory that - `spec/api/capability-matrix.md` is generated from. `python/xy/styles.py` - lists the mark properties and looks like the whole answer; the inventory also - covers the 23 chrome slots, what survives each export path, the extension - points, and where the three renderers still differ. - The entire `spec/` directory is the source of truth for intended behavior, architecture, compatibility, benchmarks, release readiness, and contributor contracts. Keep it current with every relevant code, configuration, build, and @@ -72,12 +60,8 @@ instead of treating the implementation alone as authoritative. widget, HTML export, and tests have the bundles on disk. npm devDependencies (vite/typescript/playwright, pinned in `package-lock.json`) are build/test-time only — the shipped client stays runtime-dependency-free. -- `tests/`, `benchmarks/bench.py` (§12 harness; the harnesses live in - `benchmarks/`, not `scripts/`), `scripts/render_smoke_nonumpy.py` (headless - Chromium pixel probe). -- `python/xy/styling/capabilities.py` — the customization registry (above). - `scripts/gen_capability_matrix.py --write` regenerates the committed matrix; - the suite fails if it is stale or out of step with `styles.py`. +- `tests/`, `scripts/bench.py` (§12 harness), `scripts/smoke_render.py` + (headless Chromium pixel probe). ## Commands @@ -93,10 +77,9 @@ uv pip install -e "python/reflex-xy[dev]" # enables tests/reflex_adapter (insta uv run pytest # native core required (no fallback) python3 scripts/reflex_ws_smoke.py # browser E2E vs a running reflex-xy demo app uv run ruff check . && uv run ruff format . && uv run ty check -uv run python benchmarks/bench.py # §12 benchmark harness +uv run python scripts/bench.py # §12 benchmark harness python3 scripts/bench_scatter_native.py --render # xy scatter, no deps -uv run python benchmarks/bench_vs.py # three-way vs plotly/matplotlib (needs both) -uv run python scripts/gen_capability_matrix.py --check # capability matrix currency +uv run python scripts/bench_vs.py # three-way vs plotly/matplotlib (needs both) ``` Before every commit or push, run the repository hooks and Ruff checks across the @@ -125,10 +108,4 @@ PRs, or code. Set `git config user.name/user.email` to the human author - f32 uploads are offset-encoded; tick/hover math stays f64 (§4/§16). - Every decimation/tier decision is recorded in the spec, never silent (§28). - Claims are mode-scoped and benchmarked (§2); update README numbers from - `benchmarks/bench.py`, don't invent them. -- A mark style property no renderer set can agree on is not accepted by - `style=` — two renderers honoring what a third ignores is the failure - `styles.py` exists to prevent. The styling surface is a bounded, enumerated - subset, so superlatives about it are never defensible however they are - qualified; `scripts/check_claim_guardrails.py` rejects them mechanically - alongside the performance ones. + `scripts/bench.py`, don't invent them.