@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.
- package/CHANGELOG.md +83 -5
- package/README.md +17 -4
- package/dist/auth-DdUKeVKt.mjs +1150 -0
- package/dist/{build-Cr_ZLMdq.mjs → build-DmfxJc6L.mjs} +188 -33
- package/dist/cli.mjs +16 -7
- package/dist/collab-BmaOjwP1.mjs +781 -0
- package/dist/{comments-CRqaO2MH.mjs → comments-pNxjTrGN.mjs} +3 -2
- package/dist/{daemon-DSmaS453.mjs → daemon-WjonKlPz.mjs} +3 -3
- package/dist/{dev-CLBjRs0J.mjs → dev-BXq7yE1p.mjs} +5 -5
- package/dist/events-C9k-ceOj.mjs +101 -0
- package/dist/{init-BSclDg4I.mjs → init-BpitOqRQ.mjs} +2 -3
- package/dist/{config-t9coJ-Pq.mjs → manifest-CS6krOTe.mjs} +236 -4
- package/dist/{marver-id-B8-3WiHk.mjs → marver-id-CVCoaMGI.mjs} +106 -1
- package/dist/{marver-id-gate-BDzW6ahN.mjs → marver-id-gate-DOjnZakC.mjs} +94 -6
- package/dist/{plugin-vE7sxEeT.mjs → plugin-Boy7I_JH.mjs} +82 -9
- package/dist/{profile-DcsJyppw.mjs → profile-ChpTPd-X.mjs} +1 -1
- package/dist/secure-cookie-_K1Hsx8H.mjs +26 -0
- package/dist/{serve-CZ1KEQxd.mjs → serve-BeLLizNg.mjs} +72 -18
- package/dist/share-C64b1tdN.mjs +159 -0
- package/dist/summary-Bf9tiGrE.mjs +211 -0
- package/dist/{sync-DELGomPk.mjs → sync-WYxDN9IT.mjs} +3 -102
- package/package.json +1 -1
- package/src/client/shell/App.tsx +43 -8
- package/src/client/shell/Comments.tsx +9 -5
- package/src/client/shell/LockedApp.tsx +113 -0
- package/src/client/shell/Play.tsx +97 -18
- package/src/client/shell/Toolbar.tsx +26 -2
- package/src/client/shell/canvas/Canvas.tsx +5 -15
- package/src/client/shell/canvas/ctl.ts +26 -0
- package/src/client/shell/comments-store.ts +39 -9
- package/src/client/shell/hash.ts +19 -0
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/store.ts +54 -3
- package/src/client/shell/styles.css +27 -0
- package/src/shared/events.ts +8 -3
- package/templates/instructions/publish.md +15 -1
- package/dist/auth-B5yuwnOq.mjs +0 -494
- package/dist/collab-C-n-uQdm.mjs +0 -364
- package/dist/manifest-C2tzkNaC.mjs +0 -233
- package/dist/update-DuWDj5nR.mjs +0 -77
- 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/
|
|
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,
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 {
|
|
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({
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/client/shell/hash.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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) }
|
package/src/shared/events.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|