Mark style capabilities: stroke-linecap, marker-shape, mark plugins, and a support inventory - #310
Conversation
`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.
📝 WalkthroughWalkthroughThe 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. ChangesCapability registry and documentation
Composition mark plugins
Stroke and marker rendering
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Merging this PR will not alter performance
Comparing Footnotes
|
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.
There was a problem hiding this comment.
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 winAdd the remaining customization docs to
DEFAULT_DOCS.docs/styling/customize.mdis already covered by the recursivedocs/scan, butspec/api/customization-vs-alternatives.md,CLAUDE.md, and.agents/skills/xy-evaluate/SKILL.mdare still skipped, so edits there can bypassuv run python scripts/check_claim_guardrails.py.spec/api/customization-vs-alternatives.mdalso 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 valueUnknown
linecap/linejoinin a spec raisesKeyErrorfrom the display-list writer.
_emit_lineforwards whatever string the spec carries, and_Cmd.strokeindexes_CAP_CODES[cap]directly. Values produced bycompile_mark_styleare validated, so this is unreachable today, butrender_rastertakes a plain spec dict — a hand-built or round-tripped one with a stray value fails with a bareKeyErrorinside 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 winHardcoded "drawn by all three renderers" claim isn't derived from the registry.
Both
render()andrender_public()assert every shipped mark style property is drawn identically by all three renderers, but this is a fixed string, not something checked againstprop.support. If a future shipped property lands withpartialsupport in one renderer, this line would quietly overclaim — the exact failure modetests/test_capability_registry.pywas 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 valueConsider 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 baredict[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
⛔ Files ignored due to path filters (3)
spec/assets/area-outline-cap-before-after.pngis excluded by!**/*.pngspec/assets/linecap-cross-renderer-before-after.pngis excluded by!**/*.pngspec/assets/linecap-values.pngis excluded by!**/*.png
📒 Files selected for processing (48)
.agents/skills/xy-evaluate/SKILL.mdCHANGELOG.mdCLAUDE.mdREADME.mddocs/advanced/custom-marks.mddocs/api-reference/limitations-and-alpha-status.mddocs/app/xy_docs/config.pydocs/index.mddocs/styling/capabilities.mddocs/styling/chrome-slots.mddocs/styling/customize.mddocs/styling/mark-styles.mdjs/src/40_gl.tsjs/src/50_chartview.tspython/xy/__init__.pypython/xy/_native.pypython/xy/_raster.pypython/xy/_svg.pypython/xy/components.pypython/xy/marks.pypython/xy/plugins.pypython/xy/styles.pypython/xy/styling/__init__.pypython/xy/styling/capabilities.pyscripts/check_claim_guardrails.pyscripts/gen_capability_matrix.pyscripts/plotly_schema_coverage.pyscripts/verify_local.pyscripts/verify_sdist.pyspec/README.mdspec/api/capability-matrix.mdspec/api/chart-kind-contract.mdspec/api/customization-vs-alternatives.mdspec/api/export.mdspec/api/plotly-coverage.mdspec/api/styling.mdspec/design-dossier.mdsrc/lib.rssrc/raster.rstests/test_capability_registry.pytests/test_claim_guardrails.pytests/test_css_mark_styles.pytests/test_customization_comparison.pytests/test_evaluation_skill.pytests/test_export_style_survival.pytests/test_mark_plugins.pytests/test_plotly_schema_coverage.pytests/test_static_client_security.py
| /// 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; |
There was a problem hiding this comment.
🎯 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:
- 1: https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/stroke-miterlimit
- 2: https://www.w3.org/TR/SVG11/painting.html
- 3: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/stroke-miterlimit
- 4: https://svgwg.org/specs/strokes/
- 5: https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/stroke-linejoin
- 6: https://www.w3.org/TR/svg-strokes/
- 7: https://css-tricks.com/mastering-svgs-stroke-miterlimit-attribute/
🏁 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.pyRepository: 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.
There was a problem hiding this comment.
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 winClarify that
stroke-linejoinis not accepted bystyle=.Lines 21–22 define the table as the accepted
style=vocabulary, but the table includesstroke-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
📒 Files selected for processing (4)
docs/app/tests/test_docs_site.pydocs/styling/capabilities.mdscripts/gen_capability_matrix.pysrc/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.
|
Worked through the review — 12 findings. Pushed The one that matteredThe
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. Second-worst: the stale "15 axis keys" in Applied
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
SVG defines the limit as a ratio of miter length to stroke width. With
So
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: Note on
|
…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.
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.
There was a problem hiding this comment.
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 winDocument that
calcis optional.
MarkPlugin.calcdefaults toNoneinpython/xy/plugins.py:73-87, so “A mark plugin is two functions” overstates the minimum contract. State thatbuildis required andcalcis 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
📒 Files selected for processing (24)
CLAUDE.mdREADME.mddocs/advanced/custom-marks.mddocs/api-reference/limitations-and-alpha-status.mddocs/styling/capabilities.mddocs/styling/customize.mddocs/styling/mark-styles.mdpython/xy/_raster.pypython/xy/_svg.pypython/xy/components.pypython/xy/plugins.pypython/xy/styling/capabilities.pyscripts/check_claim_guardrails.pyscripts/gen_capability_matrix.pyscripts/verify_sdist.pyspec/README.mdspec/api/capability-matrix.mdspec/api/styling.mdspec/design-dossier.mdsrc/raster.rstests/test_capability_registry.pytests/test_claim_guardrails.pytests/test_export_style_survival.pytests/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
| 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() | ||
|
|
There was a problem hiding this comment.
🎯 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.
| 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.
| 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()) |
There was a problem hiding this comment.
🎯 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.
| 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
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:
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
_pdfthen read back flat while the rasterizer drew round.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:
np.array_equal).stroke_shapeddelegates to the originalstrokefor a round cap, so nothing that already renders moves.What ships
stroke-linecap—butt/round/squareon the line family, drawn identically by WebGL, SVG, and the native rasterizer.marker-shape— the CSS spelling ofsymbol=. Nearly free: all three renderers already drew 17 symbols and only the spelling was missing. Both forms build identical specs.Mark plugins —
xy.register_mark(xy.MarkPlugin(...))adds a chart kind xy does not ship;xy.mark("name", ...)uses it. A plugin supplies acalcover declared columns and abuildreturning built-in marks. Because its output is ordinary traces it reuses the built-in rendering, picking, and export paths rather than reimplementing them.buildnever sees theFigure, 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 namesxy.mark()binds itself.Export-styling contract —
custom_csswas the only asymmetry the docs named, and it was already bounded: it raises. The unbounded one went unmentioned —class_namesand per-slotstylesare read only by the browser client, and the SVG writer emits noclassat all, so a Tailwind-styled chart exports to PNG with none of it applied and no diagnostic. Now contracted inspec/api/export.md§9, the chrome-slots guide, and the limitations page, withtests/test_export_style_survival.pyasserting the two native writers agree with each other and not merely with the page.Capability inventory —
python/xy/styling/capabilities.py, one entry per style property and chrome slot with its per-renderer support, generated intospec/api/capability-matrix.mdand a public page.tests/test_capability_registry.pypins it to cover exactlyCHART_DOM_SLOTSand exactly whatstyles.pycompiles; which mark kinds accept a property is derived fromstyles.py, never typed out. I confirmed the guard bites by adding a fake property and watching the suite go red.Deliberately not here
stroke-linejoinis 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'smiterdefault through, which_pdfhad been reading back as a mismatch with the rasterizer.Notes
ABI_VERSION41 → 42:OP_STROKEandOP_SMOOTH_STROKEcarry one extra cap byte.scripts/check_claim_guardrails.pygains a plain ban on unqualifiable superlatives about the styling surface — "most customizable", "fully customizable", "style anything" — and now scansREADME.mdandCLAUDE.md, which it previously did not. It caught two live overclaims indocs/index.mdon its first run; both are rewritten.Verification
cargo test119 passed ·cargo clippy -D warningsclean ·uv run pytest2351 passed, 69 skipped · docs 91 passed · ruff, guardrails, generator--check, public-API,abi_smoke133 checks, render smoke, append-stream smoke — all clean. No conflicts againstmainat 8edf1d0.Summary by CodeRabbit
stroke-linecapstyling for line marks andmarker-shapestyling for scatter marks across supported renderers.