/**
* Comment surfaces on the canvas. Canvas-first: pins live ON the frame
* at their anchored elements; an inactive frame collapses its open threads into a
* top-right stack; the thread card opens beside its pin. Pins keep a screen-space
* size via --sh-inv (the vbadge pattern) so zoom never shrinks them away, and never
* overlap (pin-layout.ts places them).
*/
import { TOP } from './inset.ts'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { avatarFallback, useComments } from './comments-store.ts'
import { api } from './api.ts'
import { CONFIG, useStore, type Node } from './store.ts'
import { threadHostKey } from './keys.ts'
import { isNoteAnchor, resolveNoteAnchor, useNotes } from './notes.ts'
import { canvasCtl } from './canvas/ctl.ts'
import { MARKER, placeMarkers, type Placed } from './pin-layout.ts'
import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
import { isLegacyLink } from './legacy-links.ts'
import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
import { Tip } from './Tip.tsx'
import { TagStatus } from './jam-ui.tsx'
import { CaptureBlock } from './jam-panel.tsx'
import { useJamQueue } from './jam-queue.ts'
import { mentionAlerts, mentionPeople, mentionQueryAt, parseBody, type MentionPerson } from './mentions.ts'
import { slideSize, stageFit } from '../const.ts'
import type { AgentMeta, Thread } from '../../shared/events.ts'
/** Tint the pin / composer / thread card EDGES (border, outline, focus ring) in the anchored
* element's own laser hue (captured at pick as anchor.el.hue) via --cm-line/--cm-line-ring.
* Deliberately NOT the button fills (--comment stays green: white text on a saturated hue
* fails at yellow/green/cyan) and NOT the avatar (keeps its per-user colour). No hue -> the
* CSS falls back to the green token. */
export function hueVars(hue?: number): React.CSSProperties {
// guard NaN / Infinity from a bad persisted anchor - an invalid custom property would
// suppress the CSS fallback and leave the edge uncoloured
if (!Number.isFinite(hue)) return {}
const h = hue as number
// --cm-solid is the FILLED-button colour (send / sign-in): a hue-aware lightness so the
// white glyph keeps contrast - yellow/green/cyan read light, so they go darker.
const l = (h >= 40 && h < 75) ? 34 : (h >= 75 && h < 165) ? 36 : (h >= 165 && h < 200) ? 38 : 45
return {
'--cm-line': `hsl(${h} 95% 45%)`,
'--cm-line-ring': `hsl(${h} 95% 50% / .30)`,
'--cm-solid': `hsl(${h} 85% ${l}%)`,
} as React.CSSProperties
}
const anchorHue = (a: unknown): number | undefined => (a as any)?.el?.hue
const rel = (ts: number) => {
const m = Math.round((Date.now() - ts) / 60_000)
if (m < 1) return 'now'
if (m < 60) return `${m}m`
if (m < 24 * 60) return `${Math.round(m / 60)}h`
return `${Math.round(m / 1440)}d`
}
/** The Marver mark avatar: accent-blue disc + the solid white parallelogram. One place, reused
* by the thread header, the pin/stack marker, and the notification pill. */
export function MarverAvatar({ size = 24 }: { size?: number }) {
// 0.5 keeps clear blue padding around the mark even in the marker, where a 2px white border
// shrinks the visible disc (a bigger mark looked nearly cut there).
return (
)
}
export function Avatar({ author, size = 22 }: { author?: { email?: string; name?: string; avatar?: string; agent?: boolean }; size?: number }) {
const me = useComments((s) => s.me)
const local = useComments((s) => s.local)
if (author?.agent) return // Marver shows as its own participant
// dev: an account-less author IS the person at this keyboard - render the CURRENT identity,
// not the snapshot the event happened to store (old "Designer" events become you, live)
const a = local && me && !author?.email ? { ...author, ...me } : author
if (a?.avatar) return
const { initials, hue } = avatarFallback(a)
return (
{initials}
)
}
type Face = { email?: string; id?: string; name?: string; avatar?: string; agent?: boolean }
/** The signal a marker (pin or stack) carries: WHO took part and HOW MANY comments
* in total - root + every reply, across all the given threads. Unique participants
* in first-seen order; a single reply-less comment counts as 1. */
function markerDigest(threads: Thread[]): { faces: Face[]; count: number } {
const seen = new Map()
let count = 0
// namespace the identity key so an email can never collide with someone's name, and normalize
// email case. An AGENT message is its own participant ("Marver"), NOT the owner who authored
// it - so the marker shows N + Marver. Every email-less human face collapses to ONE key: in
// dev those snapshots ("Designer", "You", nothing) are all the person at this keyboard, and
// Avatar renders them all as the live identity - two keys would show identical twin faces.
const add = (a: Face | undefined, agent?: boolean) => {
const face: Face | undefined = agent ? { agent: true } : a
// published transport carries the opaque id instead of an email - same
// namespacing rule, different stable key
const k = agent ? 'marver' : a?.email ? `e:${a.email.toLowerCase()}` : a?.id ? `i:${a.id}` : 'local'
if (face && !seen.has(k)) seen.set(k, face)
}
for (const t of threads) {
count += 1 + t.replies.length
add(t.author, t.agent)
for (const r of t.replies) add(r.author, r.agent)
}
return { faces: [...seen.values()], count }
}
/** The face of a marker: up to `max` overlapping avatars + the total count when >1.
* One shape for pins (one thread, anchored) and stacks (all threads on a frame). */
export function MarkerFace({ threads, max = 3 }: { threads: Thread[]; max?: number }) {
const { faces, count } = markerDigest(threads)
return (
<>
{faces.slice(0, max).map((a, i) => )}
{count > 1 && {count}}
>
)
}
type Point = { x: number; y: number }
/** Open threads in creation order - the order markers are placed in, so a newer one moves, never
* an older one. */
export const byCreation = (threads: Thread[]): Thread[] => [...threads].sort((a, b) => a.ts - b.ts || (a.id < b.id ? -1 : 1))
/** Place one surface's markers - a canvas frame (node px) or the stage (screen px, inv 1): fixed
* points, a fixed marker size, the column for threads with no point. It reads nothing a selection
* or an open card changes, so neither ever moves a marker. */
export const placePins = (threads: Thread[], points: Map, frameW: number, inv: number) =>
placeMarkers(byCreation(threads).map((t) => ({ id: t.id, point: points.get(t.id) ?? null })), frameW, inv)
/** The placed markers, with a thin line from each one that had to move to its point. */
export function Pins({ threads, placed, orphans, inv, active, alertIds }: {
threads: Thread[]; placed: Map; orphans: Set
inv: number; active: string | null; alertIds?: Set
}) {
const { setActive } = useComments.getState()
// a pin wears the thin Marver-blue working ring while the agent works on its thread (spec 27)
const working = useJamQueue((s) => s.cards)?.filter((c) => c.state === 'working').map((c) => c.thread) ?? []
return (
<>
{threads.map((t) => {
const p = placed.get(t.id)!
const orphan = orphans.has(t.id)
return [
p.lead && ,
e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
title={orphan ? 'the anchored element is gone - comment parked' : undefined}>
,
]
})}
>
)
}
/** Lives inside FrameNode's body, over the iframe. Owns anchor resolution for its frame. */
export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: string; iframe: React.RefObject }) {
const show = useComments((s) => s.show)
const showAnchor = useComments((s) => s.showAnchor)
const active = useComments((s) => s.active)
const draft = useComments((s) => s.draft)
// select the stable array, filter in render - a selector returning a fresh
// .filter() array re-renders forever (Object.is never matches)
const allThreads = useComments((s) => s.threads)
const allNodes = useStore((s) => s.nodes)
// one owner per thread (keys.ts): the stored anchor when it still holds, else the
// first node showing the frame - a stale key (pre-0.8.1 random keys, board rewrites,
// deleted copies) degrades to the frame, never to invisible, and never to two pins
const threads = allThreads.filter((t) => t.frame === frameId && threadHostKey(t, allNodes) === node.key)
const selected = useStore((s) => s.selection.includes(node.key))
const { setActive } = useComments.getState()
// each in-frame thread's attach point, in the frame's px - null when its element is gone
const [framePoints, setFramePoints] = useState>({})
// the zoom markers are placed at: the store commits it once a zoom settles, never mid-gesture,
// so markers hold still while you zoom and land once
const scale = useStore((s) => s.scale)
// a slide renders at its stage and the node scales it (FrameNode): rects the frame reports are
// in stage px, so they map through the same fit into the node's coordinates
const frameEntry = useStore((s) => s.frameFor(node))
const stage = frameEntry ? slideSize(frameEntry, CONFIG.viewports) : null
const fit = stage ? stageFit(stage, { w: node.w, h: node.h }) : null
const inNode = (r: R): R =>
fit ? { ...r, x: fit.ox + r.x * fit.k, y: fit.oy + r.y * fit.k, w: r.w * fit.k, h: r.h * fit.k } : r
const pointInNode = (p: Point): Point => (fit ? { x: fit.ox + p.x * fit.k, y: fit.oy + p.y * fit.k } : p)
const open = threads.filter((t) => !t.resolved)
const anchored = open.filter((t) => (t.anchor as any)?.el)
// whether the ACTIVE thread is still open on this frame - a dep for the highlight effect so
// a remote resolve (thread leaves `open` while `active` is unchanged) still clears the lock
const activeShown = !!active && open.some((t) => t.id === active)
// anchors split by where they live: in the frame document (inspect.js resolves them over
// postMessage) or on a sticky note (spec 18: shell DOM, resolved right here)
const inFrame = anchored.filter((t) => !isNoteAnchor(t.anchor))
const onNote = anchored.filter((t) => isNoteAnchor(t.anchor))
// resolve anchors against the live frame whenever threads, their anchors (a re-pin) or the
// document change
useEffect(() => {
const win = iframe.current?.contentWindow
if (!win || node.status !== 'ready' || !inFrame.length) return
const ask = () => win.postMessage({ type: 'sh:resolve-anchors', anchors: inFrame.map((t) => ({ key: t.id, anchor: t.anchor })) }, location.origin)
const onMsg = (e: MessageEvent) => {
if (e.source !== win || e.origin !== location.origin || e.data?.type !== 'sh:anchor-rects') return
const next: typeof framePoints = {}
for (const r of e.data.rects ?? []) next[r.key] = r.orphan ? null : r.point
setFramePoints((cur) => (JSON.stringify(cur) === JSON.stringify(next) ? cur : next))
}
window.addEventListener('message', onMsg)
ask()
const iv = setInterval(ask, 4000) // re-renders, scroll, hot reloads - cheap to re-ask
return () => { clearInterval(iv); window.removeEventListener('message', onMsg) }
}, [node.status, inFrame.map((t) => `${t.id}:${JSON.stringify(t.anchor)}`).join(','), iframe])
// note anchors: their own map, measured in the shell, in node-body coordinates (negative x -
// the note is left of the frame). The composing draft is measured too (key 'draft'), so it
// follows a fold like a saved pin. A folded column parks pins on the fold; a note whose file
// is gone, or whose element the markdown no longer has, parks like an orphan. Re-measured on
// every fold/unfold (twice: at once and past the transition) and on the same 4 s beat.
const [noteRects, setNoteRects] = useState>({})
const notesState = useNotes()
const draftOnNote = draft?.nodeKey === node.key && isNoteAnchor(draft.anchor) ? draft.anchor : null
useEffect(() => {
if (!onNote.length && !draftOnNote) { setNoteRects((r) => (Object.keys(r).length ? {} : r)); return }
const measure = () => {
const host = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`) as HTMLElement | null
const origin = host?.querySelector('.cm-layer')?.getBoundingClientRect()
if (!host || !origin) return
const s = origin.width ? origin.width / host.offsetWidth : 1
const toBody = (r: DOMRect) => ({ x: (r.left - origin.left) / s, y: (r.top - origin.top) / s, w: r.width / s, h: r.height / s })
const column = host.querySelector('.sh-notes')
const fold = column?.querySelector('.sh-notes-fold')
const place = (a: any) => {
const body = column?.querySelector(`[data-sticky="${a.el.note}"] .sh-sticky-body`)
if (!body) return null
if (column!.classList.contains('off')) return fold ? toBody(fold.getBoundingClientRect()) : null
const el = resolveNoteAnchor(a, body)
return el ? toBody(el.getBoundingClientRect()) : null
}
const next: typeof noteRects = {}
for (const t of onNote) next[t.id] = place(t.anchor)
if (draftOnNote) next.draft = place(draftOnNote)
setNoteRects(next)
}
measure()
const late = setTimeout(measure, 260)
const iv = setInterval(measure, 4000)
return () => { clearTimeout(late); clearInterval(iv) }
}, [onNote.map((t) => t.id).join(','), draftOnNote, notesState.all, notesState.hidden.join(','), node.key, node.w, node.h])
/** This node's sticky column when it is showing: the docked card must clear it (world px). */
const noteColumn = (): HTMLElement | null => document.querySelector(`[data-node-notes="${CSS.escape(node.key)}"]:not(.off)`)
// #4/#5: drive the persistent element highlight into this frame - the composing draft on
// this node (lock on pick), or the open thread anchored here (highlight on open). null
// clears it, so the shell owns the release; on a failed/late resolve the frame re-applies
// on its next anchor-resolve tick. The self-lock on pick handles the instant case.
useEffect(() => {
const win = iframe.current?.contentWindow
if (!win || node.status !== 'ready') return
// ONLY light an element while its thread is the active one (or a draft is composing on
// it) AND pins are shown AND element focus is on (⇧L) - closing the thread, hiding
// comments (⇧C), or dimming focus clears it, so a stray outline never lingers.
const draftHere = draft?.nodeKey === node.key ? (draft.anchor as any) : null
const activeHere = active ? open.find((t) => t.id === active && (t.anchor as any)?.el) : undefined
const anchor = show && showAnchor ? (draftHere ?? (activeHere ? (activeHere.anchor as any) : null)) : null
win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: isNoteAnchor(anchor) ? null : anchor ?? null }, location.origin)
// a note anchor lights its element in the shell the way the frame lights its own
const host = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`)
for (const el of host?.querySelectorAll('[data-sh-lock]') ?? []) el.removeAttribute('data-sh-lock')
if (isNoteAnchor(anchor)) {
const body = host?.querySelector(`[data-sticky="${anchor.el.note}"] .sh-sticky-body`)
const el = body && resolveNoteAnchor(anchor, body)
if (el) el.setAttribute('data-sh-lock', '')
}
// active/draft/show/showAnchor/activeShown and the active anchor (a re-pin) are the triggers;
// `open` is read fresh from the closure
}, [active, draft, show, showAnchor, activeShown, JSON.stringify(open.find((t) => t.id === active)?.anchor ?? null), node.status, node.key, frameId, iframe])
// the open card's frozen side (D3) - a HOOK, so it must live above the early returns; and the
// reset must too: a card can close via the inactive-frame early return (clicking another frame),
// which never reaches the render tail - reopen must still RE-PICK the side
const sideRef = useRef<{ id: string; side: 'l' | 'r' } | null>(null)
if (sideRef.current && sideRef.current.id !== active) sideRef.current = null
// what occupies this frame's LEFT flank (drives the docked card's left gutter): a variant
// badge is widest, the working shimmer next. Reactive, so a job starting mid-read adjusts.
const flankBadge = useStore((s) => !!s.frameFor(node)?.variantGroup)
const flankShim = useStore((s) => s.working.includes(frameId))
// (a sticky column is read from the DOM at render: it is per viewer and per host node)
const drafting = draft?.nodeKey === node.key
const alertIds = useMentionAlerts()
if (!show || (!open.length && !drafting)) return null
// inactive frame: the stack - count + avatars, top-right
const engaged = selected || open.some((t) => t.id === active) || drafting
if (!engaged && open.length) {
return (
)
}
// each open thread's attach point in node px: a note's where it was clicked on the note, a
// frame's where the frame says; none (the column) for a legacy frame-level pin, an orphan, or
// an anchor not resolved yet
const points = new Map()
const orphans = new Set()
for (const t of open) {
const a = t.anchor as any
if (!a?.el) continue
const r = isNoteAnchor(a) ? noteRects[t.id] : undefined
const p = isNoteAnchor(a) ? (r ? { x: r.x + (a.pos?.fx ?? 0.5) * r.w, y: r.y + (a.pos?.fy ?? 0.5) * r.h } : r) : framePoints[t.id] && pointInNode(framePoints[t.id]!)
if (p === null) orphans.add(t.id)
points.set(t.id, p ?? null)
}
const inv = 1 / Math.max(0.01, scale)
const placed = placePins(open, points, node.w, inv)
const pinAt = (t: Thread): Placed => placed.get(t.id)!
// The card's SIDE, chosen ONCE per open and frozen (D3 - never switches mid-read): the pin's
// side of the frame first, skipping a side another frame's screen rect occupies, then wherever
// the viewport has room. The active pin's teardrop tail flips toward this side.
const activeThread2 = active ? open.find((t) => t.id === active) : undefined
if (activeThread2 && sideRef.current?.id !== activeThread2.id) {
const compute = (): 'l' | 'r' => {
const W = 320, GAP = 30
const rect = document.querySelector(`[data-node="${CSS.escape(node.key)}"]`)?.getBoundingClientRect()
if (!rect) return 'r'
// a showing sticky column sits in the left gutter in WORLD px (its width + 36, the CSS
// margin): the card lands beyond it, so both room and collision use that screen offset
const scale = rect.width / Math.max(1, node.w)
const col = noteColumn()
const gutter = col ? col.getBoundingClientRect().width + 36 * scale : 0
const occupied = (s: 'l' | 'r') => {
const rx = s === 'r' ? rect.right + 10 : rect.left - gutter - 10 - W
return [...document.querySelectorAll('.sh-node, .sh-notes:not(.off)')].some((n) => {
if (n.getAttribute('data-node') === node.key || n.getAttribute('data-node-notes') === node.key) return false
const r = n.getBoundingClientRect()
return r.left < rx + W && r.right > rx && r.top < rect.bottom && r.bottom > rect.top
})
}
// the left room starts at the sidebar's edge, not the window's: a card parked under the
// panel is a card nobody can read (a note's gutter makes that reach likelier)
const panelRight = document.querySelector('.sh-panel:not(.closed)')?.getBoundingClientRect().right ?? 0
const room = (s: 'l' | 'r') => (s === 'r' ? window.innerWidth - rect.right : rect.left - gutter - panelRight) >= W + GAP
const prefer: ('l' | 'r')[] = pinAt(activeThread2).x < node.w / 2 ? ['l', 'r'] : ['r', 'l']
return prefer.find((s) => room(s) && !occupied(s)) ?? prefer.find(room) ?? prefer[0]
}
sideRef.current = { id: activeThread2.id, side: compute() }
}
const cardSide: 'l' | 'r' = sideRef.current?.side ?? 'r'
return (
<>
{activeThread2 && (
)}
{draft?.nodeKey === node.key && (() => {
const a = draft.anchor as any
const r = (draftOnNote && noteRects.draft) || (a?.rect && inNode(a.rect))
return
})()}
>
)
}
/** The open thread card - shared by canvas (bounds = node size, `at` in frame coords) and
* prototype (bounds = the on-screen stage size, `at` in screen coords). Geometry only. */
// ---- Live Jam: @marver rendering + Marver identity ----------------------
const AT_TIP = "Read like any other comment. Marver won't act on this unless the owner promotes it."
/** The mentionable set, derived from everything the client has seen (published:
* projected ids + names; dev: canonical emails). The viewer themself is out -
* this is the COMPOSER's set (you cannot mention yourself). */
function usePeople(): MentionPerson[] {
const events = useComments((s) => s.events)
const me = useComments((s) => s.me)
return mentionPeople(events, me ?? undefined)
}
/** Everyone, self included - the RENDERING set: a mention of you must light up
* hardest of all, not disappear (the bug that shipped: the excluded self made
* your own mentions render as plain text exactly for the person they were for). */
function useRenderPeople(): { people: MentionPerson[]; isMe: (p: MentionPerson) => boolean } {
const events = useComments((s) => s.events)
const me = useComments((s) => s.me)
const people = useMemo(() => mentionPeople(events), [events])
const isMe = (p: MentionPerson) =>
(!!p.id && p.id === me?.id) || (!!p.email && !!me?.email && p.email.toLowerCase() === me.email.toLowerCase())
return { people, isMe }
}
/** Threads holding an unseen mention of me - drives the pin/stack pulse. */
function useMentionAlerts(): Set {
const events = useComments((s) => s.events)
const me = useComments((s) => s.me)
const seen = useComments((s) => s.mentionSeen)
return useMemo(() => new Set(mentionAlerts(events, me, seen)), [events, me, seen])
}
/** A comment body with mentions styled. `@marver`: owner-authored → bold accent (a live
* trigger); anyone else's → plain + a teaching tooltip (context, not a command).
* `@Person` (v1.1) → the person chip; the same segmentation that derived the
* event's mentions, so what lit up is what notified. */
function CommentBody({ body, owner }: { body?: string; owner: boolean }) {
const { people, isMe } = useRenderPeople()
if (!body) return null
return (
)
}
export const HARNESS: Record = { claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', opencode: 'opencode', droid: 'Factory Droid', grok: 'Grok', pi: 'pi' }
/** claude-opus-5 → Opus 5; gpt-5.1-codex → Gpt 5.1 Codex; unknown → as-is. */
function prettyModel(m: string): string {
const cleaned = m.replace(/^claude-/, '').replace(/-/g, ' ')
return cleaned.replace(/\b\w/g, (c) => c.toUpperCase())
}
/** The provenance tooltip on the Marver avatar: who orchestrated the change.
* One row per fact, left-aligned, bold label + regular value - the house tooltip treatment. */
function AgentMetaTip({ meta }: { meta?: AgentMeta }) {
const rows = [
meta?.devUser && ['Dev user', meta.devUser],
meta?.harness && ['Harness', HARNESS[meta.harness] ?? meta.harness],
meta?.model && ['Model', prettyModel(meta.model)],
meta?.effort && ['Effort', meta.effort],
].filter(Boolean) as [string, string][]
if (!rows.length) return Marver
// Toolbar-tooltip treatment: the VALUE is bold + bright (what you scan), the label is a muted
// qualifier after it - like "Hide all UI press H to reveal".
return (
{rows.map(([label, value]) => {value}{label})}
)
}
/** A message header. Agent messages render as "Marver" with the mark + provenance tooltip;
* human messages keep their avatar + name. While the dev profile is still UNSET, every own
* message avatar invites editing on hover (discoverability); once set, only the composer's
* avatar is the door. */
function MessageHead({ author, agent, agentMeta, ts, board, eventId }: { author?: Thread['author']; agent?: boolean; agentMeta?: AgentMeta; ts: number; board?: string; eventId?: string }) {
const me = useComments((s) => s.me)
const local = useComments((s) => s.local)
const connected = useComments((s) => s.connected)
if (agent) return (
}>
Marver{rel(ts)}
)
// own = born on this machine without an account (no email); unset = identity still the default.
// Own messages render the CURRENT identity, not the stored snapshot - same rule as Avatar.
const unset = local && !connected && (!me?.name || me.name === 'You')
const own = local && !author?.email
const shown = own && me ? { ...author, ...me } : author
return (
{unset && own ? : }
{shown?.name ?? 'Someone'}{rel(ts)}
)
}
/** An auto-growing composer with the full keybindings: Enter send, Shift+Enter newline,
* Cmd/Ctrl+Enter send, Escape cancel; IME-guarded, disabled while a send is in flight. */
function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFocus, sendLabel, owner }: {
value: string; onChange: (v: string) => void; onSubmit: () => void | Promise
onCancel?: () => void; placeholder: string; autoFocus?: boolean; sendLabel: string; owner: boolean
}) {
const ref = useRef(null)
const hlRef = useRef(null)
const [busy, setBusy] = useState(false)
// ---- @-mention typeahead (v1.1): the token under the caret filters the people the
// client has seen; picking completes `@Label ` in place. Escape dismisses THIS token
// only (the composer keeps its own Escape = cancel semantics when no list is open).
const people = usePeople()
const [caret, setCaret] = useState(0)
const [pick, setPick] = useState(0)
const [dismissed, setDismissed] = useState(null)
const q = mentionQueryAt(value, caret)
const matches = q && dismissed !== q.start
? people.filter((p) => p.label.toLowerCase().startsWith(q.query.toLowerCase())).slice(0, 5)
: []
const listOpen = matches.length > 0
useEffect(() => { setPick(0) }, [q?.start, q?.query])
const track = () => setCaret(ref.current?.selectionStart ?? 0)
const choose = (p: MentionPerson) => {
if (!q) return
const pos = q.start + p.label.length + 2 // '@' + label + ' '
onChange(value.slice(0, q.start) + '@' + p.label + ' ' + value.slice(caret))
setCaret(pos)
requestAnimationFrame(() => { const el = ref.current; if (el) { el.focus(); el.setSelectionRange(pos, pos) } })
}
// +2: scrollHeight excludes the 1px borders, but border-box height includes them - without
// it every line is squeezed 2px and the single-line pill renders off-center
const grow = () => { const el = ref.current; if (el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight + 2, 132) + 'px' } }
const syncScroll = () => { if (hlRef.current && ref.current) hlRef.current.scrollTop = ref.current.scrollTop }
useEffect(() => { if (autoFocus) ref.current?.focus() }, [autoFocus])
useEffect(grow, [value])
const submit = async () => {
if (busy || !value.trim()) return
setBusy(true)
try { await onSubmit() } finally { setBusy(false) }
}
return (
{/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver
shows its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted)
and a completed @Person wears the chip it will render with. */}
)
}
/** The dev identity: a full-screen modal (the IdentityDialog treatment - same classes, same
* weight) with display name + photo, saved to design/.local/profile.json (this machine only).
* When a connect account provides name/email (the published server validates authors), the name
* is read-only here and only the photo is editable. Portaled to body: the thread card is
* transformed + overflow-hidden, so anything anchored inside it clips. */
function ProfileDialog({ onClose }: { onClose: () => void }) {
const me = useComments((s) => s.me)
const connected = useComments((s) => s.connected)
const unset = !connected && (!me?.name || me.name === 'You')
const [name, setName] = useState(unset ? '' : me?.name ?? '')
const [avatar, setAvatar] = useState(me?.avatar ?? '')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState(null)
const ready = connected || !!name.trim()
const save = async () => {
if (busy || !ready) return
setBusy(true)
try {
const e = await useComments.getState().saveProfile({ ...(connected ? {} : { name: name.trim() }), avatar })
if (e) return setErr(e) // a failed save must not close as if it worked
onClose()
} finally { setBusy(false) }
}
return createPortal(
{unset ? 'Set up your profile' : 'Edit your profile'}
{connected
? <>Your name comes from your connect account. The photo stays on this machine.>
: <>Comments carry your name and photo. Saved to design/.local on this machine - yours, nowhere else.>}
,
// into .sh-app, NOT body: every theme token (--cm-modal-bg, inks) lives on .sh-app,
// and a body-portaled modal renders transparent outside that scope
document.querySelector('.sh-app') ?? document.body)
}
/** An avatar that IS the door to your profile: hover morphs it into a pen on gray, click opens
* the modal. The composer's own avatar is always this door (dev only); while the profile is
* still unset, every own message avatar is too - discoverability until identity is real. */
function EditableAvatar({ author, size = 24 }: { author?: Thread['author']; size?: number }) {
const [open, setOpen] = useState(false)
return (
Edit your profile}>
{open && setOpen(false)} />}
)
}
/** The composer's own avatar (dev only - published viewers already have accounts). */
function ComposeAvatar() {
const me = useComments((s) => s.me)
const local = useComments((s) => s.local)
if (!local) return
return
}
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' | 'note' | null; stage?: boolean }) {
const { resolve, setActive } = useComments.getState()
const me = useComments((s) => s.me)
const local = useComments((s) => s.local)
const canComment = local || !!me // dev is always "me"; published needs a session
// Owner-authored @marver is a live trigger (bold accent); anyone else's is context (plain + tooltip).
// Match by opaque id on the projected transport, by email locally; in dev
// without a profile, local writes are the owner.
const isOwner = (a?: { email?: string; id?: string }) =>
me?.id && a?.id ? a.id === me.id : me?.email ? a?.email === me.email : local
// the event behind each message - a tag's card names its events, the status icon finds it by them
const events = useComments((s) => s.events)
const eventOf = (msg: string) => events.find((e) => (e.type === 'create' || e.type === 'reply') && e.commentId === msg && (!e.board || !thread.board || e.board === thread.board))?.id
const [text, setText] = useState('')
const [copied, setCopied] = useState(false)
const copyTimer = useRef>(undefined)
useEffect(() => () => clearTimeout(copyTimer.current), [])
// Canvas: the card is a WORLD OBJECT parked beside its frame - it stays put (panning moves
// frame + card together, never following the viewport). Zoom scales it with a CLAMP
// (0.9..1.35 of screen-constant), so it reads steady at working zooms yet never dwarfs the
// frame far out or shrinks away deep in. Height rules: capped at the frame's height; grows
// DOWNWARD from the pin until it reaches the frame's bottom, then grows UP; past the cap the
// messages scroll inside. The SIDE is chosen once at open (D3). Play keeps in-stage placement.
const scrollRef = useRef(null)
const cardRef = useRef(null)
const float = !!nodeKey
const [cardH, setCardH] = useState(320) // measured layout px (pre-transform)
useEffect(() => {
if (float && cardRef.current) {
const h = cardRef.current.offsetHeight
setCardH((c) => (Math.abs(c - h) > 2 ? h : c))
}
})
// scroll shadows: "there is more" above/below - recomputed on scroll + content growth
const [shadows, setShadows] = useState({ top: false, bot: false, can: false })
const syncShadows = () => {
const el = scrollRef.current
if (!el) return
// gate on MEANINGFUL overflow - a thread that overflows by a few px must not smear a
// 30px fade over its last line ("looks scrollable" when it isn't, really)
const can = el.scrollHeight - el.clientHeight > 12
const top = can && el.scrollTop > 4
const bot = can && el.scrollTop + el.clientHeight < el.scrollHeight - 4
setShadows((s) => (s.top === top && s.bot === bot && s.can === can ? s : { top, bot, can }))
}
// open at the LATEST message (what you came to read); follow new replies while open
useEffect(() => {
const el = scrollRef.current
if (el) el.scrollTop = el.scrollHeight
syncShadows()
}, [thread.replies.length])
// ...and when the scroller RESIZES without a scroll event (zoom changing the height cap, the
// composer growing) - a stale fade would otherwise claim "more below" at the bottom of a
// thread that no longer overflows
useEffect(() => {
const el = scrollRef.current
if (!el) return
const ro = new ResizeObserver(syncShadows)
ro.observe(el)
return () => ro.disconnect()
}, [])
// ...and after EVERY render: an edited message changes scrollHeight without resizing the
// height-capped box or the reply count. setShadows dedups, so this is free.
useEffect(syncShadows)
// PLAY: one static centered device, no zoom - the card docks FIXED beside it on the pin's side
// (falling back to the roomier side), fully viewport-clamped so the composer is ALWAYS reachable
// whatever the device layout or theme. Recomputed when the stage moves under it:
// a device/theme switch resizes .sh-play-stage and a window resize shifts it.
const computeStagePos = (): React.CSSProperties | null => {
if (!stage) return null
// the view below the Mac app's strip when the shell took the inset (TOP 44)
const W = 320, M = 12, maxH = Math.min(660, window.innerHeight - TOP - 2 * M)
const rect = document.querySelector('.sh-play-stage')?.getBoundingClientRect()
if (!rect) return { position: 'fixed', left: M, top: TOP + 72, maxHeight: maxH }
const roomR = window.innerWidth - rect.right >= W + 30
const roomL = rect.left >= W + 30
const s: 'l' | 'r' = at.x < bounds.w / 2 ? (roomL ? 'l' : 'r') : (roomR ? 'r' : 'l')
const x = Math.min(Math.max(s === 'r' ? rect.right + 18 : rect.left - 18 - W, M), window.innerWidth - W - M)
// worst-case clamp (content = maxH): even a full-height thread keeps its composer on screen
const y = Math.min(Math.max(rect.top + at.y - 36, TOP + M), Math.max(TOP + M, window.innerHeight - maxH - M))
return { position: 'fixed', left: x, top: y, maxHeight: maxH }
}
const [stagePos, setStagePos] = useState(computeStagePos)
useEffect(() => {
if (!stage) return
const re = () => setStagePos(computeStagePos())
re() // bounds changed (device/theme switch) -> re-dock
window.addEventListener('resize', re)
const el = document.querySelector('.sh-play-stage')
const ro = el ? new ResizeObserver(re) : null
if (el && ro) ro.observe(el)
return () => { window.removeEventListener('resize', re); ro?.disconnect() }
}, [stage, bounds.w, bounds.h])
const flip = !float && !stage && at.x > bounds.w * 0.55
// The card scales EXACTLY like the pins - screen-constant via --sh-inv, pure
// CSS, smooth per zoom tick with zero re-renders. Geometry in CSS math over the live vars:
// top: grows DOWN from the pin, then UP once its bottom reaches the frame's bottom; floor
// at -28 (the layer starts below the header - -28 = the frame's TOP border).
// maxHeight: the visual height never exceeds the WHOLE frame (body + 28px header).
// Gutters live in CSS (dock margins, screen-constant; wider on the left for the shimmer).
const pos: React.CSSProperties = stage && stagePos
? stagePos
: float
? {
left: side === 'r' ? bounds.w : 0,
top: `max(-28px, min(${Math.round(at.y)}px - 36px * var(--sh-inv, 1), ${bounds.h}px - ${cardH}px * var(--sh-inv, 1)))`,
// capped at the whole frame's on-screen height, with a REAL readability floor: zoomed way
// out the card still shows a meaningful slice of conversation (~3 messages), even if that
// makes it taller than the tiny frame
maxHeight: `max(320px, calc(${bounds.h + 28}px * var(--sh-s, 1)))`,
}
: { left: at.x, top: Math.min(at.y + 14, bounds.h - 40) }
// clear only after the server took it - a failed send must not eat the words,
// and text typed WHILE the request was in flight must survive the clear
const submit = async () => {
const sent = text
if (sent.trim() && await useComments.getState().replyOk(thread.id, sent)) setText((cur) => (cur === sent ? '' : cur))
}
const card = (
e.stopPropagation()} onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
{/* thread-level actions pin to the card corner, out of the header's flow -
the name row never has to share its line with them */}
{/* "there is more": the scroller MASKS its own content at the overflowing edge - a soft fade
of the text itself, not a paint layer over the glass */}
{/* a capture's comment reads as a capture: a labelled block, never a typed comment (spec 27) */}
{thread.capture
?
: }
{/* replies repeat the root's exact message shape (Figma's pattern) - only the icons differ */}
{thread.replies.map((r) => (
{/* a jam job changed a shared file it was not given: one line, so the reply says it (spec 27) */}
{r.agent && r.alsoChanged?.length ?
Also changed {r.alsoChanged.map((f) => f.split('/').pop()).join(', ')}
)
return card
}
/** The draft composer - shared by canvas and prototype, geometry via `bounds`, tinted with
* the picked element's hue. */
export function DraftComposer({ at, bounds, hue }: { at: { x: number; y: number }; bounds: { w: number; h: number }; hue?: number }) {
const { create, setDraft } = useComments.getState()
const local = useComments((s) => s.local)
const [text, setText] = useState('')
const flip = at.x > bounds.w * 0.55
return (
e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
{/* the draft is JUST the tight pill - no avatar chrome on a first comment */}
create(text)} onCancel={() => setDraft(null)} placeholder="Comment on this element…" autoFocus sendLabel="Comment" owner={local} />
)
}
/** Avatar picker: dashed circle → file input → client-side 128px downscale →
* data-URI preview. Optional everywhere it appears; never gates a CTA. */
function AvatarPick({ value, onPick }: { value: string; onPick: (dataUri: string) => void }) {
const fileRef = useRef(null)
const pick = (file: File | undefined) => {
if (!file) return
const img = new Image()
img.onload = () => {
const s = Math.min(img.width, img.height)
const c = document.createElement('canvas')
c.width = c.height = 128
c.getContext('2d')!.drawImage(img, (img.width - s) / 2, (img.height - s) / 2, s, s, 0, 0, 128, 128)
onPick(c.toDataURL('image/jpeg', 0.85))
URL.revokeObjectURL(img.src)
}
img.src = URL.createObjectURL(file)
}
return (
<>
{value ? 'Change your photo' : 'Select or upload a profile picture'}}>
pick(e.target.files?.[0])} />
>
)
}
/** Signed-out published viewer tried to comment (or arrived on an invite link).
* Invite link: a focused claim - the token in hand, email shown as a chip.
* Fallback: sign in (returning device), with a quiet switch to pasting a token.
* Primary CTAs stay disabled until their mandatory fields are filled; hovering
* the disabled button says what's missing (avatar never gates). */
export function IdentityDialog() {
const needs = useComments((s) => s.needsIdentity)
const invite = useComments((s) => s.inviteToken)
const { signIn, claim, dismissIdentity } = useComments.getState()
const [mode, setMode] = useState<'signin' | 'claim'>('signin')
const [email, setEmail] = useState(''); const [password, setPassword] = useState('')
const [token, setToken] = useState(''); const [name, setName] = useState('')
const [avatar, setAvatar] = useState('')
const [invitedAs, setInvitedAs] = useState('')
const [err, setErr] = useState(null)
// an invite link knows WHO it's for - show it (the token is the proof of holding)
useEffect(() => {
if (!needs || !invite) return setInvitedAs('')
api(`invite-info?token=${encodeURIComponent(invite)}`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => d?.email && setInvitedAs(d.email))
.catch(() => { /* chip just stays hidden */ })
}, [needs, invite])
if (!needs) return null
const claiming = !!invite || mode === 'claim'
const ready = claiming
? !!(password.trim() && name.trim() && (invite || token.trim()))
: !!(email.trim() && password.trim())
const missing = claiming
? (invite ? 'Password and display name still needed' : 'Token, password, and display name still needed')
: 'Fill in email and password'
const go = async () => {
if (!ready) return
setErr(null)
const e = claiming ? await claim(invite ?? token, password, name, avatar || undefined) : await signIn(email, password)
if (e) return setErr(e)
// a consumed invite link leaves the URL - the hash becomes the plain board view
if (invite) writeHash({ board: useStore.getState().board })
}
const onEnter = (e: React.KeyboardEvent) => e.key === 'Enter' && void go()
return (
e.stopPropagation()}>
{invite ? 'You’re invited to comment' : claiming ? 'Sign up to comment' : 'Sign in to comment'}
{invite
? <>Pick how you’ll appear - comments carry your name.>
: claiming
? <>Create your account with the invite token your admin sent you.>
: <>You’re in read-only. Sign in to your account to comment on this canvas.>}
)
}
/** Open a thread by id: activate, select its node, fit the camera. True on success. */
export function revealThread(c: string): boolean {
const t = useComments.getState().threads.find((t) => t.id === c)
if (!t) return false
// resolve the host BEFORE activating: a thread whose frame is not on this board
// must not consume the deep link (the board switch that CAN show it comes later)
const key = threadHostKey(t, useStore.getState().nodes)
if (!key) return false
useComments.getState().setActive(c)
useStore.getState().select(key)
setTimeout(() => canvasCtl.fitNode(key), 80)
return true
}
/** Board-level comment wiring: load + liveness + mode broadcast, one instance in App. */
let bootThreadConsumed = false
let pendingThread: string | null = null // cross-board deep link awaiting its board's comments
/** Open this thread once the board just switched to has loaded its comments (an old link, App.tsx). */
export const parkThread = (id: string) => { pendingThread = id }
export function CommentsController() {
const ctlShowAnchor = useComments((s) => s.showAnchor)
const board = useStore((s) => s.board)
const commentMode = useComments((s) => s.commentMode)
const active = useComments((s) => s.active)
// modal-style dismiss: any press outside the card (and outside the surfaces that
// legitimately manage it - pins, stacks, the identity dialog) closes the thread
useEffect(() => {
if (!active) return
const onDown = (e: PointerEvent) => {
const t = e.target instanceof Element ? e.target : null
if (t?.closest('.cm-card, .cm-pin, .cm-stack, .cm-modal')) return
useComments.getState().setActive(null)
}
document.addEventListener('pointerdown', onDown, true)
return () => document.removeEventListener('pointerdown', onDown, true)
}, [active])
useEffect(() => {
const { load, live } = useComments.getState()
void load(board).then(() => {
// invite link (#/i/): open the claim dialog with the token in hand -
// unless this browser is already someone (their invite is not for this session)
const s = useComments.getState()
if (bootHash.invite && !s.local && !s.me && !s.inviteToken)
useComments.setState({ inviteToken: bootHash.invite, needsIdentity: true })
// a cross-board deep link parks its thread id here until the target board's
// comments have actually loaded - a timer can't know how long that takes
if (pendingThread && revealThread(pendingThread)) { pendingThread = null; return }
// deep link ?c=: open the thread, select its node, fit it.
// Consumed only on a successful find: the controller's first effect fires with
// the pre-boot board (child effects run before the parent's boot effect), and
// burning the flag on that empty pass would eat the link.
if (bootThreadConsumed || !bootHash.c) return
if (revealThread(bootHash.c)) bootThreadConsumed = true
})
return live(board)
}, [board])
// a comment link pasted into an ALREADY-OPEN canvas is a hash-only navigation -
// no reload, no boot path. Same board: the threads are in memory, reveal now.
// Different board: park the id; the load effect above consumes it once the
// board switch has fetched that board's comments.
useEffect(() => {
const onHash = (e: HashChangeEvent) => {
// read the URL the EVENT carries, not location.hash: the shell's own
// hashchange handler resets selection, whose projection synchronously
// rewrites the hash - listener order decides which URL survives, and
// e.newURL is immune to that race
const h = parseHash(e.newURL ? new URL(e.newURL).hash : location.hash)
if (h.invite) {
const s = useComments.getState()
if (!s.local && !s.me) useComments.setState({ inviteToken: h.invite, needsIdentity: true })
return
}
if (!h.c || isLegacyLink(h)) return // an old All Scenes link: App.tsx finds its board first
if (!revealThread(h.c)) pendingThread = h.c
}
window.addEventListener('hashchange', onHash)
return () => window.removeEventListener('hashchange', onHash)
}, [])
// broadcast pick mode to every frame (laser rides along inside the bridge); a sleeping frame
// is the live document itself, so it is messaged like any other
useEffect(() => {
for (const f of document.querySelectorAll('iframe.sh-live'))
(f as HTMLIFrameElement).contentWindow?.postMessage({ type: 'sh:pick', on: commentMode, quiet: !ctlShowAnchor }, location.origin)
}, [commentMode, ctlShowAnchor])
return
}