/** * The stage - play mode's single mount. Unlike the frame host (one iframe * per frame), the stage keeps ONE tree mounted - providers + layout chain - and swaps * only the innermost frame on data-goto, so app shells in _layout persist across * navigation like a real app. Swaps ride document.startViewTransition when available; * agents opt into shared-element morphs with plain view-transition-name CSS. * * The shell owns chrome, device sizing, walk order, and the URL; the stage owns data-goto: * stage -> shell: sh:stage-ready · sh:stage-shown {at, epoch, doc} · sh:stage-at {at} · sh:stage-exit · sh:stage-error * sh:stage-key {key, code} (forwarded shortcuts) * shell -> stage: sh:stage-set {at} (history / walk / restart) · sh:set-theme * Jam captures (spec 27) speak the same messages here as in a canvas frame: the keys the shell owns * (`sh:keys` -> `sh:key`, frame-host/keys.js) and the recorder (frame-host/trail.js, mode 'play'). */ import { Component, createElement, useEffect, useRef, useState, type ComponentType, type ReactNode } from 'react' import { flushSync } from 'react-dom' import { createRoot } from 'react-dom/client' import { frameFile, frames, layoutChain, layouts, providers } from '../frame-host/registry.ts' import { SLIDE_DOC_CSS } from '../const.ts' import { createInspect } from '../frame-host/inspect.js' import { createKeys, keyAllowed } from '../frame-host/keys.js' import { createRecorder } from '../frame-host/trail.js' const params = new URLSearchParams(location.search) // Both signals, always - same pair as the frame host: [data-theme] for attribute-keyed // token systems, .dark for class-keyed ones (Tailwind/shadcn). Missing the class made // play render class-keyed apps light while the canvas showed them dark. const bootTheme = params.get('theme') ?? 'light' /** The revisions this stage document was served with (spec 26): { epoch, doc: { frame: rev } }. */ const stamps: { epoch: string; doc: Record } | null = (() => { try { return JSON.parse(document.querySelector('meta[name="mv-doc"]')?.getAttribute('content') ?? 'null') } catch { return null } })() // slides mode: the shell says so in the URL; the stage stamps the attributes every slide // can key its own motion off - data-sl-play (the deck is playing) on for the whole // show, data-sl-entered once each swap settles, data-mv-slide while the mounted frame is a // `slide: true` frame. `tr=none` and prefers-reduced-motion both skip view transitions. const slidesMode = params.get('slides') === '1' const deckTransition = params.get('tr') ?? 'fade' if (slidesMode) document.documentElement.setAttribute('data-sl-play', '') /** The deck's playback CSS - player behaviour, so it lives with the player and serves every * slide, whatever its markup: one tempo (--marver-slide-tempo) for the morphs between slides * and the optional entrance presets (data-animate + data-animate-delay), which stay inert * until the slide has arrived. Anything beyond these is the author's own CSS or JS. */ const DECK_CSS = ` ::view-transition-group(*), ::view-transition-old(root), ::view-transition-new(root) { animation-duration: var(--marver-slide-tempo, 350ms); } [data-sl-play] [data-animate] { opacity: 0 } [data-sl-play][data-sl-entered] [data-animate] { opacity: 1; animation-duration: var(--marver-slide-tempo, 350ms); animation-timing-function: cubic-bezier(.2, .7, .2, 1); animation-fill-mode: both; } [data-sl-play][data-sl-entered] [data-animate="fade-up"] { animation-name: sl-fade-up } [data-sl-play][data-sl-entered] [data-animate="fade"] { animation-name: sl-fade } [data-sl-play][data-sl-entered] [data-animate="scale-in"] { animation-name: sl-scale-in } [data-sl-play][data-sl-entered] [data-animate-delay="1"] { animation-delay: 80ms } [data-sl-play][data-sl-entered] [data-animate-delay="2"] { animation-delay: 160ms } [data-sl-play][data-sl-entered] [data-animate-delay="3"] { animation-delay: 240ms } @keyframes sl-fade-up { from { opacity: 0; transform: translateY(18px) } to { opacity: 1; transform: none } } @keyframes sl-fade { from { opacity: 0 } to { opacity: 1 } } @keyframes sl-scale-in { from { opacity: 0; transform: scale(.94) } to { opacity: 1; transform: none } } @media (prefers-reduced-motion: reduce) { [data-sl-play] [data-animate] { opacity: 1; animation: none !important } } ` if (slidesMode) { const el = document.createElement('style') el.setAttribute('data-mv-deck', '') el.textContent = DECK_CSS document.head.appendChild(el) } // the slide document baseline, keyed on data-mv-slide (set per mounted frame): any mode can // show a slide, and its geometry must not depend on what the stage showed before { const base = document.createElement('style') base.setAttribute('data-mv-slide-doc', '') base.textContent = SLIDE_DOC_CSS document.head.appendChild(base) } const reducedMotion = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches document.documentElement.dataset.theme = bootTheme document.documentElement.classList.toggle('dark', bootTheme === 'dark') const startId = params.get('at') ?? '' // same-origin parent (the shell): fixed target origin so anchor bundles never leak if a // data-goto ever navigates the stage cross-origin const post = (msg: Record) => { if (window.parent !== window) window.parent.postMessage(msg, location.origin) } window.addEventListener('error', (e) => post({ type: 'sh:stage-error', message: String(e.message || e.error) })) window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:stage-error', message: `unhandled rejection: ${e.reason}` })) // pinch inside the stage must not zoom the parent page (same rule as the frame bridge) window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.metaKey) e.preventDefault() }, { passive: false }) document.addEventListener('gesturestart', (e) => e.preventDefault()) // B0.2: a nested scroll container hitting its boundary must not chain into the shell page document.documentElement.style.overscrollBehavior = 'contain' interface Mounted { id: string; Frame: ComponentType; wrappers: ComponentType[]; slide: boolean } /** Resolve a frame id to its component + wrapper chain. Modules are import()-cached by * Vite, so re-resolving a chain yields the SAME component references - React keeps * unchanged layout instances mounted across swaps, which is the whole point. */ async function resolve(id: string): Promise { const fileKey = frameFile(id) if (!fileKey) throw new Error(`unknown frame id "${id}"`) const mod: any = await frames[fileKey]() if (mod.default == null) throw new Error(`${fileKey} has no default export`) const wrappers: ComponentType[] = [] const providerKey = Object.keys(providers)[0] if (providerKey) wrappers.push((await providers[providerKey]() as any).default) for (const lk of layoutChain(fileKey)) wrappers.push((await layouts[lk]() as any).default) return { id, Frame: mod.default, wrappers: wrappers.filter((w) => w != null), slide: mod.meta?.slide === true } } class Boundary extends Component<{ resetKey: string; children?: ReactNode }, { err: Error | null }> { state = { err: null as Error | null } static getDerivedStateFromError(err: Error) { return { err } } componentDidUpdate(prev: { resetKey: string }) { if (prev.resetKey !== this.props.resetKey && this.state.err) this.setState({ err: null }) } componentDidCatch(err: Error) { post({ type: 'sh:stage-error', message: err.message }) } render() { if (!this.state.err) return this.props.children return createElement(ErrorCard, { title: 'frame crashed', message: this.state.err.message, detail: this.props.resetKey }) } } /** spec 26: a frame that committed - inside the error boundary, so a crash never counts - says which * frame the stage shows, at which revision (the stamp this document was served with: a frame imported * later never claims more than the wrappers it renders with), for which document generation. */ function Shown({ id }: { id: string }) { // a tick later, and only if it is still mounted: a frame that throws from its own effects is replaced by // the boundary's card first, which unmounts this and cancels the report useEffect(() => { const t = setTimeout(() => post({ type: 'sh:stage-shown', at: id, nav: params.get('r') ?? '', epoch: stamps?.epoch ?? '', doc: stamps?.doc[id] ?? 0 }), 0) return () => clearTimeout(t) }, [id]) return null } function ErrorCard({ title, message, detail }: { title: string; message: string; detail: string }) { return createElement('div', { style: { fontFamily: 'ui-monospace,monospace', fontSize: 12, padding: 16, color: '#ff8a80' } }, createElement('div', { style: { fontWeight: 700, marginBottom: 8 } }, title), createElement('div', { style: { whiteSpace: 'pre-wrap' } }, message), createElement('div', { style: { marginTop: 8, color: '#7c859a' } }, detail), ) } function Stage() { const [mounted, setMounted] = useState(null) const [err, setErr] = useState<{ id: string; message: string } | null>(null) const current = useRef(startId) const swapSeq = useRef(0) const readySent = useRef(false) const recorder = useRef | null>(null) /** Swap to a frame. `announce` posts sh:stage-at (user-driven); history restores stay silent. */ const goto = async (id: string, announce: boolean) => { if (id === current.current && mounted && !err) return const seq = ++swapSeq.current try { const next = await resolve(id) if (seq !== swapSeq.current) return // a newer swap superseded this one // startViewTransition runs its callback async - recheck the seq there too, or an // older pending transition could commit stale state over a newer navigation. // current.current is advanced INSIDE apply (once the new DOM is committed), so // inspect.getId() never reports the new frame while the old DOM is still live - // a pick / anchor-resolve landing mid-swap then carries the old id and the shell // guards drop it instead of stamping it onto the wrong frame. // slides: the entrance presets re-arm per swap. The disarm (drop `entered`, // strip data-animate from morph-owned elements) runs INSIDE the transition's // update callback - after the new DOM commits, before the NEW-state capture. // Disarming before startViewTransition would capture the OUTGOING slide with // its [data-animate] elements at opacity 0 (they'd vanish from the old // snapshot), and a morphed element still carrying data-animate would be // captured transparent and pop in after the morph. const disarm = () => { if (!slidesMode) return document.documentElement.removeAttribute('data-sl-entered') // one transform owner: an element that morphs must not also run an // entrance preset - CSS cannot see a computed view-transition-name, // so the stage strips data-animate from morph-owned elements here for (const el of document.querySelectorAll('[data-animate]')) if (getComputedStyle(el).viewTransitionName !== 'none') el.removeAttribute('data-animate') } const apply = () => { if (seq !== swapSeq.current) return // the slide mark first: content primitives read it while they render document.documentElement.toggleAttribute('data-mv-slide', next.slide) flushSync(() => { setErr(null); setMounted(next) }) current.current = id disarm() } const entered = () => { if (!slidesMode || seq !== swapSeq.current) return document.documentElement.setAttribute('data-sl-entered', '') } const skipVt = reducedMotion || (slidesMode && deckTransition === 'none') if (document.startViewTransition && !skipVt) { const vt = document.startViewTransition(apply) vt.finished.then(entered, entered) } else if (skipVt) { apply(); entered() } else { // no view transitions here: a plain crossfade at the deck's tempo, so // the one-tempo contract holds even where morphs cannot apply(); entered() const raw = getComputedStyle(document.documentElement).getPropertyValue('--marver-slide-tempo').trim() const m = /^(\d*\.?\d+)\s*(ms|s)?$/i.exec(raw) const tempo = m ? parseFloat(m[1]) * (m[2]?.toLowerCase() === 's' ? 1000 : 1) : 350 // a CSS