import { create } from 'zustand' import { ROUTE, slideSize } from '../const.ts' import { tidy, parseLayout, type BoardLayout, type TidyNode } from './tidy.ts' import { clearNoteHeights, noteHeight, noteReserve, notesCramped, scopeNotes, setNoteHeight } from './notes.ts' import { stableNodeKey } from './keys.ts' import { canvasCtl } from './canvas/ctl.ts' import { api, beforeResume, canvasStopped, holdsReload, lostInResume, mvFetch, noteUnsaved, onLinkNotice, savedInResume, scoped, STOPPED_WAY, withWs } from './api.ts' import { unsavedWhen, workChanged } from './unsaved.ts' // @ts-expect-error virtual module provided by the plugin import shConfig from 'virtual:sh-config' // @ts-expect-error virtual module: null in dev; a published build inlines manifest+boards import shData from 'virtual:sh-data' scopeNotes(scoped('mv-notes')) // a per-viewer preference, kept per canvas (spec 28 A1.6) /** Published-build data. Non-null = static site: no API, no saves, no live events. * `names` is the switcher list; `default` is where `/` opens - never a synthesized * aggregate of a filtered build. */ const DATA: { manifest: Manifest; boards: Record; names: string[]; default: string /** the sidebar's folder tree over the published boards (0.16.0 bundles; older ones have none) */ tree?: TreeItem[] /** slug → title of the published boards (0.16.1 bundles); folder titles ride on `tree` */ titles?: Record /** slug → resolved type, and the status a publish row opted into (0.22 bundles; spec 20) */ meta?: Record /** when the build read the statuses it ships - a published status is a snapshot */ statusAsOf?: string /** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */ policy?: { boards: Record; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean } /** the generation of the glass textures this build shipped (publish-bakes.ts); absent = none */ bakes?: number /** content-frame heights, `scene/frame@width` -> px (design/boards/_sizes.json, published frames only) */ sizes?: Record /** node key -> frame, off a leftover all-scenes.json, for old links naming keys (spec 26; shipped frames only) */ allScenesKeys?: Record } | null = shData /** An old All Scenes link's node keys a published bundle can still turn back into frames (legacy-links.ts). */ export const ALL_SCENES_KEYS: Record = DATA?.allScenesKeys ?? {} /** The published textures' generation, or 0: the static index this build shipped is at /__mv/bakes//index.json. */ export const BAKES = DATA?.bakes ?? 0 /** When a published bundle's statuses were read (spec 20) - absent in dev, where they are live. */ export const STATUS_AS_OF = DATA?.statusAsOf ?? null /** Every published board is locked to a stage mode - the canvas shell is never * offered on this bundle (01-sharing §5.1's all-boards rule). */ export const LOCKED_SHELL = DATA?.policy?.lockedShell === true /** True on a published static canvas - no dev server, no API, no update checks. */ export const PUBLISHED = DATA !== null /** May this bundle show source paths (laser copy)? Dev always; published only * when publish.json says `reveal.source: true`. The strip already removed the * paths from the bundle - this flag removes the affordance that would show * their opaque replacements (01-sharing §6.2). */ export const SOURCE_REVEALED = DATA ? DATA.policy?.reveal?.source === true : true /** The published per-board artifact metadata (type, open, lock). Empty in dev. */ export const BOARD_POLICY: Record = DATA?.policy?.boards ?? {} export { SLIDE_INTRINSIC } from '../const.ts' /** Dev-only hydration: the shell loads the repo's publish.json boards so an * author previews slides mode exactly as viewers will get it. Published * builds carry the policy inline and never call this. */ export function hydrateBoardPolicy(boards: Record) { for (const [k, v] of Object.entries(boards)) if (!BOARD_POLICY[k]) BOARD_POLICY[k] = v } export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string; intent?: string; contentWidth?: number; slide?: boolean; note?: string } export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number; title?: string; description?: string; note?: string }[] } export interface Node { key: string; frame: string; x: number; y: number; w: number; h: number /** RESOLVED theme (what renders): themeUser ?? frame meta.theme ?? viewTheme. */ theme: string /** Renavigation nonce: bumped when the shell wants this iframe on a FRESH URL * (errored frame whose file is back in the manifest). Never persisted. */ nav?: number /** Source revision: bumped when the frame reports an HMR update (its modules changed without a * navigation). Never persisted. */ rev?: number /** The theme the frame reports as APPLIED (sh:theme-applied) - sleep waits for it to match. */ themeOn?: string /** Explicit per-frame override, set by scoped theme actions; cleared by a global set. * The only theme value that persists into the board file. */ themeUser?: string /** Size provenance for CONTENT frames, explicit: 'auto' = measured * (transient - never serialized); 'manual' = the human's - measurements never * overwrite; 'device' = a preset's. UI frames never carry it. Only manual/device * persist, so a reload can tell an authored size from a measured one. */ sizeMode?: 'auto' | 'manual' | 'device' /** Transient (never serialized): this node was sized by the 390x844 GUESS because its * frame was not in the manifest yet AND its file carried no w/h (the create-first * choreography's append-before-scan race). applyManifest corrects ONLY these when the * frame arrives - an authored size on a temporarily-missing frame is never touched. */ sizeFallback?: boolean status: 'loading' | 'ready' | 'error'; error?: string; missing?: boolean /** the frame's one automatic ready-retry has been spent (a slow dev server must not be reported * as a failed frame; see canvas/ready-watch.ts). Transient - cleared on any real ready/error and * on every fresh navigation. */ readyRetried?: boolean /** spec 26: an update waits for this node - it reloads through the boot queue once the frame is on * screen and not in use (canvas/updates.ts). Transient. */ stale?: boolean /** spec 26: the revision of the document it shows - "epoch:doc", stamped by the dev server when it * served the document, echoed by its sh:ready. A look counts only up to it. Transient. */ shown?: string /** spec 26: the revision ("epoch:doc") a stale node waits for - a document showing it (or later) * clears the wait, an older one keeps it. Transient. */ want?: string } /** A comment notification: a persistent bottom-right glass pill. Three kinds * share it - a Marver (agent) reply renders frame-first with the mark; a * human `mention`/`reply` renders person-first: their avatar, "«name» * mentioned you" / "«name» replied", the message underneath. */ export interface JamNote { threadId: string; board: string; preview: string; ts: number frame?: string; frameTitle?: string; intent?: string kind?: 'mention' | 'reply' // absent = the agent pill author?: { name?: string; avatar?: string; id?: string; email?: string } /** the jam batch this notice is about - its later questions fold into it, whenever they arrive */ batch?: string /** jam cards of one batch that failed (spec 27): the notice carries Retry, for all of them */ failed?: { card: string; rev: number }[] /** earlier notices folded under this one - View walks through them */ stack?: JamNote[] } export interface Toast { id: number; text: string; jam?: JamNote } export const CONFIG: { viewports: Record; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string; branding?: boolean; jam?: { chime: boolean; capture: 'confirm' | 'at-once'; names?: boolean } | null; workspace?: string; version?: string } = shConfig /** Invariant: `branding: false` strips every Marver mention - new chrome included. */ export const BRANDING = CONFIG.branding !== false /** The five view modes' landing map (04-solution §2.35): the artifact type picks * the default view; an explicit `open` beats it; a frame deep link beats both. */ export function landingMode(board: string): 'canvas' | 'board' | 'present' | 'focus' | 'slides' { const p = BOARD_POLICY[board] if (!p) return 'canvas' if (p.open && ['canvas', 'board', 'present', 'focus', 'slides'].includes(p.open)) { return p.open as any } switch (p.type) { case 'doc': return 'focus' case 'slides': return 'slides' case 'design': case 'sketch': case 'refs': return 'board' default: return 'canvas' } } /** Does this board PLAY as a deck? A `type: slides` board is a deck whatever * its landing (`open: canvas` only says where viewers arrive) - the P key and * the play button route here, never through landingMode. */ export function playsAsSlides(board: string): boolean { const p = BOARD_POLICY[board] return p?.type === 'slides' || p?.open === 'slides' } /** Is this board frozen to its landing mode? The lock caps disclosure - no way * out into other modes, and (published) no sidebar ever renders for it. */ export const boardLocked = (board: string): boolean => BOARD_POLICY[board]?.lock === true /** May this board enter `mode`? A lock freezes what open pinned - a board * locked to canvas must refuse present just as one locked to present refuses * canvas. Unlocked boards enter anything. */ export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides'): boolean => !boardLocked(board) || landingMode(board) === mode export { cap, humanize } from './labels.ts' import { cap, humanize } from './labels.ts' import { canAutoReload } from './canvas/ready-watch.ts' import { pumpUpdates } from './canvas/updates.ts' import { archiveLast, buildTree, flatten, framesOn, labelOf, toWire, TREE_PROTOCOL, type TreeItem } from '../../shared/board-tree.ts' import { UNPLACED } from './hash.ts' import type { BoardType, StatusWord } from '../../shared/board-types.ts' import type { Phase, Status } from '../../shared/status.ts' import { isNew, type Marks } from '../../shared/marks.ts' /** The CAS tokens a tree write echoes: the sha256 of every board file as last seen, and of * the folder registry (null = there was no file). */ export interface TreeBase { boards: Record; folders: string | null } export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase; titles: Record; meta: Record } /** What spec 20 adds to a board's row: its resolved type, and - for feature and project boards - * its status read from context/. Dev carries the evidence for the tooltip; a published bundle * carries only what its publish row opted into. */ export interface BoardMeta { type?: BoardType /** `row` is the table row that decided it (1-3: a decision on the board) */ status?: { status: Status; row?: number; fill?: Phase; reason?: string; evidence?: string[] } /** what a person may set it to from the sidebar (dev only) - shared/board-types settableStatuses */ settable?: StatusWord[] /** the board file is not valid JSON: the parser's words, line and column (#6) */ error?: string } /** The sidebar tree: root boards and folders in rank order, each folder's boards and * sub-folders inside (shared/board-tree.ts), plus the hashes it was built from. Throws on * transport failure and on a malformed registry (the server's 422 message) - callers keep * their last known tree. */ export async function fetchBoardTree(): Promise { if (DATA) return { tree: DATA.tree ?? DATA.names.map((n) => ({ kind: 'board', name: n })), base: { boards: {}, folders: null }, titles: DATA.titles ?? {}, meta: DATA.meta ?? {} } const [boards, reg] = await Promise.all([ // a refusal says why in the server's own words (a symlinked design/boards: BoardList says it out loud) api('boards').then(async (r) => { if (!r.ok) throw new Error((await r.json().catch(() => null))?.error ?? `boards ${r.status}`); return r.json() }) as Promise<{ name: string; sha256: string; order?: number; folder?: string; title?: string; type?: BoardType; status?: BoardMeta['status'] | null; settable?: StatusWord[]; error?: string }[]>, api('folders').then(async (r) => { const j = await r.json() as { folders?: { name: string; order?: number; parent?: string; title?: string }[]; sha256?: string | null; error?: string } if (!r.ok) throw new Error(j?.error ?? `folders ${r.status}`) return j }), ]) return { tree: buildTree(boards, reg.folders ?? []), base: { boards: Object.fromEntries(boards.map((b) => [b.name, b.sha256])), folders: reg.sha256 ?? null }, titles: Object.fromEntries(boards.flatMap((b) => (b.title ? [[b.name, b.title]] : []))), meta: Object.fromEntries(boards.map((b) => [b.name, { ...(b.type && b.type !== 'plain' ? { type: b.type } : {}), ...(b.status ? { status: b.status } : {}), ...(b.settable?.length ? { settable: b.settable } : {}), ...(b.error ? { error: b.error } : {}) }])), } } /** A tree read's types and statuses, kept under the same latest-wins rule as its titles. */ export function rememberMeta(meta: Record) { if (JSON.stringify(useStore.getState().boardMeta) !== JSON.stringify(meta)) useStore.setState({ boardMeta: meta }) } /** A tree read's board titles become the labels' - called by the reader once it knows the * read is its latest (an older response must not overwrite a newer one's labels). Folder * titles ride on the tree's folder items: a board and a folder may share a slug, never one map. */ export function rememberTitles(titles: Record) { if (JSON.stringify(useStore.getState().boardTitles) !== JSON.stringify(titles)) useStore.setState({ boardTitles: titles }) } /** Board names for switchers: the sidebar's reading order (folders flattened depth-first); the * first is the landing board. Throws on transport failure - callers keep their last known list. */ export async function fetchBoardNames(): Promise { if (DATA) return DATA.names const snap = await fetchBoardTree() rememberTitles(snap.titles) rememberMeta(snap.meta) return flatten(snap.tree) } /** Where the canvas opens with no link: the first board in sidebar order, else (no board yet) the * Not on a board view - empty, on a canvas with no frames either. */ export const landingBoard = async (): Promise => (await fetchBoardNames().catch(() => [] as string[]))[0] ?? UNPLACED /** Which board shows which frame (spec 26, Part 7): every board in sidebar order with archive * boards last, the live frames each shows, and the frames on no board - from the dev server, or * off a published bundle (whose frames are all on a published board). Throws on transport failure. */ export async function fetchMembership(): Promise<{ boards: { name: string; frames: string[] }[]; unplaced: string[] }> { if (!DATA) { const r = await api('membership') if (!r.ok) throw new Error(`membership ${r.status}`) return r.json() } const live = DATA.manifest.frames.map((f) => f.id) const names = archiveLast(DATA.names, (n) => n === 'archive' || DATA.meta?.[n]?.type === 'archive') return { boards: names.map((name) => ({ name, frames: framesOn(DATA.boards[name], live) })), unplaced: [] } } /** Display name for a board: its title when one is set (subscribe to `boardTitles` to * re-render on change), else the Title-Cased slug. */ export const boardLabel = (n: string) => (n === UNPLACED ? 'Not on a board' : labelOf(n, useStore.getState().boardTitles[n])) /** The canvas's name, as the sidebar heads it: the project's (share.name, else the repo folder). */ export const CANVAS_NAME = CONFIG.projectName ? humanize(CONFIG.projectName) : 'Marver' /** The browser tab: the board, then the canvas - "Start here - Acme Studio" - so a row of canvas tabs * says which project each one is (it said "- Marver" on all of them). */ export const tabTitle = (board: string | null | undefined): string => (board ? `${boardLabel(board)} - ${CANVAS_NAME}` : CANVAS_NAME) /** Display name for a scene: the title in its brief's front matter, else the Title-Cased directory. */ export const sceneLabel = (n: string) => labelOf(n, useStore.getState().manifest?.scenes.find((s) => s.name === n)?.title) /** The double-submit CSRF token the owner gate wants echoed (same read as comments-store). */ const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? '' /** POST to an owner-gated dev API route, echoing the CSRF cookie the gate requires. */ /** The reload log (spec 26 Part 1): what this page saw boot or settle - best effort, dev only. */ const TAB = Math.random().toString(36).slice(2, 8) /** Why a node's next document boots - a reload job, a reload by hand, the watchdog's retry; none known * means a first load, or a document that reloaded itself (the reload log tells them apart). */ const reloadCause = new Map() export const takeReloadCause = (key: string) => { const c = reloadCause.get(key); reloadCause.delete(key); return c } export const logReload = (entry: Record) => { if (PUBLISHED) return const body = { tab: TAB, ...entry } // a page's very first request may go before its request cookie exists: a GET primes it, then once more void postOwner('perf/reload', body).then((r) => (r.status === 403 ? api('work').then(() => postOwner('perf/reload', body)) : r)).catch(() => {}) } const postOwner = (path: string, body: unknown) => api(path, { method: 'POST', headers: { 'content-type': 'application/json', 'x-mv-c': csrf() }, body: JSON.stringify(body) }) /** Add to board (the Not on a board view): these frames appended to a board's file, as * `marver boards pin` does. The answer is said; the file change re-reads the sidebar and the view. */ export async function addToBoard(board: string, frames: string[]): Promise { const { toast } = useStore.getState() try { const r = await postOwner('boards/pin', { board, frames }) const body = await r.json().catch(() => ({})) as { added?: string[]; error?: string } toast(r.ok ? `${body.added?.length ? 'pinned' : 'already'} on ${boardLabel(board)}` : body.error ?? `could not pin (${r.status})`) } catch { toast('could not reach the dev server') } } const HEADER = 28 /** What tidy sees: nodes with their scene, variant run, header-inclusive height, the sticky * note width to reserve in front (spec 18: the frame's note, and the scene's on every member - * tidy keeps the scene reserve on the first node it places), and the measured height of the * column standing there, so a note longer than its frame gets its room below as well. */ export function tidyInput(nodes: readonly Node[], manifest: Manifest | null): TidyNode[] { const entryOf = (id: string) => manifest?.frames.find((f) => f.id === id) const sceneNote = (scene: string) => !!manifest?.scenes.find((s) => s.name === scene)?.note return nodes.map((n) => { const f = entryOf(n.frame) const scene = f?.scene ?? '' return { key: n.key, frame: n.frame, scene, group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER, noteW: noteReserve(!!f?.note, false), sceneNoteW: noteReserve(false, sceneNote(scene)), noteH: noteHeight(n.key), } }) } let toastSeq = 0 const nodeKey = () => 'n_' + Math.random().toString(36).slice(2, 8) /** Manifest revision - bumps whenever a manifest lands (boot, switch, sh:manifest). * Stamped into frame URLs so a changed frame set mints genuinely NEW iframe URLs: * the browser can never revive a pre-change document from cache (friction log #20). */ let manifestRev = 0 export const bumpManifestRev = () => { manifestRev++ } /** ws= binds the document to this canvas (spec 28 A1.11): another project on this port later answers a * frame's own reload with the "another canvas" page (server/guard.ts), never with its frame. */ export function frameUrl(frame: FrameEntry, theme: string): string { return withWs(frame.kind === 'html' ? `/${frame.file}?theme=${theme}&r=${manifestRev}` // r= carries the generation for the sh:ready guard (static serving ignores it) : `${ROUTE}/frame/?id=${encodeURIComponent(frame.id)}&theme=${theme}&r=${manifestRev}`) } /** The global view theme: the user's sticky preference, applied across boards and * reloads. Frames resolve to it unless they declare meta.theme or carry a user pin. */ const initialViewTheme = () => { try { const t = localStorage.getItem(scoped('mv-view-theme')) if (t && CONFIG.themes.includes(t)) return t } catch { /* storage unavailable */ } return CONFIG.themes[0] ?? 'light' } const manifestKey = (m: Manifest) => JSON.stringify(m.frames) // any change counts, not just added/removed ids let scenesRev = 0 // bumps per sh:scenes, so a load that straddled one keeps the live labels let liveScenes: Manifest['scenes'] | null = null // the last sh:scenes payload - applied late when it beat the first manifest /** Latest measured content heights, keyed frameId@width - this session's live truth. Board files * never carry auto sizes; the committed cache below does. */ const measuredHeights = new Map() /** Committed content heights (design/boards/_sizes.json - src/server/sizes.ts), keyed frameId@width: * a content frame's height BEFORE it measures, so a board opens at its final geometry instead of * growing from a guess and re-flowing row after row. Read at every board load in dev (the API: an * agent may have edited frames since), once from the bundle on a published canvas. A stale entry * is still the best first guess - the live measurement corrects it, and the dev shell writes the * correction back. */ let savedHeights = new Map() /** The frames that measure (render a Doc), as the dev server read their sources at the load that * committed - only theirs is a height to trust: a Doc turned bare Md keeps the size its board gives * it, even in a page that measured it as a Doc earlier. null = no such list (a published bundle * ships only measuring frames' heights). */ let measuring: Set | null = null /** What one board load read of the sizes. Read by its request, installed by its commit (spec 28 A1.7): a * request that never commits - another board chosen, a failed read - changes nothing the mounted board * reads, its settle rule (settleRule) and its known heights included. */ interface SizeRead { saved: Map; measuring: Set | null } /** Set only while a load prepares its nodes (the synchronous end of loadBoardState): the sizes it read. */ let preparing: SizeRead | null = null const savedNow = () => preparing?.saved ?? savedHeights let publishedLoaded = false /** The sizes as the server has them now, for one load; null = nothing new to read (a published canvas reads its * bundle once, here; no answer keeps what is installed). */ async function readSizes(): Promise { if (DATA) { if (!publishedLoaded) { publishedLoaded = true for (const [k, v] of Object.entries(DATA.sizes ?? {})) if (typeof v === 'number' && Number.isFinite(v) && v > 0) savedHeights.set(k, Math.round(v)) } return null } try { const r = await api('sizes') if (!r.ok) return null const body = (await r.json()) as { heights?: Record; measuring?: unknown } const saved = new Map() for (const [k, v] of Object.entries(body.heights ?? {})) if (typeof v === 'number' && Number.isFinite(v) && v > 0) saved.set(k, Math.round(v)) return { saved, measuring: Array.isArray(body.measuring) ? new Set(body.measuring.filter((x): x is string => typeof x === 'string')) : null } } catch { return null } // no cache: placeholders, as before } /** The committed load's sizes become the page's. */ function installSizes(read: SizeRead | null): void { if (!read) return savedHeights = read.saved measuring = read.measuring } const measures = (frameId: string) => { const m = preparing ? preparing.measuring : measuring; return !m || m.has(frameId) } // settled heights that differ from the committed ones, written back in batches (dev only). One // write in flight at a time; a height that changes while its write is in flight queues behind it, // so the LAST settled height is the one the file ends with. A write the server did not take (no // answer, or an error status) goes back in the queue - behind any newer height for the same key, // which wins - so the heights stay unsaved until they reach the file, and is sent again after a // wait that doubles with each failure in a row (1.5 s to 24 s) const pendingHeights = new Map() const inflightHeights = new Map() let heightsTimer: ReturnType | undefined /** The write in flight, until it has answered and its heights are saved or queued again (the resume awaits it). */ let flushing: Promise | null = null let heightsFailed = 0 const HEIGHTS_MS = 1500 const persistHeight = (key: string, h: number) => { if (DATA) return if (!inflightHeights.has(key) && savedHeights.get(key) === h) { if (pendingHeights.delete(key)) workChanged(); return } if (!pendingHeights.size) workChanged() // heights not written yet: unsaved (unsaved.ts) pendingHeights.set(key, h) if (!flushing) heightsTimer ??= setTimeout(flushHeights, HEIGHTS_MS) // a throttle, not a debounce: a board settling in waves still writes } async function flushHeights(): Promise { heightsTimer = undefined if (flushing) return flushing if (!pendingHeights.size) return if (canvasStopped()) return // held until the canvas answers again (A1.11): the resume flushes them let done = () => {} flushing = new Promise((r) => { done = r }) const batch = Object.fromEntries([...pendingHeights].slice(0, 500)) for (const [k, v] of Object.entries(batch)) { pendingHeights.delete(k); inflightHeights.set(k, v) } let answered = false try { const r = await postOwner('sizes', { heights: batch }) if (r.ok) { const accepted = new Set(((await r.json()) as { accepted?: string[] }).accepted ?? []) for (const k of Object.keys(batch)) if (accepted.has(k)) savedHeights.set(k, batch[k]) answered = true } } catch { /* lost: below */ } // no answer = the write may or may not have landed: what the file holds is unknown, so nothing is // deduplicated against it - a correction queued meanwhile still goes, the next report still writes. // Each of its heights is owed again, unless a newer one for that key is already queued if (!answered) for (const [k, v] of Object.entries(batch)) { savedHeights.delete(k); if (!pendingHeights.has(k)) pendingHeights.set(k, v) } for (const k of Object.keys(batch)) inflightHeights.delete(k) // a height that settled while the write was in flight: still owed only if it differs from what landed for (const [k, v] of pendingHeights) if (savedHeights.get(k) === v) pendingHeights.delete(k) heightsFailed = answered ? 0 : Math.min(heightsFailed + 1, 5) flushing = null workChanged() if (pendingHeights.size) heightsTimer ??= setTimeout(flushHeights, HEIGHTS_MS * 2 ** Math.max(0, heightsFailed - 1)) done() } /** Every height this page measured is in the file: none queued, none in flight. */ const heightsSaved = () => !pendingHeights.size && !inflightHeights.size /** The resume's turn (A1.11): the write in flight answers first, then what is queued goes now - unless it is waiting * out a failure's backoff, which its own timer sends. True once every height is in the file. */ async function settleHeights(): Promise { await flushing if (pendingHeights.size && !(heightsFailed && heightsTimer)) { clearTimeout(heightsTimer); await flushHeights() } return heightsSaved() } const hasKnownHeight = (frameId: string, w: number) => measures(frameId) && (measuredHeights.has(`${frameId}@${w}`) || savedNow().has(`${frameId}@${w}`)) /** The Doc sizes a layout was computed around: each auto content node whose height is known, by key, * as "WxH" (one line per node in the board file). A composed board saves it with its positions * (`laidOut`); a load where a node it names now has another size knows its rows were laid out * around other sizes - grown, shrunk, or gone from document to wide. A drag changes no size, and a * node added or removed is no size change of the others. */ export type LaidOut = Record const sizeOf = (n: Node) => `${Math.round(n.w)}x${Math.round(n.h)}` function sizeRecord(nodes: readonly Node[]): LaidOut { const out: LaidOut = {} for (const n of nodes) if (n.sizeMode === 'auto' && hasKnownHeight(n.frame, Math.round(n.w))) out[n.key] = sizeOf(n) return out } const readLaidOut = (v: unknown): LaidOut | null => { if (!v || typeof v !== 'object' || Array.isArray(v)) return null const out: LaidOut = {} for (const [k, wh] of Object.entries(v)) if (typeof wh === 'string' && /^\d+x\d+$/.test(wh)) out[k] = wh return out } function defaultSize(frame: FrameEntry) { // the precedence chain (spec 09 slice 1): slide stage → authored // viewport → content sizing → default (one helper, shared with shot) - // a slide's stage is its declared viewport, else 1280×720 const sl = slideSize(frame, CONFIG.viewports) if (sl) return { w: sl.width, h: sl.height } // content frames: own width from Doc layout (meta.viewport, when declared, wins - the existing // precedence); height from this session's measurement at that width, else the committed one, // else a placeholder until sh:measure lands if (frame.contentWidth) { const vp = CONFIG.viewports[frame.viewport ?? ''] const w = vp?.width ?? frame.contentWidth const key = `${frame.id}@${w}` const known = measures(frame.id) ? measuredHeights.get(key) ?? savedNow().get(key) : undefined return { w, h: known ?? vp?.height ?? Math.round(w * 0.75) } } const vp = CONFIG.viewports[frame.viewport ?? ''] ?? CONFIG.viewports.mobile ?? { width: 390, height: 844 } return { w: vp.width, h: vp.height } } let resizedInGesture = false // set inside the store (they need it): a reload's height hold, and the marks-vs-documents check let holdHeight: (key: string) => void = () => {} let reconcile: () => void = () => {} // ---- the recorder's view of the store (spec 28 A1.7) ------------------------------------------------------------- /** A change that moves or resizes nodes runs under a cause. The recorder's subscriber reads it while that change * notifies it (zustand notifies inside set) and calls a move outside any cause `unknown`. The inner cause wins - * except that runTidy keeps an outer one (a device preset, a resize, a reflow tidies through it). Synchronous code * only: a cause never spans an await. */ let geoCause: string | null = null export const geometryCause = (): string | null => geoCause export function withCause(cause: string, fn: () => T): T { const was = geoCause geoCause = cause try { return fn() } finally { geoCause = was } } /** How a board load started: the page's first, a switch, or the open board read from disk again. */ export type LoadCause = 'boot' | 'switch' | 'disk-reload' /** Why a requested load never committed: a newer load, a failed read, an edit or a board change meanwhile, unsaved edits. */ export type RequestCut = 'superseded' | 'error' | 'edited' | 'unsaved' /** Board loads as the recorder sees them: recorder.ts installs these on a dev canvas - no-ops until then, and for good * on a published one. A request owns the geometry from its commit, and `commit` runs right BEFORE the set that * mounts the load: mounting it is never a move. */ export const recorderHooks = { request: (_board: string, _cause: LoadCause): string => '', /** a request is about to commit: the load that was up ends - before the incoming sizes are installed, so it ends by * the sizes it read (which frames measure), its board's nodes still in the store */ handover: (_request: string): void => {}, commit: (_request: string, _nodes: readonly Node[], _manifest: Manifest): void => {}, cut: (_request: string, _reason: RequestCut): void => {}, /** nothing of the reflow story is left: no reflow pending, no height held, none owed */ reflowIdle: (): void => {}, } let reflowIdleNow: () => boolean = () => true // set inside the store /** switchBoard's calls so far - each switch, and each cancel (a switch to the board on screen): the router tells the * second event of one navigation from a new one by nothing having switched since the first (App.tsx). */ let switchCalls = 0 export const switchCount = (): number => switchCalls /** No reflow pending, no height held, none owed. */ export const reflowIdle = (): boolean => reflowIdleNow() /** What a node waits for before its load settles (recorder.ts): a content frame that measures (renders a Doc) - a * settled height at its width now; any other frame (hi-fi, html, a device-sized one, content without a Doc) - its * ready; a frame not in the manifest - nothing: it is missing. */ export function settleRule(n: Node, manifest: Manifest | null): 'measure' | 'ready' | 'missing' { const f = n.missing ? undefined : manifest?.frames.find((x) => x.id === n.frame) if (!f) return 'missing' return f.contentWidth && n.sizeMode !== 'device' && measures(f.id) ? 'measure' : 'ready' } /** Did a measuring node open at a height already known (committed in _sizes.json, or measured earlier in this page)? */ export const knownHeight = (frameId: string, w: number): boolean => hasKnownHeight(frameId, Math.round(w)) /** What measureNode took: `fit` - the report was taken at the node's width `w` (applied, or its height already) - the * only one a settled report settles a node with; `width` - it adopted the Doc's own width `w`, and the next report is * the one; `held` - kept for when the node's reload lets go; `ignored` - not taken. */ export interface MeasureResult { took: 'ignored' | 'held' | 'width' | 'fit'; w?: number } const IGNORED: MeasureResult = { took: 'ignored' } // sidebar marks: the fetch in flight, and acknowledgements the server has not confirmed yet let marksSeq = 0 let marksTimer: ReturnType | undefined const pendingSeen: Record = {} let pendingEpoch = '' /** nodes (or 'stage') looked at before the first marks arrived (a link, a click, focus) - counted when they land */ const lookedEarly = new Set() /** a look at a node (or 'stage') whose new version was still loading: it completes when that version is up, * within ARMED_MS of the look (spec 26) */ const armed = new Map() const ARMED_MS = 10_000 /** The doc revision a node's document shows, in this epoch - -1 when unknown or from another one. */ /** "epoch:rev" stamps: does `a` show at least `b` (same epoch, revision as high)? */ const atLeast = (a: string | undefined, b: string) => { const [ea, ra] = (a ?? '').split(':'), [eb, rb] = b.split(':') return ea === eb && Number(ra) >= Number(rb) } /** The later of two waits (a new epoch replaces the old one's). */ const later = (a: string | undefined, b: string | undefined) => (!a || !b ? a ?? b : atLeast(a, b) ? a : b) export const shownRev = (n: Node, m: Marks): number => { const [epoch, rev] = (n.shown ?? '').split(':') return epoch === m.epoch && rev !== undefined && /^\d+$/.test(rev) ? Number(rev) : -1 } /** Why a board's last load failed, from the server (a parse error names its line) - for its toast. */ const loadErrors = new Map() const whyNot = (name: string) => { const e = loadErrors.get(name); return e ? ` - ${e}` : '' } /** sceneRows is the legacy shorthand for a plain rows layout. */ const effectiveLayout = (layout: BoardLayout | null, sceneRows: string[][] | null): BoardLayout | undefined => layout ?? (sceneRows?.length ? { rows: sceneRows.map((r) => [...r]) } : undefined) /** Every scene of the project, empty phase scenes included - a layout naming one is no typo (#18). * A published bundle carries the empty scenes its recipes name, so it lays out as dev does. */ const knownScenes = (m: Manifest | null): ReadonlySet => new Set(m?.scenes.map((s) => s.name)) const layoutWarn = (msg: string) => { if (PUBLISHED) return // a viewer cannot fix a recipe - the owner sees it in dev (#18: empty phase scenes) console.warn('[marver layout]', msg) try { useStore.getState().toast(`layout: ${msg}`) } catch { /* store not ready during boot */ } } interface State { manifest: Manifest | null nodes: Node[] // append-only order; NEVER sorted or reordered (iframe law G-1) selection: string[] // ordered; last entry is the primary (bar anchor) interact: string | null viewTheme: string // the global theme preference; sticky across boards and reloads /** Play mode; at = current frame id. `focus` = the single-frame view (the * fourth mode): no walking, doc reading preset when the board type is doc. * `deep` = entered by a frame deep link - the chrome then carries no board * name and no canvas door (presentation default of the link, 04 §2.35). */ play: { at: string; device: string; theme: string; focus?: boolean; deep?: boolean; slides?: boolean } | null gesture: boolean // a frame drag/resize is in progress - canvas panning is disabled /** the node a person resized last (a new object each time) - a measurement never sets it */ resized: { key: string } | null laser: boolean // laser mode: a pointer - the element under the cursor lit, a click copies its path laserInspect: boolean // the laser as inspect (⌥L): every element outlined, as the laser was before 0.26.1 board: string // active board name; UNPLACED is the Not on a board view boardAuto: boolean // auto boards gain new frames on arrival; curated boards never do deviceView: string | null // board-wide device preview (viewport name), null = free-form layout sceneRows: string[][] | null // LEGACY scene arrangement; still round-trips through save layout: BoardLayout | null // lane-flow recipe, parsed; wins over sceneRows when both exist layoutRaw: unknown // the author's layout VERBATIM - save round-trips this, never the parse baseLayout: Record | null // snapshot taken on entering a device view; Default restores it exactly (auto content entries carry positions only - their sizes are measured) laidOut: LaidOut | null // a composed board's record of the Doc heights its positions were laid out around (sizeRecord) baseLaidOut: LaidOut | null // the free-form layout's laidOut, kept with baseLayout while a device view is up; Default restores both panelOpen: boolean scale: number toasts: Toast[] boardHash: string | null // sha256 of the board file on disk, when materialized dirty: boolean // Calm updates (spec 26, transient): an edit marks its frames' nodes stale; a stale node reloads // through the boot queue once it is on screen and not in use - a frame the user is actively using // (interact/play target, mid-gesture, laser/comment engaged) is "leased" and waits for a safe point. externalLeases: Record // nodeKey -> transient engagement playUpdateRevision: string | null // a revision arrived while play is open /** the frame the play/focus stage committed last, and the revision it shows (sh:stage-shown) */ stageShown: { at: string; shown: string } | null playNav: number // bumps to reload the play stage on demand pathPulse: number // bumps on each successful path copy - flashes the toolbar icon into a check imagePulse: number // bumps on each successful image copy - same flash, the images-square icon imageBusy: boolean // a copy-as-image render is in flight (one at a time) /** (re)load the open board from disk. `mayCommit`, when given, is asked again at the moment the * reload would land - a caller's own conditions (a status write's: nothing moved since its flush) */ boot(mayCommit?: () => boolean): Promise applyManifest(m: Manifest): void frameFor(node: Node): FrameEntry | undefined moveNode(key: string, x: number, y: number): void resizeNode(key: string, w: number, h: number): void /** `settled`: the Doc's fonts, images and diagrams are done (content/index.tsx). false = provisional * (an image still loading) - it may grow a frame, never shrink it under a known height. */ measureNode(key: string, frameId: string, ownWidth: number, measuredWidth: number, height: number, settled?: boolean): MeasureResult /** A node's sticky column was drawn (or grew, or went): its extent from the node's top, world px. */ noteMeasured(key: string, height: number): void setStatus(key: string, status: Node['status'], error?: string): void bumpRev(key: string): void setThemeOn(key: string, theme: string): void reloadFrame(key: string, automatic?: boolean): void removeNode(key: string): void select(key: string | null, additive?: boolean): void selectMany(keys: string[]): void selectAll(): void setInteract(key: string | null): void setPlay(p: State['play']): void setGesture(g: boolean): void setLaser(on: boolean, inspect?: boolean): void /** L (`inspect` false) or ⌥L (true): the same mode again turns the laser off; the other switches to it */ toggleLaser(inspect: boolean): void invalidateFrames(frameIds: string[], revision: string, docs?: { epoch?: string; docs?: Record }): void setExternalLease(nodeKey: string, reason: 'laser' | 'comment', on: boolean): void /** A safe point (a lease ended, a gesture ended): stale nodes may reload now. */ flushFrameUpdates(): void applyPlayUpdate(): void moveSelectedBy(dx: number, dy: number, starts: Record): void setSelectedTheme(theme: string): void setDeviceView(name: string | null): void resizeSelected(name: string | null): void switchBoard(name: string): Promise /** what the sidebar labels boards by: slug → title, off the last tree read */ boardTitles: Record /** what the sidebar draws beside a board: its type and status (spec 20), off the last tree read */ boardMeta: Record /** retitle a board - what humans see ('' = back to the Title-Cased slug). The file never * moves: its name is the board's identity (agents, publish.json, URLs, comment threads). * `baseHash` = the file as last seen; a 409 (`stale`) means someone wrote it since. */ renameBoard(name: string, title: string, baseHash?: string): Promise<{ ok: true } | { ok: false; stale?: boolean; error?: string }> /** a board's status, decided by a person (the sidebar's picker): one of its `settable` statuses, * or null to clear the decision (back to the evidence); blocked carries its reason. Only * `status` and `reason` change in the file; a 409 (`stale`) means someone wrote it since. */ setBoardStatus(name: string, status: StatusWord | null, reason?: string, baseHash?: string): Promise<{ ok: true } | { ok: false; stale?: boolean; error?: string }> /** a scene's title, into its brief's front matter (the directory never moves) */ renameScene(scene: string, title: string): Promise<{ ok: boolean; error?: string }> /** `sh:scenes`: the scenes changed (a brief's title or description) with the frames intact */ setScenes(scenes: Manifest['scenes']): void arrangeBoards(tree: TreeItem[], base: TreeBase): Promise<{ ok: true } | { ok: false; stale: boolean; error?: string }> pulsePath(): void copyFrameImage(scale: 2 | 4): void setScale(s: number): void togglePanel(): void setTheme(theme: string): void runTidy(): void toast(text: string): void jamToast(note: JamNote): void /** a jam card failed - one notice, with Retry (spec 27: only a question or a failure interrupts) */ jamFailed(cards: { id: string; rev: number; board: string; thread?: string; frames: string[]; note?: string; batch?: string }[]): void dismissToast(id: number): void /** View on a notice: a round's notice moves to its next reply, any other one goes. */ advanceToast(id: number): void clearJamToasts(): void /** Frames Marver is editing right now (Live Jam presence) - drives the working glow. */ working: string[] /** When each working frame's job started (ms epoch) - phases the animations so parallel * frames never pulse in sync. */ workingSince: Record setWorking(frames: string[], boards?: string[]): void /** setWorking from a wire payload (`{ frames, boards }`), strings only. */ setWorkingFrom(m: unknown): void /** Fetch what is lit now (dev only) - the snapshot behind the broadcasts. */ syncWorking(): Promise /** Sidebar marks (spec 25): what changed since you looked - null on a published canvas. */ marks: Marks | null syncMarks(): Promise /** Mark all as seen: clear these frames' "new", here and on disk, whatever is loaded. */ markSeen(frameIds: readonly string[]): void /** You looked at these nodes (or 'stage'): each clears its frame's "new" up to the revision it shows - * one whose new version is not up yet waits for it (`rearm` false: a check, not a new look). */ look(keys: readonly string[], rearm?: boolean): void /** A node's document reported the revision it shows (sh:ready). */ setShown(key: string, shown: string): void /** A stale node's turn: it reloads now, its height held until it settles (canvas/updates.ts). */ startReload(key: string): void /** Boards showing a working frame - their sidebar icon shimmers while an agent works. */ workingBoards: string[] /** The place in the server's order of the activity last applied - an older answer is dropped. */ workingSeq: number spawn(frameId: string): Node | null /** The Not on a board view follows its list in place: frames that left go, new ones join - the * frames that stay keep their documents (no reload, no lost input). */ syncUnplaced(ids: readonly string[]): void save(): Promise } /** A6: is any node of this frame in active use, so a hot update must DEFER? Frame-scoped - * if the same frame is placed twice and one copy is active, both stay on the same revision. */ export function frameIsLeased(frameId: string, s: State): boolean { return s.nodes.some((n) => n.frame === frameId && n.status !== 'error' && ( s.interact === n.key || (s.gesture && s.selection.includes(n.key)) || !!s.externalLeases[n.key])) } /** What a board save says when the board's file changed on disk meanwhile: the file wins, the board is read again. */ export const DISK_WON = 'board changed on disk - canvas layout reloaded' /** What a board save says when the board was renamed or removed meanwhile: it is not created again. */ export const BOARD_GONE = 'this board was renamed or removed - not recreating it' export const useStore = create((set, get) => { let saveTimer: ReturnType | undefined let saveChain: Promise = Promise.resolve(true) // saves are serialized; responses only commit if the board is still active let editRev = 0 // bumps per edit; a stale save response may never clear dirty over a newer edit let loadSeq = 0 // stale boot() responses never overwrite a newer board let switchSeq = 0 // last click wins when board switches race let firstLoad = true // the page's first boot() is its boot; every later one reads the board from disk again // the recorder's requests in flight (spec 28 A1.7): a newer boot, a newer switch, or a switch that commits supersedes // them the moment it does - not when their fetch answers, which may be never let bootRequest = '', switchRequest = '' // While a board file is being renamed or rewritten by a structural write (rename, the // sidebar's tree arrange), autosave must not fire against it (a mustExist PUT would // 409-gone and clear dirty, losing the in-flight edit; a stale baseHash would 409 and // reload). A COUNT, not a flag: two holders never release each other. Structural writes // also run one at a time through `structChain`, so a rename can never interleave a // tree write on the same file. let saveHolds = 0 const holdSaves = () => { saveHolds++; clearTimeout(saveTimer) } const releaseSaves = () => { if (--saveHolds === 0 && get().dirty) scheduleSave() } let structChain: Promise = Promise.resolve() const structural = (fn: () => Promise): Promise => { const p = structChain.then(fn, fn); structChain = p.catch(() => {}); return p } const scheduleSave = () => { editRev++; if (saveHolds > 0) return; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) } // One cancelable, BOARD-SCOPED reflow after content measurements settle. // The captured board name is the generation guard - a debounce surviving a board // switch fires into a name check and dies, never touching the new board. // `onlyIf` (a note asking for room) is re-judged when the timer fires - a drag or a restore // in the meantime may have settled it - and never downgrades an unconditional reflow pending; // two conditions pending are EITHER one (a later ask never drops an earlier one). let reflowTimer: ReturnType | undefined let reflowCheck: (() => boolean) | null = null /** Why the pending reflow runs (spec 28 A1.7) - merged across the calls it coalesces, kept through a drag, a camera * in flight and held heights: the recorder names its moves `reflow:`. */ type ReflowReason = 'measured' | 'cramped' | 'stale' | 'gesture-end' let reflowReasons = new Set() /** A board load (boot, switch) starts its own reflow story: whatever was pending - an * unconditional reflow from the board that was up - must never carry over and tidy this one. */ const cancelReflow = () => { clearTimeout(reflowTimer); reflowTimer = undefined; reflowCheck = null; reflowReasons = new Set() } /** How long a content reflow waits for a camera in flight - past it, the rows move anyway. */ const REFLOW_CAMERA_WAIT = 1500 const scheduleReflow = (reason: ReflowReason | null, onlyIf?: () => boolean, delay = 400, cameraSince?: number) => { if (reason) reflowReasons.add(reason) const boardAt = get().board const prev = reflowTimer !== undefined ? reflowCheck : undefined // undefined: nothing pending reflowCheck = prev === null || !onlyIf ? null : prev ? () => prev() || onlyIf() : onlyIf clearTimeout(reflowTimer) reflowTimer = setTimeout(() => { reflowTimer = undefined const check = reflowCheck reflowCheck = null const s = get() if (s.board !== boardAt) { reflowReasons = new Set(); reflowSettled(); return } // defer, never drop - retries after the drag if (s.gesture) { scheduleReflow('gesture-end', check ?? undefined); return } // and after a camera flight: a link's fit lands first, then the rows move around the frames it // showed (holdView keeps them where the fit put them). Looked at often, so the reflow follows the // landing closely - and never waited on past REFLOW_CAMERA_WAIT: a camera that keeps moving (fit // after fit) does not hold the rows apart forever const since = cameraSince ?? Date.now() if (canvasCtl.cameraBusy() && Date.now() - since < REFLOW_CAMERA_WAIT) { scheduleReflow(null, check ?? undefined, 60, since); return } if (check && !check()) { reflowReasons = new Set(); reflowSettled(); return } for (const k of [...holds.keys()]) if (!s.nodes.some((n) => n.key === k && !n.missing)) letGo(k, false) // gone: no longer in the batch if (holds.size) { reflowOwed = true; return } // a batch is reloading: the rows move once, when it lets go (its reasons kept) const why = `reflow:${[...reflowReasons].sort().join('+') || 'measured'}` reflowReasons = new Set() // content moved the rows, not the human: hold what they are looking at still (Canvas.tsx) if (composed(s)) withCause(why, () => { canvasCtl.holdView(); s.runTidy() }) reflowSettled() }, delay) } /** Boards whose layout the shell owns: a recipe, scene rows, or the auto board. Room for a * note is made here; a board the human placed by hand is never moved (spec 18, 0.19.1). */ const composed = (s: { layout: BoardLayout | null; sceneRows: string[][] | null; boardAuto: boolean }) => !!(s.layout || s.sceneRows?.length || s.boardAuto) const cramped = () => { const s = get(); return !!s.manifest && notesCramped(s.nodes, s.manifest) } // Calm updates (spec 26): a node reloading keeps its height until its frame says it has settled // (or HOLD_MS pass); a provisional measure in either direction waits, the last one is kept. The // board reflows once, when the last held node lets go - never once per frame of a burst. const HOLD_MS = 4000 const holds = new Map; at: number; last?: Parameters }>() let reflowOwed = false holdHeight = (key) => { const was = holds.get(key) if (was) clearTimeout(was.timer) holds.set(key, { timer: setTimeout(() => letGo(key, true), HOLD_MS), at: performance.now() }) } const letGo = (key: string, apply: boolean) => { const h = holds.get(key) if (!h) return clearTimeout(h.timer) holds.delete(key) if (apply && h.last) get().measureNode(...h.last) if (!holds.size && reflowOwed) { reflowOwed = false; scheduleReflow(null) } else reflowSettled() } const dropHolds = () => { for (const h of holds.values()) clearTimeout(h.timer); holds.clear(); reflowOwed = false } /** A content measure moved the rows: once, after the last held node lets go. */ const reflowAfterHolds = () => { reflowReasons.add('measured'); if (holds.size) reflowOwed = true; else scheduleReflow('measured') } /** The recorder hears when nothing of the reflow story is left (spec 28 A1.7): no timer, no held height, none owed. */ reflowIdleNow = () => reflowTimer === undefined && !holds.size && !reflowOwed const reflowSettled = () => { if (reflowIdleNow()) recorderHooks.reflowIdle() } // Marks (spec 26): a node that shows an older revision than its frame's `doc` has missed an update // (an event lost while disconnected, an edit that landed while it loaded): it goes stale. The // snapshot is the truth; events only make it faster. reconcile = () => { const s = get(), m = s.marks if (!m) return let any = false const nodes = s.nodes.map((n) => { if (n.stale || n.status !== 'ready' || n.missing || !n.shown) return n if (shownRev(n, m) >= (m.doc[n.frame] ?? 0)) return n any = true return { ...n, stale: true, want: later(n.want, `${m.epoch}:${m.doc[n.frame] ?? 0}`) } }) if (any) { set({ nodes }); pumpUpdates() } // the stage too: one that missed an update (a lost event) offers Update const st = s.stageShown, f = st?.at if (s.play && st && f && !s.playUpdateRevision) { const [e, r] = st.shown.split(':') if (e !== m.epoch || Number(r) < (m.doc[f] ?? 0)) set({ playUpdateRevision: 'missed' }) } } /** Acknowledge these revisions: here at once, and on disk - refused there from another epoch. */ const ack = (seen: Record) => { const m = get().marks if (!m || !Object.keys(seen).length) return Object.assign(pendingSeen, seen) set({ marks: { ...m, seen: { ...m.seen, ...seen } } }) void postOwner('marks/seen', { epoch: m.epoch, frames: seen }) // kept once confirmed too (revisions only ever rise): an answer that left before this one cannot undo it .then((r) => { if (!r.ok) throw new Error(String(r.status)) }) .catch(() => { for (const [id, at] of Object.entries(seen)) if (pendingSeen[id] === at) delete pendingSeen[id]; void get().syncMarks() }) } /** A note may have landed with no room (a frame note via the manifest, a scene note via * sh:scenes, either merged late by boot/switch): a composed board re-applies its layout. */ const roomForNotes = () => { if (composed(get()) && cramped()) scheduleReflow('cramped', cramped) } /** A composed board's saved positions were laid out around the heights of the session that saved * them. A committed height that changed since (the doc grew or shrank while another board was * open) opens at its new size over - or far above - the row below, and no measurement will re-flow * it: it already equals the committed one. So once the board is up, the recipe re-runs if the * known heights differ from the ones its positions were laid out around (`laidOut`). A frame the * human dragged, at heights that did not change, stays where they put it. */ const layoutStale = () => { const { nodes, laidOut } = get() const record = laidOut ?? {} const known = (n: Node) => n.sizeMode === 'auto' && hasKnownHeight(n.frame, Math.round(n.w)) const has = (n: Node) => Object.prototype.hasOwnProperty.call(record, n.key) // a node the layout recorded, at another size now if (nodes.some((n) => known(n) && has(n) && sizeOf(n) !== record[n.key])) return true // a node it never recorded (an agent's board, an older save, a Doc that had not measured yet): // an overlap is the one thing a size change does that a hand never meant const box = (n: Node) => ({ l: n.x, t: n.y, r: n.x + n.w, b: n.y + n.h + HEADER }) return nodes.some((a) => { if (!known(a) || has(a)) return false const A = box(a) return nodes.some((b) => { if (b === a) return false; const B = box(b); return A.l < B.r - 1 && B.l < A.r - 1 && A.t < B.b - 1 && B.t < A.b - 1 }) }) } const recheckLayout = () => { const s = get() if (composed(s) && s.nodes.some((n) => n.sizeMode === 'auto' && hasKnownHeight(n.frame, Math.round(n.w)))) scheduleReflow('stale', layoutStale) } /** Theme resolution ladder: user pin > the frame's declared meta.theme > viewTheme. */ const resolveTheme = (frame?: FrameEntry, user?: string) => user ?? frame?.theme ?? get().viewTheme /** Published builds are read-only on disk, so pins persist to sessionStorage instead - * parity with dev within a visit (keyed by frame id: virtual boards mint node keys * per load, frame ids are stable). */ const sessionPins = { read(board: string): Record { try { return JSON.parse(sessionStorage.getItem(`mv-pins-${board}`) ?? '{}') } catch { return {} } }, write(board: string, nodes: Node[]) { try { const pins: Record = {} for (const n of nodes) if (n.themeUser) pins[n.frame] = n.themeUser sessionStorage.setItem(`mv-pins-${board}`, JSON.stringify(pins)) } catch { /* storage unavailable */ } }, } /** Published parity for content-frame sizes: manual/device sizes persist * to the session the same way theme pins do - a board switch or reload on a * published canvas must not silently drop a hand-resized spec frame. Keys are * frame#occurrence: node keys are minted per load on virtual boards, but the node * ORDER round-trips (board file order / manifest order), so the ordinal is the * stable identity - duplicates of one frame keep their individual sizes. */ const sizeKey = (nodes: Node[], node: Node) => `${node.frame}#${nodes.filter((n) => n.frame === node.frame).indexOf(node)}` const sessionSizes = { read(board: string): Record { try { return JSON.parse(sessionStorage.getItem(`mv-sizes-${board}`) ?? '{}') } catch { return {} } }, write(board: string, nodes: Node[]) { try { const sizes: Record = {} for (const n of nodes) if (n.sizeMode === 'manual' || n.sizeMode === 'device') sizes[sizeKey(nodes, n)] = { w: Math.round(n.w), h: Math.round(n.h), sizeMode: n.sizeMode } sessionStorage.setItem(`mv-sizes-${board}`, JSON.stringify(sizes)) } catch { /* storage unavailable */ } }, } /** Fetch + normalize a board into ready-to-commit state, WITHOUT touching the store - nor the sizes the * mounted board reads: those it read come back with it, for the commit to install (installSizes). * null = failure (transport, malformed manifest, non-404 board error) - the caller * keeps whatever board is currently mounted. */ const loadBoardState = async (boardName: string): Promise<{ state: Partial; sizes: SizeRead | null } | null> => { clearNoteHeights() // heights are per column drawn; a key shared by two board files carries none across loadErrors.delete(boardName) try { let raw: any const saved = readSizes() // in parallel with the manifest; every size below reads it if (DATA) raw = DATA.manifest else { const mRes = await mvFetch('/design/manifest.json') if (!mRes.ok) return null raw = await mRes.json().catch(() => undefined) } const sizes = await saved if (raw === undefined || raw === null || typeof raw !== 'object') return null bumpManifestRev() // fresh manifest → fresh iframe URLs const manifest: Manifest = { frames: (Array.isArray(raw?.frames) ? raw.frames : []) .filter((f: any) => f && typeof f.id === 'string' && typeof f.file === 'string'), scenes: Array.isArray(raw?.scenes) ? raw.scenes : [], } let nodes: Node[] = [] let boardHash: string | null = null let boardAuto = false let deviceView: string | null = null let sceneRows: string[][] | null = null let layout: BoardLayout | null = null let layoutRaw: unknown = undefined let baseLayout: State['baseLayout'] = null let laidOut: LaidOut | null = null let baseLaidOut: LaidOut | null = null let needTidy = false // published build: boards come from the inlined data; absent = fresh (the 404 path) let loaded: { board: any; sha256: string } | 'fresh' | null if (DATA) loaded = DATA.boards[boardName] ? { board: DATA.boards[boardName], sha256: 'published' } : 'fresh' // the Not on a board view: a board of the frames no board shows, never saved - laid out // fresh on every load (no hash) in one band of their scenes, its keys minted like any keyless node's else if (boardName === UNPLACED) { const m = await fetchMembership().catch(() => null) const scenes = [...new Set(manifest.frames.filter((f) => m?.unplaced.includes(f.id)).map((f) => f.scene))] loaded = m ? { board: { nodes: m.unplaced.map((frame) => ({ frame })), ...(scenes.length ? { layout: { rows: [scenes] } } : {}) }, sha256: '' } : null } else { const res = await api(`boards/${boardName}`) if (res.ok) { const body = await res.json() loaded = body?.board == null ? 'fresh' : body // board:null = not materialized yet } else if (res.status === 404) loaded = 'fresh' // older servers still 404 fresh boards else { loadErrors.set(boardName, (await res.json().catch(() => null))?.error ?? ''); loaded = null } // anything else must not commit an empty canvas - and says why (#6) } if (loaded === null) return null // from here to the return nothing is awaited: the sizes this load read are the ones its nodes are made from, // and no other code runs meanwhile to read them in place of the mounted board's preparing = sizes if (loaded !== 'fresh') { const { board, sha256 } = loaded boardHash = sha256 if (typeof board?.auto === 'boolean') boardAuto = board.auto // Agent-authored boards may be just a frame list - fill sizes/keys, tidy on first load. // Keys are load-bearing (iframe identity, selection, COMMENT ANCHORS) - dupes/blanks // get minted DETERMINISTICALLY (keys.ts): a random key here orphaned every comment // created on a never-saved board the moment it reloaded. // Board nodes whose file is gone stay, flagged - deletion is surfaced, never silent. const seenKeys = new Set() const ordinals = new Map() // per-frame keyless-occurrence counter nodes = (Array.isArray(board?.nodes) ? board.nodes : []) .filter((n: any) => n && typeof n.frame === 'string') .map((n: any) => { const f = manifest.frames.find((x) => x.id === n.frame) const d = f ? defaultSize(f) : { w: 390, h: 844 } if (typeof n.x !== 'number' || typeof n.y !== 'number') needTidy = true let key = typeof n.key === 'string' && n.key ? n.key : '' if (!key || seenKeys.has(key)) { let salt = ordinals.get(n.frame) ?? 0 do { key = stableNodeKey(boardName, n.frame, salt++) } while (seenKeys.has(key)) ordinals.set(n.frame, salt) } seenKeys.add(key) // content-frame size provenance round-trips; auto nodes saved no // w/h, so they fall to defaultSize (placeholder) and remeasure on mount const sizeMode = f?.contentWidth ? { sizeMode: (n.sizeMode === 'manual' || n.sizeMode === 'device' ? n.sizeMode : 'auto') as Node['sizeMode'] } : {} // an AUTO content node whose height is KNOWN (measured, or committed - sizes.ts) opens at // it: a w/h in the file is a guess (an agent wrote the node) or a pre-auto leftover the // first measurement overrides anyway - opening at it only to jump is the swim. Unknown // (a frame that never measures: no Doc) keeps the file's size, as before const own = sizeMode.sizeMode === 'auto' && !!f && hasKnownHeight(f.id, d.w) return { key, frame: n.frame, x: typeof n.x === 'number' ? n.x : 0, y: typeof n.y === 'number' ? n.y : 0, w: !own && typeof n.w === 'number' ? n.w : d.w, h: !own && typeof n.h === 'number' ? n.h : d.h, ...(!f && typeof n.w !== 'number' && typeof n.h !== 'number' ? { sizeFallback: true } : {}), ...sizeMode, // pins persist as their own field (exact round-trip). Legacy boards stored a // theme on EVERY node: only values differing from the frame's static default // were deliberate - the rest follow viewTheme ...(() => { const stored = typeof n.theme === 'string' ? n.theme : undefined const themeUser = typeof n.themeUser === 'string' && CONFIG.themes.includes(n.themeUser) ? n.themeUser : stored && stored !== (f?.theme ?? CONFIG.themes[0] ?? 'light') ? stored : undefined return { theme: resolveTheme(f, themeUser), themeUser } })(), status: 'loading' as const, missing: !manifest.frames.some((f2) => f2.id === n.frame), } }) // device view and the free-form snapshot survive reloads - independently: // a scoped "exception" clears deviceView but the snapshot must keep restoring if (typeof board?.deviceView === 'string' && CONFIG.viewports[board.deviceView]) deviceView = board.deviceView if (Array.isArray(board?.sceneRows)) { const rows = board.sceneRows .map((r: unknown) => (Array.isArray(r) ? r.filter((x: unknown) => typeof x === 'string') : [])) .filter((r: string[]) => r.length) if (rows.length) sceneRows = rows } layoutRaw = board?.layout layout = parseLayout(board?.layout, layoutWarn) if (layout && sceneRows) layoutWarn('board has layout AND sceneRows - layout wins') if (board?.baseLayout && typeof board.baseLayout === 'object') baseLayout = board.baseLayout laidOut = readLaidOut(board?.laidOut) baseLaidOut = readLaidOut(board?.baseLaidOut) } // auto-managed goes both ways (friction log #15): an auto board gains new frames // AND sheds deleted ones. Tombstone cards are a curated-board concept. // Pruning at load DIRTIES the board (dev only) so the file on disk sheds the // tombstones too - otherwise a recreated frame id resurrects its stale node. let prunedAtLoad = false if (boardAuto) { const before = nodes.length nodes = nodes.filter((n) => !n.missing) prunedAtLoad = !DATA && nodes.length !== before } // frames not on the board yet → auto boards only (a curated board shows exactly its list). // Grouped newcomers land beside their siblings (like the live JOIN); ungrouped ones // go past the board's right edge (the live JOIN flows them below) - NEVER at (0,0) // (the old published everything-board bug: frames created after the board file was last // saved piled up at the origin) if (boardAuto) { const placed = new Set(nodes.map((n) => n.frame)) // newcomers get deterministic keys too - on a published auto board // this path mints EVERY key, every visit, and comments anchor to them const haveKeys = new Set(nodes.map((n) => n.key)) const mint = (frame: string) => { let salt = 0, k = stableNodeKey(boardName, frame, salt) while (haveKeys.has(k)) k = stableNodeKey(boardName, frame, ++salt) haveKeys.add(k) return k } let maxX = nodes.reduce((a, n) => Math.max(a, n.x + n.w), 0) for (const f of manifest.frames) { if (placed.has(f.id)) continue placed.add(f.id) const d = defaultSize(f) const vp = deviceView ? CONFIG.viewports[deviceView] : null const w = vp?.width ?? d.w, h = vp?.height ?? d.h let x: number, y: number const sibling = f.variantGroup ? nodes.filter((n) => manifest.frames.find((m) => m.id === n.frame)?.variantGroup === f.variantGroup) .reduce((a, n) => (!a || n.x + n.w > a.x + a.w ? n : a), null) : null if (sibling) { x = sibling.x + sibling.w + Math.max(140, sibling.w * 0.12); y = sibling.y } else { x = nodes.length ? maxX + 96 : 0; y = 0 } const node: Node = { key: mint(f.id), frame: f.id, x, y, w, h, theme: resolveTheme(f), status: 'loading', ...(f.contentWidth ? { sizeMode: (vp ? 'device' : 'auto') as Node['sizeMode'] } : {}) } nodes.push(node) maxX = Math.max(maxX, x + w) } } // published: re-apply this visit's pins and content sizes over the inlined data if (DATA && nodes.length) { const pins = sessionPins.read(boardName) const sizes = sessionSizes.read(boardName) for (const n of nodes) { const pin = pins[n.frame] if (pin && CONFIG.themes.includes(pin)) { n.themeUser = pin; n.theme = pin } const sz = sizes[sizeKey(nodes, n)] if (sz && manifest.frames.find((f) => f.id === n.frame)?.contentWidth && Number.isFinite(sz.w) && Number.isFinite(sz.h) && (sz.sizeMode === 'manual' || sz.sizeMode === 'device')) { n.w = sz.w; n.h = sz.h; n.sizeMode = sz.sizeMode } } } // a note that landed while this board was closed has no room in the saved positions: // a board with a recipe re-applies it (spec 18 - room is the layout's job) const cramped = !!boardHash && !needTidy && !!(layout || sceneRows?.length || boardAuto) && notesCramped(nodes, manifest) if ((!boardHash || needTidy || cramped) && nodes.length) { const placedAll = tidy(tidyInput(nodes, manifest), effectiveLayout(layout, sceneRows), layoutWarn, knownScenes(manifest)) for (const pl of placedAll) { const n = nodes.find((x) => x.key === pl.key)!; n.x = pl.x; n.y = pl.y } laidOut = sizeRecord(nodes) } // dirty matches disk by construction - except when load-time pruning changed the // node set (or a cramped note re-ran the recipe); callers see dirty:true and // schedule the save that persists it // surface recipe problems at load (dry-run): materialized boards otherwise // never run tidy, so a broken agent-authored layout would fail silently if (layout && boardHash && !needTidy && !cramped && nodes.length) { tidy(tidyInput(nodes, manifest), layout, layoutWarn, knownScenes(manifest)) } return { state: { manifest, nodes, boardHash, boardAuto, deviceView, sceneRows, layout, layoutRaw, baseLayout, laidOut, baseLaidOut, selection: [], dirty: prunedAtLoad || cramped }, sizes } } catch { return null } finally { preparing = null } } return { manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, resized: null, laser: false, laserInspect: false, board: DATA?.default ?? UNPLACED, boardAuto: false, deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null, laidOut: null, baseLaidOut: null, panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, workingBoards: [], workingSeq: -1, boardHash: null, dirty: false, boardTitles: DATA?.titles ?? {}, boardMeta: DATA?.meta ?? {}, externalLeases: {}, playUpdateRevision: null, stageShown: null, playNav: 0, pathPulse: 0, imagePulse: 0, imageBusy: false, async boot(mayCommit) { const seq = ++loadSeq recorderHooks.cut(bootRequest, 'superseded') const boardName = get().board // the recorder's request (spec 28 A1.7): the page's first load, else the open board read from disk again const request = bootRequest = recorderHooks.request(boardName, firstLoad ? 'boot' : 'disk-reload') firstLoad = false const revAtStart = editRev, scenesAtStart = scenesRev const loaded = await loadBoardState(boardName) if (seq !== loadSeq) { recorderHooks.cut(request, 'superseded'); return false } // a newer load superseded this one if (!loaded) { recorderHooks.cut(request, 'error'); get().toast(`board "${boardName}" failed to load${whyNot(boardName)}`); return false } // the user kept editing while we fetched - their newer state wins over the reload if (get().board !== boardName || editRev !== revAtStart || (mayCommit && !mayCommit())) { recorderHooks.cut(request, 'edited'); return false } const next = loaded.state const live = get().manifest // a WS manifest update may have landed mid-fetch recorderHooks.handover(request) // the load that was up ends by its own sizes installSizes(loaded.sizes) // the sizes this load read are the page's from its commit recorderHooks.commit(request, next.nodes as Node[], next.manifest as Manifest) // before the set: mounting is not a move set(next) cancelReflow(); dropHolds(); reloadCause.clear() if (next.dirty) scheduleSave() // load-time prune must reach the disk if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live) else if (scenesRev !== scenesAtStart && liveScenes) set({ manifest: { ...get().manifest!, scenes: liveScenes } }) // an sh:scenes that landed mid-fetch outranks the file we read roomForNotes() // whichever way the notes arrived, they get their room recheckLayout() // and a height committed since the board was laid out, its rows return true }, async switchBoard(name) { const mySwitch = ++switchSeq // also cancels any pending switch (incl. re-clicking the current board) switchCalls++ recorderHooks.cut(switchRequest, 'superseded') const scenesAtStart = scenesRev if (name === get().board) return // the recorder's request (spec 28 A1.7): the wait starts here, the flush included; the board still mounted // owns the geometry until this one commits const request = switchRequest = recorderHooks.request(name, 'switch') // a resize held across a switch: settle THIS board (retidy if it has a recipe) // before the flush, so the torn mid-gesture state is never what reaches disk if (resizedInGesture) { resizedInGesture = false if (get().layout || get().sceneRows?.length) withCause('resize', () => get().runTidy()) } // flush loop: an edit landing mid-save keeps dirty set and gets its own pass let ok = true for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() } if (mySwitch !== switchSeq) return recorderHooks.cut(request, 'superseded') if (get().dirty) { recorderHooks.cut(request, 'unsaved'); get().toast('current board could not be saved - staying here'); return } // load the target while the current board stays mounted; commit atomically on success - // there is never an empty intermediate state and nothing to roll back const loaded = await loadBoardState(name) if (mySwitch !== switchSeq) return recorderHooks.cut(request, 'superseded') if (!loaded) { recorderHooks.cut(request, 'error'); get().toast(`could not load "${name}"${whyNot(name)} - staying on ${get().board}`); return } const next = loaded.state // edits may have landed on the still-mounted old board during the target load for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() } if (mySwitch !== switchSeq) return recorderHooks.cut(request, 'superseded') if (get().dirty) { recorderHooks.cut(request, 'unsaved'); get().toast('current board could not be saved - staying here'); return } clearTimeout(saveTimer) // a scheduled-but-clean timer must not fire against the new board ++loadSeq // invalidate any in-flight boot of the old board recorderHooks.cut(bootRequest, 'superseded') const live = get().manifest // a WS manifest update may have landed mid-load recorderHooks.handover(request) // the load that was up ends by its own sizes installSizes(loaded.sizes) // the sizes this load read are the page's from its commit recorderHooks.commit(request, next.nodes as Node[], next.manifest as Manifest) // before the set: mounting is not a move set({ board: name, interact: null, ...next }) cancelReflow(); dropHolds(); reloadCause.clear() if (next.dirty) scheduleSave() // load-time prune must reach the disk if (live && manifestKey(live) !== manifestKey(next.manifest as Manifest)) get().applyManifest(live) else if (scenesRev !== scenesAtStart && liveScenes) set({ manifest: { ...get().manifest!, scenes: liveScenes } }) roomForNotes() recheckLayout() }, renameBoard(name, title, baseHash) { return structural(async () => { const from = name const active = from === get().board // Retitling the ACTIVE board rewrites its file under the autosave. Flush any pending // write FIRST (switchBoard's pattern), so a PUT never races the rewrite. if (active) { let ok = true for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() } if (get().dirty) return { ok: false, error: 'unsaved changes - try again' } // hold autosave across the round-trip: an edit landing mid-write would PUT against a // hash the rewrite is about to move holdSaves() } const rev = editRev // setBoardStatus's guard on the reload const mayReload = () => active && get().board === from && editRev === rev && !get().gesture try { // the active board's hash is freshest in the store (an autosave may have landed since // the sidebar looked); every other board's is the sidebar's const send = (base: string | null | undefined) => postOwner('boards/rename', { from, title, ...(base ? { baseHash: base } : {}) }) let res: Response try { res = await send(active && get().boardHash ? get().boardHash : baseHash) // the open board changed on disk: reload it, then once more (setBoardStatus's reasoning) if (res.status === 409 && mayReload() && await get().boot(mayReload)) res = await send(get().boardHash) } catch { return { ok: false, error: 'could not reach the dev server' } } const body = await res.json().catch(() => ({} as { error?: string; sha256?: string })) if (!res.ok) return { ok: false, stale: res.status === 409, error: body?.error ?? `rename failed (${res.status})` } // the write rewrote the file: the active board's CAS token moves to the hash the server // answered (else the next autosave 409s against our own write) - before releasing the hold if (active && get().board === from && body?.sha256) set({ boardHash: body.sha256 }) const titles = { ...get().boardTitles } // the label changes now; the next tree read confirms it from the file if (title) titles[from] = title; else delete titles[from] set({ boardTitles: titles }) return { ok: true } } finally { if (active) releaseSaves() } // whatever happened, autosave resumes }) }, setBoardStatus(name, status, reason, baseHash) { return structural(async () => { const active = name === get().board // the active board's file is rewritten under the autosave: flush first, hold across (renameBoard's pattern) if (active) { let ok = true for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() } if (get().dirty) return { ok: false, error: 'unsaved changes - try again' } holdSaves() } // what a reload may replace: this board as flushed - holdSaves pauses saving, not editing, so an // edit, a drag or a board switch while the request is out means the reload is not ours to do const rev = editRev const mayReload = () => active && get().board === name && editRev === rev && !get().gesture try { const send = (base: string | null | undefined) => postOwner('boards/status', { name, status, ...(reason ? { reason } : {}), ...(base ? { baseHash: base } : {}) }) let res: Response try { res = await send(active && get().boardHash ? get().boardHash : baseHash) // the open board changed on disk (an agent wrote it): its hash in the store is behind, and a // retry with it would 409 again - reload the board (layout and hash together) when nothing // has changed here since the flush, then try once more against what is on disk now if (res.status === 409 && mayReload() && await get().boot(mayReload)) res = await send(get().boardHash) } catch { return { ok: false, error: 'could not reach the dev server' } } const body = await res.json().catch(() => ({} as { error?: string; sha256?: string })) if (!res.ok) return { ok: false, stale: res.status === 409, error: body?.error ?? `status failed (${res.status})` } if (active && get().board === name && body?.sha256) set({ boardHash: body.sha256 }) // a decision shows at once; what the evidence says after a clear comes with the next tree read if (status) { const meta = { ...get().boardMeta } const cur = meta[name] ?? {} const row = ({ archived: 1, paused: 2, blocked: 3, 'in-progress': 5, building: 5, done: 6, todo: 8, backlog: 9 } as const)[status] // with context/, In progress and Building were written into the open plan, not the board const inPlan = (status === 'building' || status === 'in-progress') && !(cur.settable ?? []).includes('todo') const evidence = inPlan ? ['context/plans/: stage written from the sidebar'] : [`design/boards/${name}.json: "status": "${status}"`] meta[name] = { ...cur, status: { status, row, ...(status === 'blocked' && reason ? { reason } : {}), evidence } } set({ boardMeta: meta }) } return { ok: true } } finally { if (active) releaseSaves() } }) }, async renameScene(scene, title) { let res: Response try { res = await postOwner('scenes/rename', { scene, title }) } catch { return { ok: false, error: 'could not reach the dev server' } } if (!res.ok) { const e = await res.json().catch(() => ({} as { error?: string })); return { ok: false, error: e?.error ?? `rename failed (${res.status})` } } // the label changes now; the watcher's sh:scenes confirms it from the file const m = get().manifest if (m) set({ manifest: { ...m, scenes: m.scenes.map((s) => (s.name === scene ? { ...s, ...(title ? { title } : {}), ...(title ? {} : { title: undefined }) } : s)) } }) return { ok: true } }, setScenes(scenes) { if (!Array.isArray(scenes)) return scenesRev++ liveScenes = scenes const m = get().manifest if (m) set({ manifest: { ...m, scenes } }) // before the first manifest: kept for the boot's commit roomForNotes() // a scene note arrived: room beside the scene's first frame }, // The whole sidebar tree in one write: order, membership, the folders themselves. The // write rewrites the ACTIVE board's file too (its `order`/`folder`), so it runs like a // rename: flush the pending autosave first, hold autosave across the round-trip, then // advance boardHash to the hash the server answered - the autosave's CAS token stays // true and the watcher's echo is filtered as our own. `base` carries the hashes the // sidebar last saw; a 409 (`stale`) means someone else wrote first - refetch and replay. arrangeBoards(tree, base) { return structural(async () => { let ok = true for (let i = 0; i < 5 && get().dirty && ok; i++) { clearTimeout(saveTimer); ok = await get().save() } if (get().dirty) return { ok: false as const, stale: false, error: 'unsaved changes - try again' } holdSaves() try { // the active board's hash is freshest in the store (an autosave may have landed since // the sidebar looked); every other board's is the sidebar's const active = get().board const boards = { ...base.boards, ...(get().boardHash && base.boards[active] !== undefined ? { [active]: get().boardHash } : {}) } let res: Response try { res = await postOwner('boards/reorder', { tree: toWire(tree), base: { boards, folders: base.folders }, protocol: TREE_PROTOCOL }) } catch { return { ok: false as const, stale: false, error: 'could not reach the dev server' } } const body = await res.json().catch(() => ({} as { error?: string; sha256?: { boards?: Record } })) if (!res.ok) return { ok: false as const, stale: res.status === 409, error: body?.error } const sha = body?.sha256?.boards?.[get().board] if (sha) set({ boardHash: sha }) return { ok: true as const } } finally { releaseSaves() } // whatever happened, autosave resumes }) }, applyManifest(m) { bumpManifestRev() // frame set changed → new iframes get fresh URLs const { nodes, toast, boardAuto } = get() const known = new Set(nodes.map((n) => n.frame)) const next = [...nodes] let changed = false const { deviceView, baseLayout } = get() const vp = deviceView ? CONFIG.viewports[deviceView] : null let nextBase = baseLayout for (const f of m.frames) { if (!boardAuto) break // curated boards never auto-gain frames if (known.has(f.id)) continue const d = defaultSize(f) const maxY = next.reduce((a, n) => Math.max(a, n.y + n.h), 0) // a grouped newcomer lands BESIDE its siblings, not at the canvas bottom - the // badge/caption say "these belong together"; the position must agree const sibs = f.variantGroup ? next.filter((n) => { const g = m.frames.find((x) => x.id === n.frame)?.variantGroup; return g === f.variantGroup && !n.missing }) : [] const right = sibs.length ? sibs.reduce((a, n) => (n.x > a.x ? n : a)) : null const mode = f.contentWidth ? { sizeMode: (vp ? 'device' : 'auto') as Node['sizeMode'] } : {} // deterministic key (keys.ts): a comment dropped on a live-joined frame must // survive the reload, where the SAME key gets minted by the board-load path const mintKey = (() => { const have = new Set(next.map((n) => n.key)) let salt = 0, k = stableNodeKey(get().board, f.id, salt) while (have.has(k)) k = stableNodeKey(get().board, f.id, ++salt) return k })() const node = right ? { key: mintKey, frame: f.id, x: right.x + right.w + Math.max(140, right.w * 0.12), y: right.y, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const, ...mode } : { key: mintKey, frame: f.id, x: 0, y: maxY + 96, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const, ...mode } next.push(node) // in a device view, the snapshot learns the newcomer's DEFAULT size so 0 restores it sanely if (vp && nextBase) nextBase = { ...nextBase, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } } toast(`agent added ${f.id}`) changed = true } const live = new Set(m.frames.map((f) => f.id)) let retinted = false for (const n of next) { const missing = !live.has(n.frame) if (missing !== !!n.missing) { // a node whose frame just ARRIVED (the create-first choreography: the agent // appends the board node before the manifest registers the file) was sized by // the 390x844 GUESS - its real viewport is only knowable now. ONLY guessed // sizes are corrected (sizeFallback): a restored frame keeps its authored w/h. const arrived = !missing && m.frames.find((x) => x.id === n.frame) if (arrived && n.sizeFallback && !arrived.contentWidth) { const d = defaultSize(arrived) n.w = vp?.width ?? d.w n.h = vp?.height ?? d.h } if (arrived) { delete n.sizeFallback; n.readyRetried = false } // a fresh mount earns a fresh allowance // gone or back, the node has no document: an update waiting for the old one is void, and the // next document boots fresh through admission (a reload job must never stand in for it) delete n.stale; delete n.shown; delete n.want if (missing) letGo(n.key, false) n.missing = missing changed = true } // a frame's declared meta.theme may have changed - re-resolve unpinned nodes // (derived value: re-render yes, dirty/save no) const f = m.frames.find((x) => x.id === n.frame) const want = n.themeUser ?? f?.theme ?? get().viewTheme if (n.theme !== want) { n.theme = want; retinted = true } // content-ness can change live (agent adds/removes the primitives): reconcile // provenance or measurements are rejected / UI dims silently omitted from saves. // Under an active device view the newcomer joins it as 'device' - a measurement // must not override the preset the whole board is showing if (f?.contentWidth && !n.sizeMode) { n.sizeMode = deviceView ? 'device' : 'auto'; retinted = true } if (!f?.contentWidth && n.sizeMode) { delete n.sizeMode; retinted = true } // an errored frame whose file IS in the fresh manifest gets one automatic retry // on a rev-stamped URL - the "unknown frame id" dead end must self-heal (#20) if (!missing && n.status === 'error') { n.status = 'loading'; n.nav = (n.nav ?? 0) + 1; n.readyRetried = false; retinted = true } } // auto boards prune deleted frames outright - "auto-managed" must manage both // directions (friction log #15). Curated boards keep the explicit card. let final = next if (boardAuto) { final = next.filter((n) => !n.missing) const dropped = next.length - final.length if (dropped) { changed = true toast(dropped === 1 ? 'removed 1 deleted frame' : `removed ${dropped} deleted frames`) } } // a guessed size corrected above (in place) is a move, seen when this set notifies the recorder withCause('manifest', () => set((s) => ({ manifest: m, nodes: changed || retinted ? [...final] : final, selection: s.selection.filter((k) => final.some((n) => n.key === k)), interact: s.interact && final.some((n) => n.key === s.interact) ? s.interact : null, ...(changed ? { dirty: true, baseLayout: nextBase } : {}), }))) if (changed) scheduleSave() roomForNotes() // a frame note file arrived: room in front of its frame }, removeNode(key) { letGo(key, false) // a node that goes holds no batch set((s) => ({ nodes: s.nodes.filter((n) => n.key !== key), selection: s.selection.filter((k) => k !== key), interact: s.interact === key ? null : s.interact, dirty: true, })) scheduleSave() }, frameFor(node) { return get().manifest?.frames.find((f) => f.id === node.frame) }, moveNode(key, x, y) { withCause('drag', () => set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, x, y } : n)), dirty: true }))) scheduleSave() }, // group drag: every selected node moves by the same delta from its gesture-start position moveSelectedBy(dx, dy, starts) { withCause('drag', () => set((s) => ({ nodes: s.nodes.map((n) => (starts[n.key] ? { ...n, x: starts[n.key].x + dx, y: starts[n.key].y + dy } : n)), dirty: true, }))) scheduleSave() }, // scoped theme = an explicit per-frame PIN; it survives board reloads and yields // only to the next global set setSelectedTheme(theme) { set((s) => { const sel = new Set(s.selection) return { nodes: s.nodes.map((n) => (sel.has(n.key) ? { ...n, theme, themeUser: theme } : n)), dirty: true } }) scheduleSave() }, resizeNode(key, w, h) { resizedInGesture = true const inGesture = get().gesture // a manual resize means the board no longer uniformly shows one device; the resized // frame's snapshot entry follows the user's new size so Default will not undo it withCause('resize', () => set((s) => { const W = Math.max(120, w), H = Math.max(80, h) const cur = s.nodes.find((n) => n.key === key) // a human-resized CONTENT frame goes 'manual': measurements never overwrite it const content = !!s.manifest?.frames.find((x) => x.id === cur?.frame)?.contentWidth return { nodes: s.nodes.map((n) => (n.key === key ? { ...n, w: W, h: H, ...(content ? { sizeMode: 'manual' as const } : {}) } : n)), resized: { key }, dirty: true, deviceView: null, baseLayout: s.baseLayout && cur ? { ...s.baseLayout, [key]: { ...(s.baseLayout[key] ?? { x: cur.x, y: cur.y }), w: W, h: H } } : s.baseLayout, } })) // mid-gesture saves would persist torn state (new sizes, pre-recipe positions); // the gesture-end hook saves once, after any retidy if (!inGesture) scheduleSave() }, /** sh:measure. Admission: content frames only, finite positive numbers, * clamped. A height only commits when it was measured at the width being applied; * auto sizes are transient - applying one never dirties the board (positions from * the follow-up reflow do, exactly like a human resize). */ measureNode(key, frameId, ownWidth, measuredWidth, height, settled) { const s = get() const node = s.nodes.find((n) => n.key === key) // Only an explicit DEVICE viewport locks a content frame's height. 'auto' and 'manual' both // auto-fit height: a content frame must always grow/shrink to show ALL its content, even after // the human drags its WIDTH. 'manual' just means the human owns the WIDTH; the // HEIGHT is still measured, so resizing width reflows and refits height (no clipped/empty frame). if (!node || node.sizeMode === 'device') return IGNORED if (node.frame !== frameId) return IGNORED // generation guard: a reused node key // across a board switch never mis-attributes const f = s.manifest?.frames.find((x) => x.id === node.frame) if (!f?.contentWidth) return IGNORED // not a content frame - spoof-proofing if (![ownWidth, measuredWidth, height].every((v) => Number.isFinite(v) && v > 0)) return IGNORED // a Doc that reports IS one, whatever the last load's scan said (it arrived since): its heights // count until the next load re-reads the sources measuring?.add(f.id) // held while it reloads (spec 26): only a settled measure lands now; the last other one waits const held = holds.get(key) if (held) { if (settled !== true) { held.last = [key, frameId, ownWidth, measuredWidth, height, settled]; return { took: 'held' } } holds.delete(key); clearTimeout(held.timer) logReload({ kind: 'settled', node: key, frame: frameId, ms: performance.now() - held.at }) if (!holds.size && reflowOwed) { reflowOwed = false; scheduleReflow(null) } else reflowSettled() } // Generous cap: a reference doc with many screenshots is legitimately very tall and must fit in // FULL (this was 2.5x a viewport ~= 2700px, which clipped image-heavy docs). Still bounded so a // broken measurement can't mint an infinite frame. const maxH = 40000 const H = Math.min(maxH, Math.max(80, Math.round(height))) const curW = Math.round(node.w) const mKey = `${node.frame}@${Math.round(measuredWidth)}` // a PROVISIONAL height (an image off-screen still lazy-loading) may grow a frame but never // shrink it under a height already known for this width: that one is what the finished doc // measures, and shrinking now would only grow back when the image lands - the swim again const known = measuredHeights.get(mKey) ?? savedHeights.get(mKey) if (settled === false && known !== undefined && H < known) return IGNORED measuredHeights.set(mKey, H) // AUTO owns the width too - adopt the Doc's declared/own width. MANUAL keeps the human's width // and only fits the height. if (node.sizeMode !== 'manual') { // declared meta.viewport WINS over the Doc layout width - the existing precedence const vpw = CONFIG.viewports[f.viewport ?? '']?.width const W = vpw ?? Math.min(1600, Math.max(320, Math.round(ownWidth))) if (W !== curW) { // Doc layout changed (document<->wide): adopt the new own width first; the // iframe resizes, remeasures, and the height commits on the next message withCause('measure-width', () => set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, w: W } : n)) }))) reflowAfterHolds() return { took: 'width', w: W } } } if (Math.round(measuredWidth) !== curW) return IGNORED // height only true at the width it was measured at // a settled height at the frame's OWN width is the one the next load should open at (sizes.ts); // a human-owned width is the board file's business (manual sizes save there) if (settled === true && node.sizeMode !== 'manual') persistHeight(mKey, H) if (Math.round(node.h) === H) return { took: 'fit', w: curW } withCause('measure-height', () => set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, h: H } : n)) }))) reflowAfterHolds() return { took: 'fit', w: curW } }, noteMeasured(key, height) { // the column's height is its content's - the layout learns it here, and a composed board // whose next row now stands under a long note re-applies its recipe (0.19.2) if (setNoteHeight(key, height)) roomForNotes() }, setDeviceView(name) { withCause('device', () => { const vp = name ? CONFIG.viewports[name] : null if (name && !vp) return set((s) => { // entering a device view from free-form: snapshot the layout so Default restores it. // Auto content nodes snapshot POSITIONS only - their w/h are measured (transient by // contract) and restore comes from the measurement cache, never from the snapshot const baseLayout = name ? (s.deviceView === null ? Object.fromEntries(s.nodes.map((n) => [n.key, n.sizeMode === 'auto' ? { x: n.x, y: n.y } : { x: n.x, y: n.y, w: n.w, h: n.h }])) : s.baseLayout) : null const nodes = s.nodes.map((n) => { const f = s.manifest?.frames.find((x) => x.id === n.frame) const content = !!f?.contentWidth if (vp) return { ...n, w: vp.width, h: vp.height, ...(content ? { sizeMode: 'device' as const } : {}) } // digit 0 on a CONTENT frame = back to auto: measured size, remeasure ahead const b = s.baseLayout?.[n.key] if (content && f) { const d = defaultSize(f) return { ...n, ...(b ? { x: b.x, y: b.y } : {}), w: d.w, h: d.h, sizeMode: 'auto' as const } } if (b) return { ...n, ...b } // exact free-form layout, positions included if (!f) return n const d = defaultSize(f) // frames added mid-device-view get their default return { ...n, w: d.w, h: d.h } }) // the snapshot's positions were laid out around the free-form heights: its laidOut goes and // comes back with it (null = not known - the next load falls back to the overlap check) const laid = name ? { baseLaidOut: s.deviceView === null ? s.laidOut : s.baseLaidOut } : s.baseLayout ? { laidOut: s.baseLaidOut, baseLaidOut: null } : { baseLaidOut: null } return { deviceView: name, dirty: true, baseLayout, nodes, ...laid } }) if (name) get().runTidy() // restore must NOT tidy - it would destroy positions else { scheduleSave(); roomForNotes(); recheckLayout() } // ...unless a note grew meanwhile, or a Doc changed size, under the restored rows }) }, bumpRev(key) { set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, rev: (n.rev ?? 0) + 1 } : n)) })) }, setThemeOn(key, theme) { set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, themeOn: theme } : n)) })) }, setStatus(key, status, error) { // any real ready/error resets the one-shot retry allowance, so a later manual reload or a // fresh manifest earns its own auto-retry again set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, status, error, readyRetried: false } : n)) })) if (status === 'error') letGo(key, false) // an error holds no batch if (status !== 'loading') pumpUpdates() // a slot, or a lease, may be free }, // Reload a frame on a fresh rev-stamped URL (dodges a poisoned-cache document, #20). The // watchdog calls this with automatic=true after a silent deadline: it fires at most once per // load (canAutoReload gates it) and marks readyRetried so a second silence never retries and // never becomes an error. A manual reload passes automatic=false, granting a fresh allowance. reloadFrame(key, automatic = false) { const n = get().nodes.find((x) => x.key === key) if (!n) return if (automatic && !canAutoReload(n)) return reloadCause.set(key, automatic ? 'retry' : 'manual') bumpManifestRev() set((s) => ({ nodes: s.nodes.map((x) => (x.key === key ? { ...x, status: 'loading' as const, error: undefined, nav: (x.nav ?? 0) + 1, readyRetried: automatic } : x)) })) }, // plain select replaces; additive toggles membership. Interact survives only while its // frame stays selected. select(key, additive = false) { set((s) => { const selection = key == null ? [] : additive ? (s.selection.includes(key) ? s.selection.filter((k) => k !== key) : [...s.selection, key]) : [key] return { selection, interact: s.interact && selection.includes(s.interact) ? s.interact : null } }) // selecting a frame is looking at it, every time - even one already selected (spec 25) if (key != null) get().look([key]) }, // scoped device sizing: only the selected frames change; null restores their defaults. // Always followed by a tidy - a preset change must never scramble the layout. resizeSelected(name) { withCause('device', () => { const vp = name ? CONFIG.viewports[name] : null if (name && !vp) return set((s) => { const sel = new Set(s.selection) const nodes = s.nodes.map((n) => { if (!sel.has(n.key)) return n const f = s.manifest?.frames.find((x) => x.id === n.frame) const content = !!f?.contentWidth if (vp) return { ...n, w: vp.width, h: vp.height, ...(content ? { sizeMode: 'device' as const } : {}) } if (!f) return n const d = defaultSize(f) // digit 0 on a content frame clears provenance back to auto return { ...n, w: d.w, h: d.h, ...(content ? { sizeMode: 'auto' as const } : {}) } }) const baseLayout = s.baseLayout ? { ...s.baseLayout } : null if (baseLayout) for (const k of s.selection) { const n = nodes.find((x) => x.key === k) if (n) baseLayout[k] = { ...(baseLayout[k] ?? { x: n.x, y: n.y }), w: n.w, h: n.h } } return { nodes, dirty: true, deviceView: null, baseLayout } }) get().runTidy() }) }, selectAll() { set((s) => ({ selection: s.nodes.map((n) => n.key) })) }, // group/scene selection from sidebar rows and canvas captions selectMany(keys) { set((s) => { const valid = keys.filter((k) => s.nodes.some((n) => n.key === k)) return { selection: valid, interact: s.interact && valid.includes(s.interact) ? s.interact : null } }) // a frame link, a scene's name: the frames brought in front of you are looked at (⌘A is selectAll - not a look) get().look(get().selection) }, // interact is a ONE-frame mode: entering it collapses any multi-selection to the // interacted frame (a 4-frame selection double-clicked otherwise leaves all four // painted as "interactive"). Exiting keeps the frame selected for continuity. setInteract(key) { set((s) => ({ interact: key, selection: key ? [key] : s.selection })) get().flushFrameUpdates() // A6: the frame just left interact is no longer leased }, setPlay(play) { set({ play }) }, toggleLaser(inspect) { const s = get() if (s.laser && s.laserInspect === inspect) s.setLaser(false) else s.setLaser(true, inspect) }, setLaser(laser, inspect = false) { if (laser) { set({ laser, laserInspect: inspect }); return } // leaving laser mode drops every laser engagement lease (the bridge stops reporting), but // keeps comment engagement; the freed frames may now flush a deferred update set((s) => ({ laser, laserInspect: false, externalLeases: Object.fromEntries( Object.entries(s.externalLeases).filter(([, v]) => v.comment).map(([k]) => [k, { comment: true as const }])), })) get().flushFrameUpdates() }, setGesture(gesture) { set({ gesture }) // A board WITH a layout recipe re-applies it when a resize // gesture ends - sizes changed, so lanes and gaps must recompute if (!gesture && resizedInGesture) { resizedInGesture = false if (get().layout || get().sceneRows?.length) withCause('resize', () => get().runTidy()) // schedules the save else scheduleSave() } if (!gesture) get().flushFrameUpdates() // A6: gesture end is a safe point for deferred updates }, // Calm updates (spec 26): an edit reached these frames. Their nodes that show a document go // stale and reload through the boot queue - on screen first, never while in use; one still loading // its first document is checked against the marks when it is ready (setShown). invalidateFrames(frameIds, revision, docs) { const ids = new Set(frameIds.filter((x) => typeof x === 'string')) if (!ids.size) return const s = get() // every document of these frames is behind - one loading too (one running, one waiting): a document // that shows the revision the edit made clears it when it is ready (setShown) const want = (f: string) => (docs?.epoch && typeof docs.docs?.[f] === 'number' ? `${docs.epoch}:${docs.docs[f]}` : undefined) const nodes = s.nodes.map((n) => (ids.has(n.frame) && !n.missing ? { ...n, stale: true, want: later(n.want, want(n.frame)) } : n)) set({ nodes, ...(s.play ? { playUpdateRevision: revision } : {}) }) pumpUpdates() }, flushFrameUpdates() { pumpUpdates() }, startReload(key) { const n = get().nodes.find((x) => x.key === key) if (!n) return reloadCause.set(key, 'job') bumpManifestRev() holdHeight(key) set((s) => ({ nodes: s.nodes.map((x) => (x.key === key ? { ...x, stale: false, status: 'loading' as const, error: undefined, nav: (x.nav ?? 0) + 1, readyRetried: false } : x)) })) }, setShown(key, shown) { set((s) => ({ nodes: s.nodes.map((x) => { if (x.key !== key) return x // a document that shows the revision its node waits for (or later) clears the wait; an older // one - a reload that started before the edit - keeps it, so the edit still lands const n = { ...x, shown } if (n.want) { if (atLeast(shown, n.want)) { n.stale = false; delete n.want } else n.stale = true } return n }) })) pumpUpdates() reconcile() if (armed.has(key)) get().look([key], false) // a look you made while it loaded }, setExternalLease(nodeKey, reason, on) { set((s) => { const cur = { ...(s.externalLeases[nodeKey] ?? {}) } if (on) cur[reason] = true; else delete cur[reason] const leases = { ...s.externalLeases } if (Object.keys(cur).length) leases[nodeKey] = cur; else delete leases[nodeKey] return { externalLeases: leases } }) if (!on) get().flushFrameUpdates() }, applyPlayUpdate() { if (!get().playUpdateRevision) return bumpManifestRev() set((s) => ({ playUpdateRevision: null, playNav: s.playNav + 1, stageShown: null })) // counted again once the new stage commits // pending until the new stage commits: one that never reports (its document failed before it // could) offers Update again const nav = get().playNav setTimeout(() => { const s = get(); if (s.play && s.playNav === nav && !s.stageShown && !s.playUpdateRevision) set({ playUpdateRevision: 'retry' }) }, 15_000) }, setScale(scale) { set({ scale }) }, pulsePath() { set((s) => ({ pathPulse: s.pathPulse + 1 })) }, // Copy the ONE selected frame to the clipboard as a PNG - the dev server's headless // renderer (/api/shot, the same picture `marver shot` gives an agent) sized to what the // node shows, at 2x (or 4x). The ClipboardItem takes a PROMISE: clipboard.write runs // inside the click/keydown gesture and the browser waits for the bytes - a plain // await-then-write would have lost the transient activation during the 1-4s render. copyFrameImage(scale) { const s = get() if (PUBLISHED || s.imageBusy || s.selection.length !== 1) return const node = s.nodes.find((n) => n.key === s.selection[0]) if (!node || node.missing) return const frame = s.frameFor(node) if (!frame) { s.toast('frame is still indexing - try again in a second'); return } const CI = (globalThis as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem if (!CI || !navigator.clipboard?.write || (CI.supports && !CI.supports('image/png'))) { s.toast("this browser can't put images on the clipboard - use Chrome, Edge or Safari"); return } set({ imageBusy: true }) void import('./jam-capture.ts').then(({ recordCopy }) => recordCopy('image', frame.id)) // a capture notes that it happened const done = () => set({ imageBusy: false }) const qs = new URLSearchParams({ frame: frame.id, theme: node.theme, scale: String(scale), w: String(Math.round(node.w)), h: String(Math.round(node.h)), format: 'png' }) // the render is serialized server-side behind any CLI/jam shots; a minute is the ceiling // before the UI gives up on this copy (the server's own watchdog is 45s per shot) const ctl = new AbortController() const timer = setTimeout(() => ctl.abort(), 60_000) let meta: { scale?: number; note?: string } = {} const png = api(`shot?${qs}`, { headers: { 'x-mv-c': csrf() }, signal: ctl.signal }).then(async (r) => { if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error ?? `shot failed (${r.status})`) try { meta = JSON.parse(atob((r.headers.get('x-mv-shot') ?? '').replace(/-/g, '+').replace(/_/g, '/'))) } catch { /* summary is advisory */ } return r.blob() }).finally(() => clearTimeout(timer)) let renderErr = '' png.catch((e: Error) => { renderErr = e.name === 'AbortError' ? 'timed out - the renderer is busy' : e.message }) const fail = (err: unknown) => { done() // a prompt clipboard refusal (no gesture, focus lost) must not wait a minute on the // render: abandon the fetch, toast now. A render failure surfaces its own cause. if (!renderErr) ctl.abort() s.toast(renderErr ? `render failed - ${renderErr}` : (err as Error)?.name === 'NotAllowedError' ? 'copy blocked - click the canvas first' : `copy failed - ${(err as Error)?.message ?? err}`) } try { navigator.clipboard.write([new CI({ 'image/png': png })]).then( () => { done() const used = meta.scale ?? scale s.toast(used < scale ? `image copied at ${used}x - frame too tall for ${scale}x` : scale === 4 ? 'image copied (4x)' : 'image copied') set((st) => ({ imagePulse: st.imagePulse + 1 })) }, fail) } catch (err) { fail(err) } // a synchronous constructor/type error must not wedge busy }, togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) }, // global theme = the VIEW preference: persists across boards + reloads, clears // per-frame pins. Frames declaring meta.theme keep their mode (they only work there). setTheme(theme) { try { localStorage.setItem(scoped('mv-view-theme'), theme) } catch { /* storage unavailable */ } set((s) => ({ viewTheme: theme, nodes: s.nodes.map((n) => { const f = s.manifest?.frames.find((x) => x.id === n.frame) return { ...n, themeUser: undefined, theme: f?.theme ?? theme } }), dirty: true, })) scheduleSave() }, runTidy() { const { nodes, manifest, sceneRows, layout } = get() const placed = tidy(tidyInput(nodes, manifest), effectiveLayout(layout, sceneRows), layoutWarn, knownScenes(manifest)) // a tidy asked for by hand; one a preset, a resize, a reflow or the unplaced view runs keeps its cause withCause(geoCause ?? 'tidy', () => set((s) => ({ nodes: s.nodes.map((n) => { const p = placed.find((x) => x.key === n.key) return p ? { ...n, x: p.x, y: p.y } : n }), laidOut: sizeRecord(nodes), dirty: true, }))) scheduleSave() }, toast(text) { const id = ++toastSeq set((s) => ({ toasts: [...s.toasts, { id, text }] })) setTimeout(() => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), 4000) }, /** A Live Jam reply notification: persistent (no auto-dismiss), one per thread (a newer reply * supersedes), capped so the stack stays small. */ jamToast(note) { const id = ++toastSeq const text = note.kind === 'mention' ? 'You were mentioned' : note.kind === 'reply' ? 'New reply' : note.failed ? 'Marver failed' : 'Marver asks' set((s) => { // one notice per batch (spec 27): a batch's question arriving later folds into its open notice const prev = note.batch ? s.toasts.find((t) => t.jam?.batch === note.batch && !t.jam?.failed === !note.failed) : undefined const jam = prev ? { ...note, stack: [...(prev.jam!.stack ?? []), { ...prev.jam!, stack: undefined }, ...(note.stack ?? [])].filter((n) => n.threadId !== note.threadId) } : note // a notice whose top reply is this thread steps down to the replies under it, never loses them const others = s.toasts.flatMap((t) => { if (t === prev) return [] const rest = (t.jam?.stack ?? []).filter((n) => n.threadId !== note.threadId) if (t.jam?.threadId !== note.threadId) return [t.jam?.stack ? { ...t, jam: { ...t.jam, stack: rest } } : t] return rest.length ? [{ ...t, jam: { ...rest[rest.length - 1], ...(t.jam?.batch ? { batch: t.jam.batch } : {}), stack: rest.slice(0, -1) } }] : [] }) return { toasts: [...others, { id, text, jam }].slice(-8) } }) }, jamFailed(cards) { const card = cards[0] if (!card) return const frame = card.frames[0] const entry = frame ? get().manifest?.frames.find((f) => f.id === frame) : undefined get().jamToast({ threadId: card.thread ?? '', board: card.board, ts: Date.now(), preview: (cards.length > 1 ? `${cards.length} asks - ` : '') + (card.note ?? 'the agent could not finish'), frame, frameTitle: entry?.title ?? frame, intent: entry?.intent, failed: cards.map((c) => ({ card: c.id, rev: c.rev })), ...(card.batch ? { batch: card.batch } : {}), }) }, dismissToast(id) { set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })) }, advanceToast(id) { // a folded notice steps to its next question; any other notice goes set((s) => ({ toasts: s.toasts.flatMap((t) => { if (t.id !== id) return [t] const rest = t.jam?.stack ?? [] // the next one keeps the notice's batch, so a later question of that batch still folds in return rest.length ? [{ ...t, jam: { ...rest[rest.length - 1], ...(t.jam?.batch ? { batch: t.jam.batch } : {}), stack: rest.slice(0, -1) } }] : [] }) })) }, clearJamToasts() { set((s) => ({ toasts: s.toasts.filter((t) => !t.jam) })) }, setWorkingFrom(m) { const strings = (v: unknown) => (Array.isArray(v) ? v.filter((x: unknown): x is string => typeof x === 'string') : []) const o = (m && typeof m === 'object' ? m : {}) as { frames?: unknown; boards?: unknown; seq?: unknown } // a snapshot that left before a newer broadcast arrived is the past: it never undoes it const seq = typeof o.seq === 'number' && Number.isFinite(o.seq) ? o.seq : undefined if (seq !== undefined && seq < get().workingSeq) return if (seq !== undefined) set({ workingSeq: seq }) get().setWorking(strings(o.frames), strings(o.boards)) }, marks: null, // one fetch per burst (activity, board edits, file changes), and only the newest answer lands; // acknowledgements still in flight are kept over any answer that predates them async syncMarks() { if (PUBLISHED) return clearTimeout(marksTimer) await new Promise((done) => { marksTimer = setTimeout(done, 200) }) const seq = ++marksSeq try { for (let i = 0; i < 2; i++) { // the first ask may prime the CSRF cookie (as syncWorking) const r = await api('marks', { headers: { 'x-mv-c': csrf() } }) if (seq !== marksSeq) return if (r.ok) { const m = await r.json() as Marks | null if (seq !== marksSeq || !m) return // a new epoch (the marks started over): what this page held belongs to the old one if (m.epoch !== pendingEpoch) { for (const id of Object.keys(pendingSeen)) delete pendingSeen[id]; armed.clear(); pendingEpoch = m.epoch } const seen = { ...m.seen } for (const [id, at] of Object.entries(pendingSeen)) seen[id] = Math.max(seen[id] ?? 0, at) set({ marks: { ...m, seen } }) reconcile() if (lookedEarly.size) { const early = [...lookedEarly]; lookedEarly.clear(); get().look(early) } if (armed.size) get().look([...armed.keys()], false) // a look that waited on a newer version than the page knew return } if (r.status !== 403) return } } catch { /* the next broadcast heals it */ } }, markSeen(frameIds) { const m = get().marks if (!m) return ack(Object.fromEntries(frameIds.filter((id) => isNew(m, id)).map((id) => [id, m.changed[id] ?? 0]))) }, look(keys, rearm = true) { const s = get(), m = s.marks if (!m) { if (!PUBLISHED) for (const k of keys) lookedEarly.add(k); return } // the revision a node (or the stage) shows of its frame: -1 while it does not show the frame's last // edit (stale, loading, an older document) - a look counts up to it, never past it const upTo = (k: string, f: string): number => { const doc = m.doc[f] ?? 0 if (k === 'stage') { const st = s.stageShown if (!s.play || s.playUpdateRevision || !st) return -1 const [e, r] = st.shown.split(':') return e === m.epoch && Number(r) >= doc ? Number(r) : -1 } const n = s.nodes.find((x) => x.key === k) if (!n || n.status !== 'ready' || n.stale) return -1 const r = shownRev(n, m) return r >= doc ? r : -1 } const now = Date.now(), seen: Record = {} for (const k of keys) { const f = k === 'stage' ? s.stageShown?.at : s.nodes.find((n) => n.key === k)?.frame const a = armed.get(k) // a remembered look holds for its own frame, on its own board, for ARMED_MS if (a && (a.until <= now || a.board !== s.board || a.frame !== f)) armed.delete(k) if (!rearm && !armed.has(k)) continue if (!f) { armed.delete(k); continue } const r = upTo(k, f) // what you saw: the last change as the page knows it - or the revision on screen when the document // already shows one the page has not heard of yet (everything up to it is there) const saw = Math.max(m.changed[f] ?? 0, r) if (saw <= (m.seen[f] ?? 0)) { armed.delete(k); continue } if (r < 0) { if (rearm) armed.set(k, { until: now + ARMED_MS, board: s.board, frame: f }); continue } // not up yet: the look waits for it armed.delete(k) seen[f] = Math.max(seen[f] ?? 0, saw) } ack(seen) }, async syncWorking() { if (PUBLISHED) return try { // a fresh page may ask before its double-submit cookie exists: the refusal primes it (api.ts), // so one more try carries it for (let i = 0; i < 2; i++) { const r = await api('work', { headers: { 'x-mv-c': csrf() } }) if (r.ok) return get().setWorkingFrom(await r.json()) if (r.status !== 403) return } } catch { /* the next broadcast heals it */ } }, setWorking(frames, boards = []) { // preserve each frame's original start time; stamp now() only for newly-working frames - // the start phases the working animations so parallel frames never pulse in sync const prev = get().workingSince const workingSince: Record = {} for (const f of frames) workingSince[f] = prev[f] ?? Date.now() set({ working: frames, workingSince, workingBoards: frames.length ? boards : [] }) }, spawn(frameId) { const { manifest, nodes, deviceView, baseLayout } = get() const f = manifest?.frames.find((x) => x.id === frameId) if (!f) return null const d = defaultSize(f) const vp = deviceView ? CONFIG.viewports[deviceView] : null const maxX = nodes.reduce((a, n) => Math.max(a, n.x + n.w), 0) const node: Node = { key: nodeKey(), frame: f.id, x: maxX + 96, y: 0, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading', ...(f.contentWidth ? { sizeMode: (vp ? 'device' : 'auto') as Node['sizeMode'] } : {}) } withCause('spawn', () => set((s) => ({ nodes: [...s.nodes, node], dirty: true, ...(vp && baseLayout ? { baseLayout: { ...baseLayout, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } } } : {}), }))) scheduleSave() get().toast(`added ${f.id}`) return node }, syncUnplaced(ids) { withCause('unplaced', () => { const s = get() if (s.board !== UNPLACED || !s.manifest) return const want = new Set(ids) const keep = s.nodes.filter((n) => want.has(n.frame)) const add = ids.filter((id) => !keep.some((n) => n.frame === id)) if (keep.length === s.nodes.length && !add.length) return for (const n of s.nodes) if (!want.has(n.frame)) letGo(n.key, false) const joined: Node[] = add.flatMap((id) => { const f = s.manifest!.frames.find((x) => x.id === id) if (!f) return [] const d = defaultSize(f) return [{ key: nodeKey(), frame: f.id, x: 0, y: 0, w: d.w, h: d.h, theme: resolveTheme(f), status: 'loading' as const, ...(f.contentWidth ? { sizeMode: 'auto' as const } : {}) }] }) const nodes = [...keep, ...joined] const scenes = [...new Set(nodes.map((n) => s.manifest!.frames.find((f) => f.id === n.frame)?.scene).filter((x): x is string => !!x))] set({ nodes, selection: s.selection.filter((k) => nodes.some((n) => n.key === k)), layout: scenes.length ? { rows: [scenes] } : null }) get().runTidy() // one band of its scenes again; the view is never saved }) }, save() { // a rename is moving this board's file - defer every save so nothing writes to the OLD // name mid-move (renameBoard reschedules once the move commits under the new name) if (saveHolds > 0) return Promise.resolve(false) // the canvas stopped (A1.11): the edits stay here, dirty, until it answers on this address again if (canvasStopped()) return Promise.resolve(false) // a resize gesture in flight = torn state (new sizes, pre-recipe positions): // even a PREVIOUSLY scheduled timer must defer to the gesture-end save if (get().gesture && resizedInGesture) { scheduleSave(); return Promise.resolve(false) } // a published canvas is read-only ON DISK: theme pins persist to the // session instead, and dirty must clear or switchBoard's save-flush loop wedges if (DATA) { sessionPins.write(get().board, get().nodes) sessionSizes.write(get().board, get().nodes) set({ dirty: false }) return Promise.resolve(true) } // the Not on a board view is never saved: what is moved there lasts the visit if (get().board === UNPLACED) { set({ dirty: false }); return Promise.resolve(true) } const p = saveChain.then(async () => { // recheck INSIDE the serialized chain: a save queued behind an in-flight PUT // may only execute after a resize gesture began - the entry guard above ran // too early to see it (torn PUT #2 behind blocked PUT #1) if (get().gesture && resizedInGesture) { scheduleSave(); return false } const rev = editRev const { nodes, boardHash, deviceView, baseLayout, board: boardName, boardAuto } = get() // Missing nodes persist too - only the explicit remove button drops them. const board = { version: 1, name: boardName, auto: boardAuto, // deviceView and the free-form snapshot persist independently: a scoped // exception clears the view but 0 must still restore the snapshot after reload ...(deviceView ? { deviceView } : {}), ...(get().sceneRows?.length ? { sceneRows: get().sceneRows } : {}), ...(get().layoutRaw !== undefined ? { layout: get().layoutRaw } : {}), ...(get().laidOut && Object.keys(get().laidOut!).length && composed(get()) ? { laidOut: get().laidOut } : {}), // baseLayout entries for auto content nodes keep POSITIONS only - their // measured dimensions are transient and never reach the file ...(baseLayout ? { baseLayout: Object.fromEntries(Object.entries(baseLayout).map(([k, b]) => { const n = nodes.find((x) => x.key === k) return n?.sizeMode === 'auto' ? [k, { x: b.x, y: b.y }] : [k, b] })), ...(get().baseLaidOut ? { baseLaidOut: get().baseLaidOut } : {}), } : {}), // only PINNED themes persist - inherited values follow viewTheme at load time. // Content frames in AUTO save no dimensions: measured sizes are // transient - a reflow-triggered save can never leak an auto height to disk. // Guessed sizes (sizeFallback: the frame was not in the manifest yet) stay // transient too - persisting the 390x844 guess would make it look authored // and immortalize it across reloads. nodes: nodes.map(({ key, frame, x, y, w, h, themeUser, sizeMode, sizeFallback }) => ({ key, frame, x: Math.round(x), y: Math.round(y), ...(sizeMode === 'auto' || sizeFallback ? {} : { w: Math.round(w), h: Math.round(h) }), ...(sizeMode === 'manual' || sizeMode === 'device' ? { sizeMode } : {}), ...(themeUser ? { themeUser } : {}), })), } try { // a refusal by another canvas comes back as the helper's 503 and takes the `return false` below, // never the 409 branch (disk wins) const res = await api(`boards/${boardName}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, // A9: a board loaded from disk (boardHash set) autosaves with mustExist so a // rename/delete out from under us returns 409 instead of resurrecting a ghost. body: JSON.stringify({ board, baseHash: boardHash, mustExist: !!boardHash }), }) if (get().board !== boardName) return true // switched boards mid-flight; stale response must not touch state if (res.status === 409) { const info = await res.json().catch(() => ({} as { gone?: boolean })) if (info?.gone) { // renamed/deleted externally - drop the write, don't recreate it or loop set({ dirty: false }) get().toast(BOARD_GONE) lostInResume('gone') // a save while a resume is owed: the page after its reload says this, not "saved" return true } const reloaded = await get().boot() // hash advances only when the authoritative reload commits if (reloaded) { get().toast(DISK_WON); lostInResume('disk') } return reloaded // false keeps dirty set; the next debounce retries once edits settle } if (res.ok) { const { sha256 } = await res.json() // an edit that landed after this save started keeps dirty set for the next save set({ boardHash: sha256, ...(editRev === rev ? { dirty: false } : {}) }) savedInResume() // a save while a resume is owed (the resume's, or this autosave): the page after its reload says the edits were saved return true } return false } catch { return false /* dev server gone; edits stay local and dirty */ } }) saveChain = p return p }, } }) // a frame shown in focus or play is looked at (spec 25): the stage reports each frame it commits, // with the revision it shows (Play.tsx sets stageShown) - selecting one is handled in select() useStore.subscribe((s, prev) => { if (s.stageShown && s.stageShown !== prev.stageShown) { reconcile(); s.look(['stage']) } // a late commit of an older stage offers Update // the canvas is in view again; an Update the closed stage held goes with it (the next one is served fresh) if (!s.play && prev.play) { useStore.setState({ stageShown: null, playUpdateRevision: null }); pumpUpdates() } }) // spec 28 A1.11: the canvas answered again on this address - what this page holds is saved before it reloads // (never mid-drag: a frame held by the pointer is asked about again a second later) beforeResume(async () => { const s = () => useStore.getState() if (s().gesture) return false for (let i = 0; i < 5 && s().dirty; i++) if (!(await s().save())) break // heights a write did not land (a 500, no answer) hold the reload too: queued again, they go on their timer while // the page works as live, and it reloads the second after they reach the file const heights = await settleHeights() return heights && !s().dirty && !s().gesture }) // and an edit made, a drag begun, or a height measured or sent back to the queue while that save was on its way holds // the reload as well holdsReload(() => { const s = useStore.getState(); return s.dirty || !!s.gesture || !heightsSaved() }) // while stopped, the banner says "your unsaved edits are kept" from the edit that makes some useStore.subscribe((s, p) => { if ((s.dirty !== p.dirty || s.gesture !== p.gesture) && canvasStopped()) noteUnsaved() }) onLinkNotice((text) => useStore.getState().toast(text)) /** A tool that needs the server, asked for while the canvas is stopped (A1.11): it says why and the way back, and * does nothing. `what`: "Comments need the canvas running". */ export function stoppedFor(what: string): boolean { if (!canvasStopped()) return false useStore.getState().toast(`${what} - ${STOPPED_WAY}`) return true } // spec 28 A2.9: leaving a page that holds a board edit not on disk yet - dirty, a save in flight or one that failed - // or heights not written yet asks first (unsaved.ts's one beforeunload), live or stopped; the Mac app hears it too. // A drag is not work until it drops. A published canvas keeps its pins and sizes in the session: nothing to lose if (!PUBLISHED) { unsavedWhen('board-unsaved', () => useStore.getState().dirty) unsavedWhen('heights', () => !heightsSaved()) useStore.subscribe((s, p) => { if (s.dirty !== p.dirty) workChanged() }) } // dev-only inspection hook for tests/debugging (never installed in published builds) if (import.meta.env.DEV) (window as unknown as { __mvStore: unknown }).__mvStore = useStore