@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.
- package/CHANGELOG.md +96 -0
- package/README.md +3 -1
- package/dist/bake-kaf5kGZ7.mjs +747 -0
- package/dist/{build-D_g53Bp2.mjs → build-7ed5H2vT.mjs} +215 -13
- package/dist/cli.mjs +19 -11
- package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
- package/dist/{daemon-Bbh_jmui.mjs → daemon-DbHvLQUL.mjs} +1 -1
- package/dist/{dev-LnIISva5.mjs → dev-D3mP2x27.mjs} +205 -10
- package/dist/{init-B7YhcN2o.mjs → init-BQYCS3EU.mjs} +2 -2
- package/dist/{manifest-CaslQIAO.mjs → manifest-B01PSyDc.mjs} +34 -7
- package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
- package/dist/{plugin-D2msH1cj.mjs → plugin-DI-7NAnx.mjs} +108 -29
- package/dist/{poster-BEjUcQP3.mjs → poster-DNh6N27C.mjs} +1 -1
- package/dist/publish-bakes-Dp-ZFk3d.mjs +216 -0
- package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
- package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
- package/dist/{shot-BzQ0PXKH.mjs → shot-BFEuYbaz.mjs} +1 -1
- package/dist/{shot-DlmTO8AF.mjs → shot-DMDvDbeP.mjs} +14 -7
- package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
- package/docs/publish.md +27 -5
- package/docs/sticky-notes.md +43 -0
- package/package.json +2 -1
- package/src/client/content/diagram.tsx +27 -1
- package/src/client/content/index.tsx +2 -2
- package/src/client/content/md.ts +48 -0
- package/src/client/frame-host/bridge.js +4 -9
- package/src/client/frame-host/main.tsx +20 -6
- package/src/client/shell/App.tsx +14 -63
- package/src/client/shell/Comments.tsx +77 -14
- package/src/client/shell/Play.tsx +6 -3
- package/src/client/shell/canvas/Canvas.tsx +12 -3
- package/src/client/shell/canvas/FrameNode.tsx +99 -89
- package/src/client/shell/canvas/Sticky.tsx +284 -0
- package/src/client/shell/canvas/admission.ts +70 -0
- package/src/client/shell/canvas/sleep.ts +194 -0
- package/src/client/shell/goto.ts +72 -0
- package/src/client/shell/notes.ts +151 -0
- package/src/client/shell/store.ts +36 -18
- package/src/client/shell/styles.css +92 -25
- package/src/client/shell/tidy.ts +20 -2
- package/src/shared/sleep-rule.ts +41 -0
- package/templates/AGENTS-embedded.md +15 -0
- package/templates/AGENTS-studio.md +15 -0
- package/templates/instructions/craft.md +9 -0
- package/templates/instructions/publish.md +62 -9
- package/templates/instructions/shape.md +62 -0
- package/src/client/frame-host/serialize.ts +0 -195
- 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
|
|
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
|
-
|
|
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
|
|
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)
|