@marver-design/marver 0.13.0 → 0.15.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 +162 -0
- package/README.md +44 -20
- package/dist/{build-BxGrHFT2.mjs → build-DfuTQZlY.mjs} +46 -6
- package/dist/cli.mjs +21 -7
- package/dist/{daemon-BChkzDqQ.mjs → daemon-DalgvoA9.mjs} +1 -1
- package/dist/{dev-DLwt3Brb.mjs → dev-BxCmeU_H.mjs} +14 -4
- package/dist/{init-BpitOqRQ.mjs → init-QKNi9gvF.mjs} +66 -2
- package/dist/{manifest-CS6krOTe.mjs → manifest-BzxSMoDB.mjs} +24 -6
- package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
- package/dist/{plugin-DNc4Jpae.mjs → plugin-DJyjmQeh.mjs} +60 -10
- package/dist/poster-CbpzSzJu.mjs +143 -0
- package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
- package/dist/{shot-Cyv3GN79.mjs → shot-BWhoz6cU.mjs} +204 -57
- package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
- package/docs/live-jam.md +177 -0
- package/docs/publish.md +270 -0
- package/docs/sharing.md +333 -0
- package/docs/slides.md +140 -0
- package/package.json +3 -1
- package/src/client/const.ts +13 -0
- package/src/client/content/chart-engine.ts +33 -0
- package/src/client/content/chart.tsx +138 -0
- package/src/client/content/index.tsx +30 -6
- package/src/client/content/slide.tsx +238 -0
- package/src/client/content/video.tsx +223 -0
- package/src/client/frame-host/bridge.js +6 -1
- package/src/client/shell/App.tsx +59 -13
- package/src/client/shell/LockedApp.tsx +7 -2
- package/src/client/shell/Play.tsx +138 -24
- package/src/client/shell/Toolbar.tsx +12 -3
- package/src/client/shell/canvas/FrameNode.tsx +5 -3
- package/src/client/shell/hash.ts +3 -1
- package/src/client/shell/icons.tsx +3 -0
- package/src/client/shell/play-order.ts +22 -0
- package/src/client/shell/store.ts +80 -9
- package/src/client/shell/styles.css +23 -27
- package/src/client/stage/main.tsx +54 -3
- package/src/shared/utm.ts +3 -2
- package/templates/AGENTS-embedded.md +20 -4
- package/templates/AGENTS-studio.md +20 -4
- package/templates/instructions/boards.md +47 -5
- package/templates/instructions/craft.md +17 -0
- package/templates/instructions/iterate.md +109 -14
- package/templates/instructions/jam.md +18 -2
- package/templates/instructions/publish.md +7 -0
- package/templates/instructions/reference/deck-layouts.md +230 -0
- package/templates/instructions/reference/deck-story.md +110 -0
- package/templates/instructions/shape.md +15 -1
- package/templates/instructions/slides.md +402 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { create } from 'zustand'
|
|
2
|
-
import { ROUTE } from '../const.ts'
|
|
2
|
+
import { ROUTE, slideSize } from '../const.ts'
|
|
3
3
|
import { tidy, parseLayout, type BoardLayout } from './tidy.ts'
|
|
4
4
|
import { stableNodeKey } from './keys.ts'
|
|
5
5
|
// @ts-expect-error virtual module provided by the plugin
|
|
@@ -30,9 +30,18 @@ export const PUBLISHED = DATA !== null
|
|
|
30
30
|
export const SOURCE_REVEALED = DATA ? DATA.policy?.reveal?.source === true : true
|
|
31
31
|
|
|
32
32
|
/** The published per-board artifact metadata (type, open, lock). Empty in dev. */
|
|
33
|
-
export const BOARD_POLICY: Record<string, { type?: string; open?: string; lock?: boolean }> = DATA?.policy?.boards ?? {}
|
|
33
|
+
export const BOARD_POLICY: Record<string, { type?: string; open?: string; lock?: boolean; transition?: string; chrome?: string }> = DATA?.policy?.boards ?? {}
|
|
34
34
|
|
|
35
|
-
export
|
|
35
|
+
export { SLIDE_INTRINSIC } from '../const.ts'
|
|
36
|
+
|
|
37
|
+
/** Dev-only hydration: the shell loads the repo's publish.json boards so an
|
|
38
|
+
* author previews slides mode exactly as viewers will get it. Published
|
|
39
|
+
* builds carry the policy inline and never call this. */
|
|
40
|
+
export function hydrateBoardPolicy(boards: Record<string, { type?: string; open?: string; lock?: boolean; transition?: string; chrome?: string }>) {
|
|
41
|
+
for (const [k, v] of Object.entries(boards)) if (!BOARD_POLICY[k]) BOARD_POLICY[k] = v
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FrameEntry { id: string; file: string; kind: 'tsx' | 'html'; scene: string; title?: string; viewport?: string; theme?: string; variantGroup?: string; variant?: string; intent?: string; contentWidth?: number; slide?: boolean }
|
|
36
45
|
export interface Manifest { frames: FrameEntry[]; scenes: { name: string; frames: number }[] }
|
|
37
46
|
export interface Node {
|
|
38
47
|
key: string; frame: string; x: number; y: number; w: number; h: number
|
|
@@ -83,17 +92,24 @@ export function landingMode(board: string): 'canvas' | 'board' | 'present' | 'fo
|
|
|
83
92
|
const p = BOARD_POLICY[board]
|
|
84
93
|
if (!p) return 'canvas'
|
|
85
94
|
if (p.open && ['canvas', 'board', 'present', 'focus', 'slides'].includes(p.open)) {
|
|
86
|
-
|
|
87
|
-
return p.open === 'slides' ? 'present' : p.open as any
|
|
95
|
+
return p.open as any
|
|
88
96
|
}
|
|
89
97
|
switch (p.type) {
|
|
90
98
|
case 'doc': return 'focus'
|
|
91
|
-
case 'slides': return '
|
|
99
|
+
case 'slides': return 'slides'
|
|
92
100
|
case 'design': case 'sketch': case 'refs': return 'board'
|
|
93
101
|
default: return 'canvas'
|
|
94
102
|
}
|
|
95
103
|
}
|
|
96
104
|
|
|
105
|
+
/** Does this board PLAY as a deck? A `type: slides` board is a deck whatever
|
|
106
|
+
* its landing (`open: canvas` only says where viewers arrive) - the P key and
|
|
107
|
+
* the play button route here, never through landingMode. */
|
|
108
|
+
export function playsAsSlides(board: string): boolean {
|
|
109
|
+
const p = BOARD_POLICY[board]
|
|
110
|
+
return p?.type === 'slides' || p?.open === 'slides'
|
|
111
|
+
}
|
|
112
|
+
|
|
97
113
|
/** Is this board frozen to its landing mode? The lock caps disclosure - no way
|
|
98
114
|
* out into other modes, and (published) no sidebar ever renders for it. */
|
|
99
115
|
export const boardLocked = (board: string): boolean => BOARD_POLICY[board]?.lock === true
|
|
@@ -101,7 +117,7 @@ export const boardLocked = (board: string): boolean => BOARD_POLICY[board]?.lock
|
|
|
101
117
|
/** May this board enter `mode`? A lock freezes what open pinned - a board
|
|
102
118
|
* locked to canvas must refuse present just as one locked to present refuses
|
|
103
119
|
* canvas. Unlocked boards enter anything. */
|
|
104
|
-
export const modeAllowed = (board: string, mode: 'present' | 'focus'): boolean =>
|
|
120
|
+
export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides'): boolean =>
|
|
105
121
|
!boardLocked(board) || landingMode(board) === mode
|
|
106
122
|
|
|
107
123
|
export { cap, humanize } from './labels.ts'
|
|
@@ -173,6 +189,11 @@ const manifestKey = (m: Manifest) => JSON.stringify(m.frames) // any change co
|
|
|
173
189
|
const measuredHeights = new Map<string, number>()
|
|
174
190
|
|
|
175
191
|
function defaultSize(frame: FrameEntry) {
|
|
192
|
+
// the precedence chain (spec 09 slice 1): slide intrinsic → authored
|
|
193
|
+
// viewport → content sizing → default (one helper, shared with shot) -
|
|
194
|
+
// the Slide root renders fixed 1280×720, so nothing may size it smaller
|
|
195
|
+
const sl = slideSize(frame)
|
|
196
|
+
if (sl) return { w: sl.width, h: sl.height }
|
|
176
197
|
// content frames: own width from Doc layout; height from the latest
|
|
177
198
|
// measurement at that width, or a placeholder until sh:measure lands.
|
|
178
199
|
// meta.viewport, when declared, wins - the existing precedence.
|
|
@@ -206,7 +227,7 @@ interface State {
|
|
|
206
227
|
* fourth mode): no walking, doc reading preset when the board type is doc.
|
|
207
228
|
* `deep` = entered by a frame deep link - the chrome then carries no board
|
|
208
229
|
* name and no canvas door (presentation default of the link, 04 §2.35). */
|
|
209
|
-
play: { at: string; device: string; theme: string; focus?: boolean; deep?: boolean } | null
|
|
230
|
+
play: { at: string; device: string; theme: string; focus?: boolean; deep?: boolean; slides?: boolean } | null
|
|
210
231
|
gesture: boolean // a frame drag/resize is in progress - canvas panning is disabled
|
|
211
232
|
laser: boolean // laser/inspect mode: frames outline their structure
|
|
212
233
|
board: string // active board name; 'all-scenes' is the auto board
|
|
@@ -230,6 +251,8 @@ interface State {
|
|
|
230
251
|
playUpdateRevision: string | null // a revision arrived while play is open
|
|
231
252
|
playNav: number // bumps to reload the play stage on demand
|
|
232
253
|
pathPulse: number // bumps on each successful path copy - flashes the toolbar icon into a check
|
|
254
|
+
imagePulse: number // bumps on each successful image copy - same flash, the images-square icon
|
|
255
|
+
imageBusy: boolean // a copy-as-image render is in flight (one at a time)
|
|
233
256
|
|
|
234
257
|
boot(): Promise<boolean>
|
|
235
258
|
applyManifest(m: Manifest): void
|
|
@@ -259,6 +282,7 @@ interface State {
|
|
|
259
282
|
renameBoard(from: string, to: string): Promise<{ ok: boolean; error?: string }>
|
|
260
283
|
reorderBoards(order: string[]): Promise<boolean>
|
|
261
284
|
pulsePath(): void
|
|
285
|
+
copyFrameImage(scale: 2 | 4): void
|
|
262
286
|
setScale(s: number): void
|
|
263
287
|
togglePanel(): void
|
|
264
288
|
setTheme(theme: string): void
|
|
@@ -543,7 +567,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
543
567
|
manifest: null, nodes: [], selection: [], interact: null, viewTheme: initialViewTheme(), play: null, gesture: false, laser: false,
|
|
544
568
|
board: DATA?.default ?? 'all-scenes', boardAuto: (DATA?.default ?? 'all-scenes') === 'all-scenes', deviceView: null, sceneRows: null, layout: null, layoutRaw: undefined, baseLayout: null,
|
|
545
569
|
panelOpen: true, scale: 1, toasts: [], working: [], workingSince: {}, boardHash: null, dirty: false,
|
|
546
|
-
pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0,
|
|
570
|
+
pendingFrameRevisions: {}, externalLeases: {}, playUpdateRevision: null, playNav: 0, pathPulse: 0, imagePulse: 0, imageBusy: false,
|
|
547
571
|
|
|
548
572
|
async boot() {
|
|
549
573
|
const seq = ++loadSeq
|
|
@@ -996,6 +1020,53 @@ export const useStore = create<State>((set, get) => {
|
|
|
996
1020
|
},
|
|
997
1021
|
setScale(scale) { set({ scale }) },
|
|
998
1022
|
pulsePath() { set((s) => ({ pathPulse: s.pathPulse + 1 })) },
|
|
1023
|
+
// Copy the ONE selected frame to the clipboard as a PNG - the dev server's headless
|
|
1024
|
+
// renderer (/api/shot, the same picture `marver shot` gives an agent) sized to what the
|
|
1025
|
+
// node shows, at 2x (or 4x). The ClipboardItem takes a PROMISE: clipboard.write runs
|
|
1026
|
+
// inside the click/keydown gesture and the browser waits for the bytes - a plain
|
|
1027
|
+
// await-then-write would have lost the transient activation during the 1-4s render.
|
|
1028
|
+
copyFrameImage(scale) {
|
|
1029
|
+
const s = get()
|
|
1030
|
+
if (PUBLISHED || s.imageBusy || s.selection.length !== 1) return
|
|
1031
|
+
const node = s.nodes.find((n) => n.key === s.selection[0])
|
|
1032
|
+
if (!node || node.missing) return
|
|
1033
|
+
const frame = s.frameFor(node)
|
|
1034
|
+
if (!frame) { s.toast('frame is still indexing - try again in a second'); return }
|
|
1035
|
+
const CI = (globalThis as { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
|
|
1036
|
+
if (!CI || !navigator.clipboard?.write || (CI.supports && !CI.supports('image/png'))) { s.toast("this browser can't put images on the clipboard - use Chrome, Edge or Safari"); return }
|
|
1037
|
+
set({ imageBusy: true })
|
|
1038
|
+
const done = () => set({ imageBusy: false })
|
|
1039
|
+
const qs = new URLSearchParams({ frame: frame.id, theme: node.theme, scale: String(scale), w: String(Math.round(node.w)), h: String(Math.round(node.h)), format: 'png' })
|
|
1040
|
+
// the render is serialized server-side behind any CLI/jam shots; a minute is the ceiling
|
|
1041
|
+
// before the UI gives up on this copy (the server's own watchdog is 45s per shot)
|
|
1042
|
+
const ctl = new AbortController()
|
|
1043
|
+
const timer = setTimeout(() => ctl.abort(), 60_000)
|
|
1044
|
+
let meta: { scale?: number; note?: string } = {}
|
|
1045
|
+
const png = fetch(`${ROUTE}/api/shot?${qs}`, { headers: { 'x-mv-c': csrf() }, signal: ctl.signal }).then(async (r) => {
|
|
1046
|
+
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error ?? `shot failed (${r.status})`)
|
|
1047
|
+
try { meta = JSON.parse(atob((r.headers.get('x-mv-shot') ?? '').replace(/-/g, '+').replace(/_/g, '/'))) } catch { /* summary is advisory */ }
|
|
1048
|
+
return r.blob()
|
|
1049
|
+
}).finally(() => clearTimeout(timer))
|
|
1050
|
+
let renderErr = ''
|
|
1051
|
+
png.catch((e: Error) => { renderErr = e.name === 'AbortError' ? 'timed out - the renderer is busy' : e.message })
|
|
1052
|
+
const fail = (err: unknown) => {
|
|
1053
|
+
done()
|
|
1054
|
+
// a prompt clipboard refusal (no gesture, focus lost) must not wait a minute on the
|
|
1055
|
+
// render: abandon the fetch, toast now. A render failure surfaces its own cause.
|
|
1056
|
+
if (!renderErr) ctl.abort()
|
|
1057
|
+
s.toast(renderErr ? `render failed - ${renderErr}` : (err as Error)?.name === 'NotAllowedError' ? 'copy blocked - click the canvas first' : `copy failed - ${(err as Error)?.message ?? err}`)
|
|
1058
|
+
}
|
|
1059
|
+
try {
|
|
1060
|
+
navigator.clipboard.write([new CI({ 'image/png': png })]).then(
|
|
1061
|
+
() => {
|
|
1062
|
+
done()
|
|
1063
|
+
const used = meta.scale ?? scale
|
|
1064
|
+
s.toast(used < scale ? `image copied at ${used}x - frame too tall for ${scale}x` : scale === 4 ? 'image copied (4x)' : 'image copied')
|
|
1065
|
+
set((st) => ({ imagePulse: st.imagePulse + 1 }))
|
|
1066
|
+
},
|
|
1067
|
+
fail)
|
|
1068
|
+
} catch (err) { fail(err) } // a synchronous constructor/type error must not wedge busy
|
|
1069
|
+
},
|
|
999
1070
|
togglePanel() { set((s) => ({ panelOpen: !s.panelOpen })) },
|
|
1000
1071
|
// global theme = the VIEW preference: persists across boards + reloads, clears
|
|
1001
1072
|
// per-frame pins. Frames declaring meta.theme keep their mode (they only work there).
|
|
@@ -413,6 +413,12 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
413
413
|
justify-content: center; padding: 0 6px; margin: 0 2px 0 3px }
|
|
414
414
|
.sh-ctx button.on { background: var(--glass-accent-bg); color: var(--glass-accent) }
|
|
415
415
|
.sh-ctx button:hover:not(.on) { background: var(--glass-hover); color: var(--glass-ink) }
|
|
416
|
+
/* multi-select / no-op states read as quiet, not broken */
|
|
417
|
+
.sh-ctx button:disabled { cursor: default; opacity: .45 }
|
|
418
|
+
.sh-ctx button:disabled:hover { background: none; color: var(--glass-ink-2) }
|
|
419
|
+
/* copy-as-image while the headless render runs (1-4s): a slow breathe on the icon */
|
|
420
|
+
.sh-ctx button.busy { opacity: 1; color: var(--glass-accent); animation: sh-ctx-breathe 1.1s ease-in-out infinite }
|
|
421
|
+
@keyframes sh-ctx-breathe { 0%, 100% { opacity: .35 } 50% { opacity: 1 } }
|
|
416
422
|
.sh-ctx .sep { width: 1px; height: 14px; background: var(--glass-brd); margin: 0 3px }
|
|
417
423
|
|
|
418
424
|
/* error / missing cards (inside the frame body) */
|
|
@@ -736,22 +742,6 @@ body.sh-commenting .sh-lean { opacity: 0 }
|
|
|
736
742
|
.sh-play-chip:hover { color: #f5f5f7 }
|
|
737
743
|
.sh-play-chip.idle { opacity: 0; pointer-events: none; transform: scale(.6) }
|
|
738
744
|
|
|
739
|
-
/* the H coach bubble: hairline, translucent, teaches the way back from immersive mode.
|
|
740
|
-
Same pill geometry and separators as the bar it stands in for. */
|
|
741
|
-
.sh-play-hint { position: absolute; top: 14px; right: 16px; display: flex; align-items: center; gap: 3px;
|
|
742
|
-
padding: 4px 4px 4px 14px; border-radius: 999px;
|
|
743
|
-
background: rgba(18, 18, 24, .6); border: 1px solid rgba(255, 255, 255, .14);
|
|
744
|
-
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
|
745
|
-
font: 500 12px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .85);
|
|
746
|
-
animation: sh-play-in .3s ease-out }
|
|
747
|
-
.sh-play-hint .sep { width: 1px; height: 16px; background: rgba(255, 255, 255, .14); margin: 0 8px }
|
|
748
|
-
.sh-play-hint kbd { font: 600 11px -apple-system, system-ui, sans-serif; padding: 1px 6px; border-radius: 5px;
|
|
749
|
-
background: rgba(255, 255, 255, .12); border: 1px solid rgba(255, 255, 255, .16) }
|
|
750
|
-
.sh-play-hint button { height: 30px; padding: 0 12px; border: 0; border-radius: 999px; cursor: pointer;
|
|
751
|
-
font: 600 11px -apple-system, system-ui, sans-serif; background: rgba(255, 255, 255, .14); color: #f5f5f7 }
|
|
752
|
-
.sh-play-hint button:hover { background: rgba(255, 255, 255, .22) }
|
|
753
|
-
.sh-play-hint button.dim { background: transparent; color: rgba(245, 245, 247, .5); font-weight: 500 }
|
|
754
|
-
.sh-play-hint button.dim:hover { color: rgba(245, 245, 247, .8); background: rgba(255, 255, 255, .08) }
|
|
755
745
|
|
|
756
746
|
/* ---- Focus: silent for pointer + shortcut use; ONE branded inset ring, and only while
|
|
757
747
|
the user is actually Tab-navigating (body.sh-kbd, armed by Tab, disarmed by pointer).
|
|
@@ -861,22 +851,19 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
|
|
|
861
851
|
|
|
862
852
|
/* ---- present/focus chrome (sharing v1): brand pill top-left, counter in the
|
|
863
853
|
toolbar, doc reading preset. Same glass skin as the play pill. */
|
|
864
|
-
|
|
865
|
-
|
|
854
|
+
/* the brand pill matches the toolbar pill to the pixel: the SAME .sh-pill
|
|
855
|
+
padding (5px) and the SAME 34px inner height as .sh-pill-btn - its two
|
|
856
|
+
children are sized like buttons, so text metrics never set the height */
|
|
857
|
+
.sh-play-brand.sh-pill { left: var(--edge); right: auto; transform-origin: 22px 22px; padding: 5px 9px 5px 5px }
|
|
858
|
+
.sh-play-mark { display: inline-flex; align-items: center; gap: 7px; height: 34px; padding: 0 8px 0 10px; border-radius: 999px;
|
|
859
|
+
color: rgba(245, 245, 247, .88); text-decoration: none; cursor: pointer }
|
|
860
|
+
.sh-play-mark:hover { background: rgba(255, 255, 255, .1); color: #f5f5f7 }
|
|
866
861
|
.sh-play-mark svg { color: #6db3ff }
|
|
867
862
|
.sh-play-mark .wd { font: 600 13px -apple-system, system-ui, sans-serif; letter-spacing: -.01em }
|
|
868
|
-
.sh-play-title { font: 500 13px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .82);
|
|
863
|
+
.sh-play-title { display: inline-block; height: 34px; line-height: 34px; font: 500 13px/34px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .82);
|
|
869
864
|
max-width: 143px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 6px }
|
|
870
865
|
.sh-play-pos { font: 500 12.5px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .64);
|
|
871
866
|
font-variant-numeric: tabular-nums; padding: 0 8px; white-space: nowrap }
|
|
872
|
-
/* the hint pill: bottom center, the design's quiet keyboard cue */
|
|
873
|
-
.sh-play-hint { position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%); z-index: 31;
|
|
874
|
-
border-radius: 999px; padding: 7px 16px; white-space: nowrap;
|
|
875
|
-
background: rgba(18, 18, 24, .8); border: 1px solid rgba(255, 255, 255, .1);
|
|
876
|
-
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
|
|
877
|
-
font: 500 12px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .64) }
|
|
878
|
-
.sh-play-hint kbd { font: 500 11.5px ui-monospace, monospace }
|
|
879
|
-
body.sh-hide-ui .sh-play-hint { display: none !important }
|
|
880
867
|
/* doc preset: the page IS the surface - reading column on a calm ground, no
|
|
881
868
|
device shell, natural document scrolling inside the frame */
|
|
882
869
|
.sh-play.doc { background: #f4f4f6 }
|
|
@@ -1249,3 +1236,12 @@ body.sh-hide-ui .sh-play-hint { display: none !important }
|
|
|
1249
1236
|
color: rgba(255,255,255,.92); font: 700 13px/1 -apple-system, system-ui, sans-serif; letter-spacing: .02em }
|
|
1250
1237
|
/* the title row is flex, which swallows the JSX whitespace - the verb carries its own gap */
|
|
1251
1238
|
.sh-jam-frame .v { color: var(--glass-ink-2); font-weight: 500; margin-left: 4px; flex: none }
|
|
1239
|
+
|
|
1240
|
+
/* v1.5 slides mode: the slim progress strip + the zero-chrome escape hatch */
|
|
1241
|
+
.sh-slides-strip { position: fixed; left: 50%; bottom: 14px; transform: translateX(-50%); z-index: 30;
|
|
1242
|
+
display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-radius: 999px;
|
|
1243
|
+
background: var(--glass); border: 1px solid var(--glass-brd); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur) }
|
|
1244
|
+
.sh-slides-strip .n { font: 600 12px/1 -apple-system, system-ui, sans-serif; color: var(--glass-ink-2); white-space: nowrap }
|
|
1245
|
+
.sh-slides-strip .bar { width: 140px; height: 3px; border-radius: 999px; background: var(--glass-hover); overflow: hidden }
|
|
1246
|
+
.sh-slides-strip .bar i { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width .25s ease }
|
|
1247
|
+
@media (max-width: 480px) { .sh-slides-strip .bar { width: 80px } }
|
|
@@ -21,6 +21,14 @@ const params = new URLSearchParams(location.search)
|
|
|
21
21
|
// token systems, .dark for class-keyed ones (Tailwind/shadcn). Missing the class made
|
|
22
22
|
// play render class-keyed apps light while the canvas showed them dark.
|
|
23
23
|
const bootTheme = params.get('theme') ?? 'light'
|
|
24
|
+
// slides mode (v1.5): the shell says so in the URL; the stage stamps the ONE
|
|
25
|
+
// attribute the content primitives observe (data-sl-play lifts the rest-state
|
|
26
|
+
// motion reset; data-sl-entered arms the entrance presets after each swap
|
|
27
|
+
// settles). `tr=none` and prefers-reduced-motion both skip view transitions.
|
|
28
|
+
const slidesMode = params.get('slides') === '1'
|
|
29
|
+
const deckTransition = params.get('tr') ?? 'fade'
|
|
30
|
+
if (slidesMode) document.documentElement.setAttribute('data-sl-play', '')
|
|
31
|
+
const reducedMotion = typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
24
32
|
document.documentElement.dataset.theme = bootTheme
|
|
25
33
|
document.documentElement.classList.toggle('dark', bootTheme === 'dark')
|
|
26
34
|
const startId = params.get('at') ?? ''
|
|
@@ -95,9 +103,41 @@ function Stage() {
|
|
|
95
103
|
// inspect.getId() never reports the new frame while the old DOM is still live -
|
|
96
104
|
// a pick / anchor-resolve landing mid-swap then carries the old id and the shell
|
|
97
105
|
// guards drop it instead of stamping it onto the wrong frame.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
106
|
+
// slides: the entrance presets re-arm per swap. The disarm (drop `entered`,
|
|
107
|
+
// strip data-animate from morph-owned elements) runs INSIDE the transition's
|
|
108
|
+
// update callback - after the new DOM commits, before the NEW-state capture.
|
|
109
|
+
// Disarming before startViewTransition would capture the OUTGOING slide with
|
|
110
|
+
// its [data-animate] elements at opacity 0 (they'd vanish from the old
|
|
111
|
+
// snapshot), and a morphed element still carrying data-animate would be
|
|
112
|
+
// captured transparent and pop in after the morph.
|
|
113
|
+
const disarm = () => {
|
|
114
|
+
if (!slidesMode) return
|
|
115
|
+
document.documentElement.removeAttribute('data-sl-entered')
|
|
116
|
+
// one transform owner: an element that morphs must not also run an
|
|
117
|
+
// entrance preset - CSS cannot see a computed view-transition-name,
|
|
118
|
+
// so the stage strips data-animate from morph-owned elements here
|
|
119
|
+
for (const el of document.querySelectorAll('[data-animate]'))
|
|
120
|
+
if (getComputedStyle(el).viewTransitionName !== 'none') el.removeAttribute('data-animate')
|
|
121
|
+
}
|
|
122
|
+
const apply = () => { if (seq === swapSeq.current) { flushSync(() => { setErr(null); setMounted(next) }); current.current = id; disarm() } }
|
|
123
|
+
const entered = () => {
|
|
124
|
+
if (!slidesMode || seq !== swapSeq.current) return
|
|
125
|
+
document.documentElement.setAttribute('data-sl-entered', '')
|
|
126
|
+
}
|
|
127
|
+
const skipVt = reducedMotion || (slidesMode && deckTransition === 'none')
|
|
128
|
+
if (document.startViewTransition && !skipVt) {
|
|
129
|
+
const vt = document.startViewTransition(apply)
|
|
130
|
+
vt.finished.then(entered, entered)
|
|
131
|
+
} else if (skipVt) { apply(); entered() }
|
|
132
|
+
else {
|
|
133
|
+
// no view transitions here: a plain crossfade at the deck's tempo, so
|
|
134
|
+
// the one-tempo contract holds even where morphs cannot
|
|
135
|
+
apply(); entered()
|
|
136
|
+
const raw = getComputedStyle(document.documentElement).getPropertyValue('--marver-slide-tempo').trim()
|
|
137
|
+
const m = /^(\d*\.?\d+)\s*(ms|s)?$/i.exec(raw)
|
|
138
|
+
const tempo = m ? parseFloat(m[1]) * (m[2]?.toLowerCase() === 's' ? 1000 : 1) : 350 // a CSS <time>: 350ms or .35s; 0 is honoured
|
|
139
|
+
if (tempo > 0) document.getElementById('root')?.animate([{ opacity: 0.35 }, { opacity: 1 }], { duration: tempo, easing: 'ease-out' })
|
|
140
|
+
}
|
|
101
141
|
if (announce) post({ type: 'sh:stage-at', at: id })
|
|
102
142
|
} catch (e) {
|
|
103
143
|
if (seq !== swapSeq.current) return
|
|
@@ -129,6 +169,12 @@ function Stage() {
|
|
|
129
169
|
// laser/comment click owns the press instead - it must not navigate.
|
|
130
170
|
const onClick = (e: MouseEvent) => {
|
|
131
171
|
if (inspect.modeActive()) return
|
|
172
|
+
// slides: a background click advances (posted as the Space key) - never
|
|
173
|
+
// on anything interactive, and data-goto (below) always wins
|
|
174
|
+
if (slidesMode && e.target instanceof Element
|
|
175
|
+
&& !e.target.closest('a, button, input, textarea, select, video, .mv-video, [contenteditable], [data-goto]')) {
|
|
176
|
+
post({ type: 'sh:stage-key', key: ' ', code: 'Space' })
|
|
177
|
+
}
|
|
132
178
|
const el = e.target instanceof Element ? e.target.closest('[data-goto]') : null
|
|
133
179
|
if (!el) return
|
|
134
180
|
e.preventDefault()
|
|
@@ -149,6 +195,11 @@ function Stage() {
|
|
|
149
195
|
// l/c toggle laser/comment, C (shift+c) hides pins; the shell acts and broadcasts back.
|
|
150
196
|
if (/^Digit[0-9]$/.test(e.code) || ['d', 'h', 'r', 'l', 'c', 'C', '[', ']', 'ArrowRight', 'ArrowLeft'].includes(e.key))
|
|
151
197
|
post({ type: 'sh:stage-key', key: e.key, code: e.code })
|
|
198
|
+
// slides: Space advances - but never stolen from anything interactive
|
|
199
|
+
if (slidesMode && e.key === ' ' && !(e.target instanceof Element && e.target.closest('button, input, textarea, select, video, .mv-video, a, [contenteditable]'))) {
|
|
200
|
+
e.preventDefault()
|
|
201
|
+
post({ type: 'sh:stage-key', key: ' ', code: e.code })
|
|
202
|
+
}
|
|
152
203
|
}
|
|
153
204
|
window.addEventListener('keydown', onKey)
|
|
154
205
|
|
package/src/shared/utm.ts
CHANGED
|
@@ -4,12 +4,13 @@
|
|
|
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 | 'sign-in' finish page
|
|
7
|
+
* utm_content = the placement ('gate' badge | 'shell' wordmark | 'sign-in' finish page |
|
|
8
|
+
* 'play-brand' the present/slides brand pill)
|
|
8
9
|
*/
|
|
9
10
|
export function poweredByUrl(
|
|
10
11
|
canvasName: string | undefined,
|
|
11
12
|
source: 'published-canvas' | 'dev-canvas',
|
|
12
|
-
content: 'gate' | 'shell' | 'sign-in',
|
|
13
|
+
content: 'gate' | 'shell' | 'sign-in' | 'play-brand',
|
|
13
14
|
): string {
|
|
14
15
|
const slug = (canvasName ?? '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
15
16
|
const q = new URLSearchParams({
|
|
@@ -19,9 +19,10 @@ file in design/instructions/ - they are short, strict, and part of this contract
|
|
|
19
19
|
| Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
|
|
20
20
|
| Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
|
|
21
21
|
| Build | hi-fi frames from real components | instructions/craft.md + components.md |
|
|
22
|
-
| Iterate | changing a frame the human has seen, or retiring explorations | instructions/iterate.md |
|
|
22
|
+
| Iterate | changing a frame the human has seen, a round of feedback on a scene, or retiring explorations | instructions/iterate.md |
|
|
23
23
|
| Review | before presenting anything | instructions/review.md |
|
|
24
24
|
| Boards | creating a board, choosing what ships | instructions/boards.md |
|
|
25
|
+
| Slides | a deck is asked for, or `slide: true` frames exist | instructions/slides.md + design/slides.md |
|
|
25
26
|
| Publish | deploying the canvas: gate, volume, accounts, invites | instructions/publish.md |
|
|
26
27
|
| Live Jam | responding to an `@marver` comment (a spawned job); on by default - confirm it names YOUR tool | instructions/jam.md |
|
|
27
28
|
|
|
@@ -57,6 +58,13 @@ Two channels carry element-precise feedback - honor both:
|
|
|
57
58
|
thread carries the anchored element (tag, quoted text, css path, frame). Work that queue
|
|
58
59
|
per instructions/iterate.md; the comment names the div, so read the anchor before the words.
|
|
59
60
|
|
|
61
|
+
Either way, **a pointer names where the human noticed it, not the only place it is.** Before
|
|
62
|
+
you answer, look sideways: every other LIVE frame on that board that shares the component,
|
|
63
|
+
the pattern, the copy or the state (never `archive/` or a `<scene>-v<N>` version - history is
|
|
64
|
+
not a sibling). Same defect there? Fix it in the same pass and say which frames you touched.
|
|
65
|
+
A judgment call? Do the pinned one, then ask in the thread or the reply whether to roll it
|
|
66
|
+
across the others - never silently fix one and leave its siblings wrong.
|
|
67
|
+
|
|
60
68
|
## Show the work (working state)
|
|
61
69
|
|
|
62
70
|
The canvas can wear your effort live. When a request will create or change frames, making
|
|
@@ -147,9 +155,17 @@ A board is a saved canvas: `design/boards/<name>.json` - you create and manage t
|
|
|
147
155
|
by writing files; `all-scenes` is auto-managed, never write it. Compose a board
|
|
148
156
|
deliberately with `"layout"`: rows/columns lanes of scenes plus `{ "space": n }`
|
|
149
157
|
whitespace tokens, and the same grammar per scene for frames (columns align left
|
|
150
|
-
edges; a variant-group name is one indivisible atom).
|
|
151
|
-
|
|
152
|
-
|
|
158
|
+
edges; a variant-group name is one indivisible atom). **The default composition is
|
|
159
|
+
ONE horizontal band**: scenes side by side, frames flowing left to right; a second
|
|
160
|
+
band only when you can say why the eye should move down, and then with generous
|
|
161
|
+
vertical space. Without a recipe the shell stacks every scene as its own row - so
|
|
162
|
+
every curated board carries one. BEFORE creating a board or publishing anything,
|
|
163
|
+
read instructions/boards.md (the layout grammar, file format, publishing rules).
|
|
164
|
+
|
|
165
|
+
A round of feedback on a scene the human has already reviewed starts with a
|
|
166
|
+
**version snapshot** (`design/scenes/<scene>-v<N>/` on the `archive` board) BEFORE
|
|
167
|
+
the first edit - the human iterates fast knowing every version is one board away.
|
|
168
|
+
instructions/iterate.md has the mechanics.
|
|
153
169
|
|
|
154
170
|
## Upstream feedback (when marver itself misbehaves)
|
|
155
171
|
|
|
@@ -19,9 +19,10 @@ file in design/instructions/ - they are short, strict, and part of this contract
|
|
|
19
19
|
| Wireframe | new work: nail structure + copy in throwaway lo-fi | instructions/wireframe.md |
|
|
20
20
|
| Brand | before the first hi-fi work: extract or create the world | instructions/brand.md |
|
|
21
21
|
| Build | hi-fi frames from real components | instructions/craft.md + components.md |
|
|
22
|
-
| Iterate | changing a frame the human has seen, or retiring explorations | instructions/iterate.md |
|
|
22
|
+
| Iterate | changing a frame the human has seen, a round of feedback on a scene, or retiring explorations | instructions/iterate.md |
|
|
23
23
|
| Review | before presenting anything | instructions/review.md |
|
|
24
24
|
| Boards | creating a board, choosing what ships | instructions/boards.md |
|
|
25
|
+
| Slides | a deck is asked for, or `slide: true` frames exist | instructions/slides.md + design/slides.md |
|
|
25
26
|
| Publish | deploying the canvas: gate, volume, accounts, invites | instructions/publish.md |
|
|
26
27
|
| Live Jam | responding to an `@marver` comment (a spawned job); on by default - confirm it names YOUR tool | instructions/jam.md |
|
|
27
28
|
|
|
@@ -57,6 +58,13 @@ Two channels carry element-precise feedback - honor both:
|
|
|
57
58
|
thread carries the anchored element (tag, quoted text, css path, frame). Work that queue
|
|
58
59
|
per instructions/iterate.md; the comment names the div, so read the anchor before the words.
|
|
59
60
|
|
|
61
|
+
Either way, **a pointer names where the human noticed it, not the only place it is.** Before
|
|
62
|
+
you answer, look sideways: every other LIVE frame on that board that shares the component,
|
|
63
|
+
the pattern, the copy or the state (never `archive/` or a `<scene>-v<N>` version - history is
|
|
64
|
+
not a sibling). Same defect there? Fix it in the same pass and say which frames you touched.
|
|
65
|
+
A judgment call? Do the pinned one, then ask in the thread or the reply whether to roll it
|
|
66
|
+
across the others - never silently fix one and leave its siblings wrong.
|
|
67
|
+
|
|
60
68
|
## Show the work (working state)
|
|
61
69
|
|
|
62
70
|
The canvas can wear your effort live. When a request will create or change frames, making
|
|
@@ -147,9 +155,17 @@ A board is a saved canvas: `design/boards/<name>.json` - you create and manage t
|
|
|
147
155
|
by writing files; `all-scenes` is auto-managed, never write it. Compose a board
|
|
148
156
|
deliberately with `"layout"`: rows/columns lanes of scenes plus `{ "space": n }`
|
|
149
157
|
whitespace tokens, and the same grammar per scene for frames (columns align left
|
|
150
|
-
edges; a variant-group name is one indivisible atom).
|
|
151
|
-
|
|
152
|
-
|
|
158
|
+
edges; a variant-group name is one indivisible atom). **The default composition is
|
|
159
|
+
ONE horizontal band**: scenes side by side, frames flowing left to right; a second
|
|
160
|
+
band only when you can say why the eye should move down, and then with generous
|
|
161
|
+
vertical space. Without a recipe the shell stacks every scene as its own row - so
|
|
162
|
+
every curated board carries one. BEFORE creating a board or publishing anything,
|
|
163
|
+
read instructions/boards.md (the layout grammar, file format, publishing rules).
|
|
164
|
+
|
|
165
|
+
A round of feedback on a scene the human has already reviewed starts with a
|
|
166
|
+
**version snapshot** (`design/scenes/<scene>-v<N>/` on the `archive` board) BEFORE
|
|
167
|
+
the first edit - the human iterates fast knowing every version is one board away.
|
|
168
|
+
instructions/iterate.md has the mechanics.
|
|
153
169
|
|
|
154
170
|
## Upstream feedback (when marver itself misbehaves)
|
|
155
171
|
|
|
@@ -29,16 +29,58 @@ viewport and lays it out:
|
|
|
29
29
|
EVERY frame, so it is the heavy one) and always sinks to the BOTTOM of the switcher -
|
|
30
30
|
never the landing board, and never write its file.
|
|
31
31
|
- Do not edit board files while the canvas is open unless asked; the shell owns
|
|
32
|
-
their layout fields.
|
|
32
|
+
their layout fields (`x`/`y`/`w`/`h`, node keys). What is always yours, canvas
|
|
33
|
+
open or not: creating a board, appending nodes, and writing the `layout` recipe
|
|
34
|
+
of a board you curate (the `archive` board above all).
|
|
33
35
|
- Use boards for comparisons: version A vs B vs C of a flow, side by side. Variant
|
|
34
36
|
groups (letter-prefixed siblings) stay contiguous through every relayout
|
|
35
37
|
automatically.
|
|
36
38
|
- Content frames (specs, diagrams, mood boards - instructions/shape.md) are ordinary
|
|
37
39
|
atoms in every layout scope: a feature-story board mixes them freely with UI frames.
|
|
38
|
-
- The `archive` board (instructions/iterate.md) is the one board of
|
|
39
|
-
explorations
|
|
40
|
-
|
|
41
|
-
the
|
|
40
|
+
- The `archive` board (instructions/iterate.md) is the one board of history:
|
|
41
|
+
retired explorations (design/scenes/archive/, every frame relabeled with what
|
|
42
|
+
it was and why it retired) and **scene versions** (`<scene>-v1`, `<scene>-v2`
|
|
43
|
+
… - the whole flow as it stood before each round of feedback), one band per
|
|
44
|
+
version, oldest at the top. Winners live on the feature boards; the archive
|
|
45
|
+
answers "what did we try?" and "what did it look like before?".
|
|
46
|
+
|
|
47
|
+
## The default composition: one horizontal band
|
|
48
|
+
|
|
49
|
+
A board reads like a page: left to right first, down only for a reason. The
|
|
50
|
+
default for every curated board is **one `rows` lane holding the scenes side by
|
|
51
|
+
side, in reading order, each scene's frames flowing left to right** - the whole
|
|
52
|
+
story on one horizontal band the human pans along. Without a recipe the shell
|
|
53
|
+
stacks every scene as its own row (a vertical pile of unrelated bands), so a board
|
|
54
|
+
without a `layout` is a board you have not composed yet.
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
"layout": { "rows": [["onboarding", "checkout", "account"]] }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A **second band** is a decision, not a reflex. Open one when you can say in a
|
|
61
|
+
sentence why the eye should move down - a different chapter of the story (the
|
|
62
|
+
specs that argue for the flow above), a different audience (admin vs customer),
|
|
63
|
+
an archive or a version history, a scene so wide that beside the others it would
|
|
64
|
+
not be read. Then make the break unmistakable: the gap between bands must read as
|
|
65
|
+
"below", never as "next". Units are adaptive (proportional to the touching
|
|
66
|
+
frames), so judge the RENDERED gap: between rows of phone or laptop frames that
|
|
67
|
+
is `{ "space": 4 }`; after a band of tall spec frames `{ "space": 2 }`-`3` already
|
|
68
|
+
reads as a chapter break. Inside a band, `{ "space": 2 }`-`{ "space": 3 }`
|
|
69
|
+
separates clusters (a variant run, a scene that ends one thought and starts
|
|
70
|
+
another); plain adjacency joins.
|
|
71
|
+
|
|
72
|
+
Two boards are multi-band BY DESIGN and set their own gaps: the feature-story
|
|
73
|
+
board (instructions/shape.md - thinking, structure, answer, three bands) and the
|
|
74
|
+
`archive` board (instructions/iterate.md - one band per version). Everything else
|
|
75
|
+
starts as one band.
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
"layout": { "rows": [["onboarding", "checkout", "account"], { "space": 4 }, ["checkout-specs"]] }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`columns` are for the rarer case where things must share a left edge (versions of
|
|
82
|
+
one flow stacked as a timeline, a parked archive under a hero) - never as a way to
|
|
83
|
+
fit more on screen.
|
|
42
84
|
|
|
43
85
|
## Composing the canvas: `layout`
|
|
44
86
|
|
|
@@ -96,6 +96,23 @@ thing - changes everything. This section is binding, not aspiration:
|
|
|
96
96
|
- **Imagery is real imagery.** When the design calls for photos or screenshots,
|
|
97
97
|
fetch and commit them locally with names that say what they are - never
|
|
98
98
|
hotlink (published canvases make zero external requests, and remote URLs rot).
|
|
99
|
+
- **Charts are real charts.** A dashboard, a report, an analytics screen gets
|
|
100
|
+
`Chart` from `@marver-design/marver/content` - Apache ECharts behind a house
|
|
101
|
+
theme that inherits the SCREEN's ink, typeface and accent (light and dark),
|
|
102
|
+
renders SVG, sits still at rest and follows the layout on resize. Importing it
|
|
103
|
+
does not make the screen a content frame: it keeps its device, its height and
|
|
104
|
+
its place in the flow. Write the ECharts `option` with fixture data; never
|
|
105
|
+
set colors, fonts or animation in it. Never a static chart image, never
|
|
106
|
+
hand-drawn bars from divs when the real thing is one import away.
|
|
107
|
+
- **Video is a real video.** A hero loop, an onboarding clip, a story in a
|
|
108
|
+
phone screen: `Video` from `@marver-design/marver/content` - poster-first
|
|
109
|
+
(still on the canvas, no media fetched at rest), click-to-play wherever the
|
|
110
|
+
frame is live, `ratio="9 / 16"` for vertical, `autoplay` for a muted ambient
|
|
111
|
+
loop (an explicit choice: that frame stays live on the canvas). The poster
|
|
112
|
+
is rendered from the clip when you omit it (`<clip>.poster.png` beside it in
|
|
113
|
+
`design/assets/`); author one when the opening frame is not the picture.
|
|
114
|
+
Never a gray "video" box, never
|
|
115
|
+
a static screenshot standing in for motion the design depends on.
|
|
99
116
|
- **Licensing sanity, briefly:** brand marks from official sources shown to
|
|
100
117
|
identify the brand are fine; photos come from sources that permit the use.
|
|
101
118
|
Unsure about one? Use it, and flag it to the human in the same message.
|