@marver-design/marver 0.17.0 → 0.18.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 +68 -0
- package/README.md +1 -0
- package/dist/bake-jr38C_pX.mjs +747 -0
- package/dist/{build-D_g53Bp2.mjs → build-ER_7T6Cw.mjs} +179 -10
- package/dist/cli.mjs +19 -11
- package/dist/{comments-oYcZ3cE-.mjs → comments-ClVgfQib.mjs} +1 -1
- package/dist/{daemon-Bbh_jmui.mjs → daemon-CKhg0zuT.mjs} +1 -1
- package/dist/{dev-LnIISva5.mjs → dev-BvLbY98O.mjs} +204 -10
- package/dist/{init-B7YhcN2o.mjs → init-BWbHqVng.mjs} +2 -2
- package/dist/{manifest-CaslQIAO.mjs → manifest-DAnEL8_a.mjs} +1 -1
- package/dist/{marver-id-gate-D6By7XHj.mjs → marver-id-gate-B_idGdHm.mjs} +1 -1
- package/dist/{plugin-D2msH1cj.mjs → plugin-Cai5C1WV.mjs} +107 -28
- package/dist/{poster-BEjUcQP3.mjs → poster-CIuz_PwH.mjs} +1 -1
- package/dist/publish-bakes-D0LhbQQ3.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-iicees2e.mjs} +14 -7
- package/dist/{work-lzC-lPY0.mjs → work-0YopuMt9.mjs} +1 -1
- package/docs/publish.md +27 -5
- package/package.json +1 -1
- package/src/client/frame-host/bridge.js +4 -9
- package/src/client/frame-host/main.tsx +20 -6
- package/src/client/shell/App.tsx +7 -0
- package/src/client/shell/Comments.tsx +2 -2
- package/src/client/shell/canvas/Canvas.tsx +2 -2
- package/src/client/shell/canvas/FrameNode.tsx +82 -89
- package/src/client/shell/canvas/admission.ts +70 -0
- package/src/client/shell/canvas/sleep.ts +194 -0
- package/src/client/shell/store.ts +14 -0
- package/src/client/shell/styles.css +9 -25
- package/src/shared/sleep-rule.ts +41 -0
- package/templates/instructions/craft.md +9 -0
- package/templates/instructions/publish.md +62 -9
- package/src/client/frame-host/serialize.ts +0 -195
- package/src/client/shell/canvas/snapshots.ts +0 -233
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sleep - a frame at rest, in place (spec 16).
|
|
3
|
+
*
|
|
4
|
+
* A resting frame is its own LIVE document. Nothing is copied, pictured or swapped, so the moment
|
|
5
|
+
* the human interacts, comments or lasers, they are already looking at the thing itself: no line
|
|
6
|
+
* break, no spacing, no pixel can move. Sleep changes PAINT only:
|
|
7
|
+
*
|
|
8
|
+
* - CSS animations pause (`animation-play-state: paused`);
|
|
9
|
+
* - every `backdrop-filter` element - the one effect that reads back what is behind it on every
|
|
10
|
+
* composited frame, and the reason a glass design checkerboards at scale - gets the compositor's
|
|
11
|
+
* own filtered backdrop as a static texture under its own background layers, computed and
|
|
12
|
+
* certified by the dev server in headless Chrome (src/server/bake.ts); the rule itself is
|
|
13
|
+
* src/shared/sleep-rule.ts, the same one the compiler certified.
|
|
14
|
+
*
|
|
15
|
+
* A frame with no such element - markdown, images, slides, lo-fi - never talks to the server:
|
|
16
|
+
* its sleep is the animation pause. Frames are asked in one batch per tick; the server answers from
|
|
17
|
+
* its disk cache (keyed by frame, theme, size and source generation) or compiles. Textures are
|
|
18
|
+
* decoded BEFORE the override is installed, so sleep is one paint. Without textures - no compiler
|
|
19
|
+
* (a published canvas), a compile that failed, a texture that does not decode - the frame sleeps
|
|
20
|
+
* with the pause alone and its glass stays live: never an effect layer without its texture.
|
|
21
|
+
*
|
|
22
|
+
* Safety: the override is all or nothing - every target's selector must resolve to an element whose
|
|
23
|
+
* border box is the one the server measured (half a pixel) and whose filter is still the one baked, or
|
|
24
|
+
* the frame stays live. Wake restores the live effects under `transition: none` (an authored
|
|
25
|
+
* transition on backdrop-filter, filter or background must not animate out of the sleep, nor into
|
|
26
|
+
* it), then removes the <style> and the attributes.
|
|
27
|
+
*/
|
|
28
|
+
import { ROUTE } from '../../const.ts'
|
|
29
|
+
import { BAKES, PUBLISHED } from '../store.ts'
|
|
30
|
+
import { readOwn, sleepRule } from '../../../shared/sleep-rule.ts'
|
|
31
|
+
|
|
32
|
+
export interface SleepKey { frame: string; theme: string; w: number; h: number }
|
|
33
|
+
interface Target { sel: string; rect: { x: number; y: number; w: number; h: number }; filter: string; level: number; texture: string; verified: boolean }
|
|
34
|
+
type Answer = { ok: true; targets: Target[] } | { ok: false; error: string }
|
|
35
|
+
|
|
36
|
+
const STYLE_ID = 'mv-sleep'
|
|
37
|
+
/** How far (CSS px) any EDGE of an element's border box may sit from the one the compiler measured.
|
|
38
|
+
* Headless and headed Chrome shape text a few hundredths of a pixel apart (a 784 px pill measures
|
|
39
|
+
* 784.09 in a window, 784.125 in the compiler), which is invisible under a texture stretched to
|
|
40
|
+
* the box; a different wrap, size or place is a whole line or more and still refuses the frame. */
|
|
41
|
+
const TOL = 0.5
|
|
42
|
+
/** `?awake=1` keeps every frame live - the diagnostic switch the identity probes compare against. */
|
|
43
|
+
const AWAKE = new URLSearchParams(location.search).get('awake') === '1'
|
|
44
|
+
const PAUSE = `*,*::before,*::after{animation-play-state:paused!important}`
|
|
45
|
+
const csrf = () => document.cookie.match(/(?:^|; )mv_c=([^;]+)/)?.[1] ?? ''
|
|
46
|
+
const keyOf = (k: SleepKey) => `${k.frame}|${k.theme}|${Math.round(k.w)}|${Math.round(k.h)}`
|
|
47
|
+
|
|
48
|
+
/** What a node's DOCUMENT is asleep under, published only once its override is INSTALLED. */
|
|
49
|
+
const asleep = new Map<string, { key: string; doc: Document }>()
|
|
50
|
+
/** The node's current request, so a stale answer (a newer sleep, a wake in between) is dropped. */
|
|
51
|
+
const pending = new Map<string, number>()
|
|
52
|
+
let seq = 0
|
|
53
|
+
/** Nodes with one retry of a failed compile in flight. */
|
|
54
|
+
const retried = new Set<string>()
|
|
55
|
+
const RETRY_MS = 4000
|
|
56
|
+
|
|
57
|
+
/** Does this document have anything to compile? Cheap: one computed style per element. */
|
|
58
|
+
export function hasEffects(doc: Document): boolean {
|
|
59
|
+
for (const el of doc.querySelectorAll('*')) {
|
|
60
|
+
const cs = doc.defaultView!.getComputedStyle(el)
|
|
61
|
+
const bf = cs.backdropFilter || (cs as unknown as { webkitBackdropFilter?: string }).webkitBackdropFilter
|
|
62
|
+
if (bf && bf !== 'none') return true
|
|
63
|
+
}
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A published canvas has no compiler: its textures were compiled at build time against the very
|
|
68
|
+
* document it serves, and ship as one static index (publish-bakes.ts), read once. */
|
|
69
|
+
let published: Promise<Record<string, Answer>> | undefined
|
|
70
|
+
const staticAnswers = () => (published ??= (BAKES ? fetch(`${ROUTE}/bakes/${BAKES}/index.json`).then((r) => (r.ok ? r.json() : {})) : Promise.resolve({}))
|
|
71
|
+
.then((j: { gen?: number; answers?: Record<string, Answer> }) => (j.gen === BAKES && j.answers) || {}).catch((): Record<string, Answer> => ({})))
|
|
72
|
+
|
|
73
|
+
// ---- one batch per tick: every frame that decides to sleep in the same moment shares a browser
|
|
74
|
+
const queue: { key: SleepKey; resolve: (a: Answer) => void }[] = []
|
|
75
|
+
let flush: ReturnType<typeof setTimeout> | undefined
|
|
76
|
+
function ask(key: SleepKey): Promise<Answer> {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
queue.push({ key, resolve })
|
|
79
|
+
clearTimeout(flush)
|
|
80
|
+
flush = setTimeout(async () => {
|
|
81
|
+
const batch = queue.splice(0)
|
|
82
|
+
// one ask per key; every waiter for that key gets the same answer
|
|
83
|
+
const byKey = new Map<string, { key: SleepKey; waiters: ((a: Answer) => void)[] }>()
|
|
84
|
+
for (const q of batch) { const kk = keyOf(q.key); const e = byKey.get(kk); if (e) e.waiters.push(q.resolve); else byKey.set(kk, { key: q.key, waiters: [q.resolve] }) }
|
|
85
|
+
const asks = [...byKey.values()].map((e) => ({ frame: e.key.frame, theme: e.key.theme, w: Math.round(e.key.w), h: Math.round(e.key.h) }))
|
|
86
|
+
let data: { answers?: (Answer & SleepKey)[] } | null = null
|
|
87
|
+
const index = PUBLISHED ? await staticAnswers() : null
|
|
88
|
+
if (!index) {
|
|
89
|
+
try {
|
|
90
|
+
const r = await fetch(`${ROUTE}/api/bakes`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-mv-c': csrf() }, body: JSON.stringify({ asks }) })
|
|
91
|
+
data = r.ok ? await r.json() : null
|
|
92
|
+
} catch { data = null }
|
|
93
|
+
}
|
|
94
|
+
for (const e of byKey.values()) {
|
|
95
|
+
const a = index ? index[keyOf(e.key)] : data?.answers?.find((x) => keyOf(x) === keyOf(e.key))
|
|
96
|
+
const answer: Answer = a ? (a.ok ? { ok: true, targets: a.targets } : { ok: false, error: a.error }) : { ok: false, error: 'no answer' }
|
|
97
|
+
for (const w of e.waiters) w(answer)
|
|
98
|
+
}
|
|
99
|
+
}, 40)
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Put a node's live document to sleep under `key`. Resolves once the override is installed (or
|
|
104
|
+
* the document was found to have no effects, or the compile failed and the frame stays live).
|
|
105
|
+
* A wake or a newer sleep in the meantime supersedes it. */
|
|
106
|
+
export async function sleep(nodeKey: string, iframe: HTMLIFrameElement, key: SleepKey): Promise<'asleep' | 'live'> {
|
|
107
|
+
if (AWAKE) return 'live'
|
|
108
|
+
const k = keyOf(key)
|
|
109
|
+
const doc = iframe.contentDocument
|
|
110
|
+
if (!doc?.body) return 'live'
|
|
111
|
+
const have = asleep.get(nodeKey)
|
|
112
|
+
if (have && have.key === k && have.doc === doc && doc.getElementById(STYLE_ID)) return 'asleep'
|
|
113
|
+
const mine = ++seq
|
|
114
|
+
pending.set(nodeKey, mine)
|
|
115
|
+
const current = () => pending.get(nodeKey) === mine && iframe.contentDocument === doc
|
|
116
|
+
// the compiler measured after the frame's fonts; so does the gate below
|
|
117
|
+
if (doc.fonts) { await doc.fonts.ready; if (!current()) return 'live' }
|
|
118
|
+
if (!hasEffects(doc)) {
|
|
119
|
+
// nothing to compile: the pause alone is this frame's sleep
|
|
120
|
+
if (!current()) return 'live'
|
|
121
|
+
install(doc, [])
|
|
122
|
+
asleep.set(nodeKey, { key: k, doc })
|
|
123
|
+
return 'asleep'
|
|
124
|
+
}
|
|
125
|
+
const answer = await ask(key)
|
|
126
|
+
if (!current()) return 'live'
|
|
127
|
+
// no compiler (a published canvas, a compile that failed): the pause alone, the glass live -
|
|
128
|
+
// never an effect layer without its texture
|
|
129
|
+
let targets = answer.ok ? answer.targets.filter((t) => t.verified && t.texture) : []
|
|
130
|
+
// decode every texture BEFORE the paint that installs them - one commit, no pop-in; one that
|
|
131
|
+
// fails to decode (pruned, missing, corrupt) leaves the glass live
|
|
132
|
+
const decoded = await Promise.all(targets.map((t) => { const im = new Image(); im.src = t.texture; return im.decode().then(() => true, () => false) }))
|
|
133
|
+
if (!current()) return 'live'
|
|
134
|
+
if (decoded.some((ok) => !ok)) targets = []
|
|
135
|
+
// a published document names the generation it was built with: textures dress that build only
|
|
136
|
+
if (PUBLISHED && targets.length && doc.querySelector('meta[name="mv-bakes"]')?.getAttribute('content') !== String(BAKES)) targets = []
|
|
137
|
+
if (!install(doc, targets)) return 'live'
|
|
138
|
+
// a certified sleep is remembered; the pause-only fallback is not, so the next lifecycle event
|
|
139
|
+
// asks again (a compile the source outran, a server hiccup), and one retry is scheduled now
|
|
140
|
+
if (targets.length) asleep.set(nodeKey, { key: k, doc })
|
|
141
|
+
else if (!PUBLISHED && !retried.has(nodeKey)) { retried.add(nodeKey); setTimeout(() => { retried.delete(nodeKey); if (current()) void sleep(nodeKey, iframe, key) }, RETRY_MS) } // a static miss is final
|
|
142
|
+
return 'asleep'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Wake: paint goes back to the live effects. The document is otherwise untouched. */
|
|
146
|
+
export function wake(nodeKey: string, iframe: HTMLIFrameElement | null): void {
|
|
147
|
+
pending.delete(nodeKey)
|
|
148
|
+
asleep.delete(nodeKey)
|
|
149
|
+
const doc = iframe?.contentDocument
|
|
150
|
+
const st = doc?.getElementById(STYLE_ID)
|
|
151
|
+
if (!doc || !st) return
|
|
152
|
+
const els = [...doc.querySelectorAll<HTMLElement>('[data-mv-sleep]')]
|
|
153
|
+
still(doc, els, () => st.remove())
|
|
154
|
+
for (const el of els) el.removeAttribute('data-mv-sleep')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Run `change` with no transition able to start on `els`: an INLINE important
|
|
158
|
+
* `transition-property: none` (it outranks an authored important transition), the change, one
|
|
159
|
+
* forced style recalc - a style change event with nothing to animate - then the authored longhand
|
|
160
|
+
* back. Only the longhand is touched: an inline `transition-duration` alone does not serialize
|
|
161
|
+
* through the shorthand, and a shorthand round trip would have deleted it. */
|
|
162
|
+
function still(doc: Document, els: HTMLElement[], change: () => void): void {
|
|
163
|
+
const authored = els.map((el) => [el.style.getPropertyValue('transition-property'), el.style.getPropertyPriority('transition-property')] as const)
|
|
164
|
+
for (const el of els) el.style.setProperty('transition-property', 'none', 'important')
|
|
165
|
+
change()
|
|
166
|
+
void doc.documentElement.offsetWidth
|
|
167
|
+
els.forEach((el, i) => { const [v, p] = authored[i]; if (v) el.style.setProperty('transition-property', v, p); else el.style.removeProperty('transition-property') })
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Install the override for the targets. All or nothing: a target whose element is not exactly the
|
|
171
|
+
* one the server measured leaves the whole document live. Returns whether it was installed. */
|
|
172
|
+
function install(doc: Document, targets: Target[]): boolean {
|
|
173
|
+
doc.querySelectorAll('[data-mv-sleep]').forEach((el) => el.removeAttribute('data-mv-sleep'))
|
|
174
|
+
const rules: string[] = [PAUSE]
|
|
175
|
+
const els: HTMLElement[] = []
|
|
176
|
+
for (const [i, t] of targets.entries()) {
|
|
177
|
+
let el: HTMLElement | null = null
|
|
178
|
+
try { el = doc.querySelector<HTMLElement>(t.sel) } catch { /* a selector from another document shape */ }
|
|
179
|
+
if (!el) return false
|
|
180
|
+
const r = el.getBoundingClientRect()
|
|
181
|
+
if (Math.abs(r.x - t.rect.x) > TOL || Math.abs(r.y - t.rect.y) > TOL || Math.abs(r.right - t.rect.x - t.rect.w) > TOL || Math.abs(r.bottom - t.rect.y - t.rect.h) > TOL) return false
|
|
182
|
+
const cs = doc.defaultView!.getComputedStyle(el)
|
|
183
|
+
if ((cs.backdropFilter || (cs as unknown as { webkitBackdropFilter?: string }).webkitBackdropFilter || 'none') !== t.filter) return false
|
|
184
|
+
rules.push(sleepRule(`[data-mv-sleep="${i}"]`, readOwn(cs), t.texture))
|
|
185
|
+
els.push(el)
|
|
186
|
+
}
|
|
187
|
+
let st = doc.getElementById(STYLE_ID)
|
|
188
|
+
if (!st) { st = doc.createElement('style'); st.id = STYLE_ID; (doc.head ?? doc.documentElement).appendChild(st) }
|
|
189
|
+
const style = st
|
|
190
|
+
// one paint, and no authored transition (`transition: all` is common on a pill) may animate the
|
|
191
|
+
// effect out or the texture in
|
|
192
|
+
still(doc, els, () => { els.forEach((el, i) => el.setAttribute('data-mv-sleep', String(i))); style.textContent = rules.join('\n') })
|
|
193
|
+
return true
|
|
194
|
+
}
|
|
@@ -18,8 +18,13 @@ const DATA: {
|
|
|
18
18
|
titles?: Record<string, string>
|
|
19
19
|
/** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
|
|
20
20
|
policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
|
|
21
|
+
/** the generation of the glass textures this build shipped (publish-bakes.ts); absent = none */
|
|
22
|
+
bakes?: number
|
|
21
23
|
} | null = shData
|
|
22
24
|
|
|
25
|
+
/** The published textures' generation, or 0: the static index this build shipped is at /__mv/bakes/<gen>/index.json. */
|
|
26
|
+
export const BAKES = DATA?.bakes ?? 0
|
|
27
|
+
|
|
23
28
|
/** Every published board is locked to a stage mode - the canvas shell is never
|
|
24
29
|
* offered on this bundle (01-sharing §5.1's all-boards rule). */
|
|
25
30
|
export const LOCKED_SHELL = DATA?.policy?.lockedShell === true
|
|
@@ -54,6 +59,11 @@ export interface Node {
|
|
|
54
59
|
/** Renavigation nonce: bumped when the shell wants this iframe on a FRESH URL
|
|
55
60
|
* (errored frame whose file is back in the manifest). Never persisted. */
|
|
56
61
|
nav?: number
|
|
62
|
+
/** Source revision: bumped when the frame reports an HMR update (its modules changed without a
|
|
63
|
+
* navigation). Never persisted. */
|
|
64
|
+
rev?: number
|
|
65
|
+
/** The theme the frame reports as APPLIED (sh:theme-applied) - sleep waits for it to match. */
|
|
66
|
+
themeOn?: string
|
|
57
67
|
/** Explicit per-frame override, set by scoped theme actions; cleared by a global set.
|
|
58
68
|
* The only theme value that persists into the board file. */
|
|
59
69
|
themeUser?: string
|
|
@@ -302,6 +312,8 @@ interface State {
|
|
|
302
312
|
resizeNode(key: string, w: number, h: number): void
|
|
303
313
|
measureNode(key: string, frameId: string, ownWidth: number, measuredWidth: number, height: number): void
|
|
304
314
|
setStatus(key: string, status: Node['status'], error?: string): void
|
|
315
|
+
bumpRev(key: string): void
|
|
316
|
+
setThemeOn(key: string, theme: string): void
|
|
305
317
|
reloadFrame(key: string, automatic?: boolean): void
|
|
306
318
|
removeNode(key: string): void
|
|
307
319
|
select(key: string | null, additive?: boolean): void
|
|
@@ -985,6 +997,8 @@ export const useStore = create<State>((set, get) => {
|
|
|
985
997
|
if (name) get().runTidy() // restore must NOT tidy - it would destroy positions
|
|
986
998
|
else scheduleSave()
|
|
987
999
|
},
|
|
1000
|
+
bumpRev(key) { set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, rev: (n.rev ?? 0) + 1 } : n)) })) },
|
|
1001
|
+
setThemeOn(key, theme) { set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, themeOn: theme } : n)) })) },
|
|
988
1002
|
setStatus(key, status, error) {
|
|
989
1003
|
// any real ready/error resets the one-shot retry allowance, so a later manual reload or a
|
|
990
1004
|
// fresh manifest earns its own auto-retry again
|
|
@@ -150,21 +150,17 @@ button svg { pointer-events: none } /* event targets stay on the button - pan
|
|
|
150
150
|
opacity: var(--grid-alpha, 1) }
|
|
151
151
|
.sh-content { will-change: auto }
|
|
152
152
|
#sh-world { position: relative; width: 1px; height: 1px }
|
|
153
|
-
#sh-world.sh-gesturing .sh-live { pointer-events: none } /* law G-4
|
|
154
|
-
|
|
153
|
+
#sh-world.sh-gesturing .sh-live { pointer-events: none } /* law G-4 */
|
|
154
|
+
/* no gesture promotion (a will-change layer on the world was measured harmful: 18-29 dropped frames
|
|
155
|
+
per pan on a 32-frame board against 0-3 without, spec 16) */
|
|
155
156
|
/* preset transitions: armed by animateLayout() around device/tidy mutations - nodes ease
|
|
156
157
|
to their new frame on the same 320ms ease-out the camera fit animates with */
|
|
157
158
|
#sh-world.sh-preset .sh-node { transition: transform .32s cubic-bezier(.25,.46,.45,.94),
|
|
158
159
|
width .32s cubic-bezier(.25,.46,.45,.94), height .32s cubic-bezier(.25,.46,.45,.94) }
|
|
159
|
-
#sh-world.sh-preset .sh-node-body,
|
|
160
|
-
#sh-world.sh-preset .sh-node .sh-live { transition: width .32s cubic-bezier(.25,.46,.45,.94),
|
|
160
|
+
#sh-world.sh-preset .sh-node-body { transition: width .32s cubic-bezier(.25,.46,.45,.94),
|
|
161
161
|
height .32s cubic-bezier(.25,.46,.45,.94) }
|
|
162
|
-
/*
|
|
163
|
-
|
|
164
|
-
body - reflows smoothly every frame (real CSS, the device-sweep fix). A frame WITHOUT a usable
|
|
165
|
-
cover keeps the live width animation above. */
|
|
166
|
-
body:not(.sh-laser):not(.sh-commenting) #sh-world.sh-preset .sh-node:has(.sh-lean[data-ready]):not(.interact) .sh-live { transition: none }
|
|
167
|
-
|
|
162
|
+
/* the live iframe takes its new size at once (ONE reflow per sweep) while the body's box eases to it;
|
|
163
|
+
the frame sleeps again under the new size once it settles */
|
|
168
164
|
/* cursor conventions (Figma): arrow everywhere; grab only while space is held */
|
|
169
165
|
body.sh-space .sh-canvas { cursor: grab }
|
|
170
166
|
body.sh-space.sh-panning .sh-canvas { cursor: grabbing }
|
|
@@ -199,8 +195,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
199
195
|
font-size: 11px; color: var(--head-dim);
|
|
200
196
|
border-bottom: 1px solid var(--node-brd); user-select: none;
|
|
201
197
|
border-radius: var(--r-node) var(--r-node) 0 0;
|
|
202
|
-
background-color: var(--head-bg); background-image: var(--head-sheen)
|
|
203
|
-
backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur) }
|
|
198
|
+
background-color: var(--head-bg); background-image: var(--head-sheen) } /* no backdrop-filter: one render surface less per frame */
|
|
204
199
|
.sh-node-head .id { font-weight: 600; color: var(--head-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
205
200
|
/* frame icons: every sidebar row leads with one */
|
|
206
201
|
.sh-node-head .iicon { flex: none; color: var(--head-dim) }
|
|
@@ -210,20 +205,9 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
210
205
|
.sh-panel .sub.vrow { padding-left: 51px }
|
|
211
206
|
.sh-node-head .dim { margin-left: auto; color: var(--head-dim); flex: none; font-variant-numeric: tabular-nums }
|
|
212
207
|
.sh-node-body { position: relative; background: var(--node-bg); border-radius: 0 0 var(--r-node) var(--r-node); overflow: hidden }
|
|
208
|
+
/* no content-visibility on the body: it flips frames between skipped and rendered as they cross the
|
|
209
|
+
viewport during a zoom, and the raster storm that follows left the page unfinished 2.5 s later */
|
|
213
210
|
.sh-node iframe { border: 0; display: block }
|
|
214
|
-
/* LEAN-PRIMARY: the lean DOM-snapshot <iframe> (static html, 0 JS) is what you SEE for a
|
|
215
|
-
passive frame - at rest AND during pan/zoom/resize. There is NO per-gesture swap between the lean
|
|
216
|
-
and the live iframe (the swap shifted text ~1-2px = "jiggle", and flashed mermaid/theme colors,
|
|
217
|
-
because two documents never render pixel-identically). The live app (.sh-live) sits underneath and
|
|
218
|
-
shows ONLY when: the frame is interacted (.interact), laser/comment mode is on, or the lean is not
|
|
219
|
-
yet built (no data-ready = live-fallback). Hard cut, never a crossfade - two ~1px-offset text docs
|
|
220
|
-
would ghost into double text. */
|
|
221
|
-
.sh-lean { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; z-index: 1;
|
|
222
|
-
opacity: 0; pointer-events: none; transition: none; background: var(--node-bg) }
|
|
223
|
-
.sh-lean[data-ready] { opacity: 1 }
|
|
224
|
-
.sh-node.interact .sh-lean,
|
|
225
|
-
body.sh-laser .sh-lean,
|
|
226
|
-
body.sh-commenting .sh-lean { opacity: 0 }
|
|
227
211
|
.sh-overlay { position: absolute; inset: 0; z-index: 2 } /* above the lean (z1) so drag-by-body works; inherits the app arrow cursor */
|
|
228
212
|
.sh-node.interact { border-color: var(--interact);
|
|
229
213
|
outline: calc(2px * var(--sh-inv, 1)) solid var(--interact);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paint override a sleeping backdrop-filter element wears (spec 16) - ONE definition, used by
|
|
3
|
+
* the compiler inside the frame it certifies (src/server/bake.ts embeds these functions' source in
|
|
4
|
+
* a page script) and by the shell inside the frame it puts to sleep (src/client/shell/canvas/sleep.ts).
|
|
5
|
+
* Plain ES2020 with no imports or closures, so `Function.prototype.toString` carries it whole.
|
|
6
|
+
*
|
|
7
|
+
* The composition, bottom to top: the certified texture (the element's filtered backdrop, clipped to
|
|
8
|
+
* its border box the way the effect is), the element's own colour under the clip the author gave it,
|
|
9
|
+
* the element's own images, then its content.
|
|
10
|
+
*
|
|
11
|
+
* `backdrop-filter: none`: the element must stop being an effect layer. An effect layer is its own
|
|
12
|
+
* compositor layer with its own tiles, and thirty of them per frame are what a headed Chrome cannot
|
|
13
|
+
* re-raster fast enough under a pan or a zoom - the presented frames show the frame body missing
|
|
14
|
+
* while the pills draw (research/hifi/glitch.ts). `blur(0px)` measured better only in headless
|
|
15
|
+
* Chrome, whose forced screenshots never show a missing tile. A static `filter` keeps what the
|
|
16
|
+
* backdrop-filter gave layout - the containing block of fixed descendants (a hidden checkbox input
|
|
17
|
+
* is one) and the stacking context - without a direct compositing reason (a video, a canvas or a
|
|
18
|
+
* will-change inside still promotes that one element): `opacity(1)` when the author set none, the
|
|
19
|
+
* author's own filter otherwise.
|
|
20
|
+
*/
|
|
21
|
+
export interface OwnBackground { img: string; color: string; size: string; pos: string; rep: string; org: string; clip: string; filter: string }
|
|
22
|
+
|
|
23
|
+
/** The element's own background and filter, read once BEFORE any override touches it. */
|
|
24
|
+
export function readOwn(cs: CSSStyleDeclaration): OwnBackground {
|
|
25
|
+
return { img: cs.backgroundImage, color: cs.backgroundColor, size: cs.backgroundSize, pos: cs.backgroundPosition, rep: cs.backgroundRepeat, org: cs.backgroundOrigin, clip: cs.backgroundClip, filter: cs.filter }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sleepRule(selector: string, o: OwnBackground, texture: string): string {
|
|
29
|
+
const img = o.img === 'none' ? '' : o.img + ','
|
|
30
|
+
// the authored colour paints under the LAST layer's clip (CSS Backgrounds 3)
|
|
31
|
+
const colorClip = o.clip.split(',').pop()!.trim() || 'border-box'
|
|
32
|
+
return selector + '{backdrop-filter:none!important;-webkit-backdrop-filter:none!important;' +
|
|
33
|
+
(o.filter === 'none' ? 'filter:opacity(1)!important;' : '') +
|
|
34
|
+
'background-color:transparent!important;' +
|
|
35
|
+
'background-image:' + img + 'linear-gradient(' + o.color + ',' + o.color + '),url("' + texture + '")!important;' +
|
|
36
|
+
'background-size:' + (img ? o.size + ',' : '') + 'auto,100% 100%!important;' +
|
|
37
|
+
'background-position:' + (img ? o.pos + ',' : '') + '0 0,0 0!important;' +
|
|
38
|
+
'background-repeat:' + (img ? o.rep + ',' : '') + 'no-repeat,no-repeat!important;' +
|
|
39
|
+
'background-origin:' + (img ? o.org + ',' : '') + 'border-box,border-box!important;' +
|
|
40
|
+
'background-clip:' + (img ? o.clip + ',' : '') + colorClip + ',border-box!important}'
|
|
41
|
+
}
|
|
@@ -158,3 +158,12 @@ app, and the human attributes the fault to your frame, not to a library.
|
|
|
158
158
|
WILL sweep devices with keys 1-5.
|
|
159
159
|
- Live fully inside the settled visual world. A direction executed at full commitment
|
|
160
160
|
can be judged and improved; a hedged one can only be redone.
|
|
161
|
+
|
|
162
|
+
## Glass on the canvas
|
|
163
|
+
|
|
164
|
+
While a frame rests on the canvas, marver compiles every `backdrop-filter` element into a still
|
|
165
|
+
texture of its own filtered backdrop, certified pixel by pixel, so a board of hi-fi glass pans
|
|
166
|
+
like a board of statics; the frame wakes the moment it is interacted with. Three things cannot
|
|
167
|
+
be compiled and stay live: glass inside glass, a glass element with a `mix-blend-mode`, and a
|
|
168
|
+
frame whose paint at rest is not a function of its URL (random data at boot, a clock, a
|
|
169
|
+
count-up). Use them on the one frame that needs them, not as a house style.
|
|
@@ -77,9 +77,39 @@ The host does two things, and the deploy config names both. **`design/.dist` is
|
|
|
77
77
|
gitignored - it is built ON THE HOST at deploy time, never committed.**
|
|
78
78
|
|
|
79
79
|
- **build command**: `<install> && npx marver build` (respects `publish.json`,
|
|
80
|
-
seeds comment logs into the bundle)
|
|
80
|
+
seeds comment logs into the bundle, compiles the glass textures - see below)
|
|
81
81
|
- **start command**: `npx marver serve` (reads `PORT` + the env vars above)
|
|
82
82
|
- a **persistent volume** mounted at some path, named by `MARVER_DATA_DIR`
|
|
83
|
+
- **Chrome or Chromium on the build machine**, for the glass textures. Without one
|
|
84
|
+
the build still succeeds and says `textures: none - no Chrome on this machine`;
|
|
85
|
+
the canvas ships and every feature works, but hi-fi frames with `backdrop-filter`
|
|
86
|
+
rest with their glass live and pan the way they did before 0.18.0.
|
|
87
|
+
|
|
88
|
+
## Glass textures at build (0.18.0)
|
|
89
|
+
|
|
90
|
+
A hi-fi frame at rest sleeps under certified textures of its blurred backdrops
|
|
91
|
+
(the dev canvas compiles them on the fly). A published canvas has no compiler,
|
|
92
|
+
so `marver build` compiles them once, against the exact site it just built -
|
|
93
|
+
every published node, at its size on its board, in every theme - and ships them
|
|
94
|
+
under `design/.dist/__mv/bakes/`. The build log tells you what happened:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
textures: 8 frame views asleep under certified glass, 0 with live glass, 34 without effects (42 asked: every published node x 2 themes; 806 KB, 35 s)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- `asleep under certified glass` is the number you want to see for hi-fi boards.
|
|
101
|
+
`with live glass` counts views the compiler refused (glass inside glass, blend
|
|
102
|
+
modes, paint that is not a function of the URL): they show exactly as before.
|
|
103
|
+
- Budget: about 1-3 s per hi-fi frame and theme, under a second per frame without
|
|
104
|
+
glass; a 4-frame hi-fi board plus a 128-frame lo-fi board took 35 s.
|
|
105
|
+
- **Bundle your fonts** (`@fontsource-*`, or files under `public/`). The textures
|
|
106
|
+
are certified as the build machine renders the frame; a font that only exists on
|
|
107
|
+
the designer's laptop renders differently in the build container and the
|
|
108
|
+
visitor's browser then refuses those textures (the frame rests live, nothing
|
|
109
|
+
breaks).
|
|
110
|
+
- Skip it on purpose with `npx marver build --no-textures`, or `MARVER_NO_TEXTURES=1`
|
|
111
|
+
in a CI that has no Chrome and should stay quiet about it.
|
|
112
|
+
- `MARVER_CHROME=/path/to/chrome` names a browser marver does not find on its own.
|
|
83
113
|
|
|
84
114
|
The `@marver-design/marver` dependency must resolve from the registry (a local
|
|
85
115
|
`link:`/`file:` dep cannot ride to a remote host) - a normal registry version (`npm i -D @marver-design/marver@latest`) in
|
|
@@ -87,15 +117,36 @@ The `@marver-design/marver` dependency must resolve from the registry (a local
|
|
|
87
117
|
|
|
88
118
|
## Railway quickstart
|
|
89
119
|
|
|
90
|
-
Commit a `
|
|
120
|
+
Commit a `Dockerfile` at the upload root - Railway detects it, and it is the one
|
|
121
|
+
path that gives the build a browser for the glass textures:
|
|
122
|
+
|
|
123
|
+
```dockerfile
|
|
124
|
+
FROM node:22-slim
|
|
125
|
+
# Chromium for `marver build` (the glass textures); fonts-liberation so system-ui text
|
|
126
|
+
# has a face in the container. marver finds /usr/bin/chromium on its own and runs it
|
|
127
|
+
# with the container flags a root build needs.
|
|
128
|
+
RUN apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation \
|
|
129
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
130
|
+
WORKDIR /app
|
|
131
|
+
COPY package.json ./
|
|
132
|
+
RUN npm install
|
|
133
|
+
COPY . .
|
|
134
|
+
RUN npx marver build
|
|
135
|
+
ENV PORT=8080
|
|
136
|
+
CMD ["npx", "marver", "serve"]
|
|
137
|
+
```
|
|
91
138
|
|
|
92
|
-
```json
|
|
93
|
-
{
|
|
94
|
-
"$schema": "https://railway.com/railway.schema.json",
|
|
95
|
-
"build": { "builder": "NIXPACKS", "buildCommand": "pnpm install && npx marver build" },
|
|
96
|
-
"deploy": { "startCommand": "npx marver serve" }
|
|
97
|
-
}
|
|
98
139
|
```
|
|
140
|
+
# .dockerignore
|
|
141
|
+
node_modules
|
|
142
|
+
design/.dist
|
|
143
|
+
design/.local
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
(A `railway.json` with the NIXPACKS builder - `"buildCommand": "pnpm install && npx marver build"`,
|
|
147
|
+
`"startCommand": "npx marver serve"` - works too, but a Nixpacks image has no browser: the build
|
|
148
|
+
prints `textures: none` and ships the hi-fi frames with live glass. Add chromium to it, or use the
|
|
149
|
+
Dockerfile.)
|
|
99
150
|
|
|
100
151
|
Then, once per service:
|
|
101
152
|
|
|
@@ -117,7 +168,9 @@ railway logs # with a PASSWORD gate, the owner claim link p
|
|
|
117
168
|
|
|
118
169
|
Republishing is just `railway up` again: the server unions the seeded logs on
|
|
119
170
|
boot, so collected feedback is NEVER clobbered by a new build. Run ONE instance -
|
|
120
|
-
the event log is single-writer by design.
|
|
171
|
+
the event log is single-writer by design. A republish mints new glass textures;
|
|
172
|
+
a tab that was already open keeps asking for the old ones and rests its glass
|
|
173
|
+
live until it reloads - by design, so an old shell never dresses new frames.
|
|
121
174
|
|
|
122
175
|
## What the deployed gate offers (so you know what you're wiring)
|
|
123
176
|
|
|
@@ -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
|
-
}
|