@marver-design/marver 0.4.0 → 0.5.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.
@@ -0,0 +1,195 @@
1
+ // SPEC-M5 slice 1: the DOM-snapshot serializer. Turns a live, same-origin frame Document into a
2
+ // self-contained STATIC html string: post-render DOM + full inlined CSS (styleSheets +
3
+ // adoptedStyleSheets), JS stripped. Rendered in a `sandbox="allow-same-origin"` (NO allow-scripts)
4
+ // iframe srcdoc, it reflows / theme-flips / device-sweeps with the browser's own layout engine and
5
+ // the app's own stylesheet - so color is identical (same tokens, no raster) and layout is real CSS.
6
+ //
7
+ // Runs in the SHELL (same origin as every frame), reading the live iframe's contentDocument directly
8
+ // - no bridge round-trip. Fail soft: any throw returns a `degraded` result the coordinator refuses to
9
+ // show. Correctness beats the flash-guard: a frame we cannot serialise faithfully stays live.
10
+
11
+ export interface ScrollEntry { sel: string; top: number; left: number }
12
+ export interface SerializeResult {
13
+ html: string
14
+ scrollMap: ScrollEntry[] // native scrollers to restore shell-side after load
15
+ degraded: string[] // 'canvas' | 'video' | 'shadow-dom' | 'cross-origin-css' | 'js-layout'
16
+ notes: string[]
17
+ cssBytes: number
18
+ }
19
+
20
+ const STRIP_TAGS = new Set(['SCRIPT', 'NOSCRIPT'])
21
+
22
+ /** Absolutize relative url() in a sheet's cssText against that sheet's href (codex P1: consolidating
23
+ * rules into one <style> moves the url() base from each sheet to the srcdoc base). */
24
+ function absolutizeUrls(css: string, sheetHref: string | null): string {
25
+ if (!sheetHref) return css
26
+ return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (m, q, ref: string) => {
27
+ if (/^(data:|https?:|blob:|#|\/)/i.test(ref) || !ref.trim()) return m
28
+ try { return `url(${q}${new URL(ref, sheetHref).href}${q})` } catch { return m }
29
+ })
30
+ }
31
+
32
+ /** Serialise one sheet's rules, recursing into same-origin @import (so imported CSS is inlined, not
33
+ * left as an invalid mid-list @import pointing at an un-inlined sheet). Bumps `xo` on any unreadable
34
+ * (cross-origin) sheet or import so the caller can degrade. */
35
+ function rulesText(rules: CSSRuleList, href: string | null, xo: { n: number }): string {
36
+ let text = ''
37
+ for (const r of Array.from(rules)) {
38
+ if (r.type === 3 /* CSSRule.IMPORT_RULE */) {
39
+ const imp = r as CSSImportRule & { supportsText?: string; layerName?: string | null }
40
+ const sheet = imp.styleSheet
41
+ if (!sheet) { xo.n++; continue }
42
+ let inner: string
43
+ try { inner = rulesText(sheet.cssRules, sheet.href ?? href, xo) } catch { xo.n++; continue }
44
+ // preserve the import's conditions - flattening them makes a conditional (e.g. desktop-only,
45
+ // layered, feature-gated) sheet globally active and mis-render the lean.
46
+ if (imp.layerName != null) inner = `@layer${imp.layerName ? ' ' + imp.layerName : ''}{\n${inner}\n}`
47
+ if (imp.supportsText) inner = `@supports (${imp.supportsText}){\n${inner}\n}`
48
+ const m = imp.media?.mediaText
49
+ if (m && m !== 'all') inner = `@media ${m}{\n${inner}\n}`
50
+ text += inner
51
+ continue
52
+ }
53
+ text += r.cssText + '\n'
54
+ }
55
+ return absolutizeUrls(text, href)
56
+ }
57
+
58
+ /** Collect every CSS rule the document renders with: <style>/<link> sheets (recursing @import) AND
59
+ * constructable adoptedStyleSheets. Cross-origin sheets throw on .cssRules - record + degrade (never
60
+ * claim fidelity). Honours per-sheet media (`<link media=print>`) and skips disabled sheets. */
61
+ function collectCss(doc: Document, degraded: string[], notes: string[]): string {
62
+ const chunks: string[] = []
63
+ const xo = { n: 0 }
64
+ const dump = (sheet: CSSStyleSheet, href: string | null, label: string) => {
65
+ if (sheet.disabled) return
66
+ let rules: CSSRuleList | null = null
67
+ try { rules = sheet.cssRules } catch { xo.n++; return }
68
+ if (!rules) return
69
+ const text = rulesText(rules, href, xo)
70
+ if (!text) return
71
+ const media = sheet.media?.mediaText
72
+ chunks.push(`/* ${label} */\n${media && media !== 'all' ? `@media ${media}{\n${text}\n}` : text}`)
73
+ }
74
+ for (const s of Array.from(doc.styleSheets)) dump(s as CSSStyleSheet, s.href, s.href ?? 'inline')
75
+ const adopted = (doc as Document & { adoptedStyleSheets?: CSSStyleSheet[] }).adoptedStyleSheets ?? []
76
+ adopted.forEach((s, i) => dump(s, null, `adopted[${i}]`))
77
+ if (xo.n) { degraded.push('cross-origin-css'); notes.push(`${xo.n} cross-origin stylesheet(s)/import(s) unreadable`) }
78
+ if (adopted.length) notes.push(`${adopted.length} adoptedStyleSheet(s) inlined`)
79
+ return chunks.join('\n')
80
+ }
81
+
82
+ /** A stable-ish selector for restoring scroll in the identical-structure lean doc. */
83
+ function selectorFor(el: Element): string {
84
+ const seg: string[] = []
85
+ for (let cur: Element | null = el; cur && cur !== el.ownerDocument.documentElement; cur = cur.parentElement) {
86
+ if (cur.id) { seg.unshift(`#${CSS.escape(cur.id)}`); break }
87
+ const tag = cur.tagName.toLowerCase()
88
+ let n = 1
89
+ for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling) if (sib.tagName === cur.tagName) n++
90
+ seg.unshift(`${tag}:nth-of-type(${n})`)
91
+ }
92
+ return seg.join('>')
93
+ }
94
+
95
+ /** Strip execution + inline handlers from the clone; flag content that cannot reflow as DOM. */
96
+ function scrub(root: Element, doc: Document, degraded: string[]): void {
97
+ root.querySelectorAll('script, noscript, link[rel~="modulepreload"], link[rel~="preload"], link[rel~="stylesheet"]').forEach((n) => n.remove())
98
+ const walk = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
99
+ const flag = { canvas: false, video: false, nested: false }
100
+ for (let el = walk.currentNode as Element | null; el; el = walk.nextNode() as Element | null) {
101
+ for (const attr of Array.from(el.attributes)) {
102
+ if (/^on/i.test(attr.name)) el.removeAttribute(attr.name)
103
+ else if (/^\s*javascript:/i.test(attr.value)) el.removeAttribute(attr.name)
104
+ }
105
+ const tag = el.tagName
106
+ if (tag === 'CANVAS') flag.canvas = true
107
+ if (tag === 'VIDEO') flag.video = true
108
+ // a nested iframe/object/embed can't reflow in a scriptless clone (blank/stale, and would
109
+ // re-fetch third-party content) - degrade to live rather than ship a broken lean.
110
+ if (tag === 'IFRAME' || tag === 'OBJECT' || tag === 'EMBED') flag.nested = true
111
+ if (STRIP_TAGS.has(tag)) el.remove()
112
+ }
113
+ if (flag.canvas) degraded.push('canvas')
114
+ if (flag.video) degraded.push('video')
115
+ if (flag.nested) degraded.push('nested-frame')
116
+ }
117
+
118
+ /** Open shadow roots must be detected on the ORIGINAL tree - cloneNode(true) drops them, so the clone
119
+ * can never reveal them. A frame with any shadow DOM (open here, or closed via the boot-time flag)
120
+ * degrades to live rather than shipping a lean copy missing its shadow content. */
121
+ function hasOpenShadow(doc: Document): boolean {
122
+ const walk = doc.createTreeWalker(doc.documentElement, NodeFilter.SHOW_ELEMENT)
123
+ for (let el = walk.currentNode as Element | null; el; el = walk.nextNode() as Element | null)
124
+ if ((el as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot) return true
125
+ return false
126
+ }
127
+
128
+ /** Copy live form PROPERTIES (value/checked/selected) into the clone. They are runtime state, not
129
+ * attributes, so cloneNode never carries them - without this a re-captured frame shows empty inputs
130
+ * and unchecked boxes. Original and clone share structure, so we pair them in document order.
131
+ * tagName/type checks (not instanceof) because the clone nodes are owned by the frame's realm. */
132
+ function syncFormState(doc: Document, clone: HTMLElement): void {
133
+ const live = doc.querySelectorAll('input, textarea, option')
134
+ const dst = clone.querySelectorAll('input, textarea, option')
135
+ live.forEach((src, i) => {
136
+ const d = dst[i]
137
+ if (!d || d.tagName !== src.tagName) return
138
+ if (src.tagName === 'INPUT') {
139
+ const s = src as HTMLInputElement
140
+ // never bake a password into the retained snapshot html; a file input can't be reconstructed
141
+ if (s.type === 'password' || s.type === 'file') { d.removeAttribute('value'); return }
142
+ if (s.type === 'checkbox' || s.type === 'radio') s.checked ? d.setAttribute('checked', '') : d.removeAttribute('checked')
143
+ else d.setAttribute('value', s.value)
144
+ } else if (src.tagName === 'TEXTAREA') { d.textContent = (src as HTMLTextAreaElement).value }
145
+ else if (src.tagName === 'OPTION') { (src as HTMLOptionElement).selected ? d.setAttribute('selected', '') : d.removeAttribute('selected') }
146
+ })
147
+ }
148
+
149
+ /**
150
+ * Serialize a live same-origin Document into a static, self-contained html string.
151
+ * @param doc the live frame document (same origin - we read its cssRules directly)
152
+ * @param baseHref the frame's real URL, injected as <base> so relative url()/img/font resolve
153
+ */
154
+ export function serializeDoc(doc: Document, baseHref: string): SerializeResult {
155
+ const notes: string[] = []
156
+ const degraded: string[] = []
157
+
158
+ // shadow DOM (open detected on the original tree, closed via the boot-time attachShadow flag):
159
+ // either kind degrades the frame to live - cloneNode cannot carry a shadow root.
160
+ const win = doc.defaultView as (Window & { __mvClosedShadow?: boolean }) | null
161
+ if (win?.__mvClosedShadow || hasOpenShadow(doc)) { degraded.push('shadow-dom'); notes.push('shadow root(s) present') }
162
+
163
+ const css = collectCss(doc, degraded, notes)
164
+ const cssBytes = new Blob([css]).size
165
+
166
+ // scroll offsets to restore shell-side (native scrollers; a virtualised one that fails to resolve
167
+ // OR whose offset does not stick re-degrades the frame in the coordinator). != 0 catches RTL/negative.
168
+ const scrollMap: ScrollEntry[] = []
169
+ for (const el of Array.from(doc.querySelectorAll<HTMLElement>('*'))) {
170
+ if (el.scrollTop !== 0 || el.scrollLeft !== 0) scrollMap.push({ sel: selectorFor(el), top: el.scrollTop, left: el.scrollLeft })
171
+ }
172
+ const de = doc.documentElement
173
+ if (de.scrollTop !== 0 || de.scrollLeft !== 0) scrollMap.push({ sel: ':root', top: de.scrollTop, left: de.scrollLeft })
174
+
175
+ // clone the RENDERED dom (React's committed output = authored markup that reflows under CSS)
176
+ const html = doc.documentElement.cloneNode(true) as HTMLElement
177
+ scrub(html, doc, degraded)
178
+ syncFormState(doc, html)
179
+
180
+ // head: <meta charset> + <base> (relative url()/img/font resolve against the real frame URL) +
181
+ // ONE inlined stylesheet (both themes, all media queries). Drop the clone's own style nodes.
182
+ const head = html.querySelector('head') ?? html.insertBefore(doc.createElement('head'), html.firstChild)
183
+ head.querySelectorAll('style').forEach((n) => n.remove())
184
+ // sentinel first rule: the coordinator checks --mv-lean-ok resolves before showing the lean. If a
185
+ // hardened host's CSP (style-src 'self') blocked this inline <style>, the sentinel is absent and the
186
+ // frame stays live rather than showing an unstyled cover.
187
+ const style = doc.createElement('style'); style.textContent = ':root{--mv-lean-ok:1}\n' + css
188
+ const base = doc.createElement('base'); base.setAttribute('href', baseHref)
189
+ const meta = doc.createElement('meta'); meta.setAttribute('charset', 'utf-8')
190
+ head.insertBefore(style, head.firstChild)
191
+ head.insertBefore(base, head.firstChild)
192
+ head.insertBefore(meta, head.firstChild)
193
+
194
+ return { html: '<!doctype html>\n' + html.outerHTML, scrollMap, degraded: Array.from(new Set(degraded)), notes, cssBytes }
195
+ }
@@ -1,9 +1,10 @@
1
1
  import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { useStore, CONFIG, PUBLISHED, boardLabel, cap, fetchBoardNames, type FrameEntry } from './store.ts'
3
+ import { useStore, CONFIG, PUBLISHED, boardLabel, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
4
4
  import { Tip } from './Tip.tsx'
5
5
  import { PKG, ROUTE } from '../const.ts'
6
6
  import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
+ import { frameByWindow } from './canvas/frame-registry.ts'
7
8
  import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
8
9
  import { bootHash, parseHash, writeHash } from './hash.ts'
9
10
  import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, CommentIcon, DevicesIcon, FrameRectIcon, IntentGlyph, LaserIcon, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
@@ -23,7 +24,7 @@ function SceneGroup({ name, count, held, onPick, children }: { name: string; cou
23
24
  <button className={`it${held ? ' held' : ''}`} onClick={() => setOpen(!open)}>
24
25
  <CaretIcon size={11} className="tw" style={{ transform: open ? undefined : 'rotate(-90deg)' }} />
25
26
  {/* the NAME selects every frame in the scene; the caret/row still collapses */}
26
- <span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{cap(name) || '(root)'}</span>
27
+ <span onClick={(e) => { if (!onPick) return; e.stopPropagation(); onPick() }}>{humanize(name) || '(root)'}</span>
27
28
  <small>{count}</small>
28
29
  </button>
29
30
  {open && children}
@@ -200,7 +201,7 @@ function SelectionBar() {
200
201
  </Tip>
201
202
  ))}
202
203
  <i className="sep" />
203
- <Tip label={<><b>{multi ? `Copy ${selection.length} file paths` : 'Copy file path'}</b><span className="k">C</span></>}>
204
+ <Tip label={<><b>{multi ? `Copy ${selection.length} file paths` : 'Copy file path'}</b><span className="k">⇧P</span></>}>
204
205
  <button className="icon"
205
206
  onClick={() => { navigator.clipboard.writeText(selectedFrames().map((f) => f.file).join('\n')); toast(multi ? `${selection.length} file paths copied` : 'file path copied') }}><SignpostIcon size={15} /></button>
206
207
  </Tip>
@@ -379,8 +380,19 @@ function ThemeMenu() {
379
380
  }
380
381
 
381
382
  export function App() {
382
- const { manifest, nodes, panelOpen, toasts, selection, laser } = useStore()
383
+ // B0.1: per-field selectors, NOT `useStore()` (which subscribes to the whole store and
384
+ // re-rendered the entire shell on every setScale tick during a pan/zoom).
385
+ const manifest = useStore((s) => s.manifest)
386
+ const nodes = useStore((s) => s.nodes)
387
+ const panelOpen = useStore((s) => s.panelOpen)
388
+ const toasts = useStore((s) => s.toasts)
389
+ const selection = useStore((s) => s.selection)
390
+ const laser = useStore((s) => s.laser)
383
391
  const commentMode = useComments((s) => s.commentMode)
392
+ // SPEC-M5: laser/comment paint live outlines + do element-picking INSIDE the live frame; a scriptless
393
+ // lean cover would hide them, so a body class suppresses the cover while either mode is active.
394
+ useEffect(() => { document.body.classList.toggle('sh-laser', laser) }, [laser])
395
+ useEffect(() => { document.body.classList.toggle('sh-commenting', commentMode) }, [commentMode])
384
396
  const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
385
397
  const [pillOpen, setPillOpen] = useState(true)
386
398
 
@@ -501,6 +513,13 @@ export function App() {
501
513
  useEffect(() => {
502
514
  if (!import.meta.hot) return
503
515
  import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
516
+ // A7 controlled HMR: a frame file changed. Reload exactly the affected frames through the
517
+ // lease-aware path (idle frames now, leased ones deferred to a safe point) - never a shell
518
+ // reload, never a React Fast Refresh yanking a frame the user is in.
519
+ import.meta.hot.on('sh:frame-invalidated', (m: any) => {
520
+ const frameIds = Array.isArray(m?.frameIds) ? m.frameIds.filter((x: unknown) => typeof x === 'string') : []
521
+ if (frameIds.length && typeof m?.revision === 'string') useStore.getState().invalidateFrames(frameIds, m.revision)
522
+ })
504
523
  // multi-viewer sync: another viewer (or an agent) saved this board. A clean canvas
505
524
  // re-boots silently, keeping whatever selection survives; a dirty one keeps its
506
525
  // edits and converges through the 409 path on its next save (disk wins, spec §8).
@@ -521,11 +540,11 @@ export function App() {
521
540
  const onMsg = (e: MessageEvent) => {
522
541
  const data = e.data
523
542
  if (!data || typeof data.type !== 'string' || !data.type.startsWith('sh:')) return
524
- const iframes = [...document.querySelectorAll('iframe')]
525
- const el = iframes.find((f) => f.contentWindow === e.source) as HTMLIFrameElement | undefined
526
- if (!el) return
527
- const nodeKey = el.closest('[data-node]')?.getAttribute('data-node')
528
- if (!nodeKey) return
543
+ // B0.3: O(1) registry lookup - also the security gate (unknown source = not a
544
+ // registered frame = dropped), replacing a per-message iframe scan + DOM walk.
545
+ const reg = frameByWindow(e.source)
546
+ if (!reg) return
547
+ const { key: nodeKey, iframe: el } = reg
529
548
  const s = useStore.getState()
530
549
 
531
550
  if (data.type === 'sh:ready') {
@@ -579,6 +598,29 @@ export function App() {
579
598
  // walking a flow must not eject you to design mode at every hop
580
599
  if (s.interact === nodeKey) setInteract(node.key)
581
600
  setTimeout(() => canvasCtl.fitNode(node.key), 50)
601
+ } else if (data.type === 'sh:wheel') {
602
+ // B0.2: a passive frame forwarded a wheel event; the canvas owns it. Never for the
603
+ // interact target or play (the app scrolls itself - the parent stays authoritative).
604
+ if (s.play || s.interact === nodeKey) return
605
+ const nums = [data.deltaX, data.deltaY, data.clientX, data.clientY].map(Number)
606
+ if (!nums.every(Number.isFinite)) return
607
+ const [deltaX, deltaY, localX, localY] = nums
608
+ const rect = el.getBoundingClientRect()
609
+ // iframe-local (untransformed CSS px) -> shell-screen px: rect is the frame's
610
+ // transformed size, so rect.width/clientWidth is its effective on-screen scale.
611
+ // Plain rect.left+localX would put the zoom origin in the wrong place when zoomed.
612
+ const sx = el.clientWidth ? rect.width / el.clientWidth : 1
613
+ const sy = el.clientHeight ? rect.height / el.clientHeight : 1
614
+ canvasCtl.wheel({
615
+ deltaX, deltaY, deltaMode: Number(data.deltaMode) || 0,
616
+ ctrlKey: !!data.ctrlKey, metaKey: !!data.metaKey,
617
+ clientX: rect.left + localX * sx, clientY: rect.top + localY * sy,
618
+ })
619
+ } else if (data.type === 'sh:interaction') {
620
+ // A6: the frame reports transient laser/comment engagement (pointer inside + mode on).
621
+ // While engaged the frame is leased, so a hot update to it defers until disengage.
622
+ s.setExternalLease(nodeKey, 'laser', !!data.laser)
623
+ s.setExternalLease(nodeKey, 'comment', !!data.comment)
582
624
  }
583
625
  }
584
626
  window.addEventListener('message', onMsg)
@@ -609,7 +651,7 @@ export function App() {
609
651
  s.setLaser(!s.laser)
610
652
  }
611
653
  // C = comment mode (SPEC-M3 §6, the Figma/Miro convention) · Shift+C = hide/show
612
- // pins · copy-file-paths moved to Y (changelog 0.4.0)
654
+ // pins · Shift+P = copy file path(s) (P alone is play; D6, changelog 0.4.0)
613
655
  if (e.key === 'c' && !e.shiftKey) {
614
656
  const c = commentsStore()
615
657
  if (!c.commentMode) s.setLaser(false)
@@ -617,7 +659,7 @@ export function App() {
617
659
  toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
618
660
  }
619
661
  if (e.key === 'C' && e.shiftKey) { const c = commentsStore(); c.setShow(!c.show) }
620
- if (e.key === 'y' && s.selection.length) {
662
+ if (e.key === 'P' && e.shiftKey && s.selection.length) {
621
663
  const files = s.selection
622
664
  .map((k) => { const n = s.nodes.find((x) => x.key === k); return n ? s.frameFor(n)?.file : undefined })
623
665
  .filter((f): f is string => !!f)
@@ -702,8 +744,14 @@ export function App() {
702
744
  mounted so collapse/expand can crossfade-morph between them */}
703
745
  <aside className={`sh-panel${panelOpen ? '' : ' closed'}`} aria-hidden={!panelOpen}>
704
746
  <div className="sh-panel-top">
705
- <ParallelogramDuoIcon size={21} className="mark" />
706
- <span className="name">Marver</span>
747
+ {/* the mark links to the marver site; the title is the humanized repo name (C2: names
748
+ the project so two concurrent canvases are never confused), ellipsed if long */}
749
+ <Tip side="bottom" label="marver.design">
750
+ <a className="mark-link" href="https://marver.design" target="_blank" rel="noreferrer" aria-label="marver.design" tabIndex={panelOpen ? 0 : -1}>
751
+ <ParallelogramDuoIcon size={21} className="mark" />
752
+ </a>
753
+ </Tip>
754
+ <span className="name" title={CONFIG.projectName || 'Marver'}>{CONFIG.projectName ? humanize(CONFIG.projectName) : 'Marver'}</span>
707
755
  <Tip side="bottom" label={<><b>Collapse panel</b><span>⌘\</span></>}><button className="sh-ibtn" onClick={togglePanel} tabIndex={panelOpen ? 0 : -1}><PanelFilledIcon size={17} /></button></Tip>
708
756
  </div>
709
757
  <div className="sh-panel-scroll">
@@ -157,7 +157,7 @@ function ThreadCard({ thread, at, node }: { thread: Thread; at: { x: number; y:
157
157
  if (sent.trim() && await useComments.getState().replyOk(thread.id, sent)) setText((cur) => (cur === sent ? '' : cur))
158
158
  }
159
159
  return (
160
- <div className={`cm-card sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, node.h - 40) }}
160
+ <div data-sh-wheel-local className={`cm-card sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, node.h - 40) }}
161
161
  onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
162
162
  {/* thread-level actions pin to the card corner, out of the header's flow -
163
163
  the name row never has to share its line with them */}
@@ -227,7 +227,7 @@ function DraftComposer({ at, node }: { at: { x: number; y: number }; node: Node
227
227
  useEffect(() => { ref.current?.focus() }, [])
228
228
  const flip = at.x > node.w * 0.55
229
229
  return (
230
- <div className={`cm-card cm-draft sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, node.h - 40) }}
230
+ <div data-sh-wheel-local className={`cm-card cm-draft sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, node.h - 40) }}
231
231
  onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
232
232
  <div className="cm-compose">
233
233
  <div className="cm-inputwrap">
@@ -453,9 +453,10 @@ export function CommentsController() {
453
453
  return () => window.removeEventListener('hashchange', onHash)
454
454
  }, [])
455
455
 
456
- // broadcast pick mode to every frame (laser rides along inside the bridge)
456
+ // broadcast pick mode to every LIVE frame (laser rides along inside the bridge). Scoped to
457
+ // .sh-live so the SPEC-M5 lean cover (.sh-lean, a scriptless snapshot) is never messaged.
457
458
  useEffect(() => {
458
- for (const f of document.querySelectorAll('iframe'))
459
+ for (const f of document.querySelectorAll('iframe.sh-live'))
459
460
  (f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
460
461
  }, [commentMode])
461
462
 
@@ -120,6 +120,12 @@ function BoardMenu({ current }: { current: string }) {
120
120
  function PlayInner() {
121
121
  const play = useStore((s) => s.play)
122
122
  const board = useStore((s) => s.board)
123
+ // A6/A7: a controlled frame edit while play is open never auto-reloads the live stage
124
+ // (that would destroy the user's session mid-prototype). It records playUpdateRevision;
125
+ // we surface an "Update ready" affordance, and applyPlayUpdate bumps playNav to reload
126
+ // the stage at its current position on an explicit click.
127
+ const playUpdateRevision = useStore((s) => s.playUpdateRevision)
128
+ const playNav = useStore((s) => s.playNav)
123
129
  const iframeRef = useRef<HTMLIFrameElement>(null)
124
130
  // the src is frozen at mount - navigation happens INSIDE the stage; device and theme
125
131
  // changes must never reload it (a phone does not remount when you flip dark mode)
@@ -143,6 +149,16 @@ function PlayInner() {
143
149
 
144
150
  const postStage = (msg: Record<string, unknown>) => iframeRef.current?.contentWindow?.postMessage(msg, '*')
145
151
 
152
+ // apply a deferred update: reload the stage iframe at its CURRENT frame + theme on a fresh
153
+ // rev-stamped URL. sh:stage-ready replays position; device size is shell-owned.
154
+ useEffect(() => {
155
+ if (!playNav || !iframeRef.current) return
156
+ const p = useStore.getState().play
157
+ if (!p) return
158
+ iframeRef.current.src = `${ROUTE}/stage/?at=${encodeURIComponent(p.at)}&theme=${encodeURIComponent(p.theme)}&r=${playNav}`
159
+ }, [playNav])
160
+ const applyUpdate = () => useStore.getState().applyPlayUpdate()
161
+
146
162
  const exit = () => {
147
163
  const { at } = useStore.getState().play ?? {}
148
164
  useStore.getState().setPlay(null)
@@ -378,6 +394,14 @@ function PlayInner() {
378
394
  </button>
379
395
  </Tip>
380
396
  ))}
397
+ {playUpdateRevision && (
398
+ <>
399
+ <i className="sep" />
400
+ <Tip inv side="bottom" label={<><b>Update ready</b><span>an edit landed - reload this prototype</span></>}>
401
+ <button className="sh-play-update" onClick={applyUpdate}><ReloadIcon size={13} /><span>Update</span></button>
402
+ </Tip>
403
+ </>
404
+ )}
381
405
  <i className="sep" />
382
406
  <Tip inv side="bottom" label={<><b>Collapse toolbar</b><span>H hides everything</span><span className="k">⌘/</span></>}>
383
407
  <button onClick={() => setChrome('collapsed')}>
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
2
2
  import { TransformWrapper, TransformComponent, type ReactZoomPanPinchContentRef } from 'react-zoom-pan-pinch'
3
3
  import { CONFIG, useStore } from '../store.ts'
4
4
  import { bootHash } from '../hash.ts'
5
+ import { startPerf } from '../perf.ts'
5
6
  import { FrameNode, HEADER } from './FrameNode.tsx'
6
7
 
7
8
  /**
@@ -82,12 +83,20 @@ export function animateLayout(ms = 360) {
82
83
  presetTimer = window.setTimeout(() => w.classList.remove('sh-preset'), ms)
83
84
  }
84
85
 
86
+ /** B0.2: one wheel event, whatever its origin - a shell-document wheel over the canvas, or
87
+ * a wheel forwarded from a passive frame's iframe. clientX/Y are shell-viewport pixels. */
88
+ export interface CanvasWheelInput {
89
+ deltaX: number; deltaY: number; deltaMode: number
90
+ ctrlKey: boolean; metaKey: boolean; clientX: number; clientY: number
91
+ }
92
+
85
93
  export const canvasCtl = {
86
94
  fitNode(_key: string) {},
87
95
  fitNodes(_keys: string[]) {},
88
96
  fitAll() {},
89
97
  zoomTo(_scale: number) {},
90
98
  zoom100() { canvasCtl.zoomTo(1) },
99
+ wheel(_input: CanvasWheelInput) {},
91
100
  }
92
101
 
93
102
  /** Variant-group captions (SPEC-023 §4): "Landing · 3 variants" above each group with
@@ -131,6 +140,9 @@ export function Canvas() {
131
140
  const gesture = useStore((s) => s.gesture)
132
141
  const setScale = useStore((s) => s.setScale)
133
142
  const ref = useRef<ReactZoomPanPinchContentRef>(null)
143
+ const scaleTimer = useRef(0)
144
+
145
+ useEffect(() => { startPerf() }, []) // B0.4: frame-time sampler (window.__mvPerf)
134
146
 
135
147
  useEffect(() => {
136
148
  const wrap = () => document.querySelector('.sh-canvas') as HTMLElement | null
@@ -231,41 +243,55 @@ export function Canvas() {
231
243
  return () => el.removeEventListener('pointerdown', down)
232
244
  }, [])
233
245
 
234
- // Zoom curve: rzpp's wheel zoom is ADDITIVE in scale - a constant scale amount per
235
- // pinch, which reads as sluggish when zoomed in and runaway when zoomed out (zoom is
236
- // perceptually logarithmic). So ctrl/meta wheels (trackpad pinch arrives as ctrl-wheel
237
- // in Chrome; cmd+scroll folds in too) are intercepted on an ANCESTOR of the wrapper -
238
- // guaranteed to run before rzpp's own listener - and applied as an exponential step:
239
- // constant ratio per finger distance, uniform feel at every zoom level.
246
+ // B0.2: the shell is the SINGLE wheel-camera owner. rzpp's wheel-pan is disabled (below);
247
+ // both entry paths - a shell-document wheel over the canvas, and a wheel forwarded from a
248
+ // passive frame's iframe (sh:wheel) - feed one applyCanvasWheel. Plain wheel pans (mirrors
249
+ // rzpp's old position-delta math); ctrl/meta wheel zooms about the cursor on an exponential
250
+ // curve (constant ratio per finger distance - uniform feel at every zoom, unlike rzpp's
251
+ // additive step). setTransform drives onTransformed, which repaints the grid.
240
252
  useEffect(() => {
241
253
  const app = document.querySelector('.sh-app') as HTMLElement | null
242
- const el = document.querySelector('.sh-canvas') as HTMLElement | null
243
- if (!app || !el) return
254
+ const canvas = document.querySelector('.sh-canvas') as HTMLElement | null
255
+ if (!app || !canvas) return
244
256
  let settle = 0
245
- const onWheel = (e: WheelEvent) => {
246
- if (!e.ctrlKey && !e.metaKey) return
247
- if (!(e.target as HTMLElement | null)?.closest?.('.sh-canvas')) return
248
- e.preventDefault()
249
- e.stopImmediatePropagation()
257
+ const beginGesture = (panning: boolean) => {
258
+ // sh-camera = a CANVAS pan/zoom (drives the snapshot cover); sh-gesturing also drops iframe
259
+ // pointer-events. A frame click/drag sets only sh-gesturing, so it never flashes a snapshot.
260
+ document.getElementById('sh-world')?.classList.add('sh-gesturing', 'sh-camera')
261
+ document.body.classList.toggle('sh-panning', panning)
262
+ clearTimeout(settle)
263
+ settle = window.setTimeout(() => {
264
+ document.getElementById('sh-world')?.classList.remove('sh-gesturing', 'sh-camera')
265
+ document.body.classList.remove('sh-panning')
266
+ }, 160)
267
+ }
268
+ const applyCanvasWheel = (w: CanvasWheelInput) => {
250
269
  const inst = ref.current
251
- if (!inst) return
270
+ if (!inst || useStore.getState().play) return
252
271
  const { positionX, positionY, scale } = inst.instance.transformState
253
- // exponent scales with input; per-event ratio capped so a discrete mouse-wheel
254
- // notch (|deltaY| ~100) stays controllable while trackpad streams pass through
255
- const f = Math.min(1.4, Math.max(1 / 1.4, Math.exp(-e.deltaY * 0.0075 * (CONFIG.zoomSpeed ?? 1))))
272
+ const zooming = w.ctrlKey || w.metaKey
273
+ beginGesture(!zooming)
274
+ if (!zooming) { inst.setTransform(positionX - w.deltaX, positionY - w.deltaY, scale, 0, 'linear'); return }
275
+ const f = Math.min(1.4, Math.max(1 / 1.4, Math.exp(-w.deltaY * 0.0075 * (CONFIG.zoomSpeed ?? 1))))
256
276
  const next = Math.min(2, Math.max(0.05, scale * f))
257
277
  if (next === scale) return
278
+ const r = canvas.getBoundingClientRect()
279
+ const cx = w.clientX - r.left, cy = w.clientY - r.top
258
280
  const k = next / scale
259
- const r = el.getBoundingClientRect()
260
- const cx = e.clientX - r.left, cy = e.clientY - r.top
261
- // rzpp's zoom callbacks never fire on setTransform - manage the gesture class here
262
- document.getElementById('sh-world')?.classList.add('sh-gesturing')
263
- clearTimeout(settle)
264
- settle = window.setTimeout(() => document.getElementById('sh-world')?.classList.remove('sh-gesturing'), 160)
265
281
  inst.setTransform(cx - (cx - positionX) * k, cy - (cy - positionY) * k, next, 0, 'linear')
266
282
  }
283
+ canvasCtl.wheel = applyCanvasWheel
284
+ const onWheel = (e: WheelEvent) => {
285
+ const t = e.target as HTMLElement | null
286
+ if (!t?.closest?.('.sh-canvas')) return
287
+ if (t.closest('[data-sh-wheel-local]')) return // escape hatch for scrollable shell UI in-canvas
288
+ e.preventDefault()
289
+ e.stopImmediatePropagation()
290
+ applyCanvasWheel({ deltaX: e.deltaX, deltaY: e.deltaY, deltaMode: e.deltaMode,
291
+ ctrlKey: e.ctrlKey, metaKey: e.metaKey, clientX: e.clientX, clientY: e.clientY })
292
+ }
267
293
  app.addEventListener('wheel', onWheel, { capture: true, passive: false })
268
- return () => { app.removeEventListener('wheel', onWheel, { capture: true }); clearTimeout(settle) }
294
+ return () => { canvasCtl.wheel = () => {}; app.removeEventListener('wheel', onWheel, { capture: true }); clearTimeout(settle) }
269
295
  }, [])
270
296
 
271
297
  // space-pan: hold space, drag anywhere - nodes drop pointer-events so the canvas takes the drag
@@ -289,12 +315,14 @@ export function Canvas() {
289
315
  }
290
316
  }, [])
291
317
 
318
+ const camera = (on: boolean) => {
319
+ document.getElementById('sh-world')?.classList[on ? 'add' : 'remove']('sh-gesturing', 'sh-camera')
320
+ }
292
321
  const pan = (on: boolean) => () => {
293
- document.getElementById('sh-world')?.classList[on ? 'add' : 'remove']('sh-gesturing')
322
+ camera(on)
294
323
  document.body.classList[on ? 'add' : 'remove']('sh-panning')
295
324
  }
296
- const zoom = (on: boolean) => () =>
297
- document.getElementById('sh-world')?.classList[on ? 'add' : 'remove']('sh-gesturing')
325
+ const zoom = (on: boolean) => () => camera(on)
298
326
 
299
327
  // no initialPosition props on the wrapper: rzpp applies them ASYNC after mount and
300
328
  // stomps the boot fit when data is inlined (published builds) - the boot fit owns
@@ -312,13 +340,22 @@ export function Canvas() {
312
340
  // ctrl/meta wheel zoom never reaches rzpp (exponential curve above); pinch.step
313
341
  // only serves real touch-screen pinch.
314
342
  wheel={{ wheelDisabled: true, step: 0.225 * (CONFIG.zoomSpeed ?? 1) }}
315
- panning={{ wheelPanning: true, velocityDisabled: true, excluded: ['sh-no-pan'], disabled: gesture }}
343
+ // B0.2: rzpp no longer owns wheel-pan (the shell's applyCanvasWheel does, so it works
344
+ // over passive frames and forwarded from iframes too). excluded still governs pointer-drag.
345
+ panning={{ wheelPanning: false, velocityDisabled: true, excluded: ['sh-no-pan'], disabled: gesture }}
316
346
  pinch={{ step: 7.5 * (CONFIG.zoomSpeed ?? 1) }}
317
347
  onPanningStart={pan(true)}
318
348
  onPanningStop={pan(false)}
319
349
  onZoomStart={zoom(true)}
320
350
  onZoomStop={zoom(false)}
321
- onTransformed={(r) => { setScale(r.state.scale); paintGrid(r.state.positionX, r.state.positionY, r.state.scale) }}
351
+ // B0.1: the grid repaints live via CSS vars (no React), but the store scale - which
352
+ // only the zoom-% badge reads - commits after the transform SETTLES, so no React
353
+ // re-render happens per tick during a pan/zoom.
354
+ onTransformed={(r) => {
355
+ paintGrid(r.state.positionX, r.state.positionY, r.state.scale)
356
+ clearTimeout(scaleTimer.current)
357
+ scaleTimer.current = window.setTimeout(() => setScale(r.state.scale), 120)
358
+ }}
322
359
  onInit={(r) => paintGrid(r.state.positionX, r.state.positionY, r.state.scale)}
323
360
  >
324
361
  <TransformComponent wrapperClass="sh-canvas" contentClass="sh-content">