@marver-design/marver 0.13.0 → 0.14.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 (41) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +41 -19
  3. package/dist/{build-BxGrHFT2.mjs → build-ByYafIhj.mjs} +37 -5
  4. package/dist/cli.mjs +19 -5
  5. package/dist/{daemon-BChkzDqQ.mjs → daemon-Bfucyf1o.mjs} +1 -1
  6. package/dist/{dev-DLwt3Brb.mjs → dev-yZMyQeUj.mjs} +4 -4
  7. package/dist/{init-BpitOqRQ.mjs → init-Dvaso7YO.mjs} +66 -2
  8. package/dist/{manifest-CS6krOTe.mjs → manifest-DvOmglFp.mjs} +7 -0
  9. package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
  10. package/dist/{plugin-DNc4Jpae.mjs → plugin-BsmG5i2X.mjs} +21 -3
  11. package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
  12. package/dist/{shot-Cyv3GN79.mjs → shot-kbR_xzJH.mjs} +7 -1
  13. package/docs/live-jam.md +173 -0
  14. package/docs/publish.md +270 -0
  15. package/docs/sharing.md +333 -0
  16. package/docs/slides.md +134 -0
  17. package/package.json +3 -1
  18. package/src/client/const.ts +13 -0
  19. package/src/client/content/chart-engine.ts +33 -0
  20. package/src/client/content/chart.tsx +100 -0
  21. package/src/client/content/index.tsx +25 -4
  22. package/src/client/content/slide.tsx +238 -0
  23. package/src/client/content/video.tsx +126 -0
  24. package/src/client/shell/App.tsx +30 -12
  25. package/src/client/shell/LockedApp.tsx +7 -2
  26. package/src/client/shell/Play.tsx +138 -24
  27. package/src/client/shell/Toolbar.tsx +12 -3
  28. package/src/client/shell/canvas/FrameNode.tsx +5 -3
  29. package/src/client/shell/hash.ts +3 -1
  30. package/src/client/shell/icons.tsx +2 -0
  31. package/src/client/shell/play-order.ts +22 -0
  32. package/src/client/shell/store.ts +29 -8
  33. package/src/client/shell/styles.css +17 -27
  34. package/src/client/stage/main.tsx +54 -3
  35. package/src/shared/utm.ts +3 -2
  36. package/templates/AGENTS-embedded.md +1 -0
  37. package/templates/AGENTS-studio.md +1 -0
  38. package/templates/instructions/publish.md +7 -0
  39. package/templates/instructions/reference/deck-layouts.md +230 -0
  40. package/templates/instructions/reference/deck-story.md +110 -0
  41. package/templates/instructions/slides.md +398 -0
@@ -11,9 +11,11 @@
11
11
  * next) stays prototype-only.
12
12
  */
13
13
  import { useEffect, useRef, useState } from 'react'
14
- import { useStore, BOARD_POLICY, BRANDING, CONFIG, PUBLISHED, SOURCE_REVEALED, boardLabel, boardLocked, cap, fetchBoardNames, modeAllowed, type Node } from './store.ts'
14
+ import { useStore, BOARD_POLICY, BRANDING, CONFIG, PUBLISHED, SOURCE_REVEALED, boardLabel, boardLocked, cap, fetchBoardNames, modeAllowed, playsAsSlides, SLIDE_INTRINSIC, type Node } from './store.ts'
15
+ import { deckOrder } from './play-order.ts'
15
16
  import { useComments } from './comments-store.ts'
16
17
  import { ROUTE } from '../const.ts'
18
+ import { poweredByUrl } from '../../shared/utm.ts'
17
19
  import { canvasCtl } from './canvas/ctl.ts'
18
20
  import { Tip } from './Tip.tsx'
19
21
  import { CommentButton, DevicePicker, HideUIButton, isHideUI, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
@@ -38,14 +40,51 @@ function playList(): string[] {
38
40
  return out
39
41
  }
40
42
 
43
+ /** Slides mode (v1.5): the deck's play order is the BOARD's reading order -
44
+ * (y, then x) over slide frames - frozen at entry, so what you see arranged
45
+ * is what plays, and a mid-show board edit never yanks the presenter. */
46
+ let frozenDeck: string[] | null = null
47
+ function currentDeck(): string[] {
48
+ if (frozenDeck) return frozenDeck
49
+ const s = useStore.getState()
50
+ const frames = new Map((s.manifest?.frames ?? []).map((f) => [f.id, f]))
51
+ frozenDeck = deckOrder(s.nodes, frames).deck
52
+ return frozenDeck
53
+ }
54
+
55
+ export function enterSlides(over?: { at?: string; device?: string; theme?: string }) {
56
+ const s = useStore.getState()
57
+ if (!modeAllowed(s.board, 'slides')) return
58
+ const frames = new Map((s.manifest?.frames ?? []).map((f) => [f.id, f]))
59
+ const { deck, excluded } = deckOrder(s.nodes, frames)
60
+ if (!deck.length) {
61
+ // 0.13.0 let a slides board play its ordinary frames as a prototype - keep
62
+ // that as the fallback so an upgraded (or locked) board never dead-ends
63
+ s.toast('no slide: true frames on this board - playing it as a prototype')
64
+ enterPlay(over, { legacyDeck: true }) // enterPlay never calls back into enterSlides: no recursion
65
+ return
66
+ }
67
+ if (excluded.length) s.toast(`${excluded.length} non-slide frame${excluded.length === 1 ? '' : 's'} on this board won't play in slides mode`)
68
+ frozenDeck = deck
69
+ const at = over?.at && deck.includes(over.at) ? over.at : deck[0]
70
+ const frame = s.manifest?.frames.find((f) => f.id === at)
71
+ const theme = (over?.theme && CONFIG.themes.includes(over.theme) ? over.theme : undefined) ?? frame?.theme ?? s.viewTheme
72
+ // the deck's own stage is the default device; a restored link may carry a
73
+ // picked viewport or fill - the standard prototype picker works in slides
74
+ const device = over?.device && (CONFIG.viewports[over.device] || over.device === 'fill' || over.device === 'slide') ? over.device : 'slide'
75
+ s.setPlay({ at, device, theme, slides: true })
76
+ }
77
+
41
78
  /** Enter play on the current board: first selected node starts, else the first node.
42
79
  * `over` (deep links) can pin the start frame, device, and theme - the start may be any
43
80
  * playable manifest frame, not just a board node: the stage itself allows data-goto to
44
81
  * off-board frames, so a link captured mid-flow must restore to the same screen. */
45
- export function enterPlay(over?: { at?: string; device?: string; theme?: string }) {
82
+ export function enterPlay(over?: { at?: string; device?: string; theme?: string }, opts?: { legacyDeck?: boolean }) {
46
83
  const s = useStore.getState()
47
- // the lock freezes what open pinned: a board locked to another mode never plays
48
- if (!modeAllowed(s.board, 'present')) return
84
+ // the lock freezes what open pinned: a board locked to another mode never
85
+ // plays - except the legacy-deck fallback, where the lock pinned `slides`
86
+ // and the prototype IS what 0.13.0 showed for that board
87
+ if (!opts?.legacyDeck && !modeAllowed(s.board, 'present')) return
49
88
  const list = playList()
50
89
  if (!list.length) { s.toast('nothing to play on this board'); return }
51
90
  const overAt = over?.at && s.manifest?.frames.some((f) => f.id === over.at && f.kind === 'tsx') ? over.at : undefined
@@ -100,7 +139,10 @@ async function switchPlayBoard(name: string) {
100
139
  if (name === s.board) return
101
140
  const device = s.play?.device // the device is the viewer's choice - it survives the switch
102
141
  await s.switchBoard(name)
103
- if (useStore.getState().board === name) enterPlay(device ? { device } : undefined)
142
+ if (useStore.getState().board !== name) return
143
+ const dev = device && device !== 'slide' ? { device } : undefined // the deck's own size does not follow to a prototype
144
+ if (playsAsSlides(name)) enterSlides(device ? { device } : undefined)
145
+ else enterPlay(dev)
104
146
  }
105
147
 
106
148
  /** Control channel for history restores: the popstate handler steers the mounted stage
@@ -115,7 +157,11 @@ export function PlayOverlay() {
115
157
  const play = useStore((s) => s.play)
116
158
  const board = useStore((s) => s.board)
117
159
  if (!play) return null
118
- return <PlayInner key={board} />
160
+ // slides is part of the key: the stage src is frozen at mount (slides=1 rides
161
+ // it), so crossing present↔slides MUST remount - a synced present stage would
162
+ // otherwise keep playing while the URL and store say slides. Focus↔present
163
+ // share a src shape and deliberately keep the live mount.
164
+ return <PlayInner key={play.slides ? `${board}:slides` : board} />
119
165
  }
120
166
 
121
167
  /** Board switcher dropdown in the play pill - the shared glass popover. */
@@ -254,7 +300,10 @@ function PlayInner() {
254
300
  const iframeRef = useRef<HTMLIFrameElement>(null)
255
301
  // the src is frozen at mount - navigation happens INSIDE the stage; device and theme
256
302
  // changes must never reload it (a phone does not remount when you flip dark mode)
257
- const src = useRef(play ? `${ROUTE}/stage/?at=${encodeURIComponent(play.at)}&theme=${encodeURIComponent(play.theme)}` : '')
303
+ const slParams = (p: { slides?: boolean }) => p.slides
304
+ ? `&slides=1${BOARD_POLICY[useStore.getState().board]?.transition === 'none' ? '&tr=none' : ''}`
305
+ : ''
306
+ const src = useRef(play ? `${ROUTE}/stage/?at=${encodeURIComponent(play.at)}&theme=${encodeURIComponent(play.theme)}${slParams(play)}` : '')
258
307
  const [win, setWin] = useState({ w: window.innerWidth, h: window.innerHeight })
259
308
  const [pillOpen, setPillOpen] = useState(true) // collapse (pill -> re-open fab), like the canvas
260
309
  const [stageReady, setStageReady] = useState(0) // bumps on each sh:stage-ready (reload) - re-drives the highlight
@@ -263,6 +312,10 @@ function PlayInner() {
263
312
  // page ground) applies when the board's declared type is doc; lock and deep
264
313
  // links both drop the canvas door (lock as policy, deep as the link's chrome)
265
314
  const focus = !!play?.focus
315
+ const slides = !!play?.slides
316
+ const rawChrome = BOARD_POLICY[board]?.chrome
317
+ const deckChrome = rawChrome === 'none' || rawChrome === 'minimal' ? rawChrome : 'full'
318
+ const trimmed = slides && deckChrome !== 'full' // minimal|none: strip the present tools
266
319
  const docPreset = focus && BOARD_POLICY[board]?.type === 'doc'
267
320
  const locked = boardLocked(board)
268
321
  const noDoor = locked || !!play?.deep
@@ -277,7 +330,7 @@ function PlayInner() {
277
330
  if (!playNav || !iframeRef.current) return
278
331
  const p = useStore.getState().play
279
332
  if (!p) return
280
- iframeRef.current.src = `${ROUTE}/stage/?at=${encodeURIComponent(p.at)}&theme=${encodeURIComponent(p.theme)}&r=${playNav}`
333
+ iframeRef.current.src = `${ROUTE}/stage/?at=${encodeURIComponent(p.at)}&theme=${encodeURIComponent(p.theme)}${slParams(p)}&r=${playNav}`
281
334
  }, [playNav])
282
335
  const applyUpdate = () => useStore.getState().applyPlayUpdate()
283
336
 
@@ -290,6 +343,7 @@ function PlayInner() {
290
343
  // the lock caps disclosure: a locked board has no way out into other modes,
291
344
  // and a deep-link visit carries no door on the surface (URL still works)
292
345
  if (noDoor) return
346
+ frozenDeck = null
293
347
  const { at } = useStore.getState().play ?? {}
294
348
  if (isHideUI()) toggleHideUI() // never strand the canvas with its chrome hidden
295
349
  useStore.getState().setPlay(null)
@@ -300,7 +354,7 @@ function PlayInner() {
300
354
 
301
355
  const setDevice = (name: string) => {
302
356
  const p = useStore.getState().play
303
- if (p && (CONFIG.viewports[name] || name === 'fill')) useStore.getState().setPlay({ ...p, device: name })
357
+ if (p && (CONFIG.viewports[name] || name === 'fill' || (name === 'slide' && p.slides))) useStore.getState().setPlay({ ...p, device: name })
304
358
  }
305
359
  const setTheme = (t: string) => {
306
360
  const p = useStore.getState().play
@@ -313,13 +367,33 @@ function PlayInner() {
313
367
  const step = (dir: 1 | -1) => {
314
368
  const p = useStore.getState().play
315
369
  if (!p || p.focus) return // focus is one frame - no walking
370
+ if (p.slides) {
371
+ // a deck never wraps: past the last slide is the end, before the first is
372
+ // the start. A variant swap can put `at` off the frozen deck - step from
373
+ // its group's deck position, not from the top
374
+ const deck = currentDeck()
375
+ let i = deck.indexOf(p.at)
376
+ if (i === -1) {
377
+ const frames = useStore.getState().manifest?.frames ?? []
378
+ const g = frames.find((f) => f.id === p.at)?.variantGroup
379
+ if (g) i = deck.findIndex((id) => frames.find((f) => f.id === id)?.variantGroup === g)
380
+ }
381
+ const next = i === -1 ? deck[0] : deck[Math.min(deck.length - 1, Math.max(0, i + dir))]
382
+ if (next && next !== p.at) goTo(next)
383
+ return
384
+ }
316
385
  const list = playList()
317
386
  if (!list.length) return
318
387
  const i = list.indexOf(p.at)
319
388
  // an off-board frame has no position: → restarts, ← goes to the last board frame
320
389
  goTo(i === -1 ? (dir === 1 ? list[0] : list[list.length - 1]) : list[(i + dir + list.length) % list.length])
321
390
  }
322
- const restart = () => { if (useStore.getState().play?.focus) return; const list = playList(); if (list.length) goTo(list[0]) }
391
+ const restart = () => {
392
+ const p = useStore.getState().play
393
+ if (p?.focus) return
394
+ const list = p?.slides ? currentDeck() : playList()
395
+ if (list.length) goTo(list[0])
396
+ }
323
397
 
324
398
  /** Variant siblings of the CURRENT frame present on this board: the
325
399
  * review question is "which direction is better on THIS screen" - switch in place,
@@ -434,6 +508,7 @@ function PlayInner() {
434
508
  }
435
509
  if (key === 'ArrowRight') { step(1); return }
436
510
  if (key === 'ArrowLeft') { step(-1); return }
511
+ if (key === ' ' && useStore.getState().play?.slides) { step(1); return }
437
512
  if (key === '[') { switchVariant(-1); return }
438
513
  if (key === ']') { switchVariant(1); return }
439
514
  if (key === 'r') { restart(); return }
@@ -477,12 +552,16 @@ function PlayInner() {
477
552
 
478
553
  // doc preset: reading width, natural document scrolling - the iframe takes the
479
554
  // window's height at min(width, 860) CSS pixels, unscaled, and scrolls itself
555
+ const slideDev = slides && play.device === 'slide'
480
556
  const vp = docPreset ? { width: Math.min(win.w, 860), height: win.h }
481
557
  : fill ? { width: win.w, height: win.h }
482
- : CONFIG.viewports[play.device] ?? Object.values(CONFIG.viewports)[0]
483
- const scale = fill || docPreset ? 1 : Math.min(1, (win.w - 96) / vp.width, (win.h - 128) / vp.height)
558
+ : slideDev ? { width: SLIDE_INTRINSIC.width, height: SLIDE_INTRINSIC.height }
559
+ : CONFIG.viewports[play.device] ?? (slides ? { width: SLIDE_INTRINSIC.width, height: SLIDE_INTRINSIC.height } : Object.values(CONFIG.viewports)[0])
560
+ const scale = fill || docPreset ? 1
561
+ : slideDev ? Math.min(1, (win.w - 48) / vp.width, (win.h - (deckChrome === 'none' ? 48 : 88)) / vp.height)
562
+ : Math.min(1, (win.w - 96) / vp.width, (win.h - 128) / vp.height)
484
563
  const names = Object.keys(CONFIG.viewports)
485
- const list = focus ? [play.at] : playList()
564
+ const list = focus ? [play.at] : slides ? currentDeck() : playList()
486
565
  const pos = list.indexOf(play.at)
487
566
  const variants = variantList()
488
567
  const frameEntry = useStore.getState().manifest?.frames.find((f) => f.id === play.at)
@@ -498,7 +577,13 @@ function PlayInner() {
498
577
  <div className={`sh-play${fill || docPreset ? ' fill' : ''}${docPreset ? ` doc t-${play.theme}` : ''}`}>
499
578
  {/* device + comment overlay share ONE flex-centered box; the overlay is inset:0 over
500
579
  it (a sibling of the clipping .dev, so a thread card is never eaten by overflow) */}
501
- <div className="sh-play-stage" style={{ width: dw, height: dh }}>
580
+ <div className="sh-play-stage" style={{ width: dw, height: dh }}
581
+ onClick={slides ? (e) => {
582
+ // background advance: never on controls, links, media, comments, or goto targets
583
+ const t = e.target as Element
584
+ if (t.closest('a, button, input, textarea, select, video, [contenteditable], [data-goto], .cm-card, .cm-pin, .cm-stack, .sh-pill')) return
585
+ step(1)
586
+ } : undefined}>
502
587
  <div className="dev" data-theme={play.theme} style={{ width: dw, height: dh }}>
503
588
  <iframe
504
589
  ref={iframeRef}
@@ -510,16 +595,32 @@ function PlayInner() {
510
595
  <PlayComments iframe={iframeRef} frameId={play.at} vp={vp} dw={dw} dh={dh} ready={stageReady} />
511
596
  </div>
512
597
 
513
- {!focus && list.length > 1 && (
514
- <div className="sh-play-hint">← → to step · <kbd>c</kbd> to comment</div>
598
+ {/* the slim progress strip: the MINIMAL trim's one affordance - full
599
+ chrome already carries progress in the walker, so no duplicate */}
600
+ {slides && deckChrome === 'minimal' && (
601
+ <div className="sh-slides-strip" aria-label={`slide ${pos + 1} of ${list.length}`}>
602
+ <span className="n">{pos === -1 ? '·' : pos + 1} / {list.length}</span>
603
+ <span className="bar"><i style={{ width: `${list.length > 1 ? Math.max(2, ((pos + 1) / list.length) * 100) : 100}%` }} /></span>
604
+ </div>
515
605
  )}
516
606
 
517
607
  {/* top-left: brand + place (the present chrome's identity pill). A deep-link
518
608
  focus visit names only the frame - single-frame chrome, no board (04 §2.35);
519
609
  branding: false strips the mark (invariant 21). */}
610
+ {/* the brand/place pill: in slides it belongs ONLY to a locked deck share
611
+ (the link people open just to view and comment - no canvas door, so
612
+ the pill is what names the deck). Everywhere else the toolbars are
613
+ enough - a prototype session knows what it is playing */}
614
+ {(!slides || (locked && deckChrome !== 'none')) && (
520
615
  <nav className={`sh-pill sh-play-pill sh-play-brand${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
521
616
  {BRANDING && <>
522
- <span className="sh-play-mark"><ParallelogramDuoIcon size={19} /><span className="wd">Marver</span></span>
617
+ <a
618
+ className="sh-play-mark"
619
+ href={poweredByUrl(CONFIG.projectName, PUBLISHED ? 'published-canvas' : 'dev-canvas', 'play-brand')}
620
+ target="_blank" rel="noreferrer" aria-label="marver.design"
621
+ >
622
+ <ParallelogramDuoIcon size={19} /><span className="wd">Marver</span>
623
+ </a>
523
624
  <i className="sep" />
524
625
  </>}
525
626
  {focus && <SpecDocIcon size={15} style={{ flex: 'none', opacity: .7, marginRight: -2 }} />}
@@ -529,23 +630,32 @@ function PlayInner() {
529
630
  </Tip>
530
631
  )}
531
632
  </nav>
633
+ )}
532
634
 
635
+ {/* the toolbar. Slides trims it to the doctrine's chrome: comment
636
+ affordance + the canvas door (+ a pending update) - laser, theme,
637
+ device, position, and collapse are present-mode tools. chrome: none
638
+ renders NO pill at all (and no FAB below) - not hidden, absent. */}
639
+ {!(slides && deckChrome === 'none') && (
533
640
  <nav className={`sh-pill sh-play-pill${pillOpen ? '' : ' closed'}`} aria-hidden={!pillOpen}>
534
- {!focus && list.length > 1 && <>
641
+ {!focus && !slides && list.length > 1 && <>
535
642
  <span className="sh-play-pos">{pos === -1 ? '·' : pos + 1} / {list.length}</span>
536
643
  <i className="sep" />
537
644
  </>}
538
645
  <CommentButton />
539
- <LaserButton />
540
- <i className="sep" />
541
- {!docPreset && <DevicePicker value={fill ? 'fill' : play.device} onSelect={(n) => n && setDevice(n)} includeFill hint={deviceHint} dark />}
542
- <ThemePicker value={play.theme} onSelect={setTheme} hint="D" dark />
646
+ {!trimmed && <>
647
+ <LaserButton />
648
+ <i className="sep" />
649
+ {!docPreset && <DevicePicker value={fill ? 'fill' : play.device} onSelect={(n) => n && setDevice(n)} includeFill includeSlide={slides} hint={deviceHint} dark />}
650
+ <ThemePicker value={play.theme} onSelect={setTheme} hint="D" dark />
651
+ </>}
543
652
  {playUpdateRevision && <>
544
653
  <i className="sep" />
545
654
  <Tip side="bottom" label={<><b>Update ready</b><span>an edit landed - reload this prototype</span></>}>
546
655
  <button className="sh-pill-btn sh-play-update" onClick={applyUpdate}><ReloadIcon size={13} /><span>Update</span></button>
547
656
  </Tip>
548
657
  </>}
658
+ {!trimmed && <>
549
659
  <i className="sep" />
550
660
  <HideUIButton />
551
661
  <Tip side="bottom" label={<><b>Collapse toolbar</b><span>H hides everything · ⌘/</span></>}>
@@ -553,6 +663,7 @@ function PlayInner() {
553
663
  <PanelFilledIcon size={17} style={{ transform: 'rotate(90deg)' }} />
554
664
  </button>
555
665
  </Tip>
666
+ </>}
556
667
  {/* the canvas door: absent on a locked board (policy) and on a frame
557
668
  deep link (the link's chrome) - not hidden, not rendered */}
558
669
  {!noDoor && (
@@ -560,7 +671,7 @@ function PlayInner() {
560
671
  <button className="sh-pill-btn" onClick={exit}><GridIcon size={16} /></button>
561
672
  </Tip>
562
673
  )}
563
- {BRANDING && PUBLISHED && (
674
+ {BRANDING && PUBLISHED && !trimmed && (
564
675
  <Tip side="bottom" label={<b>Open in app</b>}>
565
676
  <a className="sh-pill-btn" href="https://app.marver.design" target="_blank" rel="noopener" aria-label="Open in app">
566
677
  <BrowsersIcon size={16} />
@@ -568,12 +679,15 @@ function PlayInner() {
568
679
  </Tip>
569
680
  )}
570
681
  </nav>
682
+ )}
683
+ {!(slides && deckChrome === 'none') && (
571
684
  <Tip side="bottom" label={<><b>Open toolbar</b><span>⌘/</span></>}>
572
685
  <button className={`sh-pill-fab${pillOpen ? ' hidden' : ''}`} onClick={() => setPillOpen(true)}
573
686
  aria-hidden={pillOpen} tabIndex={pillOpen ? -1 : 0}><PanelHollowIcon size={18} style={{ transform: 'rotate(90deg)' }} /></button>
574
687
  </Tip>
688
+ )}
575
689
 
576
- {!focus && <div className="sh-play-nav">
690
+ {!focus && !trimmed && <div className="sh-play-nav">
577
691
  <Tip inv label={<><b>Restart</b><span className="k">R</span></>}>
578
692
  <button onClick={restart}><ReloadIcon size={14} /></button>
579
693
  </Tip>
@@ -11,7 +11,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'
11
11
  import { useStore, CONFIG, SOURCE_REVEALED, cap } from './store.ts'
12
12
  import { useComments } from './comments-store.ts'
13
13
  import { Tip } from './Tip.tsx'
14
- import { CaretIcon, CheckIcon, CommentIcon, DevicesIcon, EyeSlashIcon, FrameCornersIcon, LaserIcon, MoonIcon, SunIcon, deviceIcon } from './icons.tsx'
14
+ import { CaretIcon, CheckIcon, CommentIcon, DevicesIcon, EyeSlashIcon, FrameCornersIcon, LaserIcon, MoonIcon, SlideFrameIcon, SunIcon, deviceIcon } from './icons.tsx'
15
15
 
16
16
  const commentsStore = () => useComments.getState()
17
17
 
@@ -153,18 +153,20 @@ export function HideUIButton() {
153
153
  /** Controlled device dropdown. `value` is the active device name ('fill' allowed when
154
154
  * includeFill), null = default/mixed. The trigger + menu chrome is shared; the adapter
155
155
  * owns what select does. */
156
- export function DevicePicker({ value, onSelect, includeDefault, includeFill, hint, dark }: {
156
+ export function DevicePicker({ value, onSelect, includeDefault, includeFill, includeSlide, hint, dark }: {
157
157
  value: string | null
158
158
  onSelect: (name: string | null) => void
159
159
  includeDefault?: boolean
160
160
  includeFill?: boolean
161
+ /** slides mode: the deck's own 1280×720 stage as a first-class device */
162
+ includeSlide?: boolean
161
163
  hint?: ReactNode
162
164
  dark?: boolean
163
165
  }) {
164
166
  const pop = usePopover()
165
167
  const entries = Object.entries(CONFIG.viewports)
166
168
  const pick = (name: string | null) => { onSelect(name); pop.setOpen(false) }
167
- const triggerIcon = value === 'fill' ? <FrameCornersIcon size={16} /> : deviceIcon(value, 16)
169
+ const triggerIcon = value === 'fill' ? <FrameCornersIcon size={16} /> : value === 'slide' ? <SlideFrameIcon size={16} /> : deviceIcon(value, 16)
168
170
  return (
169
171
  <div className="sh-theme" ref={pop.boxRef}>
170
172
  <Tip side="bottom" label={<><b>Device view</b>{hint && <span>{hint}</span>}</>}>
@@ -174,6 +176,13 @@ export function DevicePicker({ value, onSelect, includeDefault, includeFill, hin
174
176
  </button>
175
177
  </Tip>
176
178
  <Popover pop={pop} dark={dark}>
179
+ {includeSlide && <>
180
+ <button onClick={() => pick('slide')} title="1280 × 720">
181
+ <SlideFrameIcon size={15} /><span>Slide</span>
182
+ {value === 'slide' && <CheckIcon size={13} className="chk" />}
183
+ </button>
184
+ <i className="div" />
185
+ </>}
177
186
  {includeDefault && <>
178
187
  <button onClick={() => pick(null)}>
179
188
  <DevicesIcon size={15} /><span>Default</span><kbd>0</kbd>
@@ -1,6 +1,6 @@
1
1
  import { memo, useCallback, useEffect, useRef } from 'react'
2
2
  import { cap, frameUrl, useStore, CONFIG, type Node } from '../store.ts'
3
- import { CopyIcon, IntentGlyph, ParallelogramFillIcon, ReloadIcon, XIcon } from '../icons.tsx'
3
+ import { CopyIcon, IntentGlyph, ParallelogramFillIcon, ReloadIcon, SlideFrameIcon, XIcon } from '../icons.tsx'
4
4
  import { CommentLayer } from '../Comments.tsx'
5
5
  import { useComments } from '../comments-store.ts'
6
6
  import { threadHostKey } from '../keys.ts'
@@ -361,8 +361,10 @@ export const FrameNode = memo(function FrameNode({ node }: { node: Node }) {
361
361
  </div>
362
362
  )}
363
363
  <div className="sh-node-head sh-no-pan" onPointerDown={(e) => drag(e, 'move')} title={frame.file}>
364
- {/* content frames carry their intent glyph in the chrome */}
365
- {frame.intent && <IntentGlyph intent={frame.intent} size={12} className="iicon sh-no-pan" aria-label={frame.intent} />}
364
+ {/* the chrome badge: slide first, else the content intent glyph */}
365
+ {frame.slide
366
+ ? <SlideFrameIcon size={12} className="iicon sh-no-pan" role="img" aria-hidden={false} aria-label="slide" />
367
+ : frame.intent && <IntentGlyph intent={frame.intent} size={12} className="iicon sh-no-pan" aria-label={frame.intent} />}
366
368
  <span className="id sh-no-pan">{frame.title ?? frame.id}</span>
367
369
  <span className="dim sh-no-pan">{Math.round(node.w)} · {node.theme}</span>
368
370
  </div>
@@ -20,7 +20,7 @@ export interface HashState {
20
20
  n?: string[]
21
21
  c?: string
22
22
  invite?: string
23
- play?: { at?: string; device?: string; theme?: string }
23
+ play?: { at?: string; device?: string; theme?: string; slides?: boolean }
24
24
  /** A frame deep link: lands that visit in focus, over every other rule. */
25
25
  focus?: { at: string; device?: string; theme?: string }
26
26
  }
@@ -58,6 +58,7 @@ export function parseHash(hash: string = location.hash): HashState {
58
58
  at: params.get('at') ?? undefined,
59
59
  device: params.get('device') ?? undefined,
60
60
  theme: params.get('theme') ?? undefined,
61
+ ...(params.get('slides') === '1' ? { slides: true } : {}),
61
62
  },
62
63
  }
63
64
  } catch { return {} } // a malformed hash (#/b/%) is a default view, never a crash
@@ -75,6 +76,7 @@ export function buildHash(s: HashState): string {
75
76
  const p = new URLSearchParams({ at: s.play.at })
76
77
  if (s.play.device) p.set('device', s.play.device)
77
78
  if (s.play.theme) p.set('theme', s.play.theme)
79
+ if (s.play.slides) p.set('slides', '1')
78
80
  return `#/p/${s.board}?${p}`
79
81
  }
80
82
  if (!s.board || (s.board === 'all-scenes' && !s.n?.length && !s.c)) return '#/'
@@ -71,6 +71,8 @@ export function IntentGlyph({ intent, size = 14, ...rest }: IconProps & { intent
71
71
  : ContentBlocksIcon
72
72
  return <I size={size} role="img" aria-hidden={false} aria-label={intent} {...({ title: intent } as object)} {...rest} />
73
73
  }
74
+ /** The slide badge (v1.5): a 16:9 stage with the play wedge - a deck frame. */
75
+ export const SlideFrameIcon = icon(<path d="M192,48H64A16,16,0,0,0,48,64V192a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64A16,16,0,0,0,192,48Zm0,144H64V64H192V192ZM240,56V200a8,8,0,0,1-16,0V56a8,8,0,0,1,16,0ZM32,56V200a8,8,0,0,1-16,0V56a8,8,0,0,1,16,0Z" />)
74
76
  export const PanelHollowIcon = icon(<><rect x="32" y="48" width="192" height="160" rx="32" fill="none" stroke="currentColor" strokeWidth="16" /><rect x="72" y="88" width="48" height="80" rx="24" fill="none" stroke="currentColor" strokeWidth="14" /></>)
75
77
  /** Board glyphs: CardsThree = the built-in `all-scenes` board; Cards = curated boards. */
76
78
  export const CardsThreeIcon = icon(<path d="M208,88H48a16,16,0,0,0-16,16v96a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V104A16,16,0,0,0,208,88Zm0,112H48V104H208v96ZM48,64a8,8,0,0,1,8-8H200a8,8,0,0,1,0,16H56A8,8,0,0,1,48,64ZM64,32a8,8,0,0,1,8-8H184a8,8,0,0,1,0,16H72A8,8,0,0,1,64,32Z" />)
@@ -0,0 +1,22 @@
1
+ /** The deck order (v1.5), pure: slide tsx frames in the board's reading
2
+ * order - (y, then x, then original index), deduped by frame id. Excluded
3
+ * frames come back for a ONE-TIME warning at entry; this file stays
4
+ * dependency-free so unit tests import it without React or virtual modules. */
5
+ export interface OrderNode { frame: string; x: number; y: number; missing?: boolean }
6
+ export interface OrderFrame { id: string; kind: 'tsx' | 'html'; slide?: boolean }
7
+
8
+ export function deckOrder(
9
+ nodes: OrderNode[], frames: Map<string, OrderFrame>,
10
+ ): { deck: string[]; excluded: string[] } {
11
+ const rows = nodes
12
+ .map((n, i) => ({ n, i, f: frames.get(n.frame) }))
13
+ .filter(({ n, f }) => !n.missing && !!f)
14
+ const excluded = rows.filter(({ f }) => f!.kind !== 'tsx' || !f!.slide).map(({ f }) => f!.id)
15
+ const seen = new Set<string>()
16
+ const deck = rows
17
+ .filter(({ f }) => f!.kind === 'tsx' && !!f!.slide)
18
+ .sort((a, b) => a.n.y - b.n.y || a.n.x - b.n.x || a.i - b.i)
19
+ .filter(({ n }) => !seen.has(n.frame) && (seen.add(n.frame), true))
20
+ .map(({ n }) => n.frame)
21
+ return { deck, excluded: [...new Set(excluded)] }
22
+ }
@@ -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
@@ -736,22 +736,6 @@ body.sh-commenting .sh-lean { opacity: 0 }
736
736
  .sh-play-chip:hover { color: #f5f5f7 }
737
737
  .sh-play-chip.idle { opacity: 0; pointer-events: none; transform: scale(.6) }
738
738
 
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
739
 
756
740
  /* ---- Focus: silent for pointer + shortcut use; ONE branded inset ring, and only while
757
741
  the user is actually Tab-navigating (body.sh-kbd, armed by Tab, disarmed by pointer).
@@ -861,22 +845,19 @@ body.sh-hide-ui .sh-play-nav { display: none !important }
861
845
 
862
846
  /* ---- present/focus chrome (sharing v1): brand pill top-left, counter in the
863
847
  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) }
848
+ /* the brand pill matches the toolbar pill to the pixel: the SAME .sh-pill
849
+ padding (5px) and the SAME 34px inner height as .sh-pill-btn - its two
850
+ children are sized like buttons, so text metrics never set the height */
851
+ .sh-play-brand.sh-pill { left: var(--edge); right: auto; transform-origin: 22px 22px; padding: 5px 9px 5px 5px }
852
+ .sh-play-mark { display: inline-flex; align-items: center; gap: 7px; height: 34px; padding: 0 8px 0 10px; border-radius: 999px;
853
+ color: rgba(245, 245, 247, .88); text-decoration: none; cursor: pointer }
854
+ .sh-play-mark:hover { background: rgba(255, 255, 255, .1); color: #f5f5f7 }
866
855
  .sh-play-mark svg { color: #6db3ff }
867
856
  .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);
857
+ .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
858
  max-width: 143px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0 6px }
870
859
  .sh-play-pos { font: 500 12.5px -apple-system, system-ui, sans-serif; color: rgba(245, 245, 247, .64);
871
860
  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
861
  /* doc preset: the page IS the surface - reading column on a calm ground, no
881
862
  device shell, natural document scrolling inside the frame */
882
863
  .sh-play.doc { background: #f4f4f6 }
@@ -1249,3 +1230,12 @@ body.sh-hide-ui .sh-play-hint { display: none !important }
1249
1230
  color: rgba(255,255,255,.92); font: 700 13px/1 -apple-system, system-ui, sans-serif; letter-spacing: .02em }
1250
1231
  /* the title row is flex, which swallows the JSX whitespace - the verb carries its own gap */
1251
1232
  .sh-jam-frame .v { color: var(--glass-ink-2); font-weight: 500; margin-left: 4px; flex: none }
1233
+
1234
+ /* v1.5 slides mode: the slim progress strip + the zero-chrome escape hatch */
1235
+ .sh-slides-strip { position: fixed; left: 50%; bottom: 14px; transform: translateX(-50%); z-index: 30;
1236
+ display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-radius: 999px;
1237
+ background: var(--glass); border: 1px solid var(--glass-brd); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur) }
1238
+ .sh-slides-strip .n { font: 600 12px/1 -apple-system, system-ui, sans-serif; color: var(--glass-ink-2); white-space: nowrap }
1239
+ .sh-slides-strip .bar { width: 140px; height: 3px; border-radius: 999px; background: var(--glass-hover); overflow: hidden }
1240
+ .sh-slides-strip .bar i { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width .25s ease }
1241
+ @media (max-width: 480px) { .sh-slides-strip .bar { width: 80px } }