@marver-design/marver 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/dist/{build-BZaPa2DS.mjs → build-B8z902TW.mjs} +2 -2
- package/dist/cli.mjs +5 -5
- package/dist/{collab-CXy8gqoz.mjs → collab-pLGzSvm5.mjs} +16 -5
- package/dist/{comments-odHzYdO3.mjs → comments-BrpC86Kf.mjs} +9 -15
- package/dist/{comments-Ba8mU600.mjs → comments-DSwMhdbZ.mjs} +2 -8
- package/dist/daemon-C-huGHAM.mjs +899 -0
- package/dist/{dev-DaPQ9xA5.mjs → dev-BYm9ebfN.mjs} +14 -3
- package/dist/events-BMtBvvgU.mjs +101 -0
- package/dist/{init-DsCUmlCW.mjs → init-DrLomSWq.mjs} +13 -1
- package/dist/ledger-wFvEIEGi.mjs +64 -0
- package/dist/{manifest-C8FODq2S.mjs → manifest-D3eaARf4.mjs} +16 -1
- package/dist/{plugin-wMY9lNf3.mjs → plugin-BVFuRfEo.mjs} +56 -40
- package/dist/profile-BkiWglVE.mjs +39 -0
- package/dist/{serve-D_KBK7Oy.mjs → serve-CwAfayJk.mjs} +3 -3
- package/dist/{sync-CkBk-tUk.mjs → sync-Ch4Bymb1.mjs} +4 -102
- package/package.json +1 -1
- package/src/client/frame-host/inspect.js +15 -2
- package/src/client/shell/App.tsx +91 -9
- package/src/client/shell/Comments.tsx +418 -68
- package/src/client/shell/Play.tsx +10 -6
- package/src/client/shell/Toolbar.tsx +14 -2
- package/src/client/shell/canvas/FrameNode.tsx +33 -6
- package/src/client/shell/comments-store.ts +47 -6
- package/src/client/shell/icons.tsx +4 -0
- package/src/client/shell/mentions.ts +18 -0
- package/src/client/shell/store.ts +30 -2
- package/src/client/shell/styles.css +294 -21
- package/src/shared/events.ts +22 -5
- package/templates/AGENTS-embedded.md +1 -0
- package/templates/AGENTS-studio.md +1 -0
- package/templates/instructions/configure.md +15 -0
- package/templates/instructions/jam.md +85 -0
- package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
|
@@ -5,14 +5,16 @@
|
|
|
5
5
|
* size via --sh-inv (the vbadge pattern) so zoom never shrinks them away.
|
|
6
6
|
*/
|
|
7
7
|
import { useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { createPortal } from 'react-dom'
|
|
8
9
|
import { avatarFallback, useComments } from './comments-store.ts'
|
|
9
10
|
import { useStore, type Node } from './store.ts'
|
|
10
11
|
import { canvasCtl } from './canvas/Canvas.tsx'
|
|
11
12
|
import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
|
|
12
|
-
import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, XIcon } from './icons.tsx'
|
|
13
|
+
import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
|
|
13
14
|
import { Tip } from './Tip.tsx'
|
|
15
|
+
import { parseMentions } from './mentions.ts'
|
|
14
16
|
import { ROUTE } from '../const.ts'
|
|
15
|
-
import type { Thread } from '../../shared/events.ts'
|
|
17
|
+
import type { AgentMeta, Thread } from '../../shared/events.ts'
|
|
16
18
|
|
|
17
19
|
/** Tint the pin / composer / thread card EDGES (border, outline, focus ring) in the anchored
|
|
18
20
|
* element's own laser hue (captured at pick as anchor.el.hue) via --cm-line/--cm-line-ring.
|
|
@@ -43,9 +45,27 @@ const rel = (ts: number) => {
|
|
|
43
45
|
return `${Math.round(m / 1440)}d`
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
/** The Marver mark avatar: accent-blue disc + the solid white parallelogram. One place, reused
|
|
49
|
+
* by the thread header, the pin/stack marker, and the notification pill. */
|
|
50
|
+
export function MarverAvatar({ size = 24 }: { size?: number }) {
|
|
51
|
+
// 0.5 keeps clear blue padding around the mark even in the marker, where a 2px white border
|
|
52
|
+
// shrinks the visible disc (a bigger mark looked nearly cut there).
|
|
53
|
+
return (
|
|
54
|
+
<span className="cm-avatar cm-marver" style={{ width: size, height: size }} aria-label="Marver">
|
|
55
|
+
<ParallelogramFillIcon size={Math.round(size * 0.5)} />
|
|
56
|
+
</span>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function Avatar({ author, size = 22 }: { author?: { email?: string; name?: string; avatar?: string; agent?: boolean }; size?: number }) {
|
|
61
|
+
const me = useComments((s) => s.me)
|
|
62
|
+
const local = useComments((s) => s.local)
|
|
63
|
+
if (author?.agent) return <MarverAvatar size={size} /> // Marver shows as its own participant
|
|
64
|
+
// dev: an account-less author IS the person at this keyboard - render the CURRENT identity,
|
|
65
|
+
// not the snapshot the event happened to store (old "Designer" events become you, live)
|
|
66
|
+
const a = local && me && !author?.email ? { ...author, ...me } : author
|
|
67
|
+
if (a?.avatar) return <img className="cm-avatar" src={a.avatar} width={size} height={size} alt="" />
|
|
68
|
+
const { initials, hue } = avatarFallback(a)
|
|
49
69
|
return (
|
|
50
70
|
<span className="cm-avatar" style={{ width: size, height: size, fontSize: size * 0.42, background: `hsl(${hue} 55% 45%)` }}>
|
|
51
71
|
{initials}
|
|
@@ -53,7 +73,7 @@ export function Avatar({ author, size = 22 }: { author?: { email?: string; name?
|
|
|
53
73
|
)
|
|
54
74
|
}
|
|
55
75
|
|
|
56
|
-
type Face = { email?: string; name?: string; avatar?: string }
|
|
76
|
+
type Face = { email?: string; name?: string; avatar?: string; agent?: boolean }
|
|
57
77
|
|
|
58
78
|
/** The signal a marker (pin or stack) carries: WHO took part and HOW MANY comments
|
|
59
79
|
* in total - root + every reply, across all the given threads. Unique participants
|
|
@@ -61,13 +81,20 @@ type Face = { email?: string; name?: string; avatar?: string }
|
|
|
61
81
|
function markerDigest(threads: Thread[]): { faces: Face[]; count: number } {
|
|
62
82
|
const seen = new Map<string, Face>()
|
|
63
83
|
let count = 0
|
|
64
|
-
// namespace the identity key so an email can never collide with someone's name,
|
|
65
|
-
//
|
|
66
|
-
|
|
84
|
+
// namespace the identity key so an email can never collide with someone's name, and normalize
|
|
85
|
+
// email case. An AGENT message is its own participant ("Marver"), NOT the owner who authored
|
|
86
|
+
// it - so the marker shows N + Marver. Every email-less human face collapses to ONE key: in
|
|
87
|
+
// dev those snapshots ("Designer", "You", nothing) are all the person at this keyboard, and
|
|
88
|
+
// Avatar renders them all as the live identity - two keys would show identical twin faces.
|
|
89
|
+
const add = (a: Face | undefined, agent?: boolean) => {
|
|
90
|
+
const face: Face | undefined = agent ? { agent: true } : a
|
|
91
|
+
const k = agent ? 'marver' : a?.email ? `e:${a.email.toLowerCase()}` : 'local'
|
|
92
|
+
if (face && !seen.has(k)) seen.set(k, face)
|
|
93
|
+
}
|
|
67
94
|
for (const t of threads) {
|
|
68
95
|
count += 1 + t.replies.length
|
|
69
|
-
add(t.author)
|
|
70
|
-
for (const r of t.replies) add(r.author)
|
|
96
|
+
add(t.author, t.agent)
|
|
97
|
+
for (const r of t.replies) add(r.author, r.agent)
|
|
71
98
|
}
|
|
72
99
|
return { faces: [...seen.values()], count }
|
|
73
100
|
}
|
|
@@ -87,6 +114,7 @@ export function MarkerFace({ threads, max = 3 }: { threads: Thread[]; max?: numb
|
|
|
87
114
|
/** Lives inside FrameNode's body, over the iframe. Owns anchor resolution for its frame. */
|
|
88
115
|
export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: string; iframe: React.RefObject<HTMLIFrameElement | null> }) {
|
|
89
116
|
const show = useComments((s) => s.show)
|
|
117
|
+
const showAnchor = useComments((s) => s.showAnchor)
|
|
90
118
|
const active = useComments((s) => s.active)
|
|
91
119
|
const draft = useComments((s) => s.draft)
|
|
92
120
|
// select the stable array, filter in render - a selector returning a fresh
|
|
@@ -128,15 +156,24 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
128
156
|
const win = iframe.current?.contentWindow
|
|
129
157
|
if (!win || node.status !== 'ready') return
|
|
130
158
|
// ONLY light an element while its thread is the active one (or a draft is composing on
|
|
131
|
-
// it) AND pins are shown
|
|
132
|
-
// stray outline never lingers
|
|
159
|
+
// it) AND pins are shown AND element focus is on (⇧L) - closing the thread, hiding
|
|
160
|
+
// comments (⇧C), or dimming focus clears it, so a stray outline never lingers.
|
|
133
161
|
const draftHere = draft?.nodeKey === node.key ? (draft.anchor as any) : null
|
|
134
162
|
const activeHere = active ? open.find((t) => t.id === active && (t.anchor as any)?.el) : undefined
|
|
135
|
-
const anchor = show ? (draftHere ?? (activeHere ? (activeHere.anchor as any) : null)) : null
|
|
163
|
+
const anchor = show && showAnchor ? (draftHere ?? (activeHere ? (activeHere.anchor as any) : null)) : null
|
|
136
164
|
win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: anchor ?? null }, location.origin)
|
|
137
|
-
// active/draft/show/activeShown are the triggers; `open` is read fresh from the closure
|
|
138
|
-
}, [active, draft, show, activeShown, node.status, node.key, frameId, iframe])
|
|
165
|
+
// active/draft/show/showAnchor/activeShown are the triggers; `open` is read fresh from the closure
|
|
166
|
+
}, [active, draft, show, showAnchor, activeShown, node.status, node.key, frameId, iframe])
|
|
139
167
|
|
|
168
|
+
// the open card's frozen side (D3) - a HOOK, so it must live above the early returns; and the
|
|
169
|
+
// 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 (Codex P2)
|
|
171
|
+
const sideRef = useRef<{ id: string; side: 'l' | 'r' } | null>(null)
|
|
172
|
+
if (sideRef.current && sideRef.current.id !== active) sideRef.current = null
|
|
173
|
+
// what occupies this frame's LEFT flank (drives the docked card's left gutter): a variant
|
|
174
|
+
// badge is widest, the working shimmer next. Reactive, so a job starting mid-read adjusts.
|
|
175
|
+
const flankBadge = useStore((s) => !!s.frameFor(node)?.variantGroup)
|
|
176
|
+
const flankShim = useStore((s) => s.working.includes(frameId))
|
|
140
177
|
const drafting = draft?.nodeKey === node.key
|
|
141
178
|
if (!show || (!open.length && !drafting)) return null
|
|
142
179
|
|
|
@@ -159,12 +196,38 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
159
196
|
return { x: (p?.fx ?? 0.94) * node.w, y: (p?.fy ?? 0.06) * node.h, orphan: false }
|
|
160
197
|
}
|
|
161
198
|
|
|
199
|
+
// The card's SIDE, chosen ONCE per open and frozen (D3 - never switches mid-read): the pin's
|
|
200
|
+
// side of the frame first, skipping a side another frame's screen rect occupies, then wherever
|
|
201
|
+
// the viewport has room. The active pin's teardrop tail flips toward this side.
|
|
202
|
+
const activeThread2 = active ? open.find((t) => t.id === active) : undefined
|
|
203
|
+
if (activeThread2 && sideRef.current?.id !== activeThread2.id) {
|
|
204
|
+
const compute = (): 'l' | 'r' => {
|
|
205
|
+
const W = 320, GAP = 30
|
|
206
|
+
const rect = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`)?.getBoundingClientRect()
|
|
207
|
+
if (!rect) return 'r'
|
|
208
|
+
const occupied = (s: 'l' | 'r') => {
|
|
209
|
+
const rx = s === 'r' ? rect.right + 10 : rect.left - 10 - W
|
|
210
|
+
return [...document.querySelectorAll('.sh-node')].some((n) => {
|
|
211
|
+
if (n.getAttribute('data-node') === node.key) return false
|
|
212
|
+
const r = n.getBoundingClientRect()
|
|
213
|
+
return r.left < rx + W && r.right > rx && r.top < rect.bottom && r.bottom > rect.top
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
const room = (s: 'l' | 'r') => (s === 'r' ? window.innerWidth - rect.right : rect.left) >= W + GAP
|
|
217
|
+
const prefer: ('l' | 'r')[] = pinPos(activeThread2).x < node.w / 2 ? ['l', 'r'] : ['r', 'l']
|
|
218
|
+
return prefer.find((s) => room(s) && !occupied(s)) ?? prefer.find(room) ?? prefer[0]
|
|
219
|
+
}
|
|
220
|
+
sideRef.current = { id: activeThread2.id, side: compute() }
|
|
221
|
+
}
|
|
222
|
+
const cardSide: 'l' | 'r' = sideRef.current?.side ?? 'r'
|
|
223
|
+
|
|
162
224
|
return (
|
|
163
225
|
<>
|
|
164
226
|
{open.map((t) => {
|
|
165
227
|
const { x, y, orphan } = pinPos(t)
|
|
228
|
+
const isActive = t.id === active
|
|
166
229
|
return (
|
|
167
|
-
<div key={t.id} className={`cm-pin sh-no-pan${
|
|
230
|
+
<div key={t.id} className={`cm-pin sh-no-pan${isActive ? ' on' : ''}${isActive && cardSide === 'r' ? ' tail-r' : ''}${orphan ? ' orphan' : ''}`}
|
|
168
231
|
style={{ left: x, top: y, ...hueVars(anchorHue(t.anchor)) }}
|
|
169
232
|
onPointerDown={(e) => e.stopPropagation()}
|
|
170
233
|
onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
|
|
@@ -173,8 +236,9 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
173
236
|
</div>
|
|
174
237
|
)
|
|
175
238
|
})}
|
|
176
|
-
{
|
|
177
|
-
<ThreadCard key={active} thread={
|
|
239
|
+
{activeThread2 && (
|
|
240
|
+
<ThreadCard key={active} thread={activeThread2} at={pinPos(activeThread2)} bounds={{ w: node.w, h: node.h }} nodeKey={node.key} side={cardSide}
|
|
241
|
+
flank={cardSide === 'l' ? (flankBadge ? 'badge' : flankShim ? 'shim' : null) : null} />
|
|
178
242
|
)}
|
|
179
243
|
{draft?.nodeKey === node.key && <DraftComposer at={{ x: ((draft.anchor as any)?.rect?.x ?? 0) + ((draft.anchor as any)?.pos?.fx ?? 0.5) * ((draft.anchor as any)?.rect?.w ?? 0), y: ((draft.anchor as any)?.rect?.y ?? 0) + ((draft.anchor as any)?.pos?.fy ?? 0.5) * ((draft.anchor as any)?.rect?.h ?? 0) }} bounds={{ w: node.w, h: node.h }} hue={anchorHue(draft.anchor)} />}
|
|
180
244
|
</>
|
|
@@ -183,24 +247,327 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
183
247
|
|
|
184
248
|
/** The open thread card - shared by canvas (bounds = node size, `at` in frame coords) and
|
|
185
249
|
* prototype (bounds = the on-screen stage size, `at` in screen coords). Geometry only. */
|
|
186
|
-
|
|
250
|
+
// ---- Live Jam: @marver rendering + Marver identity (SPEC-live-jam §1, §7) ----------------------
|
|
251
|
+
|
|
252
|
+
const AT_TIP = "Read like any other comment. Marver won't act on this unless the owner promotes it."
|
|
253
|
+
|
|
254
|
+
/** A comment body with @marver mentions styled: owner-authored → bold accent (a live trigger);
|
|
255
|
+
* anyone else's → plain + a teaching tooltip (context, not a command). */
|
|
256
|
+
function CommentBody({ body, owner }: { body?: string; owner: boolean }) {
|
|
257
|
+
if (!body) return null
|
|
258
|
+
return (
|
|
259
|
+
<p className="cm-body">
|
|
260
|
+
{parseMentions(body).map((s, i) => !s.mention
|
|
261
|
+
? <span key={i}>{s.text}</span>
|
|
262
|
+
: owner
|
|
263
|
+
? <span key={i} className="cm-at owner">{s.text}</span>
|
|
264
|
+
: <Tip key={i} side="top" label={<span className="cm-at-tip">{AT_TIP}</span>}><span className="cm-at">{s.text}</span></Tip>)}
|
|
265
|
+
</p>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const HARNESS: Record<string, string> = { claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', opencode: 'OpenCode', droid: 'Factory Droid' }
|
|
270
|
+
/** claude-opus-5 → Opus 5; gpt-5.1-codex → Gpt 5.1 Codex; unknown → as-is. */
|
|
271
|
+
function prettyModel(m: string): string {
|
|
272
|
+
const cleaned = m.replace(/^claude-/, '').replace(/-/g, ' ')
|
|
273
|
+
return cleaned.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The provenance tooltip on the Marver avatar: who orchestrated the change (SPEC §7).
|
|
277
|
+
* One row per fact, left-aligned, bold label + regular value - the house tooltip treatment. */
|
|
278
|
+
function AgentMetaTip({ meta }: { meta?: AgentMeta }) {
|
|
279
|
+
const rows = [
|
|
280
|
+
meta?.devUser && ['Dev user', meta.devUser],
|
|
281
|
+
meta?.harness && ['Harness', HARNESS[meta.harness] ?? meta.harness],
|
|
282
|
+
meta?.model && ['Model', prettyModel(meta.model)],
|
|
283
|
+
meta?.effort && ['Effort', meta.effort],
|
|
284
|
+
].filter(Boolean) as [string, string][]
|
|
285
|
+
if (!rows.length) return <b>Marver</b>
|
|
286
|
+
// Toolbar-tooltip treatment: the VALUE is bold + bright (what you scan), the label is a muted
|
|
287
|
+
// qualifier after it - like "Hide all UI press H to reveal".
|
|
288
|
+
return (
|
|
289
|
+
<span className="cm-meta-tip">
|
|
290
|
+
{rows.map(([label, value]) => <span key={label} className="cm-meta-row"><b>{value}</b><span className="k">{label}</span></span>)}
|
|
291
|
+
</span>
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** A message header. Agent messages render as "Marver" with the mark + provenance tooltip;
|
|
296
|
+
* human messages keep their avatar + name. While the dev profile is still UNSET, every own
|
|
297
|
+
* message avatar invites editing on hover (discoverability); once set, only the composer's
|
|
298
|
+
* avatar is the door. */
|
|
299
|
+
function MessageHead({ author, agent, agentMeta, ts }: { author?: Thread['author']; agent?: boolean; agentMeta?: AgentMeta; ts: number }) {
|
|
300
|
+
const me = useComments((s) => s.me)
|
|
301
|
+
const local = useComments((s) => s.local)
|
|
302
|
+
const connected = useComments((s) => s.connected)
|
|
303
|
+
if (agent) return (
|
|
304
|
+
<header>
|
|
305
|
+
<Tip side="top" label={<AgentMetaTip meta={agentMeta} />}>
|
|
306
|
+
<span className="cm-marver-wrap"><MarverAvatar size={24} /></span>
|
|
307
|
+
</Tip>
|
|
308
|
+
<b className="cm-marver-name">Marver</b>
|
|
309
|
+
<span className="dim">{rel(ts)}</span>
|
|
310
|
+
</header>
|
|
311
|
+
)
|
|
312
|
+
// own = born on this machine without an account (no email); unset = identity still the default.
|
|
313
|
+
// Own messages render the CURRENT identity, not the stored snapshot - same rule as Avatar.
|
|
314
|
+
const unset = local && !connected && (!me?.name || me.name === 'You')
|
|
315
|
+
const own = local && !author?.email
|
|
316
|
+
const shown = own && me ? { ...author, ...me } : author
|
|
317
|
+
return (
|
|
318
|
+
<header>
|
|
319
|
+
{unset && own ? <EditableAvatar author={author} size={24} /> : <Avatar author={author} size={24} />}
|
|
320
|
+
<b>{shown?.name ?? 'Someone'}</b>
|
|
321
|
+
<span className="dim">{rel(ts)}</span>
|
|
322
|
+
</header>
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** An auto-growing composer with the full keybindings: Enter send, Shift+Enter newline,
|
|
327
|
+
* Cmd/Ctrl+Enter send, Escape cancel; IME-guarded, disabled while a send is in flight. */
|
|
328
|
+
function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFocus, sendLabel, owner }: {
|
|
329
|
+
value: string; onChange: (v: string) => void; onSubmit: () => void | Promise<unknown>
|
|
330
|
+
onCancel?: () => void; placeholder: string; autoFocus?: boolean; sendLabel: string; owner: boolean
|
|
331
|
+
}) {
|
|
332
|
+
const ref = useRef<HTMLTextAreaElement>(null)
|
|
333
|
+
const hlRef = useRef<HTMLDivElement>(null)
|
|
334
|
+
const [busy, setBusy] = useState(false)
|
|
335
|
+
// +2: scrollHeight excludes the 1px borders, but border-box height includes them - without
|
|
336
|
+
// it every line is squeezed 2px and the single-line pill renders off-center
|
|
337
|
+
const grow = () => { const el = ref.current; if (el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight + 2, 132) + 'px' } }
|
|
338
|
+
const syncScroll = () => { if (hlRef.current && ref.current) hlRef.current.scrollTop = ref.current.scrollTop }
|
|
339
|
+
useEffect(() => { if (autoFocus) ref.current?.focus() }, [autoFocus])
|
|
340
|
+
useEffect(grow, [value])
|
|
341
|
+
const submit = async () => {
|
|
342
|
+
if (busy || !value.trim()) return
|
|
343
|
+
setBusy(true)
|
|
344
|
+
try { await onSubmit() } finally { setBusy(false) }
|
|
345
|
+
}
|
|
346
|
+
return (
|
|
347
|
+
<div className="cm-inputwrap">
|
|
348
|
+
{/* 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). SPEC §1. */}
|
|
350
|
+
<div className="cm-hl" ref={hlRef} aria-hidden>
|
|
351
|
+
{parseMentions(value).map((s, i) => s.mention
|
|
352
|
+
? <span key={i} className={owner ? 'cm-at owner' : 'cm-at'}>{s.text}</span>
|
|
353
|
+
: <span key={i}>{s.text}</span>)}
|
|
354
|
+
{value.endsWith('\n') ? '' : ''}
|
|
355
|
+
</div>
|
|
356
|
+
<textarea ref={ref} rows={1} value={value} placeholder={placeholder} disabled={busy} spellCheck={false}
|
|
357
|
+
onChange={(e) => onChange(e.target.value)} onInput={grow} onScroll={syncScroll}
|
|
358
|
+
onKeyDown={(e) => {
|
|
359
|
+
e.stopPropagation()
|
|
360
|
+
if (e.key === 'Escape') { onCancel?.(); return }
|
|
361
|
+
if (e.key === 'Enter') {
|
|
362
|
+
if ((e.nativeEvent as unknown as { isComposing?: boolean }).isComposing) return // IME: mid-composition Enter must not send
|
|
363
|
+
if (e.shiftKey) return // newline
|
|
364
|
+
e.preventDefault(); void submit() // Enter / Cmd·Ctrl+Enter send
|
|
365
|
+
}
|
|
366
|
+
}} />
|
|
367
|
+
<Tip side="top" label={<b>⏎ send · ⇧⏎ new line</b>}>
|
|
368
|
+
<button className="cm-send" disabled={busy || !value.trim()} aria-label={sendLabel} onClick={() => void submit()}>
|
|
369
|
+
<ArrowUpIcon size={15} />
|
|
370
|
+
</button>
|
|
371
|
+
</Tip>
|
|
372
|
+
</div>
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The dev identity: a full-screen modal (the IdentityDialog treatment - same classes, same
|
|
377
|
+
* weight) with display name + photo, saved to design/.local/profile.json (this machine only).
|
|
378
|
+
* When a connect account provides name/email (the published server validates authors), the name
|
|
379
|
+
* is read-only here and only the photo is editable. Portaled to body: the thread card is
|
|
380
|
+
* transformed + overflow-hidden, so anything anchored inside it clips. */
|
|
381
|
+
function ProfileDialog({ onClose }: { onClose: () => void }) {
|
|
382
|
+
const me = useComments((s) => s.me)
|
|
383
|
+
const connected = useComments((s) => s.connected)
|
|
384
|
+
const unset = !connected && (!me?.name || me.name === 'You')
|
|
385
|
+
const [name, setName] = useState(unset ? '' : me?.name ?? '')
|
|
386
|
+
const [avatar, setAvatar] = useState(me?.avatar ?? '')
|
|
387
|
+
const [busy, setBusy] = useState(false)
|
|
388
|
+
const [err, setErr] = useState<string | null>(null)
|
|
389
|
+
const ready = connected || !!name.trim()
|
|
390
|
+
const save = async () => {
|
|
391
|
+
if (busy || !ready) return
|
|
392
|
+
setBusy(true)
|
|
393
|
+
try {
|
|
394
|
+
const e = await useComments.getState().saveProfile({ ...(connected ? {} : { name: name.trim() }), avatar })
|
|
395
|
+
if (e) return setErr(e) // a failed save must not close as if it worked
|
|
396
|
+
onClose()
|
|
397
|
+
} finally { setBusy(false) }
|
|
398
|
+
}
|
|
399
|
+
return createPortal(
|
|
400
|
+
<div className="cm-modal-wrap" onClick={onClose} onPointerDown={(e) => e.stopPropagation()}>
|
|
401
|
+
<div className="cm-modal" onClick={(e) => e.stopPropagation()}
|
|
402
|
+
onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Escape') onClose() }}>
|
|
403
|
+
<h2>{unset ? 'Set up your profile' : 'Edit your profile'}</h2>
|
|
404
|
+
<p className="dim">
|
|
405
|
+
{connected
|
|
406
|
+
? <>Your name comes from your connect account. The photo stays on this machine.</>
|
|
407
|
+
: <>Comments carry your name and photo. Saved to design/.local on this machine - yours, nowhere else.</>}
|
|
408
|
+
</p>
|
|
409
|
+
<div className="cm-fields">
|
|
410
|
+
<div className="cm-idrow">
|
|
411
|
+
<AvatarPick value={avatar} onPick={setAvatar} />
|
|
412
|
+
{connected
|
|
413
|
+
? <div className="cm-chip" style={{ flex: 1 }}><b>{me?.name}</b><span>CONNECT</span></div>
|
|
414
|
+
: <input placeholder="Set a display name" style={{ flex: 1 }} value={name}
|
|
415
|
+
onChange={(e) => setName(e.target.value)}
|
|
416
|
+
onKeyDown={(e) => e.key === 'Enter' && void save()} />}
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
{err && <span className="cm-err">{err}</span>}
|
|
420
|
+
<div className="cm-row">
|
|
421
|
+
<button className="cm-primary" disabled={busy || !ready} onClick={() => void save()}>Save</button>
|
|
422
|
+
<button onClick={onClose}>Not now</button>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
</div>,
|
|
426
|
+
// into .sh-app, NOT body: every theme token (--cm-modal-bg, inks) lives on .sh-app,
|
|
427
|
+
// and a body-portaled modal renders transparent outside that scope
|
|
428
|
+
document.querySelector('.sh-app') ?? document.body)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** An avatar that IS the door to your profile: hover morphs it into a pen on gray, click opens
|
|
432
|
+
* the modal. The composer's own avatar is always this door (dev only); while the profile is
|
|
433
|
+
* still unset, every own message avatar is too - discoverability until identity is real. */
|
|
434
|
+
function EditableAvatar({ author, size = 24 }: { author?: Thread['author']; size?: number }) {
|
|
435
|
+
const [open, setOpen] = useState(false)
|
|
436
|
+
return (
|
|
437
|
+
<span className="cm-me">
|
|
438
|
+
<Tip side="top" label={<b>Edit your profile</b>}>
|
|
439
|
+
<button className="cm-mebtn" aria-label="Edit your profile" onClick={() => setOpen(true)}>
|
|
440
|
+
<Avatar author={author} size={size} />
|
|
441
|
+
<span className="cm-pen-face"><PencilSimpleIcon size={Math.round(size * 0.55)} /></span>
|
|
442
|
+
</button>
|
|
443
|
+
</Tip>
|
|
444
|
+
{open && <ProfileDialog onClose={() => setOpen(false)} />}
|
|
445
|
+
</span>
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The composer's own avatar (dev only - published viewers already have accounts). */
|
|
450
|
+
function ComposeAvatar() {
|
|
451
|
+
const me = useComments((s) => s.me)
|
|
452
|
+
const local = useComments((s) => s.local)
|
|
453
|
+
if (!local) return <Avatar author={me ?? undefined} size={24} />
|
|
454
|
+
return <EditableAvatar author={me ?? undefined} size={24} />
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function ThreadCard({ thread, at, bounds, nodeKey, side = 'r', flank, stage }: { thread: Thread; at: { x: number; y: number }; bounds: { w: number; h: number }; nodeKey?: string; side?: 'l' | 'r'; flank?: 'badge' | 'shim' | null; stage?: boolean }) {
|
|
187
458
|
const { resolve, setActive } = useComments.getState()
|
|
188
459
|
const me = useComments((s) => s.me)
|
|
189
460
|
const local = useComments((s) => s.local)
|
|
190
461
|
const canComment = local || !!me // dev is always "me"; published needs a session
|
|
462
|
+
// Owner-authored @marver is a live trigger (bold accent); anyone else's is context (plain + tooltip).
|
|
463
|
+
// Match by email when we know the owner's; in dev without a profile, local writes are the owner.
|
|
464
|
+
const isOwner = (a?: { email?: string }) => (me?.email ? a?.email === me.email : local)
|
|
191
465
|
const [text, setText] = useState('')
|
|
192
466
|
const [copied, setCopied] = useState(false)
|
|
193
467
|
const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
|
194
468
|
useEffect(() => () => clearTimeout(copyTimer.current), [])
|
|
195
|
-
|
|
469
|
+
// Canvas: the card is a WORLD OBJECT parked beside its frame - it stays put (panning moves
|
|
470
|
+
// frame + card together, never following the viewport). Zoom scales it with a CLAMP
|
|
471
|
+
// (0.9..1.35 of screen-constant), so it reads steady at working zooms yet never dwarfs the
|
|
472
|
+
// frame far out or shrinks away deep in. Height rules: capped at the frame's height; grows
|
|
473
|
+
// DOWNWARD from the pin until it reaches the frame's bottom, then grows UP; past the cap the
|
|
474
|
+
// messages scroll inside. The SIDE is chosen once at open (D3). Play keeps in-stage placement.
|
|
475
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
476
|
+
const cardRef = useRef<HTMLDivElement>(null)
|
|
477
|
+
const float = !!nodeKey
|
|
478
|
+
const [cardH, setCardH] = useState(320) // measured layout px (pre-transform)
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
if (float && cardRef.current) {
|
|
481
|
+
const h = cardRef.current.offsetHeight
|
|
482
|
+
setCardH((c) => (Math.abs(c - h) > 2 ? h : c))
|
|
483
|
+
}
|
|
484
|
+
})
|
|
485
|
+
// scroll shadows: "there is more" above/below - recomputed on scroll + content growth
|
|
486
|
+
const [shadows, setShadows] = useState({ top: false, bot: false, can: false })
|
|
487
|
+
const syncShadows = () => {
|
|
488
|
+
const el = scrollRef.current
|
|
489
|
+
if (!el) return
|
|
490
|
+
// gate on MEANINGFUL overflow - a thread that overflows by a few px must not smear a
|
|
491
|
+
// 30px fade over its last line ("looks scrollable" when it isn't, really)
|
|
492
|
+
const can = el.scrollHeight - el.clientHeight > 12
|
|
493
|
+
const top = can && el.scrollTop > 4
|
|
494
|
+
const bot = can && el.scrollTop + el.clientHeight < el.scrollHeight - 4
|
|
495
|
+
setShadows((s) => (s.top === top && s.bot === bot && s.can === can ? s : { top, bot, can }))
|
|
496
|
+
}
|
|
497
|
+
// open at the LATEST message (what you came to read); follow new replies while open
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
const el = scrollRef.current
|
|
500
|
+
if (el) el.scrollTop = el.scrollHeight
|
|
501
|
+
syncShadows()
|
|
502
|
+
}, [thread.replies.length])
|
|
503
|
+
// ...and when the scroller RESIZES without a scroll event (zoom changing the height cap, the
|
|
504
|
+
// composer growing) - a stale fade would otherwise claim "more below" at the bottom of a
|
|
505
|
+
// thread that no longer overflows
|
|
506
|
+
useEffect(() => {
|
|
507
|
+
const el = scrollRef.current
|
|
508
|
+
if (!el) return
|
|
509
|
+
const ro = new ResizeObserver(syncShadows)
|
|
510
|
+
ro.observe(el)
|
|
511
|
+
return () => ro.disconnect()
|
|
512
|
+
}, [])
|
|
513
|
+
// ...and after EVERY render: an edited message changes scrollHeight without resizing the
|
|
514
|
+
// height-capped box or the reply count (Codex P3). setShadows dedups, so this is free.
|
|
515
|
+
useEffect(syncShadows)
|
|
516
|
+
// PLAY: one static centered device, no zoom - the card docks FIXED beside it on the pin's side
|
|
517
|
+
// (falling back to the roomier side), fully viewport-clamped so the composer is ALWAYS reachable
|
|
518
|
+
// whatever the device layout or theme (SPEC §14). Recomputed when the stage moves under it:
|
|
519
|
+
// a device/theme switch resizes .sh-play-stage and a window resize shifts it (Codex P2).
|
|
520
|
+
const computeStagePos = (): React.CSSProperties | null => {
|
|
521
|
+
if (!stage) return null
|
|
522
|
+
const W = 320, M = 12, maxH = Math.min(660, window.innerHeight - 2 * M)
|
|
523
|
+
const rect = document.querySelector('.sh-play-stage')?.getBoundingClientRect()
|
|
524
|
+
if (!rect) return { position: 'fixed', left: M, top: 72, maxHeight: maxH }
|
|
525
|
+
const roomR = window.innerWidth - rect.right >= W + 30
|
|
526
|
+
const roomL = rect.left >= W + 30
|
|
527
|
+
const s: 'l' | 'r' = at.x < bounds.w / 2 ? (roomL ? 'l' : 'r') : (roomR ? 'r' : 'l')
|
|
528
|
+
const x = Math.min(Math.max(s === 'r' ? rect.right + 18 : rect.left - 18 - W, M), window.innerWidth - W - M)
|
|
529
|
+
// worst-case clamp (content = maxH): even a full-height thread keeps its composer on screen
|
|
530
|
+
const y = Math.min(Math.max(rect.top + at.y - 36, M), Math.max(M, window.innerHeight - maxH - M))
|
|
531
|
+
return { position: 'fixed', left: x, top: y, maxHeight: maxH }
|
|
532
|
+
}
|
|
533
|
+
const [stagePos, setStagePos] = useState<React.CSSProperties | null>(computeStagePos)
|
|
534
|
+
useEffect(() => {
|
|
535
|
+
if (!stage) return
|
|
536
|
+
const re = () => setStagePos(computeStagePos())
|
|
537
|
+
re() // bounds changed (device/theme switch) -> re-dock
|
|
538
|
+
window.addEventListener('resize', re)
|
|
539
|
+
const el = document.querySelector('.sh-play-stage')
|
|
540
|
+
const ro = el ? new ResizeObserver(re) : null
|
|
541
|
+
if (el && ro) ro.observe(el)
|
|
542
|
+
return () => { window.removeEventListener('resize', re); ro?.disconnect() }
|
|
543
|
+
}, [stage, bounds.w, bounds.h])
|
|
544
|
+
const flip = !float && !stage && at.x > bounds.w * 0.55
|
|
545
|
+
// Nic's revision: the card scales EXACTLY like the pins - screen-constant via --sh-inv, pure
|
|
546
|
+
// CSS, smooth per zoom tick with zero re-renders. Geometry in CSS math over the live vars:
|
|
547
|
+
// top: grows DOWN from the pin, then UP once its bottom reaches the frame's bottom; floor
|
|
548
|
+
// at -28 (the layer starts below the header - -28 = the frame's TOP border).
|
|
549
|
+
// maxHeight: the visual height never exceeds the WHOLE frame (body + 28px header).
|
|
550
|
+
// Gutters live in CSS (dock margins, screen-constant; wider on the left for the shimmer).
|
|
551
|
+
const pos: React.CSSProperties = stage && stagePos
|
|
552
|
+
? stagePos
|
|
553
|
+
: float
|
|
554
|
+
? {
|
|
555
|
+
left: side === 'r' ? bounds.w : 0,
|
|
556
|
+
top: `max(-28px, min(${Math.round(at.y)}px - 36px * var(--sh-inv, 1), ${bounds.h}px - ${cardH}px * var(--sh-inv, 1)))`,
|
|
557
|
+
// capped at the whole frame's on-screen height, with a REAL readability floor: zoomed way
|
|
558
|
+
// out the card still shows a meaningful slice of conversation (~3 messages), even if that
|
|
559
|
+
// makes it taller than the tiny frame
|
|
560
|
+
maxHeight: `max(320px, calc(${bounds.h + 28}px * var(--sh-s, 1)))`,
|
|
561
|
+
}
|
|
562
|
+
: { left: at.x, top: Math.min(at.y + 14, bounds.h - 40) }
|
|
196
563
|
// clear only after the server took it - a failed send must not eat the words,
|
|
197
564
|
// and text typed WHILE the request was in flight must survive the clear
|
|
198
565
|
const submit = async () => {
|
|
199
566
|
const sent = text
|
|
200
567
|
if (sent.trim() && await useComments.getState().replyOk(thread.id, sent)) setText((cur) => (cur === sent ? '' : cur))
|
|
201
568
|
}
|
|
202
|
-
|
|
203
|
-
<div data-sh-wheel-local className={`cm-card sh-no-pan${flip ? ' flip' : ''}`}
|
|
569
|
+
const card = (
|
|
570
|
+
<div ref={cardRef} data-sh-wheel-local className={`cm-card sh-no-pan${flip ? ' flip' : ''}${float ? ` parked dock-${side}` : ''}${float && flank ? ` flank-${flank}` : ''}${stage ? ' stage-dock' : ''}`} style={{ ...pos, ...hueVars(anchorHue(thread.anchor)) }}
|
|
204
571
|
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
|
|
205
572
|
{/* thread-level actions pin to the card corner, out of the header's flow -
|
|
206
573
|
the name row never has to share its line with them */}
|
|
@@ -226,33 +593,23 @@ export function ThreadCard({ thread, at, bounds }: { thread: Thread; at: { x: nu
|
|
|
226
593
|
<button className="cm-icon" onClick={() => setActive(null)}><XIcon size={15} /></button>
|
|
227
594
|
</Tip>
|
|
228
595
|
</div>
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
</header>
|
|
243
|
-
<p className="cm-body">{r.body}</p>
|
|
244
|
-
</div>
|
|
245
|
-
))}
|
|
596
|
+
{/* "there is more": the scroller MASKS its own content at the overflowing edge - a soft fade
|
|
597
|
+
of the text itself, not a paint layer over the glass */}
|
|
598
|
+
<div className={`cm-scroll${shadows.top ? ' fade-top' : ''}${shadows.bot ? ' fade-bot' : ''}`} ref={scrollRef} onScroll={syncShadows}>
|
|
599
|
+
<MessageHead author={thread.author} agent={thread.agent} agentMeta={thread.agentMeta} ts={thread.ts} />
|
|
600
|
+
<CommentBody body={thread.body} owner={isOwner(thread.author)} />
|
|
601
|
+
{/* replies repeat the root's exact message shape (Figma's pattern) - only the icons differ */}
|
|
602
|
+
{thread.replies.map((r) => (
|
|
603
|
+
<div key={r.id} className="cm-msg">
|
|
604
|
+
<MessageHead author={r.author} agent={r.agent} agentMeta={r.agentMeta} ts={r.ts} />
|
|
605
|
+
<CommentBody body={r.body} owner={isOwner(r.author)} />
|
|
606
|
+
</div>
|
|
607
|
+
))}
|
|
608
|
+
</div>
|
|
246
609
|
{canComment ? (
|
|
247
610
|
<div className="cm-compose">
|
|
248
|
-
<
|
|
249
|
-
<
|
|
250
|
-
<input value={text} placeholder="Reply…" onChange={(e) => setText(e.target.value)}
|
|
251
|
-
onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') void submit(); if (e.key === 'Escape') setActive(null) }} />
|
|
252
|
-
<button className="cm-send" disabled={!text.trim()} aria-label="Send" onClick={() => void submit()}>
|
|
253
|
-
<ArrowUpIcon size={15} />
|
|
254
|
-
</button>
|
|
255
|
-
</div>
|
|
611
|
+
<ComposeAvatar />
|
|
612
|
+
<CommentInput value={text} onChange={setText} onSubmit={submit} onCancel={() => setActive(null)} placeholder="Reply…" sendLabel="Send" owner={local} />
|
|
256
613
|
</div>
|
|
257
614
|
) : (
|
|
258
615
|
<button className="cm-signin-cta" onClick={() => useComments.setState({ needsIdentity: true })}>
|
|
@@ -261,31 +618,22 @@ export function ThreadCard({ thread, at, bounds }: { thread: Thread; at: { x: nu
|
|
|
261
618
|
)}
|
|
262
619
|
</div>
|
|
263
620
|
)
|
|
621
|
+
return card
|
|
264
622
|
}
|
|
265
623
|
|
|
266
624
|
/** The draft composer - shared by canvas and prototype, geometry via `bounds`, tinted with
|
|
267
625
|
* the picked element's hue. */
|
|
268
626
|
export function DraftComposer({ at, bounds, hue }: { at: { x: number; y: number }; bounds: { w: number; h: number }; hue?: number }) {
|
|
269
627
|
const { create, setDraft } = useComments.getState()
|
|
628
|
+
const local = useComments((s) => s.local)
|
|
270
629
|
const [text, setText] = useState('')
|
|
271
|
-
const ref = useRef<HTMLInputElement>(null)
|
|
272
|
-
useEffect(() => { ref.current?.focus() }, [])
|
|
273
630
|
const flip = at.x > bounds.w * 0.55
|
|
274
631
|
return (
|
|
275
632
|
<div data-sh-wheel-local className={`cm-card cm-draft sh-no-pan${flip ? ' flip' : ''}`} style={{ left: at.x, top: Math.min(at.y + 14, bounds.h - 40), ...hueVars(hue) }}
|
|
276
633
|
onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
|
634
|
+
{/* the draft is JUST the tight pill - no avatar chrome on a first comment */}
|
|
277
635
|
<div className="cm-compose">
|
|
278
|
-
<
|
|
279
|
-
<input ref={ref} value={text} placeholder="Comment on this element…" onChange={(e) => setText(e.target.value)}
|
|
280
|
-
onKeyDown={(e) => {
|
|
281
|
-
e.stopPropagation()
|
|
282
|
-
if (e.key === 'Enter') void create(text)
|
|
283
|
-
if (e.key === 'Escape') setDraft(null)
|
|
284
|
-
}} />
|
|
285
|
-
<button className="cm-send" disabled={!text.trim()} aria-label="Comment" onClick={() => void create(text)}>
|
|
286
|
-
<ArrowUpIcon size={15} />
|
|
287
|
-
</button>
|
|
288
|
-
</div>
|
|
636
|
+
<CommentInput value={text} onChange={setText} onSubmit={() => create(text)} onCancel={() => setDraft(null)} placeholder="Comment on this element…" autoFocus sendLabel="Comment" owner={local} />
|
|
289
637
|
</div>
|
|
290
638
|
</div>
|
|
291
639
|
)
|
|
@@ -310,10 +658,11 @@ function AvatarPick({ value, onPick }: { value: string; onPick: (dataUri: string
|
|
|
310
658
|
}
|
|
311
659
|
return (
|
|
312
660
|
<>
|
|
313
|
-
<Tip side="top" label={<b>{value ? 'Change your photo' : 'Select
|
|
314
|
-
<button className={`cm-pfp${value ? ' set' : ''}`} aria-label="Select
|
|
315
|
-
|
|
316
|
-
|
|
661
|
+
<Tip side="top" label={<b>{value ? 'Change your photo' : 'Select or upload a profile picture'}</b>}>
|
|
662
|
+
<button className={`cm-pfp${value ? ' set' : ''}`} aria-label="Select or upload a profile picture"
|
|
663
|
+
onClick={() => fileRef.current?.click()}>
|
|
664
|
+
{value ? <img src={value} alt="" /> : <PlusIcon size={16} />}
|
|
665
|
+
</button>
|
|
317
666
|
</Tip>
|
|
318
667
|
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => pick(e.target.files?.[0])} />
|
|
319
668
|
</>
|
|
@@ -421,7 +770,7 @@ export function IdentityDialog() {
|
|
|
421
770
|
}
|
|
422
771
|
|
|
423
772
|
/** Open a thread by id: activate, select its node, fit the camera. True on success. */
|
|
424
|
-
function revealThread(c: string): boolean {
|
|
773
|
+
export function revealThread(c: string): boolean {
|
|
425
774
|
const t = useComments.getState().threads.find((t) => t.id === c)
|
|
426
775
|
if (!t) return false
|
|
427
776
|
useComments.getState().setActive(c)
|
|
@@ -437,6 +786,7 @@ function revealThread(c: string): boolean {
|
|
|
437
786
|
let bootThreadConsumed = false
|
|
438
787
|
let pendingThread: string | null = null // cross-board deep link awaiting its board's comments
|
|
439
788
|
export function CommentsController() {
|
|
789
|
+
const ctlShowAnchor = useComments((s) => s.showAnchor)
|
|
440
790
|
const board = useStore((s) => s.board)
|
|
441
791
|
const commentMode = useComments((s) => s.commentMode)
|
|
442
792
|
const active = useComments((s) => s.active)
|
|
@@ -502,8 +852,8 @@ export function CommentsController() {
|
|
|
502
852
|
// .sh-live so the SPEC-M5 lean cover (.sh-lean, a scriptless snapshot) is never messaged.
|
|
503
853
|
useEffect(() => {
|
|
504
854
|
for (const f of document.querySelectorAll('iframe.sh-live'))
|
|
505
|
-
(f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
|
|
506
|
-
}, [commentMode])
|
|
855
|
+
(f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode, quiet: !ctlShowAnchor }, location.origin)
|
|
856
|
+
}, [commentMode, ctlShowAnchor])
|
|
507
857
|
|
|
508
858
|
return <IdentityDialog />
|
|
509
859
|
}
|