@marver-design/marver 0.5.0 → 0.7.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 +94 -0
- package/dist/{build-BrCl9hJS.mjs → build-BZaPa2DS.mjs} +7 -3
- package/dist/cli.mjs +2 -2
- package/dist/{dev-DdeU-Jst.mjs → dev-DaPQ9xA5.mjs} +1 -1
- package/dist/{plugin-BtSGAm2h.mjs → plugin-wMY9lNf3.mjs} +12 -1
- package/package.json +1 -1
- package/src/client/content/img-lod.ts +107 -0
- package/src/client/content/index.tsx +31 -7
- package/src/client/frame-host/bridge.js +28 -242
- package/src/client/frame-host/inspect.d.ts +6 -0
- package/src/client/frame-host/inspect.js +346 -0
- package/src/client/shell/App.tsx +39 -115
- package/src/client/shell/Comments.tsx +56 -11
- package/src/client/shell/Play.tsx +214 -155
- package/src/client/shell/Toolbar.tsx +189 -0
- package/src/client/shell/canvas/Canvas.tsx +4 -2
- package/src/client/shell/canvas/FrameNode.tsx +32 -2
- package/src/client/shell/canvas/camera-broadcast.ts +44 -0
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/store.ts +32 -16
- package/src/client/shell/styles.css +63 -10
- package/src/client/stage/main.tsx +35 -11
- package/templates/instructions/boards.md +9 -3
- package/templates/instructions/shape.md +21 -19
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared top-right toolbar pieces (prototype-review Phase 1). The canvas pill and the
|
|
3
|
+
* prototype pill render the SAME controls so they never drift: laser / comment buttons
|
|
4
|
+
* (store-level, zero coupling), a Hide-UI toggle (one shared body class), and controlled
|
|
5
|
+
* Device / Theme dropdowns whose wiring stays local to each surface (canvas mutates
|
|
6
|
+
* selection/board; play mutates play.device/theme). usePopover + Popover live here too so
|
|
7
|
+
* both App and Play share the popover chrome.
|
|
8
|
+
*/
|
|
9
|
+
import { createPortal } from 'react-dom'
|
|
10
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
11
|
+
import { useStore, CONFIG, cap } from './store.ts'
|
|
12
|
+
import { useComments } from './comments-store.ts'
|
|
13
|
+
import { Tip } from './Tip.tsx'
|
|
14
|
+
import { CaretIcon, CheckIcon, CommentIcon, DevicesIcon, EyeSlashIcon, FrameCornersIcon, LaserIcon, MoonIcon, SunIcon, deviceIcon } from './icons.tsx'
|
|
15
|
+
|
|
16
|
+
const commentsStore = () => useComments.getState()
|
|
17
|
+
|
|
18
|
+
/** Shared popover machinery: trigger position, outside-click close, portal to the app
|
|
19
|
+
* root (glass never nests - a nested backdrop-filter cannot sample the page). */
|
|
20
|
+
export function usePopover() {
|
|
21
|
+
const [open, setOpen] = useState(false)
|
|
22
|
+
const [pos, setPos] = useState({ left: 0, top: 0 })
|
|
23
|
+
const boxRef = useRef<HTMLDivElement>(null)
|
|
24
|
+
const menuRef = useRef<HTMLDivElement>(null)
|
|
25
|
+
const toggle = () => {
|
|
26
|
+
if (!open && boxRef.current) {
|
|
27
|
+
const r = boxRef.current.getBoundingClientRect()
|
|
28
|
+
setPos({ left: r.left, top: r.bottom + 10 })
|
|
29
|
+
}
|
|
30
|
+
setOpen(!open)
|
|
31
|
+
}
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!open) return
|
|
34
|
+
const close = (e: PointerEvent) => {
|
|
35
|
+
const t = e.target as globalThis.Node
|
|
36
|
+
if (!boxRef.current?.contains(t) && !menuRef.current?.contains(t)) setOpen(false)
|
|
37
|
+
}
|
|
38
|
+
window.addEventListener('pointerdown', close)
|
|
39
|
+
return () => window.removeEventListener('pointerdown', close)
|
|
40
|
+
}, [open])
|
|
41
|
+
return { open, setOpen, pos, boxRef, menuRef, toggle }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function Popover({ pop, children, dark }: { pop: ReturnType<typeof usePopover>; children: ReactNode; dark?: boolean }) {
|
|
45
|
+
const app = document.querySelector('.sh-app')
|
|
46
|
+
if (!pop.open || !app) return null
|
|
47
|
+
return createPortal(
|
|
48
|
+
<div className={`sh-menu${dark ? ' sh-menu-dark' : ''}`} ref={pop.menuRef} style={{ left: pop.pos.left, top: pop.pos.top }}>{children}</div>,
|
|
49
|
+
app,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---- Hide UI: one shared, binary body class (canvas + prototype) ---------------------
|
|
54
|
+
// The state IS the body class - one source of truth for both pills, so they can never
|
|
55
|
+
// desync. Not persisted, so a page refresh always restores the chrome (the safety net
|
|
56
|
+
// for a forgotten shortcut). toggleHideUI is the only writer; useHideUI subscribes.
|
|
57
|
+
|
|
58
|
+
const hideListeners = new Set<() => void>()
|
|
59
|
+
export const isHideUI = () => document.body.classList.contains('sh-hide-ui')
|
|
60
|
+
export function toggleHideUI() {
|
|
61
|
+
document.body.classList.toggle('sh-hide-ui', !isHideUI())
|
|
62
|
+
hideListeners.forEach((f) => f())
|
|
63
|
+
}
|
|
64
|
+
export function useHideUI() {
|
|
65
|
+
const [on, setOn] = useState(isHideUI)
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const f = () => setOn(isHideUI())
|
|
68
|
+
hideListeners.add(f)
|
|
69
|
+
return () => { hideListeners.delete(f) }
|
|
70
|
+
}, [])
|
|
71
|
+
return on
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- shared controls ----------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** Laser toggle - store-level, identical on canvas and prototype. The per-surface code
|
|
77
|
+
* broadcasts sh:laser to its frame(s); this only flips the flag (and drops comment mode,
|
|
78
|
+
* since the two are one-at-a-time). */
|
|
79
|
+
export function LaserButton() {
|
|
80
|
+
const laser = useStore((s) => s.laser)
|
|
81
|
+
return (
|
|
82
|
+
<Tip side="bottom" label={<><b>Laser mode</b><span>L</span></>}>
|
|
83
|
+
<button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
|
|
84
|
+
if (!laser) commentsStore().setMode(false)
|
|
85
|
+
useStore.getState().setLaser(!laser)
|
|
86
|
+
}}><LaserIcon size={16} /></button>
|
|
87
|
+
</Tip>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Comment toggle - store-level, identical on canvas and prototype. */
|
|
92
|
+
export function CommentButton() {
|
|
93
|
+
const commentMode = useComments((s) => s.commentMode)
|
|
94
|
+
return (
|
|
95
|
+
<Tip side="bottom" label={<><b>Comment</b><span>C · ⇧C hides pins</span></>}>
|
|
96
|
+
<button className={`sh-pill-btn${commentMode ? ' on' : ''}`} onClick={() => {
|
|
97
|
+
const c = commentsStore()
|
|
98
|
+
if (!c.commentMode) useStore.getState().setLaser(false)
|
|
99
|
+
c.setMode(!c.commentMode)
|
|
100
|
+
useStore.getState().toast(c.commentMode ? 'comment mode off' : 'comment mode - click an element in a frame')
|
|
101
|
+
}}><CommentIcon size={16} /></button>
|
|
102
|
+
</Tip>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Hide-UI toggle - the dedicated binary control (no auto-fade, no hover-reveal). Its
|
|
107
|
+
* tooltip carries the way back; H is the shortcut, wired per-surface. */
|
|
108
|
+
export function HideUIButton() {
|
|
109
|
+
const on = useHideUI()
|
|
110
|
+
return (
|
|
111
|
+
<Tip side="bottom" label={<><b>Hide all UI</b><span>press H to reveal</span></>}>
|
|
112
|
+
<button className={`sh-pill-btn${on ? ' on' : ''}`} onClick={toggleHideUI}><EyeSlashIcon size={16} /></button>
|
|
113
|
+
</Tip>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Controlled device dropdown. `value` is the active device name ('fill' allowed when
|
|
118
|
+
* includeFill), null = default/mixed. The trigger + menu chrome is shared; the adapter
|
|
119
|
+
* owns what select does. */
|
|
120
|
+
export function DevicePicker({ value, onSelect, includeDefault, includeFill, hint, dark }: {
|
|
121
|
+
value: string | null
|
|
122
|
+
onSelect: (name: string | null) => void
|
|
123
|
+
includeDefault?: boolean
|
|
124
|
+
includeFill?: boolean
|
|
125
|
+
hint?: ReactNode
|
|
126
|
+
dark?: boolean
|
|
127
|
+
}) {
|
|
128
|
+
const pop = usePopover()
|
|
129
|
+
const entries = Object.entries(CONFIG.viewports)
|
|
130
|
+
const pick = (name: string | null) => { onSelect(name); pop.setOpen(false) }
|
|
131
|
+
const triggerIcon = value === 'fill' ? <FrameCornersIcon size={16} /> : deviceIcon(value, 16)
|
|
132
|
+
return (
|
|
133
|
+
<div className="sh-theme" ref={pop.boxRef}>
|
|
134
|
+
<Tip side="bottom" label={<><b>Device view</b>{hint && <span>{hint}</span>}</>}>
|
|
135
|
+
<button className="sh-pill-btn" onClick={pop.toggle}>
|
|
136
|
+
{triggerIcon}
|
|
137
|
+
<CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
|
|
138
|
+
</button>
|
|
139
|
+
</Tip>
|
|
140
|
+
<Popover pop={pop} dark={dark}>
|
|
141
|
+
{includeDefault && <>
|
|
142
|
+
<button onClick={() => pick(null)}>
|
|
143
|
+
<DevicesIcon size={15} /><span>Default</span><kbd>0</kbd>
|
|
144
|
+
{value === null && <CheckIcon size={13} className="chk" />}
|
|
145
|
+
</button>
|
|
146
|
+
<i className="div" />
|
|
147
|
+
</>}
|
|
148
|
+
{entries.map(([name, vp], i) => (
|
|
149
|
+
<button key={name} onClick={() => pick(name)} title={`${vp.width} × ${vp.height}`}>
|
|
150
|
+
{deviceIcon(name)}<span>{cap(name)}</span><kbd>{i < 9 ? i + 1 : ''}</kbd>
|
|
151
|
+
{value === name && <CheckIcon size={13} className="chk" />}
|
|
152
|
+
</button>
|
|
153
|
+
))}
|
|
154
|
+
{includeFill && <button onClick={() => pick('fill')}>
|
|
155
|
+
<FrameCornersIcon size={15} /><span>Fill window</span><kbd>{entries.length + 1}</kbd>
|
|
156
|
+
{value === 'fill' && <CheckIcon size={13} className="chk" />}
|
|
157
|
+
</button>}
|
|
158
|
+
</Popover>
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Controlled theme dropdown. `value` drives the trigger (the level the picker acts on -
|
|
164
|
+
* the scope MAJORITY on the canvas); `checked` is the menu tick and can differ (undefined
|
|
165
|
+
* = tick `value`; null = tick nothing, e.g. a mixed selection). onSelect owns the write. */
|
|
166
|
+
export function ThemePicker({ value, checked, onSelect, hint, dark }: { value: string; checked?: string | null; onSelect: (t: string) => void; hint?: ReactNode; dark?: boolean }) {
|
|
167
|
+
const pop = usePopover()
|
|
168
|
+
const pick = (t: string) => { onSelect(t); pop.setOpen(false) }
|
|
169
|
+
const tick = checked === undefined ? value : checked
|
|
170
|
+
return (
|
|
171
|
+
<div className="sh-theme" ref={pop.boxRef}>
|
|
172
|
+
<Tip side="bottom" label={<><b>Theme</b>{hint && <span>{hint}</span>}</>}>
|
|
173
|
+
<button className="sh-pill-btn" onClick={pop.toggle}>
|
|
174
|
+
{value === 'dark' ? <MoonIcon size={16} /> : <SunIcon size={16} />}
|
|
175
|
+
<CaretIcon size={11} style={{ transform: pop.open ? 'rotate(180deg)' : undefined }} />
|
|
176
|
+
</button>
|
|
177
|
+
</Tip>
|
|
178
|
+
<Popover pop={pop} dark={dark}>
|
|
179
|
+
{CONFIG.themes.map((t) => (
|
|
180
|
+
<button key={t} onClick={() => pick(t)}>
|
|
181
|
+
{t === 'dark' ? <MoonIcon size={15} /> : <SunIcon size={15} />}
|
|
182
|
+
<span>{t}</span>
|
|
183
|
+
{tick === t && <CheckIcon size={13} className="chk" />}
|
|
184
|
+
</button>
|
|
185
|
+
))}
|
|
186
|
+
</Popover>
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -4,6 +4,7 @@ import { CONFIG, useStore } from '../store.ts'
|
|
|
4
4
|
import { bootHash } from '../hash.ts'
|
|
5
5
|
import { startPerf } from '../perf.ts'
|
|
6
6
|
import { FrameNode, HEADER } from './FrameNode.tsx'
|
|
7
|
+
import { startCameraBroadcast, setCameraScale } from './camera-broadcast.ts'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The world. rzpp owns pan/zoom; nodes are absolutely positioned children of #sh-world.
|
|
@@ -142,7 +143,7 @@ export function Canvas() {
|
|
|
142
143
|
const ref = useRef<ReactZoomPanPinchContentRef>(null)
|
|
143
144
|
const scaleTimer = useRef(0)
|
|
144
145
|
|
|
145
|
-
useEffect(() => { startPerf() }, []) // B0.4: frame-time sampler
|
|
146
|
+
useEffect(() => { startPerf(); startCameraBroadcast() }, []) // B0.4: frame-time sampler + image-LOD camera signal
|
|
146
147
|
|
|
147
148
|
useEffect(() => {
|
|
148
149
|
const wrap = () => document.querySelector('.sh-canvas') as HTMLElement | null
|
|
@@ -331,7 +332,7 @@ export function Canvas() {
|
|
|
331
332
|
<TransformWrapper
|
|
332
333
|
ref={ref}
|
|
333
334
|
minScale={0.05}
|
|
334
|
-
maxScale={
|
|
335
|
+
maxScale={5}
|
|
335
336
|
limitToBounds={false}
|
|
336
337
|
doubleClick={{ disabled: true }}
|
|
337
338
|
// wheelDisabled is load-bearing: rzpp's onWheelPanning is a no-op without it, and
|
|
@@ -353,6 +354,7 @@ export function Canvas() {
|
|
|
353
354
|
// re-render happens per tick during a pan/zoom.
|
|
354
355
|
onTransformed={(r) => {
|
|
355
356
|
paintGrid(r.state.positionX, r.state.positionY, r.state.scale)
|
|
357
|
+
setCameraScale(r.state.scale) // keep the LOD's settle-scale current (cheap number write, no React)
|
|
356
358
|
clearTimeout(scaleTimer.current)
|
|
357
359
|
scaleTimer.current = window.setTimeout(() => setScale(r.state.scale), 120)
|
|
358
360
|
}}
|
|
@@ -4,6 +4,7 @@ import { CopyIcon, IntentGlyph, ReloadIcon, XIcon } from '../icons.tsx'
|
|
|
4
4
|
import { CommentLayer } from '../Comments.tsx'
|
|
5
5
|
import { useComments } from '../comments-store.ts'
|
|
6
6
|
import { registerFrame, unregisterFrame } from './frame-registry.ts'
|
|
7
|
+
import { primeCameraFor } from './camera-broadcast.ts'
|
|
7
8
|
import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
|
|
8
9
|
|
|
9
10
|
export const HEADER = 28
|
|
@@ -80,7 +81,16 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
80
81
|
if (node.status !== 'ready' || node.missing) return
|
|
81
82
|
const iframe = iframeRef.current
|
|
82
83
|
if (!iframe) return
|
|
83
|
-
const t = setTimeout(() =>
|
|
84
|
+
const t = setTimeout(() => {
|
|
85
|
+
// never re-admit a cover while this frame hosts an open thread / draft: the live app
|
|
86
|
+
// must stay visible (its highlight updates in real time). A status/theme change would
|
|
87
|
+
// otherwise capture the live DOM WITH the highlight baked in and re-cover it. The
|
|
88
|
+
// hostsCard rail recaptures a clean lean once the card closes.
|
|
89
|
+
const c = useComments.getState()
|
|
90
|
+
const hosting = (!!c.active && c.threads.some((th) => th.id === c.active && th.nodeKey === node.key && !th.resolved)) || c.draft?.nodeKey === node.key
|
|
91
|
+
if (hosting) return
|
|
92
|
+
scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme })
|
|
93
|
+
}, 450)
|
|
84
94
|
return () => clearTimeout(t)
|
|
85
95
|
// node.theme IS a dep: baked content (mermaid SVG) can't be re-themed by the cover's attribute
|
|
86
96
|
// flip, so a theme change re-captures after the live frame re-renders (key includes theme).
|
|
@@ -110,7 +120,22 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
110
120
|
// neighbors - each node is a stacking context, so an overflowing card would
|
|
111
121
|
// otherwise paint under the next frame
|
|
112
122
|
const hostsCard = useComments((s) =>
|
|
113
|
-
(!!s.active && s.threads.some((t) => t.id === s.active && t.nodeKey === node.key)) || s.draft?.nodeKey === node.key)
|
|
123
|
+
(!!s.active && s.threads.some((t) => t.id === s.active && t.nodeKey === node.key && !t.resolved)) || s.draft?.nodeKey === node.key)
|
|
124
|
+
// a frame hosting an OPEN thread or a draft must show its LIVE app, not the frozen lean
|
|
125
|
+
// cover: the active-element highlight lives in the live DOM and updates in real time
|
|
126
|
+
// (open -> lit, close -> cleared). Without this the cover re-freezes the moment comment
|
|
127
|
+
// mode ends and either bakes a stale highlight or hides the live one. Mirror the interact
|
|
128
|
+
// rail: drop the cover while hosting, rebuild a fresh lean once the card closes (the
|
|
129
|
+
// highlight is cleared by then, so the recapture is clean).
|
|
130
|
+
const prevHostsCard = useRef(hostsCard)
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (prevHostsCard.current === hostsCard) return
|
|
133
|
+
const wasHosting = prevHostsCard.current
|
|
134
|
+
prevHostsCard.current = hostsCard
|
|
135
|
+
if (hostsCard) invalidateLean(node.key)
|
|
136
|
+
else if (wasHosting && node.status === 'ready' && iframeRef.current)
|
|
137
|
+
scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
|
|
138
|
+
}, [hostsCard, node.key, node.nav, node.theme, node.status])
|
|
114
139
|
useEffect(() => {
|
|
115
140
|
if (node.status === 'ready' || !laser)
|
|
116
141
|
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
|
|
@@ -126,6 +151,11 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
126
151
|
if (node.status === 'ready' || !interact)
|
|
127
152
|
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:interactive', on: interact }, location.origin)
|
|
128
153
|
}, [interact, node.status])
|
|
154
|
+
// image-LOD: once the frame is ready its content listener is live, so send the settled zoom - a static
|
|
155
|
+
// board that never gets a gesture then still sharpens its images from the cheap low-res first paint.
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (node.status === 'ready') primeCameraFor(iframeRef.current?.contentWindow)
|
|
158
|
+
}, [node.status])
|
|
129
159
|
|
|
130
160
|
// a frame whose FILE actually changed (e.g. tsx -> html swap, same id) must renavigate
|
|
131
161
|
useEffect(() => {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Tells every content frame when the canvas camera starts and stops moving, so each frame's image-LOD
|
|
2
|
+
// (content/img-lod.ts) can freeze its bitmaps during motion and sharpen to the settled zoom afterwards.
|
|
3
|
+
//
|
|
4
|
+
// Fires ONCE per gesture, never per animation frame - it watches the single #sh-world.sh-camera class
|
|
5
|
+
// that EVERY camera path (rzpp zoom/pan, wheel, pinch, programmatic setTransform) toggles, so one hook
|
|
6
|
+
// covers them all. Posting a message to 150 frames every tick would be its own jank; two posts per
|
|
7
|
+
// gesture (start, settle) is free.
|
|
8
|
+
|
|
9
|
+
let lastScale = 1
|
|
10
|
+
/** Fed live from onTransformed so `settle` always broadcasts the final zoom. Cheap (a number write). */
|
|
11
|
+
export function setCameraScale(s: number): void { if (s > 0) lastScale = s }
|
|
12
|
+
|
|
13
|
+
function frameWindows(): Window[] {
|
|
14
|
+
const out: Window[] = []
|
|
15
|
+
for (const f of document.querySelectorAll<HTMLIFrameElement>('#sh-world iframe'))
|
|
16
|
+
if (f.contentWindow) out.push(f.contentWindow)
|
|
17
|
+
return out
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function broadcast(moving: boolean): void {
|
|
21
|
+
const msg = moving ? { type: 'sh:camera', moving: true } : { type: 'sh:camera', moving: false, scale: lastScale }
|
|
22
|
+
for (const w of frameWindows()) { try { w.postMessage(msg, location.origin) } catch { /* cross-doc timing */ } }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let started = false
|
|
26
|
+
export function startCameraBroadcast(): void {
|
|
27
|
+
if (started) return
|
|
28
|
+
const world = document.getElementById('sh-world'); if (!world) return
|
|
29
|
+
started = true
|
|
30
|
+
let moving = false
|
|
31
|
+
new MutationObserver(() => {
|
|
32
|
+
const now = world.classList.contains('sh-camera')
|
|
33
|
+
if (now === moving) return
|
|
34
|
+
moving = now
|
|
35
|
+
broadcast(now) // +sh-camera -> freeze; -sh-camera -> sharpen to the settled scale
|
|
36
|
+
}).observe(world, { attributes: true, attributeFilter: ['class'] })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Prime ONE freshly-ready frame with the current settled scale, so a static board that is never zoomed
|
|
40
|
+
* still sharpens from its cheap low-res first paint to the right resolution. */
|
|
41
|
+
export function primeCameraFor(win: Window | null | undefined): void {
|
|
42
|
+
if (!win) return
|
|
43
|
+
try { win.postMessage({ type: 'sh:camera', moving: false, scale: lastScale }, location.origin) } catch { /* timing */ }
|
|
44
|
+
}
|
|
@@ -30,6 +30,7 @@ export const CheckIcon = icon(<path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l
|
|
|
30
30
|
export const ColumnsIcon = icon(<path d="M104,32H64A16,16,0,0,0,48,48V208a16,16,0,0,0,16,16h40a16,16,0,0,0,16-16V48A16,16,0,0,0,104,32Zm0,176H64V48h40ZM192,32H152a16,16,0,0,0-16,16V208a16,16,0,0,0,16,16h40a16,16,0,0,0,16-16V48A16,16,0,0,0,192,32Zm0,176H152V48h40Z" />)
|
|
31
31
|
export const ArrowUpIcon = icon(<path d="M205.66,117.66a8,8,0,0,1-11.32,0L136,59.31V216a8,8,0,0,1-16,0V59.31L61.66,117.66a8,8,0,0,1-11.32-11.32l72-72a8,8,0,0,1,11.32,0l72,72A8,8,0,0,1,205.66,117.66Z" />)
|
|
32
32
|
export const CommentIcon = icon(<path d="M132,24A100.11,100.11,0,0,0,32,124v84a16,16,0,0,0,16,16h84a100,100,0,0,0,0-200Zm0,184H48V124a84,84,0,1,1,84,84Z" />)
|
|
33
|
+
export const EyeSlashIcon = icon(<path d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-11.07l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.57A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z" />)
|
|
33
34
|
export const CheckSquareOffsetIcon = icon(<path d="M224,48V208a16,16,0,0,1-16,16H136a8,8,0,0,1,0-16h72V48H48v96a8,8,0,0,1-16,0V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48ZM125.66,154.34a8,8,0,0,0-11.32,0L64,204.69,45.66,186.34a8,8,0,0,0-11.32,11.32l24,24a8,8,0,0,0,11.32,0l56-56A8,8,0,0,0,125.66,154.34Z" />)
|
|
34
35
|
export const DevicesIcon = icon(<path d="M224,72H208V64a24,24,0,0,0-24-24H40A24,24,0,0,0,16,64v96a24,24,0,0,0,24,24H152v8a24,24,0,0,0,24,24h48a24,24,0,0,0,24-24V96A24,24,0,0,0,224,72ZM40,168a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H184a8,8,0,0,1,8,8v8H176a24,24,0,0,0-24,24v72Zm192,24a8,8,0,0,1-8,8H176a8,8,0,0,1-8-8V96a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8Zm-96,16a8,8,0,0,1-8,8H88a8,8,0,0,1,0-16h40A8,8,0,0,1,136,208Zm80-96a8,8,0,0,1-8,8H192a8,8,0,0,1,0-16h16A8,8,0,0,1,216,112Z" />)
|
|
35
36
|
export const DeviceMobileIcon = icon(<path d="M176,16H80A24,24,0,0,0,56,40V216a24,24,0,0,0,24,24h96a24,24,0,0,0,24-24V40A24,24,0,0,0,176,16ZM72,64H184V192H72Zm8-32h96a8,8,0,0,1,8,8v8H72V40A8,8,0,0,1,80,32Zm96,192H80a8,8,0,0,1-8-8v-8H184v8A8,8,0,0,1,176,224Z" />)
|
|
@@ -40,12 +40,17 @@ export const CONFIG: { viewports: Record<string, { width: number; height: number
|
|
|
40
40
|
export { cap, humanize } from './labels.ts'
|
|
41
41
|
import { cap, humanize } from './labels.ts'
|
|
42
42
|
|
|
43
|
-
/** Board names for switchers:
|
|
44
|
-
*
|
|
43
|
+
/** Board names for switchers: the agent's curated boards FIRST (ranked by each board's `order`, then
|
|
44
|
+
* name), and the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
|
|
45
|
+
* Throws on transport failure - callers keep their last known list. */
|
|
45
46
|
export async function fetchBoardNames(): Promise<string[]> {
|
|
46
47
|
if (DATA) return DATA.names
|
|
47
|
-
const list: { name: string }[] = await (await fetch(`${ROUTE}/api/boards`)).json()
|
|
48
|
-
|
|
48
|
+
const list: { name: string; order?: number }[] = await (await fetch(`${ROUTE}/api/boards`)).json()
|
|
49
|
+
const curated = list
|
|
50
|
+
.filter((b) => b.name !== 'all-scenes')
|
|
51
|
+
.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || a.name.localeCompare(b.name))
|
|
52
|
+
.map((b) => b.name)
|
|
53
|
+
return [...curated, 'all-scenes']
|
|
49
54
|
}
|
|
50
55
|
/** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
|
|
51
56
|
export const boardLabel = (n: string) => humanize(n)
|
|
@@ -600,27 +605,38 @@ export const useStore = create<State>((set, get) => {
|
|
|
600
605
|
measureNode(key, frameId, ownWidth, measuredWidth, height) {
|
|
601
606
|
const s = get()
|
|
602
607
|
const node = s.nodes.find((n) => n.key === key)
|
|
603
|
-
|
|
608
|
+
// Only an explicit DEVICE viewport locks a content frame's height. 'auto' and 'manual' both
|
|
609
|
+
// auto-fit height: a content frame must always grow/shrink to show ALL its content, even after
|
|
610
|
+
// the human drags its WIDTH (SPEC-026 r4). 'manual' just means the human owns the WIDTH; the
|
|
611
|
+
// HEIGHT is still measured, so resizing width reflows and refits height (no clipped/empty frame).
|
|
612
|
+
if (!node || node.sizeMode === 'device') return
|
|
604
613
|
if (node.frame !== frameId) return // generation guard: a reused node key
|
|
605
614
|
// across a board switch never mis-attributes
|
|
606
615
|
const f = s.manifest?.frames.find((x) => x.id === node.frame)
|
|
607
616
|
if (!f?.contentWidth) return // not a content frame - spoof-proofing
|
|
608
617
|
if (![ownWidth, measuredWidth, height].every((v) => Number.isFinite(v) && v > 0)) return
|
|
609
|
-
|
|
610
|
-
//
|
|
611
|
-
|
|
612
|
-
const
|
|
618
|
+
// Generous cap: a reference doc with many screenshots is legitimately very tall and must fit in
|
|
619
|
+
// FULL (this was 2.5x a viewport ~= 2700px, which clipped image-heavy docs). Still bounded so a
|
|
620
|
+
// broken measurement can't mint an infinite frame.
|
|
621
|
+
const maxH = 40000
|
|
613
622
|
const H = Math.min(maxH, Math.max(80, Math.round(height)))
|
|
614
623
|
const curW = Math.round(node.w)
|
|
615
624
|
measuredHeights.set(`${node.frame}@${Math.round(measuredWidth)}`, H)
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
625
|
+
// AUTO owns the width too - adopt the Doc's declared/own width. MANUAL keeps the human's width
|
|
626
|
+
// and only fits the height.
|
|
627
|
+
if (node.sizeMode !== 'manual') {
|
|
628
|
+
// declared meta.viewport WINS over the Doc layout width - the existing precedence
|
|
629
|
+
const vpw = CONFIG.viewports[f.viewport ?? '']?.width
|
|
630
|
+
const W = vpw ?? Math.min(1600, Math.max(320, Math.round(ownWidth)))
|
|
631
|
+
if (W !== curW) {
|
|
632
|
+
// Doc layout changed (document<->wide): adopt the new own width first; the
|
|
633
|
+
// iframe resizes, remeasures, and the height commits on the next message
|
|
634
|
+
set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, w: W } : n)) }))
|
|
635
|
+
scheduleReflow()
|
|
636
|
+
return
|
|
637
|
+
}
|
|
623
638
|
}
|
|
639
|
+
if (Math.round(measuredWidth) !== curW) return // height only true at the width it was measured at
|
|
624
640
|
if (Math.round(node.h) === H) return
|
|
625
641
|
set((st) => ({ nodes: st.nodes.map((n) => (n.key === key ? { ...n, h: H } : n)) }))
|
|
626
642
|
scheduleReflow()
|
|
@@ -417,7 +417,9 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
417
417
|
|
|
418
418
|
/* dropdown menu (theme) - portaled to .sh-app (its own backdrop root), left-aligned to its trigger */
|
|
419
419
|
.sh-theme { position: relative }
|
|
420
|
-
.sh-
|
|
420
|
+
/* z above the play backdrop (.sh-play is z-30): a dropdown portals to .sh-app, so in the
|
|
421
|
+
prototype it must clear the full-window overlay or it renders behind it, invisible */
|
|
422
|
+
.sh-menu { position: absolute; min-width: 136px; z-index: 40;
|
|
421
423
|
border-radius: 16px; padding: 5px; display: flex; flex-direction: column;
|
|
422
424
|
transform-origin: top left; animation: sh-pop .14s ease }
|
|
423
425
|
@keyframes sh-pop { from { opacity: 0; transform: scale(.95) translateY(-4px) } }
|
|
@@ -643,6 +645,57 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
|
|
|
643
645
|
divider needs a touch more air before the first chip */
|
|
644
646
|
.sh-play-nav .sep + .vchip { margin-left: 5px }
|
|
645
647
|
|
|
648
|
+
/* --- prototype-review: shared toolbar in play, Hide-UI, comment overlay --- */
|
|
649
|
+
|
|
650
|
+
/* Hide-UI: one binary body class (canvas + prototype) drops all operating chrome. Not
|
|
651
|
+
persisted - a page refresh always restores it. Comment pins/threads (the review
|
|
652
|
+
annotations) stay; only the marver chrome hides, for a clean point-and-shoot frame. */
|
|
653
|
+
body.sh-hide-ui .sh-pill,
|
|
654
|
+
body.sh-hide-ui .sh-pill-fab,
|
|
655
|
+
body.sh-hide-ui .sh-panel,
|
|
656
|
+
body.sh-hide-ui .sh-fab,
|
|
657
|
+
body.sh-hide-ui .sh-ctx,
|
|
658
|
+
body.sh-hide-ui .sh-update,
|
|
659
|
+
body.sh-hide-ui .sh-toasts,
|
|
660
|
+
body.sh-hide-ui .sh-banner,
|
|
661
|
+
body.sh-hide-ui .sh-play-nav { display: none !important }
|
|
662
|
+
|
|
663
|
+
/* the prototype pill reuses the shared pill LAYOUT but wears the discreet DARK skin of the
|
|
664
|
+
bottom-left navigator (not the bright canvas glass), so it recedes into the near-black
|
|
665
|
+
stage: dark translucent fill, a faint white hairline, light-on-dark controls. */
|
|
666
|
+
.sh-play-pill, .sh-play .sh-pill-fab { z-index: 32 }
|
|
667
|
+
.sh-play-pill.sh-pill, .sh-play .sh-pill-fab {
|
|
668
|
+
background: rgba(18, 18, 24, .8); border: 1px solid rgba(255, 255, 255, .1);
|
|
669
|
+
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
|
670
|
+
box-shadow: 0 8px 28px rgba(0, 0, 0, .38) }
|
|
671
|
+
.sh-play-pill::after, .sh-play .sh-pill-fab::after { display: none } /* no light edge-light overlay */
|
|
672
|
+
.sh-play-pill .sh-pill-btn, .sh-play .sh-pill-fab { color: rgba(245, 245, 247, .6) }
|
|
673
|
+
.sh-play-pill .sh-pill-btn:hover, .sh-play .sh-pill-fab:hover { background: rgba(255, 255, 255, .1); color: #f5f5f7 }
|
|
674
|
+
.sh-play-pill .sh-pill-btn.on { background: rgba(255, 255, 255, .14); color: #f5f5f7 }
|
|
675
|
+
.sh-play-pill .sep { background: rgba(255, 255, 255, .12) }
|
|
676
|
+
.sh-play-pill .bd { color: rgba(245, 245, 247, .82) }
|
|
677
|
+
.sh-pill-btn.bd { width: auto; gap: 6px; padding: 0 8px 0 11px; white-space: nowrap; font-weight: 600 }
|
|
678
|
+
.sh-play-update { width: auto !important; gap: 5px; padding: 0 11px !important; font-size: 12px; font-weight: 600 }
|
|
679
|
+
/* the picker dropdowns portal to .sh-app; in play they carry .sh-menu-dark for the dark skin */
|
|
680
|
+
.sh-menu-dark { background: rgba(20, 20, 26, .97); border-color: rgba(255, 255, 255, .12); box-shadow: 0 12px 40px rgba(0, 0, 0, .5) }
|
|
681
|
+
.sh-menu-dark::after { display: none }
|
|
682
|
+
.sh-menu-dark button { color: rgba(245, 245, 247, .82) }
|
|
683
|
+
.sh-menu-dark button:hover { background: rgba(255, 255, 255, .1) }
|
|
684
|
+
.sh-menu-dark .chk { color: #6db3ff }
|
|
685
|
+
.sh-menu-dark kbd { color: rgba(245, 245, 247, .5) }
|
|
686
|
+
.sh-menu-dark .div { background: rgba(255, 255, 255, .1) }
|
|
687
|
+
|
|
688
|
+
/* device + comment overlay share one flex-centered box; the overlay is inset:0 over it, a
|
|
689
|
+
SIBLING of the clipping .dev so a thread card is never eaten by the device's overflow.
|
|
690
|
+
overflow:visible lets a card spill past the device edge; only its markers catch the
|
|
691
|
+
pointer, the stage below stays fully interactive. */
|
|
692
|
+
.sh-play-stage { position: relative; flex: none }
|
|
693
|
+
/* the canvas writes --sh-inv (1/zoom) for screen-constant pins; the prototype has no
|
|
694
|
+
zoom, so neutralise it or the leaked value scales every comment pin by ~12x at overview */
|
|
695
|
+
.sh-play { --sh-inv: 1 }
|
|
696
|
+
.sh-play-comments { position: absolute; inset: 0; z-index: 31; overflow: visible; pointer-events: none }
|
|
697
|
+
.sh-play-comments > * { pointer-events: auto }
|
|
698
|
+
|
|
646
699
|
/* the current variant's name trails the chips at natural width - the chips anchor,
|
|
647
700
|
so a name change grows the pill rightward without shifting any control underfoot.
|
|
648
701
|
A max caps runaway titles; the Tip carries the full name. */
|
|
@@ -704,9 +757,9 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
|
|
|
704
757
|
outline hugging the edge, 4px ring halo - in the comment green (the pin
|
|
705
758
|
is already screen-space via scale(--sh-inv), so plain px match the
|
|
706
759
|
frame's calc'd screen px) */
|
|
707
|
-
.cm-pin.on { border-color: var(--comment);
|
|
708
|
-
outline: 2px solid var(--comment); outline-offset: -1px;
|
|
709
|
-
box-shadow: 0 0 0 4px var(--comment-ring), 0 1px 2px rgba(24,24,27,.18), 0 6px 16px -6px rgba(24,24,27,.35) }
|
|
760
|
+
.cm-pin.on { border-color: var(--cm-line, var(--comment));
|
|
761
|
+
outline: 2px solid var(--cm-line, var(--comment)); outline-offset: -1px;
|
|
762
|
+
box-shadow: 0 0 0 4px var(--cm-line-ring, var(--comment-ring)), 0 1px 2px rgba(24,24,27,.18), 0 6px 16px -6px rgba(24,24,27,.35) }
|
|
710
763
|
.cm-pin.orphan { border-style: dashed; opacity: .75 }
|
|
711
764
|
|
|
712
765
|
/* stack: the frame-corner cluster - everyone across the frame's threads */
|
|
@@ -721,9 +774,9 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
|
|
|
721
774
|
transform: scale(var(--sh-inv, 1)); transform-origin: top left;
|
|
722
775
|
background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
723
776
|
/* an open card is the active selection - the frame's exact geometry, comment green */
|
|
724
|
-
color: var(--glass-ink); border-radius: 20px; border: 1px solid var(--comment);
|
|
725
|
-
outline: 2px solid var(--comment); outline-offset: -1px;
|
|
726
|
-
box-shadow: 0 0 0 4px var(--comment-ring), var(--shadow-glass), 0 16px 40px -12px rgba(24,24,27,.35);
|
|
777
|
+
color: var(--glass-ink); border-radius: 20px; border: 1px solid var(--cm-line, var(--comment));
|
|
778
|
+
outline: 2px solid var(--cm-line, var(--comment)); outline-offset: -1px;
|
|
779
|
+
box-shadow: 0 0 0 4px var(--cm-line-ring, var(--comment-ring)), var(--shadow-glass), 0 16px 40px -12px rgba(24,24,27,.35);
|
|
727
780
|
display: flex; flex-direction: column; gap: 12px;
|
|
728
781
|
font: 400 13px -apple-system, system-ui, sans-serif; letter-spacing: -.005em; text-align: left }
|
|
729
782
|
.cm-card.flip { transform: translateX(-100%) scale(var(--sh-inv, 1)); transform-origin: top right }
|
|
@@ -760,15 +813,15 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
|
|
|
760
813
|
font: 400 13px -apple-system, system-ui, sans-serif; outline: none;
|
|
761
814
|
transition: border-color .15s, box-shadow .15s }
|
|
762
815
|
.cm-compose input::placeholder { color: var(--glass-ink-3) }
|
|
763
|
-
.cm-compose input:focus { border-color: var(--comment); box-shadow: 0 0 0 3px var(--comment-ring) }
|
|
816
|
+
.cm-compose input:focus { border-color: var(--cm-line, var(--comment)); box-shadow: 0 0 0 3px var(--cm-line-ring, var(--comment-ring)) }
|
|
764
817
|
.cm-send { position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
|
|
765
818
|
width: 28px; height: 28px; border-radius: 50%; border: 0; display: flex; align-items: center; justify-content: center;
|
|
766
|
-
background: var(--comment); color: #fff; cursor: pointer; transition: background .15s, opacity .15s }
|
|
819
|
+
background: var(--cm-solid, var(--comment)); color: #fff; cursor: pointer; transition: background .15s, opacity .15s }
|
|
767
820
|
.cm-send:hover:not(:disabled) { filter: brightness(1.08) }
|
|
768
821
|
.cm-send:disabled { background: var(--glass-ink-3); opacity: .5; cursor: default }
|
|
769
822
|
/* signed-out viewer: one clear button in place of the composer */
|
|
770
823
|
.cm-signin-cta { width: 100%; height: 40px; border: 0; border-radius: 999px;
|
|
771
|
-
background: var(--comment); color: #fff; cursor: pointer;
|
|
824
|
+
background: var(--cm-solid, var(--comment)); color: #fff; cursor: pointer;
|
|
772
825
|
font: 600 13px -apple-system, system-ui, sans-serif; transition: filter .15s }
|
|
773
826
|
.cm-signin-cta:hover { filter: brightness(1.06) }
|
|
774
827
|
/* the draft composer is JUST the pill - no card chrome around a single input */
|
|
@@ -14,6 +14,7 @@ import { Component, createElement, useEffect, useRef, useState, type ComponentTy
|
|
|
14
14
|
import { flushSync } from 'react-dom'
|
|
15
15
|
import { createRoot } from 'react-dom/client'
|
|
16
16
|
import { frameFile, frames, layoutChain, layouts, providers } from '../frame-host/registry.ts'
|
|
17
|
+
import { createInspect } from '../frame-host/inspect.js'
|
|
17
18
|
|
|
18
19
|
const params = new URLSearchParams(location.search)
|
|
19
20
|
// Both signals, always - same pair as the frame host: [data-theme] for attribute-keyed
|
|
@@ -24,7 +25,9 @@ document.documentElement.dataset.theme = bootTheme
|
|
|
24
25
|
document.documentElement.classList.toggle('dark', bootTheme === 'dark')
|
|
25
26
|
const startId = params.get('at') ?? ''
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
// same-origin parent (the shell): fixed target origin so anchor bundles never leak if a
|
|
29
|
+
// data-goto ever navigates the stage cross-origin
|
|
30
|
+
const post = (msg: Record<string, unknown>) => { if (window.parent !== window) window.parent.postMessage(msg, location.origin) }
|
|
28
31
|
|
|
29
32
|
window.addEventListener('error', (e) => post({ type: 'sh:stage-error', message: String(e.message || e.error) }))
|
|
30
33
|
window.addEventListener('unhandledrejection', (e) => post({ type: 'sh:stage-error', message: `unhandled rejection: ${e.reason}` }))
|
|
@@ -77,6 +80,7 @@ function Stage() {
|
|
|
77
80
|
const [err, setErr] = useState<{ id: string; message: string } | null>(null)
|
|
78
81
|
const current = useRef(startId)
|
|
79
82
|
const swapSeq = useRef(0)
|
|
83
|
+
const readySent = useRef(false)
|
|
80
84
|
|
|
81
85
|
/** Swap to a frame. `announce` posts sh:stage-at (user-driven); history restores stay silent. */
|
|
82
86
|
const goto = async (id: string, announce: boolean) => {
|
|
@@ -85,10 +89,13 @@ function Stage() {
|
|
|
85
89
|
try {
|
|
86
90
|
const next = await resolve(id)
|
|
87
91
|
if (seq !== swapSeq.current) return // a newer swap superseded this one
|
|
88
|
-
current.current = id
|
|
89
92
|
// startViewTransition runs its callback async - recheck the seq there too, or an
|
|
90
|
-
// older pending transition could commit stale state over a newer navigation
|
|
91
|
-
|
|
93
|
+
// older pending transition could commit stale state over a newer navigation.
|
|
94
|
+
// current.current is advanced INSIDE apply (once the new DOM is committed), so
|
|
95
|
+
// inspect.getId() never reports the new frame while the old DOM is still live -
|
|
96
|
+
// a pick / anchor-resolve landing mid-swap then carries the old id and the shell
|
|
97
|
+
// guards drop it instead of stamping it onto the wrong frame.
|
|
98
|
+
const apply = () => { if (seq === swapSeq.current) { flushSync(() => { setErr(null); setMounted(next) }); current.current = id } }
|
|
92
99
|
if (document.startViewTransition) document.startViewTransition(apply)
|
|
93
100
|
else { apply(); document.getElementById('root')?.animate([{ opacity: 0.35 }, { opacity: 1 }], { duration: 180, easing: 'ease-out' }) }
|
|
94
101
|
if (announce) post({ type: 'sh:stage-at', at: id })
|
|
@@ -101,13 +108,27 @@ function Stage() {
|
|
|
101
108
|
}
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
// sh:stage-ready fires ONCE, after the first frame actually commits to the DOM (not when
|
|
112
|
+
// goto is merely scheduled) - the shell re-drives the review highlight on it, which must
|
|
113
|
+
// resolve against a live document
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (mounted && !readySent.current) { readySent.current = true; post({ type: 'sh:stage-ready', at: current.current }) }
|
|
116
|
+
}, [mounted])
|
|
117
|
+
|
|
104
118
|
useEffect(() => {
|
|
105
119
|
goto(startId, false)
|
|
106
|
-
|
|
120
|
+
|
|
121
|
+
// laser / comment-pick / anchor / highlight - the SAME controller the canvas
|
|
122
|
+
// frames run, so review works identically in the prototype. getId reports the
|
|
123
|
+
// CURRENT frame (the stage swaps in place). It self-installs its own listeners;
|
|
124
|
+
// we only borrow modeActive() to keep a review click from ALSO navigating.
|
|
125
|
+
const inspect = createInspect({ post, getId: () => current.current })
|
|
107
126
|
|
|
108
127
|
// data-goto is handled HERE, in place - never posted up as sh:go (capture phase
|
|
109
|
-
// beats any frame handler; preventDefault stops real <a href> navigations)
|
|
128
|
+
// beats any frame handler; preventDefault stops real <a href> navigations). A
|
|
129
|
+
// laser/comment click owns the press instead - it must not navigate.
|
|
110
130
|
const onClick = (e: MouseEvent) => {
|
|
131
|
+
if (inspect.modeActive()) return
|
|
111
132
|
const el = e.target instanceof Element ? e.target.closest('[data-goto]') : null
|
|
112
133
|
if (!el) return
|
|
113
134
|
e.preventDefault()
|
|
@@ -118,19 +139,21 @@ function Stage() {
|
|
|
118
139
|
document.addEventListener('click', onClick, true)
|
|
119
140
|
|
|
120
141
|
const onKey = (e: KeyboardEvent) => {
|
|
121
|
-
// Escape always exits, even mid-typing (matches the canvas bridge)
|
|
122
|
-
if (e.key === 'Escape') { post({ type: 'sh:stage-exit' }); return }
|
|
123
142
|
if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); post({ type: 'sh:stage-key', key: '/', code: e.code, meta: true }); return }
|
|
124
143
|
if (e.metaKey || e.ctrlKey) return // ⌘D is the browser's bookmark, not our theme
|
|
144
|
+
// Escape always reaches the shell, even mid-typing: it decides (cancel a
|
|
145
|
+
// comment/laser first, else exit) - the stage can't see review mode
|
|
146
|
+
if (e.key === 'Escape') { post({ type: 'sh:stage-key', key: 'Escape', code: e.code }); return }
|
|
125
147
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
|
126
|
-
// every play shortcut belongs to the shell (it owns walk order + chrome) - forward
|
|
127
|
-
|
|
148
|
+
// every play shortcut belongs to the shell (it owns walk order + chrome) - forward.
|
|
149
|
+
// l/c toggle laser/comment, C (shift+c) hides pins; the shell acts and broadcasts back.
|
|
150
|
+
if (/^Digit[0-9]$/.test(e.code) || ['d', 'h', 'r', 'l', 'c', 'C', '[', ']', 'ArrowRight', 'ArrowLeft'].includes(e.key))
|
|
128
151
|
post({ type: 'sh:stage-key', key: e.key, code: e.code })
|
|
129
152
|
}
|
|
130
153
|
window.addEventListener('keydown', onKey)
|
|
131
154
|
|
|
132
155
|
const onMsg = (e: MessageEvent) => {
|
|
133
|
-
if (e.source !== window.parent) return
|
|
156
|
+
if (e.source !== window.parent || (e.origin && e.origin !== location.origin)) return
|
|
134
157
|
const data = e.data
|
|
135
158
|
if (data?.type === 'sh:set-theme') {
|
|
136
159
|
document.documentElement.dataset.theme = data.theme
|
|
@@ -144,6 +167,7 @@ function Stage() {
|
|
|
144
167
|
document.removeEventListener('click', onClick, true)
|
|
145
168
|
window.removeEventListener('keydown', onKey)
|
|
146
169
|
window.removeEventListener('message', onMsg)
|
|
170
|
+
inspect.dispose()
|
|
147
171
|
}
|
|
148
172
|
}, [])
|
|
149
173
|
|