@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
|
@@ -40,16 +40,20 @@ export const MonitorIcon = icon(<path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,
|
|
|
40
40
|
export const TvIcon = icon(<path d="M216,64H147.31l34.35-34.34a8,8,0,1,0-11.32-11.32L128,60.69,85.66,18.34A8,8,0,0,0,74.34,29.66L108.69,64H40A16,16,0,0,0,24,80V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V80A16,16,0,0,0,216,64Zm0,136H40V80H216V200Z" />)
|
|
41
41
|
export const SignpostIcon = icon(<path d="M246,106.65,212.33,69.3A16,16,0,0,0,200.44,64H136V32a8,8,0,0,0-16,0V64H40A16,16,0,0,0,24,80v64a16,16,0,0,0,16,16h80v64a8,8,0,0,0,16,0V160h64.44a16,16,0,0,0,11.89-5.3L246,117.35A8,8,0,0,0,246,106.65ZM200.44,144H40V80H200.44l28.8,32Z" />)
|
|
42
42
|
export const ParallelogramDuoIcon = icon(<><path d="M239.29,59.28l-64.8,144a8,8,0,0,1-7.3,4.72H24a8,8,0,0,1-7.3-11.28l64.8-144A8,8,0,0,1,88.81,48H232A8,8,0,0,1,239.29,59.28Z" opacity=".1" /><path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31ZM167.19,200H24L88.81,56H232Z" /></>)
|
|
43
|
+
/** The marver mark, SOLID fill (for the Marver avatar - a filled white parallelogram on accent). */
|
|
44
|
+
export const ParallelogramFillIcon = icon(<path d="M245.43,47.31A15.94,15.94,0,0,0,232,40H88.81a16,16,0,0,0-14.59,9.43l-64.8,144A16,16,0,0,0,24,216H167.19a16,16,0,0,0,14.59-9.43l64.8-144A16,16,0,0,0,245.43,47.31Z" />)
|
|
43
45
|
/** Sidebar trigger, custom in Phosphor 256-space: outer frame + panel pill -
|
|
44
46
|
* pill filled while the sidebar is open, hollow while collapsed. */
|
|
45
47
|
export const PanelFilledIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="32" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="72" y="88" width="48" height="80" rx="24" /></>)
|
|
46
48
|
|
|
47
49
|
/** Plain UI frame - the rectangle every non-content sidebar row leads with. */
|
|
48
50
|
export const FrameRectIcon = icon(<rect x="40" y="52" width="176" height="152" rx="20" fill="none" stroke="currentColor" strokeWidth="16" />)
|
|
51
|
+
export const UserIcon = icon(<path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z" />)
|
|
52
|
+
export const PencilSimpleIcon = icon(<path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z" />)
|
|
49
53
|
export const LinkIcon = icon(<path d="M137.54,186.36a8,8,0,0,1,0,11.31l-9.94,10A56,56,0,0,1,48.38,128.4L72.5,104.28A56,56,0,0,1,149.31,102a8,8,0,1,1-10.64,12,40,40,0,0,0-54.85,1.63L59.7,139.72a40,40,0,0,0,56.58,56.58l9.94-9.94A8,8,0,0,1,137.54,186.36Zm70.08-138a56.08,56.08,0,0,0-79.22,0l-9.94,9.95a8,8,0,0,0,11.32,11.31l9.94-9.94a40,40,0,0,1,56.58,56.58L172.18,140.4a40,40,0,0,1-54.85,1.6,8,8,0,1,0-10.64,12,56,56,0,0,0,76.81-2.26l24.12-24.12A56.08,56.08,0,0,0,207.62,48.38Z" />)
|
|
50
54
|
export const LaserIcon = icon(<><circle cx="128" cy="128" r="56" fill="none" stroke="currentColor" strokeWidth="16" /><path d="M128 24 V56 M128 200 V232 M24 128 H56 M200 128 H232" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" /><circle cx="128" cy="128" r="12" /></>)
|
|
51
55
|
|
|
52
|
-
/* Content-frame intent glyphs
|
|
56
|
+
/* Content-frame intent glyphs, custom in the same 256-space. */
|
|
53
57
|
export const DiagramShapeIcon = icon(<><rect x="28" y="36" width="88" height="64" rx="14" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="140" y="156" width="88" height="64" rx="14" fill="none" stroke="currentColor" strokeWidth="16" /><path d="M116 68 H184 V156" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" strokeLinejoin="round" /></>)
|
|
54
58
|
export const SpecDocIcon = icon(<><path d="M44 48 H180 M44 96 H212 M44 144 H212 M44 192 H140" fill="none" stroke="currentColor" strokeWidth="16" strokeLinecap="round" /></>)
|
|
55
59
|
export const MoodboardIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="20" fill="none" stroke="currentColor" strokeWidth="16" /><circle cx="96" cy="104" r="18" /><path d="M56 192 L112 128 L150 168 L178 140 L200 164 V192 Z" /></>)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-key identity for comments. Board nodes the file does not key yet used to
|
|
3
|
+
* get a RANDOM key per load - and comments anchor to node keys, so every comment
|
|
4
|
+
* created on a never-saved board orphaned on the next mount: dev board switches
|
|
5
|
+
* and reloads, and every single visit on a published canvas (which can never
|
|
6
|
+
* save keys back). Deriving the key from board + frame + occurrence makes the
|
|
7
|
+
* same board file yield the same keys in every session and for every viewer.
|
|
8
|
+
* Keys stored in the file still win; this only names the unnamed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** 64 bits from two independent 32-bit passes (djb2-xor with different seeds):
|
|
12
|
+
* a single 32-bit hash has practical collisions, and a collision here would
|
|
13
|
+
* make key assignment depend on which OTHER nodes happen to be present. */
|
|
14
|
+
const h32 = (s: string, seed: number): number => {
|
|
15
|
+
let h = seed
|
|
16
|
+
for (let i = 0; i < s.length; i++) h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0
|
|
17
|
+
return h >>> 0
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function stableNodeKey(board: string, frame: string, salt: number): string {
|
|
21
|
+
const s = `${board}|${frame}|${salt}`
|
|
22
|
+
return 'n_' + h32(s, 5381).toString(36) + h32(s, 52711).toString(36)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The ONE answer to "which node renders this thread": the stored anchor when that
|
|
27
|
+
* node still exists AND still shows the thread's frame, else the first node showing
|
|
28
|
+
* the frame (adoption - a stale key from a past session, a board rewrite, a deleted
|
|
29
|
+
* copy must degrade to the frame, never to invisible), else null (frame not on this
|
|
30
|
+
* board). Every consumer - pin layer, reveal, the hosting checks - must agree, or
|
|
31
|
+
* a card renders on one node while another claims the hosting behavior.
|
|
32
|
+
*/
|
|
33
|
+
export function threadHostKey(
|
|
34
|
+
t: { nodeKey?: string; frame?: string },
|
|
35
|
+
nodes: readonly { key: string; frame: string }[],
|
|
36
|
+
): string | null {
|
|
37
|
+
if (t.nodeKey && nodes.some((n) => n.key === t.nodeKey && n.frame === t.frame)) return t.nodeKey
|
|
38
|
+
return nodes.find((n) => n.frame === t.frame)?.key ?? null
|
|
39
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @marver mention parsing - pure, so the rendering is unit-testable without
|
|
3
|
+
* pulling the whole comment UI (and its virtual:sh-config) into the test.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Split a comment body into plain text and @marver mention segments (case-insensitive, word-bounded). */
|
|
7
|
+
export function parseMentions(body: string): { text: string; mention: boolean }[] {
|
|
8
|
+
const out: { text: string; mention: boolean }[] = []
|
|
9
|
+
let last = 0
|
|
10
|
+
for (const m of body.matchAll(/@marver\b/gi)) {
|
|
11
|
+
const i = m.index ?? 0
|
|
12
|
+
if (i > last) out.push({ text: body.slice(last, i), mention: false })
|
|
13
|
+
out.push({ text: m[0], mention: true })
|
|
14
|
+
last = i + m[0].length
|
|
15
|
+
}
|
|
16
|
+
if (last < body.length) out.push({ text: body.slice(last), mention: false })
|
|
17
|
+
return out.length ? out : [{ text: body, mention: false }]
|
|
18
|
+
}
|
package/src/client/shell/perf.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* B0.4: canvas performance instrumentation. Zero cost unless explicitly enabled.
|
|
3
3
|
*
|
|
4
|
-
* Enabled in dev, or in ANY build (incl. packed/published - the
|
|
4
|
+
* Enabled in dev, or in ANY build (incl. packed/published - the gate is dev AND
|
|
5
5
|
* publish) via `?mvperf` in the URL or `localStorage.mvPerf==='1'`. So publish-side gate
|
|
6
6
|
* measurement is possible without shipping an always-on probe.
|
|
7
7
|
*
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { create } from 'zustand'
|
|
2
2
|
import { ROUTE } from '../const.ts'
|
|
3
3
|
import { tidy, parseLayout, type BoardLayout } from './tidy.ts'
|
|
4
|
+
import { stableNodeKey } from './keys.ts'
|
|
4
5
|
// @ts-expect-error virtual module provided by the plugin
|
|
5
6
|
import shConfig from 'virtual:sh-config'
|
|
6
7
|
// @ts-expect-error virtual module: null in dev; a published build inlines manifest+boards
|
|
@@ -26,14 +27,22 @@ export interface Node {
|
|
|
26
27
|
/** Explicit per-frame override, set by scoped theme actions; cleared by a global set.
|
|
27
28
|
* The only theme value that persists into the board file. */
|
|
28
29
|
themeUser?: string
|
|
29
|
-
/** Size provenance for CONTENT frames
|
|
30
|
+
/** Size provenance for CONTENT frames, explicit: 'auto' = measured
|
|
30
31
|
* (transient - never serialized); 'manual' = the human's - measurements never
|
|
31
32
|
* overwrite; 'device' = a preset's. UI frames never carry it. Only manual/device
|
|
32
33
|
* persist, so a reload can tell an authored size from a measured one. */
|
|
33
34
|
sizeMode?: 'auto' | 'manual' | 'device'
|
|
35
|
+
/** Transient (never serialized): this node was sized by the 390x844 GUESS because its
|
|
36
|
+
* frame was not in the manifest yet AND its file carried no w/h (the create-first
|
|
37
|
+
* choreography's append-before-scan race). applyManifest corrects ONLY these when the
|
|
38
|
+
* frame arrives - an authored size on a temporarily-missing frame is never touched. */
|
|
39
|
+
sizeFallback?: boolean
|
|
34
40
|
status: 'loading' | 'ready' | 'error'; error?: string; missing?: boolean
|
|
35
41
|
}
|
|
36
|
-
|
|
42
|
+
/** A Live Jam notification: a persistent bottom-right glass pill for a Marver
|
|
43
|
+
* reply. Frame-first: the FRAME is the news (icon + title, blue), Marver + preview below. */
|
|
44
|
+
export interface JamNote { threadId: string; board: string; preview: string; ts: number; frame?: string; frameTitle?: string; intent?: string }
|
|
45
|
+
export interface Toast { id: number; text: string; jam?: JamNote }
|
|
37
46
|
|
|
38
47
|
export const CONFIG: { viewports: Record<string, { width: number; height: number }>; themes: string[]; zoomSpeed?: number; noTheme: boolean; setup?: boolean; projectName?: string } = shConfig
|
|
39
48
|
|
|
@@ -55,6 +64,18 @@ export async function fetchBoardNames(): Promise<string[]> {
|
|
|
55
64
|
/** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
|
|
56
65
|
export const boardLabel = (n: string) => humanize(n)
|
|
57
66
|
|
|
67
|
+
/** The frames a board pins, straight from its file (dev fetch / published inline) -
|
|
68
|
+
* membership only, never a load. Cross-board data-goto asks "which board shows this
|
|
69
|
+
* frame?" without touching the live board state. */
|
|
70
|
+
export async function boardFrames(name: string): Promise<string[]> {
|
|
71
|
+
const board: any = DATA
|
|
72
|
+
? DATA.boards[name]
|
|
73
|
+
: (await (await fetch(`${ROUTE}/api/boards/${name}`)).json())?.board
|
|
74
|
+
return Array.isArray(board?.nodes)
|
|
75
|
+
? board.nodes.map((n: any) => (n && typeof n.frame === 'string' ? n.frame : '')).filter(Boolean)
|
|
76
|
+
: []
|
|
77
|
+
}
|
|
78
|
+
|
|
58
79
|
const HEADER = 28
|
|
59
80
|
let toastSeq = 0
|
|
60
81
|
const nodeKey = () => 'n_' + Math.random().toString(36).slice(2, 8)
|
|
@@ -82,12 +103,12 @@ const initialViewTheme = () => {
|
|
|
82
103
|
}
|
|
83
104
|
const manifestKey = (m: Manifest) => JSON.stringify(m.frames) // any change counts, not just added/removed ids
|
|
84
105
|
|
|
85
|
-
/** Latest measured content heights, keyed frameId@width. TRANSIENT by design
|
|
86
|
-
*
|
|
106
|
+
/** Latest measured content heights, keyed frameId@width. TRANSIENT by design:
|
|
107
|
+
* auto sizes are never serialized - a reload remeasures. */
|
|
87
108
|
const measuredHeights = new Map<string, number>()
|
|
88
109
|
|
|
89
110
|
function defaultSize(frame: FrameEntry) {
|
|
90
|
-
// content frames
|
|
111
|
+
// content frames: own width from Doc layout; height from the latest
|
|
91
112
|
// measurement at that width, or a placeholder until sh:measure lands.
|
|
92
113
|
// meta.viewport, when declared, wins - the existing precedence.
|
|
93
114
|
if (frame.contentWidth && !frame.viewport) {
|
|
@@ -100,7 +121,7 @@ function defaultSize(frame: FrameEntry) {
|
|
|
100
121
|
|
|
101
122
|
let resizedInGesture = false
|
|
102
123
|
|
|
103
|
-
/** sceneRows is the legacy shorthand for a plain rows layout
|
|
124
|
+
/** sceneRows is the legacy shorthand for a plain rows layout. */
|
|
104
125
|
const effectiveLayout = (layout: BoardLayout | null, sceneRows: string[][] | null): BoardLayout | undefined =>
|
|
105
126
|
layout ?? (sceneRows?.length ? { rows: sceneRows.map((r) => [...r]) } : undefined)
|
|
106
127
|
|
|
@@ -116,14 +137,14 @@ interface State {
|
|
|
116
137
|
selection: string[] // ordered; last entry is the primary (bar anchor)
|
|
117
138
|
interact: string | null
|
|
118
139
|
viewTheme: string // the global theme preference; sticky across boards and reloads
|
|
119
|
-
play: { at: string; device: string; theme: string } | null // play mode
|
|
140
|
+
play: { at: string; device: string; theme: string } | null // play mode; at = current frame id
|
|
120
141
|
gesture: boolean // a frame drag/resize is in progress - canvas panning is disabled
|
|
121
|
-
laser: boolean // laser/inspect mode
|
|
142
|
+
laser: boolean // laser/inspect mode: frames outline their structure
|
|
122
143
|
board: string // active board name; 'all-scenes' is the auto board
|
|
123
144
|
boardAuto: boolean // auto boards gain new frames on arrival; curated boards never do
|
|
124
145
|
deviceView: string | null // board-wide device preview (viewport name), null = free-form layout
|
|
125
|
-
sceneRows: string[][] | null // LEGACY scene arrangement
|
|
126
|
-
layout: BoardLayout | null // lane-flow recipe
|
|
146
|
+
sceneRows: string[][] | null // LEGACY scene arrangement; still round-trips through save
|
|
147
|
+
layout: BoardLayout | null // lane-flow recipe, parsed; wins over sceneRows when both exist
|
|
127
148
|
layoutRaw: unknown // the author's layout VERBATIM - save round-trips this, never the parse
|
|
128
149
|
baseLayout: Record<string, { x: number; y: number; w?: number; h?: number }> | null // snapshot taken on entering a device view; Default restores it exactly (auto content entries carry positions only - their sizes are measured)
|
|
129
150
|
panelOpen: boolean
|
|
@@ -169,6 +190,15 @@ interface State {
|
|
|
169
190
|
setTheme(theme: string): void
|
|
170
191
|
runTidy(): void
|
|
171
192
|
toast(text: string): void
|
|
193
|
+
jamToast(note: JamNote): void
|
|
194
|
+
dismissToast(id: number): void
|
|
195
|
+
clearJamToasts(): void
|
|
196
|
+
/** Frames Marver is editing right now (Live Jam presence) - drives the working glow. */
|
|
197
|
+
working: string[]
|
|
198
|
+
/** When each working frame's job started (ms epoch) - phases the animations so parallel
|
|
199
|
+
* frames never pulse in sync. */
|
|
200
|
+
workingSince: Record<string, number>
|
|
201
|
+
setWorking(frames: string[]): void
|
|
172
202
|
spawn(frameId: string): Node | null
|
|
173
203
|
save(): Promise<boolean>
|
|
174
204
|
}
|
|
@@ -191,7 +221,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
191
221
|
let switchSeq = 0 // last click wins when board switches race
|
|
192
222
|
const scheduleSave = () => { editRev++; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
|
|
193
223
|
|
|
194
|
-
//
|
|
224
|
+
// One cancelable, BOARD-SCOPED reflow after content measurements settle.
|
|
195
225
|
// The captured board name is the generation guard - a debounce surviving a board
|
|
196
226
|
// switch fires into a name check and dies, never touching the new board.
|
|
197
227
|
let reflowTimer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -225,7 +255,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
225
255
|
},
|
|
226
256
|
}
|
|
227
257
|
|
|
228
|
-
/** Published parity for content-frame sizes
|
|
258
|
+
/** Published parity for content-frame sizes: manual/device sizes persist
|
|
229
259
|
* to the session the same way theme pins do - a board switch or reload on a
|
|
230
260
|
* published canvas must not silently drop a hand-resized spec frame. Keys are
|
|
231
261
|
* frame#occurrence: node keys are minted per load on virtual boards, but the node
|
|
@@ -293,19 +323,26 @@ export const useStore = create<State>((set, get) => {
|
|
|
293
323
|
boardHash = sha256
|
|
294
324
|
if (typeof board?.auto === 'boolean') boardAuto = board.auto
|
|
295
325
|
// Agent-authored boards may be just a frame list - fill sizes/keys, tidy on first load.
|
|
296
|
-
// Keys are load-bearing (iframe identity, selection) - dupes/blanks
|
|
297
|
-
//
|
|
326
|
+
// Keys are load-bearing (iframe identity, selection, COMMENT ANCHORS) - dupes/blanks
|
|
327
|
+
// get minted DETERMINISTICALLY (keys.ts): a random key here orphaned every comment
|
|
328
|
+
// created on a never-saved board the moment it reloaded.
|
|
329
|
+
// Board nodes whose file is gone stay, flagged - deletion is surfaced, never silent.
|
|
298
330
|
const seenKeys = new Set<string>()
|
|
331
|
+
const ordinals = new Map<string, number>() // per-frame keyless-occurrence counter
|
|
299
332
|
nodes = (Array.isArray(board?.nodes) ? board.nodes : [])
|
|
300
333
|
.filter((n: any) => n && typeof n.frame === 'string')
|
|
301
334
|
.map((n: any) => {
|
|
302
335
|
const f = manifest.frames.find((x) => x.id === n.frame)
|
|
303
336
|
const d = f ? defaultSize(f) : { w: 390, h: 844 }
|
|
304
337
|
if (typeof n.x !== 'number' || typeof n.y !== 'number') needTidy = true
|
|
305
|
-
let key = typeof n.key === 'string' && n.key ? n.key :
|
|
306
|
-
if (seenKeys.has(key))
|
|
338
|
+
let key = typeof n.key === 'string' && n.key ? n.key : ''
|
|
339
|
+
if (!key || seenKeys.has(key)) {
|
|
340
|
+
let salt = ordinals.get(n.frame) ?? 0
|
|
341
|
+
do { key = stableNodeKey(boardName, n.frame, salt++) } while (seenKeys.has(key))
|
|
342
|
+
ordinals.set(n.frame, salt)
|
|
343
|
+
}
|
|
307
344
|
seenKeys.add(key)
|
|
308
|
-
// content-frame size provenance round-trips
|
|
345
|
+
// content-frame size provenance round-trips; auto nodes saved no
|
|
309
346
|
// w/h, so they fall to defaultSize (placeholder) and remeasure on mount
|
|
310
347
|
const sizeMode = f?.contentWidth
|
|
311
348
|
? { sizeMode: (n.sizeMode === 'manual' || n.sizeMode === 'device' ? n.sizeMode : 'auto') as Node['sizeMode'] }
|
|
@@ -315,6 +352,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
315
352
|
frame: n.frame,
|
|
316
353
|
x: typeof n.x === 'number' ? n.x : 0, y: typeof n.y === 'number' ? n.y : 0,
|
|
317
354
|
w: typeof n.w === 'number' ? n.w : d.w, h: typeof n.h === 'number' ? n.h : d.h,
|
|
355
|
+
...(!f && typeof n.w !== 'number' && typeof n.h !== 'number' ? { sizeFallback: true } : {}),
|
|
318
356
|
...sizeMode,
|
|
319
357
|
// pins persist as their own field (exact round-trip). Legacy boards stored a
|
|
320
358
|
// theme on EVERY node: only values differing from the frame's static default
|
|
@@ -345,7 +383,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
345
383
|
if (board?.baseLayout && typeof board.baseLayout === 'object') baseLayout = board.baseLayout
|
|
346
384
|
}
|
|
347
385
|
// auto-managed goes both ways (friction log #15): an auto board gains new frames
|
|
348
|
-
// AND sheds deleted ones. Tombstone cards are a curated-board concept
|
|
386
|
+
// AND sheds deleted ones. Tombstone cards are a curated-board concept.
|
|
349
387
|
// Pruning at load DIRTIES the board (dev only) so the file on disk sheds the
|
|
350
388
|
// tombstones too - otherwise a recreated frame id resurrects its stale node.
|
|
351
389
|
let prunedAtLoad = false
|
|
@@ -361,6 +399,15 @@ export const useStore = create<State>((set, get) => {
|
|
|
361
399
|
// saved piled up at the origin)
|
|
362
400
|
if (boardAuto) {
|
|
363
401
|
const placed = new Set(nodes.map((n) => n.frame))
|
|
402
|
+
// newcomers get deterministic keys too - on published auto boards (all-scenes)
|
|
403
|
+
// this path mints EVERY key, every visit, and comments anchor to them
|
|
404
|
+
const haveKeys = new Set(nodes.map((n) => n.key))
|
|
405
|
+
const mint = (frame: string) => {
|
|
406
|
+
let salt = 0, k = stableNodeKey(boardName, frame, salt)
|
|
407
|
+
while (haveKeys.has(k)) k = stableNodeKey(boardName, frame, ++salt)
|
|
408
|
+
haveKeys.add(k)
|
|
409
|
+
return k
|
|
410
|
+
}
|
|
364
411
|
let maxX = nodes.reduce((a, n) => Math.max(a, n.x + n.w), 0)
|
|
365
412
|
for (const f of manifest.frames) {
|
|
366
413
|
if (placed.has(f.id)) continue
|
|
@@ -375,7 +422,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
375
422
|
: null
|
|
376
423
|
if (sibling) { x = sibling.x + sibling.w + Math.max(140, sibling.w * 0.12); y = sibling.y }
|
|
377
424
|
else { x = nodes.length ? maxX + 96 : 0; y = 0 }
|
|
378
|
-
const node: Node = { key:
|
|
425
|
+
const node: Node = { key: mint(f.id), frame: f.id, x, y, w, h, theme: resolveTheme(f), status: 'loading', ...(f.contentWidth ? { sizeMode: (vp ? 'device' : 'auto') as Node['sizeMode'] } : {}) }
|
|
379
426
|
nodes.push(node)
|
|
380
427
|
maxX = Math.max(maxX, x + w)
|
|
381
428
|
}
|
|
@@ -420,7 +467,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
420
467
|
return {
|
|
421
468
|
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
|
|
422
469
|
board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
|
|
423
|
-
panelOpen: true, scale: 1, toasts: [], boardHash: null, dirty: false,
|
|
470
|
+
panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false,
|
|
424
471
|
pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0,
|
|
425
472
|
|
|
426
473
|
async boot() {
|
|
@@ -485,15 +532,23 @@ export const useStore = create<State>((set, get) => {
|
|
|
485
532
|
const d = defaultSize(f)
|
|
486
533
|
const maxY = next.reduce((a, n) => Math.max(a, n.y + n.h), 0)
|
|
487
534
|
// a grouped newcomer lands BESIDE its siblings, not at the canvas bottom - the
|
|
488
|
-
// badge/caption say "these belong together"; the position must agree
|
|
535
|
+
// badge/caption say "these belong together"; the position must agree
|
|
489
536
|
const sibs = f.variantGroup
|
|
490
537
|
? next.filter((n) => { const g = m.frames.find((x) => x.id === n.frame)?.variantGroup; return g === f.variantGroup && !n.missing })
|
|
491
538
|
: []
|
|
492
539
|
const right = sibs.length ? sibs.reduce((a, n) => (n.x > a.x ? n : a)) : null
|
|
493
540
|
const mode = f.contentWidth ? { sizeMode: (vp ? 'device' : 'auto') as Node['sizeMode'] } : {}
|
|
541
|
+
// deterministic key (keys.ts): a comment dropped on a live-joined frame must
|
|
542
|
+
// survive the reload, where the SAME key gets minted by the board-load path
|
|
543
|
+
const mintKey = (() => {
|
|
544
|
+
const have = new Set(next.map((n) => n.key))
|
|
545
|
+
let salt = 0, k = stableNodeKey(get().board, f.id, salt)
|
|
546
|
+
while (have.has(k)) k = stableNodeKey(get().board, f.id, ++salt)
|
|
547
|
+
return k
|
|
548
|
+
})()
|
|
494
549
|
const node = right
|
|
495
|
-
? { key:
|
|
496
|
-
: { key:
|
|
550
|
+
? { key: mintKey, frame: f.id, x: right.x + right.w + Math.max(140, right.w * 0.12), y: right.y, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const, ...mode }
|
|
551
|
+
: { key: mintKey, frame: f.id, x: 0, y: maxY + 96, w: vp?.width ?? d.w, h: vp?.height ?? d.h, theme: resolveTheme(f), status: 'loading' as const, ...mode }
|
|
497
552
|
next.push(node)
|
|
498
553
|
// in a device view, the snapshot learns the newcomer's DEFAULT size so 0 restores it sanely
|
|
499
554
|
if (vp && nextBase) nextBase = { ...nextBase, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } }
|
|
@@ -504,7 +559,21 @@ export const useStore = create<State>((set, get) => {
|
|
|
504
559
|
let retinted = false
|
|
505
560
|
for (const n of next) {
|
|
506
561
|
const missing = !live.has(n.frame)
|
|
507
|
-
if (missing !== !!n.missing) {
|
|
562
|
+
if (missing !== !!n.missing) {
|
|
563
|
+
// a node whose frame just ARRIVED (the create-first choreography: the agent
|
|
564
|
+
// appends the board node before the manifest registers the file) was sized by
|
|
565
|
+
// the 390x844 GUESS - its real viewport is only knowable now. ONLY guessed
|
|
566
|
+
// sizes are corrected (sizeFallback): a restored frame keeps its authored w/h.
|
|
567
|
+
const arrived = !missing && m.frames.find((x) => x.id === n.frame)
|
|
568
|
+
if (arrived && n.sizeFallback && !arrived.contentWidth) {
|
|
569
|
+
const d = defaultSize(arrived)
|
|
570
|
+
n.w = vp?.width ?? d.w
|
|
571
|
+
n.h = vp?.height ?? d.h
|
|
572
|
+
}
|
|
573
|
+
if (arrived) delete n.sizeFallback
|
|
574
|
+
n.missing = missing
|
|
575
|
+
changed = true
|
|
576
|
+
}
|
|
508
577
|
// a frame's declared meta.theme may have changed - re-resolve unpinned nodes
|
|
509
578
|
// (derived value: re-render yes, dirty/save no)
|
|
510
579
|
const f = m.frames.find((x) => x.id === n.frame)
|
|
@@ -521,7 +590,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
521
590
|
if (!missing && n.status === 'error') { n.status = 'loading'; n.nav = (n.nav ?? 0) + 1; retinted = true }
|
|
522
591
|
}
|
|
523
592
|
// auto boards prune deleted frames outright - "auto-managed" must manage both
|
|
524
|
-
// directions (friction log #15). Curated boards keep the explicit card
|
|
593
|
+
// directions (friction log #15). Curated boards keep the explicit card.
|
|
525
594
|
let final = next
|
|
526
595
|
if (boardAuto) {
|
|
527
596
|
final = next.filter((n) => !n.missing)
|
|
@@ -582,7 +651,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
582
651
|
set((s) => {
|
|
583
652
|
const W = Math.max(120, w), H = Math.max(80, h)
|
|
584
653
|
const cur = s.nodes.find((n) => n.key === key)
|
|
585
|
-
// a human-resized CONTENT frame goes 'manual': measurements never overwrite it
|
|
654
|
+
// a human-resized CONTENT frame goes 'manual': measurements never overwrite it
|
|
586
655
|
const content = !!s.manifest?.frames.find((x) => x.id === cur?.frame)?.contentWidth
|
|
587
656
|
return {
|
|
588
657
|
nodes: s.nodes.map((n) => (n.key === key ? { ...n, w: W, h: H, ...(content ? { sizeMode: 'manual' as const } : {}) } : n)),
|
|
@@ -598,7 +667,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
598
667
|
if (!inGesture) scheduleSave()
|
|
599
668
|
},
|
|
600
669
|
|
|
601
|
-
/**
|
|
670
|
+
/** sh:measure. Admission: content frames only, finite positive numbers,
|
|
602
671
|
* clamped. A height only commits when it was measured at the width being applied;
|
|
603
672
|
* auto sizes are transient - applying one never dirties the board (positions from
|
|
604
673
|
* the follow-up reflow do, exactly like a human resize). */
|
|
@@ -607,7 +676,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
607
676
|
const node = s.nodes.find((n) => n.key === key)
|
|
608
677
|
// Only an explicit DEVICE viewport locks a content frame's height. 'auto' and 'manual' both
|
|
609
678
|
// auto-fit height: a content frame must always grow/shrink to show ALL its content, even after
|
|
610
|
-
// the human drags its WIDTH
|
|
679
|
+
// the human drags its WIDTH. 'manual' just means the human owns the WIDTH; the
|
|
611
680
|
// HEIGHT is still measured, so resizing width reflows and refits height (no clipped/empty frame).
|
|
612
681
|
if (!node || node.sizeMode === 'device') return
|
|
613
682
|
if (node.frame !== frameId) return // generation guard: a reused node key
|
|
@@ -657,7 +726,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
657
726
|
const f = s.manifest?.frames.find((x) => x.id === n.frame)
|
|
658
727
|
const content = !!f?.contentWidth
|
|
659
728
|
if (vp) return { ...n, w: vp.width, h: vp.height, ...(content ? { sizeMode: 'device' as const } : {}) }
|
|
660
|
-
// digit 0 on a CONTENT frame = back to auto: measured size, remeasure ahead
|
|
729
|
+
// digit 0 on a CONTENT frame = back to auto: measured size, remeasure ahead
|
|
661
730
|
const b = s.baseLayout?.[n.key]
|
|
662
731
|
if (content && f) {
|
|
663
732
|
const d = defaultSize(f)
|
|
@@ -702,7 +771,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
702
771
|
if (vp) return { ...n, w: vp.width, h: vp.height, ...(content ? { sizeMode: 'device' as const } : {}) }
|
|
703
772
|
if (!f) return n
|
|
704
773
|
const d = defaultSize(f)
|
|
705
|
-
// digit 0 on a content frame clears provenance back to auto
|
|
774
|
+
// digit 0 on a content frame clears provenance back to auto
|
|
706
775
|
return { ...n, w: d.w, h: d.h, ...(content ? { sizeMode: 'auto' as const } : {}) }
|
|
707
776
|
})
|
|
708
777
|
const baseLayout = s.baseLayout ? { ...s.baseLayout } : null
|
|
@@ -717,7 +786,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
717
786
|
selectAll() {
|
|
718
787
|
set((s) => ({ selection: s.nodes.map((n) => n.key) }))
|
|
719
788
|
},
|
|
720
|
-
// group/scene selection from sidebar rows and canvas captions
|
|
789
|
+
// group/scene selection from sidebar rows and canvas captions
|
|
721
790
|
selectMany(keys) {
|
|
722
791
|
set((s) => {
|
|
723
792
|
const valid = keys.filter((k) => s.nodes.some((n) => n.key === k))
|
|
@@ -745,7 +814,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
745
814
|
},
|
|
746
815
|
setGesture(gesture) {
|
|
747
816
|
set({ gesture })
|
|
748
|
-
//
|
|
817
|
+
// A board WITH a layout recipe re-applies it when a resize
|
|
749
818
|
// gesture ends - sizes changed, so lanes and gaps must recompute
|
|
750
819
|
if (!gesture && resizedInGesture) {
|
|
751
820
|
resizedInGesture = false
|
|
@@ -833,6 +902,22 @@ export const useStore = create<State>((set, get) => {
|
|
|
833
902
|
set((s) => ({ toasts: [...s.toasts, { id, text }] }))
|
|
834
903
|
setTimeout(() => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), 4000)
|
|
835
904
|
},
|
|
905
|
+
/** A Live Jam reply notification: persistent (no auto-dismiss), one per thread (a newer reply
|
|
906
|
+
* supersedes), capped so the stack stays small. */
|
|
907
|
+
jamToast(note) {
|
|
908
|
+
const id = ++toastSeq
|
|
909
|
+
set((s) => ({ toasts: [...s.toasts.filter((t) => t.jam?.threadId !== note.threadId), { id, text: 'Marver replied', jam: note }].slice(-8) }))
|
|
910
|
+
},
|
|
911
|
+
dismissToast(id) { set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })) },
|
|
912
|
+
clearJamToasts() { set((s) => ({ toasts: s.toasts.filter((t) => !t.jam) })) },
|
|
913
|
+
setWorking(frames) {
|
|
914
|
+
// preserve each frame's original start time; stamp now() only for newly-working frames -
|
|
915
|
+
// the start phases the working animations so parallel frames never pulse in sync
|
|
916
|
+
const prev = get().workingSince
|
|
917
|
+
const workingSince: Record<string, number> = {}
|
|
918
|
+
for (const f of frames) workingSince[f] = prev[f] ?? Date.now()
|
|
919
|
+
set({ working: frames, workingSince })
|
|
920
|
+
},
|
|
836
921
|
spawn(frameId) {
|
|
837
922
|
const { manifest, nodes, deviceView, baseLayout } = get()
|
|
838
923
|
const f = manifest?.frames.find((x) => x.id === frameId)
|
|
@@ -855,7 +940,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
855
940
|
// a resize gesture in flight = torn state (new sizes, pre-recipe positions):
|
|
856
941
|
// even a PREVIOUSLY scheduled timer must defer to the gesture-end save
|
|
857
942
|
if (get().gesture && resizedInGesture) { scheduleSave(); return Promise.resolve(false) }
|
|
858
|
-
// a published canvas is read-only ON DISK
|
|
943
|
+
// a published canvas is read-only ON DISK: theme pins persist to the
|
|
859
944
|
// session instead, and dirty must clear or switchBoard's save-flush loop wedges
|
|
860
945
|
if (DATA) {
|
|
861
946
|
sessionPins.write(get().board, get().nodes)
|
|
@@ -866,11 +951,11 @@ export const useStore = create<State>((set, get) => {
|
|
|
866
951
|
const p = saveChain.then(async () => {
|
|
867
952
|
// recheck INSIDE the serialized chain: a save queued behind an in-flight PUT
|
|
868
953
|
// may only execute after a resize gesture began - the entry guard above ran
|
|
869
|
-
// too early to see it (
|
|
954
|
+
// too early to see it (torn PUT #2 behind blocked PUT #1)
|
|
870
955
|
if (get().gesture && resizedInGesture) { scheduleSave(); return false }
|
|
871
956
|
const rev = editRev
|
|
872
957
|
const { nodes, boardHash, deviceView, baseLayout, board: boardName, boardAuto } = get()
|
|
873
|
-
// Missing nodes persist too - only the explicit remove button drops them
|
|
958
|
+
// Missing nodes persist too - only the explicit remove button drops them.
|
|
874
959
|
const board = {
|
|
875
960
|
version: 1, name: boardName, auto: boardAuto,
|
|
876
961
|
// deviceView and the free-form snapshot persist independently: a scoped
|
|
@@ -879,7 +964,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
879
964
|
...(get().sceneRows?.length ? { sceneRows: get().sceneRows } : {}),
|
|
880
965
|
...(get().layoutRaw !== undefined ? { layout: get().layoutRaw } : {}),
|
|
881
966
|
// baseLayout entries for auto content nodes keep POSITIONS only - their
|
|
882
|
-
// measured dimensions are transient and never reach the file
|
|
967
|
+
// measured dimensions are transient and never reach the file
|
|
883
968
|
...(baseLayout ? {
|
|
884
969
|
baseLayout: Object.fromEntries(Object.entries(baseLayout).map(([k, b]) => {
|
|
885
970
|
const n = nodes.find((x) => x.key === k)
|
|
@@ -887,11 +972,14 @@ export const useStore = create<State>((set, get) => {
|
|
|
887
972
|
})),
|
|
888
973
|
} : {}),
|
|
889
974
|
// only PINNED themes persist - inherited values follow viewTheme at load time.
|
|
890
|
-
// Content frames in AUTO save no dimensions
|
|
975
|
+
// Content frames in AUTO save no dimensions: measured sizes are
|
|
891
976
|
// transient - a reflow-triggered save can never leak an auto height to disk.
|
|
892
|
-
|
|
977
|
+
// Guessed sizes (sizeFallback: the frame was not in the manifest yet) stay
|
|
978
|
+
// transient too - persisting the 390x844 guess would make it look authored
|
|
979
|
+
// and immortalize it across reloads.
|
|
980
|
+
nodes: nodes.map(({ key, frame, x, y, w, h, themeUser, sizeMode, sizeFallback }) => ({
|
|
893
981
|
key, frame, x: Math.round(x), y: Math.round(y),
|
|
894
|
-
...(sizeMode === 'auto' ? {} : { w: Math.round(w), h: Math.round(h) }),
|
|
982
|
+
...(sizeMode === 'auto' || sizeFallback ? {} : { w: Math.round(w), h: Math.round(h) }),
|
|
895
983
|
...(sizeMode === 'manual' || sizeMode === 'device' ? { sizeMode } : {}),
|
|
896
984
|
...(themeUser ? { themeUser } : {}),
|
|
897
985
|
})),
|