@marver-design/marver 0.8.0 → 0.9.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 +168 -5
- package/README.md +73 -16
- package/dist/{auth-B36fMCM3.mjs → auth-KQ9Aj-nB.mjs} +1 -1
- package/dist/{build-B8z902TW.mjs → build-D9gimz6K.mjs} +5 -5
- package/dist/cli.mjs +16 -7
- package/dist/{collab-pLGzSvm5.mjs → collab-s3k5byM1.mjs} +3 -3
- package/dist/{comments-DSwMhdbZ.mjs → comments-BZBKhKRO.mjs} +2 -2
- package/dist/{comments-BrpC86Kf.mjs → comments-J06jqCVV.mjs} +3 -3
- package/dist/{daemon-C-huGHAM.mjs → daemon-2Lft_lVV.mjs} +60 -66
- package/dist/{dev-BYm9ebfN.mjs → dev-CTtkqVo_.mjs} +21 -7
- package/dist/{init-DrLomSWq.mjs → init-8Giknvy1.mjs} +35 -7
- package/dist/ledger-BgA7nQoH.mjs +103 -0
- package/dist/{manifest-D3eaARf4.mjs → manifest-DJHU7qfu.mjs} +144 -19
- package/dist/{plugin-BVFuRfEo.mjs → plugin-BrBWk2Qn.mjs} +41 -9
- package/dist/{serve-CwAfayJk.mjs → serve-CZqPnj19.mjs} +32 -8
- package/dist/{sync-Ch4Bymb1.mjs → sync-BJKKmy1n.mjs} +2 -2
- package/dist/work-CLrmY-vQ.mjs +97 -0
- package/dist/work-lzC-lPY0.mjs +76 -0
- package/package.json +17 -2
- 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 +2 -2
- package/src/client/frame-host/serialize.ts +2 -2
- package/src/client/shell/App.tsx +79 -22
- package/src/client/shell/Comments.tsx +24 -18
- package/src/client/shell/Play.tsx +2 -2
- package/src/client/shell/canvas/Canvas.tsx +1 -1
- package/src/client/shell/canvas/FrameNode.tsx +16 -11
- package/src/client/shell/canvas/snapshots.ts +3 -3
- package/src/client/shell/comments-store.ts +115 -42
- package/src/client/shell/hash.ts +2 -2
- package/src/client/shell/icons.tsx +1 -1
- package/src/client/shell/keys.ts +39 -0
- package/src/client/shell/mentions.ts +1 -1
- package/src/client/shell/perf.ts +1 -1
- package/src/client/shell/store.ts +99 -39
- package/src/client/shell/styles.css +16 -16
- package/src/client/shell/tidy.ts +5 -5
- package/src/client/stage/main.tsx +2 -2
- package/src/shared/events.ts +2 -2
- package/src/shared/utm.ts +22 -0
- package/templates/AGENTS-embedded.md +49 -2
- package/templates/AGENTS-studio.md +49 -2
- package/templates/instructions/configure.md +6 -1
- package/templates/instructions/jam.md +29 -9
- package/templates/instructions/publish.md +1 -1
- package/templates/instructions/welcome.md +7 -1
- package/dist/ledger-wFvEIEGi.mjs +0 -64
|
@@ -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,19 @@ 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
|
-
/** A Live Jam notification
|
|
42
|
+
/** A Live Jam notification: a persistent bottom-right glass pill for a Marver
|
|
37
43
|
* reply. Frame-first: the FRAME is the news (icon + title, blue), Marver + preview below. */
|
|
38
44
|
export interface JamNote { threadId: string; board: string; preview: string; ts: number; frame?: string; frameTitle?: string; intent?: string }
|
|
39
45
|
export interface Toast { id: number; text: string; jam?: JamNote }
|
|
@@ -58,6 +64,18 @@ export async function fetchBoardNames(): Promise<string[]> {
|
|
|
58
64
|
/** Display name for a board: the reserved 'all-scenes' key reads as "All scenes". */
|
|
59
65
|
export const boardLabel = (n: string) => humanize(n)
|
|
60
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
|
+
|
|
61
79
|
const HEADER = 28
|
|
62
80
|
let toastSeq = 0
|
|
63
81
|
const nodeKey = () => 'n_' + Math.random().toString(36).slice(2, 8)
|
|
@@ -85,12 +103,12 @@ const initialViewTheme = () => {
|
|
|
85
103
|
}
|
|
86
104
|
const manifestKey = (m: Manifest) => JSON.stringify(m.frames) // any change counts, not just added/removed ids
|
|
87
105
|
|
|
88
|
-
/** Latest measured content heights, keyed frameId@width. TRANSIENT by design
|
|
89
|
-
*
|
|
106
|
+
/** Latest measured content heights, keyed frameId@width. TRANSIENT by design:
|
|
107
|
+
* auto sizes are never serialized - a reload remeasures. */
|
|
90
108
|
const measuredHeights = new Map<string, number>()
|
|
91
109
|
|
|
92
110
|
function defaultSize(frame: FrameEntry) {
|
|
93
|
-
// content frames
|
|
111
|
+
// content frames: own width from Doc layout; height from the latest
|
|
94
112
|
// measurement at that width, or a placeholder until sh:measure lands.
|
|
95
113
|
// meta.viewport, when declared, wins - the existing precedence.
|
|
96
114
|
if (frame.contentWidth && !frame.viewport) {
|
|
@@ -103,7 +121,7 @@ function defaultSize(frame: FrameEntry) {
|
|
|
103
121
|
|
|
104
122
|
let resizedInGesture = false
|
|
105
123
|
|
|
106
|
-
/** sceneRows is the legacy shorthand for a plain rows layout
|
|
124
|
+
/** sceneRows is the legacy shorthand for a plain rows layout. */
|
|
107
125
|
const effectiveLayout = (layout: BoardLayout | null, sceneRows: string[][] | null): BoardLayout | undefined =>
|
|
108
126
|
layout ?? (sceneRows?.length ? { rows: sceneRows.map((r) => [...r]) } : undefined)
|
|
109
127
|
|
|
@@ -119,14 +137,14 @@ interface State {
|
|
|
119
137
|
selection: string[] // ordered; last entry is the primary (bar anchor)
|
|
120
138
|
interact: string | null
|
|
121
139
|
viewTheme: string // the global theme preference; sticky across boards and reloads
|
|
122
|
-
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
|
|
123
141
|
gesture: boolean // a frame drag/resize is in progress - canvas panning is disabled
|
|
124
|
-
laser: boolean // laser/inspect mode
|
|
142
|
+
laser: boolean // laser/inspect mode: frames outline their structure
|
|
125
143
|
board: string // active board name; 'all-scenes' is the auto board
|
|
126
144
|
boardAuto: boolean // auto boards gain new frames on arrival; curated boards never do
|
|
127
145
|
deviceView: string | null // board-wide device preview (viewport name), null = free-form layout
|
|
128
|
-
sceneRows: string[][] | null // LEGACY scene arrangement
|
|
129
|
-
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
|
|
130
148
|
layoutRaw: unknown // the author's layout VERBATIM - save round-trips this, never the parse
|
|
131
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)
|
|
132
150
|
panelOpen: boolean
|
|
@@ -175,7 +193,7 @@ interface State {
|
|
|
175
193
|
jamToast(note: JamNote): void
|
|
176
194
|
dismissToast(id: number): void
|
|
177
195
|
clearJamToasts(): void
|
|
178
|
-
/** Frames Marver is editing right now (Live Jam presence
|
|
196
|
+
/** Frames Marver is editing right now (Live Jam presence) - drives the working glow. */
|
|
179
197
|
working: string[]
|
|
180
198
|
/** When each working frame's job started (ms epoch) - phases the animations so parallel
|
|
181
199
|
* frames never pulse in sync. */
|
|
@@ -203,7 +221,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
203
221
|
let switchSeq = 0 // last click wins when board switches race
|
|
204
222
|
const scheduleSave = () => { editRev++; clearTimeout(saveTimer); saveTimer = setTimeout(() => get().save(), 500) }
|
|
205
223
|
|
|
206
|
-
//
|
|
224
|
+
// One cancelable, BOARD-SCOPED reflow after content measurements settle.
|
|
207
225
|
// The captured board name is the generation guard - a debounce surviving a board
|
|
208
226
|
// switch fires into a name check and dies, never touching the new board.
|
|
209
227
|
let reflowTimer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -237,7 +255,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
237
255
|
},
|
|
238
256
|
}
|
|
239
257
|
|
|
240
|
-
/** Published parity for content-frame sizes
|
|
258
|
+
/** Published parity for content-frame sizes: manual/device sizes persist
|
|
241
259
|
* to the session the same way theme pins do - a board switch or reload on a
|
|
242
260
|
* published canvas must not silently drop a hand-resized spec frame. Keys are
|
|
243
261
|
* frame#occurrence: node keys are minted per load on virtual boards, but the node
|
|
@@ -305,19 +323,26 @@ export const useStore = create<State>((set, get) => {
|
|
|
305
323
|
boardHash = sha256
|
|
306
324
|
if (typeof board?.auto === 'boolean') boardAuto = board.auto
|
|
307
325
|
// Agent-authored boards may be just a frame list - fill sizes/keys, tidy on first load.
|
|
308
|
-
// Keys are load-bearing (iframe identity, selection) - dupes/blanks
|
|
309
|
-
//
|
|
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.
|
|
310
330
|
const seenKeys = new Set<string>()
|
|
331
|
+
const ordinals = new Map<string, number>() // per-frame keyless-occurrence counter
|
|
311
332
|
nodes = (Array.isArray(board?.nodes) ? board.nodes : [])
|
|
312
333
|
.filter((n: any) => n && typeof n.frame === 'string')
|
|
313
334
|
.map((n: any) => {
|
|
314
335
|
const f = manifest.frames.find((x) => x.id === n.frame)
|
|
315
336
|
const d = f ? defaultSize(f) : { w: 390, h: 844 }
|
|
316
337
|
if (typeof n.x !== 'number' || typeof n.y !== 'number') needTidy = true
|
|
317
|
-
let key = typeof n.key === 'string' && n.key ? n.key :
|
|
318
|
-
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
|
+
}
|
|
319
344
|
seenKeys.add(key)
|
|
320
|
-
// content-frame size provenance round-trips
|
|
345
|
+
// content-frame size provenance round-trips; auto nodes saved no
|
|
321
346
|
// w/h, so they fall to defaultSize (placeholder) and remeasure on mount
|
|
322
347
|
const sizeMode = f?.contentWidth
|
|
323
348
|
? { sizeMode: (n.sizeMode === 'manual' || n.sizeMode === 'device' ? n.sizeMode : 'auto') as Node['sizeMode'] }
|
|
@@ -327,6 +352,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
327
352
|
frame: n.frame,
|
|
328
353
|
x: typeof n.x === 'number' ? n.x : 0, y: typeof n.y === 'number' ? n.y : 0,
|
|
329
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 } : {}),
|
|
330
356
|
...sizeMode,
|
|
331
357
|
// pins persist as their own field (exact round-trip). Legacy boards stored a
|
|
332
358
|
// theme on EVERY node: only values differing from the frame's static default
|
|
@@ -357,7 +383,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
357
383
|
if (board?.baseLayout && typeof board.baseLayout === 'object') baseLayout = board.baseLayout
|
|
358
384
|
}
|
|
359
385
|
// auto-managed goes both ways (friction log #15): an auto board gains new frames
|
|
360
|
-
// AND sheds deleted ones. Tombstone cards are a curated-board concept
|
|
386
|
+
// AND sheds deleted ones. Tombstone cards are a curated-board concept.
|
|
361
387
|
// Pruning at load DIRTIES the board (dev only) so the file on disk sheds the
|
|
362
388
|
// tombstones too - otherwise a recreated frame id resurrects its stale node.
|
|
363
389
|
let prunedAtLoad = false
|
|
@@ -373,6 +399,15 @@ export const useStore = create<State>((set, get) => {
|
|
|
373
399
|
// saved piled up at the origin)
|
|
374
400
|
if (boardAuto) {
|
|
375
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
|
+
}
|
|
376
411
|
let maxX = nodes.reduce((a, n) => Math.max(a, n.x + n.w), 0)
|
|
377
412
|
for (const f of manifest.frames) {
|
|
378
413
|
if (placed.has(f.id)) continue
|
|
@@ -387,7 +422,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
387
422
|
: null
|
|
388
423
|
if (sibling) { x = sibling.x + sibling.w + Math.max(140, sibling.w * 0.12); y = sibling.y }
|
|
389
424
|
else { x = nodes.length ? maxX + 96 : 0; y = 0 }
|
|
390
|
-
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'] } : {}) }
|
|
391
426
|
nodes.push(node)
|
|
392
427
|
maxX = Math.max(maxX, x + w)
|
|
393
428
|
}
|
|
@@ -497,15 +532,23 @@ export const useStore = create<State>((set, get) => {
|
|
|
497
532
|
const d = defaultSize(f)
|
|
498
533
|
const maxY = next.reduce((a, n) => Math.max(a, n.y + n.h), 0)
|
|
499
534
|
// a grouped newcomer lands BESIDE its siblings, not at the canvas bottom - the
|
|
500
|
-
// badge/caption say "these belong together"; the position must agree
|
|
535
|
+
// badge/caption say "these belong together"; the position must agree
|
|
501
536
|
const sibs = f.variantGroup
|
|
502
537
|
? next.filter((n) => { const g = m.frames.find((x) => x.id === n.frame)?.variantGroup; return g === f.variantGroup && !n.missing })
|
|
503
538
|
: []
|
|
504
539
|
const right = sibs.length ? sibs.reduce((a, n) => (n.x > a.x ? n : a)) : null
|
|
505
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
|
+
})()
|
|
506
549
|
const node = right
|
|
507
|
-
? { key:
|
|
508
|
-
: { 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 }
|
|
509
552
|
next.push(node)
|
|
510
553
|
// in a device view, the snapshot learns the newcomer's DEFAULT size so 0 restores it sanely
|
|
511
554
|
if (vp && nextBase) nextBase = { ...nextBase, [node.key]: { x: node.x, y: node.y, w: d.w, h: d.h } }
|
|
@@ -516,7 +559,21 @@ export const useStore = create<State>((set, get) => {
|
|
|
516
559
|
let retinted = false
|
|
517
560
|
for (const n of next) {
|
|
518
561
|
const missing = !live.has(n.frame)
|
|
519
|
-
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
|
+
}
|
|
520
577
|
// a frame's declared meta.theme may have changed - re-resolve unpinned nodes
|
|
521
578
|
// (derived value: re-render yes, dirty/save no)
|
|
522
579
|
const f = m.frames.find((x) => x.id === n.frame)
|
|
@@ -533,7 +590,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
533
590
|
if (!missing && n.status === 'error') { n.status = 'loading'; n.nav = (n.nav ?? 0) + 1; retinted = true }
|
|
534
591
|
}
|
|
535
592
|
// auto boards prune deleted frames outright - "auto-managed" must manage both
|
|
536
|
-
// directions (friction log #15). Curated boards keep the explicit card
|
|
593
|
+
// directions (friction log #15). Curated boards keep the explicit card.
|
|
537
594
|
let final = next
|
|
538
595
|
if (boardAuto) {
|
|
539
596
|
final = next.filter((n) => !n.missing)
|
|
@@ -594,7 +651,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
594
651
|
set((s) => {
|
|
595
652
|
const W = Math.max(120, w), H = Math.max(80, h)
|
|
596
653
|
const cur = s.nodes.find((n) => n.key === key)
|
|
597
|
-
// a human-resized CONTENT frame goes 'manual': measurements never overwrite it
|
|
654
|
+
// a human-resized CONTENT frame goes 'manual': measurements never overwrite it
|
|
598
655
|
const content = !!s.manifest?.frames.find((x) => x.id === cur?.frame)?.contentWidth
|
|
599
656
|
return {
|
|
600
657
|
nodes: s.nodes.map((n) => (n.key === key ? { ...n, w: W, h: H, ...(content ? { sizeMode: 'manual' as const } : {}) } : n)),
|
|
@@ -610,7 +667,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
610
667
|
if (!inGesture) scheduleSave()
|
|
611
668
|
},
|
|
612
669
|
|
|
613
|
-
/**
|
|
670
|
+
/** sh:measure. Admission: content frames only, finite positive numbers,
|
|
614
671
|
* clamped. A height only commits when it was measured at the width being applied;
|
|
615
672
|
* auto sizes are transient - applying one never dirties the board (positions from
|
|
616
673
|
* the follow-up reflow do, exactly like a human resize). */
|
|
@@ -619,7 +676,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
619
676
|
const node = s.nodes.find((n) => n.key === key)
|
|
620
677
|
// Only an explicit DEVICE viewport locks a content frame's height. 'auto' and 'manual' both
|
|
621
678
|
// auto-fit height: a content frame must always grow/shrink to show ALL its content, even after
|
|
622
|
-
// the human drags its WIDTH
|
|
679
|
+
// the human drags its WIDTH. 'manual' just means the human owns the WIDTH; the
|
|
623
680
|
// HEIGHT is still measured, so resizing width reflows and refits height (no clipped/empty frame).
|
|
624
681
|
if (!node || node.sizeMode === 'device') return
|
|
625
682
|
if (node.frame !== frameId) return // generation guard: a reused node key
|
|
@@ -669,7 +726,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
669
726
|
const f = s.manifest?.frames.find((x) => x.id === n.frame)
|
|
670
727
|
const content = !!f?.contentWidth
|
|
671
728
|
if (vp) return { ...n, w: vp.width, h: vp.height, ...(content ? { sizeMode: 'device' as const } : {}) }
|
|
672
|
-
// 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
|
|
673
730
|
const b = s.baseLayout?.[n.key]
|
|
674
731
|
if (content && f) {
|
|
675
732
|
const d = defaultSize(f)
|
|
@@ -714,7 +771,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
714
771
|
if (vp) return { ...n, w: vp.width, h: vp.height, ...(content ? { sizeMode: 'device' as const } : {}) }
|
|
715
772
|
if (!f) return n
|
|
716
773
|
const d = defaultSize(f)
|
|
717
|
-
// digit 0 on a content frame clears provenance back to auto
|
|
774
|
+
// digit 0 on a content frame clears provenance back to auto
|
|
718
775
|
return { ...n, w: d.w, h: d.h, ...(content ? { sizeMode: 'auto' as const } : {}) }
|
|
719
776
|
})
|
|
720
777
|
const baseLayout = s.baseLayout ? { ...s.baseLayout } : null
|
|
@@ -729,7 +786,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
729
786
|
selectAll() {
|
|
730
787
|
set((s) => ({ selection: s.nodes.map((n) => n.key) }))
|
|
731
788
|
},
|
|
732
|
-
// group/scene selection from sidebar rows and canvas captions
|
|
789
|
+
// group/scene selection from sidebar rows and canvas captions
|
|
733
790
|
selectMany(keys) {
|
|
734
791
|
set((s) => {
|
|
735
792
|
const valid = keys.filter((k) => s.nodes.some((n) => n.key === k))
|
|
@@ -757,7 +814,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
757
814
|
},
|
|
758
815
|
setGesture(gesture) {
|
|
759
816
|
set({ gesture })
|
|
760
|
-
//
|
|
817
|
+
// A board WITH a layout recipe re-applies it when a resize
|
|
761
818
|
// gesture ends - sizes changed, so lanes and gaps must recompute
|
|
762
819
|
if (!gesture && resizedInGesture) {
|
|
763
820
|
resizedInGesture = false
|
|
@@ -883,7 +940,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
883
940
|
// a resize gesture in flight = torn state (new sizes, pre-recipe positions):
|
|
884
941
|
// even a PREVIOUSLY scheduled timer must defer to the gesture-end save
|
|
885
942
|
if (get().gesture && resizedInGesture) { scheduleSave(); return Promise.resolve(false) }
|
|
886
|
-
// a published canvas is read-only ON DISK
|
|
943
|
+
// a published canvas is read-only ON DISK: theme pins persist to the
|
|
887
944
|
// session instead, and dirty must clear or switchBoard's save-flush loop wedges
|
|
888
945
|
if (DATA) {
|
|
889
946
|
sessionPins.write(get().board, get().nodes)
|
|
@@ -894,11 +951,11 @@ export const useStore = create<State>((set, get) => {
|
|
|
894
951
|
const p = saveChain.then(async () => {
|
|
895
952
|
// recheck INSIDE the serialized chain: a save queued behind an in-flight PUT
|
|
896
953
|
// may only execute after a resize gesture began - the entry guard above ran
|
|
897
|
-
// too early to see it (
|
|
954
|
+
// too early to see it (torn PUT #2 behind blocked PUT #1)
|
|
898
955
|
if (get().gesture && resizedInGesture) { scheduleSave(); return false }
|
|
899
956
|
const rev = editRev
|
|
900
957
|
const { nodes, boardHash, deviceView, baseLayout, board: boardName, boardAuto } = get()
|
|
901
|
-
// Missing nodes persist too - only the explicit remove button drops them
|
|
958
|
+
// Missing nodes persist too - only the explicit remove button drops them.
|
|
902
959
|
const board = {
|
|
903
960
|
version: 1, name: boardName, auto: boardAuto,
|
|
904
961
|
// deviceView and the free-form snapshot persist independently: a scoped
|
|
@@ -907,7 +964,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
907
964
|
...(get().sceneRows?.length ? { sceneRows: get().sceneRows } : {}),
|
|
908
965
|
...(get().layoutRaw !== undefined ? { layout: get().layoutRaw } : {}),
|
|
909
966
|
// baseLayout entries for auto content nodes keep POSITIONS only - their
|
|
910
|
-
// measured dimensions are transient and never reach the file
|
|
967
|
+
// measured dimensions are transient and never reach the file
|
|
911
968
|
...(baseLayout ? {
|
|
912
969
|
baseLayout: Object.fromEntries(Object.entries(baseLayout).map(([k, b]) => {
|
|
913
970
|
const n = nodes.find((x) => x.key === k)
|
|
@@ -915,11 +972,14 @@ export const useStore = create<State>((set, get) => {
|
|
|
915
972
|
})),
|
|
916
973
|
} : {}),
|
|
917
974
|
// only PINNED themes persist - inherited values follow viewTheme at load time.
|
|
918
|
-
// Content frames in AUTO save no dimensions
|
|
975
|
+
// Content frames in AUTO save no dimensions: measured sizes are
|
|
919
976
|
// transient - a reflow-triggered save can never leak an auto height to disk.
|
|
920
|
-
|
|
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 }) => ({
|
|
921
981
|
key, frame, x: Math.round(x), y: Math.round(y),
|
|
922
|
-
...(sizeMode === 'auto' ? {} : { w: Math.round(w), h: Math.round(h) }),
|
|
982
|
+
...(sizeMode === 'auto' || sizeFallback ? {} : { w: Math.round(w), h: Math.round(h) }),
|
|
923
983
|
...(sizeMode === 'manual' || sizeMode === 'device' ? { sizeMode } : {}),
|
|
924
984
|
...(themeUser ? { themeUser } : {}),
|
|
925
985
|
})),
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
--interact: #db35f2; --interact-ring: rgba(219, 53, 242, .16);
|
|
29
29
|
--interact-strong: rgba(219, 53, 242, 1); --interact-soft: rgba(234, 141, 255, .95);
|
|
30
30
|
--interact-deep: rgba(176, 47, 194, .85); --interact-spark: rgba(255, 255, 255, .95);
|
|
31
|
-
/* comments
|
|
32
|
-
|
|
33
|
-
--comment: #
|
|
31
|
+
/* comments ride the brand blue: interact keeps purple, and a pin, a thread card
|
|
32
|
+
and a selected frame are told apart by shape, not by hue */
|
|
33
|
+
--comment: #0088ff; --comment-ring: rgba(0, 136, 255, .22);
|
|
34
34
|
/* comment-card field surface: APP-scoped on purpose - the card keys to the board
|
|
35
35
|
theme, and node-scoped --node-bg would flip with the frame underneath it */
|
|
36
36
|
--cm-field: #fff;
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
--interact: #ea8dff; --interact-ring: rgba(219, 53, 242, .3);
|
|
68
68
|
--interact-strong: rgba(219, 53, 242, 1); --interact-soft: rgba(234, 141, 255, .95);
|
|
69
69
|
--interact-deep: rgba(203, 48, 224, .8); --interact-spark: rgba(255, 255, 255, .9);
|
|
70
|
-
--comment: #
|
|
70
|
+
--comment: #4da6ff; --comment-ring: rgba(77, 166, 255, .34);
|
|
71
71
|
--cm-field: #0f1015;
|
|
72
72
|
--cm-modal-bg: rgba(22, 22, 27, .96);
|
|
73
73
|
|
|
@@ -157,7 +157,7 @@ button svg { pointer-events: none } /* event targets stay on the button - pan
|
|
|
157
157
|
#sh-world.sh-preset .sh-node-body,
|
|
158
158
|
#sh-world.sh-preset .sh-node .sh-live { transition: width .32s cubic-bezier(.25,.46,.45,.94),
|
|
159
159
|
height .32s cubic-bezier(.25,.46,.45,.94) }
|
|
160
|
-
/*
|
|
160
|
+
/* Device-sweep: a frame WITH a ready lean cover jumps its LIVE iframe straight to the final
|
|
161
161
|
width (ONE reflow, hidden under the cover) while the lean cover - being width:100% of the animating
|
|
162
162
|
body - reflows smoothly every frame (real CSS, the device-sweep fix). A frame WITHOUT a usable
|
|
163
163
|
cover keeps the live width animation above. */
|
|
@@ -200,7 +200,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
200
200
|
background-color: var(--head-bg); background-image: var(--head-sheen);
|
|
201
201
|
backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur) }
|
|
202
202
|
.sh-node-head .id { font-weight: 600; color: var(--head-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
|
203
|
-
/* frame icons
|
|
203
|
+
/* frame icons: every sidebar row leads with one */
|
|
204
204
|
.sh-node-head .iicon { flex: none; color: var(--head-dim) }
|
|
205
205
|
.sh-panel .sub .iicon { flex: none; margin-right: 7px; opacity: .72 }
|
|
206
206
|
/* variant members sit visibly INSIDE their group: the letter chip starts
|
|
@@ -209,7 +209,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
209
209
|
.sh-node-head .dim { margin-left: auto; color: var(--head-dim); flex: none; font-variant-numeric: tabular-nums }
|
|
210
210
|
.sh-node-body { position: relative; background: var(--node-bg); border-radius: 0 0 var(--r-node) var(--r-node); overflow: hidden }
|
|
211
211
|
.sh-node iframe { border: 0; display: block }
|
|
212
|
-
/*
|
|
212
|
+
/* LEAN-PRIMARY: the lean DOM-snapshot <iframe> (static html, 0 JS) is what you SEE for a
|
|
213
213
|
passive frame - at rest AND during pan/zoom/resize. There is NO per-gesture swap between the lean
|
|
214
214
|
and the live iframe (the swap shifted text ~1-2px = "jiggle", and flashed mermaid/theme colors,
|
|
215
215
|
because two documents never render pixel-identically). The live app (.sh-live) sits underneath and
|
|
@@ -290,8 +290,8 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
290
290
|
.sh-node.interact::before, .sh-node.interact::after,
|
|
291
291
|
.sh-node.working::before { animation: none } }
|
|
292
292
|
|
|
293
|
-
/* Live Jam "working"
|
|
294
|
-
outline + 4px ring, edge-light off - in MARVER BLUE
|
|
293
|
+
/* Live Jam "working": the SELECTED state's exact geometry - crisp border + 2px screen-px
|
|
294
|
+
outline + 4px ring, edge-light off - in MARVER BLUE, composed of three
|
|
295
295
|
layers: the border shimmer (::before orbit), the periodic content WAVE (.sh-work-wave), and the
|
|
296
296
|
shimmer MATRIX of tiny marks on the left flank (.sh-work-ind). Every animated layer is phased by
|
|
297
297
|
--mv-w0 (negative ms since THIS frame's job started), so parallel frames never pulse in sync.
|
|
@@ -566,7 +566,7 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
566
566
|
transition: color .15s, background .15s }
|
|
567
567
|
.sh-update .x:hover { background: var(--glass-hover); color: var(--glass-ink) }
|
|
568
568
|
|
|
569
|
-
/* bottom-RIGHT now
|
|
569
|
+
/* bottom-RIGHT now: jam reply pills live here; plain toasts ride along */
|
|
570
570
|
.sh-toasts { position: absolute; right: var(--edge); bottom: var(--edge); z-index: 32; display: flex;
|
|
571
571
|
flex-direction: column; align-items: flex-end; gap: 8px; max-width: min(360px, 40vw) }
|
|
572
572
|
.sh-toast { display: flex; align-items: center; gap: 6px; font: 500 12px -apple-system, system-ui, sans-serif;
|
|
@@ -624,7 +624,7 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
624
624
|
border: 0; border-radius: 50%; background: none; color: var(--glass-ink-3); cursor: pointer; transition: background .12s, color .12s }
|
|
625
625
|
.sh-jam-x:hover { background: var(--glass-hover); color: var(--glass-ink) }
|
|
626
626
|
|
|
627
|
-
/* ---- Play mode
|
|
627
|
+
/* ---- Play mode: near-black stage, one centered device, auto-hiding bar.
|
|
628
628
|
Fixed dark palette - the backdrop never follows the board theme, so no tokens here. */
|
|
629
629
|
.sh-play { position: absolute; inset: 0; z-index: 30; background: #0a0a0b; display: flex;
|
|
630
630
|
align-items: center; justify-content: center; animation: sh-play-in .28s ease-out; cursor: auto }
|
|
@@ -724,8 +724,8 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
724
724
|
.sh-app :focus { outline: none }
|
|
725
725
|
body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-offset: -2px }
|
|
726
726
|
|
|
727
|
-
/* ---- Variant groups
|
|
728
|
-
Both live in WORLD space (scale with the canvas
|
|
727
|
+
/* ---- Variant groups: badge left of grouped frames + group caption.
|
|
728
|
+
Both live in WORLD space (scale with the canvas) with a screen-space
|
|
729
729
|
minimum via --sh-inv, the resize-handle trick - legible at overview zoom, proportionate
|
|
730
730
|
up close. Colors are on-canvas ink (the dot-grid's world), not chrome glass. */
|
|
731
731
|
/* gap to the frame is ~12px screen at working zooms, capped at 40 world px far out:
|
|
@@ -761,7 +761,7 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
|
|
|
761
761
|
.sh-app.dark .sh-gcaption { color: rgba(245, 245, 247, .45) }
|
|
762
762
|
|
|
763
763
|
/* sidebar variant rows: group header carries the experimentation mark; each variant
|
|
764
|
-
row carries its letter CHIP (accent-filled when selected) + name
|
|
764
|
+
row carries its letter CHIP (accent-filled when selected) + name */
|
|
765
765
|
/* gap 0: the leading .iicon's margin-right carries the spacing, so the group
|
|
766
766
|
label lands on the SAME 51px text column as every sibling row */
|
|
767
767
|
.sub.vgroup { display: flex; align-items: center; gap: 0 }
|
|
@@ -779,7 +779,7 @@ body.sh-kbd .sh-app :focus-visible { outline: 2px solid var(--accent); outline-o
|
|
|
779
779
|
.sub .chip:hover { color: var(--glass-ink); background: var(--glass-hover) }
|
|
780
780
|
.sub .chip.on { color: #fff; background: var(--accent); border-color: var(--accent) }
|
|
781
781
|
|
|
782
|
-
/* play-mode variant chips
|
|
782
|
+
/* play-mode variant chips: the SAME badge language as the sidebar's
|
|
783
783
|
variant rows (20px, 7px-radius square, one consistent shape in every state) so the
|
|
784
784
|
two surfaces read as one concept - but in the pill's own dark-glass inks, never
|
|
785
785
|
theme tokens (the nav is always dark; theme ink goes invisible in light mode). */
|
|
@@ -877,7 +877,7 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
|
|
|
877
877
|
.sub.vgroup.held { background: color-mix(in srgb, var(--glass-hover) 55%, transparent) }
|
|
878
878
|
.sub.vgroup.held .gicon { color: var(--glass-ink-2) }
|
|
879
879
|
|
|
880
|
-
/* ---- comments
|
|
880
|
+
/* ---- comments ----------------------------------------------------------
|
|
881
881
|
The marker family (pins, stacks) is SOLID with the Figma teardrop silhouette - a
|
|
882
882
|
marker must hold contrast on any artwork. The surfaces (card, composer, dialog)
|
|
883
883
|
are OUR glass - the shell's own token language, theme-aware where Figma is flat. */
|
package/src/client/shell/tidy.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export interface TidyNode { key: string; frame: string; scene: string; group?: string; variant?: string; w: number; h: number }
|
|
2
2
|
export interface Placed { key: string; x: number; y: number }
|
|
3
3
|
|
|
4
|
-
//
|
|
4
|
+
// Lane-flow grammar: one shape at both scopes. A scope is rows XOR columns
|
|
5
5
|
// of lanes; a lane is atoms + {space:n} tokens. Board atoms are scene names; a scene
|
|
6
6
|
// recipe's atoms are frame basenames or a variant-group name (one indivisible run).
|
|
7
7
|
export interface Space { space: number }
|
|
@@ -14,7 +14,7 @@ const isSpace = (x: unknown): x is Space => !!x && typeof x === 'object' && !Arr
|
|
|
14
14
|
const units = (s: Space, warn: Warn): number =>
|
|
15
15
|
Number.isInteger(s.space) && s.space > 0 ? s.space : (warn(`invalid space ${JSON.stringify(s.space)} - using 1`), 1)
|
|
16
16
|
|
|
17
|
-
// Adaptive units
|
|
17
|
+
// Adaptive units: a "block" is a multiple of the proportional gutter,
|
|
18
18
|
// measured from the touching content's characteristic FRAME size (its largest single
|
|
19
19
|
// frame) - a wide multi-frame box must not inflate its neighbors' gutters.
|
|
20
20
|
const frameGapX = (w: number) => Math.max(140, w * 0.12)
|
|
@@ -251,7 +251,7 @@ function layoutScene(scene: string, members: TidyNode[], flow: Flow | undefined,
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
/**
|
|
254
|
-
* Pure layout
|
|
254
|
+
* Pure layout. Returns positions only - the nodes
|
|
255
255
|
* array is never reordered (iframe law G-1). Two passes: each scene lays out its
|
|
256
256
|
* frames (recipe or default lane), then the board flow places the scene boxes.
|
|
257
257
|
*/
|
|
@@ -272,7 +272,7 @@ export function tidy(nodes: TidyNode[], layout?: BoardLayout, warn: Warn = () =>
|
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
// pass 2: board flow over scene boxes. rows AND columns is invalid: fall back to
|
|
275
|
-
// PLAIN tidy (default trailing lanes), never a silent pick
|
|
275
|
+
// PLAIN tidy (default trailing lanes), never a silent pick
|
|
276
276
|
let boardFlow: Flow
|
|
277
277
|
if (layout && layout.rows && layout.columns) { warn('layout has rows AND columns - ignoring it (plain tidy)'); boardFlow = { rows: [] } }
|
|
278
278
|
else if (layout && (layout.rows || layout.columns)) boardFlow = { rows: layout.rows, columns: layout.columns }
|
|
@@ -307,7 +307,7 @@ export function tidy(nodes: TidyNode[], layout?: BoardLayout, warn: Warn = () =>
|
|
|
307
307
|
return out
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
/** Guarded parse of agent-authored board.layout
|
|
310
|
+
/** Guarded parse of agent-authored board.layout. Malformed pieces warn
|
|
311
311
|
* and degrade - they never blank the board, and never vanish silently. */
|
|
312
312
|
export function parseLayout(raw: unknown, warn: Warn): BoardLayout | null {
|
|
313
313
|
if (raw === undefined || raw === null) return null
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The stage - play mode's single mount
|
|
2
|
+
* The stage - play mode's single mount. Unlike the frame host (one iframe
|
|
3
3
|
* per frame), the stage keeps ONE tree mounted - providers + layout chain - and swaps
|
|
4
4
|
* only the innermost frame on data-goto, so app shells in _layout persist across
|
|
5
5
|
* navigation like a real app. Swaps ride document.startViewTransition when available;
|
|
@@ -174,7 +174,7 @@ function Stage() {
|
|
|
174
174
|
if (err) return createElement(ErrorCard, { title: 'frame failed', message: err.message, detail: err.id })
|
|
175
175
|
if (!mounted) return null
|
|
176
176
|
// identical wrapper references across swaps -> React preserves layout state; a chain
|
|
177
|
-
// that differs at depth k legitimately remounts everything below
|
|
177
|
+
// that differs at depth k legitimately remounts everything below
|
|
178
178
|
let tree: ReactNode = createElement(mounted.Frame)
|
|
179
179
|
for (const W of [...mounted.wrappers].reverse()) tree = createElement(W, null, tree)
|
|
180
180
|
return createElement(Boundary, { resetKey: mounted.id }, tree)
|
package/src/shared/events.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Comment events - the PURE half of the store
|
|
2
|
+
* Comment events - the PURE half of the store, shared by the node side
|
|
3
3
|
* (JSONL persistence in server/comments.ts) and the browser shell (thread state from
|
|
4
4
|
* fetched events). No node imports here, ever.
|
|
5
5
|
*/
|
|
@@ -19,7 +19,7 @@ export interface CommentEvent {
|
|
|
19
19
|
board?: string
|
|
20
20
|
nodeKey?: string // a frame can sit on a board twice - comments are node-scoped
|
|
21
21
|
frame?: string
|
|
22
|
-
anchor?: unknown //
|
|
22
|
+
anchor?: unknown // anchor bundle; absent = frame-level comment; on 'reanchor' = the new anchor
|
|
23
23
|
author?: { email: string; name?: string; avatar?: string }
|
|
24
24
|
body?: string // plain text in v1
|
|
25
25
|
emoji?: string // react events
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Automatic attribution for the powered-by links: every canvas tags its marver.design
|
|
2
|
+
* link so site analytics can see WHICH canvas sent a visitor - zero user setup.
|
|
3
|
+
* Conventional UTM semantics (stable groupings first, the variable last):
|
|
4
|
+
* utm_source = the surface class ('published-canvas' | 'dev-canvas')
|
|
5
|
+
* utm_medium = the link unit ('powered-by')
|
|
6
|
+
* utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
|
|
7
|
+
* utm_content = the placement ('gate' badge | 'shell' wordmark)
|
|
8
|
+
*/
|
|
9
|
+
export function poweredByUrl(
|
|
10
|
+
canvasName: string | undefined,
|
|
11
|
+
source: 'published-canvas' | 'dev-canvas',
|
|
12
|
+
content: 'gate' | 'shell',
|
|
13
|
+
): string {
|
|
14
|
+
const slug = (canvasName ?? '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
15
|
+
const q = new URLSearchParams({
|
|
16
|
+
utm_source: source,
|
|
17
|
+
utm_medium: 'powered-by',
|
|
18
|
+
...(slug ? { utm_campaign: slug } : {}),
|
|
19
|
+
utm_content: content,
|
|
20
|
+
})
|
|
21
|
+
return `https://marver.design/?${q.toString()}`
|
|
22
|
+
}
|