Accepted

ADR 0033 — Customise live-preview transport

How the customise view previews uncommitted theme-token edits in an iframe without touching the cache or the versioned-write path

The customise view lets an editor adjust a theme's design tokens — colours, spacing, type — with an in-page preview iframe that reflects their uncommitted edits as they drag sliders. The load-bearing problem is consulting those uncommitted edits on the public render path without polluting the surrogate-key cache, the versioned-write contract, or the separation between what the editor sees and what every other visitor sees.

The decision

Draft overrides are persisted server-side in KV and referenced by a customise-scoped preview token, rather than serialised into the iframe URL. The iframe loads the public render path with a ?customise-preview= token; the render path validates it, fetches the draft from KV, and overlays it on top of the committed token resolution.

Draft storage in KV, scoped per actor and theme

Drafts live in KV under the key theme.tokens.draft:{actorId}:{themeId}, holding a JSON map of token path to value. Per-actor scoping is the load-bearing property: two admins customising the same theme at once each see their own draft in their own iframe, with no silent stomp.

  • A leaf value is either a string override or null. A null is a draft-time reset — distinct from an absent key, which is "no draft change" — so an editor who adds an override, resets it, then saves ends up with the override removed.
  • Writes are whole-blob replaces. Slider drags update one path client-side, but the wire write is the full draft map, keeping the partial-write race surface at zero and the read at one KV get per render.
  • Drafts carry a 7-day TTL — long enough to survive a closed laptop, short enough to bound abandoned churn. Saving deletes the key explicitly.
  • Drafts are not versioned. They are pre-commit state by definition; the versioned-write contract kicks in at Save through the existing typed-settings path.

KV rather than D1 because drafts are session-scoped ephemeral state with built-in expiry, the read pattern is one key per render, and they have no audit value — the versioned record is the saved state, not the in-flight slider position.

A customise-scoped preview token

The transport reuses the preview-token signing infrastructure rather than minting a new secret. A customise token is a standard v=2 token with ctx set to "customise" and scope set to "customise-preview", plus an aid claim carrying the editor's actor id and sub carrying the theme id. The validator returns the actor id and theme id so the render path can compose the KV draft key, and rejects if the theme no longer matches the active theme.

Two deliberate divergences from the document-preview contract, both scope-gated and explicit in the validator:

  • Lifetime is 1 hour. Customise sessions are interactive; the admin re-issues automatically when the customise view mounts, so an editor who walks away does not leave a long-lived token in flight.
  • Single-use is disabled. The iframe re-renders on every draft change, so burning the token on first use makes no sense. The jti is still signed, so the token stays replay-protected against forgery; it just is not redeemed.

Domain-lock from the preview-token decision applies unchanged. A request that carries both ?preview= and ?customise-preview= is rejected with 404 — a 404 rather than a descriptive 400 because confirming that customise-preview is a recognised parameter would help a probing attacker. The conflict is logged so the admin Health page can surface it.

Resolution order at render

When a valid customise token is present and its draft loads, the render path adds the draft as the top layer of the token resolution chain:

  1. The editor's uncommitted draft override for the path.
  2. The theme-specific committed override in settings.
  3. The core committed override in settings.
  4. The theme default from the theme's tokens file.

If the token is absent, invalid, or the draft is missing or malformed, the overlay is dropped and the committed three-layer chain runs unchanged. Failure to fetch the draft is silent — the iframe shows committed state — and the SPA surfaces a "preview is showing committed state" warning if the rendered styles do not reflect pending edits within two seconds. This is the same fail-open posture the preview-token decision takes for KV outages.

No purge on draft writes; the cache contract holds

A draft write touches only the per-actor KV key. It does not fire a theme surrogate-key purge. Cached HTML for ordinary visitors stays valid; only the iframe loaded with a customise token consults the draft. An editor mid-customise cannot change what the public site shows.

Saving runs through the existing typed-settings flow: the SPA cancels any in-flight debounced draft write, reads the draft, writes each non-null override through a versioned settings PUT and each null through a settings DELETE, fires the theme purge on each write, then deletes the draft key. Every committed override goes through the versioned-write path; the draft layer never touches the versioning surface.

Consequences

  • Editor-first: a slider drag debounces to a KV write, the iframe reloads, and the rendered styles reflect the new value with no D1 round-trip beyond the existing settings read.
  • Concurrent editors are safe through per-actor scoping; drafts survive a closed laptop for seven days.
  • The signing key, domain-lock, and lifetime semantics are reused — no second secret to rotate, no parallel validator.
  • The customise iframe runs with allow-same-origin and allow-scripts so the SPA can read its inline style block and theme scripts work. Themes are signed and deployer-installed, so this matches the theme trust model; it is flagged for revisit before sandboxed plugin block renderers can run inside the preview.
See the preview-token signing decision