@marver-design/marver 0.8.0 → 0.8.1
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 +97 -5
- package/README.md +72 -16
- package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
- package/dist/{build-B8z902TW.mjs → build-BBVQRetk.mjs} +5 -5
- package/dist/cli.mjs +16 -7
- package/dist/{collab-pLGzSvm5.mjs → collab-s3k5byM1.mjs} +3 -3
- package/dist/{comments-DSwMhdbZ.mjs → comments-BZBKhKRO.mjs} +2 -2
- package/dist/{comments-BrpC86Kf.mjs → comments-J06jqCVV.mjs} +3 -3
- package/dist/{daemon-C-huGHAM.mjs → daemon-DkyNOwIt.mjs} +30 -51
- package/dist/{dev-BYm9ebfN.mjs → dev-BTAhTie-.mjs} +19 -5
- package/dist/{init-DrLomSWq.mjs → init-DWdhjJD5.mjs} +16 -3
- package/dist/{ledger-wFvEIEGi.mjs → ledger-CbzTJrV2.mjs} +1 -1
- package/dist/{manifest-D3eaARf4.mjs → manifest-B4zcDGBf.mjs} +3 -3
- package/dist/{plugin-BVFuRfEo.mjs → plugin-BdQEeTLg.mjs} +41 -9
- package/dist/{serve-CwAfayJk.mjs → serve-CZqPnj19.mjs} +32 -8
- package/dist/{sync-Ch4Bymb1.mjs → sync-BJKKmy1n.mjs} +2 -2
- package/dist/work-CLrmY-vQ.mjs +97 -0
- package/dist/work-lzC-lPY0.mjs +76 -0
- package/package.json +16 -1
- package/src/client/const.ts +1 -1
- package/src/client/content/diagram.tsx +1 -1
- package/src/client/content/index.tsx +2 -2
- package/src/client/content/md.ts +1 -1
- package/src/client/content/palette.ts +2 -2
- package/src/client/frame-host/bridge.js +1 -1
- package/src/client/frame-host/inspect.js +1 -1
- package/src/client/frame-host/serialize.ts +2 -2
- package/src/client/shell/App.tsx +79 -22
- package/src/client/shell/Comments.tsx +24 -18
- package/src/client/shell/Play.tsx +2 -2
- package/src/client/shell/canvas/Canvas.tsx +1 -1
- package/src/client/shell/canvas/FrameNode.tsx +16 -11
- package/src/client/shell/canvas/snapshots.ts +3 -3
- package/src/client/shell/comments-store.ts +115 -42
- package/src/client/shell/hash.ts +2 -2
- package/src/client/shell/icons.tsx +1 -1
- package/src/client/shell/keys.ts +39 -0
- package/src/client/shell/mentions.ts +1 -1
- package/src/client/shell/perf.ts +1 -1
- package/src/client/shell/store.ts +99 -39
- package/src/client/shell/styles.css +12 -12
- package/src/client/shell/tidy.ts +5 -5
- package/src/client/stage/main.tsx +2 -2
- package/src/shared/events.ts +2 -2
- package/src/shared/utm.ts +22 -0
- package/templates/AGENTS-embedded.md +48 -1
- package/templates/AGENTS-studio.md +48 -1
- package/templates/instructions/publish.md +1 -1
package/src/client/shell/App.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Component, useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
-
import { useStore, CONFIG, PUBLISHED, boardLabel, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
|
|
2
|
+
import { useStore, CONFIG, PUBLISHED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, type FrameEntry } from './store.ts'
|
|
3
3
|
import { Tip } from './Tip.tsx'
|
|
4
4
|
import { PKG, ROUTE } from '../const.ts'
|
|
5
5
|
import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
|
|
@@ -8,6 +8,7 @@ import { enterPlay, playCtl, PlayOverlay } from './Play.tsx'
|
|
|
8
8
|
import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
9
9
|
import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PlayIcon, PlusIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
|
|
10
10
|
import { CommentsController, revealThread } from './Comments.tsx'
|
|
11
|
+
import { poweredByUrl } from '../../shared/utm.ts'
|
|
11
12
|
import { useComments } from './comments-store.ts'
|
|
12
13
|
import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
|
|
13
14
|
|
|
@@ -126,7 +127,7 @@ function SelectionBar() {
|
|
|
126
127
|
// frame top - the caption offset (8px screen, world-capped) - the caption's height
|
|
127
128
|
// (screen-clamped 12..18px font) - the bar. Gate on the TOP edge of the selection,
|
|
128
129
|
// not the last-selected frame: a mixed selection whose topmost frames are variants
|
|
129
|
-
// still has a caption to clear
|
|
130
|
+
// still has a caption to clear.
|
|
130
131
|
const capAtTop = selNodes.some((n) => n.y === by0 && useStore.getState().frameFor(n)?.variantGroup)
|
|
131
132
|
const rawTop = capAtTop
|
|
132
133
|
? `calc(var(--sh-ty, 0px) + var(--sh-s, 1) * ${by0}px - clamp(4px * var(--sh-s, 1), 8px, 40px * var(--sh-s, 1)) - (clamp(12px, 17px * var(--sh-s, 1), 18px) * 1.4) - 44px)`
|
|
@@ -309,14 +310,14 @@ export function App() {
|
|
|
309
310
|
const selection = useStore((s) => s.selection)
|
|
310
311
|
const laser = useStore((s) => s.laser)
|
|
311
312
|
const commentMode = useComments((s) => s.commentMode)
|
|
312
|
-
//
|
|
313
|
+
// Laser/comment paint live outlines + do element-picking INSIDE the live frame; a scriptless
|
|
313
314
|
// lean cover would hide them, so a body class suppresses the cover while either mode is active.
|
|
314
315
|
useEffect(() => { document.body.classList.toggle('sh-laser', laser) }, [laser])
|
|
315
316
|
useEffect(() => { document.body.classList.toggle('sh-commenting', commentMode) }, [commentMode])
|
|
316
317
|
const { boot, applyManifest, togglePanel, select, setInteract, runTidy, toast, spawn } = useStore.getState()
|
|
317
318
|
const [pillOpen, setPillOpen] = useState(true)
|
|
318
319
|
|
|
319
|
-
// boot honors the deep link
|
|
320
|
+
// boot honors the deep link: board before load, play mode after it.
|
|
320
321
|
// Selection + camera intent are restored by the Canvas boot effect. The module-level
|
|
321
322
|
// guard makes boot single-shot: Fast Refresh re-runs mount effects on every App edit,
|
|
322
323
|
// and a re-boot would revert live state to the long-consumed deep link.
|
|
@@ -442,7 +443,7 @@ export function App() {
|
|
|
442
443
|
useEffect(() => {
|
|
443
444
|
if (!import.meta.hot) return
|
|
444
445
|
import.meta.hot.on('sh:manifest', (m: any) => applyManifest(m))
|
|
445
|
-
// Live Jam presence
|
|
446
|
+
// Live Jam presence: the daemon broadcasts the set of frames Marver is editing.
|
|
446
447
|
// Camera-safe by construction - this only toggles a glow class, never moves the view.
|
|
447
448
|
import.meta.hot.on('sh:jam-activity', (m: any) => useStore.getState().setWorking(Array.isArray(m?.frames) ? m.frames.filter((x: unknown) => typeof x === 'string') : []))
|
|
448
449
|
// Live Jam reply delivery: the daemon just wrote to a board's log - fetch it NOW instead of
|
|
@@ -457,7 +458,7 @@ export function App() {
|
|
|
457
458
|
})
|
|
458
459
|
// multi-viewer sync: another viewer (or an agent) saved this board. A clean canvas
|
|
459
460
|
// re-boots silently, keeping whatever selection survives; a dirty one keeps its
|
|
460
|
-
// edits and converges through the 409 path on its next save (disk wins
|
|
461
|
+
// edits and converges through the 409 path on its next save (disk wins).
|
|
461
462
|
import.meta.hot.on('sh:board', (m: any) => {
|
|
462
463
|
const s = useStore.getState()
|
|
463
464
|
if (m?.name !== s.board || m?.sha256 === s.boardHash || s.dirty) return
|
|
@@ -470,7 +471,7 @@ export function App() {
|
|
|
470
471
|
})
|
|
471
472
|
}, [])
|
|
472
473
|
|
|
473
|
-
// frame -> shell messages
|
|
474
|
+
// frame -> shell messages; source validated against known iframes
|
|
474
475
|
useEffect(() => {
|
|
475
476
|
const onMsg = (e: MessageEvent) => {
|
|
476
477
|
const data = e.data
|
|
@@ -490,7 +491,7 @@ export function App() {
|
|
|
490
491
|
} else if (data.type === 'sh:exit-interact') {
|
|
491
492
|
if (s.interact === nodeKey) setInteract(null)
|
|
492
493
|
} else if (data.type === 'sh:measure') {
|
|
493
|
-
//
|
|
494
|
+
// Generation guard: the sender echoes ITS document's URL rev; a
|
|
494
495
|
// WindowProxy survives navigation, so a stale pre-navigation message would
|
|
495
496
|
// otherwise route as if it came from the current document. Compare against
|
|
496
497
|
// the iframe's CURRENT src - mismatched generations are dropped.
|
|
@@ -526,14 +527,16 @@ export function App() {
|
|
|
526
527
|
if (c.commentMode) c.setDraft({ nodeKey, frame: String(data.id ?? ''), anchor: data.anchor })
|
|
527
528
|
} else if (data.type === 'sh:go') {
|
|
528
529
|
const target = String(data.target ?? '')
|
|
530
|
+
const carry = s.interact === nodeKey // a goto from inside an interacting frame
|
|
531
|
+
// CARRIES interact mode to the target - walking a flow must not eject you to
|
|
532
|
+
// design mode at every hop (and must survive a board switch)
|
|
529
533
|
const existing = s.nodes.find((n) => n.frame === target && !n.missing)
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
setTimeout(() => canvasCtl.fitNode(node.key), 50)
|
|
534
|
+
if (existing) {
|
|
535
|
+
gotoSeq++ // a local goto supersedes any cross-board one in flight
|
|
536
|
+
select(existing.key)
|
|
537
|
+
if (carry) setInteract(existing.key)
|
|
538
|
+
setTimeout(() => canvasCtl.fitNode(existing.key), 50)
|
|
539
|
+
} else void gotoAcrossBoards(target, carry)
|
|
537
540
|
} else if (data.type === 'sh:wheel') {
|
|
538
541
|
// B0.2: a passive frame forwarded a wheel event; the canvas owns it. Never for the
|
|
539
542
|
// interact target or play (the app scrolls itself - the parent stays authoritative).
|
|
@@ -590,7 +593,7 @@ export function App() {
|
|
|
590
593
|
if (!s.laser) commentsStore().setMode(false)
|
|
591
594
|
s.setLaser(!s.laser)
|
|
592
595
|
}
|
|
593
|
-
// C = comment mode (
|
|
596
|
+
// C = comment mode (the Figma/Miro convention) · Shift+C = hide/show
|
|
594
597
|
// pins · Shift+P = copy file path(s) (P alone is play; D6, changelog 0.4.0)
|
|
595
598
|
if (e.key === 'c' && !e.shiftKey) {
|
|
596
599
|
const c = commentsStore()
|
|
@@ -695,7 +698,7 @@ export function App() {
|
|
|
695
698
|
{/* the mark links to the marver site; the title is the humanized repo name (C2: names
|
|
696
699
|
the project so two concurrent canvases are never confused), ellipsed if long */}
|
|
697
700
|
<Tip side="bottom" label="marver.design">
|
|
698
|
-
<a className="mark-link" href=
|
|
701
|
+
<a className="mark-link" href={poweredByUrl(CONFIG.projectName, PUBLISHED ? 'published-canvas' : 'dev-canvas', 'shell')} target="_blank" rel="noreferrer" aria-label="marver.design" tabIndex={panelOpen ? 0 : -1}>
|
|
699
702
|
<ParallelogramDuoIcon size={21} className="mark" />
|
|
700
703
|
</a>
|
|
701
704
|
</Tip>
|
|
@@ -716,7 +719,7 @@ export function App() {
|
|
|
716
719
|
canvasCtl.fitNodes(keys)
|
|
717
720
|
}}>
|
|
718
721
|
{(() => {
|
|
719
|
-
// variant groups render as ONE surface row with A/B/C chips
|
|
722
|
+
// variant groups render as ONE surface row with A/B/C chips
|
|
720
723
|
const nodeFor = (id: string) => nodes.find((x) => x.frame === id && !x.missing) ?? nodes.find((x) => x.frame === id)
|
|
721
724
|
// sidebar order follows the CANVAS (reading order: rows top-to-bottom,
|
|
722
725
|
// left-to-right) - a story board's list must tell the same story as its
|
|
@@ -849,7 +852,7 @@ export function App() {
|
|
|
849
852
|
)
|
|
850
853
|
}
|
|
851
854
|
|
|
852
|
-
/** The bottom-right notification corner
|
|
855
|
+
/** The bottom-right notification corner. Plain toasts render as before.
|
|
853
856
|
* Jam pills are FRAME-FIRST (icon + frame title, then Marver · preview) and stack as a DECK:
|
|
854
857
|
* 1-2 show in full; 3+ collapse to the newest pill with two card edges peeking beneath and a
|
|
855
858
|
* +N badge - click to expand the full list (newest first, timestamps, Clear all). */
|
|
@@ -886,10 +889,64 @@ function JamToasts({ toasts }: { toasts: import('./store.ts').Toast[] }) {
|
|
|
886
889
|
)
|
|
887
890
|
}
|
|
888
891
|
|
|
889
|
-
/**
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
+
/** A data-goto whose target frame is not on the current board follows the frame HOME:
|
|
893
|
+
* the first curated board (switcher rank) that pins it is switched to and the frame
|
|
894
|
+
* focused there - a link is navigation, and navigation never edits a board. Only a
|
|
895
|
+
* frame NO board pins spawns onto the current board (the original prototype behavior
|
|
896
|
+
* for unpinned targets); an id the manifest doesn't know stays a toast. Every goto
|
|
897
|
+
* bumps `gotoSeq` so a slow older resolution can never override newer navigation. */
|
|
898
|
+
let gotoSeq = 0
|
|
899
|
+
async function gotoAcrossBoards(target: string, carry: boolean) {
|
|
900
|
+
const s = useStore.getState()
|
|
901
|
+
// an id the manifest doesn't know resolves NOWHERE - a tombstone pin on some board
|
|
902
|
+
// must not send us on a trip that ends in a silent timeout
|
|
903
|
+
if (!s.manifest?.frames.some((f) => f.id === target)) return s.toast(`unknown goto target "${target}"`)
|
|
904
|
+
const seq = ++gotoSeq
|
|
905
|
+
let home: string | null = null
|
|
906
|
+
try {
|
|
907
|
+
const names = (await fetchBoardNames()).filter((n) => n !== s.board && n !== 'all-scenes')
|
|
908
|
+
for (const name of names) {
|
|
909
|
+
if ((await boardFrames(name)).includes(target)) { home = name; break }
|
|
910
|
+
}
|
|
911
|
+
} catch {
|
|
912
|
+
// a transport failure is NOT proof the frame is unpinned - spawning here would
|
|
913
|
+
// recreate the board mutation this function exists to prevent
|
|
914
|
+
return useStore.getState().toast(`goto: could not read the boards - try again`)
|
|
915
|
+
}
|
|
916
|
+
if (seq !== gotoSeq) return // superseded by newer navigation
|
|
917
|
+
if (!home) {
|
|
918
|
+
// no curated board pins it - the original prototype behavior: spawn beside you
|
|
919
|
+
const st = useStore.getState()
|
|
920
|
+
const node = st.spawn(target)
|
|
921
|
+
if (!node) return st.toast(`unknown goto target "${target}"`)
|
|
922
|
+
st.select(node.key)
|
|
923
|
+
if (carry) st.setInteract(node.key)
|
|
924
|
+
setTimeout(() => canvasCtl.fitNode(node.key), 50)
|
|
925
|
+
return
|
|
926
|
+
}
|
|
927
|
+
await s.switchBoard(home)
|
|
928
|
+
for (let i = 0; i < 12; i++) { // the board commits async - retry like viewNote
|
|
929
|
+
if (seq !== gotoSeq) return
|
|
930
|
+
const st = useStore.getState()
|
|
931
|
+
if (st.board === home) { // a cancelled/failed switch must not select here
|
|
932
|
+
const node = st.nodes.find((n) => n.frame === target && !n.missing)
|
|
933
|
+
if (node) {
|
|
934
|
+
st.select(node.key)
|
|
935
|
+
if (carry) st.setInteract(node.key)
|
|
936
|
+
setTimeout(() => canvasCtl.fitNode(node.key), 50)
|
|
937
|
+
return
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
await new Promise((r) => setTimeout(r, 250))
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** View from a notification: threads are frame-scoped, so first try to reveal RIGHT HERE
|
|
945
|
+
* (the current board may show the thread's frame); only when it doesn't, switch to the
|
|
946
|
+
* note's origin board and retry while its comments load. Dismiss only on success: a
|
|
947
|
+
* cross-board note must never be a destructive no-op. */
|
|
892
948
|
async function viewNote(note: import('./store.ts').JamNote, dismiss: () => void) {
|
|
949
|
+
if (revealThread(note.threadId)) { dismiss(); return }
|
|
893
950
|
const s = useStore.getState()
|
|
894
951
|
if (note.board && s.board !== note.board) await s.switchBoard(note.board)
|
|
895
952
|
for (let i = 0; i < 12; i++) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Comment surfaces on the canvas
|
|
2
|
+
* Comment surfaces on the canvas. Canvas-first: pins live ON the frame
|
|
3
3
|
* at their anchored elements; an inactive frame collapses its open threads into a
|
|
4
4
|
* top-right stack; the thread card opens beside its pin. Pins keep a screen-space
|
|
5
5
|
* size via --sh-inv (the vbadge pattern) so zoom never shrinks them away.
|
|
@@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
8
8
|
import { createPortal } from 'react-dom'
|
|
9
9
|
import { avatarFallback, useComments } from './comments-store.ts'
|
|
10
10
|
import { useStore, type Node } from './store.ts'
|
|
11
|
+
import { threadHostKey } from './keys.ts'
|
|
11
12
|
import { canvasCtl } from './canvas/Canvas.tsx'
|
|
12
13
|
import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
|
|
13
14
|
import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
|
|
@@ -120,7 +121,11 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
120
121
|
// select the stable array, filter in render - a selector returning a fresh
|
|
121
122
|
// .filter() array re-renders forever (Object.is never matches)
|
|
122
123
|
const allThreads = useComments((s) => s.threads)
|
|
123
|
-
const
|
|
124
|
+
const allNodes = useStore((s) => s.nodes)
|
|
125
|
+
// one owner per thread (keys.ts): the stored anchor when it still holds, else the
|
|
126
|
+
// first node showing the frame - a stale key (pre-0.8.1 random keys, board rewrites,
|
|
127
|
+
// deleted copies) degrades to the frame, never to invisible, and never to two pins
|
|
128
|
+
const threads = allThreads.filter((t) => t.frame === frameId && threadHostKey(t, allNodes) === node.key)
|
|
124
129
|
const selected = useStore((s) => s.selection.includes(node.key))
|
|
125
130
|
const { setActive } = useComments.getState()
|
|
126
131
|
const [rects, setRects] = useState<Record<string, { x: number; y: number; w: number; h: number } | null>>({})
|
|
@@ -167,7 +172,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
167
172
|
|
|
168
173
|
// the open card's frozen side (D3) - a HOOK, so it must live above the early returns; and the
|
|
169
174
|
// reset must too: a card can close via the inactive-frame early return (clicking another frame),
|
|
170
|
-
// which never reaches the render tail - reopen must still RE-PICK the side
|
|
175
|
+
// which never reaches the render tail - reopen must still RE-PICK the side
|
|
171
176
|
const sideRef = useRef<{ id: string; side: 'l' | 'r' } | null>(null)
|
|
172
177
|
if (sideRef.current && sideRef.current.id !== active) sideRef.current = null
|
|
173
178
|
// what occupies this frame's LEFT flank (drives the docked card's left gutter): a variant
|
|
@@ -177,7 +182,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
177
182
|
const drafting = draft?.nodeKey === node.key
|
|
178
183
|
if (!show || (!open.length && !drafting)) return null
|
|
179
184
|
|
|
180
|
-
// inactive frame: the stack - count + avatars, top-right
|
|
185
|
+
// inactive frame: the stack - count + avatars, top-right
|
|
181
186
|
const engaged = selected || open.some((t) => t.id === active) || drafting
|
|
182
187
|
if (!engaged && open.length) {
|
|
183
188
|
return (
|
|
@@ -247,7 +252,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
247
252
|
|
|
248
253
|
/** The open thread card - shared by canvas (bounds = node size, `at` in frame coords) and
|
|
249
254
|
* prototype (bounds = the on-screen stage size, `at` in screen coords). Geometry only. */
|
|
250
|
-
// ---- Live Jam: @marver rendering + Marver identity
|
|
255
|
+
// ---- Live Jam: @marver rendering + Marver identity ----------------------
|
|
251
256
|
|
|
252
257
|
const AT_TIP = "Read like any other comment. Marver won't act on this unless the owner promotes it."
|
|
253
258
|
|
|
@@ -273,7 +278,7 @@ function prettyModel(m: string): string {
|
|
|
273
278
|
return cleaned.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
274
279
|
}
|
|
275
280
|
|
|
276
|
-
/** The provenance tooltip on the Marver avatar: who orchestrated the change
|
|
281
|
+
/** The provenance tooltip on the Marver avatar: who orchestrated the change.
|
|
277
282
|
* One row per fact, left-aligned, bold label + regular value - the house tooltip treatment. */
|
|
278
283
|
function AgentMetaTip({ meta }: { meta?: AgentMeta }) {
|
|
279
284
|
const rows = [
|
|
@@ -346,7 +351,7 @@ function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFo
|
|
|
346
351
|
return (
|
|
347
352
|
<div className="cm-inputwrap">
|
|
348
353
|
{/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver shows
|
|
349
|
-
its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted).
|
|
354
|
+
its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted). */}
|
|
350
355
|
<div className="cm-hl" ref={hlRef} aria-hidden>
|
|
351
356
|
{parseMentions(value).map((s, i) => s.mention
|
|
352
357
|
? <span key={i} className={owner ? 'cm-at owner' : 'cm-at'}>{s.text}</span>
|
|
@@ -511,12 +516,12 @@ export function ThreadCard({ thread, at, bounds, nodeKey, side = 'r', flank, sta
|
|
|
511
516
|
return () => ro.disconnect()
|
|
512
517
|
}, [])
|
|
513
518
|
// ...and after EVERY render: an edited message changes scrollHeight without resizing the
|
|
514
|
-
// height-capped box or the reply count
|
|
519
|
+
// height-capped box or the reply count. setShadows dedups, so this is free.
|
|
515
520
|
useEffect(syncShadows)
|
|
516
521
|
// PLAY: one static centered device, no zoom - the card docks FIXED beside it on the pin's side
|
|
517
522
|
// (falling back to the roomier side), fully viewport-clamped so the composer is ALWAYS reachable
|
|
518
|
-
// whatever the device layout or theme
|
|
519
|
-
// a device/theme switch resizes .sh-play-stage and a window resize shifts it
|
|
523
|
+
// whatever the device layout or theme. Recomputed when the stage moves under it:
|
|
524
|
+
// a device/theme switch resizes .sh-play-stage and a window resize shifts it.
|
|
520
525
|
const computeStagePos = (): React.CSSProperties | null => {
|
|
521
526
|
if (!stage) return null
|
|
522
527
|
const W = 320, M = 12, maxH = Math.min(660, window.innerHeight - 2 * M)
|
|
@@ -542,7 +547,7 @@ export function ThreadCard({ thread, at, bounds, nodeKey, side = 'r', flank, sta
|
|
|
542
547
|
return () => { window.removeEventListener('resize', re); ro?.disconnect() }
|
|
543
548
|
}, [stage, bounds.w, bounds.h])
|
|
544
549
|
const flip = !float && !stage && at.x > bounds.w * 0.55
|
|
545
|
-
//
|
|
550
|
+
// The card scales EXACTLY like the pins - screen-constant via --sh-inv, pure
|
|
546
551
|
// CSS, smooth per zoom tick with zero re-renders. Geometry in CSS math over the live vars:
|
|
547
552
|
// top: grows DOWN from the pin, then UP once its bottom reaches the frame's bottom; floor
|
|
548
553
|
// at -28 (the layer starts below the header - -28 = the frame's TOP border).
|
|
@@ -773,12 +778,13 @@ export function IdentityDialog() {
|
|
|
773
778
|
export function revealThread(c: string): boolean {
|
|
774
779
|
const t = useComments.getState().threads.find((t) => t.id === c)
|
|
775
780
|
if (!t) return false
|
|
781
|
+
// resolve the host BEFORE activating: a thread whose frame is not on this board
|
|
782
|
+
// must not consume the deep link (the board switch that CAN show it comes later)
|
|
783
|
+
const key = threadHostKey(t, useStore.getState().nodes)
|
|
784
|
+
if (!key) return false
|
|
776
785
|
useComments.getState().setActive(c)
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
useStore.getState().select(node.key)
|
|
780
|
-
setTimeout(() => canvasCtl.fitNode(node.key), 80)
|
|
781
|
-
}
|
|
786
|
+
useStore.getState().select(key)
|
|
787
|
+
setTimeout(() => canvasCtl.fitNode(key), 80)
|
|
782
788
|
return true
|
|
783
789
|
}
|
|
784
790
|
|
|
@@ -815,7 +821,7 @@ export function CommentsController() {
|
|
|
815
821
|
// a cross-board deep link parks its thread id here until the target board's
|
|
816
822
|
// comments have actually loaded - a timer can't know how long that takes
|
|
817
823
|
if (pendingThread && revealThread(pendingThread)) { pendingThread = null; return }
|
|
818
|
-
// deep link ?c=<thread
|
|
824
|
+
// deep link ?c=<thread>: open the thread, select its node, fit it.
|
|
819
825
|
// Consumed only on a successful find: the controller's first effect fires with
|
|
820
826
|
// the pre-boot board (child effects run before the parent's boot effect), and
|
|
821
827
|
// burning the flag on that empty pass would eat the link.
|
|
@@ -849,7 +855,7 @@ export function CommentsController() {
|
|
|
849
855
|
}, [])
|
|
850
856
|
|
|
851
857
|
// broadcast pick mode to every LIVE frame (laser rides along inside the bridge). Scoped to
|
|
852
|
-
// .sh-live so the
|
|
858
|
+
// .sh-live so the lean cover (.sh-lean, a scriptless snapshot) is never messaged.
|
|
853
859
|
useEffect(() => {
|
|
854
860
|
for (const f of document.querySelectorAll('iframe.sh-live'))
|
|
855
861
|
(f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode, quiet: !ctlShowAnchor }, location.origin)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Play mode
|
|
2
|
+
* Play mode: full-window near-black backdrop, ONE device shell centered at
|
|
3
3
|
* the chosen viewport's exact CSS pixels, scaled to fit - or `fill`, where the frame IS
|
|
4
4
|
* the window. The device hosts a single stage iframe that swaps frames in place.
|
|
5
5
|
*
|
|
@@ -281,7 +281,7 @@ function PlayInner() {
|
|
|
281
281
|
}
|
|
282
282
|
const restart = () => { const list = playList(); if (list.length) goTo(list[0]) }
|
|
283
283
|
|
|
284
|
-
/** Variant siblings of the CURRENT frame present on this board
|
|
284
|
+
/** Variant siblings of the CURRENT frame present on this board: the
|
|
285
285
|
* review question is "which direction is better on THIS screen" - switch in place,
|
|
286
286
|
* device and theme preserved, each variant's own data-goto links drive after. */
|
|
287
287
|
const variantList = () => {
|
|
@@ -100,7 +100,7 @@ export const canvasCtl = {
|
|
|
100
100
|
wheel(_input: CanvasWheelInput) {},
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
/** Variant-group captions
|
|
103
|
+
/** Variant-group captions: "Landing · 3 variants" above each group with
|
|
104
104
|
* 2+ members on this board. World-space (scales with the canvas); min screen size via
|
|
105
105
|
* --sh-inv. Groups with one lone member on a curated board keep the badge, no caption. */
|
|
106
106
|
function GroupCaptions() {
|
|
@@ -3,6 +3,7 @@ import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
|
|
|
3
3
|
import { CopyIcon, IntentGlyph, ParallelogramFillIcon, ReloadIcon, XIcon } from '../icons.tsx'
|
|
4
4
|
import { CommentLayer } from '../Comments.tsx'
|
|
5
5
|
import { useComments } from '../comments-store.ts'
|
|
6
|
+
import { threadHostKey } from '../keys.ts'
|
|
6
7
|
import { registerFrame, unregisterFrame } from './frame-registry.ts'
|
|
7
8
|
import { primeCameraFor } from './camera-broadcast.ts'
|
|
8
9
|
import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
|
|
@@ -10,7 +11,7 @@ import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from
|
|
|
10
11
|
export const HEADER = 28
|
|
11
12
|
const SNAP = 12
|
|
12
13
|
|
|
13
|
-
/** Live Jam working shimmer
|
|
14
|
+
/** Live Jam working shimmer: a slim 2x6 strip of tiny marver marks on the frame's left
|
|
14
15
|
* flank, top-aligned - each mark twinkles on its own scattered beat, phased per frame by
|
|
15
16
|
* --mv-w0 (set on the node) so parallel frames never pulse in sync. */
|
|
16
17
|
const SHIM_DELAYS = Array.from({ length: 12 }, (_, i) => {
|
|
@@ -29,7 +30,7 @@ function WorkShimmer({ belowBadge }: { belowBadge: boolean }) {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
|
-
* One frame on the canvas. Iframe laws
|
|
33
|
+
* One frame on the canvas. Iframe laws: the iframe element is created once per node key
|
|
33
34
|
* and never remounted - theme changes go through sh:set-theme, size changes are CSS only.
|
|
34
35
|
*
|
|
35
36
|
* Every interactive element carries `sh-no-pan` (rzpp's panning.excluded checks the event
|
|
@@ -88,7 +89,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
88
89
|
registerWin()
|
|
89
90
|
}, [node.key])
|
|
90
91
|
|
|
91
|
-
//
|
|
92
|
+
// Register the facade <iframe> so the lean coordinator can drive its srcdoc imperatively.
|
|
92
93
|
const bindLean = useCallback((el: HTMLIFrameElement | null) => { registerLeanFrame(node.key, el) }, [node.key])
|
|
93
94
|
// capture a fresh lean snapshot once the frame is ready and quiet, and whenever its CONTENT changes
|
|
94
95
|
// (nav). Resize needs no re-capture (the lean doc reflows) and theme needs none (attribute flip),
|
|
@@ -107,7 +108,10 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
107
108
|
// otherwise capture the live DOM WITH the highlight baked in and re-cover it. The
|
|
108
109
|
// hostsCard rail recaptures a clean lean once the card closes.
|
|
109
110
|
const c = useComments.getState()
|
|
110
|
-
|
|
111
|
+
// hosting goes through the resolver (keys.ts): an adopted thread's card renders
|
|
112
|
+
// here even though its stored nodeKey names a node that no longer exists
|
|
113
|
+
const hosting = (!!c.active && c.threads.some((th) =>
|
|
114
|
+
th.id === c.active && !th.resolved && threadHostKey(th, useStore.getState().nodes) === node.key)) || c.draft?.nodeKey === node.key
|
|
111
115
|
if (hosting) return
|
|
112
116
|
scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme })
|
|
113
117
|
}, 450)
|
|
@@ -132,7 +136,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
132
136
|
scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
|
|
133
137
|
}, [interact, node.key, node.nav, node.theme, node.status])
|
|
134
138
|
|
|
135
|
-
// laser mode
|
|
139
|
+
// laser mode rides the same rail; re-sent when a frame becomes ready
|
|
136
140
|
// so late loaders join an already-lasered board
|
|
137
141
|
const laser = useStore((s) => s.laser)
|
|
138
142
|
const commentMode = useComments((s) => s.commentMode)
|
|
@@ -140,7 +144,8 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
140
144
|
// neighbors - each node is a stacking context, so an overflowing card would
|
|
141
145
|
// otherwise paint under the next frame
|
|
142
146
|
const hostsCard = useComments((s) =>
|
|
143
|
-
(!!s.active && s.threads.some((t) =>
|
|
147
|
+
(!!s.active && s.threads.some((t) =>
|
|
148
|
+
t.id === s.active && !t.resolved && threadHostKey(t, useStore.getState().nodes) === node.key)) || s.draft?.nodeKey === node.key)
|
|
144
149
|
// a frame hosting an OPEN thread or a draft must show its LIVE app, not the frozen lean
|
|
145
150
|
// cover: the active-element highlight lives in the live DOM and updates in real time
|
|
146
151
|
// (open -> lit, close -> cleared). Without this the cover re-freezes the moment comment
|
|
@@ -199,7 +204,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
199
204
|
if (frame && iframeRef.current) iframeRef.current.src = frameUrl(frame, node.theme)
|
|
200
205
|
}, [node.nav])
|
|
201
206
|
|
|
202
|
-
// ready timeout
|
|
207
|
+
// ready timeout: 10s without sh:ready -> error card with reload
|
|
203
208
|
useEffect(() => {
|
|
204
209
|
if (node.status !== 'loading') return
|
|
205
210
|
const t = setTimeout(() => setStatus(node.key, 'error', 'frame never reported ready (10s)'), 10_000)
|
|
@@ -302,7 +307,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
302
307
|
)
|
|
303
308
|
}
|
|
304
309
|
|
|
305
|
-
// variant badge
|
|
310
|
+
// variant badge: letter + name floating LEFT of the frame, outside the
|
|
306
311
|
// artwork, world-anchored (scales with zoom) with a screen-space minimum via --sh-inv
|
|
307
312
|
const variantName = frame.title
|
|
308
313
|
?? cap((frame.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' '))
|
|
@@ -312,7 +317,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
312
317
|
className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}${working ? ' working' : ''}`}
|
|
313
318
|
data-theme={node.theme}
|
|
314
319
|
style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined,
|
|
315
|
-
// phase every working animation by when THIS frame's job started
|
|
320
|
+
// phase every working animation by when THIS frame's job started - parallel
|
|
316
321
|
// frames pulsing in sync would read as one fake choreography
|
|
317
322
|
...(working ? { ['--mv-w0' as string]: `${-(Date.now() - (workingSince ?? Date.now()))}ms` } : {}) }}
|
|
318
323
|
data-node={node.key}
|
|
@@ -327,7 +332,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
327
332
|
</div>
|
|
328
333
|
)}
|
|
329
334
|
<div className="sh-node-head sh-no-pan" onPointerDown={(e) => drag(e, 'move')} title={frame.file}>
|
|
330
|
-
{/* content frames carry their intent glyph in the chrome
|
|
335
|
+
{/* content frames carry their intent glyph in the chrome */}
|
|
331
336
|
{frame.intent && <IntentGlyph intent={frame.intent} size={12} className="iicon sh-no-pan" aria-label={frame.intent} />}
|
|
332
337
|
<span className="id sh-no-pan">{frame.title ?? frame.id}</span>
|
|
333
338
|
<span className="dim sh-no-pan">{Math.round(node.w)} · {node.theme}</span>
|
|
@@ -357,7 +362,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
357
362
|
onLoad={registerWin}
|
|
358
363
|
style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
|
|
359
364
|
/>
|
|
360
|
-
{/*
|
|
365
|
+
{/* Lean facade: a DOM-snapshot (static html, 0 JS) covering the live iframe only while
|
|
361
366
|
the canvas is gesturing (CSS), so a heavy frame never flashes white mid-transform and the
|
|
362
367
|
device sweep reflows correctly. sandbox WITHOUT allow-scripts = no JS runs; allow-same-origin
|
|
363
368
|
so fonts/assets resolve and the shell can flip its theme + restore scroll. Never registered,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The lean-frame facade coordinator. Imperative on purpose - the facade is a
|
|
3
3
|
* `<iframe class="sh-lean" sandbox="allow-same-origin">` driven by setting .srcdoc directly, so no
|
|
4
4
|
* FrameNode subscribes to snapshot state and a pan/zoom tick triggers zero React renders.
|
|
5
5
|
*
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* INVALIDATE the lean (never mutate a displayed one - baked mermaid can't re-theme in place) and a
|
|
14
14
|
* fresh capture is admitted before it is shown again. Captures run bounded-parallel, viewport-first, at idle, never while busy.
|
|
15
15
|
*
|
|
16
|
-
* Correctness beats the flash-guard
|
|
16
|
+
* Correctness beats the flash-guard: a frame the serialiser cannot render faithfully
|
|
17
17
|
* (canvas/video/shadow-dom/cross-origin-css, or an unrestorable scroller) is left DEGRADED - no
|
|
18
18
|
* `data-ready`, no cover, live pixels stay. Every install is guarded by a per-node GENERATION token
|
|
19
19
|
* so an in-flight capture can never paint a stale/wrong-node cover after a reload or unmount.
|
|
@@ -87,7 +87,7 @@ function install(nodeKey: string): void {
|
|
|
87
87
|
const doc = iframe.contentDocument
|
|
88
88
|
if (!cur || !doc) return
|
|
89
89
|
if (!restoreScroll(doc, cur.scrollMap)) { cur.degraded = [...cur.degraded, 'scroll']; iframe.removeAttribute('srcdoc'); return }
|
|
90
|
-
// CSP guard
|
|
90
|
+
// CSP guard: if a hardened host blocked the inline <style> (style-src 'self'), the lean is
|
|
91
91
|
// unstyled - the sentinel custom prop won't resolve. Stay live rather than show an unstyled cover.
|
|
92
92
|
if (getComputedStyle(doc.documentElement).getPropertyValue('--mv-lean-ok').trim() !== '1') { iframe.removeAttribute('srcdoc'); return }
|
|
93
93
|
applyTheme(doc, cur.theme)
|