import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { cap, frameUrl, shownRev, useStore, CONFIG, type Node } from '../store.ts'
import { admit, release } from './admission.ts'
import { canvasCtl } from './ctl.ts'
import { CopyIcon, IntentGlyph, ParallelogramFillIcon, ReloadIcon, SlideFrameIcon, XIcon } from '../icons.tsx'
import { CommentLayer } from '../Comments.tsx'
import { useComments } from '../comments-store.ts'
import { threadHostKey } from '../keys.ts'
import { registerFrame, unregisterFrame } from './frame-registry.ts'
import { canvasClick, canvasPointer, frameGone, frameLoaded, pressNow, useJamCapture } from '../jam-capture.ts'
import { primeCameraFor } from './camera-broadcast.ts'
import { scheduleSleep, sleep, wake } from './sleep.ts'
import { canAutoReload, shouldArmReadyWatch } from './ready-watch.ts'
import { Stickies, type NoteSpec } from './Sticky.tsx'
import { recorder } from '../recorder.ts'
import { noteId, sceneNoteHost } from '../notes.ts'
import { slideSize, stageFit } from '../../const.ts'
import { FrameSlot, toneOf, useFrameMark } from '../marks-ui.tsx'
import { stranded } from '../api.ts'
export const HEADER = 28
const SNAP = 12
/** The iframe's box in its node: a frame fills the node; a slide renders at its stage and is
* scaled uniformly into the node and centred - resizing a slide node never reflows it. The
* identity case carries no transform (no layer, no containing block). */
function iframeBox(stage: { width: number; height: number } | null, node: { w: number; h: number }) {
if (!stage) return { width: node.w, height: node.h }
const { k, ox, oy } = stageFit(stage, node)
const identity = Math.abs(k - 1) < 1e-6 && Math.abs(ox) < 0.5 && Math.abs(oy) < 0.5
return { width: stage.width, height: stage.height, ...(identity ? {} : { transform: `translate(${ox}px, ${oy}px) scale(${k})`, transformOrigin: '0 0' }) }
}
/** Live Jam working shimmer: a slim 2x6 strip of tiny marver marks on the frame's left
* flank, top-aligned - each mark twinkles on its own scattered beat, phased per frame by
* --mv-w0 (set on the node) so parallel frames never pulse in sync. */
const SHIM_DELAYS = Array.from({ length: 12 }, (_, i) => {
const r = Math.floor(i / 2), c = i % 2
return (((r * 7 + c * 13) % 9) / 9) * 0.95
})
function WorkShimmer({ belowBadge }: { belowBadge: boolean }) {
// a variant badge owns the top of the left flank - the shimmer yields and sits below it
return (
{SHIM_DELAYS.map((d, i) => (
))}
)
}
/**
* One frame on the canvas. Iframe laws: the iframe element is created once per node key
* and never remounted - theme changes go through sh:set-theme, size changes are CSS only.
*
* At rest the frame SLEEPS in place (sleep.ts, spec 16): the same live document, its animations
* paused and its backdrop-filters replaced by certified textures. Interact mode wakes it; laser,
* comment pins and selection act on the sleeping document as it is. A frame the human has
* interacted with is a state the compiler cannot reproduce from its URL: it stays awake until
* it is reloaded. Any change of theme, size or source wakes first and sleeps again once settled.
*
* Every interactive element carries `sh-no-pan` (rzpp's panning.excluded checks the event
* TARGET's classList, nothing else), and drags additionally raise the store gesture flag,
* which hard-disables canvas panning for the duration. Both are needed: the class stops the
* pan before it starts, the flag covers targets we missed.
*/
export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
const frame = useStore((s) => s.frameFor(node))
const selected = useStore((s) => s.selection.includes(node.key))
const interact = useStore((s) => s.interact === node.key)
const working = useStore((s) => s.working.includes(node.frame)) // Live Jam: Marver is editing this frame
const mark = useFrameMark(node.frame) // spec 25: new, or a question for you
// spec 26: its document shows an older revision than the frame's last edit (an event not here yet)
const behind = useStore((s) => !!s.marks && !!node.shown && shownRev(node, s.marks) < (s.marks.doc[node.frame] ?? 0))
// sticky notes (spec 18): the scene's note when this node is the scene's first on the board
// (reading order), then the frame's own. Text is compared by value - a note edit re-renders
// only its column; a pan never re-renders any node (no position subscription here).
const sceneNote = useStore((s) => {
const scene = frame?.scene
const text = scene ? s.manifest?.scenes.find((sc) => sc.name === scene)?.note : undefined
if (!text) return undefined
return sceneNoteHost(s.nodes, (id) => s.manifest?.frames.find((f) => f.id === id)?.scene, scene!) === node.key ? text : undefined
})
const notes: NoteSpec[] = []
if (frame && sceneNote) notes.push({ kind: 'scene', id: noteId('scene', frame.scene), text: sceneNote })
if (frame?.note) notes.push({ kind: 'frame', id: noteId('frame', frame.id), text: frame.note })
const workingSince = useStore((s) => s.workingSince[node.frame])
// B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
// pan/zoom tick. gestureScale below measures the world rect (the canonical source,
// Law G-5); the stored scale is only a never-hit fallback, read lazily at drag time.
const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, reloadFrame, setGesture, toast } = useStore.getState()
const iframeRef = useRef(null)
const themeRef = useRef(node.theme)
// The JSX src is set ONCE, at ADMISSION, with the theme and revision of that moment, and never
// changes again (React would write it back after any later navigation and load the frame twice):
// theme changes ride sh:set-theme (never navigation), so frame state (forms, scroll, dialogs)
// survives a theme flip; file changes and reloads navigate imperatively below, only once
// admitted - a queued frame takes the fresh revision when its turn comes.
const [src, setSrc] = useState()
// the document is another project's "another canvas" page (spec 28 A1.11): a quiet placeholder over it instead
const [away, setAway] = useState(false)
const admitted = src !== undefined
const admittedRef = useRef(false)
const fileRef = useRef(frame ? `${frame.kind}:${frame.file}` : null)
// admission (admission.ts): the iframe gets its src when a boot slot is free, nearest the centre
// of the view first; the slot goes back when the frame is ready, errored, gone, or its watchdog acts
const navigate = (url: string) => { if (admittedRef.current && iframeRef.current) iframeRef.current.src = url }
useEffect(() => {
if (!frame || node.missing) { admittedRef.current = false; setSrc(undefined); return } // gone: the card replaces the iframe; a return is a new document, admitted anew
if (admittedRef.current) return // the document exists (the frame's id changed under it): no slot
// visible first, then by distance to the centre of the canvas (the panel offsets the window's) - where the frame
// will be once the camera lands from a fit or zoom on its way (a board's first pick comes as the fit sets off)
const rank = () => {
const now = iframeRef.current?.getBoundingClientRect(), c = document.querySelector('.sh-canvas')?.getBoundingClientRect()
if (!now || !c) return Infinity
const r = canvasCtl.landing(now)
const visible = r.right > c.left && r.left < c.right && r.bottom > c.top && r.top < c.bottom
return (visible ? 0 : 1e7) + Math.hypot(r.x + r.width / 2 - c.x - c.width / 2, r.y + r.height / 2 - c.y - c.height / 2)
}
const start = () => {
const s = useStore.getState(), n = s.nodes.find((x) => x.key === node.key), f = n && s.frameFor(n)
if (!n || !f) { release(node.key); return }
if (n.status !== 'loading') setStatus(node.key, 'loading') // a returning frame boots under the watchdog like any other
admittedRef.current = true
setSrc(frameUrl(f, n.theme))
recorder.admitted(node.key)
}
admit({ key: node.key, rank, start })
return () => release(node.key)
}, [frame?.id, node.key, node.missing])
useEffect(() => { if (node.status !== 'loading') release(node.key) }, [node.status, node.key])
// theme switch without remount: the live iframe flips via message (no navigation); the frame
// reports sh:theme-applied and only then sleeps again under the new theme (node.themeOn)
useEffect(() => {
if (themeRef.current !== node.theme) {
themeRef.current = node.theme
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:set-theme', theme: node.theme }, '*')
}
}, [node.theme, node.key])
// B0.3: register this frame's WindowProxy so the shell routes its messages in O(1)
// (source window -> node), instead of rescanning every iframe + walking the DOM per
// message. Registration runs SYNCHRONOUSLY through the ref callback (P1): a fast static
// HTML frame or an immediate boot failure can post sh:ready/sh:error before a passive
// effect would run, and the registry is the security gate that would otherwise drop it
// as an unknown source (misleading 10s timeout). The WindowProxy is stable across
// navigations; onLoad re-asserts it (idempotent) after each navigation.
const regWin = useRef(null)
const downAt = useRef<{ x: number; y: number; t: number } | null>(null)
const capturing = useJamCapture((s) => !!s.current)
useEffect(() => () => frameGone(node.key), [node.key])
const registerWin = () => {
const iframe = iframeRef.current
const win = iframe?.contentWindow
if (!iframe || !win || regWin.current === win) return
regWin.current = win
registerFrame(win, { key: node.key, iframe })
}
const bindIframe = useCallback((el: HTMLIFrameElement | null) => {
if (regWin.current && (!el || el.contentWindow !== regWin.current)) { unregisterFrame(regWin.current); regWin.current = null }
iframeRef.current = el
registerWin()
}, [node.key])
// laser mode rides the same rail; re-sent when a frame becomes ready
// so late loaders join an already-lasered board
const laser = useStore((s) => s.laser)
const laserInspect = useStore((s) => s.laserInspect)
const commentMode = useComments((s) => s.commentMode)
// a node hosting the OPEN thread card (or a draft composer) rises above its
// neighbors - each node is a stacking context, so an overflowing card would
// otherwise paint under the next frame
const hostsCard = useComments((s) =>
(!!s.active && s.threads.some((t) =>
t.id === s.active && !t.resolved && threadHostKey(t, useStore.getState().nodes) === node.key)) || s.draft?.nodeKey === node.key)
useEffect(() => {
if (node.status === 'ready' || !laser)
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser, inspect: laserInspect }, location.origin)
}, [laser, laserInspect, node.status])
// comment mode = pick mode in the frame (late loaders join like laser does); quiet when
// laser comment (⇧L) is off - clicks still anchor, no lighting in the artwork
const showAnchor = useComments((s) => s.showAnchor)
useEffect(() => {
if (node.status === 'ready' || !commentMode)
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode, quiet: !showAnchor }, location.origin)
}, [commentMode, showAnchor, node.status])
// B0.2: the interact target owns its own wheel (app scrolls); passive frames forward
// wheel to the canvas. Replayed on ready like laser/pick so a reload restores truth.
useEffect(() => {
if (node.status === 'ready' || !interact)
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:interactive', on: interact }, location.origin)
}, [interact, node.status])
// SLEEP lifecycle. Interact = awake and DIRTY (the app's state is now its own); a resize drag =
// awake for the whole drag; any other change of the key (theme once applied, size, source revision,
// navigation) wakes first - the old override describes another state - and sleeps again once the
// new one has settled. Laser and comment mode need nothing: the sleeping document IS the live one.
const dirty = useRef(false)
const lastDoc = useRef(null) // a new document (reload, self-reload) is pristine again
const resizing = useRef(false)
const [resizeTick, setResizeTick] = useState(0)
useEffect(() => { dirty.current = false }, [node.nav]) // a fresh document is pristine again
// a slide's document is its stage, whatever the node's size (the node only scales it): the
// sleep key - the size the compiler renders to certify textures - is the document's size
const stage = frame ? slideSize(frame, CONFIG.viewports) : null
const w = Math.round(stage?.width ?? node.w), h = Math.round(stage?.height ?? node.h)
// a layout effect: the wake lands BEFORE the first paint of the new state (a stretched texture
// must never be painted at a new size)
useLayoutEffect(() => {
const iframe = iframeRef.current
if (!iframe || !frame || node.missing) { wake(node.key, null); return } // nothing to keep (a deleted frame's card must not retain its old document)
const doc = iframe.contentDocument
if (doc && doc !== lastDoc.current) { lastDoc.current = doc; dirty.current = false }
if (interact) dirty.current = true
wake(node.key, iframe)
// a stale document - or one behind the frame's last edit - waits awake for its reload (spec 26):
// textures are baked from the server's newest source, which it does not show; waking also cancels
// one being installed
if (interact || dirty.current || resizing.current || node.status !== 'ready' || node.stale || behind) return
if (node.themeOn !== undefined && node.themeOn !== node.theme) return // the frame has not painted the new theme yet
// counted while it waits (sleep.ts bakeWork): a headless perf run measures only once no sleep is still to come
return scheduleSleep(() => { void sleep(node.key, iframe, { frame: frame.id, theme: node.theme, w, h, doc: node.shown }) }, 250)
}, [interact, node.status, node.theme, node.themeOn, node.rev, node.nav, node.stale, behind, w, h, node.missing, frame?.id, node.key, resizeTick])
useEffect(() => () => wake(node.key, iframeRef.current), [node.key])
// image-LOD: once the frame is ready its content listener is live, so send the settled zoom - a static
// board that never gets a gesture then still sharpens its images from the cheap low-res first paint.
useEffect(() => {
if (node.status === 'ready') primeCameraFor(iframeRef.current?.contentWindow)
}, [node.status])
// a frame whose FILE actually changed (e.g. tsx -> html swap, same id) must renavigate
useEffect(() => {
if (!frame) return
const sig = `${frame.kind}:${frame.file}`
if (fileRef.current !== null && fileRef.current !== sig && iframeRef.current) {
setStatus(node.key, 'loading')
navigate(frameUrl(frame, node.theme))
}
fileRef.current = sig
}, [frame?.kind, frame?.file])
// shell-requested renavigation (store bumps node.nav): reload on a FRESH rev-stamped
// URL - the errored document's own URL may be poisoned by cache (friction log #20)
const navRef = useRef(node.nav ?? 0)
useEffect(() => {
if ((node.nav ?? 0) === navRef.current) return
navRef.current = node.nav ?? 0
if (frame) navigate(frameUrl(frame, node.theme))
}, [node.nav])
// Reload the frame, assigning the fresh URL SYNCHRONOUSLY so the live iframe's src is current the
// instant a stale sh:ready (queued by the document we just superseded) could be processed - the
// App generation guard then drops it against the new src. reloadFrame bumps nav+manifestRev; we
// pre-apply the src here and advance navRef so the passive nav effect does not navigate a 2nd time.
const reload = (automatic: boolean) => {
const before = useStore.getState().nodes.find((x) => x.key === node.key)
if (!before || (automatic && !canAutoReload(before))) return
reloadFrame(node.key, automatic)
const after = useStore.getState().nodes.find((x) => x.key === node.key)
const f = useStore.getState().frameFor(node)
if (after && f) {
navigate(frameUrl(f, node.theme))
navRef.current = after.nav ?? 0
}
}
// latest-callback ref so the watchdog effect can call the current reload() without taking it as a
// dep (an unstable function there would reset the timer every render and starve the retry)
const reloadRef = useRef(reload)
reloadRef.current = reload
// ready watchdog: a slow dev server (Vite re-optimizing deps, or the box saturated by parallel
// Live Jam agents) can leave boot()'s module fetches unresolved past the deadline - that is a slow
// frame, NOT a failed one (a real failure posts sh:error immediately). So the first silent deadline
// auto-renavigates ONCE on a fresh rev; a second silence stays 'loading' (never a red error card).
// node.nav is a dep so a fresh navigation restarts the full budget; readyRetried flips true on the
// retry and bounds it to exactly one.
// The budget counts from ADMISSION (a queued frame is not silent). The retried document keeps its
// boot slot - its boot is still running - and gives it back after a second silence.
useEffect(() => {
if (!admitted || node.status !== 'loading' || !frame || node.missing) return
// the second silence gives the slot back, and is the recorder's never-ready (spec 28 A1.7) - the frame stays
// loading, never a red card
const t = setTimeout(() => { if (shouldArmReadyWatch(node, true)) reloadRef.current(true); else { release(node.key); recorder.neverReady(node.key) } }, 10_000)
return () => clearTimeout(t)
// depend on the frame's stable SIGNATURE, not the manifest object - that object is replaced on
// every manifest reconcile, so depending on it would reset the budget on unrelated frames during
// heavy Live Jam churn and starve the retry. kind/file still re-arm on a real file swap.
}, [admitted, node.status, node.readyRetried, node.nav, node.key, node.missing, frame?.kind, frame?.file])
const drag = (e: React.PointerEvent, mode: 'move' | 'e' | 's' | 'se') => {
e.stopPropagation()
if (e.button !== 0) return
// shift-click toggles multi-selection instead of dragging (Figma convention)
if (e.shiftKey && mode === 'move') { select(node.key, true); return }
if (!(selected && useStore.getState().selection.length > 1)) select(node.key)
else useStore.getState().look([node.key]) // a click inside a group selection still looks at this one
const el = e.currentTarget as HTMLElement
const world = document.getElementById('sh-world')!
// Law G-5: measured scale, never stored zoom state. #sh-world is 1px wide by design,
// so its rendered rect width IS the scale (survives browser page-zoom too).
const gestureScale = world.getBoundingClientRect().width || useStore.getState().scale || 1
const start = { x: e.clientX, y: e.clientY, nx: node.x, ny: node.y, nw: node.w, nh: node.h }
// group drag: moving any member moves the whole selection by the same delta
const st = useStore.getState()
const groupStarts: Record = {}
if (mode === 'move' && st.selection.includes(node.key)) {
for (const k of st.selection) {
const n = st.nodes.find((x) => x.key === k)
if (n) groupStarts[k] = { x: n.x, y: n.y }
}
}
// Defer sh-gesturing (and its `will-change: transform` on .sh-content) until an ACTUAL drag
// begins - a bare click otherwise promotes then demotes the compositor layer, re-rasterising
// the frame's text at a fractional zoom = the "jiggle". A pure click now never toggles it.
let gesturing = false
const begin = () => {
if (gesturing) return
gesturing = true
world.classList.add('sh-gesturing') // drops iframe pointer-events
setGesture(true)
if (mode !== 'move') { resizing.current = true; wake(node.key, iframeRef.current); setResizeTick((t) => t + 1) } // awake before the first resized paint, for the whole resize
}
const MOVE_THRESHOLD = 3 // px in screen space before a press counts as a drag
const onMove = (ev: PointerEvent) => {
if (!gesturing && Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < MOVE_THRESHOLD) return
begin()
const dx = (ev.clientX - start.x) / gestureScale
const dy = (ev.clientY - start.y) / gestureScale
if (mode === 'move') {
if (Object.keys(groupStarts).length > 1) moveSelectedBy(dx, dy, groupStarts)
else moveNode(node.key, start.nx + dx, start.ny + dy)
}
else {
let w = mode !== 's' ? start.nw + dx : start.nw
const h = mode !== 'e' ? start.nh + dy : start.nh
for (const vp of Object.values(CONFIG.viewports)) if (Math.abs(w - vp.width) < SNAP) w = vp.width
resizeNode(node.key, w, h)
}
}
// One idempotent teardown for every exit path - stuck gestures are the acceptance test.
let finished = false
const done = () => {
if (finished) return
finished = true
try { el.releasePointerCapture(e.pointerId) } catch { /* already released */ }
world.classList.remove('sh-gesturing')
setGesture(false)
if (resizing.current) { resizing.current = false; setResizeTick((t) => t + 1) } // settle, then sleep again
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', done)
el.removeEventListener('pointercancel', done)
el.removeEventListener('lostpointercapture', done)
window.removeEventListener('blur', done)
}
try { el.setPointerCapture(e.pointerId) } catch { setGesture(false); world.classList.remove('sh-gesturing'); return }
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', done)
el.addEventListener('pointercancel', done)
el.addEventListener('lostpointercapture', done)
window.addEventListener('blur', done)
}
const gone = !frame || node.missing
// Spec §7: a deleted frame's node stays, with a card, until the user removes it. Explicit beats magic.
if (gone) {
return (
drag(e, 'move')}>
{node.frame}deleted
file deleted{node.frame}
)
}
// variant badge: letter + name floating LEFT of the frame, outside the
// artwork, world-anchored (scales with zoom) with a screen-space minimum via --sh-inv
const variantName = frame.title
?? cap((frame.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' '))
return (
(n.kind === 'scene' ? 380 : 260)))}px` } : {}),
// phase every working animation by when THIS frame's job started - parallel
// frames pulsing in sync would read as one fake choreography
...(working ? { ['--mv-w0' as string]: `${-(Date.now() - (workingSince ?? Date.now()))}ms` } : {}) }}
data-node={node.key}
>
{working && }
{frame.variantGroup && (
drag(e, 'move')} title={frame.file}>
{/* the chrome badge: slide first, else the content intent glyph */}
{frame.slide
?
: frame.intent && }
{frame.title ?? frame.id}{Math.round(node.w)} · {node.theme}
{/* the sidebar's marks on the frame itself (spec 25): no outline, no glow - the glow is an agent working */}
{node.status === 'error' ? (
frame failed{node.error}{frame.file}
) : null}
{/* a frame still silent after its one auto-retry: a slow dev server, never a failure. A quiet
non-covering pill (not the red card) keeps it honest and offers a manual reload. */}
{node.status === 'loading' && node.readyRetried && !away ? (