@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +44 -20
  3. package/dist/{build-BxGrHFT2.mjs → build-DfuTQZlY.mjs} +46 -6
  4. package/dist/cli.mjs +21 -7
  5. package/dist/{daemon-BChkzDqQ.mjs → daemon-DalgvoA9.mjs} +1 -1
  6. package/dist/{dev-DLwt3Brb.mjs → dev-BxCmeU_H.mjs} +14 -4
  7. package/dist/{init-BpitOqRQ.mjs → init-QKNi9gvF.mjs} +66 -2
  8. package/dist/{manifest-CS6krOTe.mjs → manifest-BzxSMoDB.mjs} +24 -6
  9. package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
  10. package/dist/{plugin-DNc4Jpae.mjs → plugin-DJyjmQeh.mjs} +60 -10
  11. package/dist/poster-CbpzSzJu.mjs +143 -0
  12. package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
  13. package/dist/{shot-Cyv3GN79.mjs → shot-BWhoz6cU.mjs} +204 -57
  14. package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
  15. package/docs/live-jam.md +177 -0
  16. package/docs/publish.md +270 -0
  17. package/docs/sharing.md +333 -0
  18. package/docs/slides.md +140 -0
  19. package/package.json +3 -1
  20. package/src/client/const.ts +13 -0
  21. package/src/client/content/chart-engine.ts +33 -0
  22. package/src/client/content/chart.tsx +138 -0
  23. package/src/client/content/index.tsx +30 -6
  24. package/src/client/content/slide.tsx +238 -0
  25. package/src/client/content/video.tsx +223 -0
  26. package/src/client/frame-host/bridge.js +6 -1
  27. package/src/client/shell/App.tsx +59 -13
  28. package/src/client/shell/LockedApp.tsx +7 -2
  29. package/src/client/shell/Play.tsx +138 -24
  30. package/src/client/shell/Toolbar.tsx +12 -3
  31. package/src/client/shell/canvas/FrameNode.tsx +5 -3
  32. package/src/client/shell/hash.ts +3 -1
  33. package/src/client/shell/icons.tsx +3 -0
  34. package/src/client/shell/play-order.ts +22 -0
  35. package/src/client/shell/store.ts +80 -9
  36. package/src/client/shell/styles.css +23 -27
  37. package/src/client/stage/main.tsx +54 -3
  38. package/src/shared/utm.ts +3 -2
  39. package/templates/AGENTS-embedded.md +20 -4
  40. package/templates/AGENTS-studio.md +20 -4
  41. package/templates/instructions/boards.md +47 -5
  42. package/templates/instructions/craft.md +17 -0
  43. package/templates/instructions/iterate.md +109 -14
  44. package/templates/instructions/jam.md +18 -2
  45. package/templates/instructions/publish.md +7 -0
  46. package/templates/instructions/reference/deck-layouts.md +230 -0
  47. package/templates/instructions/reference/deck-story.md +110 -0
  48. package/templates/instructions/shape.md +15 -1
  49. 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 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 }
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
- // slides is v1.5 - until the mode exists it lands in present, its nearest kin
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 'present' // slides mode is v1.5; present until then
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
- .sh-play-brand.sh-pill { left: var(--edge); right: auto; transform-origin: 22px 22px; padding: 5px 14px 5px 10px }
865
- .sh-play-mark { display: inline-flex; align-items: center; gap: 7px; padding: 0 2px 0 6px; color: rgba(245, 245, 247, .88) }
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
- const apply = () => { if (seq === swapSeq.current) { flushSync(() => { setErr(null); setMounted(next) }); current.current = id } }
99
- if (document.startViewTransition) document.startViewTransition(apply)
100
- else { apply(); document.getElementById('root')?.animate([{ opacity: 0.35 }, { opacity: 1 }], { duration: 180, easing: 'ease-out' }) }
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). BEFORE creating a board or
151
- publishing anything, read instructions/boards.md (the layout grammar, file format,
152
- publishing rules).
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). BEFORE creating a board or
151
- publishing anything, read instructions/boards.md (the layout grammar, file format,
152
- publishing rules).
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 retired
39
- explorations: curated over design/scenes/archive/, tidied with a recipe,
40
- every frame relabeled with what it was and why it retired. Winners live on
41
- the feature boards; the archive answers "what did we try?".
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.