@marver-design/marver 0.4.0 → 0.6.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 (33) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/dist/{build-p3xmXU3b.mjs → build-BZaPa2DS.mjs} +11 -5
  3. package/dist/cli.mjs +3 -3
  4. package/dist/{dev-DZi1yRhn.mjs → dev-DaPQ9xA5.mjs} +57 -8
  5. package/dist/{init-Ck8z-HiD.mjs → init-DsCUmlCW.mjs} +1 -1
  6. package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
  7. package/dist/{plugin-CtQqO5ZZ.mjs → plugin-wMY9lNf3.mjs} +67 -20
  8. package/package.json +2 -1
  9. package/src/client/content/diagram.tsx +46 -2
  10. package/src/client/content/img-lod.ts +107 -0
  11. package/src/client/content/index.tsx +37 -9
  12. package/src/client/content/md.ts +29 -0
  13. package/src/client/content/palette.ts +6 -0
  14. package/src/client/frame-host/bridge.js +40 -5
  15. package/src/client/frame-host/serialize.ts +195 -0
  16. package/src/client/shell/App.tsx +75 -18
  17. package/src/client/shell/Comments.tsx +5 -4
  18. package/src/client/shell/Play.tsx +24 -0
  19. package/src/client/shell/canvas/Canvas.tsx +69 -30
  20. package/src/client/shell/canvas/FrameNode.tsx +104 -8
  21. package/src/client/shell/canvas/camera-broadcast.ts +44 -0
  22. package/src/client/shell/canvas/frame-registry.ts +18 -0
  23. package/src/client/shell/canvas/snapshots.ts +233 -0
  24. package/src/client/shell/cursor-arrow-dark.svg +1 -0
  25. package/src/client/shell/cursor-arrow.svg +1 -0
  26. package/src/client/shell/labels.ts +10 -0
  27. package/src/client/shell/perf.ts +92 -0
  28. package/src/client/shell/store.ts +130 -22
  29. package/src/client/shell/styles.css +37 -7
  30. package/src/client/stage/main.tsx +2 -0
  31. package/templates/instructions/boards.md +9 -3
  32. package/templates/instructions/reference/color.md +22 -1
  33. package/templates/instructions/shape.md +44 -35
@@ -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
 
@@ -391,12 +403,21 @@ export function App() {
391
403
  useEffect(() => {
392
404
  if (booted) return
393
405
  booted = true
394
- if (bootHash.board && bootHash.board !== useStore.getState().board)
395
- useStore.setState({ board: bootHash.board, boardAuto: bootHash.board === 'all-scenes' })
396
- boot().then((ok) => {
406
+ const start = async () => {
407
+ if (bootHash.board) {
408
+ if (bootHash.board !== useStore.getState().board) // a deep link wins
409
+ useStore.setState({ board: bootHash.board, boardAuto: bootHash.board === 'all-scenes' })
410
+ } else if (!PUBLISHED) {
411
+ // fresh open, no deep link: LAND on the first curated board (a tight, fast board = a good first
412
+ // impression) instead of the auto all-scenes everything-board, which renders every frame at once.
413
+ const first = (await fetchBoardNames().catch(() => [] as string[])).find((n) => n !== 'all-scenes')
414
+ if (first && first !== useStore.getState().board) useStore.setState({ board: first, boardAuto: false })
415
+ }
416
+ const ok = await boot()
397
417
  urlReady.current = true
398
- if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
399
- })
418
+ if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
419
+ }
420
+ void start()
400
421
  }, [])
401
422
 
402
423
  // the URL is a projection of state: design views replace in place; entering play and
@@ -501,6 +522,13 @@ export function App() {
501
522
  useEffect(() => {
502
523
  if (!import.meta.hot) return
503
524
  import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
525
+ // A7 controlled HMR: a frame file changed. Reload exactly the affected frames through the
526
+ // lease-aware path (idle frames now, leased ones deferred to a safe point) - never a shell
527
+ // reload, never a React Fast Refresh yanking a frame the user is in.
528
+ import.meta.hot.on('sh:frame-invalidated', (m: any) => {
529
+ const frameIds = Array.isArray(m?.frameIds) ? m.frameIds.filter((x: unknown) => typeof x === 'string') : []
530
+ if (frameIds.length && typeof m?.revision === 'string') useStore.getState().invalidateFrames(frameIds, m.revision)
531
+ })
504
532
  // multi-viewer sync: another viewer (or an agent) saved this board. A clean canvas
505
533
  // re-boots silently, keeping whatever selection survives; a dirty one keeps its
506
534
  // edits and converges through the 409 path on its next save (disk wins, spec §8).
@@ -521,11 +549,11 @@ export function App() {
521
549
  const onMsg = (e: MessageEvent) => {
522
550
  const data = e.data
523
551
  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
552
+ // B0.3: O(1) registry lookup - also the security gate (unknown source = not a
553
+ // registered frame = dropped), replacing a per-message iframe scan + DOM walk.
554
+ const reg = frameByWindow(e.source)
555
+ if (!reg) return
556
+ const { key: nodeKey, iframe: el } = reg
529
557
  const s = useStore.getState()
530
558
 
531
559
  if (data.type === 'sh:ready') {
@@ -579,6 +607,29 @@ export function App() {
579
607
  // walking a flow must not eject you to design mode at every hop
580
608
  if (s.interact === nodeKey) setInteract(node.key)
581
609
  setTimeout(() => canvasCtl.fitNode(node.key), 50)
610
+ } else if (data.type === 'sh:wheel') {
611
+ // B0.2: a passive frame forwarded a wheel event; the canvas owns it. Never for the
612
+ // interact target or play (the app scrolls itself - the parent stays authoritative).
613
+ if (s.play || s.interact === nodeKey) return
614
+ const nums = [data.deltaX, data.deltaY, data.clientX, data.clientY].map(Number)
615
+ if (!nums.every(Number.isFinite)) return
616
+ const [deltaX, deltaY, localX, localY] = nums
617
+ const rect = el.getBoundingClientRect()
618
+ // iframe-local (untransformed CSS px) -> shell-screen px: rect is the frame's
619
+ // transformed size, so rect.width/clientWidth is its effective on-screen scale.
620
+ // Plain rect.left+localX would put the zoom origin in the wrong place when zoomed.
621
+ const sx = el.clientWidth ? rect.width / el.clientWidth : 1
622
+ const sy = el.clientHeight ? rect.height / el.clientHeight : 1
623
+ canvasCtl.wheel({
624
+ deltaX, deltaY, deltaMode: Number(data.deltaMode) || 0,
625
+ ctrlKey: !!data.ctrlKey, metaKey: !!data.metaKey,
626
+ clientX: rect.left + localX * sx, clientY: rect.top + localY * sy,
627
+ })
628
+ } else if (data.type === 'sh:interaction') {
629
+ // A6: the frame reports transient laser/comment engagement (pointer inside + mode on).
630
+ // While engaged the frame is leased, so a hot update to it defers until disengage.
631
+ s.setExternalLease(nodeKey, 'laser', !!data.laser)
632
+ s.setExternalLease(nodeKey, 'comment', !!data.comment)
582
633
  }
583
634
  }
584
635
  window.addEventListener('message', onMsg)
@@ -609,7 +660,7 @@ export function App() {
609
660
  s.setLaser(!s.laser)
610
661
  }
611
662
  // 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)
663
+ // pins · Shift+P = copy file path(s) (P alone is play; D6, changelog 0.4.0)
613
664
  if (e.key === 'c' && !e.shiftKey) {
614
665
  const c = commentsStore()
615
666
  if (!c.commentMode) s.setLaser(false)
@@ -617,7 +668,7 @@ export function App() {
617
668
  toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
618
669
  }
619
670
  if (e.key === 'C' && e.shiftKey) { const c = commentsStore(); c.setShow(!c.show) }
620
- if (e.key === 'y' && s.selection.length) {
671
+ if (e.key === 'P' && e.shiftKey && s.selection.length) {
621
672
  const files = s.selection
622
673
  .map((k) => { const n = s.nodes.find((x) => x.key === k); return n ? s.frameFor(n)?.file : undefined })
623
674
  .filter((f): f is string => !!f)
@@ -702,8 +753,14 @@ export function App() {
702
753
  mounted so collapse/expand can crossfade-morph between them */}
703
754
  <aside className={`sh-panel${panelOpen ? '' : ' closed'}`} aria-hidden={!panelOpen}>
704
755
  <div className="sh-panel-top">
705
- <ParallelogramDuoIcon size={21} className="mark" />
706
- <span className="name">Marver</span>
756
+ {/* the mark links to the marver site; the title is the humanized repo name (C2: names
757
+ the project so two concurrent canvases are never confused), ellipsed if long */}
758
+ <Tip side="bottom" label="marver.design">
759
+ <a className="mark-link" href="https://marver.design" target="_blank" rel="noreferrer" aria-label="marver.design" tabIndex={panelOpen ? 0 : -1}>
760
+ <ParallelogramDuoIcon size={21} className="mark" />
761
+ </a>
762
+ </Tip>
763
+ <span className="name" title={CONFIG.projectName || 'Marver'}>{CONFIG.projectName ? humanize(CONFIG.projectName) : 'Marver'}</span>
707
764
  <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
765
  </div>
709
766
  <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,7 +2,9 @@ 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'
7
+ import { startCameraBroadcast, setCameraScale } from './camera-broadcast.ts'
6
8
 
7
9
  /**
8
10
  * The world. rzpp owns pan/zoom; nodes are absolutely positioned children of #sh-world.
@@ -82,12 +84,20 @@ export function animateLayout(ms = 360) {
82
84
  presetTimer = window.setTimeout(() => w.classList.remove('sh-preset'), ms)
83
85
  }
84
86
 
87
+ /** B0.2: one wheel event, whatever its origin - a shell-document wheel over the canvas, or
88
+ * a wheel forwarded from a passive frame's iframe. clientX/Y are shell-viewport pixels. */
89
+ export interface CanvasWheelInput {
90
+ deltaX: number; deltaY: number; deltaMode: number
91
+ ctrlKey: boolean; metaKey: boolean; clientX: number; clientY: number
92
+ }
93
+
85
94
  export const canvasCtl = {
86
95
  fitNode(_key: string) {},
87
96
  fitNodes(_keys: string[]) {},
88
97
  fitAll() {},
89
98
  zoomTo(_scale: number) {},
90
99
  zoom100() { canvasCtl.zoomTo(1) },
100
+ wheel(_input: CanvasWheelInput) {},
91
101
  }
92
102
 
93
103
  /** Variant-group captions (SPEC-023 §4): "Landing · 3 variants" above each group with
@@ -131,6 +141,9 @@ export function Canvas() {
131
141
  const gesture = useStore((s) => s.gesture)
132
142
  const setScale = useStore((s) => s.setScale)
133
143
  const ref = useRef<ReactZoomPanPinchContentRef>(null)
144
+ const scaleTimer = useRef(0)
145
+
146
+ useEffect(() => { startPerf(); startCameraBroadcast() }, []) // B0.4: frame-time sampler + image-LOD camera signal
134
147
 
135
148
  useEffect(() => {
136
149
  const wrap = () => document.querySelector('.sh-canvas') as HTMLElement | null
@@ -231,41 +244,55 @@ export function Canvas() {
231
244
  return () => el.removeEventListener('pointerdown', down)
232
245
  }, [])
233
246
 
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.
247
+ // B0.2: the shell is the SINGLE wheel-camera owner. rzpp's wheel-pan is disabled (below);
248
+ // both entry paths - a shell-document wheel over the canvas, and a wheel forwarded from a
249
+ // passive frame's iframe (sh:wheel) - feed one applyCanvasWheel. Plain wheel pans (mirrors
250
+ // rzpp's old position-delta math); ctrl/meta wheel zooms about the cursor on an exponential
251
+ // curve (constant ratio per finger distance - uniform feel at every zoom, unlike rzpp's
252
+ // additive step). setTransform drives onTransformed, which repaints the grid.
240
253
  useEffect(() => {
241
254
  const app = document.querySelector('.sh-app') as HTMLElement | null
242
- const el = document.querySelector('.sh-canvas') as HTMLElement | null
243
- if (!app || !el) return
255
+ const canvas = document.querySelector('.sh-canvas') as HTMLElement | null
256
+ if (!app || !canvas) return
244
257
  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()
258
+ const beginGesture = (panning: boolean) => {
259
+ // sh-camera = a CANVAS pan/zoom (drives the snapshot cover); sh-gesturing also drops iframe
260
+ // pointer-events. A frame click/drag sets only sh-gesturing, so it never flashes a snapshot.
261
+ document.getElementById('sh-world')?.classList.add('sh-gesturing', 'sh-camera')
262
+ document.body.classList.toggle('sh-panning', panning)
263
+ clearTimeout(settle)
264
+ settle = window.setTimeout(() => {
265
+ document.getElementById('sh-world')?.classList.remove('sh-gesturing', 'sh-camera')
266
+ document.body.classList.remove('sh-panning')
267
+ }, 160)
268
+ }
269
+ const applyCanvasWheel = (w: CanvasWheelInput) => {
250
270
  const inst = ref.current
251
- if (!inst) return
271
+ if (!inst || useStore.getState().play) return
252
272
  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))))
273
+ const zooming = w.ctrlKey || w.metaKey
274
+ beginGesture(!zooming)
275
+ if (!zooming) { inst.setTransform(positionX - w.deltaX, positionY - w.deltaY, scale, 0, 'linear'); return }
276
+ const f = Math.min(1.4, Math.max(1 / 1.4, Math.exp(-w.deltaY * 0.0075 * (CONFIG.zoomSpeed ?? 1))))
256
277
  const next = Math.min(2, Math.max(0.05, scale * f))
257
278
  if (next === scale) return
279
+ const r = canvas.getBoundingClientRect()
280
+ const cx = w.clientX - r.left, cy = w.clientY - r.top
258
281
  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
282
  inst.setTransform(cx - (cx - positionX) * k, cy - (cy - positionY) * k, next, 0, 'linear')
266
283
  }
284
+ canvasCtl.wheel = applyCanvasWheel
285
+ const onWheel = (e: WheelEvent) => {
286
+ const t = e.target as HTMLElement | null
287
+ if (!t?.closest?.('.sh-canvas')) return
288
+ if (t.closest('[data-sh-wheel-local]')) return // escape hatch for scrollable shell UI in-canvas
289
+ e.preventDefault()
290
+ e.stopImmediatePropagation()
291
+ applyCanvasWheel({ deltaX: e.deltaX, deltaY: e.deltaY, deltaMode: e.deltaMode,
292
+ ctrlKey: e.ctrlKey, metaKey: e.metaKey, clientX: e.clientX, clientY: e.clientY })
293
+ }
267
294
  app.addEventListener('wheel', onWheel, { capture: true, passive: false })
268
- return () => { app.removeEventListener('wheel', onWheel, { capture: true }); clearTimeout(settle) }
295
+ return () => { canvasCtl.wheel = () => {}; app.removeEventListener('wheel', onWheel, { capture: true }); clearTimeout(settle) }
269
296
  }, [])
270
297
 
271
298
  // space-pan: hold space, drag anywhere - nodes drop pointer-events so the canvas takes the drag
@@ -289,12 +316,14 @@ export function Canvas() {
289
316
  }
290
317
  }, [])
291
318
 
319
+ const camera = (on: boolean) => {
320
+ document.getElementById('sh-world')?.classList[on ? 'add' : 'remove']('sh-gesturing', 'sh-camera')
321
+ }
292
322
  const pan = (on: boolean) => () => {
293
- document.getElementById('sh-world')?.classList[on ? 'add' : 'remove']('sh-gesturing')
323
+ camera(on)
294
324
  document.body.classList[on ? 'add' : 'remove']('sh-panning')
295
325
  }
296
- const zoom = (on: boolean) => () =>
297
- document.getElementById('sh-world')?.classList[on ? 'add' : 'remove']('sh-gesturing')
326
+ const zoom = (on: boolean) => () => camera(on)
298
327
 
299
328
  // no initialPosition props on the wrapper: rzpp applies them ASYNC after mount and
300
329
  // stomps the boot fit when data is inlined (published builds) - the boot fit owns
@@ -303,7 +332,7 @@ export function Canvas() {
303
332
  <TransformWrapper
304
333
  ref={ref}
305
334
  minScale={0.05}
306
- maxScale={2}
335
+ maxScale={5}
307
336
  limitToBounds={false}
308
337
  doubleClick={{ disabled: true }}
309
338
  // wheelDisabled is load-bearing: rzpp's onWheelPanning is a no-op without it, and
@@ -312,13 +341,23 @@ export function Canvas() {
312
341
  // ctrl/meta wheel zoom never reaches rzpp (exponential curve above); pinch.step
313
342
  // only serves real touch-screen pinch.
314
343
  wheel={{ wheelDisabled: true, step: 0.225 * (CONFIG.zoomSpeed ?? 1) }}
315
- panning={{ wheelPanning: true, velocityDisabled: true, excluded: ['sh-no-pan'], disabled: gesture }}
344
+ // B0.2: rzpp no longer owns wheel-pan (the shell's applyCanvasWheel does, so it works
345
+ // over passive frames and forwarded from iframes too). excluded still governs pointer-drag.
346
+ panning={{ wheelPanning: false, velocityDisabled: true, excluded: ['sh-no-pan'], disabled: gesture }}
316
347
  pinch={{ step: 7.5 * (CONFIG.zoomSpeed ?? 1) }}
317
348
  onPanningStart={pan(true)}
318
349
  onPanningStop={pan(false)}
319
350
  onZoomStart={zoom(true)}
320
351
  onZoomStop={zoom(false)}
321
- onTransformed={(r) => { setScale(r.state.scale); paintGrid(r.state.positionX, r.state.positionY, r.state.scale) }}
352
+ // B0.1: the grid repaints live via CSS vars (no React), but the store scale - which
353
+ // only the zoom-% badge reads - commits after the transform SETTLES, so no React
354
+ // re-render happens per tick during a pan/zoom.
355
+ onTransformed={(r) => {
356
+ paintGrid(r.state.positionX, r.state.positionY, r.state.scale)
357
+ setCameraScale(r.state.scale) // keep the LOD's settle-scale current (cheap number write, no React)
358
+ clearTimeout(scaleTimer.current)
359
+ scaleTimer.current = window.setTimeout(() => setScale(r.state.scale), 120)
360
+ }}
322
361
  onInit={(r) => paintGrid(r.state.positionX, r.state.positionY, r.state.scale)}
323
362
  >
324
363
  <TransformComponent wrapperClass="sh-canvas" contentClass="sh-content">
@@ -1,8 +1,11 @@
1
- import { memo, useEffect, useRef } from 'react'
1
+ import { memo, useCallback, useEffect, useRef } from 'react'
2
2
  import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
3
3
  import { CopyIcon, IntentGlyph, ReloadIcon, XIcon } from '../icons.tsx'
4
4
  import { CommentLayer } from '../Comments.tsx'
5
5
  import { useComments } from '../comments-store.ts'
6
+ import { registerFrame, unregisterFrame } from './frame-registry.ts'
7
+ import { primeCameraFor } from './camera-broadcast.ts'
8
+ import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
6
9
 
7
10
  export const HEADER = 28
8
11
  const SNAP = 12
@@ -20,7 +23,9 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
20
23
  const frame = useStore((s) => s.frameFor(node))
21
24
  const selected = useStore((s) => s.selection.includes(node.key))
22
25
  const interact = useStore((s) => s.interact === node.key)
23
- const scale = useStore((s) => s.scale)
26
+ // B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
27
+ // pan/zoom tick. gestureScale below measures the world rect (the canonical source,
28
+ // Law G-5); the stored scale is only a never-hit fallback, read lazily at drag time.
24
29
  const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, setGesture, toast } = useStore.getState()
25
30
  const iframeRef = useRef<HTMLIFrameElement>(null)
26
31
  const themeRef = useRef(node.theme)
@@ -30,13 +35,73 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
30
35
  if (frame && initialSrc.current === null) initialSrc.current = frameUrl(frame, node.theme)
31
36
  const fileRef = useRef(frame ? `${frame.kind}:${frame.file}` : null)
32
37
 
33
- // theme switch without remount
38
+ // theme switch without remount: the live iframe flips via message (no navigation). The lean is
39
+ // INVALIDATED (not mutated) - a baked mermaid SVG can't be re-themed in place, so we drop it, show
40
+ // live while it re-renders in the new theme, and the capture effect (theme is a dep) rebuilds a
41
+ // fresh lean that is only shown once ready. No light-on-dark flash.
34
42
  useEffect(() => {
35
43
  if (themeRef.current !== node.theme) {
36
44
  themeRef.current = node.theme
37
45
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:set-theme', theme: node.theme }, '*')
46
+ invalidateLean(node.key)
38
47
  }
39
- }, [node.theme])
48
+ }, [node.theme, node.key])
49
+
50
+ // B0.3: register this frame's WindowProxy so the shell routes its messages in O(1)
51
+ // (source window -> node), instead of rescanning every iframe + walking the DOM per
52
+ // message. Registration runs SYNCHRONOUSLY through the ref callback (P1): a fast static
53
+ // HTML frame or an immediate boot failure can post sh:ready/sh:error before a passive
54
+ // effect would run, and the registry is the security gate that would otherwise drop it
55
+ // as an unknown source (misleading 10s timeout). The WindowProxy is stable across
56
+ // navigations; onLoad re-asserts it (idempotent) after each navigation.
57
+ const regWin = useRef<WindowProxy | null>(null)
58
+ const registerWin = () => {
59
+ const iframe = iframeRef.current
60
+ const win = iframe?.contentWindow
61
+ if (!iframe || !win || regWin.current === win) return
62
+ regWin.current = win
63
+ registerFrame(win, { key: node.key, iframe })
64
+ }
65
+ const bindIframe = useCallback((el: HTMLIFrameElement | null) => {
66
+ if (regWin.current && (!el || el.contentWindow !== regWin.current)) { unregisterFrame(regWin.current); regWin.current = null }
67
+ iframeRef.current = el
68
+ registerWin()
69
+ }, [node.key])
70
+
71
+ // SPEC-M5: register the facade <iframe> so the lean coordinator can drive its srcdoc imperatively.
72
+ const bindLean = useCallback((el: HTMLIFrameElement | null) => { registerLeanFrame(node.key, el) }, [node.key])
73
+ // capture a fresh lean snapshot once the frame is ready and quiet, and whenever its CONTENT changes
74
+ // (nav). Resize needs no re-capture (the lean doc reflows) and theme needs none (attribute flip),
75
+ // so neither is a dep - keeping captures rare. Never during a gesture; the coordinator serialises.
76
+ useEffect(() => {
77
+ // capture reads the live iframe's same-origin document - true in dev AND publish (published frames
78
+ // are bundled same-origin and served by `marver serve`), so the lean tier works in both via this
79
+ // client-side capture. Fail-soft: a frame that can't serialise stays live (publish == today's
80
+ // behaviour in the worst case). No headless build step / heavy dependency needed.
81
+ if (node.status !== 'ready' || node.missing) return
82
+ const iframe = iframeRef.current
83
+ if (!iframe) return
84
+ const t = setTimeout(() => scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme }), 450)
85
+ return () => clearTimeout(t)
86
+ // node.theme IS a dep: baked content (mermaid SVG) can't be re-themed by the cover's attribute
87
+ // flip, so a theme change re-captures after the live frame re-renders (key includes theme).
88
+ }, [node.status, node.nav, node.key, node.missing, node.theme])
89
+ useEffect(() => () => dropSnapshot(node.key), [node.key]) // drop the snapshot on unmount
90
+ // a reload / file-swap / error takes the frame out of 'ready': drop its cover so a stale picture
91
+ // never lingers (nav may not bump on a same-file reload). The next 'ready' re-captures.
92
+ useEffect(() => { if (node.status !== 'ready') dropSnapshot(node.key) }, [node.status, node.key])
93
+ // LEAN-PRIMARY focus handoff: entering interact shows the live app (drop the now-stale lean at
94
+ // once); leaving it recaptures the live frame's CURRENT state (the user may have typed/toggled)
95
+ // and only swaps back to lean once that fresh capture is admitted. force=true: same nav/theme.
96
+ const prevInteract = useRef(interact)
97
+ useEffect(() => {
98
+ if (prevInteract.current === interact) return
99
+ const wasInteract = prevInteract.current
100
+ prevInteract.current = interact
101
+ if (interact) invalidateLean(node.key)
102
+ else if (wasInteract && node.status === 'ready' && iframeRef.current)
103
+ scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
104
+ }, [interact, node.key, node.nav, node.theme, node.status])
40
105
 
41
106
  // laser mode (SPEC-M3 §7) rides the same rail; re-sent when a frame becomes ready
42
107
  // so late loaders join an already-lasered board
@@ -56,6 +121,17 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
56
121
  if (node.status === 'ready' || !commentMode)
57
122
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
58
123
  }, [commentMode, node.status])
124
+ // B0.2: the interact target owns its own wheel (app scrolls); passive frames forward
125
+ // wheel to the canvas. Replayed on ready like laser/pick so a reload restores truth.
126
+ useEffect(() => {
127
+ if (node.status === 'ready' || !interact)
128
+ iframeRef.current?.contentWindow?.postMessage({ type: 'sh:interactive', on: interact }, location.origin)
129
+ }, [interact, node.status])
130
+ // image-LOD: once the frame is ready its content listener is live, so send the settled zoom - a static
131
+ // board that never gets a gesture then still sharpens its images from the cheap low-res first paint.
132
+ useEffect(() => {
133
+ if (node.status === 'ready') primeCameraFor(iframeRef.current?.contentWindow)
134
+ }, [node.status])
59
135
 
60
136
  // a frame whose FILE actually changed (e.g. tsx -> html swap, same id) must renavigate
61
137
  useEffect(() => {
@@ -94,7 +170,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
94
170
  const world = document.getElementById('sh-world')!
95
171
  // Law G-5: measured scale, never stored zoom state. #sh-world is 1px wide by design,
96
172
  // so its rendered rect width IS the scale (survives browser page-zoom too).
97
- const gestureScale = world.getBoundingClientRect().width || scale || 1
173
+ const gestureScale = world.getBoundingClientRect().width || useStore.getState().scale || 1
98
174
  const start = { x: e.clientX, y: e.clientY, nx: node.x, ny: node.y, nw: node.w, nh: node.h }
99
175
  // group drag: moving any member moves the whole selection by the same delta
100
176
  const st = useStore.getState()
@@ -105,10 +181,21 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
105
181
  if (n) groupStarts[k] = { x: n.x, y: n.y }
106
182
  }
107
183
  }
108
- world.classList.add('sh-gesturing')
109
- setGesture(true)
184
+ // Defer sh-gesturing (and its `will-change: transform` on .sh-content) until an ACTUAL drag
185
+ // begins - a bare click otherwise promotes then demotes the compositor layer, re-rasterising
186
+ // the frame's text at a fractional zoom = the "jiggle". A pure click now never toggles it.
187
+ let gesturing = false
188
+ const begin = () => {
189
+ if (gesturing) return
190
+ gesturing = true
191
+ world.classList.add('sh-gesturing') // drops iframe pointer-events (sh-camera is NOT set, so no cover)
192
+ setGesture(true)
193
+ }
194
+ const MOVE_THRESHOLD = 3 // px in screen space before a press counts as a drag
110
195
 
111
196
  const onMove = (ev: PointerEvent) => {
197
+ if (!gesturing && Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < MOVE_THRESHOLD) return
198
+ begin()
112
199
  const dx = (ev.clientX - start.x) / gestureScale
113
200
  const dy = (ev.clientY - start.y) / gestureScale
114
201
  if (mode === 'move') {
@@ -213,11 +300,20 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
213
300
  </div>
214
301
  ) : null}
215
302
  <iframe
216
- ref={iframeRef}
303
+ ref={bindIframe}
304
+ className="sh-live"
217
305
  src={initialSrc.current ?? frameUrl(frame, node.theme)}
218
306
  title={frame.id}
307
+ onLoad={registerWin}
219
308
  style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
220
309
  />
310
+ {/* SPEC-M5 lean facade: a DOM-snapshot (static html, 0 JS) covering the live iframe only while
311
+ the canvas is gesturing (CSS), so a heavy frame never flashes white mid-transform and the
312
+ device sweep reflows correctly. sandbox WITHOUT allow-scripts = no JS runs; allow-same-origin
313
+ so fonts/assets resolve and the shell can flip its theme + restore scroll. Never registered,
314
+ never messaged, pointer-events:none - it is NOT the live iframe (role: .sh-lean).
315
+ Rendered in dev AND publish (runtime client-side capture; frames are same-origin in both). */}
316
+ <iframe ref={bindLean} className="sh-lean" sandbox="allow-same-origin" title="" aria-hidden tabIndex={-1} />
221
317
  {/* the overlay eats mouse events for drag-by-body; laser and comment mode both
222
318
  need the mouse INSIDE the frame for hover highlights, so it steps aside
223
319
  (drag still works via the header) */}
@@ -0,0 +1,44 @@
1
+ // Tells every content frame when the canvas camera starts and stops moving, so each frame's image-LOD
2
+ // (content/img-lod.ts) can freeze its bitmaps during motion and sharpen to the settled zoom afterwards.
3
+ //
4
+ // Fires ONCE per gesture, never per animation frame - it watches the single #sh-world.sh-camera class
5
+ // that EVERY camera path (rzpp zoom/pan, wheel, pinch, programmatic setTransform) toggles, so one hook
6
+ // covers them all. Posting a message to 150 frames every tick would be its own jank; two posts per
7
+ // gesture (start, settle) is free.
8
+
9
+ let lastScale = 1
10
+ /** Fed live from onTransformed so `settle` always broadcasts the final zoom. Cheap (a number write). */
11
+ export function setCameraScale(s: number): void { if (s > 0) lastScale = s }
12
+
13
+ function frameWindows(): Window[] {
14
+ const out: Window[] = []
15
+ for (const f of document.querySelectorAll<HTMLIFrameElement>('#sh-world iframe'))
16
+ if (f.contentWindow) out.push(f.contentWindow)
17
+ return out
18
+ }
19
+
20
+ function broadcast(moving: boolean): void {
21
+ const msg = moving ? { type: 'sh:camera', moving: true } : { type: 'sh:camera', moving: false, scale: lastScale }
22
+ for (const w of frameWindows()) { try { w.postMessage(msg, location.origin) } catch { /* cross-doc timing */ } }
23
+ }
24
+
25
+ let started = false
26
+ export function startCameraBroadcast(): void {
27
+ if (started) return
28
+ const world = document.getElementById('sh-world'); if (!world) return
29
+ started = true
30
+ let moving = false
31
+ new MutationObserver(() => {
32
+ const now = world.classList.contains('sh-camera')
33
+ if (now === moving) return
34
+ moving = now
35
+ broadcast(now) // +sh-camera -> freeze; -sh-camera -> sharpen to the settled scale
36
+ }).observe(world, { attributes: true, attributeFilter: ['class'] })
37
+ }
38
+
39
+ /** Prime ONE freshly-ready frame with the current settled scale, so a static board that is never zoomed
40
+ * still sharpens from its cheap low-res first paint to the right resolution. */
41
+ export function primeCameraFor(win: Window | null | undefined): void {
42
+ if (!win) return
43
+ try { win.postMessage({ type: 'sh:camera', moving: false, scale: lastScale }, location.origin) } catch { /* timing */ }
44
+ }