@marver-design/marver 0.7.0 → 0.8.1
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 +141 -3
- package/README.md +72 -16
- package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
- package/dist/{build-BZaPa2DS.mjs → build-BBVQRetk.mjs} +5 -5
- package/dist/cli.mjs +16 -7
- package/dist/{collab-CXy8gqoz.mjs → collab-s3k5byM1.mjs} +18 -7
- package/dist/{comments-Ba8mU600.mjs → comments-BZBKhKRO.mjs} +4 -10
- package/dist/{comments-odHzYdO3.mjs → comments-J06jqCVV.mjs} +10 -16
- package/dist/daemon-DkyNOwIt.mjs +878 -0
- package/dist/{dev-DaPQ9xA5.mjs → dev-BTAhTie-.mjs} +28 -3
- package/dist/events-BMtBvvgU.mjs +101 -0
- package/dist/{init-DsCUmlCW.mjs → init-DWdhjJD5.mjs} +28 -3
- package/dist/ledger-CbzTJrV2.mjs +64 -0
- package/dist/{manifest-C8FODq2S.mjs → manifest-B4zcDGBf.mjs} +19 -4
- package/dist/{plugin-wMY9lNf3.mjs → plugin-BdQEeTLg.mjs} +89 -41
- package/dist/profile-BkiWglVE.mjs +39 -0
- package/dist/{serve-D_KBK7Oy.mjs → serve-CZqPnj19.mjs} +32 -8
- package/dist/{sync-CkBk-tUk.mjs → sync-BJKKmy1n.mjs} +5 -103
- package/dist/work-CLrmY-vQ.mjs +97 -0
- package/dist/work-lzC-lPY0.mjs +76 -0
- package/package.json +16 -1
- package/src/client/const.ts +1 -1
- package/src/client/content/diagram.tsx +1 -1
- package/src/client/content/index.tsx +2 -2
- package/src/client/content/md.ts +1 -1
- package/src/client/content/palette.ts +2 -2
- package/src/client/frame-host/bridge.js +1 -1
- package/src/client/frame-host/inspect.js +16 -3
- package/src/client/frame-host/serialize.ts +2 -2
- package/src/client/shell/App.tsx +165 -26
- package/src/client/shell/Comments.tsx +434 -78
- package/src/client/shell/Play.tsx +12 -8
- package/src/client/shell/Toolbar.tsx +14 -2
- package/src/client/shell/canvas/Canvas.tsx +1 -1
- package/src/client/shell/canvas/FrameNode.tsx +47 -15
- package/src/client/shell/canvas/snapshots.ts +3 -3
- package/src/client/shell/comments-store.ts +147 -33
- package/src/client/shell/hash.ts +2 -2
- package/src/client/shell/icons.tsx +5 -1
- package/src/client/shell/keys.ts +39 -0
- package/src/client/shell/mentions.ts +18 -0
- package/src/client/shell/perf.ts +1 -1
- package/src/client/shell/store.ts +127 -39
- package/src/client/shell/styles.css +303 -30
- package/src/client/shell/tidy.ts +5 -5
- package/src/client/stage/main.tsx +2 -2
- package/src/shared/events.ts +23 -6
- package/src/shared/utm.ts +22 -0
- package/templates/AGENTS-embedded.md +49 -1
- package/templates/AGENTS-studio.md +49 -1
- package/templates/instructions/configure.md +15 -0
- package/templates/instructions/jam.md +85 -0
- package/templates/instructions/publish.md +1 -1
- package/dist/rolldown-runtime-D7D4PA-g.mjs +0 -13
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Play mode
|
|
2
|
+
* Play mode: full-window near-black backdrop, ONE device shell centered at
|
|
3
3
|
* the chosen viewport's exact CSS pixels, scaled to fit - or `fill`, where the frame IS
|
|
4
4
|
* the window. The device hosts a single stage iframe that swaps frames in place.
|
|
5
5
|
*
|
|
@@ -125,6 +125,7 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
|
|
|
125
125
|
dw: number; dh: number; ready: number
|
|
126
126
|
}) {
|
|
127
127
|
const show = useComments((s) => s.show)
|
|
128
|
+
const showAnchor = useComments((s) => s.showAnchor)
|
|
128
129
|
const active = useComments((s) => s.active)
|
|
129
130
|
const draft = useComments((s) => s.draft)
|
|
130
131
|
const allThreads = useComments((s) => s.threads)
|
|
@@ -165,11 +166,12 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
|
|
|
165
166
|
useEffect(() => {
|
|
166
167
|
const win = iframe.current?.contentWindow
|
|
167
168
|
if (!win) return
|
|
168
|
-
// only while the thread is active (or composing) AND pins are shown
|
|
169
|
-
|
|
169
|
+
// only while the thread is active (or composing) AND pins are shown AND element focus is
|
|
170
|
+
// on (⇧L) - closing, ⇧C, or dimming focus clears it
|
|
171
|
+
const at = !show || !showAnchor ? null : (draft?.frame === frameId ? draft.anchor
|
|
170
172
|
: (active ? (threads.find((t) => t.id === active && (t.anchor as any)?.el)?.anchor ?? null) : null))
|
|
171
173
|
win.postMessage({ type: 'sh:highlight-anchor', frame: frameId, anchor: at ?? null }, location.origin)
|
|
172
|
-
}, [active, draft, show, frameId, iframe, rects, ready])
|
|
174
|
+
}, [active, draft, show, showAnchor, frameId, iframe, rects, ready])
|
|
173
175
|
|
|
174
176
|
if (!show) return null
|
|
175
177
|
|
|
@@ -201,7 +203,7 @@ function PlayComments({ iframe, frameId, vp, dw, dh, ready }: {
|
|
|
201
203
|
</div>
|
|
202
204
|
)
|
|
203
205
|
})}
|
|
204
|
-
{activeThread && <ThreadCard key={active!} thread={activeThread} at={pinAt(activeThread)} bounds={{ w: dw, h: dh }} />}
|
|
206
|
+
{activeThread && <ThreadCard key={active!} thread={activeThread} at={pinAt(activeThread)} bounds={{ w: dw, h: dh }} stage />}
|
|
205
207
|
{draftAt && <DraftComposer at={draftAt} bounds={{ w: dw, h: dh }} hue={(draft?.anchor as any)?.el?.hue} />}
|
|
206
208
|
</div>
|
|
207
209
|
)
|
|
@@ -218,6 +220,7 @@ function PlayInner() {
|
|
|
218
220
|
const playNav = useStore((s) => s.playNav)
|
|
219
221
|
const laser = useStore((s) => s.laser)
|
|
220
222
|
const commentMode = useComments((s) => s.commentMode)
|
|
223
|
+
const playShowAnchor = useComments((s) => s.showAnchor)
|
|
221
224
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
222
225
|
// the src is frozen at mount - navigation happens INSIDE the stage; device and theme
|
|
223
226
|
// changes must never reload it (a phone does not remount when you flip dark mode)
|
|
@@ -244,7 +247,7 @@ function PlayInner() {
|
|
|
244
247
|
// laser / comment ride the same rail as canvas frames, broadcast to the stage iframe.
|
|
245
248
|
// Re-sent on stage-ready (below) so a stage reload restores the mode.
|
|
246
249
|
useEffect(() => { postStage({ type: 'sh:laser', on: laser }) }, [laser])
|
|
247
|
-
useEffect(() => { postStage({ type: 'sh:pick', on: commentMode }) }, [commentMode])
|
|
250
|
+
useEffect(() => { postStage({ type: 'sh:pick', on: commentMode, quiet: !useComments.getState().showAnchor }) }, [commentMode, playShowAnchor])
|
|
248
251
|
|
|
249
252
|
const exit = () => {
|
|
250
253
|
const { at } = useStore.getState().play ?? {}
|
|
@@ -278,7 +281,7 @@ function PlayInner() {
|
|
|
278
281
|
}
|
|
279
282
|
const restart = () => { const list = playList(); if (list.length) goTo(list[0]) }
|
|
280
283
|
|
|
281
|
-
/** Variant siblings of the CURRENT frame present on this board
|
|
284
|
+
/** Variant siblings of the CURRENT frame present on this board: the
|
|
282
285
|
* review question is "which direction is better on THIS screen" - switch in place,
|
|
283
286
|
* device and theme preserved, each variant's own data-goto links drive after. */
|
|
284
287
|
const variantList = () => {
|
|
@@ -333,7 +336,7 @@ function PlayInner() {
|
|
|
333
336
|
postStage({ type: 'sh:set-theme', theme: p.theme })
|
|
334
337
|
}
|
|
335
338
|
postStage({ type: 'sh:laser', on: s.laser })
|
|
336
|
-
postStage({ type: 'sh:pick', on: commentsStore().commentMode })
|
|
339
|
+
postStage({ type: 'sh:pick', on: commentsStore().commentMode, quiet: !commentsStore().showAnchor })
|
|
337
340
|
setStageReady((n) => n + 1) // a reload wiped the frame's lock; let PlayComments re-drive it
|
|
338
341
|
} else if (data.type === 'sh:stage-at') {
|
|
339
342
|
const p = s.play
|
|
@@ -397,6 +400,7 @@ function PlayInner() {
|
|
|
397
400
|
if (key === 'l') { const s = useStore.getState(); if (!s.laser) commentsStore().setMode(false); s.setLaser(!s.laser); return }
|
|
398
401
|
if (key === 'c') { const c = commentsStore(); if (!c.commentMode) useStore.getState().setLaser(false); c.setMode(!c.commentMode); return }
|
|
399
402
|
if (key === 'C') { const c = commentsStore(); c.setShow(!c.show); return } // ⇧C hides/shows pins
|
|
403
|
+
if (key === 'L') { const c = commentsStore(); c.setShowAnchor(!c.showAnchor); return } // ⇧L element focus
|
|
400
404
|
|
|
401
405
|
if (/^Digit[1-9]$/.test(code)) {
|
|
402
406
|
const names = Object.keys(CONFIG.viewports)
|
|
@@ -73,13 +73,24 @@ export function useHideUI() {
|
|
|
73
73
|
|
|
74
74
|
// ---- shared controls ----------------------------------------------------------------
|
|
75
75
|
|
|
76
|
+
/** Two-row shortcut tooltip: one action per row, key right-aligned - the provenance
|
|
77
|
+
* tooltip's bold-value/muted-key language, shared by every two-shortcut button. */
|
|
78
|
+
function TipRows({ rows }: { rows: [string, string][] }) {
|
|
79
|
+
return (
|
|
80
|
+
<span className="sh-tip-rows">
|
|
81
|
+
{rows.map(([label, key]) => <span className="r" key={key}><b>{label}</b><span>{key}</span></span>)}
|
|
82
|
+
</span>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
76
86
|
/** Laser toggle - store-level, identical on canvas and prototype. The per-surface code
|
|
77
87
|
* broadcasts sh:laser to its frame(s); this only flips the flag (and drops comment mode,
|
|
78
88
|
* since the two are one-at-a-time). */
|
|
79
89
|
export function LaserButton() {
|
|
80
90
|
const laser = useStore((s) => s.laser)
|
|
91
|
+
const showAnchor = useComments((s) => s.showAnchor)
|
|
81
92
|
return (
|
|
82
|
-
<Tip side="bottom" label={
|
|
93
|
+
<Tip side="bottom" label={<TipRows rows={[['Laser mode', 'L'], [showAnchor ? 'Hide laser comment' : 'Show laser comment', '⇧L']]} />}>
|
|
83
94
|
<button className={`sh-pill-btn${laser ? ' on' : ''}`} onClick={() => {
|
|
84
95
|
if (!laser) commentsStore().setMode(false)
|
|
85
96
|
useStore.getState().setLaser(!laser)
|
|
@@ -91,8 +102,9 @@ export function LaserButton() {
|
|
|
91
102
|
/** Comment toggle - store-level, identical on canvas and prototype. */
|
|
92
103
|
export function CommentButton() {
|
|
93
104
|
const commentMode = useComments((s) => s.commentMode)
|
|
105
|
+
const show = useComments((s) => s.show)
|
|
94
106
|
return (
|
|
95
|
-
<Tip side="bottom" label={
|
|
107
|
+
<Tip side="bottom" label={<TipRows rows={[['Comment', 'C'], [show ? 'Hide pins' : 'Show pins', '⇧C']]} />}>
|
|
96
108
|
<button className={`sh-pill-btn${commentMode ? ' on' : ''}`} onClick={() => {
|
|
97
109
|
const c = commentsStore()
|
|
98
110
|
if (!c.commentMode) useStore.getState().setLaser(false)
|
|
@@ -100,7 +100,7 @@ export const canvasCtl = {
|
|
|
100
100
|
wheel(_input: CanvasWheelInput) {},
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
/** Variant-group captions
|
|
103
|
+
/** Variant-group captions: "Landing · 3 variants" above each group with
|
|
104
104
|
* 2+ members on this board. World-space (scales with the canvas); min screen size via
|
|
105
105
|
* --sh-inv. Groups with one lone member on a curated board keep the badge, no caption. */
|
|
106
106
|
function GroupCaptions() {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { memo, useCallback, useEffect, useRef } from 'react'
|
|
2
2
|
import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
|
|
3
|
-
import { CopyIcon, IntentGlyph, ReloadIcon, XIcon } from '../icons.tsx'
|
|
3
|
+
import { CopyIcon, IntentGlyph, ParallelogramFillIcon, ReloadIcon, XIcon } from '../icons.tsx'
|
|
4
4
|
import { CommentLayer } from '../Comments.tsx'
|
|
5
5
|
import { useComments } from '../comments-store.ts'
|
|
6
|
+
import { threadHostKey } from '../keys.ts'
|
|
6
7
|
import { registerFrame, unregisterFrame } from './frame-registry.ts'
|
|
7
8
|
import { primeCameraFor } from './camera-broadcast.ts'
|
|
8
9
|
import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
|
|
@@ -10,8 +11,26 @@ import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from
|
|
|
10
11
|
export const HEADER = 28
|
|
11
12
|
const SNAP = 12
|
|
12
13
|
|
|
14
|
+
/** Live Jam working shimmer: a slim 2x6 strip of tiny marver marks on the frame's left
|
|
15
|
+
* flank, top-aligned - each mark twinkles on its own scattered beat, phased per frame by
|
|
16
|
+
* --mv-w0 (set on the node) so parallel frames never pulse in sync. */
|
|
17
|
+
const SHIM_DELAYS = Array.from({ length: 12 }, (_, i) => {
|
|
18
|
+
const r = Math.floor(i / 2), c = i % 2
|
|
19
|
+
return (((r * 7 + c * 13) % 9) / 9) * 0.95
|
|
20
|
+
})
|
|
21
|
+
function WorkShimmer({ belowBadge }: { belowBadge: boolean }) {
|
|
22
|
+
// a variant badge owns the top of the left flank - the shimmer yields and sits below it
|
|
23
|
+
return (
|
|
24
|
+
<div className={`sh-work-ind${belowBadge ? ' below-vbadge' : ''}`} aria-hidden>
|
|
25
|
+
{SHIM_DELAYS.map((d, i) => (
|
|
26
|
+
<ParallelogramFillIcon key={i} size={5} style={{ animationDelay: `calc(var(--mv-w0, 0s) + ${d.toFixed(2)}s)` }} />
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
/**
|
|
14
|
-
* One frame on the canvas. Iframe laws
|
|
33
|
+
* One frame on the canvas. Iframe laws: the iframe element is created once per node key
|
|
15
34
|
* and never remounted - theme changes go through sh:set-theme, size changes are CSS only.
|
|
16
35
|
*
|
|
17
36
|
* Every interactive element carries `sh-no-pan` (rzpp's panning.excluded checks the event
|
|
@@ -23,6 +42,8 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
23
42
|
const frame = useStore((s) => s.frameFor(node))
|
|
24
43
|
const selected = useStore((s) => s.selection.includes(node.key))
|
|
25
44
|
const interact = useStore((s) => s.interact === node.key)
|
|
45
|
+
const working = useStore((s) => s.working.includes(node.frame)) // Live Jam: Marver is editing this frame
|
|
46
|
+
const workingSince = useStore((s) => s.workingSince[node.frame])
|
|
26
47
|
// B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
|
|
27
48
|
// pan/zoom tick. gestureScale below measures the world rect (the canonical source,
|
|
28
49
|
// Law G-5); the stored scale is only a never-hit fallback, read lazily at drag time.
|
|
@@ -68,7 +89,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
68
89
|
registerWin()
|
|
69
90
|
}, [node.key])
|
|
70
91
|
|
|
71
|
-
//
|
|
92
|
+
// Register the facade <iframe> so the lean coordinator can drive its srcdoc imperatively.
|
|
72
93
|
const bindLean = useCallback((el: HTMLIFrameElement | null) => { registerLeanFrame(node.key, el) }, [node.key])
|
|
73
94
|
// capture a fresh lean snapshot once the frame is ready and quiet, and whenever its CONTENT changes
|
|
74
95
|
// (nav). Resize needs no re-capture (the lean doc reflows) and theme needs none (attribute flip),
|
|
@@ -87,7 +108,10 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
87
108
|
// otherwise capture the live DOM WITH the highlight baked in and re-cover it. The
|
|
88
109
|
// hostsCard rail recaptures a clean lean once the card closes.
|
|
89
110
|
const c = useComments.getState()
|
|
90
|
-
|
|
111
|
+
// hosting goes through the resolver (keys.ts): an adopted thread's card renders
|
|
112
|
+
// here even though its stored nodeKey names a node that no longer exists
|
|
113
|
+
const hosting = (!!c.active && c.threads.some((th) =>
|
|
114
|
+
th.id === c.active && !th.resolved && threadHostKey(th, useStore.getState().nodes) === node.key)) || c.draft?.nodeKey === node.key
|
|
91
115
|
if (hosting) return
|
|
92
116
|
scheduleCapture(node.key, iframe, { sourceRevision: String(node.nav ?? 0), theme: node.theme })
|
|
93
117
|
}, 450)
|
|
@@ -112,7 +136,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
112
136
|
scheduleCapture(node.key, iframeRef.current, { sourceRevision: String(node.nav ?? 0), theme: node.theme }, true)
|
|
113
137
|
}, [interact, node.key, node.nav, node.theme, node.status])
|
|
114
138
|
|
|
115
|
-
// laser mode
|
|
139
|
+
// laser mode rides the same rail; re-sent when a frame becomes ready
|
|
116
140
|
// so late loaders join an already-lasered board
|
|
117
141
|
const laser = useStore((s) => s.laser)
|
|
118
142
|
const commentMode = useComments((s) => s.commentMode)
|
|
@@ -120,7 +144,8 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
120
144
|
// neighbors - each node is a stacking context, so an overflowing card would
|
|
121
145
|
// otherwise paint under the next frame
|
|
122
146
|
const hostsCard = useComments((s) =>
|
|
123
|
-
(!!s.active && s.threads.some((t) =>
|
|
147
|
+
(!!s.active && s.threads.some((t) =>
|
|
148
|
+
t.id === s.active && !t.resolved && threadHostKey(t, useStore.getState().nodes) === node.key)) || s.draft?.nodeKey === node.key)
|
|
124
149
|
// a frame hosting an OPEN thread or a draft must show its LIVE app, not the frozen lean
|
|
125
150
|
// cover: the active-element highlight lives in the live DOM and updates in real time
|
|
126
151
|
// (open -> lit, close -> cleared). Without this the cover re-freezes the moment comment
|
|
@@ -140,11 +165,13 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
140
165
|
if (node.status === 'ready' || !laser)
|
|
141
166
|
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:laser', on: laser }, location.origin)
|
|
142
167
|
}, [laser, node.status])
|
|
143
|
-
// comment mode = pick mode in the frame (late loaders join like laser does)
|
|
168
|
+
// comment mode = pick mode in the frame (late loaders join like laser does); quiet when
|
|
169
|
+
// laser comment (⇧L) is off - clicks still anchor, no lighting in the artwork
|
|
170
|
+
const showAnchor = useComments((s) => s.showAnchor)
|
|
144
171
|
useEffect(() => {
|
|
145
172
|
if (node.status === 'ready' || !commentMode)
|
|
146
|
-
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode }, location.origin)
|
|
147
|
-
}, [commentMode, node.status])
|
|
173
|
+
iframeRef.current?.contentWindow?.postMessage({ type: 'sh:pick', on: commentMode, quiet: !showAnchor }, location.origin)
|
|
174
|
+
}, [commentMode, showAnchor, node.status])
|
|
148
175
|
// B0.2: the interact target owns its own wheel (app scrolls); passive frames forward
|
|
149
176
|
// wheel to the canvas. Replayed on ready like laser/pick so a reload restores truth.
|
|
150
177
|
useEffect(() => {
|
|
@@ -177,7 +204,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
177
204
|
if (frame && iframeRef.current) iframeRef.current.src = frameUrl(frame, node.theme)
|
|
178
205
|
}, [node.nav])
|
|
179
206
|
|
|
180
|
-
// ready timeout
|
|
207
|
+
// ready timeout: 10s without sh:ready -> error card with reload
|
|
181
208
|
useEffect(() => {
|
|
182
209
|
if (node.status !== 'loading') return
|
|
183
210
|
const t = setTimeout(() => setStatus(node.key, 'error', 'frame never reported ready (10s)'), 10_000)
|
|
@@ -280,18 +307,22 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
280
307
|
)
|
|
281
308
|
}
|
|
282
309
|
|
|
283
|
-
// variant badge
|
|
310
|
+
// variant badge: letter + name floating LEFT of the frame, outside the
|
|
284
311
|
// artwork, world-anchored (scales with zoom) with a screen-space minimum via --sh-inv
|
|
285
312
|
const variantName = frame.title
|
|
286
313
|
?? cap((frame.id.split('/').pop() ?? '').replace(/^[a-z]-/, '').replace(/-/g, ' '))
|
|
287
314
|
|
|
288
315
|
return (
|
|
289
316
|
<div
|
|
290
|
-
className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}`}
|
|
317
|
+
className={`sh-node${selected ? ' sel' : ''}${interact ? ' interact' : ''}${working ? ' working' : ''}`}
|
|
291
318
|
data-theme={node.theme}
|
|
292
|
-
style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined
|
|
319
|
+
style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w, height: node.h + HEADER, zIndex: hostsCard ? 30 : undefined,
|
|
320
|
+
// phase every working animation by when THIS frame's job started - parallel
|
|
321
|
+
// frames pulsing in sync would read as one fake choreography
|
|
322
|
+
...(working ? { ['--mv-w0' as string]: `${-(Date.now() - (workingSince ?? Date.now()))}ms` } : {}) }}
|
|
293
323
|
data-node={node.key}
|
|
294
324
|
>
|
|
325
|
+
{working && <WorkShimmer belowBadge={!!frame.variantGroup} />}
|
|
295
326
|
{frame.variantGroup && (
|
|
296
327
|
<div className="sh-vbadge sh-no-pan" title={`${frame.variantGroup} · variant ${frame.variant?.toUpperCase()} - click to select`}
|
|
297
328
|
onPointerDown={(e) => e.stopPropagation()}
|
|
@@ -301,7 +332,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
301
332
|
</div>
|
|
302
333
|
)}
|
|
303
334
|
<div className="sh-node-head sh-no-pan" onPointerDown={(e) => drag(e, 'move')} title={frame.file}>
|
|
304
|
-
{/* content frames carry their intent glyph in the chrome
|
|
335
|
+
{/* content frames carry their intent glyph in the chrome */}
|
|
305
336
|
{frame.intent && <IntentGlyph intent={frame.intent} size={12} className="iicon sh-no-pan" aria-label={frame.intent} />}
|
|
306
337
|
<span className="id sh-no-pan">{frame.title ?? frame.id}</span>
|
|
307
338
|
<span className="dim sh-no-pan">{Math.round(node.w)} · {node.theme}</span>
|
|
@@ -331,7 +362,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
331
362
|
onLoad={registerWin}
|
|
332
363
|
style={{ width: node.w, height: node.h, display: node.missing || node.status === 'error' ? 'none' : 'block' }}
|
|
333
364
|
/>
|
|
334
|
-
{/*
|
|
365
|
+
{/* Lean facade: a DOM-snapshot (static html, 0 JS) covering the live iframe only while
|
|
335
366
|
the canvas is gesturing (CSS), so a heavy frame never flashes white mid-transform and the
|
|
336
367
|
device sweep reflows correctly. sandbox WITHOUT allow-scripts = no JS runs; allow-same-origin
|
|
337
368
|
so fonts/assets resolve and the shell can flip its theme + restore scroll. Never registered,
|
|
@@ -348,6 +379,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
348
379
|
onDoubleClick={(e) => { e.stopPropagation(); setInteract(node.key) }}
|
|
349
380
|
/>
|
|
350
381
|
)}
|
|
382
|
+
{working && <div className="sh-work-wave" />}
|
|
351
383
|
</div>
|
|
352
384
|
|
|
353
385
|
{/* comments live OUTSIDE the clipped body: a card or pin near the frame edge
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The lean-frame facade coordinator. Imperative on purpose - the facade is a
|
|
3
3
|
* `<iframe class="sh-lean" sandbox="allow-same-origin">` driven by setting .srcdoc directly, so no
|
|
4
4
|
* FrameNode subscribes to snapshot state and a pan/zoom tick triggers zero React renders.
|
|
5
5
|
*
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* INVALIDATE the lean (never mutate a displayed one - baked mermaid can't re-theme in place) and a
|
|
14
14
|
* fresh capture is admitted before it is shown again. Captures run bounded-parallel, viewport-first, at idle, never while busy.
|
|
15
15
|
*
|
|
16
|
-
* Correctness beats the flash-guard
|
|
16
|
+
* Correctness beats the flash-guard: a frame the serialiser cannot render faithfully
|
|
17
17
|
* (canvas/video/shadow-dom/cross-origin-css, or an unrestorable scroller) is left DEGRADED - no
|
|
18
18
|
* `data-ready`, no cover, live pixels stay. Every install is guarded by a per-node GENERATION token
|
|
19
19
|
* so an in-flight capture can never paint a stale/wrong-node cover after a reload or unmount.
|
|
@@ -87,7 +87,7 @@ function install(nodeKey: string): void {
|
|
|
87
87
|
const doc = iframe.contentDocument
|
|
88
88
|
if (!cur || !doc) return
|
|
89
89
|
if (!restoreScroll(doc, cur.scrollMap)) { cur.degraded = [...cur.degraded, 'scroll']; iframe.removeAttribute('srcdoc'); return }
|
|
90
|
-
// CSP guard
|
|
90
|
+
// CSP guard: if a hardened host blocked the inline <style> (style-src 'self'), the lean is
|
|
91
91
|
// unstyled - the sentinel custom prop won't resolve. Stay live rather than show an unstyled cover.
|
|
92
92
|
if (getComputedStyle(doc.documentElement).getPropertyValue('--mv-lean-ok').trim() !== '1') { iframe.removeAttribute('srcdoc'); return }
|
|
93
93
|
applyTheme(doc, cur.theme)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Comment state for the shell
|
|
2
|
+
* Comment state for the shell - its own small store beside the canvas
|
|
3
3
|
* store. One API client for both worlds: `marver dev` (api.ts mirror, local profile,
|
|
4
4
|
* poll) and the published serve (collab.ts, sessions, SSE). The shell never branches
|
|
5
5
|
* on which one it is beyond capability flags the endpoints themselves report.
|
|
@@ -11,32 +11,42 @@ import { ROUTE } from '../const.ts'
|
|
|
11
11
|
export interface Me { email: string; name: string; avatar?: string }
|
|
12
12
|
|
|
13
13
|
interface CommentsState {
|
|
14
|
-
events: CommentEvent[] //
|
|
15
|
-
threads: Thread[] // derived on every change
|
|
14
|
+
events: CommentEvent[] // EVERY board's log, unioned - comments are frame-scoped
|
|
15
|
+
threads: Thread[] // derived on every change; each carries its origin board
|
|
16
16
|
board: string | null
|
|
17
17
|
me: Me | null // null on published until signed in
|
|
18
18
|
local: boolean // dev mirror (identity is the local profile)
|
|
19
|
+
connected: boolean // dev only: a connect account provides name/email (read-only here)
|
|
19
20
|
commentMode: boolean // C - picking + composing
|
|
20
21
|
show: boolean // Shift+C - pins visible at all
|
|
22
|
+
showAnchor: boolean // Shift+L - light the tagged ELEMENT while its thread is open
|
|
21
23
|
active: string | null // open thread id
|
|
22
|
-
|
|
24
|
+
// picked, composing. `board` is captured at pick time - the write must land in the
|
|
25
|
+
// log of the board the user was LOOKING at, however long the composer stays open
|
|
26
|
+
draft: { nodeKey: string; frame: string; anchor: unknown; board?: string } | null
|
|
23
27
|
needsIdentity: boolean // published viewer tried to comment while signed out
|
|
24
28
|
inviteToken: string | null // arrived via an invite link - claim flow, token known
|
|
25
29
|
|
|
26
30
|
load(board: string): Promise<void>
|
|
27
31
|
live(board: string): () => void
|
|
28
|
-
|
|
32
|
+
/** Fetch a board's log NOW (Live Jam: the daemon just wrote a reply THERE) - same union
|
|
33
|
+
* path as the poll, so dedup + the reply notification behave identically. ANY board:
|
|
34
|
+
* notifications must reach the user wherever they are on the canvas. */
|
|
35
|
+
poke(board?: string): void
|
|
36
|
+
/** `board` routes the write to a thread's ORIGIN log; default = the active board. */
|
|
37
|
+
send(events: CommentEvent[], board?: string): Promise<boolean>
|
|
29
38
|
create(body: string): Promise<void>
|
|
30
39
|
reply(threadId: string, body: string): Promise<void>
|
|
31
40
|
replyOk(threadId: string, body: string): Promise<boolean>
|
|
32
41
|
resolve(threadId: string, reopen?: boolean): Promise<void>
|
|
33
42
|
setMode(on: boolean): void
|
|
34
43
|
setShow(show: boolean): void
|
|
44
|
+
setShowAnchor(on: boolean): void
|
|
35
45
|
setActive(id: string | null): void
|
|
36
46
|
setDraft(d: CommentsState['draft']): void
|
|
37
47
|
signIn(email: string, password: string): Promise<string | null>
|
|
38
48
|
claim(token: string, password: string, name: string, avatar?: string): Promise<string | null>
|
|
39
|
-
saveProfile(patch: Partial<Me>): Promise<
|
|
49
|
+
saveProfile(patch: Partial<Me>): Promise<string | null>
|
|
40
50
|
dismissIdentity(): void
|
|
41
51
|
}
|
|
42
52
|
|
|
@@ -51,23 +61,112 @@ const api = async (path: string, body?: unknown) => {
|
|
|
51
61
|
}
|
|
52
62
|
|
|
53
63
|
export const useComments = create<CommentsState>((set, get) => {
|
|
54
|
-
|
|
64
|
+
// replay PER BOARD, then concatenate: replay keys threads by commentId alone, so a
|
|
65
|
+
// (copied, malicious, or just colliding) id in two logs must not merge across boards
|
|
66
|
+
const derive = (events: CommentEvent[]) => {
|
|
67
|
+
const byBoard = new Map<string, CommentEvent[]>()
|
|
68
|
+
for (const e of events) {
|
|
69
|
+
const k = e.board ?? ''
|
|
70
|
+
const arr = byBoard.get(k) ?? []
|
|
71
|
+
arr.push(e)
|
|
72
|
+
byBoard.set(k, arr)
|
|
73
|
+
}
|
|
74
|
+
return { events, threads: [...byBoard.values()].flatMap((evs) => replay(evs)) }
|
|
75
|
+
}
|
|
76
|
+
// a static 0.8.1 serve answers every /api/* with 404 - comments simply do not exist
|
|
77
|
+
// there. Latch off after detection so N boards are not swept into 404s every 30s.
|
|
78
|
+
let apiOff = false
|
|
79
|
+
// the boot instant: an event younger than the page can never be "history", so it may
|
|
80
|
+
// notify even before its board's baseline sweep lands (the poke/SSE-beats-load race)
|
|
81
|
+
const bootTs = Date.now()
|
|
82
|
+
// Live Jam: a Marver reply that arrives AFTER the baseline raises a persistent
|
|
83
|
+
// bottom-right pill - from ANY board (the store holds every log), keyed on the event id
|
|
84
|
+
// via `union`'s fresh filter so it fires exactly once. The pill's board is the THREAD's
|
|
85
|
+
// origin board, so View can navigate there.
|
|
86
|
+
const notifyAgent = (fresh: CommentEvent[]) => {
|
|
87
|
+
const replies = fresh.filter((e) => e.agent && e.type === 'reply')
|
|
88
|
+
if (!replies.length) return
|
|
89
|
+
void import('./store.ts').then(({ useStore }) => {
|
|
90
|
+
const s = useStore.getState()
|
|
91
|
+
for (const e of replies) {
|
|
92
|
+
const threadId = e.parentId ?? e.commentId ?? ''
|
|
93
|
+
// the FRAME is the news: resolve the reply's thread -> frame -> manifest title + intent
|
|
94
|
+
const t = get().threads.find((t) => t.id === threadId)
|
|
95
|
+
const entry = t?.frame ? s.manifest?.frames.find((f) => f.id === t.frame) : undefined
|
|
96
|
+
s.jamToast({
|
|
97
|
+
threadId, board: t?.board ?? e.board ?? get().board ?? '', ts: e.ts,
|
|
98
|
+
preview: (e.body ?? '').replace(/\s+/g, ' ').slice(0, 90),
|
|
99
|
+
frame: t?.frame, frameTitle: entry?.title ?? t?.frame, intent: entry?.intent,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
// events are append-only everywhere, so union is ALWAYS safe - the one rule is the
|
|
105
|
+
// baseline, PER BOARD: a board's first successful read must never replay its history
|
|
106
|
+
// as notifications (the switcher list can arrive after the first sweep, so a global
|
|
107
|
+
// flag would announce a late board's whole past as news)
|
|
108
|
+
const baselinedBoards = new Set<string>()
|
|
109
|
+
// dedup by (board, id): the same id in two DIFFERENT logs is two events, never one
|
|
110
|
+
const key = (e: CommentEvent) => `${e.board ?? ''}|${e.id}`
|
|
55
111
|
const union = (events: CommentEvent[]) => {
|
|
56
|
-
const have = new Set(get().events.map(
|
|
57
|
-
|
|
58
|
-
|
|
112
|
+
const have = new Set(get().events.map(key))
|
|
113
|
+
// dedupe within the batch too - a union-merged log can carry a duplicated line
|
|
114
|
+
const fresh = events.filter((e) => !have.has(key(e)) && (have.add(key(e)), true))
|
|
115
|
+
if (!fresh.length) return
|
|
116
|
+
set(derive([...get().events, ...fresh]))
|
|
117
|
+
notifyAgent(fresh.filter((e) => (e.board && baselinedBoards.has(e.board)) || e.ts > bootTs))
|
|
118
|
+
}
|
|
119
|
+
/** Legacy logs (0.8.0 clients never sent `board`) get it from the endpoint they came
|
|
120
|
+
* from - without it, replies to their threads would route to whatever board is open. */
|
|
121
|
+
const stamped = (events: CommentEvent[] | undefined, board: string): CommentEvent[] =>
|
|
122
|
+
(events ?? []).map((e) => (e.board ? e : { ...e, board }))
|
|
123
|
+
/** Every board that could hold a log: the switcher list plus the active board -
|
|
124
|
+
* the sum of what this client can navigate to. Transport failure = just the
|
|
125
|
+
* active board; the next sweep widens again. */
|
|
126
|
+
const watchNames = async (): Promise<string[]> => {
|
|
127
|
+
const names = new Set<string>()
|
|
128
|
+
try {
|
|
129
|
+
const { fetchBoardNames } = await import('./store.ts')
|
|
130
|
+
for (const n of await fetchBoardNames()) names.add(n)
|
|
131
|
+
} catch { /* keep what we have */ }
|
|
132
|
+
const b = get().board
|
|
133
|
+
if (b) names.add(b)
|
|
134
|
+
return [...names]
|
|
135
|
+
}
|
|
136
|
+
/** One sweep across every board's log - the notification feed and the frame-scoped
|
|
137
|
+
* thread state are the same fetch. Boards baseline AFTER their events land. */
|
|
138
|
+
const fetchAll = async () => {
|
|
139
|
+
if (apiOff) return
|
|
140
|
+
const names = await watchNames()
|
|
141
|
+
const results = await Promise.all(names.map((n) =>
|
|
142
|
+
api(`comments/${n}`).then((r) => (r.ok ? stamped(r.data.events as CommentEvent[] | undefined, n) : null))))
|
|
143
|
+
union(results.flatMap((evs) => evs ?? []))
|
|
144
|
+
names.forEach((n, i) => { if (results[i] !== null) baselinedBoards.add(n) })
|
|
59
145
|
}
|
|
60
146
|
|
|
61
147
|
return {
|
|
62
|
-
events: [], threads: [], board: null, me: null, local: false,
|
|
63
|
-
commentMode: false, show: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
|
|
148
|
+
events: [], threads: [], board: null, me: null, local: false, connected: false,
|
|
149
|
+
commentMode: false, show: true, showAnchor: true, active: null, draft: null, needsIdentity: false, inviteToken: null,
|
|
150
|
+
|
|
151
|
+
poke(board) {
|
|
152
|
+
const b = board ?? get().board
|
|
153
|
+
if (!b || apiOff) return
|
|
154
|
+
void api(`comments/${b}`).then((r) => {
|
|
155
|
+
if (!r.ok) return
|
|
156
|
+
union(stamped(r.data.events, b)) // brand-new events still notify via the bootTs rule
|
|
157
|
+
baselinedBoards.add(b)
|
|
158
|
+
})
|
|
159
|
+
},
|
|
64
160
|
|
|
65
161
|
async load(board) {
|
|
66
|
-
|
|
162
|
+
// threads are frame-scoped and global - a board switch keeps them all (the same
|
|
163
|
+
// frame on another board keeps its pins); only the open card and a draft reset
|
|
164
|
+
if (get().board !== board) set({ board, active: null, draft: null })
|
|
165
|
+
else set({ board })
|
|
67
166
|
const me = await api('me')
|
|
68
|
-
if (me.
|
|
69
|
-
|
|
70
|
-
|
|
167
|
+
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 })
|
|
169
|
+
await fetchAll()
|
|
71
170
|
},
|
|
72
171
|
|
|
73
172
|
/** Liveness: SSE on the published serve; dev has no event rail (its sync loop
|
|
@@ -76,12 +175,13 @@ export const useComments = create<CommentsState>((set, get) => {
|
|
|
76
175
|
const stops: (() => void)[] = []
|
|
77
176
|
let es: EventSource | null = null
|
|
78
177
|
const wantSSE = () => {
|
|
79
|
-
if (es || get().local) return
|
|
178
|
+
if (es || get().local || apiOff) return
|
|
80
179
|
es = new EventSource(`${ROUTE}/api/events`)
|
|
81
180
|
es.addEventListener('comment', (e) => {
|
|
82
181
|
try {
|
|
83
182
|
const { board: b, ev } = JSON.parse((e as MessageEvent).data)
|
|
84
|
-
|
|
183
|
+
// every board: notifications reach the viewer anywhere (envelope names the log)
|
|
184
|
+
union(stamped([ev], typeof b === 'string' ? b : ''))
|
|
85
185
|
} catch { /* ignore */ }
|
|
86
186
|
})
|
|
87
187
|
es.addEventListener('resync', () => { void get().load(board) })
|
|
@@ -90,7 +190,7 @@ export const useComments = create<CommentsState>((set, get) => {
|
|
|
90
190
|
// `local` is only known once load() has answered - defer the SSE decision past it
|
|
91
191
|
const t = setTimeout(wantSSE, 1500)
|
|
92
192
|
stops.push(() => { clearTimeout(t); es?.close() })
|
|
93
|
-
const poll = () => { if (get().board === board) void
|
|
193
|
+
const poll = () => { if (get().board === board) void fetchAll() }
|
|
94
194
|
const iv = setInterval(poll, 30_000)
|
|
95
195
|
const onFocus = () => poll()
|
|
96
196
|
window.addEventListener('focus', onFocus)
|
|
@@ -98,10 +198,15 @@ export const useComments = create<CommentsState>((set, get) => {
|
|
|
98
198
|
return () => stops.forEach((f) => f())
|
|
99
199
|
},
|
|
100
200
|
|
|
101
|
-
async send(events) {
|
|
102
|
-
const
|
|
103
|
-
if (!
|
|
104
|
-
|
|
201
|
+
async send(events, board) {
|
|
202
|
+
const target = board ?? get().board
|
|
203
|
+
if (!target) return false
|
|
204
|
+
// stamp the origin board BEFORE the POST: the dev server fills it at origin, but
|
|
205
|
+
// the published server stores events as sent - an unstamped event would come back
|
|
206
|
+
// board-less on the next load and its replies could then route to the wrong log.
|
|
207
|
+
// Stamping client-side keeps the stored bytes and the optimistic copy identical.
|
|
208
|
+
const stamped = events.map((e) => ({ ...e, board: e.board ?? target }))
|
|
209
|
+
const res = await api(`comments/${target}`, { events: stamped })
|
|
105
210
|
if (res.status === 401) { set({ needsIdentity: true }); return false }
|
|
106
211
|
// any other refusal gets said out loud - a silent dead Enter key reads as a bug
|
|
107
212
|
// (the canonical case: a signed-in viewer on a read-only board)
|
|
@@ -109,11 +214,10 @@ export const useComments = create<CommentsState>((set, get) => {
|
|
|
109
214
|
const { useStore } = await import('./store.ts')
|
|
110
215
|
useStore.getState().toast(String((res.data as any)?.error ?? 'comment rejected'))
|
|
111
216
|
}
|
|
112
|
-
// union only what the server took - a rejected send must not leave phantoms
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
if (res.ok && get().board === board) union(events)
|
|
217
|
+
// union only what the server took - a rejected send must not leave phantoms; the
|
|
218
|
+
// store is global now, so an accepted write lands regardless of the viewed board
|
|
219
|
+
// (client ids keep the eventual SSE/poll echo idempotent)
|
|
220
|
+
if (res.ok) union(stamped)
|
|
117
221
|
return res.ok
|
|
118
222
|
},
|
|
119
223
|
|
|
@@ -125,27 +229,32 @@ export const useComments = create<CommentsState>((set, get) => {
|
|
|
125
229
|
id: uuid(), ts: Date.now(), type: 'create', commentId: id,
|
|
126
230
|
nodeKey: draft.nodeKey, frame: draft.frame, anchor: draft.anchor,
|
|
127
231
|
author: me ?? undefined, body: body.trim(),
|
|
128
|
-
}])
|
|
232
|
+
}], draft.board)
|
|
129
233
|
if (ok) set({ draft: null, active: id, commentMode: false })
|
|
130
234
|
},
|
|
131
235
|
|
|
132
236
|
async reply(threadId, body) { await get().replyOk(threadId, body) },
|
|
133
237
|
async replyOk(threadId, body) {
|
|
134
238
|
if (!body.trim()) return false
|
|
239
|
+
// route to the thread's ORIGIN log - the viewer may be reading it from another
|
|
240
|
+
// board (frame-scoped display); a reply landing in the wrong log would fork it
|
|
241
|
+
const origin = get().threads.find((t) => t.id === threadId)?.board
|
|
135
242
|
return get().send([{
|
|
136
243
|
id: uuid(), ts: Date.now(), type: 'reply', commentId: uuid(), parentId: threadId,
|
|
137
244
|
author: get().me ?? undefined, body: body.trim(),
|
|
138
|
-
}])
|
|
245
|
+
}], origin)
|
|
139
246
|
},
|
|
140
247
|
|
|
141
248
|
async resolve(threadId, reopen = false) {
|
|
142
|
-
|
|
249
|
+
const origin = get().threads.find((t) => t.id === threadId)?.board
|
|
250
|
+
await get().send([{ id: uuid(), ts: Date.now(), type: reopen ? 'reopen' : 'resolve', commentId: threadId }], origin)
|
|
143
251
|
},
|
|
144
252
|
|
|
145
253
|
setMode(on) { set({ commentMode: on, ...(on ? { show: true } : { draft: null }) }) },
|
|
146
254
|
setShow(show) { set({ show }) },
|
|
255
|
+
setShowAnchor(showAnchor) { set({ showAnchor }) },
|
|
147
256
|
setActive(active) { set({ active }) },
|
|
148
|
-
setDraft(draft) { set({ draft }) },
|
|
257
|
+
setDraft(draft) { set({ draft: draft ? { ...draft, board: draft.board ?? get().board ?? undefined } : null }) },
|
|
149
258
|
dismissIdentity() { set({ needsIdentity: false }) }, // inviteToken survives dismissal - commenting later reopens the claim
|
|
150
259
|
|
|
151
260
|
async signIn(email, password) {
|
|
@@ -162,7 +271,9 @@ export const useComments = create<CommentsState>((set, get) => {
|
|
|
162
271
|
},
|
|
163
272
|
async saveProfile(patch) {
|
|
164
273
|
const res = await api('profile', patch)
|
|
165
|
-
if (res.ok)
|
|
274
|
+
if (!res.ok) return res.data?.error ?? 'could not save - try again'
|
|
275
|
+
set({ me: res.data.user })
|
|
276
|
+
return null
|
|
166
277
|
},
|
|
167
278
|
}
|
|
168
279
|
})
|
|
@@ -173,6 +284,9 @@ if (typeof window !== 'undefined' && (import.meta as any).env?.DEV) (window as a
|
|
|
173
284
|
/** Initials + deterministic hue for avatarless authors - the whole fallback ladder. */
|
|
174
285
|
export const avatarFallback = (author?: { email?: string; name?: string }) => {
|
|
175
286
|
const name = author?.name || author?.email || '?'
|
|
287
|
+
// the unset dev default ("You", no account) renders in the COMMENT green - it's the mode's
|
|
288
|
+
// own hue, not a fake identity color, until a real profile is set
|
|
289
|
+
if (name === 'You' && !author?.email) return { initials: 'Y', hue: 131 }
|
|
176
290
|
const initials = name.split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase()
|
|
177
291
|
let h = 0
|
|
178
292
|
for (const c of (author?.email || name)) h = (h * 31 + c.charCodeAt(0)) % 360
|
package/src/client/shell/hash.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deep links
|
|
2
|
+
* Deep links. Hash-based so any static host serves them and a URL copied
|
|
3
3
|
* from dev works verbatim on a published site. The URL is a projection of state - the
|
|
4
4
|
* shell writes it as board/selection/play change and parses it once on boot; camera
|
|
5
5
|
* intent is always fit semantics (fit-all / fit-selection), never raw coordinates.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* #/ default board, fit all
|
|
8
8
|
* #/b/<board> board, fit all
|
|
9
9
|
* #/b/<board>?n=k1,k2 board with nodes selected, camera fit to selection
|
|
10
|
-
* #/b/<board>?c=<id> board with a comment thread open
|
|
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
13
|
*/
|