From a99c037dbf8c79fefa469015671e9ef8c94fa3fb Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 4 Aug 2026 12:57:38 -0700 Subject: [PATCH 1/2] Add a report-only style-compatibility preflight and the applicable-slot matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chart.style_compatibility_report(target=..., engine=..., custom_css=...) routes every declared slot style for one export target into exactly one of four outcomes — survives, native-subset (naming the kept and lost properties per format family), browser-only, or state-gated — and mirrors the export path's own refusals (custom_css with a pinned native engine, Chromium SVG) instead of re-deciding them. Report-only: computing it never changes an export; the staged compatibility= modes that act on it come separately. Two properties are load-bearing. The report is constant-time when there is nothing to route: no class_names, no per-slot styles, no custom_css means no slot walk, so preflight stays free exactly where exports are hot. And state-gated is not lost: the capability registry now tags every slot with an applicability — present in a clean static export, or gated by hover, selection, crosshair, modebar, or view (reduction badges) — so a styled tooltip is recorded with its gating state rather than counted against a file that never contains a tooltip. Counting it before this change overstated the static parity gap by exactly the chrome a static file cannot contain: of 48 slots, 24 are clean-static and 24 state-gated, and all 10 native-capable slots are in the static set. Routing derives from the capability registry, the honored property subsets from the writers' own constants (xy._svg.SLOT_TEXT_PROPS / SLOT_RASTER_PROPS), and engine selection from export._resolve_image_engine — the preflight restates none of them, so it cannot disagree with them. The legend slot stays at declaration granularity (its box properties route through the merged legend declaration, which has no constant yet) and is qualified rather than guessed either direction (§28). The generated capability matrices gain the applicable-in column and the applicable-slot counts; new registry tests pin the partition (a new modebar_*/tooltip/crosshair/badge slot that forgets its family state fails the suite, and native support on a state-gated slot is rejected until the interaction-snapshot phase adds it deliberately). export.md §9 documents the report as its programmatic form. --- CHANGELOG.md | 16 ++ docs/styling/capabilities.md | 109 ++++---- python/xy/_figure.py | 18 ++ python/xy/components.py | 21 ++ python/xy/styling/__init__.py | 12 +- python/xy/styling/capabilities.py | 64 ++++- python/xy/styling/preflight.py | 306 +++++++++++++++++++++++ scripts/gen_capability_matrix.py | 29 ++- spec/api/capability-matrix.md | 109 ++++---- spec/api/export.md | 25 ++ tests/test_capability_registry.py | 43 ++++ tests/test_style_compatibility_report.py | 215 ++++++++++++++++ 12 files changed, 854 insertions(+), 113 deletions(-) create mode 100644 python/xy/styling/preflight.py create mode 100644 tests/test_style_compatibility_report.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e41d41..683641b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ in the README). ## [Unreleased] +### Added +- `chart.style_compatibility_report(target=..., engine=..., custom_css=...)`: + a report-only export preflight that routes every declared slot style into + `survives`, `native-subset` (naming the kept and lost properties per + format family), `browser-only`, or `state-gated`, mirrors the export + path's refusals, and short-circuits in constant time for charts with no + class or per-slot declarations. The programmatic answer to + `spec/api/export.md` §9. +- The capability registry now tags every chrome slot with an + **applicability** — present in a clean static export, or gated by an + export state (`hover`, `selection`, `crosshair`, `modebar`, `view`) — and + the generated capability matrices gained the *applicable in* column and + applicable-slot counts (24 static / 24 state-gated of 48). Live-only + chrome a static file never contains is no longer counted as missing from + static export parity. + ## [0.0.5] - 2026-07-31 ### Added diff --git a/docs/styling/capabilities.md b/docs/styling/capabilities.md index 59558079..9d79c0e7 100644 --- a/docs/styling/capabilities.md +++ b/docs/styling/capabilities.md @@ -12,7 +12,7 @@ and *does the change survive where I need it*. This page answers both from the registry the implementation is checked against. - **11** mark style properties across **21** mark kinds, drawn by all three renderers. -- **48** stable chrome slots for CSS and Tailwind in the browser. +- **48** stable chrome slots for CSS and Tailwind in the browser — **24** of them in a clean static export, **24** gated by an interaction or view state. - **1** way to add a mark kind XY does not ship, without forking it. ## Mark style properties @@ -54,56 +54,63 @@ raster and vector writers have no cascade, so per-slot styling is a browser mechanism; put anything that must survive export in the chart-level `style=` token bag or in mark and axis `style=`, which every renderer reads. -| slot | browser | native raster | native vector | -|---|---|---|---| -| `root` | full | partial | partial | -| `title` | full | partial | partial | -| `chrome` | full | none | none | -| `canvas` | full | none | none | -| `annotation_layer` | full | none | none | -| `labels` | full | none | none | -| `legend` | full | partial | partial | -| `legend_title` | full | partial | partial | -| `legend_item` | full | none | none | -| `legend_swatch` | full | none | none | -| `legend_label` | full | partial | partial | -| `colorbar` | full | partial | partial | -| `colorbar_bar` | full | none | none | -| `colorbar_extension` | full | none | none | -| `colorbar_line` | full | none | none | -| `colorbar_tick` | full | partial | partial | -| `colorbar_minor_tick` | full | none | none | -| `colorbar_title` | full | partial | partial | -| `tooltip` | full | none | none | -| `tooltip_title` | full | none | none | -| `tooltip_row` | full | none | none | -| `tooltip_label` | full | none | none | -| `tooltip_value` | full | none | none | -| `modebar` | full | none | none | -| `modebar_drag_handle` | full | none | none | -| `modebar_control_group` | full | none | none | -| `modebar_separator` | full | none | none | -| `modebar_button` | full | none | none | -| `modebar_icon` | full | none | none | -| `modebar_zoom_value` | full | none | none | -| `modebar_indicator` | full | none | none | -| `modebar_selection_icon` | full | none | none | -| `modebar_menu` | full | none | none | -| `modebar_menu_separator` | full | none | none | -| `modebar_menu_icon` | full | none | none | -| `modebar_menu_label` | full | none | none | -| `modebar_history_controls` | full | none | none | -| `selection` | full | none | none | -| `crosshair_x` | full | none | none | -| `crosshair_y` | full | none | none | -| `badge` | full | none | none | -| `badge_item` | full | none | none | -| `axis_band` | full | none | none | -| `axis_line` | full | none | none | -| `tick_mark` | full | none | none | -| `tick_label` | full | partial | partial | -| `axis_title` | full | partial | partial | -| `annotation_label` | full | none | none | +The *applicable in* column says which export contains the slot at all: a clean +static export has no tooltip, modebar, crosshair, selection overlay, or +reduction badge, so styling one is not lost in such a file — it simply is not +there. Ask `chart.style_compatibility_report(target=...)` for the per-chart +answer: it routes every declared style for a target and names anything that +would not survive, before any bytes exist. + +| slot | applicable in | browser | native raster | native vector | +|---|---|---|---|---| +| `root` | clean static | full | partial | partial | +| `title` | clean static | full | partial | partial | +| `chrome` | clean static | full | none | none | +| `canvas` | clean static | full | none | none | +| `annotation_layer` | clean static | full | none | none | +| `labels` | clean static | full | none | none | +| `legend` | clean static | full | partial | partial | +| `legend_title` | clean static | full | partial | partial | +| `legend_item` | clean static | full | none | none | +| `legend_swatch` | clean static | full | none | none | +| `legend_label` | clean static | full | partial | partial | +| `colorbar` | clean static | full | partial | partial | +| `colorbar_bar` | clean static | full | none | none | +| `colorbar_extension` | clean static | full | none | none | +| `colorbar_line` | clean static | full | none | none | +| `colorbar_tick` | clean static | full | partial | partial | +| `colorbar_minor_tick` | clean static | full | none | none | +| `colorbar_title` | clean static | full | partial | partial | +| `tooltip` | hover state | full | none | none | +| `tooltip_title` | hover state | full | none | none | +| `tooltip_row` | hover state | full | none | none | +| `tooltip_label` | hover state | full | none | none | +| `tooltip_value` | hover state | full | none | none | +| `modebar` | modebar state | full | none | none | +| `modebar_drag_handle` | modebar state | full | none | none | +| `modebar_control_group` | modebar state | full | none | none | +| `modebar_separator` | modebar state | full | none | none | +| `modebar_button` | modebar state | full | none | none | +| `modebar_icon` | modebar state | full | none | none | +| `modebar_zoom_value` | modebar state | full | none | none | +| `modebar_indicator` | modebar state | full | none | none | +| `modebar_selection_icon` | modebar state | full | none | none | +| `modebar_menu` | modebar state | full | none | none | +| `modebar_menu_separator` | modebar state | full | none | none | +| `modebar_menu_icon` | modebar state | full | none | none | +| `modebar_menu_label` | modebar state | full | none | none | +| `modebar_history_controls` | modebar state | full | none | none | +| `selection` | selection state | full | none | none | +| `crosshair_x` | crosshair state | full | none | none | +| `crosshair_y` | crosshair state | full | none | none | +| `badge` | view state | full | none | none | +| `badge_item` | view state | full | none | none | +| `axis_band` | clean static | full | none | none | +| `axis_line` | clean static | full | none | none | +| `tick_mark` | clean static | full | none | none | +| `tick_label` | clean static | full | partial | partial | +| `axis_title` | clean static | full | partial | partial | +| `annotation_label` | clean static | full | none | none | ### Notes diff --git a/python/xy/_figure.py b/python/xy/_figure.py index d42f72fe..af80981f 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -2217,6 +2217,24 @@ def _repr_html_(self) -> str: """Notebook HTML repr isolated from the host document's styles.""" return export.notebook_iframe(self.to_html(), width=self.width, height=self.height) + def style_compatibility_report( + self, + target: str = "png", + *, + engine: Optional[export.Engine | str] = None, + custom_css: Optional[str] = None, + ) -> Any: + """Route every declared style for one export target, without exporting. + + The programmatic answer to export.md §9: which styling sources this + figure carries, how each styled slot routes under `target`/`engine`, + and exactly what would not survive — including the export path's own + refusals, mirrored rather than re-decided. Report-only: computing it + never changes an export.""" + from .styling.preflight import preflight + + return preflight(self, target=target, engine=engine, custom_css=custom_css) + def to_svg( self, path: Optional[str | PathLike[str]] = None, diff --git a/python/xy/components.py b/python/xy/components.py index a5ec5b9c..cdc3934c 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -4169,6 +4169,27 @@ def html( def _repr_html_(self) -> str: return self.figure()._repr_html_() + def style_compatibility_report( + self, + target: str = "png", + *, + engine: Optional[export.Engine | str] = None, + custom_css: Optional[str] = None, + ) -> Any: + """What of this chart's styling survives an export to ``target``. + + Report-only preflight: lists the styling sources present, how each + styled slot routes for the target and engine, and exactly which + declarations would not survive — before any bytes exist. Mirrors the + export path's behavior (including its refusals) rather than + re-deciding it; see `spec/api/export.md` §9. + """ + return self.figure().style_compatibility_report( + target, + engine=engine, + custom_css=custom_css, + ) + def to_svg( self, path: Optional[str] = None, diff --git a/python/xy/styling/__init__.py b/python/xy/styling/__init__.py index dc644318..5d71f30d 100644 --- a/python/xy/styling/__init__.py +++ b/python/xy/styling/__init__.py @@ -1,13 +1,17 @@ """Machine-checkable records about XY's styling surface. -`capabilities` is the one that matters: what can be styled, in which renderer, -and how far it travels. It is imported by the docs generator and pinned by +`capabilities` is the inventory: what can be styled, in which renderer, and +how far it travels. It is imported by the docs generator and pinned by `tests/test_capability_registry.py`, so a claim about customization can be checked against it rather than against a reading of `styles.py`. + +`preflight` applies that inventory to one concrete chart and export target: +`chart.style_compatibility_report()` routes every declared style and names +what would not survive, before any bytes exist. """ from __future__ import annotations -from . import capabilities +from . import capabilities, preflight -__all__ = ["capabilities"] +__all__ = ["capabilities", "preflight"] diff --git a/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py index 205c04e2..a16b9825 100644 --- a/python/xy/styling/capabilities.py +++ b/python/xy/styling/capabilities.py @@ -49,6 +49,20 @@ STATUSES: frozenset[str] = frozenset({"shipped", "partial", "planned"}) VOCABULARIES: frozenset[str] = frozenset({"css", "svg", "xy"}) +#: Interaction/view states that gate live-only chrome. A slot tagged with one +#: of these exists in the document only while its state is active — a tooltip +#: under hover, the modebar under a pointer, a reduction badge under the view +#: that triggered it — so a clean static export does not *contain* it. Styling +#: such a slot is therefore not "dropped" by a clean static export: there is +#: nothing in the file to style. Counting those slots against static parity +#: overstated the gap; tagging them records the distinction instead of leaving +#: it silent (§28). +EXPORT_STATES: tuple[str, ...] = ("hover", "selection", "crosshair", "modebar", "view") + +#: Every slot is either present in a clean static export ("static") or gated +#: by exactly one export state. +APPLICABILITIES: frozenset[str] = frozenset({"static", *EXPORT_STATES}) + @dataclass(frozen=True) class MarkStyleProperty: @@ -79,6 +93,7 @@ class SlotCapability: support: dict[str, str] notes: str channel: str = "" + applicability: str = "static" @dataclass(frozen=True) @@ -281,6 +296,39 @@ class ExtensionPoint: ) +#: The state that gates each live-only slot. Listed explicitly, one entry per +#: slot rather than by prefix, so `tests/test_capability_registry.py` can +#: assert the partition covers `CHART_DOM_SLOTS` exactly and that every member +#: of a chrome family carries its family's state — a new `modebar_*` slot that +#: forgets its entry fails the suite instead of quietly counting as static. +_STATE_GATED_SLOTS: dict[str, str] = { + "tooltip": "hover", + "tooltip_title": "hover", + "tooltip_row": "hover", + "tooltip_label": "hover", + "tooltip_value": "hover", + "modebar": "modebar", + "modebar_drag_handle": "modebar", + "modebar_control_group": "modebar", + "modebar_separator": "modebar", + "modebar_button": "modebar", + "modebar_icon": "modebar", + "modebar_zoom_value": "modebar", + "modebar_indicator": "modebar", + "modebar_selection_icon": "modebar", + "modebar_menu": "modebar", + "modebar_menu_separator": "modebar", + "modebar_menu_icon": "modebar", + "modebar_menu_label": "modebar", + "modebar_history_controls": "modebar", + "selection": "selection", + "crosshair_x": "crosshair", + "crosshair_y": "crosshair", + "badge": "view", + "badge_item": "view", +} + + CHART_SLOTS: tuple[SlotCapability, ...] = tuple( SlotCapability( id=slot, @@ -291,6 +339,7 @@ class ExtensionPoint: }, channel=_SLOT_EXCEPTIONS[slot][1] if slot in _SLOT_EXCEPTIONS else "", notes=_SLOT_EXCEPTIONS[slot][2] if slot in _SLOT_EXCEPTIONS else "", + applicability=_STATE_GATED_SLOTS.get(slot, "static"), ) for slot in CHART_DOM_SLOTS ) @@ -358,12 +407,15 @@ def markdown_mark_property_table( def markdown_slot_table(slots: Iterable[SlotCapability] = CHART_SLOTS) -> list[str]: """One row per chrome slot, with how far its styling travels.""" lines = [ - "| slot | browser | native raster | native vector |", - "|---|---|---|---|", + "| slot | applicable in | browser | native raster | native vector |", + "|---|---|---|---|---|", ] for slot in slots: + applicable = ( + "clean static" if slot.applicability == "static" else f"{slot.applicability} state" + ) lines.append( - f"| `{slot.id}` | {slot.support['browser']} | " + f"| `{slot.id}` | {applicable} | {slot.support['browser']} | " f"{slot.support['native_raster']} | {slot.support['native_vector']} |" ) return lines @@ -400,12 +452,16 @@ def axis_style_keys() -> tuple[str, ...]: def summary() -> dict[str, object]: """Counts a release note can quote without anyone recounting by hand.""" shipped = [p for p in MARK_STYLE_PROPERTIES if p.status == "shipped"] + static = [s for s in CHART_SLOTS if s.applicability == "static"] return { "axis_style_keys": len(axis_style_keys()), "mark_style_properties": len(MARK_STYLE_PROPERTIES), "mark_style_properties_shipped": len(shipped), "mark_kinds": len(styles._MARK_KINDS), "chart_slots": len(CHART_SLOTS), + "chart_slots_static": len(static), + "chart_slots_state_gated": len(CHART_SLOTS) - len(static), + "static_slots_native": sum(1 for s in static if s.support["native_raster"] != "none"), "slots_styleable_natively": sum( 1 for s in CHART_SLOTS if s.support["native_raster"] != "none" ), @@ -415,7 +471,9 @@ def summary() -> dict[str, object]: __all__ = [ + "APPLICABILITIES", "CHART_SLOTS", + "EXPORT_STATES", "EXTENSION_POINTS", "KNOWN_RENDERER_DIVERGENCES", "MARK_STYLE_PROPERTIES", diff --git a/python/xy/styling/preflight.py b/python/xy/styling/preflight.py new file mode 100644 index 00000000..d6d3d523 --- /dev/null +++ b/python/xy/styling/preflight.py @@ -0,0 +1,306 @@ +"""Report-only export preflight: what survives an export, and what does not. + +`chart.style_compatibility_report(target=...)` answers `spec/api/export.md` §9 +programmatically, per chart, before any bytes exist: for the requested target +and engine it lists which styling sources are present, how each styled slot +routes, and exactly which declarations would not survive. Nothing here changes +export behavior — the staged `compatibility=` modes that act on this report +land separately, so this module can be trusted from any code path. + +Three rules keep the report honest: + +1. **No silent decisions (§28).** Every declared style ends in exactly one + route: it survives, it is state-gated chrome a clean static file does not + contain, or it is named as a loss. There is no fourth, quiet bucket. +2. **Constant time when there is nothing to route.** A chart with no + `class_names`, no per-slot `styles`, and no `custom_css` short-circuits to + a lossless report without walking any slot — the preflight is free exactly + where exports are hot. +3. **One source of truth per fact.** Slot routing derives from the capability + registry, the honored property subsets from `xy._svg` (which the writers + themselves read), and engine selection from `xy.export`'s own resolver. + This module restates none of them, so it cannot disagree with them. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Optional + +from . import capabilities + +if TYPE_CHECKING: # pragma: no cover - import cycle guard, typing only + from .._figure import Figure + +#: Routes a declared style can take. Stable strings: the staged +#: `compatibility=` modes and the tests key on them. +ROUTE_SURVIVES = "survives" +ROUTE_SUBSET = "native-subset" +ROUTE_BROWSER_ONLY = "browser-only" +ROUTE_STATE_GATED = "state-gated" + +_RASTER_FORMATS = frozenset({"png", "jpeg", "webp"}) +_VECTOR_FORMATS = frozenset({"svg", "pdf"}) + +_SLOTS_BY_ID = {slot.id: slot for slot in capabilities.CHART_SLOTS} + + +@dataclass(frozen=True) +class SlotFinding: + """How one styled slot routes for the requested target.""" + + slot: str + source: str # "styles" | "class_names" + applicability: str # "static" or the gating export state + route: str # one of the ROUTE_* strings + kept: tuple[str, ...] = () + lost: tuple[str, ...] = () + detail: str = "" + + +@dataclass(frozen=True) +class StyleCompatibilityReport: + """The preflight answer for one chart and one export target. + + `lossless` is the single bit the staged modes will act on: True means the + export preserves every declared style that the target's document can + contain (state-gated chrome is recorded, not counted — a clean static + file has no tooltip to style). `error` carries the message of an export + that would refuse outright (for example `custom_css` with a pinned native + engine), mirroring the export path's own exception rather than predicting + a different outcome. + """ + + target: str + engine: str + sources: dict[str, bool] = field(default_factory=dict) + findings: tuple[SlotFinding, ...] = () + losses: tuple[str, ...] = () + lossless: bool = True + error: Optional[str] = None + + def explain(self) -> str: + """The report as readable lines, one decision each.""" + head = f"style compatibility for {self.target} via {self.engine} engine" + lines = [head] + present = [name for name, on in self.sources.items() if on] or ["(defaults only)"] + lines.append("sources: " + ", ".join(present)) + if self.error is not None: + lines.append(f"refused: {self.error}") + return "\n".join(lines) + for finding in self.findings: + bits = [f"{finding.source}[{finding.slot!r}]: {finding.route}"] + if finding.lost: + bits.append("loses " + ", ".join(finding.lost)) + if finding.detail: + bits.append(finding.detail) + lines.append(" " + " — ".join(bits)) + lines.append("lossless" if self.lossless else f"{len(self.losses)} loss(es)") + return "\n".join(lines) + + +def _sources(figure: Figure, custom_css: Optional[str]) -> dict[str, bool]: + """Which styling sources this chart carries. Attribute checks only.""" + return { + "chart_style": bool(figure.style), + "slot_styles": bool(figure.chrome_styles), + "class_names": bool(figure.class_names), + "custom_css": custom_css is not None, + } + + +def _resolve(target: str, engine: object, custom_css: Optional[str]) -> tuple[str, str, str]: + """(format, engine, error) via the export module's own resolver. + + Deferred import: `export` pulls in the browser-discovery machinery, and + `capabilities` must stay importable from the docs generator without it. + """ + from .. import export + + fmt = export._normalize_format(target, allow_html=True) + if fmt == "html": + return fmt, "browser", "" + try: + return fmt, export._resolve_image_engine(engine, fmt, custom_css), "" + except ValueError as exc: + return fmt, "unresolved", str(exc) + + +def _honored_props(slot: str, fmt: str) -> tuple[frozenset[str], str]: + """(honored property names, qualifier) for a native-subset slot. + + The subsets are the writers' own constants. `legend` honors box properties + beyond the shared text subset through its merged-declaration channel; that + set lives in the writer's merge logic rather than a constant yet, so the + report stays at declaration granularity there instead of guessing — the + qualifier says so, and the shared IR change makes it exact. + """ + from .. import _svg + + text = frozenset(_svg.SLOT_RASTER_PROPS if fmt in _RASTER_FORMATS else _svg.SLOT_TEXT_PROPS) + if slot == "legend": + return text, ( + "box properties (background, shadow, radius, padding) route through the " + "merged legend declaration; see the capability matrix legend note" + ) + return text, "" + + +def _css_prop(name: str) -> str: + """Match the writers' spelling: kebab-case, custom properties untouched.""" + text = str(name) + return text if text.startswith("--") else text.replace("_", "-") + + +def _class_finding(slot: str, fmt: str) -> SlotFinding: + meta = _SLOTS_BY_ID[slot] + if meta.applicability != "static": + return SlotFinding( + slot=slot, + source="class_names", + applicability=meta.applicability, + route=ROUTE_STATE_GATED, + detail=( + f"{meta.applicability}-state chrome; a clean static {fmt} does not " + "contain it, so nothing in the file is unstyled" + ), + ) + return SlotFinding( + slot=slot, + source="class_names", + applicability="static", + route=ROUTE_BROWSER_ONLY, + lost=("*",), + detail=( + "a class selects a rule out of a stylesheet, and a native export has no " + "stylesheet — browser and Chromium targets honor it" + ), + ) + + +def _styles_finding(slot: str, decls: dict[str, Any], fmt: str) -> SlotFinding: + meta = _SLOTS_BY_ID[slot] + family = "native_raster" if fmt in _RASTER_FORMATS else "native_vector" + props = tuple(_css_prop(name) for name in decls) + if meta.applicability != "static": + return SlotFinding( + slot=slot, + source="styles", + applicability=meta.applicability, + route=ROUTE_STATE_GATED, + detail=( + f"{meta.applicability}-state chrome; a clean static {fmt} does not " + "contain it, so nothing in the file is unstyled" + ), + ) + if slot == "root" or meta.support[family] == "none": + detail = meta.notes if slot == "root" else "no native path for this slot yet" + return SlotFinding( + slot=slot, + source="styles", + applicability="static", + route=ROUTE_BROWSER_ONLY, + lost=props, + detail=detail, + ) + honored, qualifier = _honored_props(slot, fmt) + kept = tuple(p for p in props if p in honored) + lost = tuple(p for p in props if p not in honored) + if slot == "legend": + # Declaration-level: unlisted properties may still route through the + # merged legend channel, so they are qualified rather than declared + # lost (§28: unsure is said out loud, not rounded either direction). + return SlotFinding( + slot=slot, + source="styles", + applicability="static", + route=ROUTE_SUBSET, + kept=kept, + lost=(), + detail=qualifier, + ) + if lost: + return SlotFinding( + slot=slot, + source="styles", + applicability="static", + route=ROUTE_SUBSET, + kept=kept, + lost=lost, + detail="outside the writer's honored subset for this slot", + ) + return SlotFinding( + slot=slot, + source="styles", + applicability="static", + route=ROUTE_SURVIVES, + kept=kept, + ) + + +def preflight( + figure: Figure, + *, + target: str = "png", + engine: object = None, + custom_css: Optional[str] = None, +) -> StyleCompatibilityReport: + """Route every declared style for one export target, without exporting. + + `engine` accepts the same values as the export APIs (`Engine`, its string + aliases, or None for auto). The report mirrors the export path's actual + behavior, including its refusals — it never predicts a different outcome + than running the export would produce. + """ + fmt, resolved, error = _resolve(target, engine, custom_css) + sources = _sources(figure, custom_css) + if error: + return StyleCompatibilityReport( + target=fmt, + engine=resolved, + sources=sources, + lossless=False, + losses=(error,), + error=error, + ) + if resolved == "browser": + # The live client renders the full cascade; nothing can drop. + return StyleCompatibilityReport(target=fmt, engine=resolved, sources=sources) + if not (figure.class_names or figure.chrome_styles): + # The constant-time path: chart-level `style=` and mark/axis `style=` + # are full in every renderer (see the capability matrix), so with no + # class or per-slot declarations there is nothing that can drop. + return StyleCompatibilityReport(target=fmt, engine=resolved, sources=sources) + + findings: list[SlotFinding] = [] + for slot in figure.class_names: + if slot in _SLOTS_BY_ID: + findings.append(_class_finding(slot, fmt)) + for slot, decls in figure.chrome_styles.items(): + if slot in _SLOTS_BY_ID and isinstance(decls, dict): + findings.append(_styles_finding(slot, decls, fmt)) + + losses = tuple( + f"{finding.source}[{finding.slot!r}] -> {', '.join(finding.lost)}" + for finding in findings + if finding.lost + ) + return StyleCompatibilityReport( + target=fmt, + engine=resolved, + sources=sources, + findings=tuple(findings), + losses=losses, + lossless=not losses, + ) + + +__all__ = [ + "ROUTE_BROWSER_ONLY", + "ROUTE_STATE_GATED", + "ROUTE_SUBSET", + "ROUTE_SURVIVES", + "SlotFinding", + "StyleCompatibilityReport", + "preflight", +] diff --git a/scripts/gen_capability_matrix.py b/scripts/gen_capability_matrix.py index f7ce5f12..e76c38f5 100644 --- a/scripts/gen_capability_matrix.py +++ b/scripts/gen_capability_matrix.py @@ -63,10 +63,15 @@ def render() -> str: f"- **{counts['mark_style_properties_shipped']}** mark style properties across " f"**{counts['mark_kinds']}** mark kinds, {claim}.", f"- **{counts['chart_slots']}** stable chrome slots, CSS- and Tailwind-addressable " - "in the browser; " - f"**{counts['slots_styleable_natively']}** of them reach the native writers — " + "in the browser. " + f"**{counts['chart_slots_static']}** of them name chrome a clean static export " + f"contains; **{counts['slots_styleable_natively']}** reach the native writers — " "nine through `styles={slot: ...}` itself, and `root` through the " - "chart-level `style=` token bag.", + "chart-level `style=` token bag. The other " + f"**{counts['chart_slots_state_gated']}** are interaction/view chrome " + "(tooltip, modebar, crosshair, selection, reduction badges) that a clean " + "static file does not contain, so they are gated by an export state " + "rather than missing.", f"- **{counts['extension_points_shipped']}** shipped extension point.", f"- **{counts['known_renderer_divergences']}** known default divergence between " "renderers, listed below rather than left to be discovered.", @@ -96,6 +101,13 @@ def render() -> str: "contracted in [export.md](export.md) §9 and pinned by", "`tests/test_export_style_survival.py`.", "", + "The *applicable in* column records which export contains the slot at all: a", + "clean static export has no tooltip, modebar, crosshair, selection overlay, or", + "reduction badge, so styling one is not dropped by such an export — there is", + "nothing in the file to style. Per chart and target,", + "`chart.style_compatibility_report()` applies this table to what was actually", + "declared and names anything that would not survive.", + "", ] lines += caps.markdown_slot_table() lines += ["", "### Notes", ""] @@ -168,7 +180,9 @@ def render_public() -> str: "", f"- **{counts['mark_style_properties_shipped']}** mark style properties across " f"**{counts['mark_kinds']}** mark kinds, {claim}.", - f"- **{counts['chart_slots']}** stable chrome slots for CSS and Tailwind in the browser.", + f"- **{counts['chart_slots']}** stable chrome slots for CSS and Tailwind in the " + f"browser — **{counts['chart_slots_static']}** of them in a clean static export, " + f"**{counts['chart_slots_state_gated']}** gated by an interaction or view state.", f"- **{counts['extension_points_shipped']}** way to add a mark kind XY does not " "ship, without forking it.", "", @@ -192,6 +206,13 @@ def render_public() -> str: "mechanism; put anything that must survive export in the chart-level `style=`", "token bag or in mark and axis `style=`, which every renderer reads.", "", + "The *applicable in* column says which export contains the slot at all: a clean", + "static export has no tooltip, modebar, crosshair, selection overlay, or", + "reduction badge, so styling one is not lost in such a file — it simply is not", + "there. Ask `chart.style_compatibility_report(target=...)` for the per-chart", + "answer: it routes every declared style for a target and names anything that", + "would not survive, before any bytes exist.", + "", ] lines += caps.markdown_slot_table() lines += ["", "### Notes", ""] diff --git a/spec/api/capability-matrix.md b/spec/api/capability-matrix.md index 1c4b18f1..c90753cd 100644 --- a/spec/api/capability-matrix.md +++ b/spec/api/capability-matrix.md @@ -14,7 +14,7 @@ which is sometimes deliberate, and the notes say which. ## In one line - **11** mark style properties across **21** mark kinds, drawn by all three renderers. -- **48** stable chrome slots, CSS- and Tailwind-addressable in the browser; **10** of them reach the native writers — nine through `styles={slot: ...}` itself, and `root` through the chart-level `style=` token bag. +- **48** stable chrome slots, CSS- and Tailwind-addressable in the browser. **24** of them name chrome a clean static export contains; **10** reach the native writers — nine through `styles={slot: ...}` itself, and `root` through the chart-level `style=` token bag. The other **24** are interaction/view chrome (tooltip, modebar, crosshair, selection, reduction badges) that a clean static file does not contain, so they are gated by an export state rather than missing. - **1** shipped extension point. - **1** known default divergence between renderers, listed below rather than left to be discovered. @@ -62,56 +62,63 @@ stylesheet an exported file does not have. That boundary is contracted in [export.md](export.md) §9 and pinned by `tests/test_export_style_survival.py`. -| slot | browser | native raster | native vector | -|---|---|---|---| -| `root` | full | partial | partial | -| `title` | full | partial | partial | -| `chrome` | full | none | none | -| `canvas` | full | none | none | -| `annotation_layer` | full | none | none | -| `labels` | full | none | none | -| `legend` | full | partial | partial | -| `legend_title` | full | partial | partial | -| `legend_item` | full | none | none | -| `legend_swatch` | full | none | none | -| `legend_label` | full | partial | partial | -| `colorbar` | full | partial | partial | -| `colorbar_bar` | full | none | none | -| `colorbar_extension` | full | none | none | -| `colorbar_line` | full | none | none | -| `colorbar_tick` | full | partial | partial | -| `colorbar_minor_tick` | full | none | none | -| `colorbar_title` | full | partial | partial | -| `tooltip` | full | none | none | -| `tooltip_title` | full | none | none | -| `tooltip_row` | full | none | none | -| `tooltip_label` | full | none | none | -| `tooltip_value` | full | none | none | -| `modebar` | full | none | none | -| `modebar_drag_handle` | full | none | none | -| `modebar_control_group` | full | none | none | -| `modebar_separator` | full | none | none | -| `modebar_button` | full | none | none | -| `modebar_icon` | full | none | none | -| `modebar_zoom_value` | full | none | none | -| `modebar_indicator` | full | none | none | -| `modebar_selection_icon` | full | none | none | -| `modebar_menu` | full | none | none | -| `modebar_menu_separator` | full | none | none | -| `modebar_menu_icon` | full | none | none | -| `modebar_menu_label` | full | none | none | -| `modebar_history_controls` | full | none | none | -| `selection` | full | none | none | -| `crosshair_x` | full | none | none | -| `crosshair_y` | full | none | none | -| `badge` | full | none | none | -| `badge_item` | full | none | none | -| `axis_band` | full | none | none | -| `axis_line` | full | none | none | -| `tick_mark` | full | none | none | -| `tick_label` | full | partial | partial | -| `axis_title` | full | partial | partial | -| `annotation_label` | full | none | none | +The *applicable in* column records which export contains the slot at all: a +clean static export has no tooltip, modebar, crosshair, selection overlay, or +reduction badge, so styling one is not dropped by such an export — there is +nothing in the file to style. Per chart and target, +`chart.style_compatibility_report()` applies this table to what was actually +declared and names anything that would not survive. + +| slot | applicable in | browser | native raster | native vector | +|---|---|---|---|---| +| `root` | clean static | full | partial | partial | +| `title` | clean static | full | partial | partial | +| `chrome` | clean static | full | none | none | +| `canvas` | clean static | full | none | none | +| `annotation_layer` | clean static | full | none | none | +| `labels` | clean static | full | none | none | +| `legend` | clean static | full | partial | partial | +| `legend_title` | clean static | full | partial | partial | +| `legend_item` | clean static | full | none | none | +| `legend_swatch` | clean static | full | none | none | +| `legend_label` | clean static | full | partial | partial | +| `colorbar` | clean static | full | partial | partial | +| `colorbar_bar` | clean static | full | none | none | +| `colorbar_extension` | clean static | full | none | none | +| `colorbar_line` | clean static | full | none | none | +| `colorbar_tick` | clean static | full | partial | partial | +| `colorbar_minor_tick` | clean static | full | none | none | +| `colorbar_title` | clean static | full | partial | partial | +| `tooltip` | hover state | full | none | none | +| `tooltip_title` | hover state | full | none | none | +| `tooltip_row` | hover state | full | none | none | +| `tooltip_label` | hover state | full | none | none | +| `tooltip_value` | hover state | full | none | none | +| `modebar` | modebar state | full | none | none | +| `modebar_drag_handle` | modebar state | full | none | none | +| `modebar_control_group` | modebar state | full | none | none | +| `modebar_separator` | modebar state | full | none | none | +| `modebar_button` | modebar state | full | none | none | +| `modebar_icon` | modebar state | full | none | none | +| `modebar_zoom_value` | modebar state | full | none | none | +| `modebar_indicator` | modebar state | full | none | none | +| `modebar_selection_icon` | modebar state | full | none | none | +| `modebar_menu` | modebar state | full | none | none | +| `modebar_menu_separator` | modebar state | full | none | none | +| `modebar_menu_icon` | modebar state | full | none | none | +| `modebar_menu_label` | modebar state | full | none | none | +| `modebar_history_controls` | modebar state | full | none | none | +| `selection` | selection state | full | none | none | +| `crosshair_x` | crosshair state | full | none | none | +| `crosshair_y` | crosshair state | full | none | none | +| `badge` | view state | full | none | none | +| `badge_item` | view state | full | none | none | +| `axis_band` | clean static | full | none | none | +| `axis_line` | clean static | full | none | none | +| `tick_mark` | clean static | full | none | none | +| `tick_label` | clean static | full | partial | partial | +| `axis_title` | clean static | full | partial | partial | +| `annotation_label` | clean static | full | none | none | ### Notes diff --git a/spec/api/export.md b/spec/api/export.md index 259e403d..1cf8134a 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -254,6 +254,31 @@ vector** (`_svg.to_svg`, and `_pdf.svg_to_pdf` on top of it). | `xy.legend(style=...)` | yes | 6 keys | 6 keys | merged with the slot and the theme token before the writers see it | | `xy.colorbar(style=...)` | yes | **dropped** | **dropped** | no native channel; use `styles={"colorbar_title"/"colorbar_tick": ...}` | +### Asking a chart, not the table + +`chart.style_compatibility_report(target=..., engine=..., custom_css=...)` is +this section applied to one concrete chart, before any bytes exist. It lists +which styling sources the chart carries and routes every declared slot style +into exactly one of four outcomes — `survives`, `native-subset` (with the kept +and lost property names), `browser-only`, or `state-gated` — and mirrors the +export path's refusals (`custom_css` with a pinned native engine, Chromium +SVG) rather than re-deciding them. It is report-only: computing it never +changes an export. The staged `compatibility=` modes that act on the report +are a separate, later contract. + +Two properties are load-bearing. First, the report is constant-time when +there is nothing to route: no `class_names`, no per-slot `styles`, and no +`custom_css` means no slot walk — preflight is free exactly where exports are +hot. Second, *state-gated is not lost*. The capability registry tags every +slot with an applicability — present in a clean static export, or gated by an +export state (`hover`, `selection`, `crosshair`, `modebar`, `view` for the +reduction badges). A clean static file contains no tooltip, so a styled +tooltip is recorded with its gating state and does not count against +losslessness; counting it would overstate the parity gap by exactly the +chrome a static file never contains. `tests/test_capability_registry.py` +pins the partition (24 static, 24 state-gated today) and +`tests/test_style_compatibility_report.py` pins the routing. + ### Why two of those rows are silent, and why that is the right default `custom_css` raises because it is an author stylesheet: there is no honest diff --git a/tests/test_capability_registry.py b/tests/test_capability_registry.py index 88daaee2..850946ae 100644 --- a/tests/test_capability_registry.py +++ b/tests/test_capability_registry.py @@ -54,6 +54,49 @@ def test_registry_covers_exactly_the_public_dom_slots() -> None: assert tuple(slot.id for slot in caps.CHART_SLOTS) == CHART_DOM_SLOTS +def test_every_slot_has_exactly_one_applicability() -> None: + # The applicable-slot contract: a slot is either present in a clean static + # export or gated by exactly one export state. An unknown value here would + # quietly break the matrix column and the preflight routing. + for slot in caps.CHART_SLOTS: + assert slot.applicability in caps.APPLICABILITIES, slot.id + counts = caps.summary() + assert counts["chart_slots_static"] + counts["chart_slots_state_gated"] == len(caps.CHART_SLOTS) + + +def test_state_gated_families_cannot_be_misfiled() -> None: + # A new `modebar_*` (or tooltip/crosshair/badge) slot must carry its + # family's state; defaulting to "static" would overstate the clean-static + # surface — the direction that is easy to miss. + families = { + "tooltip": "hover", + "modebar": "modebar", + "crosshair_": "crosshair", + "badge": "view", + } + for slot in caps.CHART_SLOTS: + expected = next( + (state for prefix, state in families.items() if slot.id.startswith(prefix)), + "selection" if slot.id == "selection" else "static", + ) + assert slot.applicability == expected, ( + f"{slot.id!r} is {slot.applicability!r}, expected {expected!r}" + ) + + +def test_native_paths_exist_only_for_clean_static_chrome() -> None: + # Today no writer draws state-gated chrome (a frozen tooltip, a modebar): + # the interaction-snapshot phase changes this deliberately, and this test + # with it. Until then, a native support level on a gated slot would claim + # a path that does not exist. + for slot in caps.CHART_SLOTS: + if slot.applicability != "static": + assert slot.support["native_raster"] == "none", slot.id + assert slot.support["native_vector"] == "none", slot.id + counts = caps.summary() + assert counts["static_slots_native"] == counts["slots_styleable_natively"] + + def test_axis_style_registry_covers_the_compiler_vocabulary() -> None: expected = ( styles._AXIS_COLOR_PROPERTIES diff --git a/tests/test_style_compatibility_report.py b/tests/test_style_compatibility_report.py new file mode 100644 index 00000000..5712b3d4 --- /dev/null +++ b/tests/test_style_compatibility_report.py @@ -0,0 +1,215 @@ +"""The preflight report must mirror the export paths, never re-decide them. + +`chart.style_compatibility_report()` is report-only (running it changes no +export), constant-time for charts with nothing to route, and derives every +fact from the capability registry, the writers' own honored-property +constants, and the export module's engine resolver. These tests pin all three +properties, plus the applicable-slot contract: state-gated chrome (tooltip, +modebar, crosshair, selection, reduction badges) is recorded, not counted as +a loss a clean static file never contained. +""" + +from __future__ import annotations + +import pytest + +import xy +from xy import _svg, export +from xy.styling import capabilities as caps +from xy.styling import preflight as pf + + +def _chart(**props): + return xy.scatter_chart(xy.scatter([1.0, 2.0, 3.0], [2.0, 1.0, 3.0]), **props) + + +def _finding(report, slot, source): + matches = [f for f in report.findings if f.slot == slot and f.source == source] + assert len(matches) == 1, f"expected one finding for {source}[{slot!r}], got {matches!r}" + return matches[0] + + +# -- constant-time path ------------------------------------------------------ + + +@pytest.mark.parametrize("target", ["png", "jpeg", "webp", "svg", "pdf", "html"]) +def test_unstyled_chart_is_lossless_with_no_findings(target: str) -> None: + report = _chart().style_compatibility_report(target) + assert report.lossless + assert report.findings == () + assert report.losses == () + assert report.error is None + assert not any(report.sources.values()) + + +def test_chart_level_style_alone_stays_on_the_constant_time_path() -> None: + # The chart-level token bag is read by every renderer (capability matrix, + # `root` row), so it is a source that can never drop — the report says the + # source is present and walks nothing. + report = _chart(style={"background": "#111"}).style_compatibility_report("png") + assert report.sources["chart_style"] + assert report.lossless + assert report.findings == () + + +# -- routing: class_names ---------------------------------------------------- + + +def test_class_on_static_slot_is_lost_natively_and_kept_in_browser_targets() -> None: + chart = _chart(class_names={"legend": "bg-slate-900"}) + native = chart.style_compatibility_report("png") + assert not native.lossless + finding = _finding(native, "legend", "class_names") + assert finding.route == pf.ROUTE_BROWSER_ONLY + assert finding.lost == ("*",) + + assert chart.style_compatibility_report("html").lossless + chromium = chart.style_compatibility_report("png", engine=export.Engine.chromium) + assert chromium.engine == "browser" + assert chromium.lossless + + +def test_class_on_hover_slot_is_state_gated_not_lost() -> None: + # The applicable-slot contract: a clean static export contains no tooltip, + # so a styled tooltip is not "dropped" by one — the report records the + # gating state and stays lossless. + report = _chart(class_names={"tooltip": "rounded-xl"}).style_compatibility_report("png") + finding = _finding(report, "tooltip", "class_names") + assert finding.route == pf.ROUTE_STATE_GATED + assert finding.applicability == "hover" + assert finding.lost == () + assert report.lossless + + +# -- routing: per-slot styles ------------------------------------------------ + + +def test_subset_math_follows_the_writers_own_constants() -> None: + chart = _chart(styles={"tick_label": {"font_weight": 600, "letter_spacing": "0.08em"}}) + + raster = chart.style_compatibility_report("png") + finding = _finding(raster, "tick_label", "styles") + assert finding.route == pf.ROUTE_SUBSET + assert "font-weight" in finding.kept + assert finding.lost == ("letter-spacing",) + assert "letter-spacing" not in _svg.SLOT_RASTER_PROPS + + vector = chart.style_compatibility_report("svg") + finding = _finding(vector, "tick_label", "styles") + assert finding.route == pf.ROUTE_SURVIVES + assert finding.lost == () + assert "letter-spacing" in _svg.SLOT_TEXT_PROPS + + +def test_pdf_inherits_the_vector_subset() -> None: + chart = _chart(styles={"axis_title": {"letter_spacing": "0.1em"}}) + assert chart.style_compatibility_report("pdf").lossless + assert not chart.style_compatibility_report("jpeg").lossless + + +def test_styles_on_a_slot_with_no_native_path_are_named_lost() -> None: + report = _chart(styles={"legend_swatch": {"border-radius": "2px"}}) + report = report.style_compatibility_report("png") + finding = _finding(report, "legend_swatch", "styles") + assert finding.route == pf.ROUTE_BROWSER_ONLY + assert finding.lost == ("border-radius",) + assert not report.lossless + + +def test_root_styles_point_at_the_token_bag_channel() -> None: + report = _chart(styles={"root": {"background": "#111"}}).style_compatibility_report("png") + finding = _finding(report, "root", "styles") + assert finding.route == pf.ROUTE_BROWSER_ONLY + assert "style=" in finding.detail + + +def test_legend_styles_are_qualified_not_guessed() -> None: + # Box properties route through the merged legend declaration; until the + # shared IR makes that set a constant, the report qualifies rather than + # declaring a loss it cannot prove (or a survival it cannot prove). + report = _chart(styles={"legend": {"background": "black"}}).style_compatibility_report("png") + finding = _finding(report, "legend", "styles") + assert finding.route == pf.ROUTE_SUBSET + assert finding.lost == () + assert "merged legend declaration" in finding.detail + assert report.lossless + + +def test_state_gated_styles_do_not_block_lossless() -> None: + report = _chart( + styles={"modebar_button": {"background": "red"}, "crosshair_x": {"opacity": 0.5}} + ).style_compatibility_report("png") + routes = {f.slot: f.route for f in report.findings} + assert routes == { + "modebar_button": pf.ROUTE_STATE_GATED, + "crosshair_x": pf.ROUTE_STATE_GATED, + } + assert report.lossless + + +# -- engine interactions ----------------------------------------------------- + + +def test_custom_css_routes_auto_to_browser_and_stays_lossless() -> None: + report = _chart(class_names={"legend": "x"}).style_compatibility_report( + "png", custom_css=".x{}" + ) + assert report.engine == "browser" + assert report.lossless + + +def test_refusals_are_mirrored_not_re_decided() -> None: + # The export path raises for these; the report carries the same message + # instead of predicting a different outcome. + pinned = _chart().style_compatibility_report( + "png", engine=export.Engine.default, custom_css=".x{}" + ) + assert pinned.error is not None + assert not pinned.lossless + with pytest.raises(ValueError) as excinfo: + export._resolve_image_engine(export.Engine.default, "png", ".x{}") + assert pinned.error == str(excinfo.value) + + svg_chromium = _chart().style_compatibility_report("svg", engine=export.Engine.chromium) + assert svg_chromium.error is not None + + +# -- report surface ---------------------------------------------------------- + + +def test_explain_names_every_route() -> None: + text = ( + _chart( + class_names={"legend": "x", "tooltip": "y"}, + styles={"tick_label": {"letter_spacing": "0.08em"}}, + ) + .style_compatibility_report("png") + .explain() + ) + assert "browser-only" in text + assert "state-gated" in text + assert "letter-spacing" in text + assert "loss(es)" in text + + +def test_chart_report_is_the_figure_report() -> None: + chart = _chart(class_names={"legend": "x"}) + via_chart = chart.style_compatibility_report("png") + via_figure = chart.figure().style_compatibility_report("png") + assert via_chart == via_figure + + +def test_every_declared_style_lands_in_exactly_one_route() -> None: + # §28 as a property: styled slots partition into survives / subset / + # browser-only / state-gated. No styled slot may be absent from the + # report, and no route outside the vocabulary may appear. + styles = {slot.id: {"font-size": 10} for slot in caps.CHART_SLOTS} + report = _chart(styles=styles).style_compatibility_report("png") + assert {f.slot for f in report.findings} == {s.id for s in caps.CHART_SLOTS} + allowed = { + pf.ROUTE_SURVIVES, + pf.ROUTE_SUBSET, + pf.ROUTE_BROWSER_ONLY, + pf.ROUTE_STATE_GATED, + } + assert {f.route for f in report.findings} <= allowed From f6617943d76165edbe08fc226cbe05069751909e Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 4 Aug 2026 13:48:14 -0700 Subject: [PATCH 2/2] Close the preflight report's two mirror gaps and derive the summary counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the preflight change, all in the mirror-the-export direction: Browser-resolved targets now validate custom_css through the export path's own _custom_css_block (type check, and comment-sequence rejection), in the export's own order — engine resolution first. The report could previously call an export lossless that would refuse its stylesheet. Malformed figure styling raises exactly like the export instead of being skipped: class_names/chrome_styles are assignable, so a report can be requested before the spec build validates them, and a silently omitted entry would be a report hiding a declaration (§28). validate_dom_slots runs the same check the spec build runs; a non-mapping declaration set is refused by name. The writer family is now classified from both format sets with a refusal for anything unclassified, so a future format cannot silently be reported against the vector subset. The generated summary derives the styles={...} slot count from the registry instead of hard-coding "nine" (the axis_style_keys lesson: prose cannot hold a count). Both public entry points return the typed StyleCompatibilityReport rather than Any. --- python/xy/_figure.py | 7 ++- python/xy/components.py | 7 ++- python/xy/styling/capabilities.py | 4 ++ python/xy/styling/preflight.py | 63 +++++++++++++++++++----- scripts/gen_capability_matrix.py | 4 +- spec/api/capability-matrix.md | 2 +- tests/test_style_compatibility_report.py | 32 ++++++++++++ 7 files changed, 99 insertions(+), 20 deletions(-) diff --git a/python/xy/_figure.py b/python/xy/_figure.py index af80981f..98df36d4 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -12,7 +12,10 @@ import warnings from collections.abc import Mapping, Sequence from os import PathLike -from typing import Any, Optional, TypeAlias, Union +from typing import TYPE_CHECKING, Any, Optional, TypeAlias, Union + +if TYPE_CHECKING: + from .styling.preflight import StyleCompatibilityReport import numpy as np @@ -2223,7 +2226,7 @@ def style_compatibility_report( *, engine: Optional[export.Engine | str] = None, custom_css: Optional[str] = None, - ) -> Any: + ) -> StyleCompatibilityReport: """Route every declared style for one export target, without exporting. The programmatic answer to export.md §9: which styling sources this diff --git a/python/xy/components.py b/python/xy/components.py index cdc3934c..5a457e65 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -40,7 +40,10 @@ from dataclasses import dataclass, field, replace from functools import lru_cache from os import PathLike -from typing import Any, Literal, Optional, TypeAlias, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, TypeAlias, Union + +if TYPE_CHECKING: + from .styling.preflight import StyleCompatibilityReport import numpy as np @@ -4175,7 +4178,7 @@ def style_compatibility_report( *, engine: Optional[export.Engine | str] = None, custom_css: Optional[str] = None, - ) -> Any: + ) -> StyleCompatibilityReport: """What of this chart's styling survives an export to ``target``. Report-only preflight: lists the styling sources present, how each diff --git a/python/xy/styling/capabilities.py b/python/xy/styling/capabilities.py index a16b9825..fa425d92 100644 --- a/python/xy/styling/capabilities.py +++ b/python/xy/styling/capabilities.py @@ -465,6 +465,10 @@ def summary() -> dict[str, object]: "slots_styleable_natively": sum( 1 for s in CHART_SLOTS if s.support["native_raster"] != "none" ), + # The `styles={slot: ...}` channel specifically — the writers' own + # STATIC_STYLED_SLOTS, counted from the registry so generated prose + # cannot hold a stale number (the axis_style_keys lesson). + "slots_via_styles": sum(1 for s in CHART_SLOTS if s.channel.startswith("styles={")), "extension_points_shipped": sum(1 for e in EXTENSION_POINTS if e.status == "shipped"), "known_renderer_divergences": len(KNOWN_RENDERER_DIVERGENCES), } diff --git a/python/xy/styling/preflight.py b/python/xy/styling/preflight.py index d6d3d523..625a7b0a 100644 --- a/python/xy/styling/preflight.py +++ b/python/xy/styling/preflight.py @@ -27,6 +27,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Optional +from ..dom import validate_dom_slots from . import capabilities if TYPE_CHECKING: # pragma: no cover - import cycle guard, typing only @@ -114,19 +115,46 @@ def _resolve(target: str, engine: object, custom_css: Optional[str]) -> tuple[st Deferred import: `export` pulls in the browser-discovery machinery, and `capabilities` must stay importable from the docs generator without it. + + Browser-resolved targets validate `custom_css` through the export path's + own `_custom_css_block` — the same type check and ``/`