Styling compatibility phase 0: preflight contract, staged modes, and the resolved-style IR

AL
4d ago
+14506-835

Consolidates the three stacked changes (#452, #453, #454) into one PR at the request of review flow — same commits, review follow-ups included. Phase 0 of the styling-compatibility program plus the schema every later phase consumes.

1. Report-only preflight and the applicable-slot matrix

  • chart.style_compatibility_report(target=..., engine=..., custom_css=...) routes every declared slot style into exactly one of survives, native-subset (naming kept/lost properties per format family), browser-only, or state-gated — and mirrors the export path's refusals by calling its own resolver and validators, so it cannot disagree with what running the export would do. Constant-time early-out when the chart carries no class/per-slot declarations.
  • Applicable-slot matrix: each of the 48 chrome slots now carries an applicability — clean-static (24) or gated by hover/modebar/crosshair/selection/view (24). A styled tooltip is no longer "dropped" by a PNG that never contains a tooltip; it's recorded with its gating state (§28). Registry tests pin the partition and reject native-support claims on state-gated slots.

2. Staged compatibility= modes and the engine contract

Every chart-, figure-, and module-level image export accepts compatibility= (facet grids deliberately excluded until their per-panel preflight): "legacy" (default, byte-identical, one string comparison of cost), "warn" (one StyleCompatibilityWarning naming each loss), "strict" (StyleCompatibilityError before emission, report attached). "lossless" is reserved-and-rejected until preflight routing exists. No mode ever re-routes an explicit engine; resolution errors precede and outrank mode logic in every mode; strict batches fail whole before any file is written. spec/process/style-compatibility-migration.md names the default flips: warn 0.0.7 → strict 0.1.0 → legacy removed 0.2.0.

3. The ResolvedStyleSnapshot (schema v1)

The renderer-neutral IR: interned declarations + identity-carrying instances, concrete values only (no var()/calc(), no relative units — enforced at both construction ends of the eventual wire), closed per-version vocabulary, canonical byte ordering so identical styling serializes identically. Generated TypeScript mirror (js/src/14_style_snapshot.ts) with a --check drift gate. Nothing rides the wire yet: PROTOCOL_VERSION stays 12; wire-protocol §8 documents the shape and reserves the message names.

Review follow-ups already included

From the bot passes on the stacked PRs — fixed: browser-path custom_css validation mirrored (_custom_css_block); malformed figure styling raises like the export instead of being skipped; summary counts derived from the registry; explicit writer-family classification; typed report returns; to_png resolution-error precedence for styled charts (test now feeds the lossy case); measured warning stacklevel so warnings land on the caller's line; SVG-aware strict remediation (no recommending an engine SVG rejects); batch vocabulary validated up front with HTML entries exempt; changelog facet wording; relative units rejected anywhere in a value (translate(50%, 20%), 2em 1em, gradient stops); cascade keywords exact-match only ("Inheritance Sans" is concrete); tokens share the full string contract; snapshot_from_payload enforces build()'s validation; canonical declaration/instance ordering with a byte-equality test. Skipped with reason: freezing the report's sources dict (transient value object, never hashed — reviewer tagged it low-value); deriving the family-prefix test from _STATE_GATED_SLOTS (would make the check assert the mapping against itself; independent restatement is the point).

Evidence

Suites at each stage: 3973 → 3987 → 4012 passed plus 244 across the program's own suites after the review fixes; engine × mode matrix runs against live Chromium; node js/build.mjs typechecks the generated mirror; ruff/format/pre-commit clean; xy.export import weight verified unchanged (lazy __getattr__ — the eager alias would have pulled the native dylib into an import chain that never had it). Dense-axis snapshot fixture: 460 instances, 2 declarations, ~38.6 KB against the 50 KB budget.

Next per the plan: the ten native-capable slots routed through the IR behind _compile_cached, gated byte/pixel-equivalent and within-noise.

Summary by CodeRabbit

  • New Features

    • Added styling compatibility reports and legacy, warn, and strict modes for exports.
    • Added live style snapshot capture and reuse for consistent native exports.
    • Added native CSS cascade resolution, stylesheet support, and Tailwind core styling.
    • Improved static export support for styled chrome, including bold, italic, borders, shadows, and rounded corners.
    • Expanded capability reporting for static and interaction-dependent styling.
  • Documentation

    • Updated export guidance, capability matrices, migration notes, and styling protocol documentation.