@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.
- package/CHANGELOG.md +111 -0
- package/README.md +1 -1
- package/dist/auth-DdUKeVKt.mjs +1150 -0
- package/dist/{build-DI31MFTB.mjs → build-BxGrHFT2.mjs} +188 -33
- package/dist/cli.mjs +16 -7
- package/dist/collab-mAiNZlDj.mjs +970 -0
- package/dist/{comments-CRqaO2MH.mjs → comments-DHB_8BRa.mjs} +3 -2
- package/dist/{daemon-DSmaS453.mjs → daemon-BChkzDqQ.mjs} +3 -3
- package/dist/{dev-BWIL2ewj.mjs → dev-DLwt3Brb.mjs} +5 -5
- package/dist/events-B3LBn74P.mjs +103 -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-B2uraTHS.mjs} +100 -9
- package/dist/{plugin-DjBFxEQ7.mjs → plugin-DNc4Jpae.mjs} +82 -9
- package/dist/{profile-DcsJyppw.mjs → profile-BjAPAJSb.mjs} +1 -1
- package/dist/{serve-3APqaNSB.mjs → serve-EjEqsiYa.mjs} +70 -17
- package/dist/share-Gqo_Ygqw.mjs +158 -0
- package/dist/summary-C7CAypbB.mjs +211 -0
- package/dist/{sync-DELGomPk.mjs → sync-BZaCWqK-.mjs} +3 -102
- package/package.json +1 -1
- package/src/client/shell/App.tsx +62 -11
- package/src/client/shell/Comments.tsx +103 -22
- 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 +96 -11
- package/src/client/shell/hash.ts +19 -0
- package/src/client/shell/icons.tsx +1 -0
- package/src/client/shell/mentions.ts +108 -1
- package/src/client/shell/sound.ts +32 -0
- package/src/client/shell/store.ts +66 -7
- package/src/client/shell/styles.css +68 -0
- package/src/shared/events.ts +20 -6
- package/dist/auth-B5yuwnOq.mjs +0 -494
- package/dist/collab-CWaG3Q4w.mjs +0 -340
- package/dist/manifest-C2tzkNaC.mjs +0 -233
- package/dist/update-DuWDj5nR.mjs +0 -77
- 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/
|
|
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 {
|
|
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
|
-
|
|
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=
|
|
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
|
-
/**
|
|
260
|
-
*
|
|
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
|
-
{
|
|
266
|
-
? <span key={i}>{s.text}</span>
|
|
267
|
-
:
|
|
268
|
-
? <span key={i}
|
|
269
|
-
:
|
|
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
|
|
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
|
-
{
|
|
357
|
-
? <span key={i} className=
|
|
358
|
-
:
|
|
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)
|
|
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
|
|
469
|
-
|
|
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/
|
|
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
|