@marver-design/marver 0.11.1 → 0.13.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +1 -1
  3. package/dist/auth-DdUKeVKt.mjs +1150 -0
  4. package/dist/{build-DI31MFTB.mjs → build-BxGrHFT2.mjs} +188 -33
  5. package/dist/cli.mjs +16 -7
  6. package/dist/collab-mAiNZlDj.mjs +970 -0
  7. package/dist/{comments-CRqaO2MH.mjs → comments-DHB_8BRa.mjs} +3 -2
  8. package/dist/{daemon-DSmaS453.mjs → daemon-BChkzDqQ.mjs} +3 -3
  9. package/dist/{dev-BWIL2ewj.mjs → dev-DLwt3Brb.mjs} +5 -5
  10. package/dist/events-B3LBn74P.mjs +103 -0
  11. package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
  12. package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
  13. package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
  14. package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-B2uraTHS.mjs} +100 -9
  15. package/dist/{plugin-DjBFxEQ7.mjs → plugin-DNc4Jpae.mjs} +82 -9
  16. package/dist/{profile-DcsJyppw.mjs → profile-BjAPAJSb.mjs} +1 -1
  17. package/dist/{serve-3APqaNSB.mjs → serve-EjEqsiYa.mjs} +70 -17
  18. package/dist/share-Gqo_Ygqw.mjs +158 -0
  19. package/dist/summary-C7CAypbB.mjs +211 -0
  20. package/dist/{sync-DELGomPk.mjs → sync-BZaCWqK-.mjs} +3 -102
  21. package/package.json +1 -1
  22. package/src/client/shell/App.tsx +62 -11
  23. package/src/client/shell/Comments.tsx +103 -22
  24. package/src/client/shell/LockedApp.tsx +113 -0
  25. package/src/client/shell/Play.tsx +97 -18
  26. package/src/client/shell/Toolbar.tsx +26 -2
  27. package/src/client/shell/canvas/Canvas.tsx +5 -15
  28. package/src/client/shell/canvas/ctl.ts +26 -0
  29. package/src/client/shell/comments-store.ts +96 -11
  30. package/src/client/shell/hash.ts +19 -0
  31. package/src/client/shell/icons.tsx +1 -0
  32. package/src/client/shell/mentions.ts +108 -1
  33. package/src/client/shell/sound.ts +32 -0
  34. package/src/client/shell/store.ts +66 -7
  35. package/src/client/shell/styles.css +68 -0
  36. package/src/shared/events.ts +20 -6
  37. package/dist/auth-B5yuwnOq.mjs +0 -494
  38. package/dist/collab-CWaG3Q4w.mjs +0 -340
  39. package/dist/manifest-C2tzkNaC.mjs +0 -233
  40. package/dist/update-DuWDj5nR.mjs +0 -77
  41. package/dist/utm-CxC3QN5X.mjs +0 -20
@@ -4,16 +4,16 @@
4
4
  * top-right stack; the thread card opens beside its pin. Pins keep a screen-space
5
5
  * size via --sh-inv (the vbadge pattern) so zoom never shrinks them away.
6
6
  */
7
- import { useEffect, useRef, useState } from 'react'
7
+ import { useEffect, useMemo, useRef, useState } from 'react'
8
8
  import { createPortal } from 'react-dom'
9
9
  import { avatarFallback, useComments } from './comments-store.ts'
10
10
  import { useStore, type Node } from './store.ts'
11
11
  import { threadHostKey } from './keys.ts'
12
- import { canvasCtl } from './canvas/Canvas.tsx'
12
+ import { canvasCtl } from './canvas/ctl.ts'
13
13
  import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
14
14
  import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
15
15
  import { Tip } from './Tip.tsx'
16
- import { parseMentions } from './mentions.ts'
16
+ import { mentionAlerts, mentionPeople, mentionQueryAt, parseBody, type MentionPerson } from './mentions.ts'
17
17
  import { ROUTE } from '../const.ts'
18
18
  import type { AgentMeta, Thread } from '../../shared/events.ts'
19
19
 
@@ -74,7 +74,7 @@ export function Avatar({ author, size = 22 }: { author?: { email?: string; name?
74
74
  )
75
75
  }
76
76
 
77
- type Face = { email?: string; name?: string; avatar?: string; agent?: boolean }
77
+ type Face = { email?: string; id?: string; name?: string; avatar?: string; agent?: boolean }
78
78
 
79
79
  /** The signal a marker (pin or stack) carries: WHO took part and HOW MANY comments
80
80
  * in total - root + every reply, across all the given threads. Unique participants
@@ -89,7 +89,9 @@ function markerDigest(threads: Thread[]): { faces: Face[]; count: number } {
89
89
  // Avatar renders them all as the live identity - two keys would show identical twin faces.
90
90
  const add = (a: Face | undefined, agent?: boolean) => {
91
91
  const face: Face | undefined = agent ? { agent: true } : a
92
- const k = agent ? 'marver' : a?.email ? `e:${a.email.toLowerCase()}` : 'local'
92
+ // published transport carries the opaque id instead of an email - same
93
+ // namespacing rule, different stable key
94
+ const k = agent ? 'marver' : a?.email ? `e:${a.email.toLowerCase()}` : a?.id ? `i:${a.id}` : 'local'
93
95
  if (face && !seen.has(k)) seen.set(k, face)
94
96
  }
95
97
  for (const t of threads) {
@@ -180,13 +182,15 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
180
182
  const flankBadge = useStore((s) => !!s.frameFor(node)?.variantGroup)
181
183
  const flankShim = useStore((s) => s.working.includes(frameId))
182
184
  const drafting = draft?.nodeKey === node.key
185
+ const alertIds = useMentionAlerts()
183
186
  if (!show || (!open.length && !drafting)) return null
184
187
 
185
188
  // inactive frame: the stack - count + avatars, top-right
186
189
  const engaged = selected || open.some((t) => t.id === active) || drafting
187
190
  if (!engaged && open.length) {
188
191
  return (
189
- <button className="cm-stack sh-no-pan" onClick={(e) => { e.stopPropagation(); useStore.getState().select(node.key); if (open[0]) setActive(open[0].id) }}>
192
+ <button className={`cm-stack sh-no-pan${open.some((t) => alertIds.has(t.id)) ? ' mention' : ''}`}
193
+ onClick={(e) => { e.stopPropagation(); useStore.getState().select(node.key); const hit = open.find((t) => alertIds.has(t.id)) ?? open[0]; if (hit) setActive(hit.id) }}>
190
194
  <MarkerFace threads={open} />
191
195
  </button>
192
196
  )
@@ -232,7 +236,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
232
236
  const { x, y, orphan } = pinPos(t)
233
237
  const isActive = t.id === active
234
238
  return (
235
- <div key={t.id} className={`cm-pin sh-no-pan${isActive ? ' on' : ''}${isActive && cardSide === 'r' ? ' tail-r' : ''}${orphan ? ' orphan' : ''}`}
239
+ <div key={t.id} className={`cm-pin sh-no-pan${isActive ? ' on' : ''}${isActive && cardSide === 'r' ? ' tail-r' : ''}${orphan ? ' orphan' : ''}${alertIds.has(t.id) ? ' mention' : ''}`}
236
240
  style={{ left: x, top: y, ...hueVars(anchorHue(t.anchor)) }}
237
241
  onPointerDown={(e) => e.stopPropagation()}
238
242
  onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
@@ -256,17 +260,51 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
256
260
 
257
261
  const AT_TIP = "Read like any other comment. Marver won't act on this unless the owner promotes it."
258
262
 
259
- /** A comment body with @marver mentions styled: owner-authored → bold accent (a live trigger);
260
- * anyone else's → plain + a teaching tooltip (context, not a command). */
263
+ /** The mentionable set, derived from everything the client has seen (published:
264
+ * projected ids + names; dev: canonical emails). The viewer themself is out -
265
+ * this is the COMPOSER's set (you cannot mention yourself). */
266
+ function usePeople(): MentionPerson[] {
267
+ const events = useComments((s) => s.events)
268
+ const me = useComments((s) => s.me)
269
+ return mentionPeople(events, me ?? undefined)
270
+ }
271
+
272
+ /** Everyone, self included - the RENDERING set: a mention of you must light up
273
+ * hardest of all, not disappear (the bug that shipped: the excluded self made
274
+ * your own mentions render as plain text exactly for the person they were for). */
275
+ function useRenderPeople(): { people: MentionPerson[]; isMe: (p: MentionPerson) => boolean } {
276
+ const events = useComments((s) => s.events)
277
+ const me = useComments((s) => s.me)
278
+ const people = useMemo(() => mentionPeople(events), [events])
279
+ const isMe = (p: MentionPerson) =>
280
+ (!!p.id && p.id === me?.id) || (!!p.email && !!me?.email && p.email.toLowerCase() === me.email.toLowerCase())
281
+ return { people, isMe }
282
+ }
283
+
284
+ /** Threads holding an unseen mention of me - drives the pin/stack pulse. */
285
+ function useMentionAlerts(): Set<string> {
286
+ const events = useComments((s) => s.events)
287
+ const me = useComments((s) => s.me)
288
+ const seen = useComments((s) => s.mentionSeen)
289
+ return useMemo(() => new Set(mentionAlerts(events, me, seen)), [events, me, seen])
290
+ }
291
+
292
+ /** A comment body with mentions styled. `@marver`: owner-authored → bold accent (a live
293
+ * trigger); anyone else's → plain + a teaching tooltip (context, not a command).
294
+ * `@Person` (v1.1) → the person chip; the same segmentation that derived the
295
+ * event's mentions, so what lit up is what notified. */
261
296
  function CommentBody({ body, owner }: { body?: string; owner: boolean }) {
297
+ const { people, isMe } = useRenderPeople()
262
298
  if (!body) return null
263
299
  return (
264
300
  <p className="cm-body">
265
- {parseMentions(body).map((s, i) => !s.mention
266
- ? <span key={i}>{s.text}</span>
267
- : owner
268
- ? <span key={i} className="cm-at owner">{s.text}</span>
269
- : <Tip key={i} side="top" label={<span className="cm-at-tip">{AT_TIP}</span>}><span className="cm-at">{s.text}</span></Tip>)}
301
+ {parseBody(body, people).map((s, i) => s.person
302
+ ? <span key={i} className={isMe(s.person) ? 'cm-at person me' : 'cm-at person'}>{s.text}</span>
303
+ : !s.marver
304
+ ? <span key={i}>{s.text}</span>
305
+ : owner
306
+ ? <span key={i} className="cm-at owner">{s.text}</span>
307
+ : <Tip key={i} side="top" label={<span className="cm-at-tip">{AT_TIP}</span>}><span className="cm-at">{s.text}</span></Tip>)}
270
308
  </p>
271
309
  )
272
310
  }
@@ -337,6 +375,27 @@ function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFo
337
375
  const ref = useRef<HTMLTextAreaElement>(null)
338
376
  const hlRef = useRef<HTMLDivElement>(null)
339
377
  const [busy, setBusy] = useState(false)
378
+ // ---- @-mention typeahead (v1.1): the token under the caret filters the people the
379
+ // client has seen; picking completes `@Label ` in place. Escape dismisses THIS token
380
+ // only (the composer keeps its own Escape = cancel semantics when no list is open).
381
+ const people = usePeople()
382
+ const [caret, setCaret] = useState(0)
383
+ const [pick, setPick] = useState(0)
384
+ const [dismissed, setDismissed] = useState<number | null>(null)
385
+ const q = mentionQueryAt(value, caret)
386
+ const matches = q && dismissed !== q.start
387
+ ? people.filter((p) => p.label.toLowerCase().startsWith(q.query.toLowerCase())).slice(0, 5)
388
+ : []
389
+ const listOpen = matches.length > 0
390
+ useEffect(() => { setPick(0) }, [q?.start, q?.query])
391
+ const track = () => setCaret(ref.current?.selectionStart ?? 0)
392
+ const choose = (p: MentionPerson) => {
393
+ if (!q) return
394
+ const pos = q.start + p.label.length + 2 // '@' + label + ' '
395
+ onChange(value.slice(0, q.start) + '@' + p.label + ' ' + value.slice(caret))
396
+ setCaret(pos)
397
+ requestAnimationFrame(() => { const el = ref.current; if (el) { el.focus(); el.setSelectionRange(pos, pos) } })
398
+ }
340
399
  // +2: scrollHeight excludes the 1px borders, but border-box height includes them - without
341
400
  // it every line is squeezed 2px and the single-line pill renders off-center
342
401
  const grow = () => { const el = ref.current; if (el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight + 2, 132) + 'px' } }
@@ -350,18 +409,38 @@ function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFo
350
409
  }
351
410
  return (
352
411
  <div className="cm-inputwrap">
353
- {/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver shows
354
- its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted). */}
412
+ {/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver
413
+ shows its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted)
414
+ and a completed @Person wears the chip it will render with. */}
355
415
  <div className="cm-hl" ref={hlRef} aria-hidden>
356
- {parseMentions(value).map((s, i) => s.mention
357
- ? <span key={i} className={owner ? 'cm-at owner' : 'cm-at'}>{s.text}</span>
358
- : <span key={i}>{s.text}</span>)}
416
+ {parseBody(value, people).map((s, i) => s.person
417
+ ? <span key={i} className="cm-at person">{s.text}</span>
418
+ : s.marver
419
+ ? <span key={i} className={owner ? 'cm-at owner' : 'cm-at'}>{s.text}</span>
420
+ : <span key={i}>{s.text}</span>)}
359
421
  {value.endsWith('\n') ? '​' : ''}
360
422
  </div>
423
+ {listOpen && (
424
+ <div className="cm-mentionlist" role="listbox">
425
+ {matches.map((p, i) => (
426
+ <button key={p.id ?? p.email} type="button" className={i === pick ? 'on' : ''} role="option" aria-selected={i === pick}
427
+ onMouseDown={(e) => { e.preventDefault(); choose(p) }} onMouseEnter={() => setPick(i)}>
428
+ <Avatar author={{ ...p, name: p.label }} size={18} /><span>{p.label}</span>
429
+ </button>
430
+ ))}
431
+ </div>
432
+ )}
361
433
  <textarea ref={ref} rows={1} value={value} placeholder={placeholder} disabled={busy} spellCheck={false}
362
- onChange={(e) => onChange(e.target.value)} onInput={grow} onScroll={syncScroll}
434
+ onChange={(e) => { onChange(e.target.value); setDismissed(null); setCaret(e.target.selectionStart ?? 0) }}
435
+ onInput={grow} onScroll={syncScroll} onClick={track} onKeyUp={track}
363
436
  onKeyDown={(e) => {
364
437
  e.stopPropagation()
438
+ if (listOpen) {
439
+ if (e.key === 'ArrowDown') { e.preventDefault(); setPick((v) => (v + 1) % matches.length); return }
440
+ if (e.key === 'ArrowUp') { e.preventDefault(); setPick((v) => (v + matches.length - 1) % matches.length); return }
441
+ if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); choose(matches[pick] ?? matches[0]); return }
442
+ if (e.key === 'Escape') { setDismissed(q!.start); return }
443
+ }
365
444
  if (e.key === 'Escape') { onCancel?.(); return }
366
445
  if (e.key === 'Enter') {
367
446
  if ((e.nativeEvent as unknown as { isComposing?: boolean }).isComposing) return // IME: mid-composition Enter must not send
@@ -465,8 +544,10 @@ export function ThreadCard({ thread, at, bounds, nodeKey, side = 'r', flank, sta
465
544
  const local = useComments((s) => s.local)
466
545
  const canComment = local || !!me // dev is always "me"; published needs a session
467
546
  // Owner-authored @marver is a live trigger (bold accent); anyone else's is context (plain + tooltip).
468
- // Match by email when we know the owner's; in dev without a profile, local writes are the owner.
469
- const isOwner = (a?: { email?: string }) => (me?.email ? a?.email === me.email : local)
547
+ // Match by opaque id on the projected transport, by email locally; in dev
548
+ // without a profile, local writes are the owner.
549
+ const isOwner = (a?: { email?: string; id?: string }) =>
550
+ me?.id && a?.id ? a.id === me.id : me?.email ? a?.email === me.email : local
470
551
  const [text, setText] = useState('')
471
552
  const [copied, setCopied] = useState(false)
472
553
  const copyTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
@@ -0,0 +1,113 @@
1
+ /**
2
+ * The locked-shell entry (01-sharing §5.1's all-boards rule) - what a bundle
3
+ * whose every published board is locked to a stage mode boots into INSTEAD of
4
+ * the canvas shell. The build swaps this module in for App.tsx, so the canvas
5
+ * surface (sidebar, board grid, toolbar) is not hidden - its code never enters
6
+ * the bundle. Publish-to-web, as an artifact rather than a flag.
7
+ *
8
+ * What remains is exactly the locked surface: boot, the landing rule, the play
9
+ * or focus overlay (whose lock already refuses every exit), and deep links
10
+ * within what the lock allows.
11
+ */
12
+ import { Component, useEffect, useRef, type ReactNode } from 'react'
13
+ import { useStore, boardLabel, landingMode } from './store.ts'
14
+ import { enterFocus, enterPlay, PlayOverlay, playCtl } from './Play.tsx'
15
+ import { bootHash, parseHash, writeHash } from './hash.ts'
16
+ import { useComments } from './comments-store.ts'
17
+
18
+ export class ShellBoundary extends Component<{ children: ReactNode }, { err: Error | null }> {
19
+ state = { err: null as Error | null }
20
+ static getDerivedStateFromError(err: Error) { return { err } }
21
+ render() {
22
+ if (!this.state.err) return this.props.children
23
+ return (
24
+ <div style={{ fontFamily: 'ui-monospace, monospace', padding: 32 }}>
25
+ <b style={{ color: '#a81f16' }}>shell crashed</b>
26
+ <pre style={{ whiteSpace: 'pre-wrap', fontSize: 12 }}>{this.state.err.message}</pre>
27
+ <button onClick={() => location.reload()}>reload</button>
28
+ </div>
29
+ )
30
+ }
31
+ }
32
+
33
+ let booted = false
34
+
35
+ export function App() {
36
+ const toasts = useStore((s) => s.toasts)
37
+ const board = useStore((s) => s.board)
38
+
39
+ /** The landing rule, enforced: the mode the lock pinned, entered on boot and
40
+ * re-entered if anything ever drops the overlay - there is no other surface. */
41
+ const land = () => {
42
+ const s = useStore.getState()
43
+ if (s.play) return
44
+ const mode = landingMode(s.board)
45
+ if (mode === 'focus') enterFocus()
46
+ else enterPlay()
47
+ }
48
+
49
+ useEffect(() => {
50
+ if (booted) return
51
+ booted = true
52
+ const start = async () => {
53
+ if (bootHash.board && bootHash.board !== useStore.getState().board)
54
+ useStore.setState({ board: bootHash.board, boardAuto: false })
55
+ const ok = await boot()
56
+ urlReady.current = true
57
+ if (!ok) return
58
+ if (bootHash.focus) enterFocus(bootHash.focus.at, { ...bootHash.focus, deep: true })
59
+ else if (bootHash.play) enterPlay(bootHash.play)
60
+ else land()
61
+ }
62
+ const boot = useStore.getState().boot
63
+ void start()
64
+ }, [])
65
+
66
+ // the overlay IS the surface: if it ever closes, land again
67
+ const play = useStore((s) => s.play)
68
+ useEffect(() => { if (!play) setTimeout(land, 30) }, [play])
69
+
70
+ // the URL projection, minimal: stage state only (there is no selection here)
71
+ const urlReady = useRef(false)
72
+ useEffect(() => {
73
+ if (!urlReady.current || !play) return
74
+ if (play.focus && play.deep) writeHash({ focus: { at: play.at, device: play.device, theme: play.theme } })
75
+ else if (!play.focus) writeHash({ board: useStore.getState().board, play })
76
+ })
77
+
78
+ // back/forward + pasted links, within what the lock allows
79
+ useEffect(() => {
80
+ const onPop = async () => {
81
+ const h = parseHash()
82
+ const s = useStore.getState()
83
+ if (h.board && h.board !== s.board) {
84
+ await s.switchBoard(h.board)
85
+ land()
86
+ return
87
+ }
88
+ if (h.focus) enterFocus(h.focus.at, { ...h.focus, deep: true })
89
+ else if (h.play && s.play) playCtl.sync(h.play)
90
+ else if (h.play) enterPlay(h.play)
91
+ }
92
+ window.addEventListener('popstate', onPop)
93
+ window.addEventListener('hashchange', onPop)
94
+ return () => { window.removeEventListener('popstate', onPop); window.removeEventListener('hashchange', onPop) }
95
+ }, [])
96
+
97
+ // comment liveness for the stage overlay - same wiring the canvas shell does
98
+ useEffect(() => {
99
+ if (!board) return
100
+ const c = useComments.getState()
101
+ void c.load(board)
102
+ return c.live(board)
103
+ }, [board])
104
+
105
+ useEffect(() => { document.title = board ? `${boardLabel(board)} - Marver` : 'Marver' }, [board])
106
+
107
+ return (
108
+ <div tabIndex={-1} className="sh-app dark">
109
+ <PlayOverlay />
110
+ <div className="sh-toasts">{toasts.map((t) => <div className="sh-toast" key={t.id}>{t.text}</div>)}</div>
111
+ </div>
112
+ )
113
+ }
@@ -11,14 +11,14 @@
11
11
  * next) stays prototype-only.
12
12
  */
13
13
  import { useEffect, useRef, useState } from 'react'
14
- import { useStore, CONFIG, boardLabel, cap, fetchBoardNames, type Node } from './store.ts'
14
+ import { useStore, BOARD_POLICY, BRANDING, CONFIG, PUBLISHED, SOURCE_REVEALED, boardLabel, boardLocked, cap, fetchBoardNames, modeAllowed, type Node } from './store.ts'
15
15
  import { useComments } from './comments-store.ts'
16
16
  import { ROUTE } from '../const.ts'
17
- import { canvasCtl } from './canvas/Canvas.tsx'
17
+ import { canvasCtl } from './canvas/ctl.ts'
18
18
  import { Tip } from './Tip.tsx'
19
19
  import { CommentButton, DevicePicker, HideUIButton, isHideUI, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
20
20
  import { DraftComposer, hueVars, MarkerFace, ThreadCard } from './Comments.tsx'
21
- import { ArrowLeftIcon, ArrowRightIcon, CaretIcon, CheckIcon, PanelFilledIcon, PanelHollowIcon, ReloadIcon, XIcon } from './icons.tsx'
21
+ import { ArrowLeftIcon, ArrowRightIcon, BrowsersIcon, CaretIcon, CheckIcon, GridIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ReloadIcon, SpecDocIcon } from './icons.tsx'
22
22
 
23
23
  const commentsStore = () => useComments.getState()
24
24
 
@@ -44,6 +44,8 @@ function playList(): string[] {
44
44
  * off-board frames, so a link captured mid-flow must restore to the same screen. */
45
45
  export function enterPlay(over?: { at?: string; device?: string; theme?: string }) {
46
46
  const s = useStore.getState()
47
+ // the lock freezes what open pinned: a board locked to another mode never plays
48
+ if (!modeAllowed(s.board, 'present')) return
47
49
  const list = playList()
48
50
  if (!list.length) { s.toast('nothing to play on this board'); return }
49
51
  const overAt = over?.at && s.manifest?.frames.some((f) => f.id === over.at && f.kind === 'tsx') ? over.at : undefined
@@ -63,6 +65,34 @@ export function enterPlay(over?: { at?: string; device?: string; theme?: string
63
65
  s.setPlay({ at, device, theme })
64
66
  }
65
67
 
68
+ /**
69
+ * Enter Focus - the fourth view mode (01-sharing §6.1): one frame, full screen.
70
+ * Landing on a doc board opens its first frame at the doc preset; a frame deep
71
+ * link (`deep`) may target any playable manifest frame and carries the
72
+ * single-frame chrome - no board name, no canvas door. Presentation, not
73
+ * access: every published board stays reachable by URL (04-solution §2.35).
74
+ */
75
+ export function enterFocus(at?: string, over?: { device?: string; theme?: string; deep?: boolean }) {
76
+ const s = useStore.getState()
77
+ // a frame deep link may focus anywhere EXCEPT a board locked away from focus
78
+ // is still allowed - the lock caps THIS board's disclosure, and a deep link
79
+ // into a locked board stays in focus on that frame (01-sharing §6.1 rule 4);
80
+ // a non-deep focus entry respects the lock like any other mode change
81
+ if (!over?.deep && !modeAllowed(s.board, 'focus')) return
82
+ const list = playList()
83
+ const target = at && s.manifest?.frames.some((f) => f.id === at && f.kind === 'tsx') ? at : list[0]
84
+ if (!target) { s.toast('nothing to focus here'); return }
85
+ const frame = s.manifest?.frames.find((f) => f.id === target)
86
+ const node = s.nodes.find((n) => n.frame === target && !n.missing)
87
+ const names = Object.keys(CONFIG.viewports)
88
+ const device = (over?.device && (CONFIG.viewports[over.device] || over.device === 'fill') ? over.device : undefined)
89
+ ?? (node ? names.find((v) => CONFIG.viewports[v].width === node.w) : undefined)
90
+ ?? (frame?.viewport && CONFIG.viewports[frame.viewport] ? frame.viewport : names[0])
91
+ const theme = (over?.theme && CONFIG.themes.includes(over.theme) ? over.theme : undefined)
92
+ ?? node?.theme ?? frame?.theme ?? s.viewTheme
93
+ s.setPlay({ at: target, device, theme, focus: true, ...(over?.deep ? { deep: true } : {}) })
94
+ }
95
+
66
96
  /** Switch boards WITHOUT leaving play: the overlay stays up over the canvas churn, then
67
97
  * a fresh stage mounts at the new board's start (PlayInner is keyed by board). */
68
98
  async function switchPlayBoard(name: string) {
@@ -229,6 +259,13 @@ function PlayInner() {
229
259
  const [pillOpen, setPillOpen] = useState(true) // collapse (pill -> re-open fab), like the canvas
230
260
  const [stageReady, setStageReady] = useState(0) // bumps on each sh:stage-ready (reload) - re-drives the highlight
231
261
  const fill = play?.device === 'fill'
262
+ // the mode's shape: focus is single-frame; the doc preset (reading width,
263
+ // page ground) applies when the board's declared type is doc; lock and deep
264
+ // links both drop the canvas door (lock as policy, deep as the link's chrome)
265
+ const focus = !!play?.focus
266
+ const docPreset = focus && BOARD_POLICY[board]?.type === 'doc'
267
+ const locked = boardLocked(board)
268
+ const noDoor = locked || !!play?.deep
232
269
 
233
270
  // the stage is same-origin (/__mv/stage/); a fixed target origin keeps anchor bundles
234
271
  // from leaking if a link ever navigates the iframe cross-origin
@@ -250,6 +287,9 @@ function PlayInner() {
250
287
  useEffect(() => { postStage({ type: 'sh:pick', on: commentMode, quiet: !useComments.getState().showAnchor }) }, [commentMode, playShowAnchor])
251
288
 
252
289
  const exit = () => {
290
+ // the lock caps disclosure: a locked board has no way out into other modes,
291
+ // and a deep-link visit carries no door on the surface (URL still works)
292
+ if (noDoor) return
253
293
  const { at } = useStore.getState().play ?? {}
254
294
  if (isHideUI()) toggleHideUI() // never strand the canvas with its chrome hidden
255
295
  useStore.getState().setPlay(null)
@@ -272,14 +312,14 @@ function PlayInner() {
272
312
  const goTo = (at: string) => playCtl.setAt(at)
273
313
  const step = (dir: 1 | -1) => {
274
314
  const p = useStore.getState().play
275
- if (!p) return
315
+ if (!p || p.focus) return // focus is one frame - no walking
276
316
  const list = playList()
277
317
  if (!list.length) return
278
318
  const i = list.indexOf(p.at)
279
319
  // an off-board frame has no position: → restarts, ← goes to the last board frame
280
320
  goTo(i === -1 ? (dir === 1 ? list[0] : list[list.length - 1]) : list[(i + dir + list.length) % list.length])
281
321
  }
282
- const restart = () => { const list = playList(); if (list.length) goTo(list[0]) }
322
+ const restart = () => { if (useStore.getState().play?.focus) return; const list = playList(); if (list.length) goTo(list[0]) }
283
323
 
284
324
  /** Variant siblings of the CURRENT frame present on this board: the
285
325
  * review question is "which direction is better on THIS screen" - switch in place,
@@ -398,7 +438,7 @@ function PlayInner() {
398
438
  if (key === ']') { switchVariant(1); return }
399
439
  if (key === 'r') { restart(); return }
400
440
  if (key === 'h') { toggleHideUI(); return }
401
- if (key === 'l') { const s = useStore.getState(); if (!s.laser) commentsStore().setMode(false); s.setLaser(!s.laser); return }
441
+ if (key === 'l' && SOURCE_REVEALED) { const s = useStore.getState(); if (!s.laser) commentsStore().setMode(false); s.setLaser(!s.laser); return }
402
442
  if (key === 'c') { const c = commentsStore(); if (!c.commentMode) useStore.getState().setLaser(false); c.setMode(!c.commentMode); return }
403
443
  if (key === 'C') { const c = commentsStore(); c.setShow(!c.show); return } // ⇧C hides/shows pins
404
444
  if (key === 'L') { const c = commentsStore(); c.setShowAnchor(!c.showAnchor); return } // ⇧L element focus
@@ -435,12 +475,18 @@ function PlayInner() {
435
475
 
436
476
  if (!play) return null // parent gates on play; belt to its braces
437
477
 
438
- const vp = fill ? { width: win.w, height: win.h } : CONFIG.viewports[play.device] ?? Object.values(CONFIG.viewports)[0]
439
- const scale = fill ? 1 : Math.min(1, (win.w - 96) / vp.width, (win.h - 128) / vp.height)
478
+ // doc preset: reading width, natural document scrolling - the iframe takes the
479
+ // window's height at min(width, 860) CSS pixels, unscaled, and scrolls itself
480
+ const vp = docPreset ? { width: Math.min(win.w, 860), height: win.h }
481
+ : fill ? { width: win.w, height: win.h }
482
+ : CONFIG.viewports[play.device] ?? Object.values(CONFIG.viewports)[0]
483
+ const scale = fill || docPreset ? 1 : Math.min(1, (win.w - 96) / vp.width, (win.h - 128) / vp.height)
440
484
  const names = Object.keys(CONFIG.viewports)
441
- const list = playList()
485
+ const list = focus ? [play.at] : playList()
442
486
  const pos = list.indexOf(play.at)
443
487
  const variants = variantList()
488
+ const frameEntry = useStore.getState().manifest?.frames.find((f) => f.id === play.at)
489
+ const title = focus ? (frameEntry?.title ?? (play.at.split('/').pop() ?? play.at).replace(/-/g, ' ')) : boardLabel(board)
444
490
 
445
491
  // whole-pixel wrapper + per-axis scale so the iframe lands exactly on its edges -
446
492
  // fractional sizes left subpixel seams glowing at the corners on dark frames
@@ -449,7 +495,7 @@ function PlayInner() {
449
495
  const deviceHint = fill ? 'Fill window' : `${vp.width} × ${vp.height} · keys 1-${names.length + 1}`
450
496
 
451
497
  return (
452
- <div className={`sh-play${fill ? ' fill' : ''}`}>
498
+ <div className={`sh-play${fill || docPreset ? ' fill' : ''}${docPreset ? ` doc t-${play.theme}` : ''}`}>
453
499
  {/* device + comment overlay share ONE flex-centered box; the overlay is inset:0 over
454
500
  it (a sibling of the clipping .dev, so a thread card is never eaten by overflow) */}
455
501
  <div className="sh-play-stage" style={{ width: dw, height: dh }}>
@@ -464,13 +510,35 @@ function PlayInner() {
464
510
  <PlayComments iframe={iframeRef} frameId={play.at} vp={vp} dw={dw} dh={dh} ready={stageReady} />
465
511
  </div>
466
512
 
513
+ {!focus && list.length > 1 && (
514
+ <div className="sh-play-hint">← → to step · <kbd>c</kbd> to comment</div>
515
+ )}
516
+
517
+ {/* top-left: brand + place (the present chrome's identity pill). A deep-link
518
+ focus visit names only the frame - single-frame chrome, no board (04 §2.35);
519
+ branding: false strips the mark (invariant 21). */}
520
+ <nav className={`sh-pill sh-play-pill sh-play-brand${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
521
+ {BRANDING && <>
522
+ <span className="sh-play-mark"><ParallelogramDuoIcon size={19} /><span className="wd">Marver</span></span>
523
+ <i className="sep" />
524
+ </>}
525
+ {focus && <SpecDocIcon size={15} style={{ flex: 'none', opacity: .7, marginRight: -2 }} />}
526
+ {!focus && !locked && !PUBLISHED ? <BoardMenu current={board} /> : (
527
+ <Tip side="bottom" label={<b>{CONFIG.projectName ?? title}</b>}>
528
+ <span className="sh-play-title">{title}</span>
529
+ </Tip>
530
+ )}
531
+ </nav>
532
+
467
533
  <nav className={`sh-pill sh-play-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
468
- <BoardMenu current={board} />
469
- <i className="sep" />
534
+ {!focus && list.length > 1 && <>
535
+ <span className="sh-play-pos">{pos === -1 ? '·' : pos + 1} / {list.length}</span>
536
+ <i className="sep" />
537
+ </>}
470
538
  <CommentButton />
471
539
  <LaserButton />
472
540
  <i className="sep" />
473
- <DevicePicker value={fill ? 'fill' : play.device} onSelect={(n) => n && setDevice(n)} includeFill hint={deviceHint} dark />
541
+ {!docPreset && <DevicePicker value={fill ? 'fill' : play.device} onSelect={(n) => n && setDevice(n)} includeFill hint={deviceHint} dark />}
474
542
  <ThemePicker value={play.theme} onSelect={setTheme} hint="D" dark />
475
543
  {playUpdateRevision && <>
476
544
  <i className="sep" />
@@ -485,16 +553,27 @@ function PlayInner() {
485
553
  <PanelFilledIcon size={17} style={{ transform: 'rotate(90deg)' }} />
486
554
  </button>
487
555
  </Tip>
488
- <Tip side="bottom" label={<><b>Exit Prototype view</b><span>Esc</span></>}>
489
- <button className="sh-pill-btn" onClick={exit}><XIcon size={14} /></button>
490
- </Tip>
556
+ {/* the canvas door: absent on a locked board (policy) and on a frame
557
+ deep link (the link's chrome) - not hidden, not rendered */}
558
+ {!noDoor && (
559
+ <Tip side="bottom" label={<><b>Open in canvas</b><span>Esc</span></>}>
560
+ <button className="sh-pill-btn" onClick={exit}><GridIcon size={16} /></button>
561
+ </Tip>
562
+ )}
563
+ {BRANDING && PUBLISHED && (
564
+ <Tip side="bottom" label={<b>Open in app</b>}>
565
+ <a className="sh-pill-btn" href="https://app.marver.design" target="_blank" rel="noopener" aria-label="Open in app">
566
+ <BrowsersIcon size={16} />
567
+ </a>
568
+ </Tip>
569
+ )}
491
570
  </nav>
492
571
  <Tip side="bottom" label={<><b>Open toolbar</b><span>⌘/</span></>}>
493
572
  <button className={`sh-pill-fab${pillOpen ? ' hidden' : ''}`} onClick={() => setPillOpen(true)}
494
573
  aria-hidden={pillOpen} tabIndex={pillOpen ? -1 : 0}><PanelHollowIcon size={18} style={{ transform: 'rotate(90deg)' }} /></button>
495
574
  </Tip>
496
575
 
497
- <div className="sh-play-nav">
576
+ {!focus && <div className="sh-play-nav">
498
577
  <Tip inv label={<><b>Restart</b><span className="k">R</span></>}>
499
578
  <button onClick={restart}><ReloadIcon size={14} /></button>
500
579
  </Tip>
@@ -523,7 +602,7 @@ function PlayInner() {
523
602
  return <Tip inv label={<b>{full}</b>}><span className="vname">{full}</span></Tip>
524
603
  })()}
525
604
  </>}
526
- </div>
605
+ </div>}
527
606
  </div>
528
607
  )
529
608
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { createPortal } from 'react-dom'
10
10
  import { useEffect, useRef, useState, type ReactNode } from 'react'
11
- import { useStore, CONFIG, cap } from './store.ts'
11
+ import { useStore, CONFIG, SOURCE_REVEALED, cap } from './store.ts'
12
12
  import { useComments } from './comments-store.ts'
13
13
  import { Tip } from './Tip.tsx'
14
14
  import { CaretIcon, CheckIcon, CommentIcon, DevicesIcon, EyeSlashIcon, FrameCornersIcon, LaserIcon, MoonIcon, SunIcon, deviceIcon } from './icons.tsx'
@@ -89,6 +89,9 @@ function TipRows({ rows }: { rows: [string, string][] }) {
89
89
  export function LaserButton() {
90
90
  const laser = useStore((s) => s.laser)
91
91
  const showAnchor = useComments((s) => s.showAnchor)
92
+ // stripped build: the paths are not in the bundle, so the button that would
93
+ // copy them has nothing honest to offer
94
+ if (!SOURCE_REVEALED) return null
92
95
  return (
93
96
  <Tip side="bottom" label={<TipRows rows={[['Laser mode', 'L'], [showAnchor ? 'Hide laser comment' : 'Show laser comment', '⇧L']]} />}>
94
97
  <button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
@@ -99,10 +102,31 @@ export function LaserButton() {
99
102
  )
100
103
  }
101
104
 
102
- /** Comment toggle - store-level, identical on canvas and prototype. */
105
+ /** Comment toggle - store-level, identical on canvas and prototype.
106
+ * A viewer whose role stops at view on this board sees the LOCKED capability:
107
+ * the honest message ("not available for you yet") and the Ask-to-comment
108
+ * path into the owner's request queue (04-solution §2.35). */
103
109
  export function CommentButton() {
104
110
  const commentMode = useComments((s) => s.commentMode)
105
111
  const show = useComments((s) => s.show)
112
+ const me = useComments((s) => s.me)
113
+ const myBoards = useComments((s) => s.myBoards)
114
+ const board = useStore((s) => s.board)
115
+ const asked = useRef(0)
116
+ const lockedOut = !!me && !!myBoards && myBoards[board] === 'view'
117
+ if (lockedOut) {
118
+ return (
119
+ <Tip side="bottom" label={<><b>Commenting is not available for you yet</b><span>click to ask the owner</span></>}>
120
+ <button className="sh-pill-btn" style={{ opacity: .55 }} onClick={() => {
121
+ const s = useStore.getState()
122
+ if (Date.now() - asked.current < 8000) return
123
+ asked.current = Date.now()
124
+ void commentsStore().askToComment().then((ok) =>
125
+ s.toast(ok ? 'asked the owner for comment access - they decide' : 'could not send the request - try again'))
126
+ }}><CommentIcon size={16} /></button>
127
+ </Tip>
128
+ )
129
+ }
106
130
  return (
107
131
  <Tip side="bottom" label={<TipRows rows={[['Comment', 'C'], [show ? 'Hide pins' : 'Show pins', '⇧C']]} />}>
108
132
  <button className={`sh-pill-btn${commentMode ? ' on' : ''}`} onClick={() => {
@@ -84,21 +84,11 @@ export function animateLayout(ms = 360) {
84
84
  presetTimer = window.setTimeout(() => w.classList.remove('sh-preset'), ms)
85
85
  }
86
86
 
87
- /** B0.2: one wheel event, whatever its origin - a shell-document wheel over the canvas, or
88
- * a wheel forwarded from a passive frame's iframe. clientX/Y are shell-viewport pixels. */
89
- export interface CanvasWheelInput {
90
- deltaX: number; deltaY: number; deltaMode: number
91
- ctrlKey: boolean; metaKey: boolean; clientX: number; clientY: number
92
- }
93
-
94
- export const canvasCtl = {
95
- fitNode(_key: string) {},
96
- fitNodes(_keys: string[]) {},
97
- fitAll() {},
98
- zoomTo(_scale: number) {},
99
- zoom100() { canvasCtl.zoomTo(1) },
100
- wheel(_input: CanvasWheelInput) {},
101
- }
87
+ // the control channel lives in ctl.ts so play/focus can import it without
88
+ // dragging the canvas into a locked-shell bundle; Canvas assigns the real
89
+ // implementations below when it mounts
90
+ import { canvasCtl, type CanvasWheelInput } from './ctl.ts'
91
+ export { canvasCtl, type CanvasWheelInput }
102
92
 
103
93
  /** Variant-group captions: "Landing · 3 variants" above each group with
104
94
  * 2+ members on this board. World-space (scales with the canvas); min screen size via