@marver-design/marver 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +3 -1
  3. package/dist/bake-kaf5kGZ7.mjs +747 -0
  4. package/dist/{build-D_g53Bp2.mjs → build-7ed5H2vT.mjs} +215 -13
  5. package/dist/cli.mjs +19 -11
  6. package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
  7. package/dist/{daemon-Bbh_jmui.mjs → daemon-DbHvLQUL.mjs} +1 -1
  8. package/dist/{dev-LnIISva5.mjs → dev-D3mP2x27.mjs} +205 -10
  9. package/dist/{init-B7YhcN2o.mjs → init-BQYCS3EU.mjs} +2 -2
  10. package/dist/{manifest-CaslQIAO.mjs → manifest-B01PSyDc.mjs} +34 -7
  11. package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
  12. package/dist/{plugin-D2msH1cj.mjs → plugin-DI-7NAnx.mjs} +108 -29
  13. package/dist/{poster-BEjUcQP3.mjs → poster-DNh6N27C.mjs} +1 -1
  14. package/dist/publish-bakes-Dp-ZFk3d.mjs +216 -0
  15. package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
  16. package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
  17. package/dist/{shot-BzQ0PXKH.mjs → shot-BFEuYbaz.mjs} +1 -1
  18. package/dist/{shot-DlmTO8AF.mjs → shot-DMDvDbeP.mjs} +14 -7
  19. package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
  20. package/docs/publish.md +27 -5
  21. package/docs/sticky-notes.md +43 -0
  22. package/package.json +2 -1
  23. package/src/client/content/diagram.tsx +27 -1
  24. package/src/client/content/index.tsx +2 -2
  25. package/src/client/content/md.ts +48 -0
  26. package/src/client/frame-host/bridge.js +4 -9
  27. package/src/client/frame-host/main.tsx +20 -6
  28. package/src/client/shell/App.tsx +14 -63
  29. package/src/client/shell/Comments.tsx +77 -14
  30. package/src/client/shell/Play.tsx +6 -3
  31. package/src/client/shell/canvas/Canvas.tsx +12 -3
  32. package/src/client/shell/canvas/FrameNode.tsx +99 -89
  33. package/src/client/shell/canvas/Sticky.tsx +284 -0
  34. package/src/client/shell/canvas/admission.ts +70 -0
  35. package/src/client/shell/canvas/sleep.ts +194 -0
  36. package/src/client/shell/goto.ts +72 -0
  37. package/src/client/shell/notes.ts +151 -0
  38. package/src/client/shell/store.ts +36 -18
  39. package/src/client/shell/styles.css +92 -25
  40. package/src/client/shell/tidy.ts +20 -2
  41. package/src/shared/sleep-rule.ts +41 -0
  42. package/templates/AGENTS-embedded.md +15 -0
  43. package/templates/AGENTS-studio.md +15 -0
  44. package/templates/instructions/craft.md +9 -0
  45. package/templates/instructions/publish.md +62 -9
  46. package/templates/instructions/shape.md +62 -0
  47. package/src/client/frame-host/serialize.ts +0 -195
  48. package/src/client/shell/canvas/snapshots.ts +0 -233
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Frame admission: how many iframes boot at once.
3
+ *
4
+ * Every frame on a board is a document booting on the ONE renderer main thread (~400 ms each for a
5
+ * lo-fi React frame), and a batch that starts together finishes together: 64 started at once, the
6
+ * first is ready at 25.6 s and the last at 26.0 s - the board is blank until the end. Admitted a few
7
+ * at a time, the first is ready at 1.8 s and the board fills in view order for the same total
8
+ * (research/hifi/bootscale.ts). The ready watchdog counts from admission, not from mount, so a
9
+ * queued frame is never mistaken for a stalled one.
10
+ *
11
+ * A new batch admits ONE frame first (the quickest first content: one boot alone is ~550 ms, four
12
+ * together ~1.8 s), then up to SLOTS at a time. Pure, apart from its timing: a board that mounts
13
+ * registers every node, the shell fits the camera (App.tsx, a few tens of ms later), then the first
14
+ * pump ranks them (visible first, nearest the centre of the canvas first); a freed slot pumps on
15
+ * the next microtask, ranking again by the view of that moment.
16
+ */
17
+ export const SLOTS = 4
18
+ /** A new batch's head start: one frame alone until it is done, or until this long - one stalled
19
+ * first frame must not hold the whole board. */
20
+ export const HEAD_MS = 2000
21
+ let primed = false // the batch may use every slot
22
+ let gen = 0 // the batch: a pump scheduled for an earlier one is void
23
+ let timer: ReturnType<typeof setTimeout> | undefined
24
+
25
+ export interface Admission { key: string; rank: () => number; start: () => void }
26
+
27
+ const waiting = new Map<string, Admission>()
28
+ const active = new Set<string>()
29
+ let scheduled = false
30
+
31
+ /** Ask for a slot. Starts once the view has settled (SETTLE ms) when one is free; else queued by rank. */
32
+ export function admit(a: Admission): void {
33
+ if (active.has(a.key)) return
34
+ if (!waiting.size && !active.size) { // a new batch
35
+ primed = false; gen++; scheduled = false; clearTimeout(timer)
36
+ timer = setTimeout(() => { primed = true; schedule(0) }, HEAD_MS)
37
+ }
38
+ waiting.set(a.key, a)
39
+ schedule(SETTLE)
40
+ }
41
+ const SETTLE = 350 // the board fit animates ~250 ms from 60 ms after mount: rank the first pick on the settled view
42
+
43
+ /** The frame is done booting (ready, error, gone, or its watchdog took over): free the slot, or
44
+ * leave the queue if it never started. */
45
+ export function release(key: string): void {
46
+ waiting.delete(key)
47
+ if (active.delete(key)) { primed = true; schedule(0) }
48
+ }
49
+
50
+ function schedule(ms: number): void {
51
+ if (scheduled) return
52
+ scheduled = true
53
+ const g = gen, run = () => { if (g === gen) pump() }
54
+ if (ms) setTimeout(run, ms); else queueMicrotask(run)
55
+ }
56
+
57
+ function pump(): void {
58
+ scheduled = false
59
+ while (active.size < (primed ? SLOTS : 1) && waiting.size) {
60
+ let best: Admission | undefined, bestRank = Infinity
61
+ for (const a of waiting.values()) { const r = a.rank(); if (r < bestRank || !best) { best = a; bestRank = r } }
62
+ waiting.delete(best!.key)
63
+ active.add(best!.key)
64
+ ;(globalThis as { __mvAdmitted?: string[] }).__mvAdmitted?.push(best!.key) // diagnostic: the order, when a probe asks for it
65
+ best!.start()
66
+ }
67
+ }
68
+
69
+ /** For tests: nothing queued, nothing active. */
70
+ export function resetAdmission(): void { waiting.clear(); active.clear(); primed = false; scheduled = false; gen++; clearTimeout(timer) }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Sleep - a frame at rest, in place (spec 16).
3
+ *
4
+ * A resting frame is its own LIVE document. Nothing is copied, pictured or swapped, so the moment
5
+ * the human interacts, comments or lasers, they are already looking at the thing itself: no line
6
+ * break, no spacing, no pixel can move. Sleep changes PAINT only:
7
+ *
8
+ * - CSS animations pause (`animation-play-state: paused`);
9
+ * - every `backdrop-filter` element - the one effect that reads back what is behind it on every
10
+ * composited frame, and the reason a glass design checkerboards at scale - gets the compositor's
11
+ * own filtered backdrop as a static texture under its own background layers, computed and
12
+ * certified by the dev server in headless Chrome (src/server/bake.ts); the rule itself is
13
+ * src/shared/sleep-rule.ts, the same one the compiler certified.
14
+ *
15
+ * A frame with no such element - markdown, images, slides, lo-fi - never talks to the server:
16
+ * its sleep is the animation pause. Frames are asked in one batch per tick; the server answers from
17
+ * its disk cache (keyed by frame, theme, size and source generation) or compiles. Textures are
18
+ * decoded BEFORE the override is installed, so sleep is one paint. Without textures - no compiler
19
+ * (a published canvas), a compile that failed, a texture that does not decode - the frame sleeps
20
+ * with the pause alone and its glass stays live: never an effect layer without its texture.
21
+ *
22
+ * Safety: the override is all or nothing - every target's selector must resolve to an element whose
23
+ * border box is the one the server measured (half a pixel) and whose filter is still the one baked, or
24
+ * the frame stays live. Wake restores the live effects under `transition: none` (an authored
25
+ * transition on backdrop-filter, filter or background must not animate out of the sleep, nor into
26
+ * it), then removes the <style> and the attributes.
27
+ */
28
+ import { ROUTE } from '../../const.ts'
29
+ import { BAKES, PUBLISHED } from '../store.ts'
30
+ import { readOwn, sleepRule } from '../../../shared/sleep-rule.ts'
31
+
32
+ export interface SleepKey { frame: string; theme: string; w: number; h: number }
33
+ interface Target { sel: string; rect: { x: number; y: number; w: number; h: number }; filter: string; level: number; texture: string; verified: boolean }
34
+ type Answer = { ok: true; targets: Target[] } | { ok: false; error: string }
35
+
36
+ const STYLE_ID = 'mv-sleep'
37
+ /** How far (CSS px) any EDGE of an element's border box may sit from the one the compiler measured.
38
+ * Headless and headed Chrome shape text a few hundredths of a pixel apart (a 784 px pill measures
39
+ * 784.09 in a window, 784.125 in the compiler), which is invisible under a texture stretched to
40
+ * the box; a different wrap, size or place is a whole line or more and still refuses the frame. */
41
+ const TOL = 0.5
42
+ /** `?awake=1` keeps every frame live - the diagnostic switch the identity probes compare against. */
43
+ const AWAKE = new URLSearchParams(location.search).get('awake') === '1'
44
+ const PAUSE = `*,*::before,*::after{animation-play-state:paused!important}`
45
+ const csrf = () => document.cookie.match(/(?:^|; )mv_c=([^;]+)/)?.[1] ?? ''
46
+ const keyOf = (k: SleepKey) => `${k.frame}|${k.theme}|${Math.round(k.w)}|${Math.round(k.h)}`
47
+
48
+ /** What a node's DOCUMENT is asleep under, published only once its override is INSTALLED. */
49
+ const asleep = new Map<string, { key: string; doc: Document }>()
50
+ /** The node's current request, so a stale answer (a newer sleep, a wake in between) is dropped. */
51
+ const pending = new Map<string, number>()
52
+ let seq = 0
53
+ /** Nodes with one retry of a failed compile in flight. */
54
+ const retried = new Set<string>()
55
+ const RETRY_MS = 4000
56
+
57
+ /** Does this document have anything to compile? Cheap: one computed style per element. */
58
+ export function hasEffects(doc: Document): boolean {
59
+ for (const el of doc.querySelectorAll('*')) {
60
+ const cs = doc.defaultView!.getComputedStyle(el)
61
+ const bf = cs.backdropFilter || (cs as unknown as { webkitBackdropFilter?: string }).webkitBackdropFilter
62
+ if (bf && bf !== 'none') return true
63
+ }
64
+ return false
65
+ }
66
+
67
+ /** A published canvas has no compiler: its textures were compiled at build time against the very
68
+ * document it serves, and ship as one static index (publish-bakes.ts), read once. */
69
+ let published: Promise<Record<string, Answer>> | undefined
70
+ const staticAnswers = () => (published ??= (BAKES ? fetch(`${ROUTE}/bakes/${BAKES}/index.json`).then((r) => (r.ok ? r.json() : {})) : Promise.resolve({}))
71
+ .then((j: { gen?: number; answers?: Record<string, Answer> }) => (j.gen === BAKES && j.answers) || {}).catch((): Record<string, Answer> => ({})))
72
+
73
+ // ---- one batch per tick: every frame that decides to sleep in the same moment shares a browser
74
+ const queue: { key: SleepKey; resolve: (a: Answer) => void }[] = []
75
+ let flush: ReturnType<typeof setTimeout> | undefined
76
+ function ask(key: SleepKey): Promise<Answer> {
77
+ return new Promise((resolve) => {
78
+ queue.push({ key, resolve })
79
+ clearTimeout(flush)
80
+ flush = setTimeout(async () => {
81
+ const batch = queue.splice(0)
82
+ // one ask per key; every waiter for that key gets the same answer
83
+ const byKey = new Map<string, { key: SleepKey; waiters: ((a: Answer) => void)[] }>()
84
+ for (const q of batch) { const kk = keyOf(q.key); const e = byKey.get(kk); if (e) e.waiters.push(q.resolve); else byKey.set(kk, { key: q.key, waiters: [q.resolve] }) }
85
+ const asks = [...byKey.values()].map((e) => ({ frame: e.key.frame, theme: e.key.theme, w: Math.round(e.key.w), h: Math.round(e.key.h) }))
86
+ let data: { answers?: (Answer & SleepKey)[] } | null = null
87
+ const index = PUBLISHED ? await staticAnswers() : null
88
+ if (!index) {
89
+ try {
90
+ const r = await fetch(`${ROUTE}/api/bakes`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-mv-c': csrf() }, body: JSON.stringify({ asks }) })
91
+ data = r.ok ? await r.json() : null
92
+ } catch { data = null }
93
+ }
94
+ for (const e of byKey.values()) {
95
+ const a = index ? index[keyOf(e.key)] : data?.answers?.find((x) => keyOf(x) === keyOf(e.key))
96
+ const answer: Answer = a ? (a.ok ? { ok: true, targets: a.targets } : { ok: false, error: a.error }) : { ok: false, error: 'no answer' }
97
+ for (const w of e.waiters) w(answer)
98
+ }
99
+ }, 40)
100
+ })
101
+ }
102
+
103
+ /** Put a node's live document to sleep under `key`. Resolves once the override is installed (or
104
+ * the document was found to have no effects, or the compile failed and the frame stays live).
105
+ * A wake or a newer sleep in the meantime supersedes it. */
106
+ export async function sleep(nodeKey: string, iframe: HTMLIFrameElement, key: SleepKey): Promise<'asleep' | 'live'> {
107
+ if (AWAKE) return 'live'
108
+ const k = keyOf(key)
109
+ const doc = iframe.contentDocument
110
+ if (!doc?.body) return 'live'
111
+ const have = asleep.get(nodeKey)
112
+ if (have && have.key === k && have.doc === doc && doc.getElementById(STYLE_ID)) return 'asleep'
113
+ const mine = ++seq
114
+ pending.set(nodeKey, mine)
115
+ const current = () => pending.get(nodeKey) === mine && iframe.contentDocument === doc
116
+ // the compiler measured after the frame's fonts; so does the gate below
117
+ if (doc.fonts) { await doc.fonts.ready; if (!current()) return 'live' }
118
+ if (!hasEffects(doc)) {
119
+ // nothing to compile: the pause alone is this frame's sleep
120
+ if (!current()) return 'live'
121
+ install(doc, [])
122
+ asleep.set(nodeKey, { key: k, doc })
123
+ return 'asleep'
124
+ }
125
+ const answer = await ask(key)
126
+ if (!current()) return 'live'
127
+ // no compiler (a published canvas, a compile that failed): the pause alone, the glass live -
128
+ // never an effect layer without its texture
129
+ let targets = answer.ok ? answer.targets.filter((t) => t.verified && t.texture) : []
130
+ // decode every texture BEFORE the paint that installs them - one commit, no pop-in; one that
131
+ // fails to decode (pruned, missing, corrupt) leaves the glass live
132
+ const decoded = await Promise.all(targets.map((t) => { const im = new Image(); im.src = t.texture; return im.decode().then(() => true, () => false) }))
133
+ if (!current()) return 'live'
134
+ if (decoded.some((ok) => !ok)) targets = []
135
+ // a published document names the generation it was built with: textures dress that build only
136
+ if (PUBLISHED && targets.length && doc.querySelector('meta[name="mv-bakes"]')?.getAttribute('content') !== String(BAKES)) targets = []
137
+ if (!install(doc, targets)) return 'live'
138
+ // a certified sleep is remembered; the pause-only fallback is not, so the next lifecycle event
139
+ // asks again (a compile the source outran, a server hiccup), and one retry is scheduled now
140
+ if (targets.length) asleep.set(nodeKey, { key: k, doc })
141
+ else if (!PUBLISHED && !retried.has(nodeKey)) { retried.add(nodeKey); setTimeout(() => { retried.delete(nodeKey); if (current()) void sleep(nodeKey, iframe, key) }, RETRY_MS) } // a static miss is final
142
+ return 'asleep'
143
+ }
144
+
145
+ /** Wake: paint goes back to the live effects. The document is otherwise untouched. */
146
+ export function wake(nodeKey: string, iframe: HTMLIFrameElement | null): void {
147
+ pending.delete(nodeKey)
148
+ asleep.delete(nodeKey)
149
+ const doc = iframe?.contentDocument
150
+ const st = doc?.getElementById(STYLE_ID)
151
+ if (!doc || !st) return
152
+ const els = [...doc.querySelectorAll<HTMLElement>('[data-mv-sleep]')]
153
+ still(doc, els, () => st.remove())
154
+ for (const el of els) el.removeAttribute('data-mv-sleep')
155
+ }
156
+
157
+ /** Run `change` with no transition able to start on `els`: an INLINE important
158
+ * `transition-property: none` (it outranks an authored important transition), the change, one
159
+ * forced style recalc - a style change event with nothing to animate - then the authored longhand
160
+ * back. Only the longhand is touched: an inline `transition-duration` alone does not serialize
161
+ * through the shorthand, and a shorthand round trip would have deleted it. */
162
+ function still(doc: Document, els: HTMLElement[], change: () => void): void {
163
+ const authored = els.map((el) => [el.style.getPropertyValue('transition-property'), el.style.getPropertyPriority('transition-property')] as const)
164
+ for (const el of els) el.style.setProperty('transition-property', 'none', 'important')
165
+ change()
166
+ void doc.documentElement.offsetWidth
167
+ els.forEach((el, i) => { const [v, p] = authored[i]; if (v) el.style.setProperty('transition-property', v, p); else el.style.removeProperty('transition-property') })
168
+ }
169
+
170
+ /** Install the override for the targets. All or nothing: a target whose element is not exactly the
171
+ * one the server measured leaves the whole document live. Returns whether it was installed. */
172
+ function install(doc: Document, targets: Target[]): boolean {
173
+ doc.querySelectorAll('[data-mv-sleep]').forEach((el) => el.removeAttribute('data-mv-sleep'))
174
+ const rules: string[] = [PAUSE]
175
+ const els: HTMLElement[] = []
176
+ for (const [i, t] of targets.entries()) {
177
+ let el: HTMLElement | null = null
178
+ try { el = doc.querySelector<HTMLElement>(t.sel) } catch { /* a selector from another document shape */ }
179
+ if (!el) return false
180
+ const r = el.getBoundingClientRect()
181
+ if (Math.abs(r.x - t.rect.x) > TOL || Math.abs(r.y - t.rect.y) > TOL || Math.abs(r.right - t.rect.x - t.rect.w) > TOL || Math.abs(r.bottom - t.rect.y - t.rect.h) > TOL) return false
182
+ const cs = doc.defaultView!.getComputedStyle(el)
183
+ if ((cs.backdropFilter || (cs as unknown as { webkitBackdropFilter?: string }).webkitBackdropFilter || 'none') !== t.filter) return false
184
+ rules.push(sleepRule(`[data-mv-sleep="${i}"]`, readOwn(cs), t.texture))
185
+ els.push(el)
186
+ }
187
+ let st = doc.getElementById(STYLE_ID)
188
+ if (!st) { st = doc.createElement('style'); st.id = STYLE_ID; (doc.head ?? doc.documentElement).appendChild(st) }
189
+ const style = st
190
+ // one paint, and no authored transition (`transition: all` is common on a pill) may animate the
191
+ // effect out or the texture in
192
+ still(doc, els, () => { els.forEach((el, i) => el.setAttribute('data-mv-sleep', String(i))); style.textContent = rules.join('\n') })
193
+ return true
194
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `goto:` navigation, one path for every caller: a frame's data-goto (sh:go from the bridge)
3
+ * and a sticky note's link (shell DOM, spec 18). On this board: select and fit. Elsewhere: follow
4
+ * the frame home (below). Never edits a board unless no board pins the target.
5
+ */
6
+ import { boardFrames, fetchBoardNames, useStore } from './store.ts'
7
+ import { canvasCtl } from './canvas/ctl.ts'
8
+
9
+ /** Navigate to a frame id. `carry` keeps interact mode across the hop (a link inside an
10
+ * interacting frame walks a flow - it must not eject you to design mode). */
11
+ export function goTo(target: string, carry = false): void {
12
+ const s = useStore.getState()
13
+ const existing = s.nodes.find((n) => n.frame === target && !n.missing)
14
+ if (existing) {
15
+ gotoSeq++ // a local goto supersedes any cross-board one in flight
16
+ s.select(existing.key)
17
+ if (carry) s.setInteract(existing.key)
18
+ setTimeout(() => canvasCtl.fitNode(existing.key), 50)
19
+ } else void gotoAcrossBoards(target, carry)
20
+ }
21
+
22
+ /** A data-goto whose target frame is not on the current board follows the frame HOME:
23
+ * the first curated board (switcher rank) that pins it is switched to and the frame
24
+ * focused there - a link is navigation, and navigation never edits a board. Only a
25
+ * frame NO board pins spawns onto the current board (the original prototype behavior
26
+ * for unpinned targets); an id the manifest doesn't know stays a toast. Every goto
27
+ * bumps `gotoSeq` so a slow older resolution can never override newer navigation. */
28
+ let gotoSeq = 0
29
+ async function gotoAcrossBoards(target: string, carry: boolean) {
30
+ const s = useStore.getState()
31
+ // an id the manifest doesn't know resolves NOWHERE - a tombstone pin on some board
32
+ // must not send us on a trip that ends in a silent timeout
33
+ if (!s.manifest?.frames.some((f) => f.id === target)) return s.toast(`unknown goto target "${target}"`)
34
+ const seq = ++gotoSeq
35
+ let home: string | null = null
36
+ try {
37
+ const names = (await fetchBoardNames()).filter((n) => n !== s.board && n !== 'all-scenes')
38
+ for (const name of names) {
39
+ if ((await boardFrames(name)).includes(target)) { home = name; break }
40
+ }
41
+ } catch {
42
+ // a transport failure is NOT proof the frame is unpinned - spawning here would
43
+ // recreate the board mutation this function exists to prevent
44
+ return useStore.getState().toast(`goto: could not read the boards - try again`)
45
+ }
46
+ if (seq !== gotoSeq) return // superseded by newer navigation
47
+ if (!home) {
48
+ // no curated board pins it - the original prototype behavior: spawn beside you
49
+ const st = useStore.getState()
50
+ const node = st.spawn(target)
51
+ if (!node) return st.toast(`unknown goto target "${target}"`)
52
+ st.select(node.key)
53
+ if (carry) st.setInteract(node.key)
54
+ setTimeout(() => canvasCtl.fitNode(node.key), 50)
55
+ return
56
+ }
57
+ await s.switchBoard(home)
58
+ for (let i = 0; i < 12; i++) { // the board commits async - retry like viewNote
59
+ if (seq !== gotoSeq) return
60
+ const st = useStore.getState()
61
+ if (st.board === home) { // a cancelled/failed switch must not select here
62
+ const node = st.nodes.find((n) => n.frame === target && !n.missing)
63
+ if (node) {
64
+ st.select(node.key)
65
+ if (carry) st.setInteract(node.key)
66
+ setTimeout(() => canvasCtl.fitNode(node.key), 50)
67
+ return
68
+ }
69
+ }
70
+ await new Promise((r) => setTimeout(r, 250))
71
+ }
72
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Sticky notes (spec 18): the pure half. Widths the layout reserves, which node hosts a
3
+ * scene's note, the per-viewer hide state, and the comment anchor a note builds and resolves
4
+ * in the shell (a note is shell DOM - inspect.js never sees it, so the shell plays its part).
5
+ * No React, no store import: unit tests read this straight.
6
+ */
7
+ import { create } from 'zustand'
8
+
9
+ /** World px. A frame note is a column beside one frame; a scene note is wider, an intro. */
10
+ export const NOTE_W = 260
11
+ export const SCENE_NOTE_W = 380
12
+ /** The gutter between a note and its frame (the variant badge lives in the same gutter). */
13
+ export const NOTE_GAP = 24
14
+ /** Pin and card tint for anchors on a note - the paper's own hue. */
15
+ export const NOTE_HUE = 48
16
+
17
+ export type NoteKind = 'frame' | 'scene'
18
+ export const noteId = (kind: NoteKind, name: string): string => `${kind}:${name}`
19
+
20
+ /** The width tidy reserves in front of a node: its own note, and the scene note when this node
21
+ * hosts it - one column, so the wider of the two, never the sum. */
22
+ export function noteReserve(frameNote: boolean, sceneNote: boolean): number {
23
+ const w = Math.max(frameNote ? NOTE_W : 0, sceneNote ? SCENE_NOTE_W : 0)
24
+ return w ? w + NOTE_GAP : 0
25
+ }
26
+
27
+ /** The node that shows a scene's note: the scene's first node on the board in reading order -
28
+ * smallest y, then x, then original index (the deck rule), missing nodes skipped. Null when the
29
+ * scene has no node here. */
30
+ export function sceneNoteHost(
31
+ nodes: readonly { key: string; frame: string; x: number; y: number; missing?: boolean }[],
32
+ sceneOf: (frame: string) => string | undefined,
33
+ scene: string,
34
+ ): string | null {
35
+ let best: { key: string; x: number; y: number; i: number } | null = null
36
+ nodes.forEach((n, i) => {
37
+ if (n.missing || sceneOf(n.frame) !== scene) return
38
+ if (!best || n.y < best.y || (n.y === best.y && (n.x < best.x || (n.x === best.x && i < best.i)))) best = { key: n.key, x: n.x, y: n.y, i }
39
+ })
40
+ return best ? (best as { key: string }).key : null
41
+ }
42
+
43
+ // ---- per-viewer visibility -------------------------------------------------------------
44
+
45
+ const STORAGE = 'mv-notes'
46
+ interface NotesState {
47
+ /** the N toggle: false hides every note on the canvas */
48
+ all: boolean
49
+ /** note ids folded by their own corner */
50
+ hidden: string[]
51
+ /** fold or unfold one column (its ids together) */
52
+ toggle(ids: string[]): void
53
+ /** N: any note visible -> hide all; none -> show all (and unfold every column) */
54
+ toggleAll(anyVisible: boolean): void
55
+ }
56
+ const load = (): { all: boolean; hidden: string[] } => {
57
+ try {
58
+ const raw = JSON.parse(localStorage.getItem(STORAGE) ?? 'null')
59
+ if (raw && typeof raw === 'object') return { all: raw.all !== false, hidden: Array.isArray(raw.hidden) ? raw.hidden.filter((x: unknown) => typeof x === 'string') : [] }
60
+ } catch { /* storage unavailable or junk: shown by default */ }
61
+ return { all: true, hidden: [] }
62
+ }
63
+ const save = (s: { all: boolean; hidden: string[] }) => { try { localStorage.setItem(STORAGE, JSON.stringify(s)) } catch { /* private mode */ } }
64
+
65
+ export const useNotes = create<NotesState>((set, get) => ({
66
+ ...load(),
67
+ toggle(ids) {
68
+ const { all, hidden } = get()
69
+ const off = !all || ids.some((id) => hidden.includes(id))
70
+ // unfolding a column while N hid everything shows that column alone: N flips back on and
71
+ // every OTHER column is folded, so the one asked for is the one that appears
72
+ const next = off
73
+ ? { all: true, hidden: all ? hidden.filter((id) => !ids.includes(id)) : [...new Set([...allIds(), ...hidden].filter((id) => !ids.includes(id)))] }
74
+ : { all, hidden: [...new Set([...hidden, ...ids])] }
75
+ set(next); save(next)
76
+ },
77
+ toggleAll(anyVisible) {
78
+ const next = anyVisible ? { all: false, hidden: get().hidden } : { all: true, hidden: [] }
79
+ set(next); save(next)
80
+ },
81
+ }))
82
+ /** Every note id currently on the canvas - read from the DOM, the one place that knows. */
83
+ const allIds = (): string[] => (typeof document === 'undefined' ? [] : [...document.querySelectorAll('[data-note]')].map((el) => el.getAttribute('data-note')!))
84
+
85
+ export const noteVisible = (s: { all: boolean; hidden: string[] }, ids: string[]): boolean => s.all && !ids.some((id) => s.hidden.includes(id))
86
+
87
+ // ---- comment anchors on a note ------------------------------------------------------------
88
+
89
+ /** The same bundle inspect.js posts for a frame element, plus `note` so the shell resolves it
90
+ * itself. `rect` and `pos` are in node-body coordinates (the CommentLayer's), so a pin on a note
91
+ * sits at a negative x - left of the frame. */
92
+ export interface NoteAnchor {
93
+ el: { semantics: { tag: string; quote?: string }; cssPath: string; note: NoteKind; hue: number }
94
+ pos: { fx: number; fy: number }
95
+ rect: { x: number; y: number; w: number; h: number }
96
+ }
97
+
98
+ /** An nth-of-type chain from the sticky body to the element - stable across re-renders of the
99
+ * same markdown, honest when it changes (the quote fallback then decides). */
100
+ export function cssPathWithin(el: Element, root: Element): string {
101
+ const parts: string[] = []
102
+ let cur: Element | null = el
103
+ while (cur && cur !== root) {
104
+ const tag = cur.tagName.toLowerCase()
105
+ const parent: Element | null = cur.parentElement
106
+ if (!parent) break
107
+ const same = [...parent.children].filter((c) => c.tagName === cur!.tagName)
108
+ parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${same.indexOf(cur) + 1})` : tag)
109
+ cur = parent
110
+ }
111
+ return parts.join(' > ')
112
+ }
113
+
114
+ const quoteOf = (el: Element) => (el.textContent ?? '').trim().replace(/\s+/g, ' ')
115
+
116
+ /** Build the anchor for a click at (clientX, clientY) on `el` inside a sticky whose body is
117
+ * `root`; `toBody` maps a screen rect to node-body coordinates. */
118
+ export function noteAnchor(el: Element, root: Element, kind: NoteKind, at: { clientX: number; clientY: number }, toBody: (r: DOMRect) => { x: number; y: number; w: number; h: number }): NoteAnchor {
119
+ const r = el.getBoundingClientRect()
120
+ const quote = quoteOf(el).slice(0, 200)
121
+ return {
122
+ el: { semantics: { tag: el.tagName.toLowerCase(), ...(quote ? { quote } : {}) }, cssPath: cssPathWithin(el, root), note: kind, hue: NOTE_HUE },
123
+ pos: {
124
+ fx: r.width ? Math.min(1, Math.max(0, (at.clientX - r.left) / r.width)) : .5,
125
+ fy: r.height ? Math.min(1, Math.max(0, (at.clientY - r.top) / r.height)) : .5,
126
+ },
127
+ rect: toBody(r),
128
+ }
129
+ }
130
+
131
+ /** inspect.js's resolve, for a note: the path when it still matches, else tag + quote. */
132
+ export function resolveNoteAnchor(anchor: unknown, root: Element): Element | null {
133
+ const a = anchor as Partial<NoteAnchor> | null
134
+ const want = a?.el?.semantics ?? { tag: '' }
135
+ const match = (el: Element) => {
136
+ if (want.tag && el.tagName.toLowerCase() !== want.tag) return false
137
+ if (want.quote) { const t = quoteOf(el); if (!(t.startsWith(want.quote.slice(0, 60)) || t.includes(want.quote.slice(0, 40)))) return false }
138
+ return true
139
+ }
140
+ try {
141
+ const byPath = a?.el?.cssPath ? root.querySelector(a.el.cssPath) : null
142
+ if (byPath && match(byPath)) return byPath
143
+ } catch { /* stale selector */ }
144
+ if (want.quote && /^[a-z][a-z0-9-]*$/.test(want.tag)) for (const el of root.querySelectorAll(want.tag)) if (match(el)) return el
145
+ return null
146
+ }
147
+
148
+ export const isNoteAnchor = (anchor: unknown): anchor is NoteAnchor => {
149
+ const n = (anchor as any)?.el?.note
150
+ return n === 'frame' || n === 'scene'
151
+ }
@@ -1,6 +1,7 @@
1
1
  import { create } from 'zustand'
2
2
  import { ROUTE, slideSize } from '../const.ts'
3
- import { tidy, parseLayout, type BoardLayout } from './tidy.ts'
3
+ import { tidy, parseLayout, type BoardLayout, type TidyNode } from './tidy.ts'
4
+ import { noteReserve } from './notes.ts'
4
5
  import { stableNodeKey } from './keys.ts'
5
6
  // @ts-expect-error virtual module provided by the plugin
6
7
  import shConfig from 'virtual:sh-config'
@@ -18,8 +19,13 @@ const DATA: {
18
19
  titles?: Record<string, string>
19
20
  /** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
20
21
  policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
22
+ /** the generation of the glass textures this build shipped (publish-bakes.ts); absent = none */
23
+ bakes?: number
21
24
  } | null = shData
22
25
 
26
+ /** The published textures' generation, or 0: the static index this build shipped is at /__mv/bakes/<gen>/index.json. */
27
+ export const BAKES = DATA?.bakes ?? 0
28
+
23
29
  /** Every published board is locked to a stage mode - the canvas shell is never
24
30
  * offered on this bundle (01-sharing §5.1's all-boards rule). */
25
31
  export const LOCKED_SHELL = DATA?.policy?.lockedShell === true
@@ -45,8 +51,8 @@ export function hydrateBoardPolicy(boards: Record<string, { type?: string; open?
45
51
  for (const [k, v] of Object.entries(boards)) if (!BOARD_POLICY[k]) BOARD_POLICY[k] = v
46
52
  }
47
53
 
48
- 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 }
49
- export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number; title?: string; description?: string }[] }
54
+ 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 }
55
+ export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number; title?: string; description?: string; note?: string }[] }
50
56
  export interface Node {
51
57
  key: string; frame: string; x: number; y: number; w: number; h: number
52
58
  /** RESOLVED theme (what renders): themeUser ?? frame meta.theme ?? viewTheme. */
@@ -54,6 +60,11 @@ export interface Node {
54
60
  /** Renavigation nonce: bumped when the shell wants this iframe on a FRESH URL
55
61
  * (errored frame whose file is back in the manifest). Never persisted. */
56
62
  nav?: number
63
+ /** Source revision: bumped when the frame reports an HMR update (its modules changed without a
64
+ * navigation). Never persisted. */
65
+ rev?: number
66
+ /** The theme the frame reports as APPLIED (sh:theme-applied) - sleep waits for it to match. */
67
+ themeOn?: string
57
68
  /** Explicit per-frame override, set by scoped theme actions; cleared by a global set.
58
69
  * The only theme value that persists into the board file. */
59
70
  themeUser?: string
@@ -197,6 +208,21 @@ export async function boardFrames(name: string): Promise<string[]> {
197
208
  }
198
209
 
199
210
  const HEADER = 28
211
+ /** What tidy sees: nodes with their scene, variant run, header-inclusive height, and the sticky
212
+ * note width to reserve in front (spec 18: the frame's note, and the scene's on every member -
213
+ * tidy keeps the scene reserve on the first node it places). */
214
+ export function tidyInput(nodes: readonly Node[], manifest: Manifest | null): TidyNode[] {
215
+ const entryOf = (id: string) => manifest?.frames.find((f) => f.id === id)
216
+ const sceneNote = (scene: string) => !!manifest?.scenes.find((s) => s.name === scene)?.note
217
+ return nodes.map((n) => {
218
+ const f = entryOf(n.frame)
219
+ const scene = f?.scene ?? ''
220
+ return {
221
+ key: n.key, frame: n.frame, scene, group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER,
222
+ noteW: noteReserve(!!f?.note, false), sceneNoteW: noteReserve(false, sceneNote(scene)),
223
+ }
224
+ })
225
+ }
200
226
  let toastSeq = 0
201
227
  const nodeKey = () => 'n_' + Math.random().toString(36).slice(2, 8)
202
228
 
@@ -302,6 +328,8 @@ interface State {
302
328
  resizeNode(key: string, w: number, h: number): void
303
329
  measureNode(key: string, frameId: string, ownWidth: number, measuredWidth: number, height: number): void
304
330
  setStatus(key: string, status: Node['status'], error?: string): void
331
+ bumpRev(key: string): void
332
+ setThemeOn(key: string, theme: string): void
305
333
  reloadFrame(key: string, automatic?: boolean): void
306
334
  removeNode(key: string): void
307
335
  select(key: string | null, additive?: boolean): void
@@ -601,11 +629,7 @@ export const useStore = create<State>((set, get) => {
601
629
  }
602
630
  }
603
631
  if ((!boardHash || needTidy) && nodes.length) {
604
- const entryOf = (id: string) => manifest.frames.find((f) => f.id === id)
605
- const placedAll = tidy(nodes.map((n) => {
606
- const f = entryOf(n.frame)
607
- return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
608
- }), effectiveLayout(layout, sceneRows), layoutWarn)
632
+ const placedAll = tidy(tidyInput(nodes, manifest), effectiveLayout(layout, sceneRows), layoutWarn)
609
633
  for (const pl of placedAll) { const n = nodes.find((x) => x.key === pl.key)!; n.x = pl.x; n.y = pl.y }
610
634
  }
611
635
  // dirty matches disk by construction - except when load-time pruning changed the
@@ -613,11 +637,7 @@ export const useStore = create<State>((set, get) => {
613
637
  // surface recipe problems at load (dry-run): materialized boards otherwise
614
638
  // never run tidy, so a broken agent-authored layout would fail silently
615
639
  if (layout && boardHash && !needTidy && nodes.length) {
616
- const entryOf2 = (id: string) => manifest.frames.find((f) => f.id === id)
617
- tidy(nodes.map((n) => {
618
- const f = entryOf2(n.frame)
619
- return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
620
- }), layout, layoutWarn)
640
+ tidy(tidyInput(nodes, manifest), layout, layoutWarn)
621
641
  }
622
642
  return { manifest, nodes, boardHash, boardAuto, deviceView, sceneRows, layout, layoutRaw, baseLayout, selection: [], dirty: prunedAtLoad }
623
643
  } catch { return null }
@@ -985,6 +1005,8 @@ export const useStore = create<State>((set, get) => {
985
1005
  if (name) get().runTidy() // restore must NOT tidy - it would destroy positions
986
1006
  else scheduleSave()
987
1007
  },
1008
+ bumpRev(key) { set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, rev: (n.rev ?? 0) + 1 } : n)) })) },
1009
+ setThemeOn(key, theme) { set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, themeOn: theme } : n)) })) },
988
1010
  setStatus(key, status, error) {
989
1011
  // any real ready/error resets the one-shot retry allowance, so a later manual reload or a
990
1012
  // fresh manifest earns its own auto-retry again
@@ -1189,11 +1211,7 @@ export const useStore = create<State>((set, get) => {
1189
1211
  },
1190
1212
  runTidy() {
1191
1213
  const { nodes, manifest, sceneRows, layout } = get()
1192
- const entryOf = (id: string) => manifest?.frames.find((f) => f.id === id)
1193
- const placed = tidy(nodes.map((n) => {
1194
- const f = entryOf(n.frame)
1195
- return { key: n.key, frame: n.frame, scene: f?.scene ?? '', group: f?.variantGroup, variant: f?.variant, w: n.w, h: n.h + HEADER }
1196
- }), effectiveLayout(layout, sceneRows), layoutWarn)
1214
+ const placed = tidy(tidyInput(nodes, manifest), effectiveLayout(layout, sceneRows), layoutWarn)
1197
1215
  set((s) => ({
1198
1216
  nodes: s.nodes.map((n) => {
1199
1217
  const p = placed.find((x) => x.key === n.key)