@marver-design/marver 0.6.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.
@@ -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
+ }
@@ -81,7 +81,16 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
81
81
  if (node.status !== 'ready' || node.missing) return
82
82
  const iframe = iframeRef.current
83
83
  if (!iframe) return
84
- const t = setTimeout(() => scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme }), 450)
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)
85
94
  return () => clearTimeout(t)
86
95
  // node.theme IS a dep: baked content (mermaid SVG) can't be re-themed by the cover's attribute
87
96
  // flip, so a theme change re-captures after the live frame re-renders (key includes theme).
@@ -111,7 +120,22 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
111
120
  // neighbors - each node is a stacking context, so an overflowing card would
112
121
  // otherwise paint under the next frame
113
122
  const hostsCard = useComments((s) =>
114
- (!!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])
115
139
  useEffect(() => {
116
140
  if (node.status === 'ready' || !laser)
117
141
  iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
@@ -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" />)
@@ -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-menu { position: absolute; min-width: 136px; z-index: 20;
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
- const post = (msg: Record<string, unknown>) => { if (window.parent !== window) window.parent.postMessage(msg, '*') }
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
- const apply = () => { if (seq === swapSeq.current) flushSync(() => { setErr(null); setMounted(next) }) }
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
- post({ type: 'sh:stage-ready', at: startId })
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
- if (/^Digit[0-9]$/.test(e.code) || ['d', 'h', 'r', '[', ']', 'ArrowRight', 'ArrowLeft'].includes(e.key))
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