@marver-design/marver 0.11.0 → 0.12.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 +83 -5
  2. package/README.md +17 -4
  3. package/dist/auth-DdUKeVKt.mjs +1150 -0
  4. package/dist/{build-Cr_ZLMdq.mjs → build-DmfxJc6L.mjs} +188 -33
  5. package/dist/cli.mjs +16 -7
  6. package/dist/collab-BmaOjwP1.mjs +781 -0
  7. package/dist/{comments-CRqaO2MH.mjs → comments-pNxjTrGN.mjs} +3 -2
  8. package/dist/{daemon-DSmaS453.mjs → daemon-WjonKlPz.mjs} +3 -3
  9. package/dist/{dev-CLBjRs0J.mjs → dev-BXq7yE1p.mjs} +5 -5
  10. package/dist/events-C9k-ceOj.mjs +101 -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-DOjnZakC.mjs} +94 -6
  15. package/dist/{plugin-vE7sxEeT.mjs → plugin-Boy7I_JH.mjs} +82 -9
  16. package/dist/{profile-DcsJyppw.mjs → profile-ChpTPd-X.mjs} +1 -1
  17. package/dist/secure-cookie-_K1Hsx8H.mjs +26 -0
  18. package/dist/{serve-CZ1KEQxd.mjs → serve-BeLLizNg.mjs} +72 -18
  19. package/dist/share-C64b1tdN.mjs +159 -0
  20. package/dist/summary-Bf9tiGrE.mjs +211 -0
  21. package/dist/{sync-DELGomPk.mjs → sync-WYxDN9IT.mjs} +3 -102
  22. package/package.json +1 -1
  23. package/src/client/shell/App.tsx +43 -8
  24. package/src/client/shell/Comments.tsx +9 -5
  25. package/src/client/shell/LockedApp.tsx +113 -0
  26. package/src/client/shell/Play.tsx +97 -18
  27. package/src/client/shell/Toolbar.tsx +26 -2
  28. package/src/client/shell/canvas/Canvas.tsx +5 -15
  29. package/src/client/shell/canvas/ctl.ts +26 -0
  30. package/src/client/shell/comments-store.ts +39 -9
  31. package/src/client/shell/hash.ts +19 -0
  32. package/src/client/shell/icons.tsx +1 -0
  33. package/src/client/shell/store.ts +54 -3
  34. package/src/client/shell/styles.css +27 -0
  35. package/src/shared/events.ts +8 -3
  36. package/templates/instructions/publish.md +15 -1
  37. package/dist/auth-B5yuwnOq.mjs +0 -494
  38. package/dist/collab-C-n-uQdm.mjs +0 -364
  39. package/dist/manifest-C2tzkNaC.mjs +0 -233
  40. package/dist/update-DuWDj5nR.mjs +0 -77
  41. package/dist/utm-CxC3QN5X.mjs +0 -20
@@ -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
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The canvas control channel, in its own dependency-free module.
3
+ *
4
+ * Play, Comments and App steer the camera through this object; Canvas assigns
5
+ * the real implementations when it mounts. Living here rather than inside
6
+ * Canvas.tsx keeps the play/focus surfaces importable WITHOUT the canvas - a
7
+ * locked-shell bundle mounts the stage chrome and the canvas code never enters
8
+ * it (01-sharing §5.1's all-boards rule). Before Canvas mounts (or when it
9
+ * never does), every call is a harmless no-op.
10
+ */
11
+
12
+ /** B0.2: one wheel event, whatever its origin - a shell-document wheel over the canvas, or
13
+ * a wheel forwarded from a passive frame's iframe. clientX/Y are shell-viewport pixels. */
14
+ export interface CanvasWheelInput {
15
+ deltaX: number; deltaY: number; deltaMode: number
16
+ ctrlKey: boolean; metaKey: boolean; clientX: number; clientY: number
17
+ }
18
+
19
+ export const canvasCtl = {
20
+ fitNode(_key: string) {},
21
+ fitNodes(_keys: string[]) {},
22
+ fitAll() {},
23
+ zoomTo(_scale: number) {},
24
+ zoom100() { canvasCtl.zoomTo(1) },
25
+ wheel(_input: CanvasWheelInput) {},
26
+ }
@@ -8,13 +8,28 @@ import { create } from 'zustand'
8
8
  import { replay, type CommentEvent, type Thread } from '../../shared/events.ts'
9
9
  import { ROUTE } from '../const.ts'
10
10
 
11
- export interface Me { email: string; name: string; avatar?: string }
11
+ export interface Me {
12
+ email: string
13
+ name: string
14
+ avatar?: string
15
+ /** The viewer's own opaque author id on a published canvas - what projected
16
+ * events carry instead of an email. "Is this mine" compares ids first. */
17
+ id?: string
18
+ }
19
+
20
+ /** The author snapshot a POST carries: the canonical profile, never the opaque
21
+ * id - that field is server-assigned, and the server refuses it in writes. */
22
+ const postAuthor = (me: Me | null): { email: string; name?: string; avatar?: string } | undefined =>
23
+ me ? { email: me.email, name: me.name, ...(me.avatar ? { avatar: me.avatar } : {}) } : undefined
12
24
 
13
25
  interface CommentsState {
14
26
  events: CommentEvent[] // EVERY board's log, unioned - comments are frame-scoped
15
27
  threads: Thread[] // derived on every change; each carries its origin board
16
28
  board: string | null
17
29
  me: Me | null // null on published until signed in
30
+ /** The viewer's effective role per board (published) - what makes a locked
31
+ * capability honest: `view` on a comment board = "not available for you YET". */
32
+ myBoards: Record<string, string> | null
18
33
  local: boolean // dev mirror (identity is the local profile)
19
34
  connected: boolean // dev only: a connect account provides name/email (read-only here)
20
35
  commentMode: boolean // C - picking + composing
@@ -48,6 +63,8 @@ interface CommentsState {
48
63
  claim(token: string, password: string, name: string, avatar?: string): Promise<string | null>
49
64
  saveProfile(patch: Partial<Me>): Promise<string | null>
50
65
  dismissIdentity(): void
66
+ /** The admitted viewer's "Ask to comment" - one request into the owner's queue. */
67
+ askToComment(): Promise<boolean>
51
68
  }
52
69
 
53
70
  const uuid = () => crypto.randomUUID()
@@ -145,7 +162,7 @@ export const useComments = create<CommentsState>((set, get) => {
145
162
  }
146
163
 
147
164
  return {
148
- events: [], threads: [], board: null, me: null, local: false, connected: false,
165
+ events: [], threads: [], board: null, me: null, myBoards: null, local: false, connected: false,
149
166
  commentMode: false, show: true, showAnchor: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
150
167
 
151
168
  poke(board) {
@@ -165,8 +182,16 @@ export const useComments = create<CommentsState>((set, get) => {
165
182
  else set({ board })
166
183
  const me = await api('me')
167
184
  if (me.status === 404) { apiOff = true; return } // static serve: no comments API at all
168
- if (me.ok) set({ me: me.data.user ?? null, local: !!me.data.local, connected: !!me.data.connected })
185
+ if (me.ok) set({
186
+ me: me.data.user ? { ...me.data.user, ...(me.data.id ? { id: me.data.id } : {}) } : null,
187
+ myBoards: me.data.boards && typeof me.data.boards === 'object' ? me.data.boards : null,
188
+ local: !!me.data.local, connected: !!me.data.connected,
189
+ })
169
190
  await fetchAll()
191
+ // mark-seen: the board was actually PRESENTED (load runs on open, never
192
+ // from the background poll), so the viewer's unread mark advances - the
193
+ // front door's counter moves on this and nothing else (02-home §4)
194
+ if (!get().local && get().me) void api('seen', { board }).catch(() => {})
170
195
  },
171
196
 
172
197
  /** Liveness: SSE on the published serve; dev has no event rail (its sync loop
@@ -228,7 +253,7 @@ export const useComments = create<CommentsState>((set, get) => {
228
253
  const ok = await get().send([{
229
254
  id: uuid(), ts: Date.now(), type: 'create', commentId: id,
230
255
  nodeKey: draft.nodeKey, frame: draft.frame, anchor: draft.anchor,
231
- author: me ?? undefined, body: body.trim(),
256
+ author: postAuthor(me), body: body.trim(),
232
257
  }], draft.board)
233
258
  if (ok) set({ draft: null, active: id, commentMode: false })
234
259
  },
@@ -241,7 +266,7 @@ export const useComments = create<CommentsState>((set, get) => {
241
266
  const origin = get().threads.find((t) => t.id === threadId)?.board
242
267
  return get().send([{
243
268
  id: uuid(), ts: Date.now(), type: 'reply', commentId: uuid(), parentId: threadId,
244
- author: get().me ?? undefined, body: body.trim(),
269
+ author: postAuthor(get().me), body: body.trim(),
245
270
  }], origin)
246
271
  },
247
272
 
@@ -275,20 +300,25 @@ export const useComments = create<CommentsState>((set, get) => {
275
300
  set({ me: res.data.user })
276
301
  return null
277
302
  },
303
+ async askToComment() {
304
+ const res = await api('request-access', { requestedRole: 'comment' })
305
+ return res.ok
306
+ },
278
307
  }
279
308
  })
280
309
 
281
310
  // dev-only debug handle - the canvas store exposes the same
282
311
  if (typeof window !== 'undefined' && (import.meta as any).env?.DEV) (window as any).__mvComments = useComments
283
312
 
284
- /** Initials + deterministic hue for avatarless authors - the whole fallback ladder. */
285
- export const avatarFallback = (author?: { email?: string; name?: string }) => {
313
+ /** Initials + deterministic hue for avatarless authors - the whole fallback ladder.
314
+ * Hue keys on the stable identity: email locally, the opaque id when projected. */
315
+ export const avatarFallback = (author?: { email?: string; id?: string; name?: string }) => {
286
316
  const name = author?.name || author?.email || '?'
287
317
  // the unset dev default ("You", no account) renders in the COMMENT green - it's the mode's
288
318
  // own hue, not a fake identity color, until a real profile is set
289
- if (name === 'You' && !author?.email) return { initials: 'Y', hue: 131 }
319
+ if (name === 'You' && !author?.email && !author?.id) return { initials: 'Y', hue: 131 }
290
320
  const initials = name.split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase()
291
321
  let h = 0
292
- for (const c of (author?.email || name)) h = (h * 31 + c.charCodeAt(0)) % 360
322
+ for (const c of (author?.email || author?.id || name)) h = (h * 31 + c.charCodeAt(0)) % 360
293
323
  return { initials, hue: h }
294
324
  }
@@ -10,6 +10,9 @@
10
10
  * #/b/<board>?c=<id> board with a comment thread open
11
11
  * #/i/<token> invite link - opens the claim dialog with the token
12
12
  * #/p/<board>?at=<frame-id>&device=<viewport>&theme=<theme> play mode
13
+ * #/f/<frame-id>?device=&theme= focus - one frame, full screen, no board in
14
+ * the chrome (a frame deep link is presentation,
15
+ * not access: 01-sharing §3.5)
13
16
  */
14
17
 
15
18
  export interface HashState {
@@ -18,6 +21,8 @@ export interface HashState {
18
21
  c?: string
19
22
  invite?: string
20
23
  play?: { at?: string; device?: string; theme?: string }
24
+ /** A frame deep link: lands that visit in focus, over every other rule. */
25
+ focus?: { at: string; device?: string; theme?: string }
21
26
  }
22
27
 
23
28
  const BOARD_RE = /^[a-z0-9][a-z0-9-]*$/
@@ -31,6 +36,13 @@ export function parseHash(hash: string = location.hash): HashState {
31
36
  const params = new URLSearchParams(q === -1 ? '' : raw.slice(q + 1))
32
37
  const mi = path.match(/^\/i\/([\w-]{8,128})$/)
33
38
  if (mi) return { invite: mi[1] }
39
+ // frame ids are <scene>/<name> (or bare <name>), so the f route takes the rest of the path
40
+ const mf = path.match(/^\/f\/(.+)$/)
41
+ if (mf) {
42
+ const at = decodeURIComponent(mf[1])
43
+ if (!/^[\w./-]{1,200}$/.test(at) || at.includes('..')) return {}
44
+ return { focus: { at, device: params.get('device') ?? undefined, theme: params.get('theme') ?? undefined } }
45
+ }
34
46
  const m = path.match(/^\/(b|p)\/([^/?]+)$/)
35
47
  if (!m) return {}
36
48
  const board = decodeURIComponent(m[2])
@@ -52,6 +64,13 @@ export function parseHash(hash: string = location.hash): HashState {
52
64
  }
53
65
 
54
66
  export function buildHash(s: HashState): string {
67
+ if (s.focus?.at) {
68
+ const p = new URLSearchParams()
69
+ if (s.focus.device) p.set('device', s.focus.device)
70
+ if (s.focus.theme) p.set('theme', s.focus.theme)
71
+ const q = p.toString()
72
+ return `#/f/${s.focus.at}${q ? `?${q}` : ''}`
73
+ }
55
74
  if (s.play?.at && s.board) {
56
75
  const p = new URLSearchParams({ at: s.play.at })
57
76
  if (s.play.device) p.set('device', s.play.device)
@@ -86,3 +86,4 @@ export function deviceIcon(name: string | null, size = 14) {
86
86
  return <Icon size={size} />
87
87
  }
88
88
 
89
+ export const BrowsersIcon = icon(<path d="M216,40H72A16,16,0,0,0,56,56V72H40A16,16,0,0,0,24,88V200a16,16,0,0,0,16,16H184a16,16,0,0,0,16-16V184h16a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM184,88v16H40V88Zm0,112H40V120H184v80Zm32-32H200V88a16,16,0,0,0-16-16H72V56H216Z" />)
@@ -10,11 +10,28 @@ import shData from 'virtual:sh-data'
10
10
  /** Published-build data. Non-null = static site: no API, no saves, no live events.
11
11
  * `names` is the switcher list (all-scenes only when actually published); `default`
12
12
  * is where `/` opens - never a synthesized aggregate of a filtered build. */
13
- const DATA: { manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string } | null = shData
13
+ const DATA: {
14
+ manifest: Manifest; boards: Record<string, unknown>; names: string[]; default: string
15
+ /** publish.json v2: per-board artifact type + open/lock, and the reveal flags. */
16
+ policy?: { boards: Record<string, { type?: string; open?: string; lock?: boolean }>; reveal?: { structure?: boolean; source?: boolean }; lockedShell?: boolean }
17
+ } | null = shData
18
+
19
+ /** Every published board is locked to a stage mode - the canvas shell is never
20
+ * offered on this bundle (01-sharing §5.1's all-boards rule). */
21
+ export const LOCKED_SHELL = DATA?.policy?.lockedShell === true
14
22
 
15
23
  /** True on a published static canvas - no dev server, no API, no update checks. */
16
24
  export const PUBLISHED = DATA !== null
17
25
 
26
+ /** May this bundle show source paths (laser copy)? Dev always; published only
27
+ * when publish.json says `reveal.source: true`. The strip already removed the
28
+ * paths from the bundle - this flag removes the affordance that would show
29
+ * their opaque replacements (01-sharing §6.2). */
30
+ export const SOURCE_REVEALED = DATA ? DATA.policy?.reveal?.source === true : true
31
+
32
+ /** The published per-board artifact metadata (type, open, lock). Empty in dev. */
33
+ export const BOARD_POLICY: Record<string, { type?: string; open?: string; lock?: boolean }> = DATA?.policy?.boards ?? {}
34
+
18
35
  export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string; intent?: string; contentWidth?: number }
19
36
  export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
20
37
  export interface Node {
@@ -48,7 +65,37 @@ export interface Node {
48
65
  export interface JamNote { threadId: string; board: string; preview: string; ts: number; frame?: string; frameTitle?: string; intent?: string }
49
66
  export interface Toast { id: number; text: string; jam?: JamNote }
50
67
 
51
- export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string } = shConfig
68
+ export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string; branding?: boolean } = shConfig
69
+
70
+ /** Invariant: `branding: false` strips every Marver mention - new chrome included. */
71
+ export const BRANDING = CONFIG.branding !== false
72
+
73
+ /** The five view modes' landing map (04-solution §2.35): the artifact type picks
74
+ * the default view; an explicit `open` beats it; a frame deep link beats both. */
75
+ export function landingMode(board: string): 'canvas' | 'board' | 'present' | 'focus' | 'slides' {
76
+ const p = BOARD_POLICY[board]
77
+ if (!p) return 'canvas'
78
+ if (p.open && ['canvas', 'board', 'present', 'focus', 'slides'].includes(p.open)) {
79
+ // slides is v1.5 - until the mode exists it lands in present, its nearest kin
80
+ return p.open === 'slides' ? 'present' : p.open as any
81
+ }
82
+ switch (p.type) {
83
+ case 'doc': return 'focus'
84
+ case 'slides': return 'present' // slides mode is v1.5; present until then
85
+ case 'design': case 'sketch': case 'refs': return 'board'
86
+ default: return 'canvas'
87
+ }
88
+ }
89
+
90
+ /** Is this board frozen to its landing mode? The lock caps disclosure - no way
91
+ * out into other modes, and (published) no sidebar ever renders for it. */
92
+ export const boardLocked = (board: string): boolean => BOARD_POLICY[board]?.lock === true
93
+
94
+ /** May this board enter `mode`? A lock freezes what open pinned - a board
95
+ * locked to canvas must refuse present just as one locked to present refuses
96
+ * canvas. Unlocked boards enter anything. */
97
+ export const modeAllowed = (board: string, mode: 'present' | 'focus'): boolean =>
98
+ !boardLocked(board) || landingMode(board) === mode
52
99
 
53
100
  export { cap, humanize } from './labels.ts'
54
101
  import { cap, humanize } from './labels.ts'
@@ -148,7 +195,11 @@ interface State {
148
195
  selection: string[] // ordered; last entry is the primary (bar anchor)
149
196
  interact: string | null
150
197
  viewTheme: string // the global theme preference; sticky across boards and reloads
151
- play: { at: string; device: string; theme: string } | null // play mode; at = current frame id
198
+ /** Play mode; at = current frame id. `focus` = the single-frame view (the
199
+ * fourth mode): no walking, doc reading preset when the board type is doc.
200
+ * `deep` = entered by a frame deep link - the chrome then carries no board
201
+ * name and no canvas door (presentation default of the link, 04 §2.35). */
202
+ play: { at: string; device: string; theme: string; focus?: boolean; deep?: boolean } | null
152
203
  gesture: boolean // a frame drag/resize is in progress - canvas panning is disabled
153
204
  laser: boolean // laser/inspect mode: frames outline their structure
154
205
  board: string // active board name; 'all-scenes' is the auto board
@@ -856,6 +856,33 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
856
856
  .sh-play-pill .sep { background: rgba(255, 255, 255, .12) }
857
857
  .sh-play-pill .bd { color: rgba(245, 245, 247, .82) }
858
858
  .sh-pill-btn.bd { width: auto; gap: 6px; padding: 0 8px 0 11px; white-space: nowrap; font-weight: 600 }
859
+
860
+ /* ---- present/focus chrome (sharing v1): brand pill top-left, counter in the
861
+ toolbar, doc reading preset. Same glass skin as the play pill. */
862
+ .sh-play-brand.sh-pill { left: var(--edge); right: auto; transform-origin: 22px 22px; padding: 5px 14px 5px 10px }
863
+ .sh-play-mark { display: inline-flex; align-items: center; gap: 7px; padding: 0 2px 0 6px; color: rgba(245, 245, 247, .88) }
864
+ .sh-play-mark svg { color: #6db3ff }
865
+ .sh-play-mark .wd { font: 600 13px -apple-system, system-ui, sans-serif; letter-spacing: -.01em }
866
+ .sh-play-title { font: 500 13px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .82);
867
+ max-width: 143px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 6px }
868
+ .sh-play-pos { font: 500 12.5px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .64);
869
+ font-variant-numeric: tabular-nums; padding: 0 8px; white-space: nowrap }
870
+ /* the hint pill: bottom center, the design's quiet keyboard cue */
871
+ .sh-play-hint { position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); z-index: 31;
872
+ border-radius: 999px; padding: 7px 16px; white-space: nowrap;
873
+ background: rgba(18, 18, 24, .8); border: 1px solid rgba(255, 255, 255, .1);
874
+ backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
875
+ font: 500 12px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .64) }
876
+ .sh-play-hint kbd { font: 500 11.5px ui-monospace, monospace }
877
+ body.sh-hide-ui .sh-play-hint { display: none !important }
878
+ /* doc preset: the page IS the surface - reading column on a calm ground, no
879
+ device shell, natural document scrolling inside the frame */
880
+ .sh-play.doc { background: #f4f4f6 }
881
+ .sh-play.doc.t-dark { background: #101116 }
882
+ .sh-play.doc .dev { --pdev-bg: #fff; box-shadow: none }
883
+ .sh-play.doc.t-dark .dev { --pdev-bg: #0f1015 }
884
+ /* the doc chrome floats over a LIGHT ground in light theme - the dark pill skin
885
+ still reads (it is glass over anything), matching the design's fixed pills */
859
886
  .sh-play-update { width: auto !important; gap: 5px; padding: 0 11px !important; font-size: 12px; font-weight: 600 }
860
887
  /* the picker dropdowns portal to .sh-app; in play they carry .sh-menu-dark for the dark skin */
861
888
  .sh-menu-dark { background: rgba(20, 20, 26, .97); border-color: rgba(255, 255, 255, .12); box-shadow: 0 12px 40px rgba(0, 0, 0, .5) }
@@ -20,7 +20,10 @@ export interface CommentEvent {
20
20
  nodeKey?: string // a frame can sit on a board twice - comments are node-scoped
21
21
  frame?: string
22
22
  anchor?: unknown // anchor bundle; absent = frame-level comment; on 'reanchor' = the new anchor
23
- author?: { email: string; name?: string; avatar?: string }
23
+ /** Canonical logs carry the email; the browser DTO replaces it with `id`, an
24
+ * opaque per-canvas HMAC of it (01-sharing §7.5) - a member's address never
25
+ * travels to another viewer's browser. Exactly one of the two is present. */
26
+ author?: { email?: string; id?: string; name?: string; avatar?: string }
24
27
  body?: string // plain text in v1
25
28
  emoji?: string // react events
26
29
  addressedIn?: string // resolve events: the variant frame that answered
@@ -103,9 +106,11 @@ export function replay(events: CommentEvent[]): Thread[] {
103
106
  break
104
107
  }
105
108
  case 'react': {
106
- // toggle keyed on comment+author+emoji: present removes, absent adds
109
+ // toggle keyed on comment+author+emoji: present removes, absent adds.
110
+ // The key is the opaque id when the transport is projected, the email
111
+ // when it is canonical - one store only ever sees one kind.
107
112
  const t = ev.commentId ? threads.get(ev.commentId) : undefined
108
- const who = ev.author?.email
113
+ const who = ev.author?.id ?? ev.author?.email
109
114
  if (!t || !who || !ev.emoji) break
110
115
  const users = (t.reactions[ev.emoji] ??= [])
111
116
  const at = users.indexOf(who)