v0.24.0

Released June 16, 2026 · All releases

Live backdrops & scroll-reveal motion. bg can host a live sandbox as a full-bleed animated backdrop behind a surface's content. A new reveal attribute, with stagger, animates runes into view as they scroll in, driven by theme tokens and skipped when motion is reduced or scripts are off.

Background: · ·

Changes

  • Live sandbox guests in the bg backdrop layer (SPEC-104). A surface can now carry both an animated backdrop and a positioned subject media — the visualiser is the bg, the image/code/embed stays an in-flow media guest, so they stop competing for the single media zone.
  • bg gains an optional body holding one bare sandbox: it's transformed normally (the real rune runs, with file resolution + sanitisation), tagged data-bg-guest, and the engine relocates it into the bg layer (a sibling of bg-video, above the boot frame, below overlay/scrim). A chromed guest (video/audio/figure) is rejected with a build warning.
  • A new backdrop posture (data-guest-posture="backdrop"): the guest is mounted and running but pointer-inert; the sandbox is forced to height="fill" + eager activation, not mounted under prefers-reduced-motion (the boot frame stands in), and suspended off-screen / on a hidden tab.
  • A named sandbox bg preset (BgPresetDefinition.sandbox, project-level backgrounds config) applies a reusable scene by name (bg="midnight-waves") like any other preset, resolved at transform time and memoised per scene.
  • Scroll-reveal motion — a token-driven entrance dimension (SPEC-105). Sections can now animate in as they scroll into view. The author declares intent with two universal attributes; the theme owns the choreography; a behaviour owns the timing — JS = when, CSS = how.
  • reveal — a closed entrance vocabulary on every block rune: none (default), fade, slide, scale, blur. An unknown value is a build error.
  • stagger — cascades a multi-child block's items in (feature/bento/steps/pricing/playlist); a silent no-op on single-child runes. The engine stamps --rf-reveal-index on the cascade items.
  • The motion dimension (dimensions/motion.css + --rf-reveal-* physics tokens) renders each character keyed on data-reveal × data-in-view, from one stylesheet covering all section runes. It animates the individual translate/scale properties (never the transform shorthand) so it composes with existing rune transforms. The physics are a first-class token group, retunable site-wide via refrakt.config.json theme.tokens.reveal.*.
  • An IntersectionObserver behaviour flips data-in-view on first intersection. Opt-in and enhancement-gated: SSR/no-JS/crawler and prefers-reduced-motion render the fully-visible final state — nothing is hidden behind JS.
  • Fix: mockup as a media guest renders correctly (incl. iOS Safari). A mockup placed in a rune's media zone (cards, bento) now fills its slot instead of capping at its native size, and is inset from the media-zone sides. The upscale is gated to fine-pointer / wide slots, scaled with transform rather than zoom, and its fill factor is measured in JS (a new mockup behaviour) rather than cqi — fixing the sizing on iOS Safari.
  • Fix: structure contracts were missing the plan plugin (and recent chart modifiers). The committed structures.json (both the repo-level copy and Lumina's shipped ./contracts export) had drifted from the generator — the drift guard regenerated from a fullConfig that omitted @refrakt-md/plan, so 11 plan runes (spec/work/bug/decision/milestone + the plan UI runes) and the chart tick-count/tick-step/label-angle modifiers were absent from the published contract. The contract now covers all 9 official plugins (131 runes); the drift test includes the plan plugin and additionally guards the repo-level copy so the two committed files can't diverge again.
  • Fix: preview no longer bleeds to the viewport edge inside a feature (default layout). The WORK-438 skeleton/skin split promoted the in-feature breakout .rf-preview--in-feature { margin-inline: calc(-1 * var(--rf-content-gutter)) } into @layer skeleton, where it lost to Lumina's skin rule .rf-preview { margin: 2rem 0 } (skin beats skeleton regardless of specificity), nullifying the breakout. The bleed is moved back to @layer skin, where it again wins over the base margin by specificity and fires together with the canvas border-radius reset at the @container (max-width: 1280px) breakpoint. Docs-layout previews were unaffected (they bleed via the canvas de-chrome, not the in-feature root margin).
  • Fix: plan create now validates enum attributes at write time. plan update rejected invalid status/priority/complexity/severity values, but plan create passed any attrs straight into the scaffolded file unchecked — so a stray complexity="small" (or status="todo") landed silently and only surfaced later as a plan validate error. create (and the plan.create MCP tool) now run the same validation as update, rejecting unknown attributes and out-of-vocabulary enum values with a message listing the valid set, before any file is written. The vocabularies (VALID_STATUS, VALID_PRIORITY, VALID_COMPLEXITY, VALID_SEVERITY, allowed-attr lists) are consolidated into a single shared enums module so create, update, and validate can no longer drift apart, and the plan.create MCP schema documents the accepted enum values.

v0.24.0 — Live backdrops & scroll-reveal motion

v0.24.0complete

v0.24.0 — Live backdrops & scroll-reveal motion

Two independent feature threads, split out of v0.23.0 so the theme-platform extraction stays focused and these ship on their own cadence. Neither depends on the v0.23.0 surface/skeleton work; each is gated only on its own foundation.

bg live sandbox guests (SPEC-104)

Let bg host a live sandbox as a bare, full-bleed backdrop — the layer that lets a surface carry both an animated backdrop and a positioned subject media.

  • WORK-428 — guest body + engine relocation + bare-surface guardrail (the data-bg-guest contract). The gate.
  • WORK-429 — transform-resolved sandbox bg preset (BgPresetDefinition.sandbox, schema, project-config home).
  • WORK-430 — docs + music-blog backdrop showcase (layout reuse + SPEC-006 bridge cross-link).

Scroll-reveal motion (SPEC-105)

A token-driven motion dimension: a closed reveal vocabulary + stagger, author intent / theme choreography / IO-triggered, enhancement-gated.

  • WORK-431 — reveal/stagger engine facet (data-reveal, --rf-reveal-index). The gate.
  • WORK-432 — motion dimension CSS + tokens (global hooks; individual translate/scale composition).
  • WORK-433 — reveal IntersectionObserver behaviour + enhancement gating.
  • WORK-434 — docs + feature-stagger showcase.

Sequencing

The two tracks are independent of each other, each gated on its own foundation:

  • bg: WORK-428 → WORK-429 → WORK-430.
  • motion: WORK-431 → (WORK-432 ∥ WORK-433) → WORK-434.

Both source specs (SPEC-104, SPEC-105) are still draft — accept them before starting the respective thread.

Live sandbox guests in the bg backdrop layer

SPEC-104accepted

Live sandbox guests in the bg backdrop layer

bg (SPEC-088) can fill its backdrop with an image, video, or gradient — resources fully specified by a URL or token reference. It cannot host a live program (a three.js / canvas visualizer in a sandbox). This spec adds a bg guest: a sandbox rendered into the bg layer as a bare, presentational, full-bleed backdrop — behind a rune's content, beneath its tint/substrate, never in flow. It also adds a named, transform-resolved sandbox preset so a reusable backdrop is applied by name (bg="midnight-waves") like any other preset.

This is the missing layer that lets a surface carry both an animated backdrop and a positioned subject media: the visualizer is the bg, the image/video is the in-flow media guest. It is the general fix behind the music-blog hero (a playlist whose cover is a live audio visualizer with a mood image), but it is a surface-model capability, not a media-specific one.

Target: next minor.

surfacesrunesenginebgsandboxluminalayoutmedia

Motivation

Two limits collide:

  • bg hosts only URL/CSS fills. The engine builds the bg layer from bg-* metas (engine.ts §1f): --bg-image: url(…) / a gradient, plus a hand-built <video> for bg-video, an overlay, and a scrim. Every fill is a passive resource the engine can synthesize from a string. A sandbox is not — see below.
  • cover consumes the single media zone. SPEC-089 cover repurposes a rune's media guest as the backdrop ("media fills the interior, content overlays"). That is why a rune can't have a cover backdrop and a positioned subject media at once — there is only one media zone and cover eats it (SPEC-101 got away with it because the sandbox was the sole media).

The surface model already distinguishes the two intents (SPEC-087 §4): the subject media (in flow, framed/chromed, placed by media-position) vs decorative ambiance behind content (bg, out of flow). A live visualizer behind a positioned mood image is the textbook case of ambiance + subject — so the visualizer belongs in bg, the image stays a media guest, and they stop competing for one slot.

The decisive constraint: a sandbox can't be synthesized from a string

bg video="url" works because the engine can hand-build a <video>. A sandbox cannot be built this way: resolving it means reading its source directory, sanitising, and applying the security policy — all of which happen inside the sandbox rune's transform, at page-transform time, using the config.variables.__sandboxReadFile / __sandboxListDir readers (tags/sandbox.ts). The identity engine runs later, on serialized tags, with no file access. So a bg sandbox="scene" attribute the engine expands is a dead end. The guest must be rendered by the real sandbox rune — i.e. it must arrive as authored content (a body) or be expanded into one before transform.

Design

1. The bg guest body

bg (today a directive with no body, tags/bg.ts) gains an optional, constrained body holding a single media guest:

{% hero media-position="start" %}
{% bg %}
  {% sandbox src="midnight-waves" framework="three" dependencies="three" /%}
{% /bg %}
![Late-night haze](./moods/midnight.jpg)   <!-- positioned subject media -->
---
# Midnight Tape
{% /hero %}
  • bg transforms its body normally (so the genuine sandbox rune runs, with full file resolution + sanitisation), then tags the rendered guest data-bg-guest, forces height="fill" (the SPEC-101 host-owned-height mode — iframe height:100%, no auto-resize negotiation), and gives it the backdrop posture (§2 — force-mounted but non-interactive).
  • bg emits that tagged element alongside its existing bg-* metas, exactly as the unwrapped metas land among the host's children today.

2. Engine relocation (mirrors bg-video)

When engine.ts §1f raises the bg layer, it additionally collects any data-bg-guest descendant of the host and pushes it into the bg <div>'s children — layered above the --bg-image boot frame and below the overlay/scrim, a structural sibling of the bg-video branch. The guest is marked consumed so it is not also rendered in the content flow.

Backdrop posture — mounted but inert (not SPEC-090 presentational). A bg sandbox must still run — a visualiser whose iframe never mounts is a blank backdrop — so it cannot take SPEC-090's presentational posture, which makes the enhancement layer skip the guest entirely (initRuneBehaviors bails on any [data-guest-posture="presentational"] descendant, leaving the static fallback). The bg guest instead takes a distinct data-guest-posture="backdrop": the enhancement layer does mount it — and forces eager activation regardless of the author's activation mode, so the scene boots without a user gesture — while the posture suppresses interaction only: pointer-events: none, no user-facing controls/poster, removed from the tab order. The contract is "enhance, then make inert," not "don't enhance." No sandbox logic moves into the engine; bg never has to understand sandboxes; the host rune never has to know either.

3. Bare-surface guardrail

The bg layer must stay bare and inert. The body therefore accepts a presentational guest only (a sandbox). A build warning (SPEC-084-style validation) fires if a chromed content rune is placed there:

bg backdrop guest must be presentational (a sandbox); a video carries player chrome (WORK-018) — place it in the media zone as a positioned guest, or use bg video="…" for a bare video backdrop.

This stops the "any media rune" over-generalisation from dragging controls/captions (video, audio, figure) into a decorative layer. The mechanisms stay right-sized: URL → attribute (bg src=/video=); rendered-bare → body (sandbox). A bare image/video backdrop keeps its existing attribute path; only the un-URL-able sandbox needs the body.

4. Boot-frame composition

The meta facets are unchanged and layer with the guest: a --bg-image (image or gradient) paints behind the guest as the SPEC-101 §5 boot frame (designed first paint while the scene initialises); overlay/scrim sit above it for legibility. When the host also carries a positioned image media guest, that image still flows to og:image / structured-data image via the host's normal media extraction (e.g. playlist's extractMediaImage) — the animated backdrop is decorative, the still image is the crawlable/share/reduced-motion representation.

5. The sandbox preset — transform-resolved

BgPresetDefinition gains a structured sandbox descriptor, a sibling to the engine-resolved gradient/style:

interface BgPresetDefinition {
  style?:    Record<string, string>;                  // engine-resolved (CSS)
  gradient?: { type?; direction?; stops: string[] };  // engine-resolved (CSS)
  sandbox?:  { src: string; framework?: string; dependencies?: string };  // NEW — transform-resolved
  extends?:  string;
}
  • Two resolution sites, one name namespace. A new transform-time expansion step (where the sandbox readers live) sees bg="name", finds a sandbox-typed preset, and injects the real {% sandbox %} guest into the bg body — producing the same data-bg-guest element the engine relocates (§2). The author sees bg="midnight-waves" behave like bg="brand-fade"; internally one resolves to CSS in the engine, the other to a rendered guest at transform time.
  • The preset is sugar over the §1 body, not a parallel system: the body is the primitive, the preset is a named shorthand the expander turns into it.
  • Forced behaviours are not author-set. height: fill, the backdrop posture (§2 — mounted but inert), and forced eager activation come from the bg-guest mechanism; the preset describes only what the scene is (src/framework/dependencies). A preset may also carry a gradient/style for the boot frame (§4) — both land in the same data-name="bg" div.
  • Config home: project config. A scene is content (its files live in the project, e.g. site/examples/midnight-waves/), so a sandbox preset belongs in refrakt.config.json sites.<site>.backgrounds, following the project-vs-theme split SPEC-088 set for the escape hatch; project backgrounds merge over theme. The refrakt.config.schema.json gains the sandbox key.
// refrakt.config.json → sites.main.backgrounds
{
  "surface-dark": { "style": { "border": "1px solid rgba(255,255,255,0.12)" } },
  "midnight-waves": {
    "sandbox": { "src": "midnight-waves", "framework": "three", "dependencies": "three" },
    "gradient": { "type": "linear", "direction": "to-b", "stops": ["surface", "ishi"] }
  }
}

6. Reuse: placement via the layout cascade, audio via SPEC-006

DRY reuse splits along two axes:

  • Value reuse — the named preset (§5) defines the scene once.
  • Placement reuse — the layout cascade applies it once. For "every blog post," set bg="midnight-waves" in the blog-article layout's cover region (the route rule blog/* → blog-article already exists); every post inherits the backdrop with zero per-post authoring.
  • Per-page audio — the SPEC-006 audio↔sandbox bridge (audio.onFrame, audio.onTrackChange, the DATA.tracks global, streaming lifecycle) binds each page's own player to the shared scene at runtime, so one named backdrop reacts to each post's mixtape. The bridge itself is SPEC-006's; this spec only ensures a bg-layer sandbox is a valid subscriber.
  • Memoisation — a named scene's assembled source is byte-identical across pages, so the expander may build the bundle once and clone per page (an optimisation only the named path affords).

7. Reduced motion & performance

A live sandbox backdrop is an always-running animation behind every page that carries it (it spreads by layout cascade, §6), so it must be gated like any other motion — consistent with the SPEC-105 reduced-motion baseline rather than a one-off.

  • Reduced motion → boot frame only. Under prefers-reduced-motion: reduce, the live scene is not mounted; the §4 boot frame (the preset/inline gradient/image still) stands in as the complete, static representation. This reuses the backdrop posture's forced-activation switch — the enhancement layer simply declines to mount the scene under reduced motion, exactly as it marks reveals in-view immediately (SPEC-105).
  • Off-screen / hidden → suspended. The backdrop pauses (or tears down) its render loop when scrolled off-screen or on a hidden tab, so a long article isn't driving a 3-D scene the reader can't see. The boot frame remains painted underneath, so suspension is invisible.
  • No-JS / crawler already get the boot frame (the scene only exists once enhanced), matching the SPEC-105 "static page is always complete" rule.

Acceptance Criteria

  • bg accepts an optional, constrained body holding one bare guest (sandbox); bg transforms it (real sandbox rune — file resolution + sanitisation), tags it data-bg-guest, and forces height="fill" + the backdrop posture.
  • The engine (§1f) relocates a data-bg-guest element into the data-name="bg" layer — above the --bg-image boot frame, below overlay/scrim, sibling to bg-video — and marks it consumed so it does not render in content flow.
  • Backdrop posture (§2): the bg guest gets data-guest-posture="backdrop" — the enhancement layer mounts it and forces eager activation (the scene runs), while suppressing interaction only (pointer-events: none, no controls, out of tab order). It is explicitly not SPEC-090 presentational (which would skip enhancement and leave a dead backdrop); a test covers that a backdrop guest mounts while a presentational one does not.
  • A chromed content rune (video/audio/figure) in the bg body produces a build warning redirecting to the media-guest slot or bg video="…"; bare image/video backdrops keep their existing bg src=/video= attribute path (unchanged).
  • Boot frame composes: a preset/inline gradient/image paints behind the guest; overlay/scrim above it; a host's positioned image still flows to og:image / structured-data image.
  • Reduced motion & performance (§7): under prefers-reduced-motion: reduce the live scene is not mounted and the boot frame stands in; the backdrop suspends its render loop when off-screen / on a hidden tab; no-JS/crawler render the boot frame. Consistent with the SPEC-105 reduced-motion baseline.
  • BgPresetDefinition gains a sandbox descriptor (src/framework/dependencies), resolved at transform time by an expansion step that injects the §1 body guest — not in the identity engine; bg="name" reaches author-parity with a gradient preset.
  • A sandbox preset may also carry gradient/style (boot frame); extends resolves as for other bg presets; refrakt.config.schema.json documents the sandbox key; project backgrounds merge over theme.
  • Docs: the bg reference documents the guest body, the bare-surface guardrail, the sandbox preset (with the refrakt.config.json example), and the boot-frame layering; contracts regenerated (refrakt contracts --check green) and CSS coverage passes for any new bg-guest selectors.

Non-goals

  • Chromed runes as backdrops — video/audio are subject media; they live in the media-guest slot, never bg (§3). A bare video backdrop uses bg video="…".
  • The audio bridge itself — audio.onFrame/onTrackChange, the streaming lifecycle, and the DATA global are SPEC-006's; this spec only makes a bg-layer sandbox a valid subscriber.
  • Playlist/hero cover parity — porting cover-overlay modifiers onto leaner runes is a separate layout item; this spec is the bg layer only.
  • A first-party (non-sandbox) canvas backdrop — a built-in visualizer component that shares the AudioContext without an iframe is a distinct, later option; here the guest is an author-editable sandbox.
  • Generalising the guest body to arbitrary content — one bare guest only (a sandbox), gated by the §3 guardrail.

Work breakdown (provisional)

  1. bg guest body + engine relocation + guardrail (§1–§4, §7) — bg body, data-bg-guest tagging + fill, the backdrop posture (mounted-but-inert + forced eager) and the reduced-motion/off-screen gating, engine §1f relocation, bare-surface validation, boot-frame layering.
  2. sandbox bg preset (§5) — BgPresetDefinition.sandbox, the transform-time expansion step (sandbox readers), schema + project-config home, memoisation.
  3. Docs + showcase (§6) — bg reference, refrakt.config.json example. The music-blog audio-reactive showcase is deferred until the SPEC-006 audio bridge is built (it is accepted but not yet implemented); a static/non-reactive live backdrop is demonstrable from item 1 alone, and the reactive showcase lands with SPEC-006.

References

  • SPEC-088 — bg gradients + escape hatch; BgPresetDefinition, engine §1f resolution (engine.ts), tags/bg.ts, bg.css.
  • SPEC-087 — surface-fill model; the subject-media-vs-ambiance boundary (§4) this builds on.
  • SPEC-089 — cover layout (why cover consumes the single media zone); the subject/ambiance line.
  • SPEC-090 — media-guest interaction posture; the bg guest defines a sibling backdrop posture (mounted-but-inert), distinct from presentational (not enhanced) — see §2.
  • SPEC-101 — sandbox as a cover media guest (prior art): height="fill" host-owned height, the boot-frame guidance, production posture; packages/behaviors/src/elements/sandbox.ts.
  • SPEC-006 — the audio↔sandbox bridge (audio.onFrame/onTrackChange, streaming lifecycle) the backdrop subscribes to.
  • WORK-018 — the video rune's player chrome (why it is a subject, not a backdrop).
  • SPEC-084 — rune validation (the bare-surface build warning).

Scroll-reveal motion — a token-driven entrance dimension

SPEC-105shipped

Scroll-reveal motion — a token-driven entrance dimension

refrakt can compose elaborate backdrops (bg, substrate, cover) but has no vocabulary for choreography — how a section arrives. Add a motion dimension: a small, closed, author-facing reveal vocabulary that declares the intent of an entrance (a section fades in; feature items slide in as they scroll into view), with the theme owning the choreography and an IntersectionObserver behaviour owning the timing trigger. Motion is opt-in, token-driven, and enhancement-gated so the static page is always complete.

Target: next minor.

motionanimationluminaenginebehaviorsrunesa11ydimensionsdx

Motivation

You can build a live three.js backdrop, but you can't fade a hero in or stagger a feature grid as it scrolls into view. The pieces to do it coherently already exist:

  • The dimensions/ system — refrakt already models cross-cutting, token-driven, data-*-keyed concerns as "dimensions" (packages/lumina/styles/dimensions/state.css toggles open/closed/active off data-state). Motion is a new dimension, not a per-rune feature.
  • The reduced-motion baseline (WORK-352) — global.css already neutralises all animation/transition durations to 0.01ms under prefers-reduced-motion (not none, so transitionend/animationend still fire for awaiting JS). New motion inherits this guard for free.
  • The progressive-enhancement behaviours — tabs/accordion/datatable/form already establish the framework-agnostic JS home; a reveal observer joins them.

Principle

The division that keeps motion from leaking author concerns into theme concerns:

  • Author declares intent — reveal="fade", stagger — a coarse, per-section character. Never how far, how fast, or which part moves.
  • Theme owns choreography — what moves, in what order, distance, easing, sync vs offset — expressed in CSS over the rune's named BEM anatomy (.rf-feature__content, __media, …) it already styles. Two themes render the same reveal="fade" differently (whole-section in sync vs content/media slightly offset) with no author change.
  • The behaviour owns when — an IntersectionObserver flips one data-in-view state on the container. Theme-agnostic; JS = when, CSS = how.
  • The shared vocabulary is the contract — a closed named set with a documented character per value; themes vary the choreography but must preserve the character (a fade reads as a fade everywhere).

Design

1. The reveal vocabulary (closed, author-facing)

A bounded matches set on the reveal modifier — an unknown value is a build error, keeping the author↔theme contract enforceable.

ValueCharacter the theme must preserveTheme is free to vary
noneNo entrance (the default / opt-out).—
fadeOpacity-led: 0 → 1.Subtle movement, per-part sync/offset, duration/easing
slideMovement-led: translation into place + opacity.Axis, direction, distance (often layout-derived), easing, offset
scaleScale-led: slightly <1 → 1 + opacity.Start scale, transform-origin, easing, offset
blurFocus-led: blur → sharp + opacity.Blur amount, easing; may downgrade to fade under perf/reduced constraints
  • Direction is deliberately not in the vocabulary. slide means "enters from a theme-chosen offset"; the theme derives the axis (often from the rune's layout — media from its side, content from its side, reading-direction aware). A future optional logical reveal-from="bottom|top|start|end" hint (theme may honour or override) is a v2 escalation, not v1.
  • blur carries a documented downgrade — it animates filter (not a compositor-cheap property), so a theme or a low-power/reduced path may render it as fade.

2. stagger — an orthogonal timing modifier

stagger is not a character; it composes with any value (reveal="slide" stagger) and governs whether a container's children arrive together or in a cascade.

  • The engine stamps --rf-reveal-index (0,1,2,…) on each child; the theme turns it into a per-child delay against a stagger-interval token (animation-delay: calc(var(--rf-reveal-index) * var(--rf-reveal-stagger))), measured from the container's single in-view trigger (not per-child observation — see Non-goals).
  • Multi-child only — meaningful on feature/bento/steps/pricing/playlist tracks; a silent no-op on single-child runes (hero). The theme owns the rhythm and order (a grid theme may read the index as a diagonal/column sweep, not strict source order).

3. Layer 1 — engine config (the intent → data attributes)

A shared reveal/stagger modifier on section-level runes (hero, feature, bento, cta, card, steps, pricing, testimonial, playlist, …) — a cross-cutting facet like media-position/tint, configured once, not per rune. It emits:

  • data-reveal="<value>" on the rune root,
  • data-stagger when set,
  • --rf-reveal-index on each enumerated child (the engine already enumerates children for numbered sequences, so index assignment is free).

No new structure is required: the theme choreographs over the named anatomy the engine already emits, so enabling theme choreography costs only these attributes.

4. Layer 2 — the motion dimension CSS (the choreography)

A new dimensions/motion.css (+ motion tokens in tokens/) defines, per character, the keyframes/transitions keyed on data-reveal × data-in-view, plus the stagger delay. The theme owns the physics tokens — duration, easing, travel distance, scale start, stagger interval — so a "calm" theme and a "punchy" theme differ purely by retuning tokens, like spacing/radius scales. Choreography across named parts (__content/__media offset) lives here too.

One global stylesheet, not per-rune edits. The dimension covers every section rune without touching any rune's own CSS, because it keys on generic hooks, not structure:

  • Root entrance keys on [data-reveal][data-in-view] on the rune root — no knowledge of internals, so fade/slide/scale/blur work for all section runes from one rule.
  • Stagger targets the index marker the engine stamps — [data-stagger][data-in-view] [style*="--rf-reveal-index"] — not a structural > *. The "which children cascade" decision lives in each rune's config (the child enumeration that stamps the index), so the CSS never needs per-rune wrapper-depth knowledge.
  • Transform composition (the critical rule). Lumina already uses transform in ~48 files (hover-lifts on card/cta/feature/pricing, frame displacement, drawer/nav slides). Reveal therefore animates the individual translate/scale properties, never the transform shorthand — so a reveal translate composes with an existing transform: scale(…) hover instead of clobbering it. This is what lets the dimension stay global rather than auditing and patching every rune that already transforms.
  • Per-part choreography (__content/__media offset) is opt-in theme polish on top of the working global default — never a per-rune requirement.

5. Layer 3 — the reveal behaviour (the trigger)

A tiny IntersectionObserver behaviour (joining tabs/accordion/datatable/form):

  • Observes each [data-reveal]:not([data-reveal="none"]) container; on first intersection it sets data-in-view and unobserves it.
  • Honours matchMedia('(prefers-reduced-motion: reduce)') — marks everything in-view immediately (belt-and-braces with the global reset).
  • Theme-agnostic: it sets one state attribute and nothing else.

6. Enhancement gating — the static page is always complete

The cardinal rule (and the classic scroll-reveal footgun avoided): SSR / no-JS / crawler / reduced-motion render the fully visible final state. Never bake opacity: 0 into SSR HTML that only JS removes.

  • The hide-then-reveal CSS is scoped under a root data-animate flag the behaviour adds on boot. No flag (no JS) → every section fully rendered, no motion. With the flag, [data-reveal]:not([data-in-view]) holds the pre-entrance state until the observer fires.
  • Reuses the WORK-352 global reduced-motion reset; animates compositor-only properties (opacity/transform), blur being the documented exception that may downgrade.

7. Defaults

reveal defaults to none — no surprise motion; the author opts a section in. A theme may set per-rune defaults via config (e.g. hero → fade), but the safe baseline is opt-in.

Acceptance Criteria

  • A closed reveal modifier (none|fade|slide|scale|blur, default none, unknown = build error) is available on section-level runes as a shared engine facet; it emits data-reveal on the root and --rf-reveal-index on enumerated children.
  • stagger is an orthogonal modifier composing with any character; it emits data-stagger, is a silent no-op on single-child runes, and drives a per-child delay from --rf-reveal-index against a theme stagger-interval token.
  • A dimensions/motion.css + motion tokens define each character keyed on data-reveal × data-in-view; the theme owns duration/easing/distance/scale/stagger as tokens, and may choreograph across a rune's named parts (sync or offset) without author input.
  • Global coverage, not per-rune: the dimension covers all section runes from one stylesheet — root entrance keys on [data-reveal][data-in-view], stagger on the engine's --rf-reveal-index marker (not a structural > *); no rune's own CSS file gains a motion block for the baseline (per-part choreography is opt-in).
  • Transform composition: reveal animates the individual translate/scale properties, never the transform shorthand, so it composes with the ~48 Lumina files that already use transform (hover-lifts, frame displacement, drawer/nav) instead of clobbering them; verified against at least one hover-transform rune (card/cta).
  • An IntersectionObserver reveal behaviour sets data-in-view on first intersection and unobserves; under prefers-reduced-motion it marks all in-view immediately.
  • Enhancement gating: SSR/no-JS/crawler render the fully visible final state (no opacity:0 baked in); the pre-entrance state is scoped under a root data-animate flag the behaviour adds on boot; the WORK-352 reduced-motion reset still neutralises motion.
  • blur documents (and a theme may apply) a downgrade to fade; all other characters animate compositor-only properties.
  • Docs: a theme-authoring motion dimension page (the vocabulary, the character contract, the physics tokens, choreography over named parts) and an author-facing reveal/stagger reference with a feature-stagger example; contracts regenerated and CSS coverage passes for the new selectors.

Non-goals

  • A timeline / keyframe authoring system in Markdown, or raw inline CSS animation — the author surface is the closed vocabulary + stagger, nothing finer.
  • Animate-by-default — opt-in only (theme may set per-rune defaults).
  • Direction in the vocabulary — slide is theme/layout-derived; an optional reveal-from hint is a deferred v2 escalation.
  • Per-part scroll triggers — one container-level trigger + CSS delay covers "slightly out of sync"; parts triggering at genuinely different scroll positions is deferred.
  • CSS scroll-driven animation (animation-timeline: view()) — IO is the v1 baseline (universal, controllable); scroll-driven CSS is a later zero-JS progressive upgrade where supported.
  • Page/route transitions, parallax, scroll-linked continuous animation — this spec is discrete entrance reveals only.

Work breakdown (provisional)

  1. Engine facet (§3) — shared reveal/stagger modifier, data-reveal/data-stagger, --rf-reveal-index child enumeration; matches validation.
  2. Motion dimension CSS + tokens (§4) — dimensions/motion.css, per-character keyframes/transitions, motion tokens, stagger delay; global hooks only (generic attrs + --rf-reveal-index marker) and individual translate/scale properties so it composes with existing rune transforms — no per-rune motion CSS.
  3. reveal behaviour + enhancement gating (§5–§6) — IO observer, data-in-view, root data-animate boot flag, reduced-motion path.
  4. Docs + showcase (§7) — theme-authoring motion-dimension page, author reveal reference, a feature-stagger demo.

References

  • The dimension precedent (data-state toggled by behaviours, read by the theme): packages/lumina/styles/dimensions/state.css; theme-authoring dimensions doc site/content/extend/theme-authoring/dimensions.md.
  • Reduced-motion baseline this builds on: WORK-352; packages/lumina/styles/global.css.
  • Behaviour-package home + progressive-enhancement pattern: packages/behaviors/src/ (tabs/accordion/datatable/form).
  • Engine modifier + child enumeration: packages/transform/src/engine.ts, packages/runes/src/config.ts.
  • Named-set / token-discipline convention this follows: SPEC-088 (bounded facets vs escape hatch), SPEC-086 (named scales).