@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
@@ -0,0 +1,107 @@
1
+ // Client-side image Level-Of-Detail, running INSIDE a content frame's iframe.
2
+ //
3
+ // THE PROBLEM: a 2708x1610 screenshot is ~1.5MB as a PNG but ~17MB once decoded to RGBA. A board with
4
+ // 150 of them holds ~2.6GB of decoded bitmaps, and the browser resamples every one each zoom frame =
5
+ // jank + memory pressure. File size is a red herring; DECODED size is the killer.
6
+ //
7
+ // THE FIX: decode each image STRAIGHT to its on-screen size with createImageBitmap(blob,{resizeWidth}) -
8
+ // the full-size decode is never retained - and paint it on a <canvas> via a zero-copy bitmaprenderer
9
+ // transfer. The bitmap is FROZEN while the canvas is being pan/zoomed (the shell posts sh:camera
10
+ // {moving:true}) and only re-picked when the gesture SETTLES (sh:camera {moving:false, scale}), so zoom
11
+ // never triggers a decode/resample storm. Crisp at rest, cheap in motion. This mirrors how tldraw does
12
+ // LOD (resolution by on-screen size, debounced so it never thrashes mid-zoom).
13
+
14
+ /** Feature probe: createImageBitmap with resize + a canvas bitmaprenderer that can receive it. */
15
+ export const lodSupported = (() => {
16
+ try {
17
+ if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') return false
18
+ const ctx = document.createElement('canvas').getContext('bitmaprenderer')
19
+ return !!ctx && typeof ctx.transferFromImageBitmap === 'function'
20
+ } catch { return false }
21
+ })()
22
+
23
+ // Quantized resize widths (device px). A `want` above the top bucket decodes at NATIVE (bucket 0 = no
24
+ // resize), reached only when a frame is zoomed in past ~2048 on-screen device px.
25
+ const BUCKETS = [256, 640, 1280, 2048]
26
+ const DPR = (): number => Math.min(window.devicePixelRatio || 1, 2) // cap at 2; 3x buys nothing here
27
+
28
+ // Bounded decode pool: never run more than N createImageBitmap jobs at once (peak decode memory + CPU).
29
+ const MAX = 3
30
+ let active = 0
31
+ const q: Array<() => void> = []
32
+ const pump = (): void => { while (active < MAX && q.length) q.shift()!() }
33
+ const schedule = (job: () => Promise<void>): void => {
34
+ q.push(() => { active++; void job().finally(() => { active--; pump() }) })
35
+ pump()
36
+ }
37
+
38
+ interface Item { canvas: HTMLCanvasElement; src: string; bucket: number; token: number }
39
+ const items = new Set<Item>()
40
+ let scale = 0.2 // overview default until the shell primes the settled scale on frame-ready
41
+ let moving = false
42
+
43
+ function pick(devicePx: number): number {
44
+ for (const b of BUCKETS) if (b >= devicePx) return b
45
+ return 0 // native decode - only past the top bucket
46
+ }
47
+
48
+ async function decode(it: Item, bucket: number): Promise<void> {
49
+ const token = ++it.token
50
+ let bmp: ImageBitmap
51
+ try {
52
+ const blob = await (await fetch(it.src)).blob()
53
+ bmp = bucket
54
+ ? await createImageBitmap(blob, { resizeWidth: bucket, resizeQuality: 'high' })
55
+ : await createImageBitmap(blob)
56
+ } catch { return } // network / decode failure: keep the last frame
57
+ if (it.token !== token || !it.canvas.isConnected) { bmp.close(); return } // superseded or unmounted
58
+ const ctx = it.canvas.getContext('bitmaprenderer')
59
+ if (!ctx) { bmp.close(); return }
60
+ // PIN the display aspect-ratio on the first decode and never change it. Different buckets round to
61
+ // slightly different integer dims (256x153 = 1.673 vs 1280x761 = 1.682), so if each drove height:auto
62
+ // the layout box would shift a hair on every resolution switch - the doc reflows, the content frame
63
+ // auto-resizes, and the frame "jiggles" as you zoom (and the reflow storm helped starve frames to a
64
+ // ready-timeout). A fixed aspect-ratio makes the box identical across buckets: only pixels sharpen.
65
+ if (!it.canvas.style.aspectRatio) it.canvas.style.aspectRatio = `${bmp.width} / ${bmp.height}`
66
+ it.canvas.width = bmp.width; it.canvas.height = bmp.height
67
+ ctx.transferFromImageBitmap(bmp) // zero-copy; consumes + closes the bitmap
68
+ it.bucket = bucket
69
+ }
70
+
71
+ function refresh(it: Item): void {
72
+ const layoutW = it.canvas.getBoundingClientRect().width
73
+ if (!layoutW) return // not laid out yet (or display:none)
74
+ const want = pick(Math.ceil(layoutW * scale * DPR()))
75
+ if (want === it.bucket) return
76
+ // hysteresis: only DOWNgrade once we're comfortably below the current level, so a jittery zoom that
77
+ // hovers a threshold doesn't swap back and forth (upgrades are always taken - sharper is worth it).
78
+ if (it.bucket > 0 && want > 0 && want < it.bucket && Math.ceil(layoutW * scale * DPR()) > it.bucket * 0.6) return
79
+ schedule(() => decode(it, want))
80
+ }
81
+
82
+ /** A content <canvas> registers here; returns an unregister fn. Paints a cheap first frame immediately,
83
+ * then sharpens to the real zoom on the next prime/settle. */
84
+ export function registerLodImage(canvas: HTMLCanvasElement, src: string): () => void {
85
+ const it: Item = { canvas, src, bucket: -1, token: 0 }
86
+ items.add(it)
87
+ schedule(() => decode(it, BUCKETS[0])) // low-res first paint (fast, ~1MB); prime sharpens
88
+ return () => { items.delete(it); it.token++ } // token bump drops any in-flight decode
89
+ }
90
+
91
+ // The shell posts camera transitions ONCE per gesture. Freeze during motion; re-pick every image's
92
+ // resolution to the SETTLED zoom - but debounced, and cancelling any queued work from a prior settle, so
93
+ // a fast zoom in-out-in doesn't stack decode waves across all frames (which starved the main thread and
94
+ // timed frames out). The re-decode only fires once the camera has truly rested.
95
+ let settleTimer = 0
96
+ if (typeof window !== 'undefined' && lodSupported) {
97
+ window.addEventListener('message', (e) => {
98
+ if (e.origin !== location.origin) return
99
+ const m = (e as MessageEvent).data as { type?: string; moving?: boolean; scale?: number } | null
100
+ if (!m || m.type !== 'sh:camera') return
101
+ if (m.moving) { moving = true; clearTimeout(settleTimer); q.length = 0; return } // new gesture: drop pending decodes
102
+ moving = false
103
+ if (typeof m.scale === 'number' && m.scale > 0) scale = m.scale
104
+ clearTimeout(settleTimer)
105
+ settleTimer = window.setTimeout(() => { if (!moving) for (const it of items) refresh(it) }, 220)
106
+ })
107
+ }
@@ -9,7 +9,12 @@
9
9
  */
10
10
  import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
11
11
  import { CONTENT_WIDTH } from '../const.ts'
12
- import { assetUrl, renderMarkdown } from './md.ts'
12
+ import { assetUrl, renderMarkdown, FAMILIES } from './md.ts'
13
+ import { lodSupported, registerLodImage } from './img-lod.ts'
14
+
15
+ // D3: family color classes for inline Md (`:blue[...]`), theme-aware (frames carry .dark + [data-theme])
16
+ const FAMILY_CSS = Object.entries(FAMILIES).map(([f, c]) =>
17
+ `.mv-md .mv-c-${f}{color:${c.light}}.dark .mv-md .mv-c-${f},[data-theme="dark"] .mv-md .mv-c-${f}{color:${c.dark}}`).join('\n')
13
18
 
14
19
  export { Diagram } from './diagram.tsx'
15
20
 
@@ -75,6 +80,14 @@ export function Md({ children }: { children?: ReactNode }) {
75
80
  export function Img({ src, caption, alt, h }: { src: string; caption?: string; alt?: string; h?: number }) {
76
81
  const url = assetUrl(src)
77
82
  const [err, setErr] = useState(false)
83
+ const canvasRef = useRef<HTMLCanvasElement>(null)
84
+ // LOD: paint the image on a <canvas> decoded to its on-screen size (never the full 17MB bitmap), and
85
+ // re-pick resolution only when the canvas settles after a zoom. See img-lod.ts. Falls back to a plain
86
+ // <img> where createImageBitmap/bitmaprenderer isn't available (correctness over the optimization).
87
+ useEffect(() => {
88
+ if (!url || err || !lodSupported || !canvasRef.current) return
89
+ return registerLodImage(canvasRef.current, url)
90
+ }, [url, err])
78
91
  if (!url || err) {
79
92
  return (
80
93
  <div className="mv-block mv-imgerr">
@@ -84,13 +97,18 @@ export function Img({ src, caption, alt, h }: { src: string; caption?: string; a
84
97
  </div>
85
98
  )
86
99
  }
100
+ // A reference image ALWAYS shows in full: it fills its column at its natural aspect ratio, never
101
+ // cropped and never letterboxed. Equal-aspect images (e.g. a row of screenshots) line up on their own,
102
+ // and the frame auto-heights to fit. `h` is accepted for back-compat but no longer constrains size -
103
+ // capping height below natural would force the image narrower than its column (whitespace) or slice it
104
+ // (the old object-fit:cover). Width:100% + height:auto come from the .mv-img-el rule.
105
+ void h
106
+ const style = undefined
87
107
  return (
88
108
  <figure className="mv-block mv-img">
89
- {/* h: shared rendered height for a row of mixed-aspect images - equal widths
90
- alone never make unequal images READ equal; cover-crop to one height does */}
91
- <img src={url} alt={alt ?? caption ?? ''} loading="lazy"
92
- style={h ? { height: h, width: '100%', objectFit: 'cover' } : undefined}
93
- onError={() => setErr(true)} />
109
+ {lodSupported
110
+ ? <canvas ref={canvasRef} className="mv-img-el" role="img" aria-label={alt ?? caption ?? ''} style={style} />
111
+ : <img className="mv-img-el" src={url} alt={alt ?? caption ?? ''} loading="lazy" style={style} onError={() => setErr(true)} />}
94
112
  {caption && <figcaption>{caption}</figcaption>}
95
113
  </figure>
96
114
  )
@@ -106,7 +124,7 @@ function ensureStyles() {
106
124
  stylesIn = true
107
125
  const el = document.createElement('style')
108
126
  el.id = 'mv-content-css'
109
- el.textContent = CSS
127
+ el.textContent = CSS + '\n' + FAMILY_CSS
110
128
  document.head.appendChild(el)
111
129
  }
112
130
 
@@ -140,14 +158,24 @@ body { margin: 0; }
140
158
  a whisper, not a card - the content pops, the block only frames it */
141
159
  .mv-block { margin: 0; padding: ${UNIT}px; border: 1px solid var(--mv-block-line);
142
160
  border-radius: 10px; background: var(--mv-block-bg); }
143
- .mv-block figcaption, .mv-img figcaption { font-size: 12.5px; color: var(--mv-faint); padding-top: 8px; }
161
+ /* an image block is NOT a card: the screenshot IS the content. Drop the surface + border so we don't
162
+ frame a frame; give the image itself a hairline edge and a whisper of shadow so it reads as a clean,
163
+ distinct object on the page (no heavy double-border around browser mockups). */
164
+ .mv-block.mv-img { padding: 0; border: none; background: none; }
165
+ /* INSET hairline: the line sits ON the image's own rounded edge (outline-offset:-1px), overriding a
166
+ screenshot's ragged or baked-in dark border instead of drawing a second frame outside it. It shares
167
+ the image's exact border-radius (same element), so the two never mismatch. Grayscale token = clean in
168
+ both light and dark. A whisper of drop shadow lifts it off the page. */
169
+ .mv-img .mv-img-el { outline: 1px solid var(--mv-line); outline-offset: -1px;
170
+ box-shadow: 0 1px 4px rgba(20, 22, 28, 0.07); }
171
+ .mv-block figcaption, .mv-img figcaption { font-size: 12.5px; color: var(--mv-faint); padding-top: 10px; }
144
172
  .mv-diagram-svg { display: flex; justify-content: center; }
145
173
  .mv-diagram-svg svg { max-width: 100%; height: auto; }
146
174
  .mv-diagram-err { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--mv-muted);
147
175
  font-family: ui-monospace, "SF Mono", Menlo, monospace; }
148
176
  .mv-diagram-err b { color: #E0402F; font-family: inherit; }
149
177
  .mv-diagram-err .dim, .mv-imgerr .dim { color: var(--mv-faint); font-size: 12px; }
150
- .mv-img img { display: block; max-width: 100%; border-radius: 6px; }
178
+ .mv-img .mv-img-el { display: block; width: 100%; height: auto; max-width: 100%; border-radius: 6px; }
151
179
  .mv-imgerr { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--mv-muted); }
152
180
  .mv-imgerr b { color: #E0402F; }
153
181
 
@@ -8,6 +8,19 @@ import { Marked } from 'marked'
8
8
 
9
9
  const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`)
10
10
 
11
+ /** D3: named color families for inline Md - the SAME families the diagrams use, so prose and
12
+ * the diagram beside it speak one color language. Theme pairs (light/dark). Bound to classes,
13
+ * never raw HTML (raw HTML stays inert). Used by the `:family[text]` extension + the frame CSS. */
14
+ export const FAMILIES: Record<string, { light: string; dark: string }> = {
15
+ blue: { light: '#0088FF', dark: '#3B9DFF' },
16
+ orange: { light: '#F5820A', dark: '#FF9F33' },
17
+ purple: { light: '#B32BC8', dark: '#D34FE8' },
18
+ green: { light: '#1FA34A', dark: '#34C759' },
19
+ red: { light: '#E5342B', dark: '#FF453A' },
20
+ gray: { light: '#8B95A3', dark: '#7D8794' },
21
+ }
22
+ const FAMILY_RE = new RegExp(`^:(${Object.keys(FAMILIES).join('|')})\\[([^\\]\\n]+)\\]`)
23
+
11
24
  /** Relative design/assets/ path -> served URL; null for anything else (fail closed).
12
25
  * Decodes BEFORE validating (a %2e%2e must not sneak past the ".." check) and
13
26
  * re-encodes per segment, so the validated path is the path the browser requests. */
@@ -45,6 +58,22 @@ const marked = new Marked({
45
58
  },
46
59
  })
47
60
 
61
+ // D3: `:blue[shipper's world]` -> a family-colored span (inline markdown inside still parses).
62
+ marked.use({
63
+ extensions: [{
64
+ name: 'mvcolor',
65
+ level: 'inline',
66
+ start(src: string) { return src.match(/:(?:blue|orange|purple|green|red|gray)\[/)?.index },
67
+ tokenizer(this: any, src: string) {
68
+ const m = FAMILY_RE.exec(src)
69
+ if (m) return { type: 'mvcolor', raw: m[0], family: m[1], tokens: this.lexer.inlineTokens(m[2]) }
70
+ },
71
+ renderer(this: any, token: any) {
72
+ return `<span class="mv-c-${token.family}">${this.parser.parseInline(token.tokens)}</span>`
73
+ },
74
+ }],
75
+ })
76
+
48
77
  export function renderMarkdown(src: string): string {
49
78
  return marked.parse(src, { async: false }) as string
50
79
  }
@@ -25,6 +25,12 @@ export const THEME_CSS = `
25
25
  .nodeLabel, .cluster-label { font-weight: 600; line-height: 1.4; }
26
26
  .nodeLabel p, .edgeLabel p, .label p { margin: 0; }
27
27
  .edgeLabel, .edgeLabel .label { font-weight: 500; }
28
+ /* D1 head/gloss hierarchy: a "Head :: gloss" label renders as two paragraphs -
29
+ bold head (the strong from the markdown), lighter/smaller gloss below. */
30
+ .nodeLabel p + p, .cluster-label p + p {
31
+ font-weight: 400; font-size: 0.84em; opacity: 0.68;
32
+ letter-spacing: 0; margin-top: 3px;
33
+ }
28
34
  `
29
35
 
30
36
  /** Mermaid themeVariables for one mode. Base theme + these = the marver look. */
@@ -4,6 +4,15 @@ const isHtmlFrame = new URL(import.meta.url).searchParams.get('html') === '1'
4
4
  const post = (msg) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
5
5
  const id = new URLSearchParams(location.search).get('id') ?? location.pathname
6
6
 
7
+ // SPEC-M5: the shell serialises this frame's DOM (same origin) for the lean facade. Open shadow roots
8
+ // are walkable, but a CLOSED root is invisible after the fact - flag it at creation so the serialiser
9
+ // degrades the frame (keeps it live) instead of shipping a lean copy missing its shadow content.
10
+ const _attachShadow = Element.prototype.attachShadow
11
+ if (_attachShadow) Element.prototype.attachShadow = function (init) {
12
+ if (init && init.mode === 'closed') window.__mvClosedShadow = true
13
+ return _attachShadow.call(this, init)
14
+ }
15
+
7
16
  // theme lands as BOTH signals: [data-theme] plus the `dark` class Tailwind/shadcn key on
8
17
  const setTheme = (theme) => {
9
18
  document.documentElement.dataset.theme = theme
@@ -32,6 +41,8 @@ window.addEventListener('error', (e) => post({ type: 'sh:error', id, message: St
32
41
  window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:error', id, message: `unhandled rejection: ${e.reason}` }))
33
42
 
34
43
  window.addEventListener('message', (e) => {
44
+ // commands come from the SHELL (parent) only - embedded app content must not flip the theme
45
+ if (e.source !== window.parent || window.parent === window) return
35
46
  if (e?.data?.type === 'sh:set-theme') setTheme(e.data.theme)
36
47
  })
37
48
 
@@ -40,10 +51,23 @@ if (isHtmlFrame) {
40
51
  document.readyState === 'loading' ? addEventListener('DOMContentLoaded', ready) : ready()
41
52
  }
42
53
 
43
- // pinch inside a frame must not zoom the parent PAGE (wheel events here belong to the
44
- // iframe's document, so the shell's blocker cannot see them). Keyboard cmd +/- untouched.
45
- window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.metaKey) e.preventDefault() }, { passive: false })
54
+ // B0.2 wheel ownership. A frame is either the interact/play target (the APP owns wheel -
55
+ // its own scroll) or passive (laser/comment/plain view - the CANVAS owns wheel). Wheel
56
+ // events land in the iframe's document, so the shell can't see them: when passive we
57
+ // forward them to the shell, when interactive we leave them for the app (only blocking
58
+ // the browser's own ctrl/meta page pinch-zoom). preventing here is mandatory - the parent
59
+ // gets the forwarded message too late to cancel the iframe's own scroll.
60
+ let interactiveOn = false
61
+ window.addEventListener('wheel', (e) => {
62
+ if (interactiveOn) { if (e.ctrlKey || e.metaKey) e.preventDefault(); return }
63
+ e.preventDefault()
64
+ e.stopImmediatePropagation()
65
+ post({ type: 'sh:wheel', id, deltaX: e.deltaX, deltaY: e.deltaY, deltaMode: e.deltaMode,
66
+ ctrlKey: e.ctrlKey, metaKey: e.metaKey, clientX: e.clientX, clientY: e.clientY })
67
+ }, { capture: true, passive: false })
46
68
  document.addEventListener('gesturestart', (e) => e.preventDefault())
69
+ // a nested scroll container hitting its boundary must not chain into the shell page
70
+ document.documentElement.style.overscrollBehavior = 'contain'
47
71
 
48
72
  // ---- laser mode + element picking (SPEC-M3 §5, §7) ----------------------------------
49
73
  // Laser: one injected stylesheet, outline only (zero layout shift), depth-based hue -
@@ -91,6 +115,15 @@ const COPIED_HTML = `<svg width="12" height="12" viewBox="0 0 256 256" fill="cur
91
115
  let laserOn = false, pickOn = false, hoverEl = null, labelEl = null
92
116
  const modeActive = () => laserOn || pickOn
93
117
 
118
+ // A6: report transient laser/comment engagement (pointer inside this frame AND a mode on) so the
119
+ // shell leases the frame - a hot update to it then defers until the pointer leaves or the mode
120
+ // ends, instead of yanking the user mid-inspect/mid-comment.
121
+ let pointerInside = false
122
+ const reportInteraction = () => post({ type: 'sh:interaction', id, laser: pointerInside && laserOn, comment: pointerInside && pickOn })
123
+ document.addEventListener('mouseover', () => { if (!pointerInside) { pointerInside = true; reportInteraction() } })
124
+ document.addEventListener('mouseout', (e) => { if (!e.relatedTarget) { pointerInside = false; reportInteraction() } })
125
+ window.addEventListener('blur', () => { if (pointerInside) { pointerInside = false; reportInteraction() } })
126
+
94
127
  // laser and pick are independent looks over shared hover machinery: the stylesheet
95
128
  // is regenerated on every flip so each mode contributes exactly its own rules
96
129
  const applyModes = () => {
@@ -258,8 +291,10 @@ window.addEventListener('message', (e) => {
258
291
  const m = e?.data
259
292
  if (!m || typeof m !== 'object') return
260
293
  // independent toggles - each mode contributes its own rules, applyModes composes
261
- if (m.type === 'sh:laser') { laserOn = !!m.on; applyModes() }
262
- if (m.type === 'sh:pick') { pickOn = !!m.on; applyModes() }
294
+ if (m.type === 'sh:laser') { laserOn = !!m.on; applyModes(); reportInteraction() }
295
+ if (m.type === 'sh:pick') { pickOn = !!m.on; applyModes(); reportInteraction() }
296
+ // B0.2: interact/play target owns its own wheel; passive frames forward it to the canvas
297
+ if (m.type === 'sh:interactive') { interactiveOn = !!m.on }
263
298
  if (m.type === 'sh:copy-ok') showCopied(m.seq)
264
299
  if (m.type === 'sh:resolve-anchors' && Array.isArray(m.anchors)) {
265
300
  const rects = m.anchors.slice(0, 200).map((a) => {
@@ -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
+ }