From 0b97ae5407ce1fb996a4a93cbd4f6afb028a9d4a Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 4 Aug 2026 13:27:06 -0700 Subject: [PATCH 1/2] 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 3aa6728221971ca0019cb91b65c142454ea2ab23 Mon Sep 17 00:00:00 2001 From: Alek Petuskey Date: Tue, 4 Aug 2026 13:57:47 -0700 Subject: [PATCH 2/2] Harden the snapshot's concreteness contract and canonicalize its bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the schema, all four in the same direction: the contract the module claims is now the contract it enforces. Relative units are rejected anywhere in a value, not only as a whole-string suffix. translate(50%, 20%), a "2em 1em" shorthand, and a gradient stop at 50% all carry the same document dependency a bare "1.5em" does; the end-anchored check let exactly those through. Cascade keywords went the other way: inherit/unset (plus initial/revert/revert-layer) reject only as the entire normalized value, so a face named "Inheritance Sans" is no longer refused for containing the letters. Tokens share the full string contract with declarations through one helper — a token "1.5em" or an empty string smuggles what a declaration would — and snapshot_from_payload now enforces the identical rules build() does: environment vocabulary and finiteness, and every token through the shared validator. The payload path is the untrusted end of the wire; a snapshot that could only exist by bypassing the builder must not become renderer-facing IR by arriving serialized. build() now emits canonical bytes: declaration slots assigned by content (instance indices remapped), instances sorted by identity. The docstring claimed order-independence while the payload depended on insertion order, and the old test compared declaration sets, which cannot see index drift — it now asserts byte-equal payloads from builders fed the same styling in different orders. Instance order carries no meaning; identity lives in (slot, qualifiers), which is what makes a snapshot cacheable across producers. --- python/xy/styling/resolved.py | 190 +++++++++++++++++++------- spec/design/wire-protocol.md | 9 +- tests/test_resolved_style_snapshot.py | 66 ++++++++- 3 files changed, 206 insertions(+), 59 deletions(-) diff --git a/python/xy/styling/resolved.py b/python/xy/styling/resolved.py index cf4d4261..81ec581a 100644 --- a/python/xy/styling/resolved.py +++ b/python/xy/styling/resolved.py @@ -30,6 +30,7 @@ import json import math +import re from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from typing import Any, Optional @@ -100,26 +101,72 @@ _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") +#: 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, ...] = ("em", "rem", "ex", "ch", "vw", "vh", "vmin", "vmax", "%") +_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 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. + 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( @@ -133,26 +180,7 @@ def assert_resolved(prop: str, value: object) -> str | float: 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 + return _assert_concrete_text(prop, value) @dataclass(frozen=True) @@ -237,9 +265,12 @@ 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. + `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: @@ -299,15 +330,42 @@ def build( 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)}") + 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), - instances=tuple(self._instances), + 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), @@ -315,8 +373,35 @@ def build( ) +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.""" + """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)): @@ -324,14 +409,7 @@ def assert_resolved_token(name: object, value: object) -> str | float: 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 + return _assert_concrete_text(f"token {name!r}", value) def snapshot_from_payload(payload: Mapping[str, Any]) -> ResolvedStyleSnapshot: @@ -369,18 +447,26 @@ def snapshot_from_payload(payload: Mapping[str, Any]) -> ResolvedStyleSnapshot: 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=SnapshotEnvironment( - width=float(env["width"]), - height=float(env["height"]), - dpr=float(env.get("dpr", 1.0)), - color_scheme=str(env.get("color_scheme", "light")), + 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=dict(payload.get("tokens", {})), - states=tuple(payload.get("states", ())), - unrepresentable=tuple(payload.get("unrepresentable", ())), + 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)), ) diff --git a/spec/design/wire-protocol.md b/spec/design/wire-protocol.md index 4625110e..1d365f27 100644 --- a/spec/design/wire-protocol.md +++ b/spec/design/wire-protocol.md @@ -524,9 +524,12 @@ eventual wire: 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 +- **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 diff --git a/tests/test_resolved_style_snapshot.py b/tests/test_resolved_style_snapshot.py index 160db2ee..0543bbdd 100644 --- a/tests/test_resolved_style_snapshot.py +++ b/tests/test_resolved_style_snapshot.py @@ -66,15 +66,20 @@ def test_a_dense_axis_stays_inside_the_size_budget() -> None: assert snapshot.payload_bytes() < 50_000 -def test_builder_output_is_insertion_order_independent() -> None: +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}) - 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))) + assert a.build(_env()).to_payload() == b.build(_env()).to_payload() def test_empty_declarations_are_refused() -> None: @@ -92,9 +97,17 @@ def test_empty_declarations_are_refused() -> None: ("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"), @@ -115,6 +128,16 @@ def test_concrete_values_pass_unchanged() -> None: 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)") @@ -128,6 +151,12 @@ def test_tokens_carry_open_names_but_the_same_value_contract() -> None: 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 --------------------------------------------------------- @@ -183,11 +212,40 @@ def test_malformed_payloads_fail_loudly() -> None: 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 ---------------------------------------------------