@marver-design/marver 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/README.md +3 -1
- package/dist/bake-kaf5kGZ7.mjs +747 -0
- package/dist/{build-D_g53Bp2.mjs → build-7ed5H2vT.mjs} +215 -13
- package/dist/cli.mjs +19 -11
- package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
- package/dist/{daemon-Bbh_jmui.mjs → daemon-DbHvLQUL.mjs} +1 -1
- package/dist/{dev-LnIISva5.mjs → dev-D3mP2x27.mjs} +205 -10
- package/dist/{init-B7YhcN2o.mjs → init-BQYCS3EU.mjs} +2 -2
- package/dist/{manifest-CaslQIAO.mjs → manifest-B01PSyDc.mjs} +34 -7
- package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
- package/dist/{plugin-D2msH1cj.mjs → plugin-DI-7NAnx.mjs} +108 -29
- package/dist/{poster-BEjUcQP3.mjs → poster-DNh6N27C.mjs} +1 -1
- package/dist/publish-bakes-Dp-ZFk3d.mjs +216 -0
- package/dist/{serve-Bcwfpvhl.mjs → serve-z5qtj_wJ.mjs} +3 -3
- package/dist/{share-Gqo_Ygqw.mjs → share--bdSc4G5.mjs} +1 -1
- package/dist/{shot-BzQ0PXKH.mjs → shot-BFEuYbaz.mjs} +1 -1
- package/dist/{shot-DlmTO8AF.mjs → shot-DMDvDbeP.mjs} +14 -7
- package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
- package/docs/publish.md +27 -5
- package/docs/sticky-notes.md +43 -0
- package/package.json +2 -1
- package/src/client/content/diagram.tsx +27 -1
- package/src/client/content/index.tsx +2 -2
- package/src/client/content/md.ts +48 -0
- package/src/client/frame-host/bridge.js +4 -9
- package/src/client/frame-host/main.tsx +20 -6
- package/src/client/shell/App.tsx +14 -63
- package/src/client/shell/Comments.tsx +77 -14
- package/src/client/shell/Play.tsx +6 -3
- package/src/client/shell/canvas/Canvas.tsx +12 -3
- package/src/client/shell/canvas/FrameNode.tsx +99 -89
- package/src/client/shell/canvas/Sticky.tsx +284 -0
- package/src/client/shell/canvas/admission.ts +70 -0
- package/src/client/shell/canvas/sleep.ts +194 -0
- package/src/client/shell/goto.ts +72 -0
- package/src/client/shell/notes.ts +151 -0
- package/src/client/shell/store.ts +36 -18
- package/src/client/shell/styles.css +92 -25
- package/src/client/shell/tidy.ts +20 -2
- package/src/shared/sleep-rule.ts +41 -0
- package/templates/AGENTS-embedded.md +15 -0
- package/templates/AGENTS-studio.md +15 -0
- package/templates/instructions/craft.md +9 -0
- package/templates/instructions/publish.md +62 -9
- package/templates/instructions/shape.md +62 -0
- package/src/client/frame-host/serialize.ts +0 -195
- package/src/client/shell/canvas/snapshots.ts +0 -233
|
@@ -1,195 +0,0 @@
|
|
|
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 (consolidating
|
|
23
|
-
* rules into one <style> moves the url() base from each sheet to the srcdoc base). */
|
|
24
|
-
function absolutizeUrls(css: string, sheetHref: string | null): string {
|
|
25
|
-
if (!sheetHref) return css
|
|
26
|
-
return css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (m, q, ref: string) => {
|
|
27
|
-
if (/^(data:|https?:|blob:|#|\/)/i.test(ref) || !ref.trim()) return m
|
|
28
|
-
try { return `url(${q}${new URL(ref, sheetHref).href}${q})` } catch { return m }
|
|
29
|
-
})
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Serialise one sheet's rules, recursing into same-origin @import (so imported CSS is inlined, not
|
|
33
|
-
* left as an invalid mid-list @import pointing at an un-inlined sheet). Bumps `xo` on any unreadable
|
|
34
|
-
* (cross-origin) sheet or import so the caller can degrade. */
|
|
35
|
-
function rulesText(rules: CSSRuleList, href: string | null, xo: { n: number }): string {
|
|
36
|
-
let text = ''
|
|
37
|
-
for (const r of Array.from(rules)) {
|
|
38
|
-
if (r.type === 3 /* CSSRule.IMPORT_RULE */) {
|
|
39
|
-
const imp = r as CSSImportRule & { supportsText?: string; layerName?: string | null }
|
|
40
|
-
const sheet = imp.styleSheet
|
|
41
|
-
if (!sheet) { xo.n++; continue }
|
|
42
|
-
let inner: string
|
|
43
|
-
try { inner = rulesText(sheet.cssRules, sheet.href ?? href, xo) } catch { xo.n++; continue }
|
|
44
|
-
// preserve the import's conditions - flattening them makes a conditional (e.g. desktop-only,
|
|
45
|
-
// layered, feature-gated) sheet globally active and mis-render the lean.
|
|
46
|
-
if (imp.layerName != null) inner = `@layer${imp.layerName ? ' ' + imp.layerName : ''}{\n${inner}\n}`
|
|
47
|
-
if (imp.supportsText) inner = `@supports (${imp.supportsText}){\n${inner}\n}`
|
|
48
|
-
const m = imp.media?.mediaText
|
|
49
|
-
if (m && m !== 'all') inner = `@media ${m}{\n${inner}\n}`
|
|
50
|
-
text += inner
|
|
51
|
-
continue
|
|
52
|
-
}
|
|
53
|
-
text += r.cssText + '\n'
|
|
54
|
-
}
|
|
55
|
-
return absolutizeUrls(text, href)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Collect every CSS rule the document renders with: <style>/<link> sheets (recursing @import) AND
|
|
59
|
-
* constructable adoptedStyleSheets. Cross-origin sheets throw on .cssRules - record + degrade (never
|
|
60
|
-
* claim fidelity). Honours per-sheet media (`<link media=print>`) and skips disabled sheets. */
|
|
61
|
-
function collectCss(doc: Document, degraded: string[], notes: string[]): string {
|
|
62
|
-
const chunks: string[] = []
|
|
63
|
-
const xo = { n: 0 }
|
|
64
|
-
const dump = (sheet: CSSStyleSheet, href: string | null, label: string) => {
|
|
65
|
-
if (sheet.disabled) return
|
|
66
|
-
let rules: CSSRuleList | null = null
|
|
67
|
-
try { rules = sheet.cssRules } catch { xo.n++; return }
|
|
68
|
-
if (!rules) return
|
|
69
|
-
const text = rulesText(rules, href, xo)
|
|
70
|
-
if (!text) return
|
|
71
|
-
const media = sheet.media?.mediaText
|
|
72
|
-
chunks.push(`/* ${label} */\n${media && media !== 'all' ? `@media ${media}{\n${text}\n}` : text}`)
|
|
73
|
-
}
|
|
74
|
-
for (const s of Array.from(doc.styleSheets)) dump(s as CSSStyleSheet, s.href, s.href ?? 'inline')
|
|
75
|
-
const adopted = (doc as Document & { adoptedStyleSheets?: CSSStyleSheet[] }).adoptedStyleSheets ?? []
|
|
76
|
-
adopted.forEach((s, i) => dump(s, null, `adopted[${i}]`))
|
|
77
|
-
if (xo.n) { degraded.push('cross-origin-css'); notes.push(`${xo.n} cross-origin stylesheet(s)/import(s) unreadable`) }
|
|
78
|
-
if (adopted.length) notes.push(`${adopted.length} adoptedStyleSheet(s) inlined`)
|
|
79
|
-
return chunks.join('\n')
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** A stable-ish selector for restoring scroll in the identical-structure lean doc. */
|
|
83
|
-
function selectorFor(el: Element): string {
|
|
84
|
-
const seg: string[] = []
|
|
85
|
-
for (let cur: Element | null = el; cur && cur !== el.ownerDocument.documentElement; cur = cur.parentElement) {
|
|
86
|
-
if (cur.id) { seg.unshift(`#${CSS.escape(cur.id)}`); break }
|
|
87
|
-
const tag = cur.tagName.toLowerCase()
|
|
88
|
-
let n = 1
|
|
89
|
-
for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling) if (sib.tagName === cur.tagName) n++
|
|
90
|
-
seg.unshift(`${tag}:nth-of-type(${n})`)
|
|
91
|
-
}
|
|
92
|
-
return seg.join('>')
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Strip execution + inline handlers from the clone; flag content that cannot reflow as DOM. */
|
|
96
|
-
function scrub(root: Element, doc: Document, degraded: string[]): void {
|
|
97
|
-
root.querySelectorAll('script, noscript, link[rel~="modulepreload"], link[rel~="preload"], link[rel~="stylesheet"]').forEach((n) => n.remove())
|
|
98
|
-
const walk = doc.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
|
|
99
|
-
const flag = { canvas: false, video: false, nested: false }
|
|
100
|
-
for (let el = walk.currentNode as Element | null; el; el = walk.nextNode() as Element | null) {
|
|
101
|
-
for (const attr of Array.from(el.attributes)) {
|
|
102
|
-
if (/^on/i.test(attr.name)) el.removeAttribute(attr.name)
|
|
103
|
-
else if (/^\s*javascript:/i.test(attr.value)) el.removeAttribute(attr.name)
|
|
104
|
-
}
|
|
105
|
-
const tag = el.tagName
|
|
106
|
-
if (tag === 'CANVAS') flag.canvas = true
|
|
107
|
-
if (tag === 'VIDEO') flag.video = true
|
|
108
|
-
// a nested iframe/object/embed can't reflow in a scriptless clone (blank/stale, and would
|
|
109
|
-
// re-fetch third-party content) - degrade to live rather than ship a broken lean.
|
|
110
|
-
if (tag === 'IFRAME' || tag === 'OBJECT' || tag === 'EMBED') flag.nested = true
|
|
111
|
-
if (STRIP_TAGS.has(tag)) el.remove()
|
|
112
|
-
}
|
|
113
|
-
if (flag.canvas) degraded.push('canvas')
|
|
114
|
-
if (flag.video) degraded.push('video')
|
|
115
|
-
if (flag.nested) degraded.push('nested-frame')
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Open shadow roots must be detected on the ORIGINAL tree - cloneNode(true) drops them, so the clone
|
|
119
|
-
* can never reveal them. A frame with any shadow DOM (open here, or closed via the boot-time flag)
|
|
120
|
-
* degrades to live rather than shipping a lean copy missing its shadow content. */
|
|
121
|
-
function hasOpenShadow(doc: Document): boolean {
|
|
122
|
-
const walk = doc.createTreeWalker(doc.documentElement, NodeFilter.SHOW_ELEMENT)
|
|
123
|
-
for (let el = walk.currentNode as Element | null; el; el = walk.nextNode() as Element | null)
|
|
124
|
-
if ((el as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot) return true
|
|
125
|
-
return false
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Copy live form PROPERTIES (value/checked/selected) into the clone. They are runtime state, not
|
|
129
|
-
* attributes, so cloneNode never carries them - without this a re-captured frame shows empty inputs
|
|
130
|
-
* and unchecked boxes. Original and clone share structure, so we pair them in document order.
|
|
131
|
-
* tagName/type checks (not instanceof) because the clone nodes are owned by the frame's realm. */
|
|
132
|
-
function syncFormState(doc: Document, clone: HTMLElement): void {
|
|
133
|
-
const live = doc.querySelectorAll('input, textarea, option')
|
|
134
|
-
const dst = clone.querySelectorAll('input, textarea, option')
|
|
135
|
-
live.forEach((src, i) => {
|
|
136
|
-
const d = dst[i]
|
|
137
|
-
if (!d || d.tagName !== src.tagName) return
|
|
138
|
-
if (src.tagName === 'INPUT') {
|
|
139
|
-
const s = src as HTMLInputElement
|
|
140
|
-
// never bake a password into the retained snapshot html; a file input can't be reconstructed
|
|
141
|
-
if (s.type === 'password' || s.type === 'file') { d.removeAttribute('value'); return }
|
|
142
|
-
if (s.type === 'checkbox' || s.type === 'radio') s.checked ? d.setAttribute('checked', '') : d.removeAttribute('checked')
|
|
143
|
-
else d.setAttribute('value', s.value)
|
|
144
|
-
} else if (src.tagName === 'TEXTAREA') { d.textContent = (src as HTMLTextAreaElement).value }
|
|
145
|
-
else if (src.tagName === 'OPTION') { (src as HTMLOptionElement).selected ? d.setAttribute('selected', '') : d.removeAttribute('selected') }
|
|
146
|
-
})
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Serialize a live same-origin Document into a static, self-contained html string.
|
|
151
|
-
* @param doc the live frame document (same origin - we read its cssRules directly)
|
|
152
|
-
* @param baseHref the frame's real URL, injected as <base> so relative url()/img/font resolve
|
|
153
|
-
*/
|
|
154
|
-
export function serializeDoc(doc: Document, baseHref: string): SerializeResult {
|
|
155
|
-
const notes: string[] = []
|
|
156
|
-
const degraded: string[] = []
|
|
157
|
-
|
|
158
|
-
// shadow DOM (open detected on the original tree, closed via the boot-time attachShadow flag):
|
|
159
|
-
// either kind degrades the frame to live - cloneNode cannot carry a shadow root.
|
|
160
|
-
const win = doc.defaultView as (Window & { __mvClosedShadow?: boolean }) | null
|
|
161
|
-
if (win?.__mvClosedShadow || hasOpenShadow(doc)) { degraded.push('shadow-dom'); notes.push('shadow root(s) present') }
|
|
162
|
-
|
|
163
|
-
const css = collectCss(doc, degraded, notes)
|
|
164
|
-
const cssBytes = new Blob([css]).size
|
|
165
|
-
|
|
166
|
-
// scroll offsets to restore shell-side (native scrollers; a virtualised one that fails to resolve
|
|
167
|
-
// OR whose offset does not stick re-degrades the frame in the coordinator). != 0 catches RTL/negative.
|
|
168
|
-
const scrollMap: ScrollEntry[] = []
|
|
169
|
-
for (const el of Array.from(doc.querySelectorAll<HTMLElement>('*'))) {
|
|
170
|
-
if (el.scrollTop !== 0 || el.scrollLeft !== 0) scrollMap.push({ sel: selectorFor(el), top: el.scrollTop, left: el.scrollLeft })
|
|
171
|
-
}
|
|
172
|
-
const de = doc.documentElement
|
|
173
|
-
if (de.scrollTop !== 0 || de.scrollLeft !== 0) scrollMap.push({ sel: ':root', top: de.scrollTop, left: de.scrollLeft })
|
|
174
|
-
|
|
175
|
-
// clone the RENDERED dom (React's committed output = authored markup that reflows under CSS)
|
|
176
|
-
const html = doc.documentElement.cloneNode(true) as HTMLElement
|
|
177
|
-
scrub(html, doc, degraded)
|
|
178
|
-
syncFormState(doc, html)
|
|
179
|
-
|
|
180
|
-
// head: <meta charset> + <base> (relative url()/img/font resolve against the real frame URL) +
|
|
181
|
-
// ONE inlined stylesheet (both themes, all media queries). Drop the clone's own style nodes.
|
|
182
|
-
const head = html.querySelector('head') ?? html.insertBefore(doc.createElement('head'), html.firstChild)
|
|
183
|
-
head.querySelectorAll('style').forEach((n) => n.remove())
|
|
184
|
-
// sentinel first rule: the coordinator checks --mv-lean-ok resolves before showing the lean. If a
|
|
185
|
-
// hardened host's CSP (style-src 'self') blocked this inline <style>, the sentinel is absent and the
|
|
186
|
-
// frame stays live rather than showing an unstyled cover.
|
|
187
|
-
const style = doc.createElement('style'); style.textContent = ':root{--mv-lean-ok:1}\n' + css
|
|
188
|
-
const base = doc.createElement('base'); base.setAttribute('href', baseHref)
|
|
189
|
-
const meta = doc.createElement('meta'); meta.setAttribute('charset', 'utf-8')
|
|
190
|
-
head.insertBefore(style, head.firstChild)
|
|
191
|
-
head.insertBefore(base, head.firstChild)
|
|
192
|
-
head.insertBefore(meta, head.firstChild)
|
|
193
|
-
|
|
194
|
-
return { html: '<!doctype html>\n' + html.outerHTML, scrollMap, degraded: Array.from(new Set(degraded)), notes, cssBytes }
|
|
195
|
-
}
|
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The lean-frame facade coordinator. Imperative on purpose - the facade is a
|
|
3
|
-
* `<iframe class="sh-lean" sandbox="allow-same-origin">` driven by setting .srcdoc directly, so no
|
|
4
|
-
* FrameNode subscribes to snapshot state and a pan/zoom tick triggers zero React renders.
|
|
5
|
-
*
|
|
6
|
-
* The lean tier is a DOM SNAPSHOT, not a bitmap: the shell serialises the live frame's document
|
|
7
|
-
* (same origin) into self-contained static html (post-render DOM + full inlined CSS, JS stripped).
|
|
8
|
-
* LEAN-PRIMARY: this snapshot is what you SEE for a passive frame - at rest AND during pan/zoom - so
|
|
9
|
-
* there is NO per-gesture swap between the lean and the live iframe (two documents never render
|
|
10
|
-
* pixel-identically; the swap shifted text ~1-2px = jiggle, and flashed mermaid/theme). The live app
|
|
11
|
-
* shows underneath only when the frame is interacted, in laser/comment mode, or while the lean is
|
|
12
|
-
* being (re)built. Real DOM + real CSS: exact color, native reflow on resize. Theme change / focus
|
|
13
|
-
* INVALIDATE the lean (never mutate a displayed one - baked mermaid can't re-theme in place) and a
|
|
14
|
-
* fresh capture is admitted before it is shown again. Captures run bounded-parallel, viewport-first, at idle, never while busy.
|
|
15
|
-
*
|
|
16
|
-
* Correctness beats the flash-guard: a frame the serialiser cannot render faithfully
|
|
17
|
-
* (canvas/video/shadow-dom/cross-origin-css, or an unrestorable scroller) is left DEGRADED - no
|
|
18
|
-
* `data-ready`, no cover, live pixels stay. Every install is guarded by a per-node GENERATION token
|
|
19
|
-
* so an in-flight capture can never paint a stale/wrong-node cover after a reload or unmount.
|
|
20
|
-
*/
|
|
21
|
-
import { serializeDoc, type SerializeResult } from '../../frame-host/serialize.ts'
|
|
22
|
-
|
|
23
|
-
export interface SnapMeta { sourceRevision: string; theme: string }
|
|
24
|
-
|
|
25
|
-
interface Entry { key: string; html: string; scrollMap: SerializeResult['scrollMap']; degraded: string[]; theme: string; gen: number; live: HTMLIFrameElement; meta: SnapMeta }
|
|
26
|
-
const byNode = new Map<string, Entry>() // nodeKey -> current lean snapshot
|
|
27
|
-
const frames = new Map<string, HTMLIFrameElement>() // nodeKey -> the facade <iframe> element
|
|
28
|
-
const gen = new Map<string, number>() // nodeKey -> generation (bumped on drop/reload)
|
|
29
|
-
const recheck = new Map<string, number>() // nodeKey -> a one-shot re-capture timer (slow async)
|
|
30
|
-
const rechecked = new Set<string>() // nodes whose one-shot recheck already fired (no re-arm loop)
|
|
31
|
-
const MAX_LEAN_BYTES = 4 * 1024 * 1024 // over this a frame is too heavy to inline - stay live
|
|
32
|
-
|
|
33
|
-
// content identity INCLUDES theme: content whose colors are baked into the DOM at render time
|
|
34
|
-
// (mermaid SVG) cannot be re-themed by the cover's attribute mutation, so a theme change must
|
|
35
|
-
// re-capture after the live frame re-renders. Size still needs no re-capture (the lean doc reflows).
|
|
36
|
-
const keyOf = (m: SnapMeta) => `${m.sourceRevision}|${m.theme}`
|
|
37
|
-
const genOf = (k: string) => gen.get(k) ?? 0
|
|
38
|
-
const bumpGen = (k: string) => gen.set(k, genOf(k) + 1) // invalidate any in-flight capture/onload for k
|
|
39
|
-
|
|
40
|
-
const inMotion = (): boolean => {
|
|
41
|
-
const w = document.getElementById('sh-world')
|
|
42
|
-
return !!w && (w.classList.contains('sh-gesturing') || w.classList.contains('sh-preset'))
|
|
43
|
-
}
|
|
44
|
-
// never serialise while laser/comment mode is on: those inject outline styles + hover chrome into the
|
|
45
|
-
// live doc, which the shell-side clone would bake into the lean (visible after the mode ends).
|
|
46
|
-
const modeActive = (): boolean => document.body.classList.contains('sh-laser') || document.body.classList.contains('sh-commenting')
|
|
47
|
-
const busy = (): boolean => inMotion() || modeActive()
|
|
48
|
-
|
|
49
|
-
/** Apply a node's current theme to its lean doc via attribute mutation (allow-same-origin lets the
|
|
50
|
-
* shell touch the doc; the full CSS is inlined so only which rules match changes - no re-capture). */
|
|
51
|
-
function applyTheme(doc: Document, theme: string): void {
|
|
52
|
-
doc.documentElement.dataset.theme = theme
|
|
53
|
-
doc.documentElement.classList.toggle('dark', theme === 'dark')
|
|
54
|
-
// pin the cover's color-scheme to the FRAME theme, not the viewer's OS. A srcdoc doc otherwise
|
|
55
|
-
// follows the OS: on a dark-mode Mac the UA canvas + any prefers-color-scheme rules go dark and
|
|
56
|
-
// bleed into the cover (dark mermaid boxes) while data-theme says light. This holds it to light.
|
|
57
|
-
doc.documentElement.style.colorScheme = theme
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Restore captured scroll offsets shell-side (the lean doc runs no JS). Returns false if any mapped
|
|
61
|
-
* scroller cannot be resolved OR the offset did not stick (clamped by a differently-reflowed lean
|
|
62
|
-
* doc) - the frame then degrades to live rather than showing a mis-scrolled cover. */
|
|
63
|
-
function restoreScroll(doc: Document, scrollMap: Entry['scrollMap']): boolean {
|
|
64
|
-
for (const s of scrollMap) {
|
|
65
|
-
const el = s.sel === ':root' ? doc.documentElement : doc.querySelector<HTMLElement>(s.sel)
|
|
66
|
-
if (!el) return false
|
|
67
|
-
el.scrollTop = s.top; el.scrollLeft = s.left
|
|
68
|
-
if (Math.abs(el.scrollTop - s.top) > 2 || Math.abs(el.scrollLeft - s.left) > 2) return false
|
|
69
|
-
}
|
|
70
|
-
return true
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Install the stored snapshot into a node's lean iframe: parse srcdoc, then on load restore scroll,
|
|
74
|
-
* apply theme, and (only if nothing degraded AND fonts+paint have settled) mark ready. Every step is
|
|
75
|
-
* generation-guarded so a superseded capture or an about:blank reset never re-admits a cover. */
|
|
76
|
-
function install(nodeKey: string): void {
|
|
77
|
-
const iframe = frames.get(nodeKey)
|
|
78
|
-
if (!iframe) return
|
|
79
|
-
iframe.onload = null // detach any prior handler (about:blank reset can't re-admit)
|
|
80
|
-
delete iframe.dataset.ready
|
|
81
|
-
const e = byNode.get(nodeKey)
|
|
82
|
-
if (!e || e.degraded.length) { iframe.removeAttribute('srcdoc'); return } // degraded = no cover, keep live
|
|
83
|
-
const myGen = e.gen
|
|
84
|
-
iframe.onload = () => {
|
|
85
|
-
if (genOf(nodeKey) !== myGen || frames.get(nodeKey) !== iframe) return // superseded / remounted
|
|
86
|
-
const cur = byNode.get(nodeKey)
|
|
87
|
-
const doc = iframe.contentDocument
|
|
88
|
-
if (!cur || !doc) return
|
|
89
|
-
if (!restoreScroll(doc, cur.scrollMap)) { cur.degraded = [...cur.degraded, 'scroll']; iframe.removeAttribute('srcdoc'); return }
|
|
90
|
-
// CSP guard: if a hardened host blocked the inline <style> (style-src 'self'), the lean is
|
|
91
|
-
// unstyled - the sentinel custom prop won't resolve. Stay live rather than show an unstyled cover.
|
|
92
|
-
if (getComputedStyle(doc.documentElement).getPropertyValue('--mv-lean-ok').trim() !== '1') { iframe.removeAttribute('srcdoc'); return }
|
|
93
|
-
applyTheme(doc, cur.theme)
|
|
94
|
-
// font+paint readiness gate (F3): the srcdoc doc reloads fonts independently, so mark ready only
|
|
95
|
-
// after its fonts settle + two paints, else a fallback-font seam shows on the swap.
|
|
96
|
-
const markReady = () => {
|
|
97
|
-
if (genOf(nodeKey) !== myGen || frames.get(nodeKey) !== iframe) return
|
|
98
|
-
iframe.dataset.ready = '1'
|
|
99
|
-
// slow-async guard: a data fetch / route change that lands AFTER the capture window would leave
|
|
100
|
-
// the lean frozen on a loading state. ONE bounded re-capture ~3s after admit catches it; later
|
|
101
|
-
// changes self-heal on focus. `rechecked` makes it truly one-shot - the recheck's own recapture
|
|
102
|
-
// must not re-arm the timer (that was an endless ~3s loop), so a long-poll can't thrash.
|
|
103
|
-
clearTimeout(recheck.get(nodeKey))
|
|
104
|
-
if (cur.live && !rechecked.has(nodeKey)) recheck.set(nodeKey, window.setTimeout(() => {
|
|
105
|
-
recheck.delete(nodeKey); rechecked.add(nodeKey)
|
|
106
|
-
if (genOf(nodeKey) === myGen) scheduleCapture(nodeKey, cur.live, cur.meta, true)
|
|
107
|
-
}, 3000))
|
|
108
|
-
}
|
|
109
|
-
void (doc.fonts?.ready ?? Promise.resolve()).then(() => requestAnimationFrame(() => requestAnimationFrame(markReady)))
|
|
110
|
-
}
|
|
111
|
-
iframe.srcdoc = e.html
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** FrameNode registers its facade <iframe> on mount so the coordinator can drive it imperatively. */
|
|
115
|
-
export function registerLeanFrame(nodeKey: string, iframe: HTMLIFrameElement | null): void {
|
|
116
|
-
if (!iframe) { frames.delete(nodeKey); return }
|
|
117
|
-
frames.set(nodeKey, iframe)
|
|
118
|
-
if (byNode.has(nodeKey)) install(nodeKey)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Hide the lean at once (show the live app underneath) and cancel in-flight work. Used when a frame
|
|
122
|
-
* is focused/interacted, or its theme changes: a baked mermaid SVG cannot be re-themed in place, so
|
|
123
|
-
* we never mutate a DISPLAYED snapshot across themes - we drop it and rebuild a fresh one, which is
|
|
124
|
-
* only shown once it passes admission. Live stays visible in the meantime (live-fallback). */
|
|
125
|
-
export function invalidateLean(nodeKey: string): void {
|
|
126
|
-
bumpGen(nodeKey)
|
|
127
|
-
pending.delete(nodeKey)
|
|
128
|
-
clearTimeout(recheck.get(nodeKey)); recheck.delete(nodeKey); rechecked.delete(nodeKey)
|
|
129
|
-
const iframe = frames.get(nodeKey)
|
|
130
|
-
if (iframe) delete iframe.dataset.ready
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Drop a node's snapshot (unmount, or content changed / frame reloaded). Cancels queued + in-flight
|
|
134
|
-
* work via the generation bump and clears the cover. */
|
|
135
|
-
export function dropSnapshot(nodeKey: string): void {
|
|
136
|
-
bumpGen(nodeKey)
|
|
137
|
-
byNode.delete(nodeKey)
|
|
138
|
-
pending.delete(nodeKey)
|
|
139
|
-
clearTimeout(recheck.get(nodeKey)); recheck.delete(nodeKey); rechecked.delete(nodeKey)
|
|
140
|
-
const iframe = frames.get(nodeKey)
|
|
141
|
-
if (iframe) { iframe.onload = null; delete iframe.dataset.ready; iframe.removeAttribute('srcdoc') }
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ---- capture coordinator: bounded-parallel, viewport-first, idle-scheduled, never while busy --------
|
|
145
|
-
const pending = new Map<string, { live: HTMLIFrameElement; meta: SnapMeta }>() // nodeKey -> latest request
|
|
146
|
-
const inflightNodes = new Set<string>() // nodes currently being captured - never capture one twice at once
|
|
147
|
-
const MAX_CONCURRENT = 3 // overlap the per-frame settle waits (mostly timers) so a big board's leans
|
|
148
|
-
let inflight = 0 // land in ~1/3 the wall-clock of strictly-serial, without janking the loop
|
|
149
|
-
const idle = (fn: () => void) =>
|
|
150
|
-
(window as unknown as { requestIdleCallback?: (f: () => void, o?: object) => void }).requestIdleCallback?.(fn, { timeout: 600 }) ?? setTimeout(fn, 80)
|
|
151
|
-
const rafSettle = () => new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())))
|
|
152
|
-
const withDeadline = <T,>(p: Promise<T>, ms: number) => Promise.race([p, new Promise<void>((r) => setTimeout(r, ms))])
|
|
153
|
-
|
|
154
|
-
/** Wait until the frame's DOM stops mutating for `quietMs` (bounded by `maxMs`). Async content -
|
|
155
|
-
* lazily-imported mermaid renders its SVG well after the frame reports 'ready', late images/webfont
|
|
156
|
-
* swaps, entrance animations - all land here. Capturing before this quiet window yields a cover
|
|
157
|
-
* missing the diagram (the mermaid pop-in/out bug). Same-origin, so the shell can observe the doc. */
|
|
158
|
-
function domQuiet(doc: Document, quietMs: number, maxMs: number): Promise<void> {
|
|
159
|
-
return new Promise((resolve) => {
|
|
160
|
-
let timer = 0, done = false
|
|
161
|
-
const finish = () => { if (done) return; done = true; clearTimeout(timer); clearTimeout(hard); mo.disconnect(); resolve() }
|
|
162
|
-
const mo = new MutationObserver(() => { clearTimeout(timer); timer = window.setTimeout(finish, quietMs) })
|
|
163
|
-
try { mo.observe(doc.documentElement, { subtree: true, childList: true, attributes: true, characterData: true }) }
|
|
164
|
-
catch { return resolve() }
|
|
165
|
-
timer = window.setTimeout(finish, quietMs)
|
|
166
|
-
const hard = window.setTimeout(finish, maxMs)
|
|
167
|
-
})
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Request a fresh lean snapshot for a ready/quiet frame. Coalesces to the latest per node. `force`
|
|
171
|
-
* recaptures even when the key is unchanged - used on blur, where the live state changed under the
|
|
172
|
-
* same revision/theme (typed input, toggled UI) and the old lean is now wrong. */
|
|
173
|
-
export function scheduleCapture(nodeKey: string, live: HTMLIFrameElement, meta: SnapMeta, force = false): void {
|
|
174
|
-
if (!force && byNode.get(nodeKey)?.key === keyOf(meta)) return // already have this content revision
|
|
175
|
-
pending.set(nodeKey, { live, meta })
|
|
176
|
-
pump()
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** The next node to capture: the pending frame nearest the viewport centre, on-screen before off. So
|
|
180
|
-
* the frames the user is actually looking at get their lean first (perceived-instant), and the rest
|
|
181
|
-
* fill in behind - instead of registration order, which pops in arbitrary corners of a big board. */
|
|
182
|
-
function pickNext(): string | null {
|
|
183
|
-
let best: string | null = null, bestScore = Infinity
|
|
184
|
-
const vw = window.innerWidth, vh = window.innerHeight
|
|
185
|
-
for (const [k, req] of pending) {
|
|
186
|
-
if (inflightNodes.has(k)) continue // that node is mid-capture; its re-request waits
|
|
187
|
-
const r = req.live.getBoundingClientRect()
|
|
188
|
-
const off = r.bottom < 0 || r.top > vh || r.right < 0 || r.left > vw
|
|
189
|
-
const dx = r.left + r.width / 2 - vw / 2, dy = r.top + r.height / 2 - vh / 2
|
|
190
|
-
const score = (off ? 1e7 : 0) + Math.hypot(dx, dy) // on-screen first, then by distance to centre
|
|
191
|
-
if (score < bestScore) { bestScore = score; best = k }
|
|
192
|
-
}
|
|
193
|
-
return best
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function pump(): void {
|
|
197
|
-
if (busy()) { if (pending.size) idle(pump); return } // never serialise mid-gesture or during laser/comment
|
|
198
|
-
while (inflight < MAX_CONCURRENT) {
|
|
199
|
-
const nodeKey = pickNext()
|
|
200
|
-
if (!nodeKey) break
|
|
201
|
-
const req = pending.get(nodeKey)!
|
|
202
|
-
pending.delete(nodeKey)
|
|
203
|
-
inflightNodes.add(nodeKey); inflight++
|
|
204
|
-
const done = () => { inflightNodes.delete(nodeKey); inflight--; idle(pump) }
|
|
205
|
-
void capture(nodeKey, req.live, req.meta).then(done, done)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
async function capture(nodeKey: string, live: HTMLIFrameElement, meta: SnapMeta): Promise<void> {
|
|
210
|
-
const myGen = genOf(nodeKey)
|
|
211
|
-
const doc = live.contentDocument
|
|
212
|
-
if (!doc) return
|
|
213
|
-
// settle: fonts, two stable paints, THEN a DOM-quiet window so async content (mermaid renders its
|
|
214
|
-
// SVG after 'ready', late images) is captured - not a diagram-less frame. All bounded.
|
|
215
|
-
await withDeadline(doc.fonts?.ready ?? Promise.resolve(), 400).catch(() => {})
|
|
216
|
-
await rafSettle()
|
|
217
|
-
await domQuiet(doc, 180, 2500) // still-loading frames wait longer so async data lands in-capture
|
|
218
|
-
// bail if the world changed under us during settle: superseded (drop/reload), a newer request
|
|
219
|
-
// landed, the frame renavigated, or a gesture/preset started (serialising+parsing now would jank).
|
|
220
|
-
if (genOf(nodeKey) !== myGen || pending.has(nodeKey) || live.contentDocument !== doc) return
|
|
221
|
-
if (busy()) { pending.set(nodeKey, { live, meta }); return } // requeue for the next idle tick
|
|
222
|
-
let result: SerializeResult
|
|
223
|
-
try { result = serializeDoc(doc, doc.URL) } // <base> = the frame's own URL, so relative url()/img/font resolve
|
|
224
|
-
catch { return } // fail soft: keep live pixels
|
|
225
|
-
// budget: a pathologically heavy frame (huge inlined CSS/DOM) would multiply memory across the board
|
|
226
|
-
// and jank the main thread parsing it - over the cap, degrade (stay live) AND drop the html so the
|
|
227
|
-
// giant string is not retained in byNode (keeping it would defeat the memory bound).
|
|
228
|
-
const oversized = result.html.length > MAX_LEAN_BYTES
|
|
229
|
-
const degraded = oversized ? [...result.degraded, 'oversized'] : result.degraded
|
|
230
|
-
const theme = doc.documentElement.dataset.theme || meta.theme // theme AT capture, not a stale closure
|
|
231
|
-
byNode.set(nodeKey, { key: keyOf(meta), html: oversized ? '' : result.html, scrollMap: oversized ? [] : result.scrollMap, degraded, theme, gen: myGen, live, meta })
|
|
232
|
-
install(nodeKey)
|
|
233
|
-
}
|