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..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 @@ -2217,6 +2220,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, + ) -> StyleCompatibilityReport: + """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..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 @@ -4169,6 +4172,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, + ) -> StyleCompatibilityReport: + """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..fa425d92 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,22 +452,32 @@ 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" ), + # 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), } __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..625a7b0a --- /dev/null +++ b/python/xy/styling/preflight.py @@ -0,0 +1,343 @@ +"""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 ..dom import validate_dom_slots +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. + + Browser-resolved targets validate `custom_css` through the export path's + own `_custom_css_block` — the same type check and ``/`