@marver-design/marver 0.3.1 → 0.5.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 +151 -0
- package/README.md +2 -0
- package/dist/auth-B36fMCM3.mjs +245 -0
- package/dist/{build-Ckmyci3O.mjs → build-BrCl9hJS.mjs} +56 -14
- package/dist/cli.mjs +15 -6
- package/dist/collab-CXy8gqoz.mjs +297 -0
- package/dist/comments-Ba8mU600.mjs +90 -0
- package/dist/comments-odHzYdO3.mjs +179 -0
- package/dist/dev-DdeU-Jst.mjs +162 -0
- package/dist/{init-DolP_Ld4.mjs → init-DsCUmlCW.mjs} +2 -1
- package/dist/{manifest-DW-T52MM.mjs → manifest-C8FODq2S.mjs} +26 -1
- package/dist/{plugin-Crp4CAma.mjs → plugin-BtSGAm2h.mjs} +169 -19
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/serve-D_KBK7Oy.mjs +431 -0
- package/dist/sync-CkBk-tUk.mjs +248 -0
- package/package.json +3 -1
- package/src/client/content/diagram.tsx +46 -2
- package/src/client/content/index.tsx +6 -2
- package/src/client/content/md.ts +29 -0
- package/src/client/content/palette.ts +6 -0
- package/src/client/frame-host/bridge.js +269 -3
- package/src/client/frame-host/serialize.ts +195 -0
- package/src/client/shell/App.tsx +131 -17
- package/src/client/shell/Comments.tsx +464 -0
- package/src/client/shell/Play.tsx +24 -0
- package/src/client/shell/canvas/Canvas.tsx +66 -29
- package/src/client/shell/canvas/FrameNode.tsx +130 -10
- package/src/client/shell/canvas/frame-registry.ts +18 -0
- package/src/client/shell/canvas/snapshots.ts +233 -0
- package/src/client/shell/comments-store.ts +180 -0
- package/src/client/shell/cursor-arrow-dark.svg +1 -0
- package/src/client/shell/cursor-arrow.svg +1 -0
- package/src/client/shell/hash.ts +14 -3
- package/src/client/shell/icons.tsx +6 -0
- package/src/client/shell/labels.ts +10 -0
- package/src/client/shell/perf.ts +92 -0
- package/src/client/shell/store.ts +101 -6
- package/src/client/shell/styles.css +196 -7
- package/src/client/stage/main.tsx +2 -0
- package/src/shared/events.ts +103 -0
- package/templates/AGENTS-embedded.md +13 -1
- package/templates/AGENTS-studio.md +13 -1
- package/templates/instructions/boards.md +6 -3
- package/templates/instructions/configure.md +28 -0
- package/templates/instructions/iterate.md +38 -0
- package/templates/instructions/publish.md +129 -0
- package/templates/instructions/reference/color.md +22 -1
- package/templates/instructions/shape.md +23 -16
- package/dist/dev-C2oTKuXe.mjs +0 -97
- package/dist/serve-OtA9Nlow.mjs +0 -207
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components. The tool ships no AI - your coding agent is the designer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"dist",
|
|
13
13
|
"src/client",
|
|
14
|
+
"src/shared",
|
|
14
15
|
"templates",
|
|
15
16
|
"README.md",
|
|
16
17
|
"LICENSE",
|
|
@@ -32,6 +33,7 @@
|
|
|
32
33
|
"@tailwindcss/vite": "^4.0.0",
|
|
33
34
|
"@vitejs/plugin-react": "^6.0.0",
|
|
34
35
|
"cac": "^7.0.0",
|
|
36
|
+
"html-to-image": "^1.11.13",
|
|
35
37
|
"marked": "^16.0.0",
|
|
36
38
|
"mermaid": "^11.6.0",
|
|
37
39
|
"react-zoom-pan-pinch": "^3.6.1",
|
|
@@ -23,6 +23,50 @@ export function cleanSource(src: string): string {
|
|
|
23
23
|
.trim()
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** flowchart/graph is where classDef + markdown-string labels both apply; other diagram
|
|
27
|
+
* types (sequence, pie, ...) don't take the family/hierarchy sugar. */
|
|
28
|
+
function isFlowchart(src: string): boolean {
|
|
29
|
+
const first = src.split('\n').find((l) => l.trim())?.trim() ?? ''
|
|
30
|
+
return /^(flowchart|graph)\b/.test(first)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// D1: head/gloss auto-hierarchy. An agent writes a natural `Head :: gloss` label and marver
|
|
34
|
+
// renders the head BOLD on top with the gloss on a lighter, smaller line below - no backticks,
|
|
35
|
+
// no `**`, no `<br>` to hand-author. It expands the label into a mermaid markdown string (bold
|
|
36
|
+
// head, blank line -> a second <p> the THEME_CSS styles down). ` :: ` (spaced double colon) is
|
|
37
|
+
// the token: rare in prose, and distinct from the `:::family` class tag (no spaces, three colons).
|
|
38
|
+
const GLOSS = ' :: '
|
|
39
|
+
export function withLabelHierarchy(src: string): string {
|
|
40
|
+
if (!isFlowchart(src)) return src
|
|
41
|
+
return src.replace(/"([^"\n]*?)"/g, (m, body: string) => {
|
|
42
|
+
const at = body.indexOf(GLOSS)
|
|
43
|
+
if (at < 0 || body.startsWith('`')) return m // no token, or already a markdown string
|
|
44
|
+
const head = body.slice(0, at).trim()
|
|
45
|
+
const gloss = body.slice(at + GLOSS.length).trim()
|
|
46
|
+
if (!head || !gloss) return m
|
|
47
|
+
const bhead = /[*`]/.test(head) ? head : `**${head}**` // don't double-bold a hand-marked head
|
|
48
|
+
return `"\`${bhead}\n\n${gloss}\`"` // "`**Head**⏎⏎gloss`"
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// D2: named family fills - the SAME colour language as the Md `:blue[...]` families, so an agent
|
|
53
|
+
// tags a node `HQ:::blue` with zero classDef boilerplate and prose + diagram read as one palette.
|
|
54
|
+
const DIAGRAM_FAMILIES: Record<string, string> = {
|
|
55
|
+
blue: 'fill:#0088FF,stroke:#0066CC,color:#fff',
|
|
56
|
+
orange: 'fill:#F5820A,stroke:#C96A08,color:#fff',
|
|
57
|
+
purple: 'fill:#B32BC8,stroke:#8F22A0,color:#fff',
|
|
58
|
+
green: 'fill:#1FA34A,stroke:#178139,color:#fff',
|
|
59
|
+
red: 'fill:#E5342B,stroke:#B71C13,color:#fff',
|
|
60
|
+
gray: 'fill:#E5E5EA,stroke:#C7C7CC,color:#1C1C1E',
|
|
61
|
+
}
|
|
62
|
+
/** Append the family classDefs to flowchart/graph diagrams (classDef is a flowchart feature).
|
|
63
|
+
* Unused defs are harmless; `X:::blue` resolves them regardless of position. */
|
|
64
|
+
export function withFamilies(src: string): string {
|
|
65
|
+
if (!isFlowchart(src)) return src
|
|
66
|
+
const defs = Object.entries(DIAGRAM_FAMILIES).map(([n, s]) => `classDef ${n} ${s}`).join('\n')
|
|
67
|
+
return `${src}\n${defs}`
|
|
68
|
+
}
|
|
69
|
+
|
|
26
70
|
/** Remove external URL references from rendered SVG (images, links, href attrs). */
|
|
27
71
|
export function sanitizeSvg(svg: string): string {
|
|
28
72
|
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
|
|
@@ -38,9 +82,9 @@ export function sanitizeSvg(svg: string): string {
|
|
|
38
82
|
}
|
|
39
83
|
|
|
40
84
|
export function Diagram({ title, children }: { title?: string; children?: ReactNode }) {
|
|
41
|
-
const src = cleanSource(
|
|
85
|
+
const src = withFamilies(withLabelHierarchy(cleanSource(
|
|
42
86
|
typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : String(children ?? ''),
|
|
43
|
-
)
|
|
87
|
+
)))
|
|
44
88
|
const ref = useRef<HTMLDivElement>(null)
|
|
45
89
|
const [error, setError] = useState<string | null>(null)
|
|
46
90
|
const uid = useRef(`mv-mmd-${++uidSeq}`)
|
|
@@ -9,7 +9,11 @@
|
|
|
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
|
+
|
|
14
|
+
// D3: family color classes for inline Md (`:blue[...]`), theme-aware (frames carry .dark + [data-theme])
|
|
15
|
+
const FAMILY_CSS = Object.entries(FAMILIES).map(([f, c]) =>
|
|
16
|
+
`.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
17
|
|
|
14
18
|
export { Diagram } from './diagram.tsx'
|
|
15
19
|
|
|
@@ -106,7 +110,7 @@ function ensureStyles() {
|
|
|
106
110
|
stylesIn = true
|
|
107
111
|
const el = document.createElement('style')
|
|
108
112
|
el.id = 'mv-content-css'
|
|
109
|
-
el.textContent = CSS
|
|
113
|
+
el.textContent = CSS + '\n' + FAMILY_CSS
|
|
110
114
|
document.head.appendChild(el)
|
|
111
115
|
}
|
|
112
116
|
|
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
|
|
@@ -16,6 +25,9 @@ if (isHtmlFrame) {
|
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
document.addEventListener('click', (e) => {
|
|
28
|
+
// laser/comment mode owns every click - a goto link must not ALSO navigate
|
|
29
|
+
// (both handlers capture on document, so stopPropagation can't referee)
|
|
30
|
+
if (modeActive()) return
|
|
19
31
|
const el = e.target instanceof Element ? e.target.closest('[data-goto]') : null
|
|
20
32
|
if (!el) return
|
|
21
33
|
e.preventDefault()
|
|
@@ -29,6 +41,8 @@ window.addEventListener('error', (e) => post({ type: 'sh:error', id, message: St
|
|
|
29
41
|
window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:error', id, message: `unhandled rejection: ${e.reason}` }))
|
|
30
42
|
|
|
31
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
|
|
32
46
|
if (e?.data?.type === 'sh:set-theme') setTheme(e.data.theme)
|
|
33
47
|
})
|
|
34
48
|
|
|
@@ -37,7 +51,259 @@ if (isHtmlFrame) {
|
|
|
37
51
|
document.readyState === 'loading' ? addEventListener('DOMContentLoaded', ready) : ready()
|
|
38
52
|
}
|
|
39
53
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
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 })
|
|
43
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'
|
|
71
|
+
|
|
72
|
+
// ---- laser mode + element picking (SPEC-M3 §5, §7) ----------------------------------
|
|
73
|
+
// Laser: one injected stylesheet, outline only (zero layout shift), depth-based hue -
|
|
74
|
+
// each nesting level steps 60° around the wheel, cycling at 6. Picking: laser plus a
|
|
75
|
+
// click interception that captures the anchor bundle and posts it to the shell.
|
|
76
|
+
|
|
77
|
+
const LASER_ID = 'mv-laser-style'
|
|
78
|
+
// comment-mode cursor: the pin's teardrop in the comment green, duotone (dark rim,
|
|
79
|
+
// lighter inner) with a white halo ring so it pops on any content; hotspot at the tail
|
|
80
|
+
const PICK_CURSOR = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 256 256'%3E%3Cpath d='M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Z' fill='none' stroke='%23fff' stroke-width='40'/%3E%3Cpath d='M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Z' fill='%2334c759' stroke='%231f8a3d' stroke-width='12'/%3E%3Ccircle cx='138' cy='118' r='46' fill='%23fff' opacity='.32'/%3E%3C/svg%3E") 4 21, crosshair`
|
|
81
|
+
// laser-mode cursor: the crosshair reticle in accent blue with a white halo ring,
|
|
82
|
+
// hotspot dead center
|
|
83
|
+
const LASER_CURSOR = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 256 256'%3E%3Cg stroke='%23fff' stroke-width='46' fill='none'%3E%3Ccircle cx='128' cy='128' r='56'/%3E%3Cpath d='M128 24 V56 M128 200 V232 M24 128 H56 M200 128 H232' stroke-linecap='round'/%3E%3C/g%3E%3Cg stroke='%230088ff' stroke-width='20' fill='none'%3E%3Ccircle cx='128' cy='128' r='56'/%3E%3Cpath d='M128 24 V56 M128 200 V232 M24 128 H56 M200 128 H232' stroke-linecap='round'/%3E%3C/g%3E%3Ccircle cx='128' cy='128' r='16' fill='%230088ff' stroke='%23fff' stroke-width='8'/%3E%3C/svg%3E") 12 12, crosshair`
|
|
84
|
+
const laserCss = () => {
|
|
85
|
+
// depth via unrolled descendant combinators - CSS custom properties cannot cycle
|
|
86
|
+
let rules = 'body { --mv-hue: 0 }\n'
|
|
87
|
+
for (let d = 1; d <= 12; d++)
|
|
88
|
+
rules += `body ${'> * '.repeat(d)}{ --mv-hue: ${(d % 6) * 60} }\n`
|
|
89
|
+
// full rainbow only in laser mode; comment mode keeps just the hover highlight
|
|
90
|
+
// (understand what you'd click without the whole board shouting). The label and
|
|
91
|
+
// its children are chrome, never subjects - no outlines on them.
|
|
92
|
+
if (laserOn)
|
|
93
|
+
rules += 'body *:not(script):not(style):not(#mv-laser-label):not(#mv-laser-label *) { outline: 1px solid hsl(var(--mv-hue) 85% 55% / .75); outline-offset: -1px }\n'
|
|
94
|
+
// comment cursor wins when both modes are on (comment owns the click)
|
|
95
|
+
if (pickOn || laserOn)
|
|
96
|
+
rules += `body, body * { cursor: ${pickOn ? PICK_CURSOR : LASER_CURSOR} !important }\n`
|
|
97
|
+
return rules + `
|
|
98
|
+
body [data-mv-hover] { outline: 2px solid hsl(var(--mv-hue) 95% 45%); outline-offset: -2px;
|
|
99
|
+
background-image: linear-gradient(hsl(var(--mv-hue) 95% 50% / .08), hsl(var(--mv-hue) 95% 50% / .08)) }
|
|
100
|
+
#mv-laser-label { position: fixed; z-index: 2147483647; pointer-events: none; outline: none !important;
|
|
101
|
+
display: flex; align-items: center; gap: 5px; width: max-content;
|
|
102
|
+
font: 600 10px -apple-system, system-ui, sans-serif; color: #fff; background: rgba(20, 20, 24, .92);
|
|
103
|
+
padding: 3px 7px; border-radius: 5px; max-width: 340px; white-space: nowrap; overflow: hidden;
|
|
104
|
+
text-overflow: ellipsis; letter-spacing: .01em }
|
|
105
|
+
#mv-laser-label svg { flex: none; display: block; outline: none !important }
|
|
106
|
+
#mv-laser-label.mv-copied { animation: mv-pop .22s cubic-bezier(.32, .72, .35, 1) }
|
|
107
|
+
@keyframes mv-pop { from { transform: scale(.85); opacity: .5 } }
|
|
108
|
+
`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// filled clipboard with a check inside (Phosphor clipboard-fill + check): the label
|
|
112
|
+
// wears this while confirming a copy
|
|
113
|
+
const COPIED_HTML = `<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor"><path d="M200,32H163.74a47.92,47.92,0,0,0-71.48,0H56A16,16,0,0,0,40,48V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V48A16,16,0,0,0,200,32Zm-72,0a32,32,0,0,1,32,32H96A32,32,0,0,1,128,32Z"/><path d="M92 150l26 26 48-54" fill="none" stroke="rgba(20,20,24,.92)" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Element path copied</span>`
|
|
114
|
+
|
|
115
|
+
let laserOn = false, pickOn = false, hoverEl = null, labelEl = null
|
|
116
|
+
const modeActive = () => laserOn || pickOn
|
|
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
|
+
|
|
127
|
+
// laser and pick are independent looks over shared hover machinery: the stylesheet
|
|
128
|
+
// is regenerated on every flip so each mode contributes exactly its own rules
|
|
129
|
+
const applyModes = () => {
|
|
130
|
+
const cur = document.getElementById(LASER_ID)
|
|
131
|
+
if (!modeActive()) {
|
|
132
|
+
if (cur) cur.remove()
|
|
133
|
+
clearHover()
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
const s = cur ?? document.createElement('style')
|
|
137
|
+
s.id = LASER_ID
|
|
138
|
+
s.textContent = laserCss()
|
|
139
|
+
if (!cur) document.head.appendChild(s)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let copiedTimer = null
|
|
143
|
+
|
|
144
|
+
const clearHover = () => {
|
|
145
|
+
if (hoverEl) { delete hoverEl.dataset.mvHover; hoverEl = null }
|
|
146
|
+
if (labelEl) { labelEl.remove(); labelEl = null }
|
|
147
|
+
clearTimeout(copiedTimer)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// the shell confirmed the clipboard write - the hover label ITSELF says so (a
|
|
151
|
+
// corner toast is too far from where the eyes are), then reverts after 2s.
|
|
152
|
+
// The seq + element check keeps a slow ack from decorating a DIFFERENT label
|
|
153
|
+
// (pointer moved on, or laser was retoggled, before writeText resolved).
|
|
154
|
+
const showCopied = (seq) => {
|
|
155
|
+
if (seq !== copySeq || hoverEl !== copyEl) return
|
|
156
|
+
if (!labelEl) return
|
|
157
|
+
labelEl.classList.add('mv-copied')
|
|
158
|
+
labelEl.innerHTML = COPIED_HTML
|
|
159
|
+
clearTimeout(copiedTimer)
|
|
160
|
+
copiedTimer = setTimeout(() => {
|
|
161
|
+
if (!labelEl) return
|
|
162
|
+
labelEl.classList.remove('mv-copied')
|
|
163
|
+
if (hoverEl) labelEl.textContent = describe(hoverEl)
|
|
164
|
+
else { labelEl.remove(); labelEl = null }
|
|
165
|
+
}, 2000)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const describe = (el) => {
|
|
169
|
+
const tag = el.tagName.toLowerCase()
|
|
170
|
+
const loc = el.dataset.mvLoc
|
|
171
|
+
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40)
|
|
172
|
+
return loc ? `${tag} · ${loc}` : text ? `${tag} · “${text}”` : tag
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
document.addEventListener('mousemove', (e) => {
|
|
176
|
+
if (!modeActive()) return
|
|
177
|
+
const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
|
|
178
|
+
if (!el || el === hoverEl) { if (labelEl && hoverEl) place(labelEl, e); return }
|
|
179
|
+
clearHover()
|
|
180
|
+
hoverEl = el
|
|
181
|
+
el.dataset.mvHover = '1'
|
|
182
|
+
labelEl = document.createElement('div')
|
|
183
|
+
labelEl.id = 'mv-laser-label'
|
|
184
|
+
labelEl.textContent = describe(el)
|
|
185
|
+
document.body.appendChild(labelEl)
|
|
186
|
+
place(labelEl, e)
|
|
187
|
+
}, true)
|
|
188
|
+
// label rides to the RIGHT of the cursor, vertically centered on it - below-right
|
|
189
|
+
// hid the pointer's target; flips to the left when the right edge runs out
|
|
190
|
+
const place = (label, e) => {
|
|
191
|
+
const pad = 18
|
|
192
|
+
const w = label.offsetWidth, h = label.offsetHeight
|
|
193
|
+
let x = e.clientX + pad
|
|
194
|
+
if (x + w > innerWidth - 4) x = Math.max(4, e.clientX - pad - w)
|
|
195
|
+
label.style.left = x + 'px'
|
|
196
|
+
label.style.top = Math.min(Math.max(4, e.clientY - h / 2), innerHeight - h - 4) + 'px'
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// the anchor bundle (SPEC-M3 §5): every rung captured at pick time
|
|
200
|
+
const cssPath = (el) => {
|
|
201
|
+
const seg = []
|
|
202
|
+
for (let cur = el; cur && cur !== document.body; cur = cur.parentElement) {
|
|
203
|
+
if (cur.id) { seg.unshift(`#${CSS.escape(cur.id)}`); break }
|
|
204
|
+
const tag = cur.tagName.toLowerCase()
|
|
205
|
+
let n = 1
|
|
206
|
+
for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling)
|
|
207
|
+
if (sib.tagName === cur.tagName) n++
|
|
208
|
+
seg.unshift(n > 1 ? `${tag}:nth-of-type(${n})` : tag)
|
|
209
|
+
}
|
|
210
|
+
return seg.join(' > ')
|
|
211
|
+
}
|
|
212
|
+
const anchorBundle = (el, e) => {
|
|
213
|
+
const r = el.getBoundingClientRect()
|
|
214
|
+
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
|
|
215
|
+
return {
|
|
216
|
+
el: {
|
|
217
|
+
semantics: {
|
|
218
|
+
tag: el.tagName.toLowerCase(),
|
|
219
|
+
role: el.getAttribute('role') ?? undefined,
|
|
220
|
+
ariaLabel: el.getAttribute('aria-label') ?? undefined,
|
|
221
|
+
testId: el.getAttribute('data-testid') ?? undefined,
|
|
222
|
+
quote: text.slice(0, 200) || undefined,
|
|
223
|
+
},
|
|
224
|
+
cssPath: cssPath(el),
|
|
225
|
+
source: el.dataset.mvLoc ?? undefined,
|
|
226
|
+
},
|
|
227
|
+
pos: {
|
|
228
|
+
fx: r.width ? Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)) : .5,
|
|
229
|
+
fy: r.height ? Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)) : .5,
|
|
230
|
+
},
|
|
231
|
+
rect: { x: r.left, y: r.top, w: r.width, h: r.height }, // frame-viewport coords for the shell's pin math
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// every press is reported (tiny message, capture, nothing prevented): the shell
|
|
236
|
+
// uses it to close an open thread card when the click lands INSIDE a frame -
|
|
237
|
+
// shell-document listeners can never see those
|
|
238
|
+
document.addEventListener('pointerdown', () => post({ type: 'sh:frame-down', id }), true)
|
|
239
|
+
|
|
240
|
+
let copySeq = 0, copyEl = null
|
|
241
|
+
document.addEventListener('click', (e) => {
|
|
242
|
+
if (!modeActive()) return
|
|
243
|
+
e.preventDefault()
|
|
244
|
+
e.stopPropagation()
|
|
245
|
+
const el = e.target instanceof Element && e.target.closest('body *:not(#mv-laser-label)')
|
|
246
|
+
if (!el) return
|
|
247
|
+
// comment mode wins when both are on; plain laser click hands the agent an
|
|
248
|
+
// exact address - frame file + css path (+ source loc when the build stamps one)
|
|
249
|
+
if (pickOn) post({ type: 'sh:picked', id, anchor: anchorBundle(el, e) })
|
|
250
|
+
else {
|
|
251
|
+
copyEl = el
|
|
252
|
+
post({ type: 'sh:laser-copy', id, seq: ++copySeq, path: cssPath(el), source: el.dataset.mvLoc ?? null })
|
|
253
|
+
}
|
|
254
|
+
}, true)
|
|
255
|
+
|
|
256
|
+
/** Resolve a stored anchor back to a rect (the ladder, §5): semantics-verified CSS
|
|
257
|
+
* path first, then testId, then a quote scan; null = orphan. Every check compares
|
|
258
|
+
* the FULL captured semantics - two textless buttons must not swap silently. */
|
|
259
|
+
const resolveAnchor = (anchor) => {
|
|
260
|
+
const want = anchor?.el?.semantics ?? {}
|
|
261
|
+
const match = (el) => {
|
|
262
|
+
if (want.tag && el.tagName.toLowerCase() !== want.tag) return false
|
|
263
|
+
if (want.role && el.getAttribute('role') !== want.role) return false
|
|
264
|
+
if (want.ariaLabel && el.getAttribute('aria-label') !== want.ariaLabel) return false
|
|
265
|
+
if (want.testId && el.getAttribute('data-testid') !== want.testId) return false
|
|
266
|
+
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
|
|
267
|
+
if (want.quote && !(text.startsWith(want.quote.slice(0, 60)) || text.includes(want.quote.slice(0, 40)))) return false
|
|
268
|
+
return true
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const byPath = anchor?.el?.cssPath && document.querySelector(anchor.el.cssPath)
|
|
272
|
+
if (byPath && match(byPath)) return byPath
|
|
273
|
+
} catch { /* stale selector */ }
|
|
274
|
+
try {
|
|
275
|
+
if (want.testId) {
|
|
276
|
+
const el = document.querySelector(`[data-testid="${CSS.escape(want.testId)}"]`)
|
|
277
|
+
if (el && match(el)) return el
|
|
278
|
+
}
|
|
279
|
+
if (want.quote && typeof want.tag === 'string' && /^[a-z][a-z0-9-]*$/.test(want.tag)) {
|
|
280
|
+
for (const el of document.querySelectorAll(want.tag))
|
|
281
|
+
if (match(el)) return el
|
|
282
|
+
}
|
|
283
|
+
} catch { /* malformed semantics must not sink the whole batch */ }
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
window.addEventListener('message', (e) => {
|
|
288
|
+
// commands come from the SHELL only - the parent window. Anything else (nested
|
|
289
|
+
// third-party iframes, a hijacked opener) is ignored.
|
|
290
|
+
if (e.source !== window.parent || window.parent === window) return
|
|
291
|
+
const m = e?.data
|
|
292
|
+
if (!m || typeof m !== 'object') return
|
|
293
|
+
// independent toggles - each mode contributes its own rules, applyModes composes
|
|
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 }
|
|
298
|
+
if (m.type === 'sh:copy-ok') showCopied(m.seq)
|
|
299
|
+
if (m.type === 'sh:resolve-anchors' && Array.isArray(m.anchors)) {
|
|
300
|
+
const rects = m.anchors.slice(0, 200).map((a) => {
|
|
301
|
+
let el = null
|
|
302
|
+
try { el = resolveAnchor(a.anchor) } catch { /* one bad anchor, not the batch */ }
|
|
303
|
+
if (!el) return { key: a.key, orphan: true }
|
|
304
|
+
const r = el.getBoundingClientRect()
|
|
305
|
+
return { key: a.key, rect: { x: r.left, y: r.top, w: r.width, h: r.height } }
|
|
306
|
+
})
|
|
307
|
+
post({ type: 'sh:anchor-rects', id, rects })
|
|
308
|
+
}
|
|
309
|
+
})
|
|
@@ -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
|
+
}
|