diff --git a/CHANGELOG.md b/CHANGELOG.md index 47364734..e1789617 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 chart-, figure-, and module-level image-export API (`to_png`, `to_svg`, `to_image`, `write_image`, `export.write_images`) accepts `compatibility=` — facet-grid exports deliberately do not yet (their 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..81ec581a --- /dev/null +++ b/python/xy/styling/resolved.py @@ -0,0 +1,488 @@ +"""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 +import re +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) + +#: Function-shaped constructs whose value depends on a document, a cascade, +#: or an environment the renderer does not have. Matched as substrings — +#: a `var(` buried inside a gradient is exactly as unresolved as one alone. +_UNRESOLVED_FUNCTIONS: tuple[str, ...] = ("var(", "calc(", "env(", "attr(") + +#: Cascade keywords, matched only as the entire normalized value: `inherit` +#: as a value defers to a cascade, but a face named "Inheritance Sans" is a +#: concrete string and must not be rejected for containing the letters. +_UNRESOLVED_KEYWORDS: frozenset[str] = frozenset( + {"inherit", "initial", "unset", "revert", "revert-layer"} +) + +#: 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, ...] = ("vmin", "vmax", "rem", "em", "ex", "ch", "vw", "vh", "%") + +#: A relative-unit length *anywhere* in the value — `translate(50%, 20%)`, +#: a `2em 1em` shorthand, a gradient stop — not only as a whole-string +#: suffix. Mid-string relative units are precisely the ones that slipped a +#: document dependency past the earlier end-anchored check. +_RELATIVE_UNIT_RE = re.compile( + r"(? str: + """The one string contract, shared by declarations and tokens alike.""" + text = value.strip() + if not text: + raise ValueError(f"{label}: a resolved value cannot be empty") + lowered = text.lower() + if lowered in _UNRESOLVED_KEYWORDS: + raise ValueError( + f"{label}: {text!r} defers to a cascade the renderer does not have; " + "resolve it before it enters the snapshot" + ) + for marker in _UNRESOLVED_FUNCTIONS: + if marker in lowered: + raise ValueError( + f"{label}: {text!r} still depends on a cascade the renderer does not " + f"have ({marker.rstrip('(')}); resolve it before it enters the snapshot" + ) + relative = _RELATIVE_UNIT_RE.search(lowered) + if relative is not None: + raise ValueError( + f"{label}: {text!r} carries {relative.group(0)!r}, which is relative to " + "metrics the consumer would have to re-derive; resolved lengths are " + "plain numbers in CSS px" + ) + return text + + +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 a relative-unit length anywhere in the value — + 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 + return _assert_concrete_text(prop, value) + + +@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. `build` emits a **canonical** snapshot: declarations + sorted by their property content (instance indices remapped to match) + and instances sorted by identity, so a builder fed the same styling in + any order emits byte-identical payloads. Instance order therefore + carries no meaning — identity lives in `(slot, qualifiers)` — which is + what makes a snapshot cacheable and comparable across producers. + """ + + 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: + environment = _validated_environment( + environment.width, environment.height, environment.dpr, environment.color_scheme + ) + resolved_tokens = { + str(name): assert_resolved_token(name, value) for name, value in (tokens or {}).items() + } + # Canonicalize: declaration slots by content, instances by identity, + # so logically identical styling is byte-identical on the wire. + order = sorted( + range(len(self._declarations)), + key=lambda i: tuple(self._declarations[i].items()), + ) + remap = {old: new for new, old in enumerate(order)} + instances = sorted( + ( + SlotInstance( + slot=inst.slot, + declaration=remap[inst.declaration], + qualifiers=inst.qualifiers, + geometry=inst.geometry, + content=inst.content, + ) + for inst in self._instances + ), + key=lambda inst: ( + inst.slot, + inst.qualifiers, + inst.declaration, + inst.geometry or (), + inst.content or "", + ), + ) + return ResolvedStyleSnapshot( + environment=environment, + declarations=tuple(self._declarations[i] for i in order), + instances=tuple(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 _validated_environment( + width: object, height: object, dpr: object, color_scheme: object +) -> SnapshotEnvironment: + """One environment contract for both construction ends of the wire.""" + numbers = {} + for label, value in (("width", width), ("height", height), ("dpr", dpr)): + try: + out = float(value) # type: ignore[arg-type] + except (TypeError, ValueError) as exc: + raise ValueError(f"environment {label} must be a finite positive number") from exc + if not math.isfinite(out) or out <= 0: + raise ValueError(f"environment {label} must be a finite positive number") + numbers[label] = out + scheme = str(color_scheme) + if scheme not in _COLOR_SCHEMES: + raise ValueError(f"color_scheme must be one of {sorted(_COLOR_SCHEMES)}") + return SnapshotEnvironment( + width=numbers["width"], height=numbers["height"], dpr=numbers["dpr"], color_scheme=scheme + ) + + +def assert_resolved_token(name: object, value: object) -> str | float: + """Chart tokens carry open names but the same resolved-value contract. + + The string rules are `_assert_concrete_text`, shared with declarations — + a token `1.5em` smuggles the identical document dependency a declaration + `1.5em` would, so the two vocabularies differ only in that token *names* + are open while the schema property list is closed. + """ + 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 + return _assert_concrete_text(f"token {name!r}", 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"), + ) + ) + # The payload path is the untrusted end of the wire, so it enforces the + # identical contract `build()` enforces: environment vocabulary and + # finiteness, and every token through the shared resolved-value rules. A + # snapshot that could only have been made by bypassing the builder must + # not become renderer-facing IR by arriving serialized. + return ResolvedStyleSnapshot( + environment=_validated_environment( + env["width"], + env["height"], + env.get("dpr", 1.0), + env.get("color_scheme", "light"), + ), + declarations=tuple(dict(d) for d in declarations), + instances=tuple(instances), + tokens={ + str(name): assert_resolved_token(name, value) + for name, value in dict(payload.get("tokens", {})).items() + }, + states=tuple(str(s) for s in payload.get("states", ())), + unrepresentable=tuple(str(u) for u in 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..1d365f27 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -482,3 +482,57 @@ 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, canonically ordered.** Each distinct declaration + is stored once; instances reference it by index and carry only identity + (`q`, e.g. `["y","major","3"]`), geometry, and content. The builder emits + declarations sorted by content and instances sorted by identity, so + logically identical styling serializes byte-identically regardless of + production order — instance order carries no meaning. 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..0543bbdd --- /dev/null +++ b/tests/test_resolved_style_snapshot.py @@ -0,0 +1,267 @@ +"""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_builders_fed_the_same_styling_in_any_order_emit_identical_bytes() -> None: + # Canonicalization is byte-level, not set-level: declaration slots are + # assigned by content and instances sorted by identity at build, so a + # cacheable snapshot cannot depend on which chrome happened to be walked + # first. Comparing payloads (not declaration sets) is what pins the + # instance-index remapping too. + a, b = rs.SnapshotBuilder(), rs.SnapshotBuilder() + a.add("title", {"font-size": 18, "color": "#fff"}) + a.add("axis_title", {"font-size": 12}) + a.add("tick_label", {"font-size": 12}, qualifiers=("x", "0")) + b.add("tick_label", {"font-size": 12}, qualifiers=("x", "0")) + b.add("axis_title", {"font-size": 12}) + b.add("title", {"color": "#fff", "font-size": 18}) + assert a.build(_env()).to_payload() == b.build(_env()).to_payload() + + +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"), + ("unset", "cascade"), + ("revert-layer", "cascade"), + ("1.5em", "re-derive"), + ("120%", "re-derive"), + ("2rem", "re-derive"), + # Relative units anywhere in the value, not only as a whole-string + # suffix — the shapes that slipped the earlier end-anchored check. + ("translate(50%, 20%)", "re-derive"), + ("2em 1em", "re-derive"), + ("linear-gradient(45deg, red 50%, blue 50%)", "re-derive"), + ("0.5ch", "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_cascade_keywords_reject_as_whole_values_not_substrings() -> None: + # `inherit` as the value defers to a cascade; a face that merely contains + # the letters is a concrete string. Substring matching rejected the + # latter; whole-value matching may not miss the former. + assert rs.assert_resolved("font-family", "Inheritance Sans") == "Inheritance Sans" + assert rs.assert_resolved("font-family", '"Unsettled Grotesk", sans-serif') + with pytest.raises(ValueError, match="cascade"): + rs.assert_resolved("font-family", " INHERIT ") + + +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)"}) + # The whole string contract, not just the function markers: a token + # `1.5em` smuggles the same document dependency a declaration would. + with pytest.raises(ValueError, match="re-derive"): + builder.build(_env(), tokens={"--chart-pad": "1.5em"}) + with pytest.raises(ValueError, match="empty"): + builder.build(_env(), tokens={"--chart-pad": " "}) + + +# -- 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_the_payload_path_enforces_the_builders_contract() -> None: + # from_payload is the untrusted end of the wire: a payload that could + # only have been made by bypassing the builder must not round-trip into + # renderer-facing IR. Same vocabulary, same validators, both ends. + builder = rs.SnapshotBuilder() + builder.add("title", {"font-size": 18}) + good = builder.build(_env()).to_payload() + + sepia = {**good, "environment": {**good["environment"], "color_scheme": "sepia"}} + with pytest.raises(ValueError, match="color_scheme"): + rs.snapshot_from_payload(sepia) + + unresolved_token = {**good, "tokens": {"--fg": "var(--slate-50)"}} + with pytest.raises(ValueError, match="cascade"): + rs.snapshot_from_payload(unresolved_token) + + relative_token = {**good, "tokens": {"--pad": "2em"}} + with pytest.raises(ValueError, match="re-derive"): + rs.snapshot_from_payload(relative_token) + + non_finite = {**good, "environment": {**good["environment"], "width": float("nan")}} + with pytest.raises(ValueError, match="finite"): + rs.snapshot_from_payload(non_finite) + + +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") + ) + with pytest.raises(ValueError, match="finite"): + rs.SnapshotBuilder().build(rs.SnapshotEnvironment(width=float("inf"), height=100)) + with pytest.raises(ValueError, match="finite"): + rs.SnapshotBuilder().build(rs.SnapshotEnvironment(width=100, height=100, dpr=0.0)) + + +# -- 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