Skip to content

Mark style capabilities: stroke-linecap, marker-shape, mark plugins, and a support inventory - #310

Merged
Alek99 merged 18 commits into
mainfrom
claude/customization-capability-matrix-vckjxg
Jul 26, 2026
Merged

Mark style capabilities: stroke-linecap, marker-shape, mark plugins, and a support inventory#310
Alek99 merged 18 commits into
mainfrom
claude/customization-capability-matrix-vckjxg

Conversation

@Alek99

@Alek99 Alek99 commented Jul 26, 2026

Copy link
Copy Markdown
Member

New customization capabilities, plus an honest inventory of which renderer draws what and how far each survives export.

The rendering fix, shown

Line caps disagreed across the three renderers and none of them said so. Same chart, same 18px stroke, zoomed on the polyline's left end, no style property set — this is the default:

Line caps now agree across renderers

The area outline had the same bug one level quieter: 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.

The area outline had the same bug

Both figures come from checking the pre-change tree into a worktree, building its Rust core and its client, and running the same figure through both — the only difference between columns is the change. Two facts fall out of the frames:

  • Native raster output is byte-identical before and after (np.array_equal). stroke_shaped delegates to the original stroke for a round cap, so nothing that already renders moves.
  • Browser output changed in exactly 1024 pixels, in two clusters at the polyline's two ends. That is the entire diff.

What ships

stroke-linecapbutt/round/square on the line family, drawn identically by WebGL, SVG, and the native rasterizer.

The three stroke-linecap values

marker-shape — the CSS spelling of symbol=. Nearly free: all three renderers already drew 17 symbols and only the spelling was missing. Both forms build identical specs.

Mark pluginsxy.register_mark(xy.MarkPlugin(...)) adds a chart kind xy does not ship; xy.mark("name", ...) uses it. A plugin supplies a calc over declared columns and a build returning built-in marks. Because its output is ordinary traces 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. 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.

Export-styling contractcustom_css was the only asymmetry the docs named, and it was already bounded: it raises. The unbounded one went unmentioned — class_names and per-slot styles are read only by the browser client, and the SVG writer emits no class at all, so a Tailwind-styled chart exports to PNG with none of it applied and no diagnostic. Now contracted in spec/api/export.md §9, the chrome-slots guide, and the limitations page, with tests/test_export_style_survival.py asserting the two native writers agree with each other and not merely with the page.

Capability inventorypython/xy/styling/capabilities.py, one entry per style property and chrome slot with its per-renderer support, generated into spec/api/capability-matrix.md and a public page. tests/test_capability_registry.py pins it to cover exactly CHART_DOM_SLOTS and exactly what styles.py compiles; which mark kinds accept a property is derived from styles.py, never typed out. I confirmed the guard bites by adding a fake property and watching the suite go red.

Deliberately not here

stroke-linejoin is not selectable. Joins are always round — what the capsule distance field produced before caps became selectable. The WebGL client draws polylines as one instanced quad per segment with no join geometry, so a selectable join would be honored by two renderers and ignored by one. The native miter/bevel implementation and its wire byte are removed rather than shipped dark.

The SVG half stays, because it fixed a real defect: every stroked path now names stroke-linejoin="round" instead of letting the format's miter default through, which _pdf had been reading back as a mismatch with the rasterizer.

Notes

ABI_VERSION 41 → 42: OP_STROKE and OP_SMOOTH_STROKE carry one extra cap byte.

scripts/check_claim_guardrails.py gains a plain ban on unqualifiable superlatives about the styling surface — "most customizable", "fully customizable", "style anything" — and now scans README.md and CLAUDE.md, which it previously did not. It caught two live overclaims in docs/index.md on its first run; both are rewritten.

Verification

cargo test 119 passed · cargo clippy -D warnings clean · uv run pytest 2351 passed, 69 skipped · docs 91 passed · ruff, guardrails, generator --check, public-API, abi_smoke 133 checks, render smoke, append-stream smoke — all clean. No conflicts against main at 8edf1d0.

Summary by CodeRabbit

  • New Features
    • Added mark plugins, allowing custom marks to compose existing built-in marks.
    • Added stroke-linecap styling for line marks and marker-shape styling for scatter marks across supported renderers.
  • Documentation
    • Added a capability matrix covering styling support, chart slots, renderers, and export behavior.
    • Added Custom Marks guidance, styling examples, and clearer export limitations.
  • Bug Fixes
    • Improved line-cap rendering consistency across browser, SVG, and raster outputs.
    • Corrected SVG and raster output to honor configured stroke caps and marker shapes.

Alek99 added 13 commits July 26, 2026 01:44
`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.
`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.
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.
`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.
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.
§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.
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.
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.
…ity-registry' into claude/customization-capability-matrix-vckjxg-5-comparison-matrix
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.
…son-matrix' into claude/customization-capability-matrix-vckjxg-6-plotly-schema-coverage
…schema-coverage' into claude/customization-capability-matrix-vckjxg-7-retrieval-path
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.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The pull request adds a generated styling capability matrix, composition-based mark plugins, CSS support for line caps and scatter marker shapes, renderer-specific stroke handling, export-style contracts, documentation updates, and associated validation and packaging checks.

Changes

Capability registry and documentation

Layer / File(s) Summary
Capability registry and generated reports
python/xy/styling/*, scripts/gen_capability_matrix.py, tests/test_capability_registry.py, spec/api/capability-matrix.md
Adds implementation-derived capability registries, generated spec and public matrices, summary output, freshness checks, and sdist requirements.
Styling and export contracts
docs/styling/*, spec/api/styling.md, spec/api/export.md, docs/api-reference/*, docs/app/xy_docs/config.py
Documents supported style properties, renderer support, chrome slots, export survival, extension points, and new documentation routes.
Claim guardrails and repository guidance
CLAUDE.md, README.md, scripts/check_claim_guardrails.py, tests/test_claim_guardrails.py
Adds capability-grounded documentation guidance and rejects unqualified customization superlatives, including in README scans.

Composition mark plugins

Layer / File(s) Summary
Plugin contract and registry
python/xy/plugins.py, python/xy/__init__.py
Adds MarkContext, MarkPlugin, registry operations, validation rules, and lazy top-level exports.
Plugin mark construction and compilation
python/xy/components.py, tests/test_mark_plugins.py
Adds xy.mark, resolves plugin columns and calculated data, restricts output to built-in marks, forwards options, preserves axes and styles, and validates plugin behavior.
Custom mark documentation
docs/advanced/custom-marks.md, spec/api/chart-kind-contract.md, spec/design-dossier.md
Documents the shipped composition model, plugin constraints, registration rules, and the boundary between composition and new primitives.

Stroke and marker rendering

Layer / File(s) Summary
Style contract and mark propagation
python/xy/styles.py, python/xy/marks.py, tests/test_css_mark_styles.py
Adds validated stroke-linecap and marker-shape style properties, propagates them through marks, and tests defaults, rejection rules, SVG output, and raster differences.
WebGL line-cap pipeline
js/src/40_gl.ts, js/src/50_chartview.ts, tests/test_static_client_security.py
Adds cap uniforms, cap-aware vertex and fragment coverage, dash alignment, and updated shader invariants.
Native stroke geometry and export writers
python/xy/_raster.py, src/raster.rs, python/xy/_svg.py, python/xy/_native.py
Encodes cap values in raster commands, rasterizes cap-aware stroke pieces with dash validation, emits SVG cap and join attributes, and synchronizes the native ABI version.
Renderer and export validation
tests/test_export_style_survival.py
Tests browser versus native slot styling, legend’s native channel, custom_css errors, and raster equivalence when per-slot styling is dropped.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • reflex-dev/xy#302: Overlaps with the line-cap and marker-shape styling changes across the shader, client, Python renderer, ABI, and test paths.

Suggested reviewers: farhanaliraza, carlosabadia

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.93% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: stroke-linecap, marker-shape, mark plugins, and the new support inventory.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/customization-capability-matrix-vckjxg

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

@codspeed-hq

codspeed-hq Bot commented Jul 26, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 103 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/customization-capability-matrix-vckjxg (8b95435) with main (8edf1d0)

Open in CodSpeed

Footnotes

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

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 `<!-- generated by -->` 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
scripts/check_claim_guardrails.py (1)

18-29: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add the remaining customization docs to DEFAULT_DOCS. docs/styling/customize.md is already covered by the recursive docs/ scan, but spec/api/customization-vs-alternatives.md, CLAUDE.md, and .agents/skills/xy-evaluate/SKILL.md are still skipped, so edits there can bypass uv run python scripts/check_claim_guardrails.py. spec/api/customization-vs-alternatives.md also needs a small wording/policy tweak before it can be included as-is.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_claim_guardrails.py` around lines 18 - 29, Add the three
omitted documentation paths to DEFAULT_DOCS in the guardrail script:
spec/api/customization-vs-alternatives.md, CLAUDE.md, and
.agents/skills/xy-evaluate/SKILL.md. Apply the required wording/policy
adjustment in spec/api/customization-vs-alternatives.md before including it so
the guardrail check passes.
🧹 Nitpick comments (3)
python/xy/_raster.py (1)

1068-1074: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Unknown linecap/linejoin in a spec raises KeyError from the display-list writer.

_emit_line forwards whatever string the spec carries, and _Cmd.stroke indexes _CAP_CODES[cap] directly. Values produced by compile_mark_style are validated, so this is unreachable today, but render_raster takes a plain spec dict — a hand-built or round-tripped one with a stray value fails with a bare KeyError inside the byte packer rather than at the boundary. Falling back to the documented default keeps the writer total.

♻️ Fall back to the round default
-    cap = str(style.get("linecap", "round"))
-    join = str(style.get("linejoin", "round"))
+    cap = str(style.get("linecap", "round"))
+    join = str(style.get("linejoin", "round"))
+    if cap not in _CAP_CODES:
+        cap = "round"
+    if join not in _JOIN_CODES:
+        join = "round"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/xy/_raster.py` around lines 1068 - 1074, Validate the cap and join
values in _emit_line before passing them to cmd.smooth_stroke or cmd.stroke,
falling back independently to the documented "round" default when either value
is not supported. Preserve valid style values and ensure the display-list writer
never receives unknown linecap or linejoin strings.
scripts/gen_capability_matrix.py (1)

46-49: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Hardcoded "drawn by all three renderers" claim isn't derived from the registry.

Both render() and render_public() assert every shipped mark style property is drawn identically by all three renderers, but this is a fixed string, not something checked against prop.support. If a future shipped property lands with partial support in one renderer, this line would quietly overclaim — the exact failure mode tests/test_capability_registry.py was built to prevent elsewhere.

♻️ Proposed guard
 def render() -> str:
     counts = caps.summary()
+    shipped = [p for p in caps.MARK_STYLE_PROPERTIES if p.status == "shipped"]
+    assert all(v == "full" for p in shipped for v in p.support.values()), (
+        "a shipped property is not full in every renderer; update the summary prose"
+    )
     lines = [

Also applies to: 150-152

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/gen_capability_matrix.py` around lines 46 - 49, Update the
capability-matrix summary generated by render() and render_public() so the
“drawn by all three renderers” wording is emitted only when the registry
confirms every shipped mark style property has full support across all renderers
via prop.support. Derive the claim from the existing capability data rather than
hardcoding it, and preserve the current output for the fully supported case.
python/xy/styling/capabilities.py (1)

217-227: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a dataclass for KNOWN_RENDERER_DIVERGENCES, for parity with the rest of the registry.

Every other registry entry (MarkStyleProperty, SlotCapability, ExtensionPoint) is a frozen dataclass with typed fields; this one is a bare dict[str, str], so a typo in a key (e.g. "webgl" vs "webGL") would silently fail to render instead of raising.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/xy/styling/capabilities.py` around lines 217 - 227, The
KNOWN_RENDERER_DIVERGENCES registry should use a frozen typed dataclass instead
of unvalidated dictionaries. Define a dataclass with fields for id, what, webgl,
svg, native, visible_when, and tracked_by, then update the registry entry to
instantiate that dataclass while preserving its existing values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.agents/skills/xy-evaluate/SKILL.md:
- Around line 14-19: Update the quoted axis-key count in the evaluation guidance
to 16, matching the authoritative axis_style_keys() registry and current
customization comparison. Preserve the surrounding customization-surface
description and instruct agents to consult authoritative registries before
stating counts.

In `@docs/styling/customize.md`:
- Around line 98-99: Update the styling documentation sentence to explicitly
list line, step, stairs, and ecdf as the marks supporting stroke-linecap; keep
area scoped to stroke-dasharray only, and preserve the existing border-radius
and scatter marker-shape statements.

In `@python/xy/_svg.py`:
- Around line 1657-1663: In the path construction within the marks-append logic,
remove the redundant f-string prefix from the literal containing fill="none";
keep the existing concatenation and _cap_join_attrs(style) behavior unchanged.

In `@python/xy/components.py`:
- Around line 5203-5210: Update _plugin_column so numeric plugin-derived arrays
are converted to contiguous CPU-side float64 before returning, including values
already provided as NumPy arrays. Preserve non-numeric columns without coercion
and retain the existing handling for None and conversion failures.

In `@python/xy/plugins.py`:
- Around line 119-122: Update plugin registration around the duplicate-column
validation before assigning to _REGISTRY so columns using xy.mark()
control-field names—data, name, style, class_name, key, animation, x_axis, or
y_axis—are rejected with a ValueError. Preserve the existing duplicate detection
and only register plugins whose declared columns do not conflict with mark()
arguments.

In `@scripts/plotly_schema_coverage.py`:
- Around line 100-112: Reorder the rules in the coverage definitions so the
excluded layout-root patterns from the block around lines 128-133 are evaluated
before the generic layout.*axis rules. Update the ordering used by classify()
while preserving each rule’s existing classification, ensuring scene and polar
axis properties are marked unsupported before wildcard supported rules can match
them.

In `@spec/api/plotly-coverage.md`:
- Around line 29-47: The “Why attributes are unsupported” table in the coverage
report does not account for the full unsupported total. Update the report
generation or table content to include an “other” row covering the remaining
3,994 attributes, or explicitly label the section as showing selected/top
reasons; keep customization and benchmark claims scoped to authoritative,
measured registries.

In `@src/raster.rs`:
- Around line 575-583: Remove the stray space in the tuple pattern of the
StrokePiece::Box arm within coverage, formatting it as (a, b) so the code passes
cargo fmt --check.
- Around line 666-704: Validate every dash value as finite and strictly positive
before the shared dash-walking logic used by stroke_shaped and
stroke_with_threads; reject or bail out on any invalid entry, including
mixed-sign patterns, before computing or entering the loop. Preserve normal
processing for valid dash lists and ensure no negative step can reach the
walker.
- Around line 446-448: Update the miter join guard in the raster join logic to
compare against 1.0 / (2.0 * MITER_LIMIT) instead of 1.0 / MITER_LIMIT,
preserving the SVG/PDF-compatible 4:1 miter behavior for sharp corners.

In `@tests/test_customization_comparison.py`:
- Around line 61-72: Extend
test_pinned_competitor_versions_match_the_benchmark_constraints to include
Altair in the package-to-label checks, using the pinned Altair version from
benchmarks/requirements-ci.in and matching the documented “Altair” comparison
text alongside Plotly, Bokeh, and Matplotlib.

In `@tests/test_export_style_survival.py`:
- Around line 73-83: Extend test_custom_css_is_refused_by_every_native_path to
invoke PDF export with custom_css for the applicable native engines, asserting
the same ValueError rejection pattern used for SVG. Keep the existing PNG and
SVG assertions unchanged, and ensure PDF is covered before treating all native
paths as tested.

---

Outside diff comments:
In `@scripts/check_claim_guardrails.py`:
- Around line 18-29: Add the three omitted documentation paths to DEFAULT_DOCS
in the guardrail script: spec/api/customization-vs-alternatives.md, CLAUDE.md,
and .agents/skills/xy-evaluate/SKILL.md. Apply the required wording/policy
adjustment in spec/api/customization-vs-alternatives.md before including it so
the guardrail check passes.

---

Nitpick comments:
In `@python/xy/_raster.py`:
- Around line 1068-1074: Validate the cap and join values in _emit_line before
passing them to cmd.smooth_stroke or cmd.stroke, falling back independently to
the documented "round" default when either value is not supported. Preserve
valid style values and ensure the display-list writer never receives unknown
linecap or linejoin strings.

In `@python/xy/styling/capabilities.py`:
- Around line 217-227: The KNOWN_RENDERER_DIVERGENCES registry should use a
frozen typed dataclass instead of unvalidated dictionaries. Define a dataclass
with fields for id, what, webgl, svg, native, visible_when, and tracked_by, then
update the registry entry to instantiate that dataclass while preserving its
existing values.

In `@scripts/gen_capability_matrix.py`:
- Around line 46-49: Update the capability-matrix summary generated by render()
and render_public() so the “drawn by all three renderers” wording is emitted
only when the registry confirms every shipped mark style property has full
support across all renderers via prop.support. Derive the claim from the
existing capability data rather than hardcoding it, and preserve the current
output for the fully supported case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: eab77b7c-0f22-4ee0-8316-24c86de700c2

📥 Commits

Reviewing files that changed from the base of the PR and between 62462ec and ee6c94c.

⛔ Files ignored due to path filters (3)
  • spec/assets/area-outline-cap-before-after.png is excluded by !**/*.png
  • spec/assets/linecap-cross-renderer-before-after.png is excluded by !**/*.png
  • spec/assets/linecap-values.png is excluded by !**/*.png
📒 Files selected for processing (48)
  • .agents/skills/xy-evaluate/SKILL.md
  • CHANGELOG.md
  • CLAUDE.md
  • README.md
  • docs/advanced/custom-marks.md
  • docs/api-reference/limitations-and-alpha-status.md
  • docs/app/xy_docs/config.py
  • docs/index.md
  • docs/styling/capabilities.md
  • docs/styling/chrome-slots.md
  • docs/styling/customize.md
  • docs/styling/mark-styles.md
  • js/src/40_gl.ts
  • js/src/50_chartview.ts
  • python/xy/__init__.py
  • python/xy/_native.py
  • python/xy/_raster.py
  • python/xy/_svg.py
  • python/xy/components.py
  • python/xy/marks.py
  • python/xy/plugins.py
  • python/xy/styles.py
  • python/xy/styling/__init__.py
  • python/xy/styling/capabilities.py
  • scripts/check_claim_guardrails.py
  • scripts/gen_capability_matrix.py
  • scripts/plotly_schema_coverage.py
  • scripts/verify_local.py
  • scripts/verify_sdist.py
  • spec/README.md
  • spec/api/capability-matrix.md
  • spec/api/chart-kind-contract.md
  • spec/api/customization-vs-alternatives.md
  • spec/api/export.md
  • spec/api/plotly-coverage.md
  • spec/api/styling.md
  • spec/design-dossier.md
  • src/lib.rs
  • src/raster.rs
  • tests/test_capability_registry.py
  • tests/test_claim_guardrails.py
  • tests/test_css_mark_styles.py
  • tests/test_customization_comparison.py
  • tests/test_evaluation_skill.py
  • tests/test_export_style_survival.py
  • tests/test_mark_plugins.py
  • tests/test_plotly_schema_coverage.py
  • tests/test_static_client_security.py

Comment thread .agents/skills/xy-evaluate/SKILL.md Outdated
Comment thread docs/styling/customize.md Outdated
Comment thread python/xy/_svg.py
Comment thread python/xy/components.py
Comment thread python/xy/plugins.py
Comment thread src/raster.rs Outdated
Comment on lines +446 to +448
/// 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

SVG stroke-miterlimit definition miter length divided by stroke-width ratio

💡 Result:

In SVG, the stroke-miterlimit attribute defines the maximum allowable ratio between the miter length and the stroke-width [1][2]. The miter length is the distance from the intersection of the path segments (the inner corner) to the outer tip of the miter join [1][3]. When two line segments meet at a sharp angle, the resulting miter can extend significantly beyond the thickness of the stroke [1][4]. The stroke-miterlimit ensures that if this extension becomes too long, the join is converted to a bevel (or, in the case of miter-clip, truncated) to prevent excessively long, sharp points [1][5][4]. The ratio of the miter length to the stroke-width is mathematically related to the angle theta between the segments by the following formula [1][2]: miterLength / stroke-width = 1 / sin(theta / 2) If the calculated ratio exceeds the specified stroke-miterlimit, the join will be converted from a miter to a bevel [1][6]. Key details include: - The value must be a real number greater than or equal to 1 [1][4]. - The default value is 4 [1][2]. - It only applies when the stroke-linejoin property is set to miter (or other miter-based joins like miter-clip or arcs) [7][4][6]. - When both a presentation attribute and a CSS property are defined for stroke-miterlimit, the CSS property takes priority [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant stroke-join logic in src/raster.rs.
sed -n '520,545p' src/raster.rs

printf '\n---\n'

# Look for any public surface that exposes stroke-linejoin / miter behavior.
rg -n "stroke-linejoin|miter|JOIN_|CAP_" src python -g '!**/target/**' -g '!**/node_modules/**'

Repository: reflex-dev/xy

Length of output: 5466


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '48,62p' python/xy/styles.py
printf '\n---\n'
sed -n '170,190p' python/xy/styling/capabilities.py
printf '\n---\n'
sed -n '1080,1094p' python/xy/_svg.py

Repository: reflex-dev/xy

Length of output: 2685


Match the miter guard to SVG’s 4:1 limit. mcos >= 1.0 / MITER_LIMIT bevels miter joins too early; use 1.0 / (2.0 * MITER_LIMIT) so native output matches SVG/PDF on sharp corners.

🐛 Match the SVG ratio
-            if mcos >= 1.0 / MITER_LIMIT {
+            // miterLength / strokeWidth = 1 / (2 * mcos) <= MITER_LIMIT
+            if mcos >= 1.0 / (2.0 * MITER_LIMIT) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/raster.rs` around lines 446 - 448, Update the miter join guard in the
raster join logic to compare against 1.0 / (2.0 * MITER_LIMIT) instead of 1.0 /
MITER_LIMIT, preserving the SVG/PDF-compatible 4:1 miter behavior for sharp
corners.

Comment thread src/raster.rs
Comment thread src/raster.rs
Comment thread tests/test_customization_comparison.py Outdated
Comment thread tests/test_export_style_survival.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/styling/capabilities.md (1)

19-22: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clarify that stroke-linejoin is not accepted by style=.

Lines 21–22 define the table as the accepted style= vocabulary, but the table includes stroke-linejoin, which Lines 34 and 47 explicitly state is rejected. Either exclude planned properties from this table or label the table as registry coverage and separate shipped properties from planned entries.

Also applies to: 34-34, 47-47

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/styling/capabilities.md` around lines 19 - 22, Clarify the “Mark style
properties” documentation so the accepted style= vocabulary excludes
stroke-linejoin, which is explicitly rejected elsewhere. Remove stroke-linejoin
from the accepted-properties table or clearly separate and label it as a
planned/unshipped property while preserving the rejection statements at the
referenced sections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@docs/styling/capabilities.md`:
- Around line 19-22: Clarify the “Mark style properties” documentation so the
accepted style= vocabulary excludes stroke-linejoin, which is explicitly
rejected elsewhere. Remove stroke-linejoin from the accepted-properties table or
clearly separate and label it as a planned/unshipped property while preserving
the rejection statements at the referenced sections.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a41cc860-2f6d-4b19-8899-6926fe46ea6c

📥 Commits

Reviewing files that changed from the base of the PR and between ee6c94c and 4894e27.

📒 Files selected for processing (4)
  • docs/app/tests/test_docs_site.py
  • docs/styling/capabilities.md
  • scripts/gen_capability_matrix.py
  • src/raster.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • scripts/gen_capability_matrix.py
  • src/raster.rs

…ejection

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.

Alek99 commented Jul 26, 2026

Copy link
Copy Markdown
Member Author

Worked through the review — 12 findings. Pushed 16bbfe0. Eleven applied, one rejected with a test.

The one that mattered

The fnmatch rule-ordering bug was real and the most valuable finding here. * spans dots and first-match wins, so layout.*axis.showgrid was claiming layout.polar.angularaxis.showgrid before the exclusion below it could reject it. 3-D, geo, polar, ternary, and smith axes were being counted as supported.

before after
supported (whole schema) 345 218
mapped-with-difference 126 115
in-scope supported 344 217

A 127-attribute overstatement — 37% — in the number this PR exists to make trustworthy. Corrected in the report, the dossier, the changelog, the skill, and the PR body. test_excluded_subtrees_are_not_stolen_by_the_wildcard_axis_rules pins the ordering so it can't silently regress.

Second-worst: the stale "15 axis keys" in xy-evaluate/SKILL.md. Exactly right, and worse than it looks — that file's entire job is to send agents to registries instead of stale prose, and it shipped with stale prose. Every count it states is now pinned to capabilities.summary().

Applied

_plugin_column → contiguous f64 before calc (§27) · reserved xy.mark() control-field names rejected at registration · planned rows labelled as not accepted in the generated table · "drawn by all three renderers" derived from prop.support instead of hardcoded · unsupported-reason table folds its tail and prints a total so the column sums · KNOWN_RENDERER_DIVERGENCES typed · guardrail now scans CLAUDE.md, the skill, and the comparison doc · _emit_line falls back instead of raising KeyError · dash values validated before both stroke walkers · PDF/JPEG/WebP added to the custom_css test · Altair pinned · line family enumerated in the docs · stray space and placeholderless f-string removed.

Two notes on those. The guardrail's comparative rule needed the same ±10-line window the numeric-multiplier rule already uses — a claim taxonomy states its scope above the table, and a sentence-level window rejected the very wording the document exists to model. And on dashes: I could not construct a hang (a negative entry advances the index rather than looping), so that change is hardening of a byte-decoder boundary, not a fix for a reproduced defect.

Rejected: the miter limit

mcos >= 1.0 / MITER_LIMIT is correct; 1.0 / (2.0 * MITER_LIMIT) would be wrong.

SVG defines the limit as a ratio of miter length to stroke width. With phi the turn angle between segment directions and mcos = cos(phi/2):

  • apex distance from the vertex = hw / mcos
  • miter length = tip to inner corner = 2 * hw / mcos = w / mcos
  • ratio = miterLength / w = 1 / mcos

So ratio <= 4mcos >= 1/4 = 1.0 / MITER_LIMIT. The 2 * would bevel at mcos < 0.125, admitting ratio-8 spikes — twice what SVG and PDF draw for the same path.

miter_joins_bevel_past_svgs_four_to_one_limit pins it at three angles (90° ratio 1.41 miters, 150° ratio 3.86 miters, 155° ratio 4.62 bevels). I applied the suggested value and confirmed the test fails on the third case, then reverted.

Not doing: docstring coverage to 80%

Measured rather than assumed. My library code is now 100% and my scripts 91% — I added the missing ones. The gap is test functions, at 0%.

Repo baseline: python/xy 53.1%, scripts 18.7%, tests 5.3% (95 of 1,790). This repo documents tests with comments at the assertion, not docstrings, and my tests follow that — heavily. Adding 56 docstrings that restate adjacent comments would move a number without adding information, in a codebase that has never met that threshold anywhere. Happy to revisit if the 80% bar is one the project actually wants.

Note on cargo fmt

Fixed the stray space, which was mine. I did not reformat the rest: cargo fmt --check already fails on main across font.rs, kernels.rs, tiles.rs, and raster.rs, and no workflow, Makefile target, or verify_local check runs it. Reformatting here would bury the diff in unrelated churn.

Verification

cargo test 121 passed · cargo clippy -D warnings clean · uv run pytest 2370 passed · docs 91 passed · ruff, guardrails (84 documents), both generator --checks, public-API, abi_smoke 133 checks, render smoke — all clean.


Generated by Claude Code

…entory

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.
@Alek99 Alek99 changed the title Make customization claims defensible: cap fix, mark plugins, and a capability registry that proves itself Mark style capabilities: stroke-linecap, marker-shape, mark plugins, and a support inventory Jul 26, 2026
Alek99 added 2 commits July 26, 2026 08:04
The changelog is not mine to write. Restored to the branch point, so this
branch no longer touches it at all.
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/advanced/custom-marks.md (1)

12-17: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document that calc is optional.

MarkPlugin.calc defaults to None in python/xy/plugins.py:73-87, so “A mark plugin is two functions” overstates the minimum contract. State that build is required and calc is optional.

Proposed wording
-A mark plugin is two functions and a name:
+A mark plugin has a name, a required `build` function, and an optional `calc` function:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/advanced/custom-marks.md` around lines 12 - 17, Update the mark plugin
introduction to state that a plugin requires the build function, while calc is
optional and may be omitted when no input-column transformation is needed.
Adjust the “two functions” wording and the calc description without changing the
existing build guidance.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_mark_plugins.py`:
- Around line 202-212: Extend the reserved-field tuple in
test_a_plugin_cannot_claim_a_name_that_xy_mark_binds_itself to include
class_name, animation, and y_axis alongside the existing names. Keep the same
ValueError assertion and final registration check so all eight xy.mark() control
fields are covered.
- Around line 214-236: Update test_declared_columns_reach_calc_as_canonical_f64
so the registered MarkPlugin captures dtypes inside its calc callback, rather
than only inside build. Configure the plugin with the appropriate calc
implementation that records ctx.columns dtypes and returns the required result,
then retain the float32/int16 inputs and assert every dtype received by calc is
np.float64.

---

Outside diff comments:
In `@docs/advanced/custom-marks.md`:
- Around line 12-17: Update the mark plugin introduction to state that a plugin
requires the build function, while calc is optional and may be omitted when no
input-column transformation is needed. Adjust the “two functions” wording and
the calc description without changing the existing build guidance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0f25032a-6eaa-4fe8-aa7d-1b60cf25cfe1

📥 Commits

Reviewing files that changed from the base of the PR and between 4894e27 and d27f979.

📒 Files selected for processing (24)
  • CLAUDE.md
  • README.md
  • docs/advanced/custom-marks.md
  • docs/api-reference/limitations-and-alpha-status.md
  • docs/styling/capabilities.md
  • docs/styling/customize.md
  • docs/styling/mark-styles.md
  • python/xy/_raster.py
  • python/xy/_svg.py
  • python/xy/components.py
  • python/xy/plugins.py
  • python/xy/styling/capabilities.py
  • scripts/check_claim_guardrails.py
  • scripts/gen_capability_matrix.py
  • scripts/verify_sdist.py
  • spec/README.md
  • spec/api/capability-matrix.md
  • spec/api/styling.md
  • spec/design-dossier.md
  • src/raster.rs
  • tests/test_capability_registry.py
  • tests/test_claim_guardrails.py
  • tests/test_export_style_survival.py
  • tests/test_mark_plugins.py
💤 Files with no reviewable changes (1)
  • scripts/verify_sdist.py
🚧 Files skipped from review as they are similar to previous changes (11)
  • docs/styling/customize.md
  • docs/api-reference/limitations-and-alpha-status.md
  • docs/styling/capabilities.md
  • docs/styling/mark-styles.md
  • python/xy/_svg.py
  • python/xy/plugins.py
  • tests/test_capability_registry.py
  • CLAUDE.md
  • python/xy/styling/capabilities.py
  • tests/test_export_style_survival.py
  • python/xy/components.py

Comment on lines +202 to +212
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Cover every xy.mark() control field.

The public binding also reserves class_name, animation, and y_axis, but this test only exercises five of the eight control fields. Add the omitted names so regressions in their reservation cannot pass unnoticed.

Proposed test update
-    for reserved in ("name", "style", "data", "x_axis", "key"):
+    for reserved in (
+        "name", "style", "data", "class_name",
+        "key", "animation", "x_axis", "y_axis",
+    ):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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_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", "class_name",
"key", "animation", "x_axis", "y_axis",
):
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()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_mark_plugins.py` around lines 202 - 212, Extend the reserved-field
tuple in test_a_plugin_cannot_claim_a_name_that_xy_mark_binds_itself to include
class_name, animation, and y_axis alongside the existing names. Keep the same
ValueError assertion and final registration check so all eight xy.mark() control
fields are covered.

Comment on lines +214 to +236
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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert canonical dtype at the calc boundary.

The test records dtypes in build, while the title and comments claim to verify inputs received by calc; the registered plugin has no calc callback. A regression that passes float32/int16 into calc and widens only afterward would still pass.

Proposed test update
     seen = {}
 
+    def calc(columns):
+        seen["dtypes"] = {k: v.dtype for k, v in columns.items()}
+        return columns
+
     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))
+    xy.register_mark(
+        xy.MarkPlugin(
+            name="dtypes",
+            columns=("t", "low"),
+            calc=calc,
+            build=build,
+        )
+    )

As per coding guidelines, canonical data must remain CPU-side f64; GPU and derived buffers are rebuildable caches, and NaN must never reach vertex buffers.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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())
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 calc(columns):
seen["dtypes"] = {k: v.dtype for k, v in columns.items()}
return columns
def build(ctx):
return [xy.line(x=ctx.columns["t"], y=ctx.columns["low"])]
xy.register_mark(
xy.MarkPlugin(
name="dtypes",
columns=("t", "low"),
calc=calc,
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())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_mark_plugins.py` around lines 214 - 236, Update
test_declared_columns_reach_calc_as_canonical_f64 so the registered MarkPlugin
captures dtypes inside its calc callback, rather than only inside build.
Configure the plugin with the appropriate calc implementation that records
ctx.columns dtypes and returns the required result, then retain the
float32/int16 inputs and assert every dtype received by calc is np.float64.

Source: Coding guidelines

@Alek99
Alek99 merged commit f7e314c into main Jul 26, 2026
36 of 37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant