@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.
- package/CHANGELOG.md +107 -0
- package/dist/{build-p3xmXU3b.mjs → build-BZaPa2DS.mjs} +11 -5
- package/dist/cli.mjs +3 -3
- package/dist/{dev-DZi1yRhn.mjs → dev-DaPQ9xA5.mjs} +57 -8
- package/dist/{init-Ck8z-HiD.mjs → init-DsCUmlCW.mjs} +1 -1
- package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
- package/dist/{plugin-CtQqO5ZZ.mjs → plugin-wMY9lNf3.mjs} +67 -20
- package/package.json +2 -1
- package/src/client/content/diagram.tsx +46 -2
- package/src/client/content/img-lod.ts +107 -0
- package/src/client/content/index.tsx +37 -9
- package/src/client/content/md.ts +29 -0
- package/src/client/content/palette.ts +6 -0
- package/src/client/frame-host/bridge.js +40 -5
- package/src/client/frame-host/serialize.ts +195 -0
- package/src/client/shell/App.tsx +75 -18
- package/src/client/shell/Comments.tsx +5 -4
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +69 -30
- package/src/client/shell/canvas/FrameNode.tsx +104 -8
- package/src/client/shell/canvas/camera-broadcast.ts +44 -0
- package/src/client/shell/canvas/frame-registry.ts +18 -0
- package/src/client/shell/canvas/snapshots.ts +233 -0
- package/src/client/shell/cursor-arrow-dark.svg +1 -0
- package/src/client/shell/cursor-arrow.svg +1 -0
- package/src/client/shell/labels.ts +10 -0
- package/src/client/shell/perf.ts +92 -0
- package/src/client/shell/store.ts +130 -22
- package/src/client/shell/styles.css +37 -7
- package/src/client/stage/main.tsx +2 -0
- package/templates/instructions/boards.md +9 -3
- package/templates/instructions/reference/color.md +22 -1
- 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
|
-
{
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
package/src/client/content/md.ts
CHANGED
|
@@ -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
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
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
|
+
}
|