)
}
/** A shell bug shows a banner, never a white screen. */
export class ShellBoundary extends Component<{ children: ReactNode }, { err: Error | null }> {
state = { err: null as Error | null }
static getDerivedStateFromError(err: Error) { return { err } }
// the recorder's (spec 28 A1.7): the error, and the component it came from
componentDidCatch(err: Error, info: { componentStack?: string | null }) {
recorder.error({ source: 'shell', message: err.message, stack: err.stack, component: /\bat (\w+)/.exec(info.componentStack ?? '')?.[1] })
}
render() {
if (!this.state.err) return this.props.children
return (
shell crashed
{this.state.err.message}
)
}
}
/** Selection toolbar: screen-space overlay above the selected frame - constant size at any
* zoom. Position derives from --sh-s/tx/ty (written per transform frame in Canvas), so
* pan/zoom tracking is pure CSS with zero React re-renders. */
function SelectionBar() {
const selection = useStore((s) => s.selection)
const node = useStore((s) => s.nodes.find((n) => n.key === s.selection[s.selection.length - 1]))
const frame = useStore((s) => (node ? s.frameFor(node) : undefined))
const nodes = useStore((s) => s.nodes)
// measured width feeds the viewport clamp below; a callback ref because the bar
// mounts/unmounts with the selection (an effect with [] would miss remounts)
const [barW, setBarW] = useState(0)
const roRef = useRef(null)
const barRef = (el: HTMLDivElement | null) => {
roRef.current?.disconnect()
roRef.current = null
if (el) {
roRef.current = new ResizeObserver(() => setBarW(el.offsetWidth))
roRef.current.observe(el)
}
}
// the copy-path icon flashes into a check on a successful copy - from the icon OR the
// Shift+P shortcut. pathPulse (store) bumps on each copy; these hooks sit ABOVE the
// early return so their order never changes with the selection.
const pathPulse = useStore((s) => s.pathPulse)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!pathPulse) return
setCopied(true)
const t = setTimeout(() => setCopied(false), 1400)
return () => clearTimeout(t)
}, [pathPulse])
// copy-as-image: same flash contract (imagePulse bumps only on a real clipboard write),
// plus a busy state for the 1-4s headless render - the icon dims and the button locks
const imagePulse = useStore((s) => s.imagePulse)
const imageBusy = useStore((s) => s.imageBusy)
const [copiedImage, setCopiedImage] = useState(false)
useEffect(() => {
if (!imagePulse) return
setCopiedImage(true)
const t = setTimeout(() => setCopiedImage(false), 1400)
return () => clearTimeout(t)
}, [imagePulse])
if (!node || !frame || node.missing) return null
// anchor: centered over the bounding box of ALL selected frames, above the topmost
const selNodes = nodes.filter((n) => selection.includes(n.key))
const bx0 = Math.min(...selNodes.map((n) => n.x))
const bx1 = Math.max(...selNodes.map((n) => n.x + n.w))
const by0 = Math.min(...selNodes.map((n) => n.y))
const { resizeSelected, toast } = useStore.getState()
const multi = selection.length > 1
const applyDevice = (name: string) => {
animateLayout()
resizeSelected(name)
setTimeout(() => canvasCtl.fitNodes(useStore.getState().selection), 30)
}
const setNodeTheme = (t: string) => useStore.getState().setSelectedTheme(t)
const selectedFrames = () => {
const st = useStore.getState()
return st.selection
.map((k) => { const n = st.nodes.find((x) => x.key === k); return n ? st.frameFor(n) : undefined })
.filter((f): f is NonNullable => !!f)
}
// centered over the selection's bounding box, then CLAMPED into the viewport: the
// controls for a selected frame must stay reachable when its top edge is panned
// off-screen, and must never drift off the sides (friction log #23)
const centerX = `calc(var(--sh-tx, 0px) + var(--sh-s, 1) * ${(bx0 + bx1) / 2}px)`
// a grouped frame carries a caption above it - clear it EXACTLY, in screen terms:
// frame top - the caption offset (8px screen, world-capped) - the caption's height
// (screen-clamped 12..18px font) - the bar. Gate on the TOP edge of the selection,
// not the last-selected frame: a mixed selection whose topmost frames are variants
// still has a caption to clear.
const capAtTop = selNodes.some((n) => n.y === by0 && useStore.getState().frameFor(n)?.variantGroup)
const rawTop = capAtTop
? `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - clamp(4px * var(--sh-s, 1), 8px, 40px * var(--sh-s, 1)) - (clamp(12px, 17px * var(--sh-s, 1), 18px) * 1.4) - 44px)`
: `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - 52px)`
return (
{multi && <>
{selection.length}
>}
{Object.entries(CONFIG.viewports).map(([name, vp], vi) => {
const active = node.w === vp.width
return (
{cap(name)}{vp.width} × {vp.height}{vi + 1}>}>
)
})}
{CONFIG.themes.map((t) => (
))}
{multi ? `Copy ${selection.length} paths` : 'Copy path'}⇧P>}>
{/* copy as image: dev only (the renderer is the dev server's headless Chrome) and one
frame at a time (a clipboard holds one image). I = 2x, Shift+I = 4x. */}
{!PUBLISHED && (
Copy as image⇧i>}>
)}
)
}
/** Devices view: one click sizes frames to a device width, tidies, and fits. Scoped
* like the digit keys: the selection when one exists, the whole board otherwise. The
* dropdown chrome is the shared DevicePicker; this adapter owns the canvas wiring. */
function DeviceMenu() {
const deviceView = useStore((s) => s.deviceView)
const selection = useStore((s) => s.selection)
const nodes = useStore((s) => s.nodes)
const scoped = selection.length > 0
const pick = (name: string | null) => {
animateLayout()
const st = useStore.getState()
if (scoped) {
st.resizeSelected(name)
setTimeout(() => canvasCtl.fitNodes(useStore.getState().selection), 30)
} else {
st.setDeviceView(name)
setTimeout(() => canvasCtl.fitAll(), 30)
}
}
const entries = Object.entries(CONFIG.viewports)
// active check: board-wide it is the device view; scoped, the device every selected frame wears
const selNodes = scoped ? nodes.filter((n) => selection.includes(n.key)) : []
const active = scoped
? entries.find(([, vp]) => selNodes.length > 0 && selNodes.every((n) => n.w === vp.width))?.[0] ?? null
: deviceView
const hint = scoped ? `${selection.length} selected` : deviceView ? cap(deviceView) : `keys 1-${entries.length}`
return
}
/** Update pill (dev only): the daily registry check surfaces here - same glass, same
* pill, bottom-center. Click the command to copy it; × dismisses THIS version for
* good (localStorage), so the pill returns only when the next release lands. */
function UpdatePill() {
const [latest, setLatest] = useState(null)
const play = useStore((s) => s.play)
useEffect(() => {
if (PUBLISHED) return // a shared canvas never nags its viewers
api('update')
.then((r) => (r.ok ? r.json() : null))
.then((u) => {
if (u?.latest && localStorage.getItem(scoped('mv-update-seen')) !== u.latest) setLatest(u.latest)
})
.catch(() => { /* dev server gone or endpoint absent - stay quiet */ })
}, [])
if (!latest || play) return null
// init rides along so managed files (AGENTS.md, instructions/) refresh with the code
const cmd = `npm i -D ${PKG}@latest && npx marver init`
const dismiss = () => {
try { localStorage.setItem(scoped('mv-update-seen'), latest) } catch { /* storage unavailable */ }
setLatest(null)
}
return (
{latest} is out
)
}
const ZOOMS = [2, 1.5, 1, 0.5, 0.25, 0.1]
/** Zoom preset dropdown on the percentage readout. */
function ZoomMenu() {
const scale = useStore((s) => s.scale)
const pop = usePopover()
const go = (fn: () => void) => { fn(); pop.setOpen(false) }
return (
{ZOOMS.map((z) => (
))}
)
}
/** Theme dropdown, scoped like the device digits: the selection when one exists, every
* frame otherwise. The trigger reflects the scope's MAJORITY (per-frame overrides never
* flip a board-level trigger - it reports the level it acts on, same rule as the shell).
* The dropdown chrome is the shared ThemePicker; this adapter owns the canvas wiring. */
function ThemeMenu() {
const nodes = useStore((s) => s.nodes)
const selection = useStore((s) => s.selection)
const viewTheme = useStore((s) => s.viewTheme)
const scoped = selection.length > 0
const scope = scoped ? nodes.filter((n) => selection.includes(n.key)) : []
const majority = scoped
? (scope.length ? [...scope.reduce((m, n) => m.set(n.theme, (m.get(n.theme) ?? 0) + 1), new Map()).entries()].sort((a, b) => b[1] - a[1])[0][0] : viewTheme)
: viewTheme
// the tick shows only a UNIFORM scope (mixed selection = no theme is "the" one), while
// the trigger reports the majority - the level the control acts on
const uniform = scoped ? (scope.length && scope.every((n) => n.theme === scope[0].theme) ? scope[0].theme : null) : viewTheme
const pick = (t: string) => {
const st = useStore.getState()
scoped ? st.setSelectedTheme(t) : st.setTheme(t)
}
const hint = scoped ? `${selection.length} selected · D` : 'all frames · D'
return
}
export function App() {
// B0.1: per-field selectors, NOT `useStore()` (which subscribes to the whole store and
// re-rendered the entire shell on every setScale tick during a pan/zoom).
useSeenOnScreen()
const manifest = useStore((s) => s.manifest)
const stopped = useLink((s) => s.shown) // the canvas stopped (spec 28 A1.11): nothing leaves this page
const nodes = useStore((s) => s.nodes)
const panelOpen = useStore((s) => s.panelOpen)
const laneLift = useLaneLift()
// the collapsed toolbar keeps the dot - while a capture records, and for a whole Jam session
const capturing = useJamCapture((st) => !!st.current)
const inSession = useJamQueue((st) => !!st.session)
const recording = capturing || inSession
const toasts = useStore((s) => s.toasts)
const selection = useStore((s) => s.selection)
const laser = useStore((s) => s.laser)
const commentMode = useComments((s) => s.commentMode)
// Laser/comment paint live outlines + do element-picking INSIDE the live frame; a scriptless
// lean cover would hide them, so a body class suppresses the cover while either mode is active.
useEffect(() => { document.body.classList.toggle('sh-laser', laser) }, [laser])
useEffect(() => { document.body.classList.toggle('sh-commenting', commentMode) }, [commentMode])
const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
const [pillOpen, setPillOpen] = useState(true)
const cm = useContextMenu() // shared sidebar right-click menu (boards + scenes)
const [sceneNaming, setSceneNaming] = useState(null) // the scene whose title is being edited inline
// boot honors the deep link: board before load, play mode after it.
// Selection + camera intent are restored by the Canvas boot effect. The module-level
// guard makes boot single-shot: Fast Refresh re-runs mount effects on every App edit,
// and a re-boot would revert live state to the long-consumed deep link.
useEffect(() => {
if (booted) return
booted = true
const start = async () => {
if (!PUBLISHED) {
// dev: hydrate board policy (slides/type/open) from the repo so P and
// deep links preview exactly what viewers get
try {
const r = await api('policy')
if (r.ok) hydrateBoardPolicy(((await r.json()) as { boards?: Record }).boards ?? {})
} catch { /* dev server without the route - fine */ }
}
// a link from before 0.25 names the All Scenes board: it lands on a real one (legacy-links.ts)
let notice: string | null = null
if (isLegacyLink(bootHash)) {
const r = await landLegacy(bootHash)
retargetBoot(r.hash)
writeHash(r.hash)
notice = r.notice
}
if (bootHash.board && !(PUBLISHED && bootHash.board === UNPLACED)) { // the Not on a board view is dev's
if (bootHash.board !== useStore.getState().board) // a deep link wins
useStore.setState({ board: bootHash.board, boardAuto: false })
} else if (!PUBLISHED) {
// fresh open, no deep link: LAND on the first board - with none yet, the Not on a board view
const first = await landingBoard()
if (first !== useStore.getState().board) useStore.setState({ board: first, boardAuto: false })
}
const ok = await boot()
if (notice) toast(notice)
// the page before this one reloaded itself when its canvas came back (A1.11): what its save came to - saved, or
// the board's file won on disk, or the board was gone
if (ok && resumed?.outcome === 'saved') toast('Back - your edits were saved')
else if (resumed?.outcome === 'disk') toast(DISK_WON)
else if (resumed?.outcome === 'gone') toast(BOARD_GONE)
urlReady.current = true
// a link naming frames on a board with NO nodes: the canvas's boot fit never runs (it waits
// for the first frame) - follow the frame to where it lives from here
if (ok && bootHash.f?.length && !bootHash.focus && !useStore.getState().nodes.length) void revealFrame(bootHash.f[0])
if (ok && bootHash.focus) {
// a frame deep link forces focus for that visit, over every other rule
// (01-sharing §6.1 step 3) - the stage mounts any manifest frame, so no
// board lookup is needed
enterFocus(bootHash.focus.at, { ...bootHash.focus, deep: true })
} else if (ok && bootHash.play?.slides) enterSlides(bootHash.play)
else if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/ alone = board start
else if (ok && PUBLISHED) {
// published landing: explicit `open` beats the type default; canvas and
// board land on the canvas surface (board-scoped already); a lock will
// then hold the mode by hiding every door (04-solution §2.35)
const mode = landingMode(useStore.getState().board)
if (mode === 'present') enterPlay()
else if (mode === 'focus') enterFocus()
else if (mode === 'slides') enterSlides()
}
}
void start()
}, [])
// the URL is a projection of state: design views replace in place; entering play and
// every in-play navigation push, so browser back walks the flow
const playState = useStore((s) => s.play)
const prevPlay = useRef(playState)
const urlReady = useRef(false) // never clobber a deep link before boot restores it
useEffect(() => {
if (!urlReady.current) return
const s = useStore.getState()
if (playState?.focus && playState.deep) {
// a deep-link focus visit keeps its frame URL - the shareable form
writeHash({ focus: { at: playState.at, device: playState.device, theme: playState.theme } })
} else if (playState && !playState.focus) {
const push = !prevPlay.current || prevPlay.current.at !== playState.at
writeHash({ board: s.board, play: playState }, push)
} else {
// type-default focus keeps the board URL the visitor arrived on
writeHash({ board: s.board, n: playState ? [] : s.selection })
}
prevPlay.current = playState
})
// browser back/forward: re-apply the URL as a whole - board first, then play or
// selection against the newly loaded state. writeHash skips identical hashes, so
// restores never echo back into history.
useEffect(() => {
// the switch a navigation started, while it may still run: back/forward, a pasted link and `location.hash =` each
// fire popstate AND hashchange, and a second switch to the same board would supersede the first - a second board
// fetch, and a load that never committed in perf --loads
let routing: { hash: string; switches: number } | null = null
const onPop = async () => {
// the same hash, and no switch or cancel since this router started its own (a board picked, a goto, a newer
// navigation): the second event of the navigation the first is carrying through - board, frame and view
if (routing && routing.hash === location.hash && routing.switches === switchCount()) return
const raw = location.hash
const nav = navigated() // a reveal still looking up its board must not land after this
// a lookup below still running when newer navigation lands - a link, a board picked, a step in play - is dropped
const at = useStore.getState().board, step = useStore.getState().play?.at
const overtaken = () => !isLatest(nav) || useStore.getState().board !== at || useStore.getState().play?.at !== step
let h = parseHash()
if (PUBLISHED && h.board === UNPLACED) h = {} // the Not on a board view is dev's: published, the default board
const legacy = isLegacyLink(h)
if (legacy) {
// an old All Scenes link pasted, or walked back to: where it lands now, written over it -
// once, though back/forward fires two events
const r = await landLegacy(h)
const board = r.hash.board ?? await landingBoard()
if (overtaken()) return
h = { ...r.hash, board }
writeHash(h)
if (r.notice) toast(r.notice)
} else if (!h.board && !h.focus && !h.invite) {
// the bare #/ opens the landing board
const board = await landingBoard()
if (overtaken()) return
h = { ...h, board }
}
let s = useStore.getState()
if (h.board && h.board !== s.board) {
if (s.play) s.setPlay(null) // a stale overlay must never survive into another board
const switched = switchFor(h.board, nav)
const mine = routing = { hash: raw, switches: switchCount() }
const landed = await switched
if (routing === mine) routing = null
// the switch failed (the projection effect re-syncs the URL), or a newer navigation came while this one's board
// was read - a link, a goto, a board picked - and may have landed on this same board first: the board is the
// newer one's then, and so are the selection, the mode and the camera
if (!landed) return
s = useStore.getState()
}
// the thread an old link names (Comments.tsx opens every other link's): now, or once the new board's comments load
if (legacy && h.c) { if (s.board !== at) parkThread(h.c); else revealThread(h.c) }
if (h.focus) {
enterFocus(h.focus.at, { ...h.focus, deep: true })
} else if (h.play) {
// sync only holds WITHIN a mode: a slides hash over a present stage (or
// the reverse) re-enters, which remounts the stage with the right src
if (s.play && !!h.play.slides === !!s.play.slides) playCtl.sync(h.play)
else if (h.play.slides) enterSlides(h.play)
else enterPlay(h.play)
} else {
if (s.play) s.setPlay(null)
const keys = linkTargets(h, s.nodes)
useStore.setState({ selection: keys })
s.look(keys) // a link opened is a look
if (!keys.length && h.f?.length) void revealFrame(h.f[0]) // not on this board - where it lives
else setTimeout(() => { if (isLatest(nav)) (keys.length ? canvasCtl.fitNodes(keys) : canvasCtl.fitAll()) }, 60) // unless a newer navigation has the camera
}
}
// hashchange too: pasting a link into the same tab or editing the URL bar changes
// the hash WITHOUT a popstate - ignoring it let the projection rewrite the URL back.
// Our own writeHash never fires either event (history API), so there is no echo;
// back/forward fires both: the second finds the first's switch on its way and leaves it
// be (above) - without a switch, the handler is idempotent under the double call.
window.addEventListener('popstate', onPop)
window.addEventListener('hashchange', onPop)
return () => { window.removeEventListener('popstate', onPop); window.removeEventListener('hashchange', onPop) }
}, [])
// page title follows the open board (and its title, when one is set)
const board = useStore((s) => s.board)
const titles = useStore((s) => s.boardTitles)
// an empty Not on a board view (a canvas with no board and no frame yet) is no place to name in the tab
const emptyView = useStore((s) => s.board === UNPLACED && !s.nodes.length)
useEffect(() => { document.title = tabTitle(emptyView ? null : board) }, [board, titles, emptyView])
// favicon follows the mode: blue pack in design mode, purple pack in interact.
// The links are rebuilt (not toggled) so the set stays deterministic; the .ico is
// design-blue only, so interact mode omits it and the browser takes the purple PNGs.
const interactingIcon = useStore((s) => s.interact !== null)
useEffect(() => {
const sfx = interactingIcon ? '-interactive' : ''
document.head.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach((l) => l.remove())
const add = (rel: string, href: string, attrs: Record = {}) => {
const l = document.createElement('link')
l.rel = rel
l.href = href
for (const [k, v] of Object.entries(attrs)) l.setAttribute(k, v)
document.head.appendChild(l)
}
if (!interactingIcon) add('icon', `${ROUTE}/favicon/favicon.ico`, { sizes: '48x48' })
add('icon', `${ROUTE}/favicon/favicon-32x32${sfx}.png`, { type: 'image/png', sizes: '32x32' })
add('icon', `${ROUTE}/favicon/favicon-16x16${sfx}.png`, { type: 'image/png', sizes: '16x16' })
add('apple-touch-icon', `${ROUTE}/favicon/apple-touch-icon${sfx}.png`)
}, [interactingIcon])
// focus rings appear ONLY during real Tab navigation: the browser flips into keyboard
// modality on ANY keystroke, so a shortcut press painted the stock double ring on
// whatever was last clicked. Tab arms body.sh-kbd; any pointer use disarms it.
useEffect(() => {
const arm = (e: KeyboardEvent) => { if (e.key === 'Tab') document.body.classList.add('sh-kbd') }
const disarm = () => document.body.classList.remove('sh-kbd')
window.addEventListener('keydown', arm, true)
window.addEventListener('pointerdown', disarm, true)
return () => { window.removeEventListener('keydown', arm, true); window.removeEventListener('pointerdown', disarm, true) }
}, [])
// browser pinch-zoom is disabled inside the app: a pinch over the chrome (or anywhere
// off-canvas) was scaling the PAGE and wrecking the layout. Canvas zoom is unaffected
// (rzpp handles its own events), and keyboard cmd +/- is never intercepted.
useEffect(() => {
const block = (e: WheelEvent) => { if (e.ctrlKey || e.metaKey) e.preventDefault() }
const blockGesture = (e: Event) => e.preventDefault()
window.addEventListener('wheel', block, { passive: false })
document.addEventListener('gesturestart', blockGesture)
return () => {
window.removeEventListener('wheel', block)
document.removeEventListener('gesturestart', blockGesture)
}
}, [])
// live updates from the dev server (source-served shell has import.meta.hot)
useEffect(() => {
if (!import.meta.hot) return
import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
// a brief's title or description changed with the frames intact: relabel, never re-key
import.meta.hot.on('sh:scenes', (m: any) => useStore.getState().setScenes(m?.scenes))
// Live Jam presence: the daemon broadcasts the set of frames Marver is editing.
// Camera-safe by construction - this only toggles a glow class, never moves the view.
import.meta.hot.on('sh:jam-activity', (m: any) => { useStore.getState().setWorkingFrom(m); void useStore.getState().syncMarks() })
// then ask for what is lit now - a broadcast sent before this listener existed is gone - and again
// after the dev server reconnects
void useStore.getState().syncWorking()
import.meta.hot.on('vite:ws:connect', () => void useStore.getState().syncWorking())
// sidebar marks (spec 25): a frame changed on disk, or an agent's work started or ended
void useStore.getState().syncMarks()
logReload({ kind: 'shell-boot' }) // the reload log: every time the canvas page itself loads
import.meta.hot.on('vite:ws:connect', () => void useStore.getState().syncMarks())
import.meta.hot.on('sh:marks', () => void useStore.getState().syncMarks())
import.meta.hot.on('sh:boards', () => void useStore.getState().syncMarks()) // a board's frames changed: its roll-up too
// Live Jam reply delivery: the daemon just wrote to a board's log - fetch it NOW instead of
// waiting out the 30s comment poll, so the reply + notification land within a second.
import.meta.hot.on('sh:jam-comment', (m: any) => { if (typeof m?.board === 'string') useComments.getState().poke(m.board) })
// the jam queue (spec 27): the status icon by each tag's time, and a failure's one notice
import.meta.hot.on('sh:jam-queue', (m: any) => { void import('./jam-queue.ts').then(({ useJamQueue }) => useJamQueue.getState().apply(m)) })
void import('./jam-queue.ts').then(({ useJamQueue }) => useJamQueue.getState().load())
// captures sealed in an earlier page that never reached the server
void import('./jam-capture.ts').then(({ resendSealed }) => resendSealed())
// A7 controlled HMR: a frame file changed. Reload exactly the affected frames through the
// lease-aware path (idle frames now, leased ones deferred to a safe point) - never a shell
// reload, never a React Fast Refresh yanking a frame the user is in.
import.meta.hot.on('sh:frame-invalidated', (m: any) => {
const frameIds = Array.isArray(m?.frameIds) ? m.frameIds.filter((x: unknown) => typeof x === 'string') : []
if (frameIds.length && typeof m?.revision === 'string') useStore.getState().invalidateFrames(frameIds, m.revision, { epoch: m?.epoch, docs: m?.docs })
})
// multi-viewer sync: another viewer (or an agent) saved this board. A clean canvas
// re-boots silently, keeping whatever selection survives; a dirty one keeps its
// edits and converges through the 409 path on its next save (disk wins).
import.meta.hot.on('sh:board', (m: any) => {
const s = useStore.getState()
if (m?.name !== s.board || m?.sha256 === s.boardHash || s.dirty) return
const sel = s.selection
s.boot().then((ok) => {
if (!ok) return
const nodes = useStore.getState().nodes
useStore.setState({ selection: sel.filter((k) => nodes.some((n) => n.key === k)) })
})
})
}, [])
// frame -> shell messages; source validated against known iframes
useEffect(() => {
const onMsg = (e: MessageEvent) => {
const data = e.data
if (!data || typeof data.type !== 'string' || !data.type.startsWith('sh:')) return
if (e.origin && e.origin !== location.origin) return // a cross-origin-navigated frame keeps its WindowProxy; reject it
// B0.3: O(1) registry lookup - also the security gate (unknown source = not a
// registered frame = dropped), replacing a per-message iframe scan + DOM walk.
const reg = frameByWindow(e.source)
if (!reg) return
const { key: nodeKey, iframe: el } = reg
const s = useStore.getState()
// Jam (spec 27): a capture's trail, handshake, probes and flushes; a key the shell owns
if (onFrameMessage(nodeKey, data)) return
if (data.type === 'sh:key') { if (typeof data.key === 'string') jamKey({ key: data.key, shift: !!data.shift, alt: !!data.alt, meta: !!data.meta, ctrl: !!data.ctrl }); return }
if (data.type === 'sh:ready') {
// a WindowProxy survives navigation, so a ready queued by a SUPERSEDED document (one the
// ready-watchdog auto-renavigated past, or a same-URL reload's predecessor) can arrive here;
// dropped by generation and document before it touches anything, so a stale ready never marks
// a reloading frame ready, frees its admission slot or counts as seen
if (!fromLiveDocument(el, data)) return
// a document that loaded again on its own (a full reload) is a new source revision: the
// sleeping override of the previous document must not be trusted for this one
if (s.nodes.find((n) => n.key === nodeKey)?.status === 'ready') s.bumpRev(nodeKey)
s.setStatus(nodeKey, 'ready')
recorder.ready(nodeKey, data)
frameReady(nodeKey)
// the revision this document was served with (spec 26): a look counts up to it, and one older
// than the frame's last edit reloads
const before = s.nodes.find((n) => n.key === nodeKey)?.shown
if (typeof data.doc === 'string') s.setShown(nodeKey, data.doc)
logReload({ kind: 'ready', cause: takeReloadCause(nodeKey) ?? (before === undefined ? 'first' : 'self'), node: nodeKey, frame: String(data.id ?? ''), shown: typeof data.doc === 'string' ? data.doc : undefined, ms: Number(data.phases?.commit) })
} else if (data.type === 'sh:error') {
// the same guard as sh:ready: an error from a document since replaced fails nothing and is not recorded
if (!fromLiveDocument(el, data)) return
s.setStatus(nodeKey, 'error', String(data.message ?? 'unknown error'))
recorder.frameError(nodeKey, data)
} else if (data.type === 'sh:boot') {
recorder.booted(nodeKey, data) // a new document: its node's outcome starts over (spec 28 A1.7)
} else if (data.type === 'sh:exit-interact') {
if (s.interact === nodeKey) setInteract(null)
} else if (data.type === 'sh:theme-applied') {
if (typeof data.theme === 'string') s.setThemeOn(nodeKey, data.theme)
} else if (data.type === 'sh:hmr') {
s.bumpRev(nodeKey)
} else if (data.type === 'sh:measure') {
// Generation and document guard: the sender echoes ITS document's URL rev and id; a
// WindowProxy survives navigation, so a stale pre-navigation message would
// otherwise route as if it came from the current document. Compared against
// the iframe's CURRENT src and document - a stale one moves and keeps nothing.
if (!fromLiveDocument(el, data)) return
// measureNode does the rest of the admission (content frames only,
// frame-id match, finite positive, clamped)
const took = s.measureNode(nodeKey, String(data.frame ?? ''), Number(data.ownWidth), Number(data.measuredWidth), Number(data.height),
typeof data.settled === 'boolean' ? data.settled : undefined)
recorder.measured(nodeKey, data, took)
} else if (data.type === 'sh:laser-copy') {
// laser click = copy the element's full address for the agent: WHERE it lives on
// the canvas ([board ▸ scene]) + frame source file + css path (+ jsx source loc)
const n = s.nodes.find((x) => x.key === nodeKey)
const f = n && s.frameFor(n)
// drop a stale post that outran a frame swap (the sender echoes its frame id)
if (f && String(data.id ?? '') === f.id) {
const addr = `[${s.board} ▸ ${f.scene || '(root)'}] ${f.file} · ${String(data.path ?? '')}${data.source ? ` (${String(data.source)})` : ''}`
// success confirms IN the frame's hover label (right where the eyes are);
// only failure needs the toast
navigator.clipboard.writeText(addr).then(
() => el.contentWindow?.postMessage({ type: 'sh:copy-ok', seq: data.seq }, location.origin),
() => toast('copy blocked - click the canvas first'))
}
} else if (data.type === 'sh:frame-down') {
// clicks INSIDE a frame never reach the shell document - the frame reports
// them so an open thread card dismisses modal-style from anywhere
const c = commentsStore()
if (c.active) c.setActive(null)
letGoOfTile() // and a lane tile selected with nothing open for it, as a canvas click does
} else if (data.type === 'sh:picked') {
// comment mode: the frame reports the picked element - stage the draft on
// that node (picking while a thread is open replaces it, modal-style);
// the CommentLayer opens the composer at the pin
const c = commentsStore()
if (c.active) c.setActive(null)
if (c.commentMode) c.setDraft({ nodeKey, frame: String(data.id ?? ''), anchor: data.anchor })
} else if (data.type === 'sh:go') {
// a goto from inside an interacting frame CARRIES interact mode to the target - walking
// a flow must not eject you to design mode at every hop (and must survive a board switch)
goTo(String(data.target ?? ''), s.interact === nodeKey)
} else if (data.type === 'sh:wheel') {
// B0.2: a passive frame forwarded a wheel event; the canvas owns it. Never for the
// interact target or play (the app scrolls itself - the parent stays authoritative).
if (s.play || s.interact === nodeKey) return
const nums = [data.deltaX, data.deltaY, data.clientX, data.clientY].map(Number)
if (!nums.every(Number.isFinite)) return
const [deltaX, deltaY, localX, localY] = nums
const rect = el.getBoundingClientRect()
// iframe-local (untransformed CSS px) -> shell-screen px: rect is the frame's
// transformed size, so rect.width/clientWidth is its effective on-screen scale.
// Plain rect.left+localX would put the zoom origin in the wrong place when zoomed.
const sx = el.clientWidth ? rect.width / el.clientWidth : 1
const sy = el.clientHeight ? rect.height / el.clientHeight : 1
canvasCtl.wheel({
deltaX, deltaY, deltaMode: Number(data.deltaMode) || 0,
ctrlKey: !!data.ctrlKey, metaKey: !!data.metaKey,
clientX: rect.left + localX * sx, clientY: rect.top + localY * sy,
})
} else if (data.type === 'sh:interaction') {
// A6: the frame reports transient laser/comment engagement (pointer inside + mode on).
// While engaged the frame is leased, so a hot update to it defers until disengage.
s.setExternalLease(nodeKey, 'laser', !!data.laser)
s.setExternalLease(nodeKey, 'comment', !!data.comment)
}
}
window.addEventListener('message', onMsg)
return () => window.removeEventListener('message', onMsg)
}, [])
// keyboard: t tidy · d theme · ⌘\ panel · Escape exit · shift+0/1/2 zoom
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
// anything that edits text owns its keys: inputs, textareas, selects, contenteditable (the
// comment composer), and an IME mid-composition
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return
if (e.target instanceof HTMLElement && e.target.isContentEditable) return
if (e.isComposing) return
const s = useStore.getState()
if (s.play) return // play mode owns the keyboard (Play.tsx)
// Jam's keys first (spec 27): the first state that takes the key; the rest falls through
// with Option a Mac types a symbol (⌥L is "¬"): an Alt shortcut is named by the key pressed
const key = e.altKey && /^Key[A-Z]$/.test(e.code) ? e.code.slice(3).toLowerCase() : e.key
if (!e.repeat && jamKey({ key, shift: e.shiftKey, alt: e.altKey, meta: e.metaKey, ctrl: e.ctrlKey })) { e.preventDefault(); return }
if ((e.metaKey || e.ctrlKey) && e.key === '\\') { e.preventDefault(); togglePanel(); return }
if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); setPillOpen((o) => !o); return }
if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault(); s.selectAll(); return }
if (e.metaKey || e.ctrlKey) return
if (e.key === 'Escape') {
const c = commentsStore()
if (c.commentMode || c.active || c.draft) { c.setMode(false); c.setActive(null); c.setDraft(null) }
else if (s.laser) s.setLaser(false) // Escape also exits laser mode (parity with comment)
else s.interact ? setInteract(null) : select(null)
}
// a slides board PLAYS as slides even when its landing is the canvas -
// the deck is what the board IS; landing only says where viewers arrive
if (e.key === 'p') { if (playsAsSlides(useStore.getState().board)) enterSlides(); else enterPlay() }
// H hides all chrome (shared binary Hide-UI); press again to reveal. Not persisted,
// so a refresh always restores it (the safety net for a forgotten toggle).
if (e.key === 'h') { toggleHideUI(); return }
if (e.key === 't') { animateLayout(); runTidy() }
// laser and comment mode are one-at-a-time: comment mode already highlights
// what you'd click, so stacking the full rainbow on top only adds noise
// L: the pointer laser (the element under the cursor); ⌥L: inspect (every element outlined)
if (key === 'l' && !e.shiftKey && SOURCE_REVEALED) {
e.preventDefault()
commentsStore().setMode(false)
s.toggleLaser(e.altKey)
}
// C = comment mode (the Figma/Miro convention) · Shift+C = hide/show
// pins · Shift+P = copy file path(s) (P alone is play; D6, changelog 0.4.0)
if (e.key === 'c' && !e.shiftKey) {
const c = commentsStore()
if (!c.commentMode && s.board === UNPLACED) return void toast(NO_COMMENTS_HERE)
if (!c.commentMode && stoppedFor('Comments need the canvas running')) return
if (!c.commentMode) s.setLaser(false)
c.setMode(!c.commentMode)
toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
}
if (e.key === 'C' && e.shiftKey) { const c = commentsStore(); c.setShow(!c.show) }
// sticky notes (spec 18): any note visible -> hide all; none -> show all
// U: the next frame that needs you, else the next new one (spec 25)
if (e.key === 'u' && !e.shiftKey && !PUBLISHED) {
const live = s.nodes.filter((n) => !n.missing).sort((a, b) => a.y - b.y || a.x - b.x)
const from = s.selection.length ? s.nodes.find((n) => n.key === s.selection[s.selection.length - 1])?.frame ?? null : null
const id = nextToLook(s.marks, asksNow(), [...new Set(live.map((n) => n.frame))], from)
const n = id ? live.find((x) => x.frame === id) : null
if (n) { s.select(n.key); canvasCtl.fitNode(n.key) } else toast('nothing new on this board')
return
}
if (e.key === 'n' && !e.shiftKey) { const visible = document.querySelectorAll('.sh-notes:not(.off)').length > 0; useNotes.getState().toggleAll(visible); toast(visible ? 'notes hidden' : 'notes shown'); return }
// Shift+L = laser comment: the laser-sharp lighting on the element a comment tags
// (pick hover, compose lock, open-thread highlight). Pins and cards stay - this
// only dims the lighting inside the artwork.
if (e.key === 'L' && e.shiftKey) {
const c = commentsStore()
c.setShowAnchor(!c.showAnchor)
toast(c.showAnchor ? 'laser comment off' : 'laser comment on')
}
// I = copy the selected frame as a 2x PNG · Shift+I = 4x (dev only; one frame at a time)
if ((e.key === 'i' || e.key === 'I') && !PUBLISHED && !e.altKey && !e.repeat) { s.copyFrameImage(e.shiftKey ? 4 : 2); return }
if (e.key === 'P' && e.shiftKey && s.selection.length) {
const paths = s.selection
.map((k) => { const n = s.nodes.find((x) => x.key === k); const f = n && s.frameFor(n); return f ? framePath(s.board, f) : undefined })
.filter((p): p is string => !!p)
if (paths.length) {
// pulse the toolbar icon into a check ONLY on a real copy success
for (const k of s.selection) { const f = s.frameFor(s.nodes.find((x) => x.key === k)!); if (f) recordCopy('path', f.id) }
navigator.clipboard.writeText(paths.join('\n')).then(
() => { toast(paths.length > 1 ? `${paths.length} paths copied` : 'path copied'); s.pulsePath() },
() => toast('copy blocked - click the canvas first'))
}
}
if (e.key === 'd' && CONFIG.themes.length > 1) {
// scoped like the device digits: selection pins those frames; no selection cycles
// the global VIEW theme (sticky across boards - the user's preference)
if (s.selection.length) {
const scope = s.nodes.filter((n) => s.selection.includes(n.key))
const cur = scope.length && scope.every((n) => n.theme === scope[0].theme) ? scope[0].theme : CONFIG.themes[0]
s.setSelectedTheme(CONFIG.themes[(CONFIG.themes.indexOf(cur) + 1) % CONFIG.themes.length])
} else {
s.setTheme(CONFIG.themes[(CONFIG.themes.indexOf(s.viewTheme) + 1) % CONFIG.themes.length])
}
}
if (e.shiftKey && e.code === 'Digit0') canvasCtl.zoom100()
if (e.shiftKey && e.code === 'Digit1') canvasCtl.fitAll()
if (e.shiftKey && e.code === 'Digit2' && s.selection.length) canvasCtl.fitNodes(s.selection)
// plain digits: 0 = default, 1..n = devices. Scoped to the selection when one
// exists, board-wide otherwise. Every path tidies - presets never scramble layout.
if (!e.shiftKey && /^Digit[0-9]$/.test(e.code)) {
const idx = Number(e.code.slice(5))
const names = Object.keys(CONFIG.viewports)
if (idx !== 0 && !names[idx - 1]) return
const name = idx === 0 ? null : names[idx - 1]
animateLayout()
if (s.selection.length) {
s.resizeSelected(name)
setTimeout(() => canvasCtl.fitNodes(useStore.getState().selection), 30)
} else {
s.setDeviceView(name)
setTimeout(() => canvasCtl.fitAll(), 30)
}
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
// the sidebar reflects the ACTIVE BOARD: only scenes/frames with a node on this board
const onBoard = new Set(nodes.map((n) => n.frame))
const frames = (manifest?.frames ?? []).filter((f) => onBoard.has(f.id))
// scene groups follow the CANVAS reading order (earliest node y, then x), the
// same law as frame rows - a story board's phases list top-to-bottom as laid out
const scenes = [...new Set(frames.map((f) => f.scene))].sort((a, b) => {
const min = (sc: string) => nodes.reduce((acc, n) => {
const f = frames.find((x) => x.id === n.frame)
if (f?.scene !== sc) return acc
return !acc || n.y < acc.y || (n.y === acc.y && n.x < acc.x) ? { y: n.y, x: n.x } : acc
}, null as { y: number; x: number } | null)
const ma = min(a), mb = min(b)
if (!ma || !mb) return ma ? -1 : mb ? 1 : a.localeCompare(b)
return ma.y - mb.y || ma.x - mb.x || a.localeCompare(b)
})
.map((name) => ({ name, frames: frames.filter((f) => f.scene === name).length }))
// frame ids currently selected, for marking their parent scenes as `held`
const selFrames = new Set(nodes.filter((n) => selection.includes(n.key)).map((n) => n.frame))
// the shell follows the user's VIEW theme - per-frame pins never flip the chrome
const dark = useStore((s) => s.viewTheme) === 'dark'
// interact mode re-accents the ENTIRE shell purple - one token override class,
// everything derived from --accent follows (mark, sidebar, bars, handles, beams)
const interacting = useStore((s) => s.interact !== null)
// leaving interact returns keyboard focus to the shell: while a frame held focus its
// document swallowed every shortcut (the bridge only forwards Escape), so d/t/digits
// appeared dead after an interact session
const appRef = useRef(null)
useEffect(() => {
if (!interacting) appRef.current?.focus({ preventScroll: true })
}, [interacting])
// Every published board is locked to a stage mode: the canvas shell is never
// offered - no sidebar, no canvas surface, no toolbar. The bundle IS the
// locked view (the Publish-to-web shape). Boot still ran above; the landing
// effect entered the mode, and PlayOverlay's noDoor keeps it there.
if (LOCKED_SHELL) {
return (
{toasts.map((t) =>
{t.text}
)}
)
}
return (
{/* the scroll edge under the Mac app's transparent strip (0.27.2, A3.8 #31): over the world, under the chrome */}
{TOP ? : null}
{/* floating pill panel (no top bar - spec); panel and fab are both always
mounted so collapse/expand can crossfade-morph between them */}
Open panel⌘\>}>
{/* floating pill nav, top right; collapses to a chip (same ladder as panel/fab) */}
Open toolbar⌘/>}>
{CONFIG.setup
?
no app detected - designs would be built from nothing. See design/instructions/setup.md, then restart
no theme configured - frames render unstyled. Create design/theme.css importing your app's stylesheet (or set theme in design/config.ts)
}
{stopped && }
{/* Jam V2 (spec 27): the lane, the focus panel, the notes card, the recording ring */}
{!PUBLISHED && }
)
}
/** The canvas stopped (spec 28 A1.11): what happened and the way back - and, only when this tab holds some, that
* its unsaved edits are kept. It sits on the toolbar's centre line in the free gap between the sidebar and the
* toolbar, or just below the toolbar row when that gap is too narrow; it never covers either, and Hide UI (H)
* leaves it up. In play it wears the play chrome's dark skin. */
function StoppedBanner() {
const link = useLink((s) => s.link)
const unsaved = useLink((s) => s.unsaved)
const play = useStore((s) => !!s.play)
const ref = useRef(null)
const cmd = npx marver dev
const what = link === 'resuming' ? <>This canvas is back{unsaved ? ' - saving your edits' : ''}>
: link === 'elsewhere' ? <>Another canvas is running on this address - stop it and run {cmd} to bring this one back>
: <>This canvas stopped - run {cmd} to bring it back>
const kept = unsaved && link !== 'resuming'
useLayoutEffect(() => {
const el = ref.current
if (!el) return
let raf = 0
const place = () => { cancelAnimationFrame(raf); raf = 0; placeBanner(el) }
const soon = () => { raf ||= requestAnimationFrame(place) }
place()
// what moves the gap: the window, the chrome around it opening, closing or changing size, play, Hide UI
const seen = new Set()
const ro = new ResizeObserver(soon)
const watch = () => { for (const n of el.parentElement?.querySelectorAll(OBSTACLES) ?? []) if (!seen.has(n)) { seen.add(n); ro.observe(n) } }
watch()
const mo = new MutationObserver(() => { watch(); soon() })
if (el.parentElement) mo.observe(el.parentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
mo.observe(document.body, { attributes: true, attributeFilter: ['class'] })
window.addEventListener('resize', soon)
return () => { cancelAnimationFrame(raf); ro.disconnect(); mo.disconnect(); window.removeEventListener('resize', soon) }
}, [link, unsaved, play])
return (
{what}{kept && <> · your unsaved edits are kept in this tab>}
)
}
/** The chrome the stopped banner keeps clear of: the sidebar (or its button) on the left, the toolbar (or its
* button) on the right - the canvas's own, or play's brand and toolbar pills over it. */
const OBSTACLES = ':scope > .sh-panel, :scope > .sh-fab, :scope > nav.sh-pill, :scope > .sh-pill-fab, :scope > .sh-play > .sh-pill, :scope > .sh-play > .sh-pill-fab'
const BANNER_GAP = 12
function placeBanner(el: HTMLElement) {
const app = el.parentElement
if (!app) return
const edge = parseFloat(getComputedStyle(app).getPropertyValue('--edge')) || 10
// a box in the app's own coordinates, from layout - never the rect mid-morph (a closing panel scales down)
const box = (n: HTMLElement) => {
let x = 0, y = 0
for (let e: HTMLElement | null = n; e && e !== app; e = e.offsetParent as HTMLElement | null) { x += e.offsetLeft; y += e.offsetTop }
return { left: x, top: y, right: x + n.offsetWidth, bottom: y + n.offsetHeight }
}
const pick = (root: Element, sel: string) => {
const n = root.querySelector(sel)
return n && !n.matches('.closed, .hidden') && n.offsetWidth ? box(n) : null
}
let left: ReturnType | null = null, right: ReturnType | null = null
if (!document.body.classList.contains('sh-hide-ui')) {
const stage = app.querySelector(':scope > .sh-play')
if (stage) {
left = pick(stage, ':scope > .sh-play-brand')
right = pick(stage, ':scope > .sh-play-pill:not(.sh-play-brand)') ?? pick(stage, ':scope > .sh-pill-fab')
} else {
left = pick(app, ':scope > .sh-panel') ?? pick(app, ':scope > .sh-fab')
right = pick(app, ':scope > nav.sh-pill') ?? pick(app, ':scope > .sh-pill-fab')
}
}
// the toolbar row: its centre line, and its foot - the sidebar runs down the page, so it is no part of the row
const tall = !!left && left.bottom - left.top > 64
const row = [right, tall ? null : left].filter((b): b is NonNullable => !!b)
// with no row to measure: the toolbar's line, below the Mac app's strip when the shell took the inset
const mid = row.length ? Math.max(...row.map((b) => (b.top + b.bottom) / 2)) : TOP + edge + 23
const foot = Math.max(TOP + edge + 46, ...row.map((b) => b.bottom))
const W = app.clientWidth
const from = left ? left.right + BANNER_GAP : edge, to = right ? right.left - BANNER_GAP : W - edge
const at = (where: 'row' | 'stack' | 'below', x0: number, x1: number, top?: number) => {
el.dataset.at = where
el.style.left = `${Math.round(x0 + Math.max(0, (x1 - x0 - el.offsetWidth) / 2))}px`
el.style.top = `${Math.round(top ?? mid - el.offsetHeight / 2)}px`
}
el.style.transform = 'none'
el.dataset.at = 'row'
// one line in the gap
el.style.whiteSpace = 'nowrap'
el.style.maxWidth = 'none'
const line = el.offsetHeight
if (el.offsetWidth <= to - from) return at('row', from, to)
// two lines in the gap: what happened, then the kept edits under it
if (el.querySelector('.kept')) {
el.dataset.at = 'stack'
if (el.offsetWidth <= to - from) return at('stack', from, to)
el.dataset.at = 'row'
}
// or wrapped in two, the box no wider than its longest line
el.style.whiteSpace = 'normal'
el.style.maxWidth = `${to - from}px`
if (to - from >= 220 && el.offsetHeight <= line + 18) { hug(el); return at('row', from, to) }
// too narrow: under the toolbar row, centred in what the sidebar leaves, the kept edits on a line of their own
el.dataset.at = 'below'
const lo = tall && left ? left.right + BANNER_GAP : edge, hi = W - edge
el.style.maxWidth = `${Math.max(160, Math.min(680, hi - lo))}px`
hug(el)
at('below', lo, hi, row.length ? foot + 8 : edge) // no row with the chrome hidden: the top edge
}
/** A wrapped banner's box shrinks to its longest line (a max-width box stays as wide as it may be). */
function hug(el: HTMLElement) {
const r = document.createRange()
r.selectNodeContents(el)
const lines = [...r.getClientRects()]
if (!lines.length) return
const cs = getComputedStyle(el)
const chrome = el.offsetWidth - el.clientWidth + parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight)
const need = Math.ceil(Math.max(...lines.map((l) => l.right)) - Math.min(...lines.map((l) => l.left)) + chrome) + 1
const was = el.style.maxWidth, h = el.offsetHeight
el.style.maxWidth = `${Math.min(need, el.offsetWidth)}px`
if (el.offsetHeight > h) el.style.maxWidth = was // never a line more than it had (a balanced wrap re-flows)
}
/** The bottom-right notification corner. Plain toasts render as before.
* Jam pills are FRAME-FIRST (icon + frame title, then Marver · preview) and stack as a DECK:
* 1-2 show in full; 3+ collapse to the newest pill with two card edges peeking beneath and a
* +N badge - click to expand the full list (newest first, timestamps, Clear all). */
function JamToasts({ toasts }: { toasts: import('./store.ts').Toast[] }) {
const [expanded, setExpanded] = useState(false)
const plain = toasts.filter((t) => !t.jam)
const jams = toasts.filter((t) => t.jam)
useEffect(() => { if (jams.length <= 2) setExpanded(false) }, [jams.length])
const deck = jams.length > 2 && !expanded
const newest = jams[jams.length - 1]
return (
)
}
/** The pill's face for a human notification - the author's picture, or the
* house initials fallback (same ladder the thread avatars use). */
function JamAvatar({ author }: { author: { name?: string; avatar?: string; id?: string; email?: string } }) {
const { initials, hue } = avatarFallback(author)
return author.avatar
?
: {initials}
}
/** View from a notification: threads are frame-scoped, so first try to reveal RIGHT HERE
* (the current board may show the thread's frame); only when it doesn't, switch to the
* note's origin board and retry while its comments load. Dismiss only on success: a
* cross-board note must never be a destructive no-op. */
async function viewNote(note: import('./store.ts').JamNote, dismiss: () => void) {
if (revealThread(note.threadId)) { dismiss(); return }
const s = useStore.getState()
// a navigation: a link still finding its board gives way to it - and it to a newer one, wherever this one has got to
const nav = navigated()
// a thread in the old all-scenes log has no board of its own: its frame's board shows it (spec 26)
if (note.board === ALL_SCENES) { if (note.frame) await revealFrame(note.frame, nav) }
else if (note.board && s.board !== note.board && !await switchFor(note.board, nav)) return
for (let i = 0; i < 12 && isLatest(nav); i++) {
if (revealThread(note.threadId)) { dismiss(); return }
await new Promise((r) => setTimeout(r, 250))
}
}
/** Retry a batch's failed jam cards from their notice - each one still failed (a newer change wins). */
async function retryNote(failed: { card: string; rev: number }[]) {
const { useJamQueue } = await import('./jam-queue.ts')
for (const f of failed) {
const card = useJamQueue.getState().cards?.find((c) => c.id === f.card)
if (card && card.state === 'failed') await useJamQueue.getState().act(card, 'retry')
}
}
/** One jam pill, frame-first: row 1 = intent icon + FRAME TITLE (blue) · age; row 2 = Marver · preview.
* `badge` shows the +N deck count (replacing View); `inert` disables inner clicks (the deck handles it). */
function JamToast({ id, note, badge, inert }: { id: number; note: import('./store.ts').JamNote; badge?: number; inert?: boolean }) {
const dismiss = () => useStore.getState().dismissToast(id)
// human pills are person-first: their avatar, "name mentioned you"; the
// agent pill keeps its frame-first shape with the mark
const human = note.kind && note.author
return (