From a99c037dbf8c79fefa469015671e9ef8c94fa3fb Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 4 Aug 2026 12:57:38 -0700 Subject: [PATCH 01/26] 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 cc0fab7ab2614f923005a3c0a60f5b2b5509982f Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 4 Aug 2026 13:15:30 -0700 Subject: [PATCH 02/26] Stage the styling contract: compatibility= modes and the engine collision table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every image-export API (to_png, to_svg, to_image, write_image, and the write_images batch, at chart, figure, and module level) accepts compatibility=: "legacy" preserves today's behavior exactly, "warn" surfaces every declaration the export would drop as one StyleCompatibilityWarning, and "strict" raises StyleCompatibilityError before emission with the full preflight report attached. "lossless" is reserved and rejected: accepting the name before Engine.auto can actually re-route on preflight evidence would make it a lie. The default path pays one string comparison. The literal-"legacy" short-circuit returns before the preflight machinery is even imported, and export's StyleCompatibilityError/Warning aliases resolve through a module __getattr__, so importing xy.export stays exactly as heavy as it was before the modes existed (the preflight chain reaches the native library through the writers' constants — eagerly aliasing it would have added the dylib load to a module import that never had one). warn/strict pay the routing walk only when the chart carries class or per-slot declarations and the engine is native. Engine selection and compatibility stay orthogonal, and an explicit engine is a hard constraint: strict refuses on the pinned engine rather than re-routing to Chromium, a Chromium pin renders the full cascade and gives every mode nothing to do, and resolution errors (custom_css with a pinned native engine, Chromium SVG) precede and outrank mode logic in every mode. A strict batch fails whole while the plan is resolved, before any file is written. HTML export rejects a non-default mode like the other options it cannot honor, because a document that renders the full cascade has nothing to check. State-gated chrome never trips warn or strict in a clean static export: a file with no tooltip has dropped nothing by not styling one — the applicable-slot contract from the preflight change, now enforced rather than only reported. spec/process/style-compatibility-migration.md names the release each default flips in — warn in 0.0.7, strict at the 0.1.0 minor boundary, legacy removed in 0.2.0 — per the rule that a deprecation window is named when it opens, not "a future release". Facet grids deliberately do not accept the option yet rather than half-honoring it; their per-panel preflight is tracked there too. --- CHANGELOG.md | 9 + python/xy/_figure.py | 15 +- python/xy/components.py | 23 ++- python/xy/export.py | 61 +++++- python/xy/styling/preflight.py | 152 ++++++++++++--- spec/api/export.md | 9 +- spec/process/style-compatibility-migration.md | 64 +++++++ tests/test_components.py | 4 + tests/test_style_compatibility_modes.py | 173 ++++++++++++++++++ 9 files changed, 479 insertions(+), 31 deletions(-) create mode 100644 spec/process/style-compatibility-migration.md create mode 100644 tests/test_style_compatibility_modes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 683641b5..995018de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ in the README). ## [Unreleased] ### Added +- Every image-export API (`to_png`, `to_svg`, `to_image`, `write_image`, + `export.write_images`) accepts `compatibility=`: `"legacy"` (default — + behavior unchanged), `"warn"` (one `StyleCompatibilityWarning` naming each + declaration the export would drop), or `"strict"` + (`StyleCompatibilityError` before emission, preflight report attached). + Modes never re-route an explicit engine; `"lossless"` is reserved and + rejected until preflight routing exists. The default flips only on the + published schedule in `spec/process/style-compatibility-migration.md` + (warn in 0.0.7, strict in 0.1.0, legacy removed in 0.2.0). - `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 diff --git a/python/xy/_figure.py b/python/xy/_figure.py index af80981f..4c1bde65 100644 --- a/python/xy/_figure.py +++ b/python/xy/_figure.py @@ -2241,13 +2241,17 @@ def to_svg( *, width: Optional[int] = None, height: Optional[int] = None, + compatibility: str = "legacy", ) -> str: """Static SVG (_svg.py): a pure-Python render of the same decimated payload the browser client consumes — resolution-independent, tiny (screen-bounded regardless of source size), and dependency-free. - `width`/`height` override the figure's pixel size.""" + `width`/`height` override the figure's pixel size. `compatibility` + stages the styling contract: "warn" surfaces any declaration this + vector export would drop, "strict" refuses to drop one.""" from . import _svg + export._enforce_compatibility(self, "svg", "native", None, compatibility) return _svg.to_svg(self, path, width=width, height=height) def to_png( @@ -2262,6 +2266,7 @@ def to_png( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Static PNG (export.py). `engine=Engine.default` paints the decimated payload with the built-in Rust rasterizer — no browser, @@ -2283,6 +2288,7 @@ def to_png( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) def to_image( @@ -2299,13 +2305,15 @@ def to_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Unified static export: PNG/JPEG/WebP/SVG/PDF bytes (export.py). `engine=Engine.auto` is deterministic — the browser-free native path for every format, Chromium only when `custom_css` needs a real CSS engine. See `export.to_image` for the format, quality, and background - policies.""" + policies, and `compatibility=` ("legacy"/"warn"/"strict") for the + staged styling contract.""" return export.to_image( self, format, @@ -2319,6 +2327,7 @@ def to_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) def write_image( @@ -2336,6 +2345,7 @@ def write_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Atomic file export with extension-inferred format (export.py): .png/.jpg/.jpeg/.webp/.svg/.pdf, plus .html routing to `to_html`.""" @@ -2353,6 +2363,7 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) def memory_report(self) -> dict[str, Any]: diff --git a/python/xy/components.py b/python/xy/components.py index cdc3934c..dcafe490 100644 --- a/python/xy/components.py +++ b/python/xy/components.py @@ -4196,9 +4196,15 @@ def to_svg( *, width: Optional[int] = None, height: Optional[int] = None, + compatibility: str = "legacy", ) -> str: - """A static SVG render of the chart (written to ``path`` if given).""" - return self.figure().to_svg(path, width=width, height=height) + """A static SVG render of the chart (written to ``path`` if given). + + ``compatibility`` stages the styling contract: ``"warn"`` surfaces + any declaration this export would drop, ``"strict"`` refuses to drop + one; the default preserves current behavior. + """ + return self.figure().to_svg(path, width=width, height=height, compatibility=compatibility) def to_png( self, @@ -4212,12 +4218,14 @@ def to_png( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """A PNG render of the chart, returned as bytes. ``scale`` multiplies the pixel density; ``engine`` picks the raster path (native or headless Chromium). Written to ``path`` - when given. + when given. ``compatibility`` stages the styling contract + (``"legacy"``/``"warn"``/``"strict"``). """ return self.figure().to_png( path, @@ -4229,6 +4237,7 @@ def to_png( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) def _export_defaults( @@ -4278,12 +4287,14 @@ def to_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Unified static export: PNG/JPEG/WebP/SVG/PDF bytes. Omitted width/height/scale/background/quality fall back to the chart's `export_config` defaults; explicit arguments override them. - See `export.to_image` for the full format/engine/background policy.""" + See `export.to_image` for the full format/engine/background policy + and `compatibility=` for the staged styling contract.""" fmt = export._normalize_format(format) resolved = export._resolve_image_engine(engine, fmt, custom_css) return self.figure().to_image( @@ -4293,6 +4304,7 @@ def to_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, **self._export_defaults( fmt, width, @@ -4319,6 +4331,7 @@ def write_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Atomic file export with extension-inferred format (.png/.jpg/ .jpeg/.webp/.svg/.pdf/.html). `export_config` defaults apply as in @@ -4356,6 +4369,7 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) return self.figure().write_image( path, @@ -4365,6 +4379,7 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, **defaults, ) diff --git a/python/xy/export.py b/python/xy/export.py index f2f3dec1..dc124292 100644 --- a/python/xy/export.py +++ b/python/xy/export.py @@ -43,6 +43,19 @@ class Engine(StrEnum): chromium = "chromium" +def __getattr__(name: str) -> object: + # StyleCompatibilityError / StyleCompatibilityWarning are catchable from + # the module users already import for `Engine`, but resolved lazily: the + # preflight chain reaches the native library via the writers' constants, + # and importing this module must stay exactly as heavy as it was before + # the compatibility modes existed. + if name in ("StyleCompatibilityError", "StyleCompatibilityWarning"): + from .styling import preflight as _preflight + + return getattr(_preflight, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + # Warn above this payload size; base64 carries a stated ~33% tax (§29). EMBED_WARN_BYTES = 64 * 2**20 @@ -649,6 +662,7 @@ def write_images( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> list[bytes]: """Export many figures through ONE amortized pipeline (mixed formats OK). @@ -665,7 +679,10 @@ def write_images( exactly as in `Chart.to_image`. Writes are atomic per file; on error, files already exported remain. Other options match `to_image`; quality applies to JPEG and Chromium WebP and is ignored by the other formats - (native WebP stays lossless), so mixed batches stay ergonomic.""" + (native WebP stays lossless), so mixed batches stay ergonomic. + `compatibility=` applies per figure while the plan is resolved, so a + strict batch refuses whole — before any file is written — rather than + after a partial export.""" if figures is not None: if figs is not None: raise ValueError("pass figs positionally or figures=, not both") @@ -709,6 +726,9 @@ def write_images( plan.append((fig, path, fmt, "html", {}, None, None)) continue resolved = _resolve_image_engine(engine, fmt, custom_css) + # Per figure, up front with the rest of the plan: a strict batch + # fails whole before any file is written, never after a partial one. + _enforce_compatibility(fig, fmt, resolved, custom_css, compatibility) if callable(getattr(obj, "_export_defaults", None)): settings = obj._export_defaults( fmt, @@ -792,6 +812,7 @@ def to_png( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Rasterize `fig` to a PNG (bytes, optionally saved). @@ -819,6 +840,7 @@ def to_png( optimize = _bool_option(optimize, "PNG optimize") sandbox = _bool_option(sandbox, "PNG sandbox") resolved_engine = _png_engine(engine) + _enforce_compatibility(fig, "png", resolved_engine, custom_css, compatibility) if resolved_engine == "native": if custom_css is not None: raise ValueError("custom_css requires engine=Engine.chromium") @@ -899,6 +921,35 @@ def _infer_format(path: str | PathLike[str]) -> str: ) from None +def _enforce_compatibility( + fig: "Figure", + fmt: str, + resolved_engine: str, + custom_css: Optional[str], + compatibility: str, +) -> None: + """Apply the staged compatibility mode to one already-resolved export. + + The literal-"legacy" short-circuit is the whole performance contract: + the default export path does one string comparison and never imports the + preflight machinery. Everything else — mode validation, the constant-time + unstyled path, warning versus refusing — lives in + `styling.preflight.enforce`. Modes never re-route an engine; they decide + whether to proceed, warn, or refuse on the engine the caller resolved. + """ + if compatibility == "legacy": + return + from .styling import preflight as _preflight + + _preflight.enforce( + fig, + fmt=fmt, + resolved_engine=resolved_engine, + custom_css=custom_css, + compatibility=compatibility, + ) + + def _resolve_image_engine(engine: object, fmt: str, custom_css: Optional[str]) -> str: """Deterministic engine selection: -> "native" | "browser". @@ -1137,6 +1188,7 @@ def to_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Render `fig` to image bytes in the requested `format`. @@ -1154,6 +1206,7 @@ def to_image( bounded rasters (the documented hybrid-vector policy).""" fmt = _normalize_format(format) resolved_engine = _resolve_image_engine(engine, fmt, custom_css) + _enforce_compatibility(fig, fmt, resolved_engine, custom_css, compatibility) quality = _validated_quality(quality, fmt, resolved_engine) background = _validated_background(background, fmt) w, h = _export_dimensions(fig, width, height) @@ -1201,6 +1254,7 @@ def write_image( custom_css: Optional[str] = None, sandbox: bool = True, gl: str = "software", + compatibility: str = "legacy", ) -> bytes: """Export `fig` to `path`, inferring the format from the extension. @@ -1221,6 +1275,10 @@ def write_image( ("background", background, None), ("quality", quality, None), ("optimize", optimize, False), + # HTML renders the full cascade in the browser — nothing can + # drop, so a compatibility mode has nothing to check and is + # rejected like the other options that cannot apply. + ("compatibility", compatibility, "legacy"), ) if value != default ] @@ -1246,6 +1304,7 @@ def write_image( custom_css=custom_css, sandbox=sandbox, gl=gl, + compatibility=compatibility, ) _atomic_write_bytes(path, data) return data diff --git a/python/xy/styling/preflight.py b/python/xy/styling/preflight.py index d6d3d523..04d928fe 100644 --- a/python/xy/styling/preflight.py +++ b/python/xy/styling/preflight.py @@ -39,6 +39,32 @@ ROUTE_BROWSER_ONLY = "browser-only" ROUTE_STATE_GATED = "state-gated" +#: The staged compatibility modes, in rollout order. `legacy` is today's +#: behavior and the default; `warn` surfaces every loss as one +#: `StyleCompatibilityWarning`; `strict` refuses to emit bytes that drop a +#: declaration. "lossless" is reserved for the phase that lets `Engine.auto` +#: re-route on preflight evidence — accepting it before that phase would make +#: the name a lie, so it is rejected now. The default flips on the schedule in +#: `spec/process/style-compatibility-migration.md`, never silently. +COMPATIBILITY_MODES: tuple[str, ...] = ("legacy", "warn", "strict") + + +class StyleCompatibilityWarning(UserWarning): + """An export in `compatibility="warn"` mode dropped declared styling.""" + + +class StyleCompatibilityError(ValueError): + """An export in `compatibility="strict"` mode refused to drop styling. + + Carries the full preflight `report`; the message is its `explain()` plus + the ways out, so the fix is in the traceback rather than a docs hunt. + """ + + def __init__(self, message: str, report: StyleCompatibilityReport) -> None: + super().__init__(message) + self.report = report + + _RASTER_FORMATS = frozenset({"png", "jpeg", "webp"}) _VECTOR_FORMATS = frozenset({"svg", "pdf"}) @@ -238,39 +264,28 @@ def _styles_finding(slot: str, decls: dict[str, Any], fmt: str) -> SlotFinding: ) -def preflight( +def route_resolved( figure: Figure, *, - target: str = "png", - engine: object = None, + fmt: str, + resolved_engine: str, custom_css: Optional[str] = None, ) -> StyleCompatibilityReport: - """Route every declared style for one export target, without exporting. + """The routing core, for callers that already resolved format and engine. - `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. + The export paths call this with their own resolution result so the + resolver (and its deprecation warnings) runs exactly once per export; + `preflight()` wraps it with a resolution of its own for standalone use. """ - 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": + if resolved_engine == "browser": # The live client renders the full cascade; nothing can drop. - return StyleCompatibilityReport(target=fmt, engine=resolved, sources=sources) + return StyleCompatibilityReport(target=fmt, engine=resolved_engine, 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) + return StyleCompatibilityReport(target=fmt, engine=resolved_engine, sources=sources) findings: list[SlotFinding] = [] for slot in figure.class_names: @@ -287,7 +302,7 @@ def preflight( ) return StyleCompatibilityReport( target=fmt, - engine=resolved, + engine=resolved_engine, sources=sources, findings=tuple(findings), losses=losses, @@ -295,12 +310,105 @@ def preflight( ) +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) + if error: + return StyleCompatibilityReport( + target=fmt, + engine=resolved, + sources=_sources(figure, custom_css), + lossless=False, + losses=(error,), + error=error, + ) + return route_resolved(figure, fmt=fmt, resolved_engine=resolved, custom_css=custom_css) + + +def validate_compatibility(value: object) -> str: + """The mode, or a loud error naming the vocabulary (and the reserved word).""" + if isinstance(value, str) and value in COMPATIBILITY_MODES: + return value + if value == "lossless": + raise ValueError( + 'compatibility="lossless" is reserved for the lossless-routing phase ' + "(spec/process/style-compatibility-migration.md); until it can re-route " + f"engines on preflight evidence, pick one of {COMPATIBILITY_MODES}" + ) + raise ValueError(f"compatibility must be one of {COMPATIBILITY_MODES}, got {value!r}") + + +def enforce( + figure: Figure, + *, + fmt: str, + resolved_engine: str, + custom_css: Optional[str], + compatibility: str, +) -> None: + """Apply a compatibility mode to one already-resolved export. + + `legacy` returns before doing anything at all — the default path pays + zero. `warn` and `strict` pay the preflight only when the chart carries + class or per-slot declarations and the engine is native; a lossless + report also returns quietly. Engines are never re-routed here: the mode + decides whether to proceed, warn, or refuse — never where to render + (spec/process/style-compatibility-migration.md pins that contract). + """ + import warnings + + mode = validate_compatibility(compatibility) + if mode == "legacy": + return + if resolved_engine == "browser" or not (figure.class_names or figure.chrome_styles): + return + report = route_resolved(figure, fmt=fmt, resolved_engine=resolved_engine, custom_css=custom_css) + if report.lossless: + return + if mode == "warn": + warnings.warn( + StyleCompatibilityWarning( + f"this {fmt} export drops declared styling — " + + "; ".join(report.losses) + + " — chart.style_compatibility_report() has the full routing; " + "engine=Engine.chromium or to_html() keeps everything" + ), + stacklevel=2, + ) + return + raise StyleCompatibilityError( + report.explain() + "\nstrict compatibility refuses to drop declared styling; keep it with " + "engine=Engine.chromium or to_html(), move it into chart/mark style= or a " + 'supported styles= subset, or export with compatibility="warn" during ' + "migration", + report, + ) + + __all__ = [ + "COMPATIBILITY_MODES", "ROUTE_BROWSER_ONLY", "ROUTE_STATE_GATED", "ROUTE_SUBSET", "ROUTE_SURVIVES", "SlotFinding", + "StyleCompatibilityError", "StyleCompatibilityReport", + "StyleCompatibilityWarning", + "enforce", "preflight", + "route_resolved", + "validate_compatibility", ] diff --git a/spec/api/export.md b/spec/api/export.md index 1cf8134a..5deb2dd9 100644 --- a/spec/api/export.md +++ b/spec/api/export.md @@ -263,8 +263,13 @@ 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. +changes an export. The staged `compatibility=` modes act on this report: +`legacy` (the default — today's behavior, one string comparison of cost), +`warn` (one `StyleCompatibilityWarning` naming every loss), and `strict` +(`StyleCompatibilityError` before emission, report attached). `"lossless"` +is reserved and rejected until preflight routing exists. Modes never +re-route an explicit engine, and the default only flips on the schedule in +`spec/process/style-compatibility-migration.md`. 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 diff --git a/spec/process/style-compatibility-migration.md b/spec/process/style-compatibility-migration.md new file mode 100644 index 00000000..879ab1ea --- /dev/null +++ b/spec/process/style-compatibility-migration.md @@ -0,0 +1,64 @@ +# Style-compatibility migration + +The staged path from "native exports silently drop `class_names`" to "no +renderer drops a declaration without saying so" — with the release each step +ships in named now, so none of them can quietly become permanent. The +programmatic foundation is `chart.style_compatibility_report()` +(`spec/api/export.md` §9) and the `compatibility=` export option. + +## The modes + +| Mode | Behavior | Cost on the unstyled path | +| --- | --- | --- | +| `legacy` | Exactly today's behavior: browser-only declarations drop silently from native exports. | One string comparison; the preflight machinery is not even imported. | +| `warn` | Every export that would drop a declaration emits one `StyleCompatibilityWarning` naming each loss. Bytes are still produced. | Zero for charts with no `class_names`/`styles`/`custom_css` (constant-time early-out). | +| `strict` | An export that would drop a declaration raises `StyleCompatibilityError` **before emission**, carrying the full preflight report and the ways out. | Same early-out as `warn`. | +| `lossless` | **Reserved, rejected today.** Arrives with the preflight-routing phase, where `Engine.auto` may choose a different lossless route on report evidence. Accepting the name before the routing exists would make it a lie. | — | + +State-gated chrome never trips `warn`/`strict` in a clean static export: a +file with no tooltip has dropped nothing by not styling one (the +applicable-slot contract, `spec/api/export.md` §9). + +## The engine contract + +Engine selection and compatibility are orthogonal, and **an explicit engine +is a hard constraint — no compatibility mode may re-route it**: + +| Request | Behavior | +| --- | --- | +| `compatibility="strict"`, explicit engine | Stay on the pinned engine; fail before emission on every unsupported declaration. | +| `compatibility="warn"` or `"legacy"`, explicit engine | Stay on the pinned engine; warn, or preserve legacy behavior, respectively. | +| any mode, `engine=Engine.chromium` (or resolved browser) | The live client renders the full cascade; nothing can drop, so the mode has nothing to do. | +| any mode, `custom_css` with a pinned native engine | Today's `ValueError` fires unchanged — resolution errors precede and outrank mode logic. | +| future `"lossless"`, `engine=Engine.auto` | Native only when preflight proves lossless; otherwise Chromium where the format supports it; otherwise raise. | +| future `"lossless"`, pinned engine | Never overridden: raise "cannot satisfy lossless natively; supply a snapshot/stylesheet or unpin the engine." | + +`Engine.auto`'s current rule — native for every format, Chromium only when +`custom_css` needs a real CSS engine — is unchanged until the lossless phase, +and changing it then requires prominent release notes (speed, determinism, +dependency, and security posture all shift with an engine). + +## The schedule + +Pre-1.0, minor versions may break (README stability table); these are the +concrete releases each default flips in. Moving a step **later** needs only a +changelog note; moving one **earlier** is a breaking change and needs the +same notice a breaking release gets. + +| Release | Change | +| --- | --- | +| 0.0.6 | `compatibility=` ships, default `legacy`. `warn`/`strict` are opt-in. Docs recommend `warn`. | +| 0.0.7 | Default flips to `warn`: silent drops end. `legacy` silences per call site. | +| 0.1.0 | Default flips to `strict` at the minor boundary. `warn` and `legacy` remain as opt-outs. | +| 0.2.0 | `legacy` is **removed** (per the Phase-0 rule that the removal release is named at announcement). `warn` remains indefinitely as the non-fatal mode. | +| lossless phase | `"lossless"` unreserved once preflight routing exists; `Engine.auto` may then take the lossless route by default, behind its own release note. | + +## Out of scope here, tracked + +- **Facet grids** keep `legacy` behavior regardless of the option until their + per-panel preflight lands; `FacetGrid` export does not accept + `compatibility=` yet rather than accepting and half-honoring it. +- The **resolved-style snapshot** (shared IR) makes the legend slot's + declaration-level qualification property-exact; until then `strict` does + not fail on legend box properties it cannot prove either way (§28: unsure + is said out loud, not rounded to an error). diff --git a/tests/test_components.py b/tests/test_components.py index 5e0007cb..c5fac5a3 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -1982,6 +1982,7 @@ def fake_to_png( custom_css=None, sandbox=True, gl="software", + compatibility="legacy", ): seen.update( { @@ -1995,6 +1996,7 @@ def fake_to_png( "custom_css": custom_css, "sandbox": sandbox, "gl": gl, + "compatibility": compatibility, } ) return b"PNG" @@ -2011,6 +2013,7 @@ def fake_to_png( custom_css=".chart { color: rebeccapurple; }", sandbox=False, gl="hardware", + compatibility="warn", ) assert data == b"PNG" @@ -2025,6 +2028,7 @@ def fake_to_png( "custom_css": ".chart { color: rebeccapurple; }", "sandbox": False, "gl": "hardware", + "compatibility": "warn", } diff --git a/tests/test_style_compatibility_modes.py b/tests/test_style_compatibility_modes.py new file mode 100644 index 00000000..a3db4f30 --- /dev/null +++ b/tests/test_style_compatibility_modes.py @@ -0,0 +1,173 @@ +"""The staged `compatibility=` modes: legacy is untouched, warn says every +loss out loud once, strict refuses before emission, and no mode ever +re-routes an explicit engine (spec/process/style-compatibility-migration.md). +""" + +from __future__ import annotations + +import warnings + +import pytest + +import xy +from xy import export +from xy.styling.preflight import ( + StyleCompatibilityError, + StyleCompatibilityWarning, + validate_compatibility, +) + + +def _chart(**props): + return xy.scatter_chart(xy.scatter([1.0, 2.0, 3.0], [2.0, 1.0, 3.0]), **props) + + +def _lossy_chart(): + return _chart(class_names={"legend": "bg-slate-900"}) + + +# -- vocabulary -------------------------------------------------------------- + + +def test_unknown_modes_fail_loudly_and_lossless_is_reserved() -> None: + with pytest.raises(ValueError, match="legacy"): + _chart().to_png(compatibility="Legacy") + with pytest.raises(ValueError, match="reserved"): + _lossy_chart().to_png(compatibility="lossless") + # An invalid mode fails even when nothing could drop: vocabulary errors + # must not depend on what happens to be styled. + with pytest.raises(ValueError, match="compatibility"): + _chart().to_png(compatibility="stricted") + assert validate_compatibility("warn") == "warn" + + +# -- legacy: byte-identical, zero machinery ---------------------------------- + + +def test_legacy_output_is_byte_identical_to_the_default() -> None: + chart = _lossy_chart() + assert chart.to_png() == chart.to_png(compatibility="legacy") + assert chart.to_svg() == chart.to_svg(compatibility="legacy") + + +def test_legacy_never_warns() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error", StyleCompatibilityWarning) + _lossy_chart().to_png() + _lossy_chart().to_png(compatibility="legacy") + + +# -- warn -------------------------------------------------------------------- + + +def test_warn_names_each_loss_once_and_still_emits_bytes() -> None: + with pytest.warns(StyleCompatibilityWarning, match=r"class_names\['legend'\]") as caught: + data = _lossy_chart().to_png(compatibility="warn") + assert data[:8] == b"\x89PNG\r\n\x1a\n" + assert len([w for w in caught if w.category is StyleCompatibilityWarning]) == 1 + + +def test_warn_is_silent_when_nothing_drops() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error", StyleCompatibilityWarning) + # Unstyled: the constant-time early-out. + _chart().to_png(compatibility="warn") + # State-gated only: a clean static file contains no tooltip to lose. + _chart(styles={"tooltip": {"color": "red"}}).to_png(compatibility="warn") + # Vector keeps the full text subset this declaration uses. + _chart(styles={"tick_label": {"letter_spacing": "0.08em"}}).to_svg(compatibility="warn") + + +def test_warn_applies_to_the_vector_writers_too() -> None: + with pytest.warns(StyleCompatibilityWarning, match="tick_label"): + _chart(styles={"tick_label": {"letter_spacing": "0.08em"}}).to_png(compatibility="warn") + + +# -- strict ------------------------------------------------------------------ + + +def test_strict_refuses_before_emission_with_the_report_attached(tmp_path) -> None: + target = tmp_path / "chart.png" + with pytest.raises(StyleCompatibilityError) as excinfo: + _lossy_chart().write_image(target, compatibility="strict") + assert not target.exists(), "strict must fail before any bytes are written" + report = excinfo.value.report + assert not report.lossless + assert any(f.slot == "legend" for f in report.findings) + assert "chromium" in str(excinfo.value).lower() + + +def test_strict_passes_lossless_exports_untouched() -> None: + chart = _chart(styles={"title": {"font-size": 18}}) + assert chart.to_png(compatibility="strict") == chart.to_png() + assert chart.to_svg(compatibility="strict") == chart.to_svg() + + +def test_strict_batch_fails_whole_before_any_file(tmp_path) -> None: + clean, lossy = _chart(), _lossy_chart() + paths = [tmp_path / "a.png", tmp_path / "b.png"] + with pytest.raises(StyleCompatibilityError): + export.write_images([clean, lossy], [str(p) for p in paths], compatibility="strict") + assert not any(p.exists() for p in paths) + + +# -- the engine contract ----------------------------------------------------- + + +def test_no_mode_reroutes_an_explicit_engine() -> None: + # Chromium pin + lossy styling: the browser renders the cascade, so every + # mode proceeds without warning or error — and none of them may fall back + # to native. Native pin + lossy styling: strict refuses rather than + # re-routing to Chromium. + chart = _lossy_chart() + if export.find_chromium() is not None: + with warnings.catch_warnings(): + warnings.simplefilter("error", StyleCompatibilityWarning) + data = chart.to_png(engine=export.Engine.chromium, compatibility="strict") + assert data[:8] == b"\x89PNG\r\n\x1a\n" + with pytest.raises(StyleCompatibilityError): + chart.to_png(engine=export.Engine.default, compatibility="strict") + + +def test_resolution_errors_precede_and_outrank_mode_logic() -> None: + # custom_css with a pinned native engine raises today's ValueError in + # every mode — never a StyleCompatibilityError, never a silent re-route. + for mode in ("legacy", "warn", "strict"): + with pytest.raises(ValueError, match="custom_css requires"): + _chart().to_png(engine=export.Engine.default, custom_css=".x{}", compatibility=mode) + + +def test_auto_with_custom_css_is_lossless_in_every_mode() -> None: + if export.find_chromium() is None: + pytest.skip("Chromium unavailable") + chart = _lossy_chart() + with warnings.catch_warnings(): + warnings.simplefilter("error", StyleCompatibilityWarning) + data = chart.to_image("png", custom_css=".x{}", compatibility="strict") + assert data[:8] == b"\x89PNG\r\n\x1a\n" + + +# -- routing symmetry -------------------------------------------------------- + + +def test_every_image_entry_point_honors_the_mode(tmp_path) -> None: + chart = _lossy_chart() + with pytest.raises(StyleCompatibilityError): + chart.to_image("jpeg", compatibility="strict") + with pytest.raises(StyleCompatibilityError): + chart.to_svg(compatibility="strict") + with pytest.raises(StyleCompatibilityError): + chart.figure().write_image(tmp_path / "x.pdf", compatibility="strict") + with pytest.raises(StyleCompatibilityError): + export.to_png(chart.figure(), compatibility="strict") + + +def test_html_rejects_a_compatibility_mode_like_other_inapplicable_options( + tmp_path, +) -> None: + # HTML renders the full cascade; there is nothing for a mode to check, so + # write_image treats it like the other options HTML cannot honor. + with pytest.raises(ValueError, match="compatibility"): + _chart().write_image(tmp_path / "chart.html", compatibility="strict") + # The default passes through untouched. + _chart().write_image(tmp_path / "chart2.html") From 0b97ae5407ce1fb996a4a93cbd4f6afb028a9d4a Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 4 Aug 2026 13:27:06 -0700 Subject: [PATCH 03/26] Land the ResolvedStyleSnapshot: schema v1, interned, with a generated TS mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit python/xy/styling/resolved.py defines the renderer-neutral styling IR the compatibility program converges on: authored styling arrives from five mechanisms and (soon) two resolvers, and renderers should consume exactly one shape regardless of where it came from. Three contract properties, each enforced at construction on both ends of the eventual wire: Concrete values only. No var()/calc()/env()/inherit and no relative units: a value that still depends on a cascade, or on metrics the consumer would have to re-derive, is rejected loudly with the reason (§28). One unresolved value smuggled into the IR would re-create exactly the per-renderer divergence the IR exists to end. Interned declarations. A snapshot stores each distinct declaration once; instances reference it by index and carry only identity qualifiers (e.g. ["y","major","3"]), resolved geometry, and content. Interning is canonicalized, so a builder fed the same styling in any order emits the same snapshot. The dense-axis fixture (400 tick labels + 60 legend rows = 460 instances, 2 declarations) serializes to ~38.6 KB against the spec's 50 KB uncompressed budget, and the test states the headroom so eating it is a visible decision. Closed vocabulary per version. Schema v1's property list (paint, typography, layout, effects) is a generated constant in both languages; growing it is a STYLE_SNAPSHOT_VERSION bump, so a snapshot's vocabulary is always recoverable from its version field. snapshot_from_payload refuses versions it does not know rather than guessing. js/src/14_style_snapshot.ts is the TypeScript mirror, rendered by scripts/gen_style_snapshot_types.py from the Python module — one schema, two languages, and the suite runs --check so the committed mirror cannot drift (the gen_capability_matrix.py contract, applied to types). The client build typechecks it; nothing imports it yet. Nothing rides the wire in this change, so PROTOCOL_VERSION stays at 12. wire-protocol.md gains §8 documenting the payload shape and reserving the style_snapshot_request / style_snapshot message names for the capture change, which bumps the protocol and carries this schema as its reply. --- CHANGELOG.md | 7 + js/src/14_style_snapshot.ts | 105 +++++++ python/xy/styling/__init__.py | 8 +- python/xy/styling/resolved.py | 402 ++++++++++++++++++++++++++ scripts/gen_style_snapshot_types.py | 136 +++++++++ spec/design/wire-protocol.md | 51 ++++ tests/test_resolved_style_snapshot.py | 209 +++++++++++++ 7 files changed, 916 insertions(+), 2 deletions(-) create mode 100644 js/src/14_style_snapshot.ts create mode 100644 python/xy/styling/resolved.py create mode 100644 scripts/gen_style_snapshot_types.py create mode 100644 tests/test_resolved_style_snapshot.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 995018de..85aeb1a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ in the README). ## [Unreleased] ### Added +- The renderer-neutral styling IR: `xy.styling.resolved` defines the + versioned, interned `ResolvedStyleSnapshot` (schema v1 — concrete values + only, declarations deduped, instances referencing them by index), with a + generated TypeScript mirror (`js/src/14_style_snapshot.ts`) that the test + suite pins to the Python schema. Wire shape and reserved message names: + `spec/design/wire-protocol.md` §8; nothing rides the wire yet, so + `PROTOCOL_VERSION` is unchanged. - Every image-export API (`to_png`, `to_svg`, `to_image`, `write_image`, `export.write_images`) accepts `compatibility=`: `"legacy"` (default — behavior unchanged), `"warn"` (one `StyleCompatibilityWarning` naming each diff --git a/js/src/14_style_snapshot.ts b/js/src/14_style_snapshot.ts new file mode 100644 index 00000000..4f175541 --- /dev/null +++ b/js/src/14_style_snapshot.ts @@ -0,0 +1,105 @@ +// @generated by scripts/gen_style_snapshot_types.py — do not edit by hand. +// +// The TypeScript mirror of `python/xy/styling/resolved.py` (schema v1). +// Concrete values only: no var()/calc(), no relative units — the Python +// side rejects them at construction and the capture side must never +// produce them. Wire shape: spec/design/wire-protocol.md §8. + +export const STYLE_SNAPSHOT_VERSION = 1 as const; + +export const STYLE_SNAPSHOT_PAINT_PROPERTIES = [ + "color", + "fill", + "background", + "background-image", + "opacity", + "fill-opacity", + "stroke", + "stroke-opacity", + "stroke-width", + "border-color", + "border-style", + "border-width", + "border-radius", + "box-shadow", +] as const; + +export const STYLE_SNAPSHOT_TYPOGRAPHY_PROPERTIES = [ + "font-family", + "font-size", + "font-style", + "font-weight", + "letter-spacing", + "line-height", + "text-align", + "xy-rotation", +] as const; + +export const STYLE_SNAPSHOT_LAYOUT_PROPERTIES = [ + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "gap", + "width", + "height", + "max-width", + "max-height", + "transform", + "clip-path", +] as const; + +export const STYLE_SNAPSHOT_EFFECT_PROPERTIES = [ + "filter", + "mix-blend-mode", + "isolation", + "mask", +] as const; + +export const STYLE_SNAPSHOT_PROPERTIES = [ + ...STYLE_SNAPSHOT_PAINT_PROPERTIES, + ...STYLE_SNAPSHOT_TYPOGRAPHY_PROPERTIES, + ...STYLE_SNAPSHOT_LAYOUT_PROPERTIES, + ...STYLE_SNAPSHOT_EFFECT_PROPERTIES, +] as const; + +export type StyleSnapshotProperty = (typeof STYLE_SNAPSHOT_PROPERTIES)[number]; + +/** One interned declaration: resolved property -> concrete value. */ +export type ResolvedDeclaration = Partial< + Record +>; + +/** One styled slot occurrence; `d` indexes the snapshot's declarations. + * Instance keys are one-letter on the wire because instances are the + * part that repeats with chart density (spec §8). */ +export interface StyleSnapshotInstance { + /** slot name (a `data-xy-slot` value) */ + s: string; + /** declaration index into `declarations` */ + d: number; + /** stable identity beyond the slot name, e.g. ["y", "major", "3"] */ + q?: readonly string[]; + /** resolved box in CSS px: [x, y, w, h] */ + g?: readonly [number, number, number, number]; + /** drawn text, when the slot has any */ + c?: string; +} + +export interface StyleSnapshotEnvironment { + width: number; + height: number; + dpr: number; + color_scheme: "light" | "dark"; +} + +export interface ResolvedStyleSnapshot { + version: 1; + style_epoch: number; + environment: StyleSnapshotEnvironment; + tokens: Record; + states: readonly string[]; + unrepresentable: readonly string[]; + declarations: readonly ResolvedDeclaration[]; + instances: readonly StyleSnapshotInstance[]; +} diff --git a/python/xy/styling/__init__.py b/python/xy/styling/__init__.py index 5d71f30d..6020fd65 100644 --- a/python/xy/styling/__init__.py +++ b/python/xy/styling/__init__.py @@ -8,10 +8,14 @@ `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. + +`resolved` is the renderer-neutral styling IR those two converge on: the +versioned, interned `ResolvedStyleSnapshot` of concrete values that every +resolver produces and every renderer consumes. """ from __future__ import annotations -from . import capabilities, preflight +from . import capabilities, preflight, resolved -__all__ = ["capabilities", "preflight"] +__all__ = ["capabilities", "preflight", "resolved"] diff --git a/python/xy/styling/resolved.py b/python/xy/styling/resolved.py new file mode 100644 index 00000000..cf4d4261 --- /dev/null +++ b/python/xy/styling/resolved.py @@ -0,0 +1,402 @@ +"""The `ResolvedStyleSnapshot`: one interned styling IR between every source +and every renderer. + +Authored styling arrives from five mechanisms and two resolvers (the Python +style compiler today, the browser's computed-style capture next); renderers +should consume exactly one shape regardless of where it came from. That shape +is this module: **concrete values only** — a resolved color, a pixel length, +a settled font descriptor — never a `var()`, a `calc()`, an `em`, or anything +else whose meaning depends on a document the renderer does not have. A value +that still needs resolving is rejected loudly at construction (§28), because +a snapshot that smuggles one unresolved value re-creates in the IR the exact +per-renderer divergence the IR exists to end. + +Declarations are **interned**: a snapshot stores each distinct declaration +once and instances reference it by index, so four hundred tick labels styled +alike cost one declaration plus four hundred three-item instances — the +size/capture budgets in the spec assume this, and +`tests/test_resolved_style_snapshot.py` enforces it with a dense-axis +fixture. + +The schema is versioned independently of the wire protocol +(`STYLE_SNAPSHOT_VERSION`): nothing here rides the wire yet, so +`PROTOCOL_VERSION` does not bump — the capture/transport change bumps it, +carrying this schema as its payload (`spec/design/wire-protocol.md` §8). +`scripts/gen_style_snapshot_types.py` renders the TypeScript mirror from +this module, and the test suite fails when the two drift. +""" + +from __future__ import annotations + +import json +import math +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Optional + +from ..dom import CHART_DOM_SLOTS + +#: Bumped when the schema's shape or vocabulary changes. A consumer that sees +#: a version it does not know must refuse, not guess. +STYLE_SNAPSHOT_VERSION = 1 + +#: The closed property vocabulary of schema v1, grouped the way the renderers +#: consume it. Growing this list IS a schema change: add the property AND +#: bump `STYLE_SNAPSHOT_VERSION`, so a snapshot's vocabulary is always +#: recoverable from its version field alone. Names are kebab-case CSS except +#: the `xy-` prefixed ones, which have no CSS spelling (rotation). +PAINT_PROPERTIES_V1: tuple[str, ...] = ( + "color", + "fill", + "background", + "background-image", + "opacity", + "fill-opacity", + "stroke", + "stroke-opacity", + "stroke-width", + "border-color", + "border-style", + "border-width", + "border-radius", + "box-shadow", +) + +TYPOGRAPHY_PROPERTIES_V1: tuple[str, ...] = ( + "font-family", + "font-size", + "font-style", + "font-weight", + "letter-spacing", + "line-height", + "text-align", + "xy-rotation", +) + +LAYOUT_PROPERTIES_V1: tuple[str, ...] = ( + "padding-top", + "padding-right", + "padding-bottom", + "padding-left", + "gap", + "width", + "height", + "max-width", + "max-height", + "transform", + "clip-path", +) + +EFFECT_PROPERTIES_V1: tuple[str, ...] = ( + "filter", + "mix-blend-mode", + "isolation", + "mask", +) + +PROPERTIES_V1: tuple[str, ...] = ( + PAINT_PROPERTIES_V1 + TYPOGRAPHY_PROPERTIES_V1 + LAYOUT_PROPERTIES_V1 + EFFECT_PROPERTIES_V1 +) + +_PROPERTY_SET = frozenset(PROPERTIES_V1) + +#: Constructs whose value depends on a document, a cascade, or an +#: environment the renderer does not have. Their presence means the value is +#: not resolved, whatever else it looks like. +_UNRESOLVED_MARKERS: tuple[str, ...] = ("var(", "calc(", "env(", "attr(", "inherit", "unset") + +#: Length units a *resolved* value may not carry: every one is relative to +#: font metrics or viewport the consumer would have to re-derive. Resolved +#: lengths are plain numbers (CSS px). +_RELATIVE_UNITS: tuple[str, ...] = ("em", "rem", "ex", "ch", "vw", "vh", "vmin", "vmax", "%") + +_COLOR_SCHEMES = frozenset({"light", "dark"}) + + +def assert_resolved(prop: str, value: object) -> str | float: + """A schema-v1 value, or a loud error saying exactly why it is not one. + + Numbers pass as finite floats. Strings pass unless they carry an + unresolved construct or end in a relative unit — the two ways a value can + quietly mean something different in the consumer than it did in the + source. There is no silent coercion in either direction. + """ + if prop not in _PROPERTY_SET: + raise ValueError( + f"{prop!r} is not in the schema-v{STYLE_SNAPSHOT_VERSION} vocabulary; " + "growing the vocabulary is a schema change (bump STYLE_SNAPSHOT_VERSION)" + ) + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + raise ValueError(f"{prop}: resolved values are numbers or strings, got {value!r}") + if isinstance(value, (int, float)): + number = float(value) + if not math.isfinite(number): + raise ValueError(f"{prop}: resolved numbers must be finite, got {value!r}") + return number + text = value.strip() + if not text: + raise ValueError(f"{prop}: a resolved value cannot be empty") + lowered = text.lower() + for marker in _UNRESOLVED_MARKERS: + if marker in lowered: + raise ValueError( + f"{prop}: {text!r} still depends on a cascade the renderer does not " + f"have ({marker.rstrip('(')}); resolve it before it enters the snapshot" + ) + for unit in _RELATIVE_UNITS: + if ( + lowered.endswith(unit) + and lowered[: -len(unit)].replace(".", "", 1).lstrip("+-").isdigit() + ): + raise ValueError( + f"{prop}: {text!r} is relative to metrics the consumer would have to " + "re-derive; resolved lengths are plain numbers in CSS px" + ) + return text + + +@dataclass(frozen=True) +class SlotInstance: + """One styled occurrence of a slot, referencing an interned declaration. + + `qualifiers` is the stable identity beyond the slot name — for example + `("y", "major", "3")` for a tick label — so repeated chrome keeps + per-instance identity while sharing one declaration. `geometry` is the + resolved box in CSS px `(x, y, w, h)` when the producer knows it; + `content` is the drawn text when the slot has any. + """ + + slot: str + declaration: int + qualifiers: tuple[str, ...] = () + geometry: Optional[tuple[float, float, float, float]] = None + content: Optional[str] = None + + +@dataclass(frozen=True) +class SnapshotEnvironment: + """What the values were resolved against; part of every cache key.""" + + width: float + height: float + dpr: float = 1.0 + color_scheme: str = "light" + + +@dataclass(frozen=True) +class ResolvedStyleSnapshot: + """A complete, renderer-neutral styling result for one chart state.""" + + environment: SnapshotEnvironment + declarations: tuple[dict[str, str | float], ...] = () + instances: tuple[SlotInstance, ...] = () + tokens: dict[str, str | float] = field(default_factory=dict) + states: tuple[str, ...] = () + unrepresentable: tuple[str, ...] = () + style_epoch: int = 0 + version: int = STYLE_SNAPSHOT_VERSION + + def to_payload(self) -> dict[str, Any]: + """The JSON-safe wire shape (`spec/design/wire-protocol.md` §8). + + Snapshot-level keys are spelled out; the per-instance keys are the + one-letter spellings the spec documents, because instances are the + part that repeats with chart density. + """ + return { + "version": self.version, + "style_epoch": self.style_epoch, + "environment": { + "width": self.environment.width, + "height": self.environment.height, + "dpr": self.environment.dpr, + "color_scheme": self.environment.color_scheme, + }, + "tokens": dict(self.tokens), + "states": list(self.states), + "unrepresentable": list(self.unrepresentable), + "declarations": [dict(decl) for decl in self.declarations], + "instances": [ + { + "s": inst.slot, + "d": inst.declaration, + **({"q": list(inst.qualifiers)} if inst.qualifiers else {}), + **({"g": list(inst.geometry)} if inst.geometry is not None else {}), + **({"c": inst.content} if inst.content is not None else {}), + } + for inst in self.instances + ], + } + + def payload_bytes(self) -> int: + """Uncompressed serialized size — what the spec's 50 KB budget meters.""" + return len(json.dumps(self.to_payload(), separators=(",", ":")).encode("utf-8")) + + +class SnapshotBuilder: + """Interning constructor: identical declarations share one record. + + The producer calls `add(slot, declaration, ...)` per styled instance and + `build(...)` once; canonicalization (sorted property order) makes + interning independent of declaration insertion order, so a builder fed + the same styling in any order emits the same snapshot. + """ + + def __init__(self) -> None: + self._declarations: list[dict[str, str | float]] = [] + self._index: dict[tuple[tuple[str, str | float], ...], int] = {} + self._instances: list[SlotInstance] = [] + + def intern(self, declaration: Mapping[str, object]) -> int: + """The index for this declaration, adding it only if it is new.""" + if not declaration: + raise ValueError("an empty declaration styles nothing; do not intern it") + resolved = {prop: assert_resolved(prop, value) for prop, value in declaration.items()} + key = tuple(sorted(resolved.items())) + found = self._index.get(key) + if found is not None: + return found + self._index[key] = len(self._declarations) + self._declarations.append(dict(sorted(resolved.items()))) + return self._index[key] + + def add( + self, + slot: str, + declaration: Mapping[str, object], + *, + qualifiers: Sequence[str] = (), + geometry: Optional[Sequence[float]] = None, + content: Optional[str] = None, + ) -> int: + """Record one styled slot instance; returns its declaration index.""" + if slot not in CHART_DOM_SLOTS: + raise ValueError(f"unknown slot {slot!r}; expected one of CHART_DOM_SLOTS") + geom: Optional[tuple[float, float, float, float]] = None + if geometry is not None: + values = tuple(float(v) for v in geometry) + if len(values) != 4 or not all(math.isfinite(v) for v in values): + raise ValueError(f"geometry must be four finite numbers (x, y, w, h): {geometry!r}") + geom = values + index = self.intern(declaration) + self._instances.append( + SlotInstance( + slot=slot, + declaration=index, + qualifiers=tuple(str(q) for q in qualifiers), + geometry=geom, + content=content, + ) + ) + return index + + def build( + self, + environment: SnapshotEnvironment, + *, + tokens: Optional[Mapping[str, object]] = None, + states: Sequence[str] = (), + unrepresentable: Sequence[str] = (), + style_epoch: int = 0, + ) -> ResolvedStyleSnapshot: + if environment.color_scheme not in _COLOR_SCHEMES: + raise ValueError(f"color_scheme must be one of {sorted(_COLOR_SCHEMES)}") + resolved_tokens = { + str(name): assert_resolved_token(name, value) for name, value in (tokens or {}).items() + } + return ResolvedStyleSnapshot( + environment=environment, + declarations=tuple(self._declarations), + instances=tuple(self._instances), + tokens=resolved_tokens, + states=tuple(str(s) for s in states), + unrepresentable=tuple(str(u) for u in unrepresentable), + style_epoch=int(style_epoch), + ) + + +def assert_resolved_token(name: object, value: object) -> str | float: + """Chart tokens carry open names but the same resolved-value contract.""" + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + raise ValueError(f"token {name!r}: resolved values are numbers or strings, got {value!r}") + if isinstance(value, (int, float)): + number = float(value) + if not math.isfinite(number): + raise ValueError(f"token {name!r}: resolved numbers must be finite") + return number + lowered = value.lower() + for marker in _UNRESOLVED_MARKERS: + if marker in lowered: + raise ValueError( + f"token {name!r}: {value!r} still depends on a cascade " + f"({marker.rstrip('(')}); resolve it before it enters the snapshot" + ) + return value + + +def snapshot_from_payload(payload: Mapping[str, Any]) -> ResolvedStyleSnapshot: + """The inverse of `to_payload`, refusing versions it does not know.""" + version = payload.get("version") + if version != STYLE_SNAPSHOT_VERSION: + raise ValueError( + f"style snapshot version {version!r} is not supported " + f"(this build reads v{STYLE_SNAPSHOT_VERSION}); refusing to guess" + ) + env = payload["environment"] + declarations = [ + {prop: assert_resolved(prop, value) for prop, value in decl.items()} + for decl in payload.get("declarations", ()) + ] + instances = [] + for raw in payload.get("instances", ()): + index = raw["d"] + if not isinstance(index, int) or not 0 <= index < len(declarations): + raise ValueError(f"instance {raw!r} references declaration {index!r}, which is absent") + geometry: Optional[tuple[float, float, float, float]] = None + if "g" in raw: + values = tuple(float(v) for v in raw["g"]) + if len(values) != 4 or not all(math.isfinite(v) for v in values): + raise ValueError(f"instance {raw!r} geometry must be four finite numbers") + geometry = values + if raw["s"] not in CHART_DOM_SLOTS: + raise ValueError(f"instance {raw!r} names unknown slot {raw['s']!r}") + instances.append( + SlotInstance( + slot=raw["s"], + declaration=index, + qualifiers=tuple(str(q) for q in raw.get("q", ())), + geometry=geometry, + content=raw.get("c"), + ) + ) + return ResolvedStyleSnapshot( + environment=SnapshotEnvironment( + width=float(env["width"]), + height=float(env["height"]), + dpr=float(env.get("dpr", 1.0)), + color_scheme=str(env.get("color_scheme", "light")), + ), + declarations=tuple(dict(d) for d in declarations), + instances=tuple(instances), + tokens=dict(payload.get("tokens", {})), + states=tuple(payload.get("states", ())), + unrepresentable=tuple(payload.get("unrepresentable", ())), + style_epoch=int(payload.get("style_epoch", 0)), + ) + + +__all__ = [ + "EFFECT_PROPERTIES_V1", + "LAYOUT_PROPERTIES_V1", + "PAINT_PROPERTIES_V1", + "PROPERTIES_V1", + "STYLE_SNAPSHOT_VERSION", + "TYPOGRAPHY_PROPERTIES_V1", + "ResolvedStyleSnapshot", + "SlotInstance", + "SnapshotBuilder", + "SnapshotEnvironment", + "assert_resolved", + "assert_resolved_token", + "snapshot_from_payload", +] diff --git a/scripts/gen_style_snapshot_types.py b/scripts/gen_style_snapshot_types.py new file mode 100644 index 00000000..751e7a99 --- /dev/null +++ b/scripts/gen_style_snapshot_types.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Generate `js/src/14_style_snapshot.ts` from `xy.styling.resolved`. + +One schema, two languages, zero drift: the Python module is the source of +truth, this script renders the TypeScript mirror, and +`tests/test_resolved_style_snapshot.py` runs `--check` so a committed mirror +that falls behind the schema fails the suite — the same contract +`gen_capability_matrix.py` established for the capability documents. + + uv run python scripts/gen_style_snapshot_types.py --write + uv run python scripts/gen_style_snapshot_types.py --check +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "python")) + +from xy.styling import resolved # noqa: E402 + +TARGET = ROOT / "js" / "src" / "14_style_snapshot.ts" + + +def _const_list(name: str, values: tuple[str, ...]) -> list[str]: + lines = [f"export const {name} = ["] + lines += [f' "{value}",' for value in values] + lines.append("] as const;") + return lines + + +def render() -> str: + lines = [ + "// @generated by scripts/gen_style_snapshot_types.py — do not edit by hand.", + "//", + "// The TypeScript mirror of `python/xy/styling/resolved.py` (schema " + f"v{resolved.STYLE_SNAPSHOT_VERSION}).", + "// Concrete values only: no var()/calc(), no relative units — the Python", + "// side rejects them at construction and the capture side must never", + "// produce them. Wire shape: spec/design/wire-protocol.md §8.", + "", + f"export const STYLE_SNAPSHOT_VERSION = {resolved.STYLE_SNAPSHOT_VERSION} as const;", + "", + ] + lines += _const_list("STYLE_SNAPSHOT_PAINT_PROPERTIES", resolved.PAINT_PROPERTIES_V1) + lines.append("") + lines += _const_list("STYLE_SNAPSHOT_TYPOGRAPHY_PROPERTIES", resolved.TYPOGRAPHY_PROPERTIES_V1) + lines.append("") + lines += _const_list("STYLE_SNAPSHOT_LAYOUT_PROPERTIES", resolved.LAYOUT_PROPERTIES_V1) + lines.append("") + lines += _const_list("STYLE_SNAPSHOT_EFFECT_PROPERTIES", resolved.EFFECT_PROPERTIES_V1) + lines += [ + "", + "export const STYLE_SNAPSHOT_PROPERTIES = [", + " ...STYLE_SNAPSHOT_PAINT_PROPERTIES,", + " ...STYLE_SNAPSHOT_TYPOGRAPHY_PROPERTIES,", + " ...STYLE_SNAPSHOT_LAYOUT_PROPERTIES,", + " ...STYLE_SNAPSHOT_EFFECT_PROPERTIES,", + "] as const;", + "", + "export type StyleSnapshotProperty = (typeof STYLE_SNAPSHOT_PROPERTIES)[number];", + "", + "/** One interned declaration: resolved property -> concrete value. */", + "export type ResolvedDeclaration = Partial<", + " Record", + ">;", + "", + "/** One styled slot occurrence; `d` indexes the snapshot's declarations.", + " * Instance keys are one-letter on the wire because instances are the", + " * part that repeats with chart density (spec §8). */", + "export interface StyleSnapshotInstance {", + " /** slot name (a `data-xy-slot` value) */", + " s: string;", + " /** declaration index into `declarations` */", + " d: number;", + ' /** stable identity beyond the slot name, e.g. ["y", "major", "3"] */', + " q?: readonly string[];", + " /** resolved box in CSS px: [x, y, w, h] */", + " g?: readonly [number, number, number, number];", + " /** drawn text, when the slot has any */", + " c?: string;", + "}", + "", + "export interface StyleSnapshotEnvironment {", + " width: number;", + " height: number;", + " dpr: number;", + ' color_scheme: "light" | "dark";', + "}", + "", + "export interface ResolvedStyleSnapshot {", + f" version: {resolved.STYLE_SNAPSHOT_VERSION};", + " style_epoch: number;", + " environment: StyleSnapshotEnvironment;", + " tokens: Record;", + " states: readonly string[];", + " unrepresentable: readonly string[];", + " declarations: readonly ResolvedDeclaration[];", + " instances: readonly StyleSnapshotInstance[];", + "}", + "", + ] + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="regenerate the committed mirror") + parser.add_argument("--check", action="store_true", help="fail if the mirror is stale") + args = parser.parse_args(argv) + + document = render() + if args.write: + TARGET.write_text(document, encoding="utf-8") + print(f"wrote {TARGET.relative_to(ROOT)}") + return 0 + if args.check: + current = TARGET.read_text(encoding="utf-8") if TARGET.exists() else "" + if current != document: + print( + f"{TARGET.relative_to(ROOT)} is stale; run " + "scripts/gen_style_snapshot_types.py --write", + file=sys.stderr, + ) + return 1 + print("style snapshot types are current") + return 0 + print(document) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 4f9ccd41..4625110e 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -482,3 +482,54 @@ installed `xy` distribution, repairing a stale link if the install moved. The JS that renders a payload is therefore always the build that shipped with the Python that produced it. The protocol check exists for the case that survives this: a browser holding a cached bundle against a restarted kernel. + +## 8. Resolved style snapshot (schema v1, transport pending) + +`python/xy/styling/resolved.py` defines the renderer-neutral styling IR — +the `ResolvedStyleSnapshot` — and `js/src/14_style_snapshot.ts` is its +generated TypeScript mirror (`scripts/gen_style_snapshot_types.py`; the test +suite fails when the two drift). Nothing here rides the wire yet, so +`PROTOCOL_VERSION` stays at 12: the capture change that adds the +`style_snapshot_request` / `style_snapshot` request-reply pair bumps it and +carries this schema as the reply payload. The message names are reserved +now so nothing else claims them. + +The schema is versioned independently (`STYLE_SNAPSHOT_VERSION = 1`), +because a snapshot can outlive a session: it is cacheable and supplyable to +an unmounted export, so a consumer may meet one produced by another build. +A consumer that sees a version it does not know refuses; it never guesses. + +Shape (JSON-safe; spelled-out keys at snapshot level, one-letter keys on +instances because instances are the part that repeats with chart density): + +```text +{ + version: 1, + style_epoch: int, # producer's style generation counter + environment: { width, height, dpr, color_scheme }, # resolution inputs + tokens: { name: value, ... }, # resolved chart tokens (open names) + states: [ "hover", ... ], # export states included, if any + unrepresentable: [ property, ... ],# values with no target representation + declarations: [ { property: value, ... }, ... ], # interned, deduped + instances: [ { s: slot, d: decl_index, + q?: [qualifiers], g?: [x,y,w,h], c?: text }, ... ] +} +``` + +Three contract properties, all enforced at construction on both ends of the +eventual wire: + +- **Concrete values only.** No `var()`/`calc()`/`env()`/`inherit`, no + relative units (`em`, `%`, `vw`, …): a value that still depends on a + cascade or on metrics the consumer would re-derive is rejected loudly + (§28). This is what lets every renderer consume one shape without a CSS + engine. +- **Interned declarations.** Each distinct declaration is stored once; + instances reference it by index and carry only identity + (`q`, e.g. `["y","major","3"]`), geometry, and content. A dense-axis + fixture (460 instances, 2 declarations, ~39 KB) pins the spec's 50 KB + uncompressed budget in `tests/test_resolved_style_snapshot.py`. +- **Closed vocabulary per version.** Schema v1's property list is the + generated constant in both languages; growing it is a + `STYLE_SNAPSHOT_VERSION` bump, so a snapshot's vocabulary is always + recoverable from its version field alone. diff --git a/tests/test_resolved_style_snapshot.py b/tests/test_resolved_style_snapshot.py new file mode 100644 index 00000000..160db2ee --- /dev/null +++ b/tests/test_resolved_style_snapshot.py @@ -0,0 +1,209 @@ +"""Schema v1 of the ResolvedStyleSnapshot: interned, concrete, versioned. + +Three contracts, each load-bearing for a later phase: declarations intern +(the size budget assumes repeated chrome shares records), values are +concrete (a var()/em that slips through re-creates per-renderer divergence +inside the IR), and the schema refuses versions and vocabulary it does not +know (growing either is a deliberate version bump, never an accident). +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from xy.styling import resolved as rs + +ROOT = Path(__file__).resolve().parents[1] + + +def _env() -> rs.SnapshotEnvironment: + return rs.SnapshotEnvironment(width=640.0, height=400.0, dpr=2.0, color_scheme="dark") + + +# -- interning --------------------------------------------------------------- + + +def test_identical_declarations_intern_to_one_record_in_any_order() -> None: + builder = rs.SnapshotBuilder() + first = builder.add("tick_label", {"font-size": 11, "color": "#94a3b8"}) + second = builder.add("tick_label", {"color": "#94a3b8", "font-size": 11.0}) + assert first == second == 0 + snapshot = builder.build(_env()) + assert len(snapshot.declarations) == 1 + assert len(snapshot.instances) == 2 + + +def test_a_dense_axis_stays_inside_the_size_budget() -> None: + # 400 tick labels + 60 legend rows styled alike: the spec's 50 KB + # uncompressed budget assumes exactly this shape. Assert with headroom so + # growth is a decision, not drift. + builder = rs.SnapshotBuilder() + for i in range(400): + builder.add( + "tick_label", + {"font-size": 11, "color": "#94a3b8", "font-weight": 500}, + qualifiers=("x", "major", str(i)), + geometry=(4.0 * i, 380.0, 24.0, 12.0), + content=str(i), + ) + for i in range(60): + builder.add( + "legend_label", + {"font-size": 12, "color": "#e2e8f0"}, + qualifiers=(f"series-{i}",), + content=f"series {i}", + ) + snapshot = builder.build(_env(), style_epoch=7) + assert len(snapshot.declarations) == 2 + assert len(snapshot.instances) == 460 + # ~38.6 KB when this landed; the spec budget is 50 KB uncompressed. The + # gap is the schema's headroom — a change that eats it shows up here as + # a decision to make, not after a capture starts failing in the field. + assert snapshot.payload_bytes() < 50_000 + + +def test_builder_output_is_insertion_order_independent() -> None: + a, b = rs.SnapshotBuilder(), rs.SnapshotBuilder() + a.add("title", {"font-size": 18, "color": "#fff"}) + a.add("axis_title", {"font-size": 12}) + b.add("axis_title", {"font-size": 12}) + b.add("title", {"color": "#fff", "font-size": 18}) + left = a.build(_env()).declarations + right = b.build(_env()).declarations + assert set(map(tuple, (d.items() for d in left))) == set(map(tuple, (d.items() for d in right))) + + +def test_empty_declarations_are_refused() -> None: + with pytest.raises(ValueError, match="styles nothing"): + rs.SnapshotBuilder().intern({}) + + +# -- concreteness ------------------------------------------------------------ + + +@pytest.mark.parametrize( + "value, why", + [ + ("var(--chart-fg)", "cascade"), + ("calc(100% - 8px)", "cascade"), + ("env(safe-area-inset-top)", "cascade"), + ("inherit", "cascade"), + ("1.5em", "re-derive"), + ("120%", "re-derive"), + ("2rem", "re-derive"), + (float("nan"), "finite"), + (float("inf"), "finite"), + (True, "numbers or strings"), + (None, "numbers or strings"), + ("", "empty"), + ], +) +def test_unresolved_values_are_rejected_with_the_reason(value, why) -> None: + with pytest.raises(ValueError, match=why): + rs.assert_resolved("font-size", value) + + +def test_concrete_values_pass_unchanged() -> None: + assert rs.assert_resolved("font-size", 11) == 11.0 + assert rs.assert_resolved("color", "#94a3b8") == "#94a3b8" + assert rs.assert_resolved("stroke-width", "2px") == "2px" + assert rs.assert_resolved("background-image", "linear-gradient(#000, #fff)") + assert rs.assert_resolved("transform", "matrix(1, 0, 0, 1, 4, 8)") + + +def test_vocabulary_is_closed_per_version() -> None: + with pytest.raises(ValueError, match="STYLE_SNAPSHOT_VERSION"): + rs.assert_resolved("backdrop-filter", "blur(4px)") + assert len(rs.PROPERTIES_V1) == len(set(rs.PROPERTIES_V1)) + + +def test_tokens_carry_open_names_but_the_same_value_contract() -> None: + builder = rs.SnapshotBuilder() + builder.add("title", {"font-size": 18}) + snapshot = builder.build(_env(), tokens={"--chart-legend-bg": "#0f172a"}) + assert snapshot.tokens["--chart-legend-bg"] == "#0f172a" + with pytest.raises(ValueError, match="cascade"): + builder.build(_env(), tokens={"--chart-legend-bg": "var(--slate-900)"}) + + +# -- wire round-trip --------------------------------------------------------- + + +def test_payload_round_trips_exactly() -> None: + builder = rs.SnapshotBuilder() + builder.add( + "tick_label", + {"font-size": 11, "color": "#94a3b8"}, + qualifiers=("y", "major", "3"), + geometry=(12.0, 40.0, 30.0, 12.0), + content="1,000", + ) + builder.add("legend", {"background": "#0f172a", "border-radius": 6}) + snapshot = builder.build( + _env(), + tokens={"--chart-fg": "#e2e8f0"}, + states=("hover",), + unrepresentable=("backdrop-filter",), + style_epoch=3, + ) + payload = snapshot.to_payload() + assert rs.snapshot_from_payload(payload).to_payload() == payload + + +def test_unknown_versions_are_refused_not_guessed() -> None: + payload = rs.SnapshotBuilder().build(_env()).to_payload() + payload["version"] = rs.STYLE_SNAPSHOT_VERSION + 1 + with pytest.raises(ValueError, match="refusing to guess"): + rs.snapshot_from_payload(payload) + + +def test_malformed_payloads_fail_loudly() -> None: + builder = rs.SnapshotBuilder() + builder.add("title", {"font-size": 18}) + good = builder.build(_env()).to_payload() + + dangling = {**good, "instances": [{"s": "title", "d": 5}]} + with pytest.raises(ValueError, match="absent"): + rs.snapshot_from_payload(dangling) + + unknown_slot = {**good, "instances": [{"s": "not_a_slot", "d": 0}]} + with pytest.raises(ValueError, match="unknown slot"): + rs.snapshot_from_payload(unknown_slot) + + bad_geometry = {**good, "instances": [{"s": "title", "d": 0, "g": [1.0, 2.0]}]} + with pytest.raises(ValueError, match="four finite"): + rs.snapshot_from_payload(bad_geometry) + + smuggled = {**good, "declarations": [{"color": "var(--fg)"}]} + with pytest.raises(ValueError, match="cascade"): + rs.snapshot_from_payload(smuggled) + + +def test_environment_is_validated() -> None: + with pytest.raises(ValueError, match="color_scheme"): + rs.SnapshotBuilder().build( + rs.SnapshotEnvironment(width=100, height=100, color_scheme="sepia") + ) + + +# -- the TypeScript mirror --------------------------------------------------- + + +def test_the_committed_typescript_mirror_is_regenerated_not_hand_edited() -> None: + result = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "gen_style_snapshot_types.py"), "--check"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_the_mirror_names_every_property_and_the_version() -> None: + text = (ROOT / "js" / "src" / "14_style_snapshot.ts").read_text(encoding="utf-8") + for prop in rs.PROPERTIES_V1: + assert f'"{prop}"' in text + assert f"STYLE_SNAPSHOT_VERSION = {rs.STYLE_SNAPSHOT_VERSION}" in text From f6617943d76165edbe08fc226cbe05069751909e Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 4 Aug 2026 13:48:14 -0700 Subject: [PATCH 04/26] 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 ``/`