@marver-design/marver 0.10.1 → 0.11.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 +176 -0
- package/dist/auth-B5yuwnOq.mjs +494 -0
- package/dist/{build-BkckEcZd.mjs → build-Cr_ZLMdq.mjs} +3 -2
- package/dist/cli.mjs +6 -6
- package/dist/{collab-s3k5byM1.mjs → collab-C-n-uQdm.mjs} +67 -11
- package/dist/{comments-J06jqCVV.mjs → comments-CRqaO2MH.mjs} +26 -11
- package/dist/{comments-BZBKhKRO.mjs → comments-DZyobpxG.mjs} +8 -2
- package/dist/{manifest-DIsp3ldB.mjs → config-t9coJ-Pq.mjs} +3 -233
- package/dist/{daemon-B_8BmmSJ.mjs → daemon-DSmaS453.mjs} +24 -6
- package/dist/{dev-Bm-R_-gs.mjs → dev-CLBjRs0J.mjs} +126 -12
- package/dist/{init-D2EQEbV6.mjs → init-BSclDg4I.mjs} +9 -29
- package/dist/{ledger-BgA7nQoH.mjs → ledger-Bu0BjqIe.mjs} +1 -12
- package/dist/managed-HwHVNI3h.mjs +94 -0
- package/dist/manifest-C2tzkNaC.mjs +233 -0
- package/dist/marver-id-B8-3WiHk.mjs +411 -0
- package/dist/marver-id-gate-BDzW6ahN.mjs +728 -0
- package/dist/{plugin-DhkR3NdW.mjs → plugin-vE7sxEeT.mjs} +18 -80
- package/dist/{profile-BkiWglVE.mjs → profile-DcsJyppw.mjs} +9 -6
- package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
- package/dist/{serve-CZqPnj19.mjs → serve-CZ1KEQxd.mjs} +194 -42
- package/dist/{shot-BRgPaFCs.mjs → shot-Cyv3GN79.mjs} +140 -13
- package/dist/sync-DELGomPk.mjs +397 -0
- package/dist/update-DuWDj5nR.mjs +77 -0
- package/dist/utm-CxC3QN5X.mjs +20 -0
- package/package.json +1 -1
- package/src/client/content/img-lod.ts +6 -0
- package/src/client/frame-host/bridge.js +3 -1
- package/src/client/frame-host/main.tsx +4 -1
- package/src/client/shell/App.tsx +5 -0
- package/src/client/shell/canvas/FrameNode.tsx +45 -6
- package/src/client/shell/canvas/ready-watch.ts +28 -0
- package/src/client/shell/store.ts +27 -6
- package/src/client/shell/styles.css +8 -0
- package/src/shared/utm.ts +2 -2
- package/templates/instructions/configure.md +4 -0
- package/templates/instructions/jam.md +4 -0
- package/templates/instructions/publish.md +84 -15
- package/dist/auth-KQ9Aj-nB.mjs +0 -245
- package/dist/events-BMtBvvgU.mjs +0 -101
- package/dist/sync-BJKKmy1n.mjs +0 -150
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
//#region src/shared/utm.ts
|
|
2
|
+
/** Automatic attribution for the powered-by links: every canvas tags its marver.design
|
|
3
|
+
* link so site analytics can see WHICH canvas sent a visitor - zero user setup.
|
|
4
|
+
* Conventional UTM semantics (stable groupings first, the variable last):
|
|
5
|
+
* utm_source = the surface class ('published-canvas' | 'dev-canvas')
|
|
6
|
+
* utm_medium = the link unit ('powered-by')
|
|
7
|
+
* utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
|
|
8
|
+
* utm_content = the placement ('gate' badge | 'shell' wordmark | 'sign-in' finish page)
|
|
9
|
+
*/
|
|
10
|
+
function poweredByUrl(canvasName, source, content) {
|
|
11
|
+
const slug = (canvasName ?? "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
12
|
+
return `https://marver.design/?${new URLSearchParams({
|
|
13
|
+
utm_source: source,
|
|
14
|
+
utm_medium: "powered-by",
|
|
15
|
+
...slug ? { utm_campaign: slug } : {},
|
|
16
|
+
utm_content: content
|
|
17
|
+
}).toString()}`;
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { poweredByUrl as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -35,6 +35,12 @@ const schedule = (job: () => Promise<void>): void => {
|
|
|
35
35
|
pump()
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Decode-idle signal for the headless shot: 0 means no decode is running or queued. The shot
|
|
39
|
+
// (src/server/shot.ts) polls this to know each image's first decode has settled - which pins
|
|
40
|
+
// its aspect-ratio and so stabilizes the frame's measured height - before it measures and
|
|
41
|
+
// captures. Harmless and 0 when there are no LOD images (nothing ever decodes).
|
|
42
|
+
if (typeof window !== 'undefined') (window as { __mvLodBusy?: () => number }).__mvLodBusy = () => active + q.length
|
|
43
|
+
|
|
38
44
|
interface Item { canvas: HTMLCanvasElement; src: string; bucket: number; token: number }
|
|
39
45
|
const items = new Set<Item>()
|
|
40
46
|
let scale = 0.2 // overview default until the shell primes the settled scale on frame-ready
|
|
@@ -73,7 +73,9 @@ window.addEventListener('message', (e) => {
|
|
|
73
73
|
})
|
|
74
74
|
|
|
75
75
|
if (isHtmlFrame) {
|
|
76
|
-
|
|
76
|
+
// stamp the URL revision (see main.tsx) so the shell can drop a ready from a superseded document
|
|
77
|
+
const gen = new URLSearchParams(location.search).get('r') ?? ''
|
|
78
|
+
const ready = () => post({ type: 'sh:ready', id, gen })
|
|
77
79
|
document.readyState === 'loading' ? addEventListener('DOMContentLoaded', ready) : ready()
|
|
78
80
|
}
|
|
79
81
|
|
|
@@ -77,7 +77,10 @@ async function boot() {
|
|
|
77
77
|
for (const W of wrappers.reverse()) if (W != null) tree = createElement(W, null, tree)
|
|
78
78
|
|
|
79
79
|
createRoot(document.getElementById('root')!).render(createElement(Boundary, null, tree))
|
|
80
|
-
|
|
80
|
+
// stamp the URL revision so the shell can drop a ready queued by a superseded document (one it
|
|
81
|
+
// auto-renavigated past) - a WindowProxy survives navigation, so a stale ready could otherwise
|
|
82
|
+
// mark a reloading frame ready. Mirrors the sh:measure generation guard.
|
|
83
|
+
post({ type: 'sh:ready', id, gen: params.get('r') ?? '', meta: frameMod.meta && typeof frameMod.meta === 'object' ? frameMod.meta : undefined })
|
|
81
84
|
} catch (err) {
|
|
82
85
|
fail((err as Error).message)
|
|
83
86
|
}
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -710,6 +710,11 @@ export function App() {
|
|
|
710
710
|
const s = useStore.getState()
|
|
711
711
|
|
|
712
712
|
if (data.type === 'sh:ready') {
|
|
713
|
+
// a WindowProxy survives navigation, so a ready queued by a SUPERSEDED document (one the
|
|
714
|
+
// ready-watchdog auto-renavigated past) can arrive here; drop it by generation like
|
|
715
|
+
// sh:measure does, so a stale ready never marks a reloading frame ready.
|
|
716
|
+
const gen = el.src.match(/[?&]r=(\d+)/)?.[1] ?? ''
|
|
717
|
+
if (String(data.gen ?? '') !== gen) return
|
|
713
718
|
s.setStatus(nodeKey, 'ready')
|
|
714
719
|
} else if (data.type === 'sh:error') {
|
|
715
720
|
s.setStatus(nodeKey, 'error', String(data.message ?? 'unknown error'))
|
|
@@ -7,6 +7,7 @@ import { threadHostKey } from '../keys.ts'
|
|
|
7
7
|
import { registerFrame, unregisterFrame } from './frame-registry.ts'
|
|
8
8
|
import { primeCameraFor } from './camera-broadcast.ts'
|
|
9
9
|
import { registerLeanFrame, dropSnapshot, scheduleCapture, invalidateLean } from './snapshots.ts'
|
|
10
|
+
import { canAutoReload, shouldArmReadyWatch } from './ready-watch.ts'
|
|
10
11
|
|
|
11
12
|
export const HEADER = 28
|
|
12
13
|
const SNAP = 12
|
|
@@ -47,7 +48,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
47
48
|
// B0.1: no reactive scale subscription - it re-rendered every FrameNode on every
|
|
48
49
|
// pan/zoom tick. gestureScale below measures the world rect (the canonical source,
|
|
49
50
|
// Law G-5); the stored scale is only a never-hit fallback, read lazily at drag time.
|
|
50
|
-
const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, setGesture, toast } = useStore.getState()
|
|
51
|
+
const { select, setInteract, moveNode, moveSelectedBy, resizeNode, setStatus, reloadFrame, setGesture, toast } = useStore.getState()
|
|
51
52
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
|
52
53
|
const themeRef = useRef(node.theme)
|
|
53
54
|
// src is frozen at mount: theme changes ride sh:set-theme (never navigation), so
|
|
@@ -204,12 +205,40 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
204
205
|
if (frame && iframeRef.current) iframeRef.current.src = frameUrl(frame, node.theme)
|
|
205
206
|
}, [node.nav])
|
|
206
207
|
|
|
207
|
-
//
|
|
208
|
+
// Reload the frame, assigning the fresh URL SYNCHRONOUSLY so the live iframe's src is current the
|
|
209
|
+
// instant a stale sh:ready (queued by the document we just superseded) could be processed - the
|
|
210
|
+
// App generation guard then drops it against the new src. reloadFrame bumps nav+manifestRev; we
|
|
211
|
+
// pre-apply the src here and advance navRef so the passive nav effect does not navigate a 2nd time.
|
|
212
|
+
const reload = (automatic: boolean) => {
|
|
213
|
+
const before = useStore.getState().nodes.find((x) => x.key === node.key)
|
|
214
|
+
if (!before || (automatic && !canAutoReload(before))) return
|
|
215
|
+
reloadFrame(node.key, automatic)
|
|
216
|
+
const after = useStore.getState().nodes.find((x) => x.key === node.key)
|
|
217
|
+
const f = useStore.getState().frameFor(node)
|
|
218
|
+
if (after && f && iframeRef.current) {
|
|
219
|
+
iframeRef.current.src = frameUrl(f, node.theme)
|
|
220
|
+
navRef.current = after.nav ?? 0
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// latest-callback ref so the watchdog effect can call the current reload() without taking it as a
|
|
224
|
+
// dep (an unstable function there would reset the timer every render and starve the retry)
|
|
225
|
+
const reloadRef = useRef(reload)
|
|
226
|
+
reloadRef.current = reload
|
|
227
|
+
|
|
228
|
+
// ready watchdog: a slow dev server (Vite re-optimizing deps, or the box saturated by parallel
|
|
229
|
+
// Live Jam agents) can leave boot()'s module fetches unresolved past the deadline - that is a slow
|
|
230
|
+
// frame, NOT a failed one (a real failure posts sh:error immediately). So the first silent deadline
|
|
231
|
+
// auto-renavigates ONCE on a fresh rev; a second silence stays 'loading' (never a red error card).
|
|
232
|
+
// node.nav is a dep so a fresh navigation restarts the full budget; readyRetried flips true on the
|
|
233
|
+
// retry and bounds it to exactly one.
|
|
208
234
|
useEffect(() => {
|
|
209
|
-
if (node
|
|
210
|
-
const t = setTimeout(() =>
|
|
235
|
+
if (!shouldArmReadyWatch(node, !!frame)) return
|
|
236
|
+
const t = setTimeout(() => reloadRef.current(true), 10_000)
|
|
211
237
|
return () => clearTimeout(t)
|
|
212
|
-
|
|
238
|
+
// depend on the frame's stable SIGNATURE, not the manifest object - that object is replaced on
|
|
239
|
+
// every manifest reconcile, so depending on it would reset the budget on unrelated frames during
|
|
240
|
+
// heavy Live Jam churn and starve the retry. kind/file still re-arm on a real file swap.
|
|
241
|
+
}, [node.status, node.readyRetried, node.nav, node.key, node.missing, frame?.kind, frame?.file])
|
|
213
242
|
|
|
214
243
|
const drag = (e: React.PointerEvent, mode: 'move' | 'e' | 's' | 'se') => {
|
|
215
244
|
e.stopPropagation()
|
|
@@ -345,7 +374,7 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
345
374
|
<span className="msg">{node.error}</span>
|
|
346
375
|
<span className="dim">{frame.file}</span>
|
|
347
376
|
<span className="row">
|
|
348
|
-
<button className="sh-no-pan" onClick={() =>
|
|
377
|
+
<button className="sh-no-pan" onClick={() => reload(false)}>
|
|
349
378
|
<ReloadIcon size={12} /> reload
|
|
350
379
|
</button>
|
|
351
380
|
<button className="sh-no-pan" onClick={() => { navigator.clipboard.writeText(`${frame.file}: ${node.error}`); toast('error copied for agent') }}>
|
|
@@ -354,6 +383,16 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
|
|
|
354
383
|
</span>
|
|
355
384
|
</div>
|
|
356
385
|
) : null}
|
|
386
|
+
{/* a frame still silent after its one auto-retry: a slow dev server, never a failure. A quiet
|
|
387
|
+
non-covering pill (not the red card) keeps it honest and offers a manual reload. */}
|
|
388
|
+
{node.status === 'loading' && node.readyRetried ? (
|
|
389
|
+
<div className="sh-loading sh-no-pan">
|
|
390
|
+
<span>still loading</span>
|
|
391
|
+
<button className="sh-no-pan" onClick={() => reload(false)}>
|
|
392
|
+
<ReloadIcon size={11} /> reload
|
|
393
|
+
</button>
|
|
394
|
+
</div>
|
|
395
|
+
) : null}
|
|
357
396
|
<iframe
|
|
358
397
|
ref={bindIframe}
|
|
359
398
|
className="sh-live"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Readiness-watchdog policy, kept pure so it can be unit-tested apart from the browser-coupled
|
|
2
|
+
// store (which imports Vite virtual modules and can't load under vitest).
|
|
3
|
+
//
|
|
4
|
+
// The frame host posts sh:ready SYNCHRONOUSLY after it renders, so a frame that misses the
|
|
5
|
+
// deadline has not failed - its dev-server module fetches just haven't settled (Vite re-optimizing
|
|
6
|
+
// dependencies, or the box saturated by parallel Live Jam agents). A genuine failure takes the
|
|
7
|
+
// separate immediate sh:error path. So a silent deadline earns exactly ONE automatic re-navigation
|
|
8
|
+
// on a fresh revision; a second silence stays 'loading' forever (never a red failure card).
|
|
9
|
+
|
|
10
|
+
export interface ReadyNode {
|
|
11
|
+
status: 'loading' | 'ready' | 'error'
|
|
12
|
+
readyRetried?: boolean
|
|
13
|
+
missing?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Arm the 10s watchdog only for a present, still-loading, not-yet-retried frame. A missing or
|
|
17
|
+
* absent frame must NOT spend the one-shot allowance - it has no live iframe booting, and would
|
|
18
|
+
* otherwise burn its retry before its file even arrives. */
|
|
19
|
+
export function shouldArmReadyWatch(node: ReadyNode, hasFrame: boolean): boolean {
|
|
20
|
+
return node.status === 'loading' && !node.readyRetried && hasFrame && !node.missing
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether an automatic (watchdog-driven) reload still applies: one shot, and only while the frame
|
|
24
|
+
* is still silently loading. A frame that already went ready/error, or already spent its retry,
|
|
25
|
+
* is left alone. */
|
|
26
|
+
export function canAutoReload(node: ReadyNode): boolean {
|
|
27
|
+
return node.status === 'loading' && !node.readyRetried
|
|
28
|
+
}
|
|
@@ -38,6 +38,10 @@ export interface Node {
|
|
|
38
38
|
* frame arrives - an authored size on a temporarily-missing frame is never touched. */
|
|
39
39
|
sizeFallback?: boolean
|
|
40
40
|
status: 'loading' | 'ready' | 'error'; error?: string; missing?: boolean
|
|
41
|
+
/** the frame's one automatic ready-retry has been spent (a slow dev server must not be reported
|
|
42
|
+
* as a failed frame; see canvas/ready-watch.ts). Transient - cleared on any real ready/error and
|
|
43
|
+
* on every fresh navigation. */
|
|
44
|
+
readyRetried?: boolean
|
|
41
45
|
}
|
|
42
46
|
/** A Live Jam notification: a persistent bottom-right glass pill for a Marver
|
|
43
47
|
* reply. Frame-first: the FRAME is the news (icon + title, blue), Marver + preview below. */
|
|
@@ -48,6 +52,7 @@ export const CONFIG: { viewports: Record<string, { width: number; height: number
|
|
|
48
52
|
|
|
49
53
|
export { cap, humanize } from './labels.ts'
|
|
50
54
|
import { cap, humanize } from './labels.ts'
|
|
55
|
+
import { canAutoReload } from './canvas/ready-watch.ts'
|
|
51
56
|
|
|
52
57
|
/** Board names for switchers: the agent's curated boards FIRST (ranked by each board's `order`, then
|
|
53
58
|
* name), and the auto `all-scenes` everything-board LAST - it is the expensive one, never the landing.
|
|
@@ -94,7 +99,7 @@ export const bumpManifestRev = () => { manifestRev++ }
|
|
|
94
99
|
|
|
95
100
|
export function frameUrl(frame: FrameEntry, theme: string): string {
|
|
96
101
|
return frame.kind === 'html'
|
|
97
|
-
? `/${frame.file}?theme=${theme}`
|
|
102
|
+
? `/${frame.file}?theme=${theme}&r=${manifestRev}` // r= carries the generation for the sh:ready guard (static serving ignores it)
|
|
98
103
|
: `${ROUTE}/frame/?id=${encodeURIComponent(frame.id)}&theme=${theme}&r=${manifestRev}`
|
|
99
104
|
}
|
|
100
105
|
|
|
@@ -175,6 +180,7 @@ interface State {
|
|
|
175
180
|
resizeNode(key: string, w: number, h: number): void
|
|
176
181
|
measureNode(key: string, frameId: string, ownWidth: number, measuredWidth: number, height: number): void
|
|
177
182
|
setStatus(key: string, status: Node['status'], error?: string): void
|
|
183
|
+
reloadFrame(key: string, automatic?: boolean): void
|
|
178
184
|
removeNode(key: string): void
|
|
179
185
|
select(key: string | null, additive?: boolean): void
|
|
180
186
|
selectMany(keys: string[]): void
|
|
@@ -620,7 +626,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
620
626
|
n.w = vp?.width ?? d.w
|
|
621
627
|
n.h = vp?.height ?? d.h
|
|
622
628
|
}
|
|
623
|
-
if (arrived) delete n.sizeFallback
|
|
629
|
+
if (arrived) { delete n.sizeFallback; n.readyRetried = false } // a fresh mount earns a fresh allowance
|
|
624
630
|
n.missing = missing
|
|
625
631
|
changed = true
|
|
626
632
|
}
|
|
@@ -637,7 +643,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
637
643
|
if (!f?.contentWidth && n.sizeMode) { delete n.sizeMode; retinted = true }
|
|
638
644
|
// an errored frame whose file IS in the fresh manifest gets one automatic retry
|
|
639
645
|
// on a rev-stamped URL - the "unknown frame id" dead end must self-heal (#20)
|
|
640
|
-
if (!missing && n.status === 'error') { n.status = 'loading'; n.nav = (n.nav ?? 0) + 1; retinted = true }
|
|
646
|
+
if (!missing && n.status === 'error') { n.status = 'loading'; n.nav = (n.nav ?? 0) + 1; n.readyRetried = false; retinted = true }
|
|
641
647
|
}
|
|
642
648
|
// auto boards prune deleted frames outright - "auto-managed" must manage both
|
|
643
649
|
// directions (friction log #15). Curated boards keep the explicit card.
|
|
@@ -793,7 +799,22 @@ export const useStore = create<State>((set, get) => {
|
|
|
793
799
|
else scheduleSave()
|
|
794
800
|
},
|
|
795
801
|
setStatus(key, status, error) {
|
|
796
|
-
|
|
802
|
+
// any real ready/error resets the one-shot retry allowance, so a later manual reload or a
|
|
803
|
+
// fresh manifest earns its own auto-retry again
|
|
804
|
+
set((s) => ({ nodes: s.nodes.map((n) => (n.key === key ? { ...n, status, error, readyRetried: false } : n)) }))
|
|
805
|
+
},
|
|
806
|
+
// Reload a frame on a fresh rev-stamped URL (dodges a poisoned-cache document, #20). The
|
|
807
|
+
// watchdog calls this with automatic=true after a silent deadline: it fires at most once per
|
|
808
|
+
// load (canAutoReload gates it) and marks readyRetried so a second silence never retries and
|
|
809
|
+
// never becomes an error. A manual reload passes automatic=false, granting a fresh allowance.
|
|
810
|
+
reloadFrame(key, automatic = false) {
|
|
811
|
+
const n = get().nodes.find((x) => x.key === key)
|
|
812
|
+
if (!n) return
|
|
813
|
+
if (automatic && !canAutoReload(n)) return
|
|
814
|
+
bumpManifestRev()
|
|
815
|
+
set((s) => ({ nodes: s.nodes.map((x) => (x.key === key
|
|
816
|
+
? { ...x, status: 'loading' as const, error: undefined, nav: (x.nav ?? 0) + 1, readyRetried: automatic }
|
|
817
|
+
: x)) }))
|
|
797
818
|
},
|
|
798
819
|
// plain select replaces; additive toggles membership. Interact survives only while its
|
|
799
820
|
// frame stays selected.
|
|
@@ -884,7 +905,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
884
905
|
const nodes = s.nodes.map((n) => {
|
|
885
906
|
if (!ids.has(n.frame)) return n
|
|
886
907
|
if (frameIsLeased(n.frame, s)) { pending[n.frame] = revision; return n }
|
|
887
|
-
return { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1 }
|
|
908
|
+
return { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1, readyRetried: false }
|
|
888
909
|
})
|
|
889
910
|
set({ nodes, pendingFrameRevisions: pending, ...(s.play ? { playUpdateRevision: revision } : {}) })
|
|
890
911
|
},
|
|
@@ -897,7 +918,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
897
918
|
const pending = { ...s.pendingFrameRevisions }
|
|
898
919
|
for (const id of ids) delete pending[id]
|
|
899
920
|
const nodes = s.nodes.map((n) => apply.has(n.frame)
|
|
900
|
-
? { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1 } : n)
|
|
921
|
+
? { ...n, status: 'loading' as const, error: undefined, nav: (n.nav ?? 0) + 1, readyRetried: false } : n)
|
|
901
922
|
set({ nodes, pendingFrameRevisions: pending })
|
|
902
923
|
},
|
|
903
924
|
setExternalLease(nodeKey, reason, on) {
|
|
@@ -426,6 +426,14 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
426
426
|
border: 1px solid currentColor; background: transparent; color: inherit; border-radius: 999px;
|
|
427
427
|
padding: 4px 10px; cursor: pointer }
|
|
428
428
|
|
|
429
|
+
/* still-loading pill: a slow (not failed) frame after its one auto-retry. Non-covering, sits above
|
|
430
|
+
the drag overlay (z2) so its reload button is clickable, and stays out of the frame's content. */
|
|
431
|
+
.sh-loading { position: absolute; left: 8px; bottom: 8px; z-index: 6; display: inline-flex; align-items: center;
|
|
432
|
+
gap: 8px; padding: 4px 4px 4px 10px; border-radius: 999px; font: 500 11px -apple-system, system-ui, sans-serif;
|
|
433
|
+
color: var(--card-warn-ink); background: var(--card-warn-bg); box-shadow: 0 1px 4px rgba(0, 0, 0, .16) }
|
|
434
|
+
.sh-loading button { display: inline-flex; align-items: center; gap: 4px; font: 600 11px -apple-system, system-ui, sans-serif;
|
|
435
|
+
border: 1px solid currentColor; background: transparent; color: inherit; border-radius: 999px; padding: 3px 9px; cursor: pointer }
|
|
436
|
+
|
|
429
437
|
/* shared icon button (glass surfaces) */
|
|
430
438
|
.sh-ibtn { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 999px;
|
|
431
439
|
border: 0; background: none; color: var(--glass-ink-3); cursor: pointer }
|
package/src/shared/utm.ts
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
* utm_source = the surface class ('published-canvas' | 'dev-canvas')
|
|
5
5
|
* utm_medium = the link unit ('powered-by')
|
|
6
6
|
* utm_campaign = THIS canvas's name, slugged ("Marver tour" -> "marver-tour")
|
|
7
|
-
* utm_content = the placement ('gate' badge | 'shell' wordmark)
|
|
7
|
+
* utm_content = the placement ('gate' badge | 'shell' wordmark | 'sign-in' finish page)
|
|
8
8
|
*/
|
|
9
9
|
export function poweredByUrl(
|
|
10
10
|
canvasName: string | undefined,
|
|
11
11
|
source: 'published-canvas' | 'dev-canvas',
|
|
12
|
-
content: 'gate' | 'shell',
|
|
12
|
+
content: 'gate' | 'shell' | 'sign-in',
|
|
13
13
|
): string {
|
|
14
14
|
const slug = (canvasName ?? '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
15
15
|
const q = new URLSearchParams({
|
|
@@ -72,6 +72,10 @@ If collaboration is deployed, each engineer runs `marver comments connect
|
|
|
72
72
|
cloud sync on top of git. Git carries the committed comments; `connect` adds
|
|
73
73
|
the real-time stream from published viewers.
|
|
74
74
|
|
|
75
|
+
On a canvas gated by Marver Sign In there are no per-engineer CLI accounts:
|
|
76
|
+
identity accounts have no password to connect with. The repo connects once with
|
|
77
|
+
the canvas's `MARVER_CLI_TOKEN`, acting as the owner - see instructions/publish.md.
|
|
78
|
+
|
|
75
79
|
## Dev identity (who comments render as)
|
|
76
80
|
|
|
77
81
|
`marver dev` resolves the local author from `design/.local/` - never invent or
|
|
@@ -92,6 +92,10 @@ The `result.json` is the universal signal - it works even when you cannot see im
|
|
|
92
92
|
throw shows the frame's own exception, "the frame rendered an error - ..."; an unreachable
|
|
93
93
|
dev server or missing Chrome says so). So a crashed or blank frame is caught by the JSON
|
|
94
94
|
alone. `"ok":true` means it painted - and THEN the PNG tells you whether it painted *well*.
|
|
95
|
+
The result also reports the exact `width`/`height` captured: a content frame (a `Doc`) is
|
|
96
|
+
shot at its natural width and its FULL height, so a wide layout or a long spec renders in
|
|
97
|
+
full, not cropped. A frame tall enough to hit the capture cap comes back with
|
|
98
|
+
`"truncated":true` and a `note` - split it or shorten it and re-shoot.
|
|
95
99
|
|
|
96
100
|
(If you DO have a shell - `npx marver shot <scene/frame> [--theme dark]` is the same thing
|
|
97
101
|
in one line, printing the PNG path.)
|
|
@@ -18,11 +18,32 @@ vars if the host has no CLI.
|
|
|
18
18
|
`--boards a,b` overrides ad hoc (grants comment), `--all-boards` ships
|
|
19
19
|
everything loudly.
|
|
20
20
|
|
|
21
|
-
2. **
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
2. **Who gets in** - pick ONE of two gates at serve time. They are alternatives,
|
|
22
|
+
not layers; setting both weakens the invite list to "an account OR whoever
|
|
23
|
+
has the password". No env var at all = an open canvas.
|
|
24
|
+
|
|
25
|
+
**Marver Sign In - `MARVER_ID_ISSUER`. Use this by default.** People sign in
|
|
26
|
+
as themselves (Google, or an emailed code) instead of sharing a secret. One
|
|
27
|
+
sign-in opens every canvas gated this way, there is no password to leak or
|
|
28
|
+
rotate, and removing one person removes exactly them. Needs
|
|
29
|
+
`MARVER_PUBLIC_ORIGIN` set to the canvas's exact public origin, and
|
|
30
|
+
`MARVER_DATA_DIR`.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
MARVER_ID_ISSUER=https://id.marver.design
|
|
34
|
+
MARVER_PUBLIC_ORIGIN=https://<the deployed url>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**The canvas password - `MARVER_PASSWORD`.** The sovereign option, and the
|
|
38
|
+
right one when the canvas must depend on nothing outside itself: no outbound
|
|
39
|
+
request of any kind. One shared password for GUESTS; members never need it,
|
|
40
|
+
since their own account signs them in and invite links skip it entirely.
|
|
41
|
+
Choose it deliberately - offline or air-gapped hosting, or an explicit
|
|
42
|
+
preference for no third party in the sign-in path - not by default.
|
|
43
|
+
|
|
44
|
+
**Who may enter is decided by the canvas either way**, from the invite list
|
|
45
|
+
the owner keeps. Marver Sign In proves who somebody is; it has no say in
|
|
46
|
+
where they may go, and is never told the answer.
|
|
26
47
|
|
|
27
48
|
3. **Collaboration on or off** - `MARVER_DATA_DIR` at serve time. Set it to a
|
|
28
49
|
path on a PERSISTENT disk and the serve grows accounts + live comments.
|
|
@@ -35,9 +56,12 @@ vars if the host has no CLI.
|
|
|
35
56
|
| Var | Meaning |
|
|
36
57
|
|---|---|
|
|
37
58
|
| `PORT` | listen port (hosts inject this) |
|
|
38
|
-
| `
|
|
59
|
+
| `MARVER_ID_ISSUER` | `https://id.marver.design` - people sign in as themselves (**preferred**) |
|
|
60
|
+
| `MARVER_PUBLIC_ORIGIN` | REQUIRED with `MARVER_ID_ISSUER`: this canvas's exact public origin |
|
|
61
|
+
| `MARVER_PASSWORD` | the sovereign alternative: one shared password; unset = open |
|
|
39
62
|
| `MARVER_DATA_DIR` | persistent dir for `comments/` + `auth.json`; unset = no collaboration |
|
|
40
|
-
| `MARVER_OWNER_EMAIL` |
|
|
63
|
+
| `MARVER_OWNER_EMAIL` | who owns an empty canvas. With Marver Sign In they just sign in; with a password, a single-use claim link prints in the deploy logs on first boot |
|
|
64
|
+
| `MARVER_CLI_TOKEN` | generated 32+ char secret (`openssl rand -hex 24`) that lets this repo run `comments invite`/`revoke`/`sync` as the owner. Required with `MARVER_ID_ISSUER` - identity accounts have no password for the CLI to use |
|
|
41
65
|
| `MARVER_TRUSTED_PROXY` | set to `1` behind a reverse proxy (Railway, Fly) so rate limits see real IPs |
|
|
42
66
|
|
|
43
67
|
## The host contract (works on any volume-capable host)
|
|
@@ -71,10 +95,17 @@ Then, once per service:
|
|
|
71
95
|
```bash
|
|
72
96
|
railway init # or `railway link` an existing service
|
|
73
97
|
railway volume add --mount-path /data
|
|
74
|
-
|
|
75
|
-
|
|
98
|
+
# The default gate: people sign in as themselves.
|
|
99
|
+
railway variables --set MARVER_ID_ISSUER=https://id.marver.design \
|
|
100
|
+
--set MARVER_PUBLIC_ORIGIN=https://<the deployed url> --set MARVER_DATA_DIR=/data \
|
|
101
|
+
--set MARVER_OWNER_EMAIL=<owner@email> --set MARVER_TRUSTED_PROXY=1 \
|
|
102
|
+
--set MARVER_CLI_TOKEN="$(openssl rand -hex 24)"
|
|
103
|
+
# ...or the sovereign alternative, if this canvas must depend on nothing external:
|
|
104
|
+
# --set MARVER_PASSWORD=<pw> (instead of MARVER_ID_ISSUER/MARVER_PUBLIC_ORIGIN)
|
|
76
105
|
railway up # uploads the repo; Railway runs build then start
|
|
77
|
-
railway logs #
|
|
106
|
+
railway logs # with a PASSWORD gate, the owner claim link prints
|
|
107
|
+
# here once. With Marver Sign In there is no link -
|
|
108
|
+
# the owner simply signs in. See below.
|
|
78
109
|
```
|
|
79
110
|
|
|
80
111
|
Republishing is just `railway up` again: the server unions the seeded logs on
|
|
@@ -83,7 +114,12 @@ the event log is single-writer by design.
|
|
|
83
114
|
|
|
84
115
|
## What the deployed gate offers (so you know what you're wiring)
|
|
85
116
|
|
|
86
|
-
|
|
117
|
+
**With `MARVER_ID_ISSUER`** there is one door: people sign in at
|
|
118
|
+
id.marver.design with Google or an emailed code, come back, and are let in if
|
|
119
|
+
the owner's list has their address. Nobody types a canvas password because there
|
|
120
|
+
isn't one. An address that is not on the list is refused by name, on screen.
|
|
121
|
+
|
|
122
|
+
**With `MARVER_PASSWORD`** the gate has three doors, one credential each:
|
|
87
123
|
- **Guest** - the canvas password → read-only across published boards.
|
|
88
124
|
- **Member** - "Sign in instead" → their own email + password → read + comment.
|
|
89
125
|
A member session IS gate passage; they never touch the shared password again.
|
|
@@ -92,9 +128,41 @@ On a collaboration canvas the gate has three doors, one credential each:
|
|
|
92
128
|
|
|
93
129
|
## Wiring people up (after first deploy)
|
|
94
130
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
131
|
+
**With Marver Sign In**, the owner just signs in - `MARVER_OWNER_EMAIL` claims an
|
|
132
|
+
empty canvas for that address, with no token to pass around.
|
|
133
|
+
|
|
134
|
+
**To manage people from this machine, the canvas needs `MARVER_CLI_TOKEN`.**
|
|
135
|
+
`comments invite`, `revoke` and `sync` sign the CLI in with a password, and
|
|
136
|
+
identity mode has none. Generate a secret - do not choose one, nothing
|
|
137
|
+
rate-limits this and nothing slows a guess down - set it on the canvas, and hand
|
|
138
|
+
the same value back:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
# on the canvas (deployment environment): MARVER_CLI_TOKEN=$(openssl rand -hex 24)
|
|
142
|
+
MARVER_CLI_TOKEN='<that same value>' marver comments connect https://canvas.example.com
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Hex, not base64: an `Authorization` header carries letters, digits, `_` and `-`,
|
|
146
|
+
and the canvas refuses to start on a value it could never accept. `--token` works
|
|
147
|
+
too, but a value on the command line is visible to anything that can list
|
|
148
|
+
processes, so prefer the variable.
|
|
149
|
+
|
|
150
|
+
It acts as whoever owns the canvas, so let the owner sign in once first - until
|
|
151
|
+
somebody owns it there is nobody for the token to be. `connect` trades it for an
|
|
152
|
+
ordinary session and stores THAT, so the secret never lands in your repo.
|
|
153
|
+
Rotating `MARVER_CLI_TOKEN` ends every session it minted.
|
|
154
|
+
|
|
155
|
+
It is a deployment variable and not something a page hands out, because authored
|
|
156
|
+
frames run same-origin in a canvas: frame code can read `mv_c` and ride the
|
|
157
|
+
viewer's session, so anything a browser can mint, a frame can mint silently. A
|
|
158
|
+
browser-approved device flow was built for this job and removed before release for
|
|
159
|
+
that reason. Do not reintroduce one, and do not invent a workaround that posts to
|
|
160
|
+
internal endpoints.
|
|
161
|
+
|
|
162
|
+
**With a canvas password**, `MARVER_OWNER_EMAIL` makes the first boot print an
|
|
163
|
+
owner bootstrap in the logs: a browser link (`<url>/#/i/<token>`) AND the exact
|
|
164
|
+
repo command. The token is single-use. Claim it from the repo so this machine can
|
|
165
|
+
mint invites:
|
|
98
166
|
|
|
99
167
|
```bash
|
|
100
168
|
# 1. claim the owner account - copy the command the deploy logs printed:
|
|
@@ -125,5 +193,6 @@ through sign-in.
|
|
|
125
193
|
- `<MARVER_DATA_DIR>/auth.json` - accounts (scrypt), sessions, invites, on the volume.
|
|
126
194
|
- `design/comments/<board>.jsonl` - the dev-side mirror, git-tracked: feedback
|
|
127
195
|
has history, and the volume has an off-site replica for free.
|
|
128
|
-
-
|
|
196
|
+
- `~/.marver/canvases/<project-hash>.json` - THIS machine's device credential, kept
|
|
197
|
+
OUTSIDE the repo because `marver dev` serves the repo;
|
|
129
198
|
never commit it.
|