Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 58 additions & 51 deletions docs/styling/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
23 changes: 22 additions & 1 deletion python/xy/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Comment thread
Alek99 marked this conversation as resolved.
"""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,
Expand Down
26 changes: 25 additions & 1 deletion python/xy/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Comment thread
Alek99 marked this conversation as resolved.
"""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,
Expand Down
12 changes: 8 additions & 4 deletions python/xy/styling/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
68 changes: 65 additions & 3 deletions python/xy/styling/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -79,6 +93,7 @@ class SlotCapability:
support: dict[str, str]
notes: str
channel: str = ""
applicability: str = "static"


@dataclass(frozen=True)
Expand Down Expand Up @@ -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,
Expand All @@ -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
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading