@marver-design/marver 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/dist/{build-BZaPa2DS.mjs → build-B8z902TW.mjs} +2 -2
  3. package/dist/cli.mjs +5 -5
  4. package/dist/{collab-CXy8gqoz.mjs → collab-pLGzSvm5.mjs} +16 -5
  5. package/dist/{comments-odHzYdO3.mjs → comments-BrpC86Kf.mjs} +9 -15
  6. package/dist/{comments-Ba8mU600.mjs → comments-DSwMhdbZ.mjs} +2 -8
  7. package/dist/daemon-C-huGHAM.mjs +899 -0
  8. package/dist/{dev-DaPQ9xA5.mjs → dev-BYm9ebfN.mjs} +14 -3
  9. package/dist/events-BMtBvvgU.mjs +101 -0
  10. package/dist/{init-DsCUmlCW.mjs → init-DrLomSWq.mjs} +13 -1
  11. package/dist/ledger-wFvEIEGi.mjs +64 -0
  12. package/dist/{manifest-C8FODq2S.mjs → manifest-D3eaARf4.mjs} +16 -1
  13. package/dist/{plugin-wMY9lNf3.mjs → plugin-BVFuRfEo.mjs} +56 -40
  14. package/dist/profile-BkiWglVE.mjs +39 -0
  15. package/dist/{serve-D_KBK7Oy.mjs → serve-CwAfayJk.mjs} +3 -3
  16. package/dist/{sync-CkBk-tUk.mjs → sync-Ch4Bymb1.mjs} +4 -102
  17. package/package.json +1 -1
  18. package/src/client/frame-host/bridge.js +28 -242
  19. package/src/client/frame-host/inspect.d.ts +6 -0
  20. package/src/client/frame-host/inspect.js +359 -0
  21. package/src/client/shell/App.tsx +107 -110
  22. package/src/client/shell/Comments.tsx +465 -70
  23. package/src/client/shell/Play.tsx +218 -155
  24. package/src/client/shell/Toolbar.tsx +201 -0
  25. package/src/client/shell/canvas/FrameNode.tsx +59 -8
  26. package/src/client/shell/comments-store.ts +47 -6
  27. package/src/client/shell/icons.tsx +5 -0
  28. package/src/client/shell/mentions.ts +18 -0
  29. package/src/client/shell/store.ts +30 -2
  30. package/src/client/shell/styles.css +356 -30
  31. package/src/client/stage/main.tsx +35 -11
  32. package/src/shared/events.ts +22 -5
  33. package/templates/AGENTS-embedded.md +1 -0
  34. package/templates/AGENTS-studio.md +1 -0
  35. package/templates/instructions/configure.md +15 -0
  36. package/templates/instructions/jam.md +85 -0
  37. package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
@@ -1,7 +1,15 @@
1
1
  // Shared frame bridge. TSX frames import it plainly; the plugin injects it into HTML frames
2
2
  // with ?html=1, which turns on theme-from-query and auto-ready (TSX posts its own ready after boot).
3
+ //
4
+ // The laser / comment-pick / anchor / highlight machinery lives in ./inspect.js and is
5
+ // shared with the prototype stage; this bridge owns the canvas-frame concerns around it:
6
+ // theme, wheel ownership, the interaction lease, data-goto, and error reporting.
7
+ import { createInspect } from './inspect.js'
8
+
3
9
  const isHtmlFrame = new URL(import.meta.url).searchParams.get('html') === '1'
4
- const post = (msg) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
10
+ // same-origin parent (the shell): a fixed target origin keeps posts (incl. picked anchor
11
+ // bundles) from leaking if this frame is ever navigated cross-origin
12
+ const post = (msg) => { if (window.parent !== window) window.parent.postMessage(msg, location.origin) }
5
13
  const id = new URLSearchParams(location.search).get('id') ?? location.pathname
6
14
 
7
15
  // SPEC-M5: the shell serialises this frame's DOM (same origin) for the lean facade. Open shadow roots
@@ -24,10 +32,25 @@ if (isHtmlFrame) {
24
32
  if (theme) setTheme(theme)
25
33
  }
26
34
 
35
+ // A6: report transient laser/comment engagement (pointer inside this frame AND a mode on) so the
36
+ // shell leases the frame - a hot update to it then defers until the pointer leaves or the mode
37
+ // ends, instead of yanking the user mid-inspect/mid-comment.
38
+ let pointerInside = false
39
+ let interactiveOn = false // B0.2: set by sh:interactive - the play/interact target owns its own wheel
40
+ const reportInteraction = () => post({ type: 'sh:interaction', id, laser: pointerInside && inspect.isLaser(), comment: pointerInside && inspect.isPick() })
41
+
42
+ // laser / pick / anchor / highlight - one shared controller (see inspect.js). The id is
43
+ // static here (one iframe per frame); onModeChange re-reports the lease when a mode flips.
44
+ const inspect = createInspect({ post, getId: () => id, onModeChange: () => reportInteraction() })
45
+
46
+ document.addEventListener('mouseover', () => { if (!pointerInside) { pointerInside = true; reportInteraction() } })
47
+ document.addEventListener('mouseout', (e) => { if (!e.relatedTarget) { pointerInside = false; reportInteraction() } })
48
+ window.addEventListener('blur', () => { if (pointerInside) { pointerInside = false; reportInteraction() } })
49
+
27
50
  document.addEventListener('click', (e) => {
28
51
  // laser/comment mode owns every click - a goto link must not ALSO navigate
29
52
  // (both handlers capture on document, so stopPropagation can't referee)
30
- if (modeActive()) return
53
+ if (inspect.modeActive()) return
31
54
  const el = e.target instanceof Element ? e.target.closest('[data-goto]') : null
32
55
  if (!el) return
33
56
  e.preventDefault()
@@ -43,7 +66,10 @@ window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:error', id
43
66
  window.addEventListener('message', (e) => {
44
67
  // commands come from the SHELL (parent) only - embedded app content must not flip the theme
45
68
  if (e.source !== window.parent || window.parent === window) return
69
+ if (e.origin && e.origin !== location.origin) return // a navigated frame must not spoof commands
46
70
  if (e?.data?.type === 'sh:set-theme') setTheme(e.data.theme)
71
+ // B0.2: interact/play target owns its own wheel; passive frames forward it to the canvas
72
+ if (e?.data?.type === 'sh:interactive') { interactiveOn = !!e.data.on }
47
73
  })
48
74
 
49
75
  if (isHtmlFrame) {
@@ -57,7 +83,6 @@ if (isHtmlFrame) {
57
83
  // forward them to the shell, when interactive we leave them for the app (only blocking
58
84
  // the browser's own ctrl/meta page pinch-zoom). preventing here is mandatory - the parent
59
85
  // gets the forwarded message too late to cancel the iframe's own scroll.
60
- let interactiveOn = false
61
86
  window.addEventListener('wheel', (e) => {
62
87
  if (interactiveOn) { if (e.ctrlKey || e.metaKey) e.preventDefault(); return }
63
88
  e.preventDefault()
@@ -68,242 +93,3 @@ window.addEventListener('wheel', (e) => {
68
93
  document.addEventListener('gesturestart', (e) => e.preventDefault())
69
94
  // a nested scroll container hitting its boundary must not chain into the shell page
70
95
  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,6 @@
1
+ // Types for inspect.js (kept as plain JS so it serves into frames like bridge.js).
2
+ export function createInspect(opts: {
3
+ post: (msg: Record<string, unknown>) => void
4
+ getId: () => string
5
+ onModeChange?: (laser: boolean, pick: boolean) => void
6
+ }): { modeActive: () => boolean; isLaser: () => boolean; isPick: () => boolean; dispose: () => void }