@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
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The Slide root (v1.5) - the ONE slide primitive. Owns the 1280×720 stage,
3
+ * the tokens, the type roles, and the rest-state motion reset. Recipes are
4
+ * prose in instructions/slides.md; markup composes INSIDE <Slide> with the
5
+ * project's own classes - nothing else is an API.
6
+ *
7
+ * Tokens read documented host variables (--marver-slide-*) from the frame's
8
+ * theme when the host defines them, and fall back to the palette - an
9
+ * unthemed repo still gets a coherent deck.
10
+ *
11
+ * Motion law: at rest (canvas), everything inside a slide is STILL - CSS
12
+ * animations and transitions are suspended. Slides mode lifts the reset by
13
+ * setting `data-sl-play` on <html> (the stage owns that flag), which also
14
+ * arms the entrance presets (`data-animate`, run once after the swap
15
+ * settles - the stage adds `data-sl-entered`).
16
+ */
17
+ import { createContext, useContext, useEffect, useRef, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react'
18
+ import { FONT_STACK } from './palette.ts'
19
+
20
+ export const SLIDE_W = 1280
21
+ export const SLIDE_H = 720
22
+ /** Stage margins: wide at the sides, tight top and bottom (content 1104x632). */
23
+ const PAD_X = 88
24
+ const PAD_Y = 44
25
+
26
+ /** Img (and anything else that cares) asks: am I inside a slide? */
27
+ export const SlideCtx = createContext(false)
28
+ export const useInSlide = () => useContext(SlideCtx)
29
+
30
+ /**
31
+ * Is slides mode live? The STAGE owns the answer: it stamps `data-sl-play`
32
+ * on <html> (boot: the `slides` URL param; thereafter its own messages).
33
+ * CSS reacts to the attribute natively; React components (Chart's entrance,
34
+ * Video's player mount) subscribe here - a MutationObserver over the
35
+ * documentElement attribute, so the contract is one attribute, one owner,
36
+ * observable by anyone.
37
+ */
38
+ const subscribePlay = (cb: () => void) => {
39
+ if (typeof document === 'undefined') return () => {}
40
+ const mo = new MutationObserver(cb)
41
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-sl-play', 'data-sl-entered'] })
42
+ return () => mo.disconnect()
43
+ }
44
+ const readPlay = () => typeof document !== 'undefined' && document.documentElement.hasAttribute('data-sl-play')
45
+ export const useSlidePlay = (): boolean => useSyncExternalStore(subscribePlay, readPlay, () => false)
46
+
47
+ const SLIDE_CSS = `
48
+ /* the frame document must not pad the stage: a 1280px root in a margined
49
+ body overflows the intrinsic by 16px - reset where a slide lives. The body
50
+ also paints the slide ground, so the letterbox around a scaled stage is
51
+ seamless instead of a default-white halo. */
52
+ body:has(.sl-root) { margin: 0; background: var(--marver-slide-ground, #ffffff) }
53
+ .dark body:has(.sl-root), [data-theme="dark"] body:has(.sl-root) { background: var(--marver-slide-ground-dark, #101014) }
54
+ .sl-root, .sl-root * { box-sizing: border-box }
55
+ .sl-root {
56
+ --sl-ink: var(--marver-slide-ink, #18181b);
57
+ --sl-ground: var(--marver-slide-ground, #ffffff);
58
+ --sl-accent: var(--marver-slide-accent, #0088ff);
59
+ --sl-muted: var(--marver-slide-muted, rgba(24, 24, 27, .55));
60
+ --sl-tempo: var(--marver-slide-tempo, 350ms);
61
+ --sl-font: var(--marver-slide-font, ${FONT_STACK});
62
+ /* THE STAGE MARGINS, in px and ASYMMETRIC - the shape every well-made deck
63
+ uses. Generous at the sides, tighter top and bottom, so the title sits
64
+ high, the footnote sits low, and the middle band is the tallest thing on
65
+ the slide. Percentages are wrong here twice over: they would resolve
66
+ against this absolutely positioned box's containing block (the viewport,
67
+ not the stage), and one value for all four sides squeezes the middle. */
68
+ --sl-pad-x: var(--marver-slide-pad-x, ${PAD_X}px);
69
+ --sl-pad-y: var(--marver-slide-pad-y, ${PAD_Y}px);
70
+ --sl-margin: var(--sl-pad-x); /* the side margin, for author math */
71
+ /* THE FIT: authored at exactly ${SLIDE_W}x${SLIDE_H}, then scaled and
72
+ centered to the largest box the viewport gives it - fill window, any
73
+ device, a canvas node resized to a phone, any published viewer's screen.
74
+ One coordinate system, so the author's px, Tailwind classes, and charts
75
+ all scale together. Pure CSS (tan(atan2(a, b)) is the unitless ratio
76
+ a / b), so a lean cover - which runs no JS - reflows to the right scale
77
+ the moment its node is resized. 1 wherever the viewport IS the stage.
78
+ Declared under @supports below: an engine without CSS trig would keep
79
+ the invalid tokens, defeat the var() fallback, and invalidate the whole
80
+ transform - unscaled AND uncentered. */
81
+ --sl-fit: 1;
82
+ /* translate-center, not inset+margin:auto - an overconstrained absolute
83
+ box (1280px stage in a 390px viewport) resolves margins to 0 and the
84
+ scaled slide drifts off-center; translate(-50%,-50%) centers at ANY size */
85
+ position: absolute; left: 50%; top: 50%;
86
+ width: ${SLIDE_W}px; height: ${SLIDE_H}px; overflow: hidden;
87
+ transform: translate(-50%, -50%) scale(var(--sl-fit, 1)); transform-origin: center center;
88
+ background: var(--sl-ground); color: var(--sl-ink);
89
+ font-family: var(--sl-font);
90
+ padding: var(--sl-pad-y) var(--sl-pad-x); box-sizing: border-box;
91
+ display: flex; flex-direction: column; justify-content: center; gap: 28px;
92
+ }
93
+ @supports (width: calc(1px * tan(atan2(1px, 1px)))) {
94
+ .sl-root { --sl-fit: min(tan(atan2(100vw, ${SLIDE_W}px)), tan(atan2(100vh, ${SLIDE_H}px))) }
95
+ }
96
+ .dark .sl-root, [data-theme="dark"] .sl-root {
97
+ --sl-ink: var(--marver-slide-ink-dark, #f5f5f7);
98
+ --sl-ground: var(--marver-slide-ground-dark, #101014);
99
+ --sl-muted: var(--marver-slide-muted-dark, rgba(245, 245, 247, .55));
100
+ }
101
+ /* type roles - fixed values, one coordinate system with the 1280x720 stage.
102
+ sl-display is the ONE sanctioned oversize: the big-number stat, a section
103
+ numeral, the manifesto line - never running text. */
104
+ .sl-display { font-size: 160px; line-height: 1; font-weight: 800; letter-spacing: -.03em; margin: 0; font-variant-numeric: tabular-nums }
105
+ /* sl-stat is the ROW size: three or four figures side by side, where one
106
+ sl-display would not fit and sl-assertion would not read as a number. */
107
+ .sl-stat { font-size: 88px; line-height: 1.02; font-weight: 700; letter-spacing: -.03em; margin: 0; font-variant-numeric: tabular-nums }
108
+ .sl-assertion { font-size: 56px; line-height: 1.08; font-weight: 750; letter-spacing: -.02em; margin: 0 }
109
+ .sl-support { font-size: 30px; line-height: 1.25; font-weight: 500; margin: 0 }
110
+ .sl-body { font-size: 24px; line-height: 1.45; margin: 0 }
111
+ .sl-caption { font-size: 18px; line-height: 1.4; color: var(--sl-muted); margin: 0 }
112
+
113
+ /* THE MOTION RESET - at rest, a slide is still: animation NONE (not paused -
114
+ none is deterministic; paused can freeze mid-keyframe) and no transitions.
115
+ Slides mode (the stage sets data-sl-play on <html>) lifts it. This governs
116
+ EVERY descendant - spinners and loaders included - and that is the Slide
117
+ contract, documented in instructions/slides.md. */
118
+ :root:not([data-sl-play]) .sl-root, :root:not([data-sl-play]) .sl-root *,
119
+ :root:not([data-sl-play]) .sl-root *::before, :root:not([data-sl-play]) .sl-root *::after {
120
+ animation: none !important;
121
+ transition: none !important;
122
+ }
123
+
124
+ /* Entrance presets: inert until the stage marks the swap settled. An element
125
+ carrying a morph name must not carry data-animate (one transform owner) -
126
+ the doctrine says so; the selector below cannot check it, the review gate does. */
127
+ [data-sl-play] .sl-root [data-animate] { opacity: 0 }
128
+ /* ONE tempo per deck: the token times the entrances AND the view-transition
129
+ morphs between slides (the stage document is this document). */
130
+ ::view-transition-group(*), ::view-transition-old(root), ::view-transition-new(root) {
131
+ animation-duration: var(--marver-slide-tempo, 350ms);
132
+ }
133
+ [data-sl-play][data-sl-entered] .sl-root [data-animate] {
134
+ opacity: 1; animation-duration: var(--sl-tempo); animation-timing-function: cubic-bezier(.2, .7, .2, 1);
135
+ animation-fill-mode: both;
136
+ }
137
+ [data-sl-play][data-sl-entered] .sl-root [data-animate="fade-up"] { animation-name: sl-fade-up }
138
+ [data-sl-play][data-sl-entered] .sl-root [data-animate="fade"] { animation-name: sl-fade }
139
+ [data-sl-play][data-sl-entered] .sl-root [data-animate="scale-in"] { animation-name: sl-scale-in }
140
+ [data-sl-play][data-sl-entered] .sl-root [data-animate-delay="1"] { animation-delay: 80ms }
141
+ [data-sl-play][data-sl-entered] .sl-root [data-animate-delay="2"] { animation-delay: 160ms }
142
+ [data-sl-play][data-sl-entered] .sl-root [data-animate-delay="3"] { animation-delay: 240ms }
143
+ @keyframes sl-fade-up { from { opacity: 0; transform: translateY(18px) } to { opacity: 1; transform: none } }
144
+ @keyframes sl-fade { from { opacity: 0 } to { opacity: 1 } }
145
+ @keyframes sl-scale-in { from { opacity: 0; transform: scale(.94) } to { opacity: 1; transform: none } }
146
+ @media (prefers-reduced-motion: reduce) {
147
+ [data-sl-play] .sl-root [data-animate] { opacity: 1; animation: none !important }
148
+ }
149
+ `
150
+
151
+ function ensureSlideStyles() {
152
+ // keyed by the DOCUMENT, not a module boolean - HMR reloads and multiple
153
+ // roots must not double- or under-inject
154
+ if (typeof document === 'undefined' || document.querySelector('style[data-mv-slide]')) return
155
+ const el = document.createElement('style')
156
+ el.setAttribute('data-mv-slide', '')
157
+ el.textContent = SLIDE_CSS
158
+ document.head.appendChild(el)
159
+ }
160
+
161
+ export function Slide({ children, style }: { children?: ReactNode; style?: CSSProperties }) {
162
+ ensureSlideStyles()
163
+ const ref = useRef<HTMLDivElement>(null)
164
+ // DEV overflow marker: a slide that outgrows its stage is a VISIBLE defect
165
+ // (outline + console + data-sl-over), never a silent clip decision left to
166
+ // chance. Development only - a published deck paints no diagnostics.
167
+ // Two tests, both in the stage's LAYOUT space (rects are post-transform,
168
+ // the fit scales the stage, so rect offsets are divided by the scale):
169
+ // 1. escape - a box leaving the 1280x720 stage. Measured against the
170
+ // STAGE, not the padded content box: full-bleed images and clipped
171
+ // photos break the margins on purpose.
172
+ // 2. collision - a flex/grid child outgrowing its parent's content box.
173
+ // Inside a flex column a body at flex:1 keeps its own box while ITS
174
+ // children spill over the neighbouring bands, all still inside the
175
+ // stage, which test 1 never sees.
176
+ useEffect(() => {
177
+ if (!import.meta.env.DEV) return
178
+ const el = ref.current
179
+ if (!el || typeof ResizeObserver === 'undefined') return
180
+ let last: boolean | null = null
181
+ let raf = 0
182
+ const check = () => {
183
+ raf = 0
184
+ const r = el.getBoundingClientRect()
185
+ const k = el.offsetWidth ? r.width / el.offsetWidth : 1
186
+ const pad = (v: string) => parseFloat(v) || 0
187
+ const W = el.offsetWidth, H = el.offsetHeight
188
+ let over = el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1
189
+ let culprit: Element | null = null
190
+ const all = el.querySelectorAll('*')
191
+ const n = Math.min(all.length, 600)
192
+ for (let i = 0; !over && i < n; i++) {
193
+ const c = all[i]
194
+ const b = c.getBoundingClientRect()
195
+ if (!b.width && !b.height) continue
196
+ const y0 = (b.top - r.top) / k, y1 = (b.bottom - r.top) / k
197
+ const x0 = (b.left - r.left) / k, x1 = (b.right - r.left) / k
198
+ if (y1 > H + 1.5 || y0 < -1.5 || x1 > W + 1.5 || x0 < -1.5) { over = true; culprit = c; break }
199
+ const par = c.parentElement
200
+ if (!par || !par.clientHeight) continue
201
+ const ps = getComputedStyle(par)
202
+ if (!/flex|grid/.test(ps.display) || ps.overflowY !== 'visible' || ps.overflowX !== 'visible') continue
203
+ if (/absolute|fixed/.test(getComputedStyle(c).position)) continue
204
+ const pr = par.getBoundingClientRect()
205
+ const lo = (pr.top - b.top) / k + pad(ps.paddingTop)
206
+ const hi = (b.bottom - pr.bottom) / k + pad(ps.paddingBottom)
207
+ if (lo > 2 || hi > 2) { over = true; culprit = c; break }
208
+ }
209
+ if (over === last) return // idempotent: never write the same state twice
210
+ last = over
211
+ el.style.outline = over ? '3px solid #ff4d4f' : ''
212
+ el.dataset.slOver = over ? '1' : ''
213
+ if (over) console.warn('[marver slide] content overflows the 1280×720 stage - split the slide, never shrink the type', culprit ?? el)
214
+ }
215
+ const schedule = () => { if (!raf) raf = requestAnimationFrame(check) }
216
+ // observe every descendant (bounded): a flex child keeps its box while its
217
+ // content grows, and a root-only observer never hears about that. Nodes
218
+ // added later are picked up by the childList observer - childList ONLY,
219
+ // so our own outline/data writes (attribute mutations) cannot re-trigger.
220
+ const ro = new ResizeObserver(schedule)
221
+ const seen = new WeakSet<Element>()
222
+ const observeAll = () => {
223
+ const all = el.querySelectorAll('*')
224
+ for (let i = 0; i < all.length && i < 600; i++) { if (!seen.has(all[i])) { seen.add(all[i]); ro.observe(all[i]) } }
225
+ }
226
+ ro.observe(el)
227
+ observeAll()
228
+ const mo = new MutationObserver(() => { observeAll(); schedule() })
229
+ mo.observe(el, { childList: true, subtree: true })
230
+ schedule()
231
+ return () => { cancelAnimationFrame(raf); ro.disconnect(); mo.disconnect() }
232
+ }, [])
233
+ return (
234
+ <SlideCtx.Provider value={true}>
235
+ <div ref={ref} className="sl-root" style={style}>{children}</div>
236
+ </SlideCtx.Provider>
237
+ )
238
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Video - poster-first, glass-controlled, in EVERY kind of frame.
3
+ *
4
+ * At rest there is NO <video> element and no media fetch: the poster <img>
5
+ * plus a play glyph IS the frame (lean-DOM safe - a <video> element would
6
+ * pin the frame live). The real element mounts with the glass control strip
7
+ * (play/pause, seekable progress, mute, fullscreen, auto-hiding) in two ways:
8
+ * - anywhere a frame is live (interact mode, play/present, focus, a
9
+ * published prototype): the poster is the play button - one click mounts
10
+ * the player and starts it, inside that gesture, so sound is allowed;
11
+ * - in slides mode (useSlidePlay): the player mounts on its own, the deck's
12
+ * entrance contract.
13
+ * `autoplay` is the third shape - an ambient loop (muted, no strip) for a hero
14
+ * or a product mockup; it mounts the element as soon as the frame is live,
15
+ * which keeps that frame live on the canvas (the serializer marks <video>
16
+ * degraded) - an explicit, per-video choice.
17
+ * Leaving (play flips off / unmount) pauses everything.
18
+ *
19
+ * Sources: a design-asset path or a public https direct-file URL (mp4/webm;
20
+ * HLS where the browser supports it natively). The poster is the frame at
21
+ * rest: authored, or - omitted on a local clip - the generated
22
+ * `<clip>.poster.png` beside it (server/poster.ts renders it; the primitive
23
+ * asks the dev server once when the file is missing). `ratio` is the CSS aspect-ratio of the box
24
+ * (default 16 / 9; "9 / 16" for a vertical clip inside a phone screen).
25
+ */
26
+ import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react'
27
+ import { ROUTE } from '../const.ts'
28
+ import { assetUrl } from './md.ts'
29
+ import { useSlidePlay } from './slide.tsx'
30
+
31
+ const isRemote = (s: string) => /^https:\/\//.test(s)
32
+ const resolve = (s: string): string | null => (isRemote(s) ? s : assetUrl(s))
33
+ /** The generated poster's conventional name (server/poster.ts owns the rule). */
34
+ const posterNameFor = (src: string) => `${src}.poster.png`
35
+
36
+ // One generation request per clip per document: the dev server renders the poster from
37
+ // the clip's own first moments (server/poster.ts) and the <img> reloads. A published canvas
38
+ // has no such API (build already generated the file); a failed request just keeps the card.
39
+ const requested = new Map<string, Promise<boolean>>()
40
+ const csrf = () => /(?:^|;\s*)mv_c=([\w-]+)/.exec(document.cookie)?.[1] ?? ''
41
+ // in-flight probes + generations, for the shot renderer's settle (like __mvLodBusy): a
42
+ // screenshot taken while a poster is still being rendered would show the empty box
43
+ let busy = 0
44
+ if (typeof window !== 'undefined') (window as { __mvPosterBusy?: () => number }).__mvPosterBusy = () => busy
45
+ const requestPoster = (src: string): Promise<boolean> => {
46
+ let p = requested.get(src)
47
+ if (!p) {
48
+ p = (async () => {
49
+ // the owner gate wants the double-submit cookie echoed; a frame host opened cold has not
50
+ // made an API GET yet, so prime it with the cheapest one first
51
+ if (!csrf()) await fetch(`${ROUTE}/api/policy`).catch(() => null)
52
+ const r = await fetch(`${ROUTE}/api/poster?src=${encodeURIComponent(src)}`, { headers: { 'x-mv-c': csrf() } }).catch(() => null)
53
+ return !!r?.ok
54
+ })()
55
+ requested.set(src, p)
56
+ // a failure is not forever: the next mount (a fix, Chrome installed) may ask again
57
+ void p.then((ok) => { if (!ok) requested.delete(src) })
58
+ }
59
+ return p
60
+ }
61
+
62
+ /** Is the human in the canvas's interact mode on this frame? The bridge mirrors sh:interactive
63
+ * on the document. Absent everywhere else (play, focus, published) - only the true->false
64
+ * transition matters: it disarms a playing clip when the mode is left. */
65
+ const subscribeInteractive = (cb: () => void) => {
66
+ if (typeof document === 'undefined') return () => {}
67
+ const mo = new MutationObserver(cb)
68
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-sh-interactive'] })
69
+ return () => mo.disconnect()
70
+ }
71
+ const readInteractive = () => typeof document !== 'undefined' && document.documentElement.hasAttribute('data-sh-interactive')
72
+ const useInteractive = (): boolean => useSyncExternalStore(subscribeInteractive, readInteractive, () => false)
73
+
74
+ const VIDEO_CSS = `
75
+ .mv-video { position: relative; width: 100%; border-radius: 14px; overflow: hidden; background: #000; aspect-ratio: var(--mv-video-ratio, 16 / 9) }
76
+ .mv-video.armed { cursor: pointer }
77
+ .mv-video.armed:hover .glyph span { background: rgba(16, 16, 20, .75) }
78
+ .mv-video > img, .mv-video > video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block }
79
+ .mv-video .glyph { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none }
80
+ .mv-video .glyph span { width: 72px; height: 72px; border-radius: 999px; display: flex; align-items: center; justify-content: center;
81
+ background: rgba(16, 16, 20, .55); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, .28) }
82
+ .mv-video .glyph svg { margin-left: 2px }
83
+ .mv-video .strip { position: absolute; left: 10px; right: 10px; bottom: 10px; display: flex; align-items: center; gap: 10px;
84
+ padding: 8px 12px; border-radius: 999px; background: rgba(16, 16, 20, .55); backdrop-filter: blur(12px);
85
+ border: 1px solid rgba(255, 255, 255, .22); color: #fff; opacity: 1; transition: opacity .25s }
86
+ .mv-video .strip.hidden { opacity: 0; pointer-events: none }
87
+ .mv-video .strip button { border: 0; background: none; color: #fff; cursor: pointer; display: flex; padding: 2px }
88
+ .mv-video .strip input[type=range] { flex: 1; accent-color: #fff; height: 3px; cursor: pointer }
89
+ .mv-video .strip .t { font: 500 12px/1 -apple-system, system-ui, sans-serif; opacity: .85; min-width: 34px }
90
+ `
91
+ let injected = false
92
+ const ensureCss = () => {
93
+ if (injected || typeof document === 'undefined') return
94
+ injected = true
95
+ const el = document.createElement('style')
96
+ el.setAttribute('data-mv-video', '')
97
+ el.textContent = VIDEO_CSS
98
+ document.head.appendChild(el)
99
+ }
100
+
101
+ const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
102
+
103
+ // Phosphor icons (play/pause in the fill weight, speaker and corners regular), path data
104
+ // inlined from @phosphor-icons/core - MIT (c) Phosphor Icons - the same way the shell's
105
+ // icons.tsx does it: no icon dependency reaches the host.
106
+ const P = ({ d, size }: { d: string; size: number }) => (
107
+ <svg width={size} height={size} viewBox="0 0 256 256" fill="#fff" aria-hidden><path d={d} /></svg>
108
+ )
109
+ const PLAY = 'M240,128a15.74,15.74,0,0,1-7.6,13.51L88.32,229.65a16,16,0,0,1-16.2.3A15.86,15.86,0,0,1,64,216.13V39.87a15.86,15.86,0,0,1,8.12-13.82,16,16,0,0,1,16.2.3L232.4,114.49A15.74,15.74,0,0,1,240,128Z'
110
+ const PAUSE = 'M216,48V208a16,16,0,0,1-16,16H160a16,16,0,0,1-16-16V48a16,16,0,0,1,16-16h40A16,16,0,0,1,216,48ZM96,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16H96a16,16,0,0,0,16-16V48A16,16,0,0,0,96,32Z'
111
+ const SPEAKER = 'M155.51,24.81a8,8,0,0,0-8.42.88L77.25,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H77.25l69.84,54.31A8,8,0,0,0,160,224V32A8,8,0,0,0,155.51,24.81ZM32,96H72v64H32ZM144,207.64,88,164.09V91.91l56-43.55Zm54-106.08a40,40,0,0,1,0,52.88,8,8,0,0,1-12-10.58,24,24,0,0,0,0-31.72,8,8,0,0,1,12-10.58ZM248,128a79.9,79.9,0,0,1-20.37,53.34,8,8,0,0,1-11.92-10.67,64,64,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67A79.83,79.83,0,0,1,248,128Z'
112
+ const SPEAKER_SLASH = 'M53.92,34.62A8,8,0,1,0,42.08,45.38L73.55,80H32A16,16,0,0,0,16,96v64a16,16,0,0,0,16,16H77.25l69.84,54.31A8,8,0,0,0,160,224V175.09l42.08,46.29a8,8,0,1,0,11.84-10.76ZM32,96H72v64H32ZM144,207.64,88,164.09V95.89l56,61.6Zm42-63.77a24,24,0,0,0,0-31.72,8,8,0,1,1,12-10.57,40,40,0,0,1,0,52.88,8,8,0,0,1-12-10.59Zm-80.16-76a8,8,0,0,1,1.4-11.23l39.85-31A8,8,0,0,1,160,32v74.83a8,8,0,0,1-16,0V48.36l-26.94,21A8,8,0,0,1,105.84,67.91ZM248,128a79.9,79.9,0,0,1-20.37,53.34,8,8,0,0,1-11.92-10.67,64,64,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67A79.83,79.83,0,0,1,248,128Z'
113
+ const CORNERS = 'M216,48V88a8,8,0,0,1-16,0V56H168a8,8,0,0,1,0-16h40A8,8,0,0,1,216,48ZM88,200H56V168a8,8,0,0,0-16,0v40a8,8,0,0,0,8,8H88a8,8,0,0,0,0-16Zm120-40a8,8,0,0,0-8,8v32H168a8,8,0,0,0,0,16h40a8,8,0,0,0,8-8V168A8,8,0,0,0,208,160ZM88,40H48a8,8,0,0,0-8,8V88a8,8,0,0,0,16,0V56H88a8,8,0,0,0,0-16Z'
114
+ const PlayGlyph = ({ size = 30 }: { size?: number }) => <P d={PLAY} size={size} />
115
+ const PauseGlyph = ({ size = 18 }: { size?: number }) => <P d={PAUSE} size={size} />
116
+
117
+ export function Video({ src, poster, ratio, autoplay = false }: { src: string; poster?: string; ratio?: string; autoplay?: boolean }) {
118
+ ensureCss()
119
+ const play = useSlidePlay()
120
+ const interactive = useInteractive()
121
+ const [armed, setArmed] = useState(false) // the poster was clicked in a live frame
122
+ // leaving interact mode disarms: the clip pauses (Player unmounts) and the frame returns to
123
+ // its poster, so the canvas can serialize it lean again
124
+ useEffect(() => { if (!interactive) setArmed(false) }, [interactive])
125
+ // poster omitted on a local clip: the generated one, by convention. `gen` walks
126
+ // probing -> ready (the file exists, or was just rendered - reloaded with a cache-buster)
127
+ // or failed (the card). The <img> mounts only once ready: no broken-image glyph while the
128
+ // dev server renders the poster, and a shot taken meanwhile waits on __mvPosterBusy.
129
+ const generated = !poster && !isRemote(src) ? posterNameFor(src) : null
130
+ const [gen, setGen] = useState<'probing' | 'ready' | 'failed'>(generated ? 'probing' : 'ready')
131
+ const [bust, setBust] = useState(0)
132
+ const posterUrl = poster ? resolve(poster) : generated ? resolve(generated) : null
133
+ const posterSrc = posterUrl && bust ? `${posterUrl}?v=${bust}` : posterUrl
134
+ const srcUrl = resolve(src)
135
+ const style = ratio ? ({ '--mv-video-ratio': ratio } as CSSProperties) : undefined
136
+ useEffect(() => {
137
+ if (!generated || !posterUrl) { setGen('ready'); return }
138
+ let alive = true, pending = true
139
+ setGen('probing'); setBust(0)
140
+ busy++
141
+ const done = () => { if (pending) { pending = false; busy-- } }
142
+ const probe = new Image()
143
+ const settle = (state: 'ready' | 'failed', rebust = false) => { done(); if (!alive) return; if (rebust) setBust(Date.now()); setGen(state) }
144
+ probe.onload = () => settle('ready')
145
+ probe.onerror = () => {
146
+ // not there yet: ask the dev server to render it (once per clip per document)
147
+ void requestPoster(src).then((ok) => settle(ok ? 'ready' : 'failed', ok))
148
+ }
149
+ probe.src = posterUrl
150
+ return () => { alive = false; probe.onload = probe.onerror = null; done() }
151
+ }, [generated, posterUrl, src])
152
+ const bad = !srcUrl || (!isRemote(src) && !posterUrl) || gen === 'failed'
153
+ if (bad) {
154
+ return (
155
+ <div className="mv-block mv-imgerr">
156
+ <b>video unavailable</b>
157
+ <span>{src}</span>
158
+ <span className="dim">{!srcUrl ? 'must be a design/assets/ path or an https file URL' : `no poster - add poster="…", or put ${generated} beside the clip (the dev server renders it when Chrome is installed)`}</span>
159
+ </div>
160
+ )
161
+ }
162
+ // Ambient: a muted loop, no chrome - the author accepted that this frame stays live.
163
+ if (autoplay) {
164
+ return (
165
+ <div className="mv-block mv-video" style={style}>
166
+ <video src={srcUrl!} poster={gen === 'ready' ? posterSrc ?? undefined : undefined} autoPlay muted loop playsInline preload="auto" />
167
+ </div>
168
+ )
169
+ }
170
+ // AT REST: the poster and nothing else - no <video>, no fetch, still. In a lean cover the
171
+ // click never fires (no script); in a live frame it arms the player inside the gesture.
172
+ if (!play && !armed) {
173
+ return (
174
+ <div className="mv-block mv-video armed" style={style} role="button" aria-label="Play video" onClick={() => setArmed(true)}>
175
+ {posterSrc && gen === 'ready' ? <img src={posterSrc} alt="" loading="lazy" /> : null}
176
+ <div className="glyph"><span><PlayGlyph /></span></div>
177
+ </div>
178
+ )
179
+ }
180
+ return <Player src={srcUrl!} poster={gen === 'ready' ? posterSrc ?? undefined : undefined} style={style} autoStart={armed} />
181
+ }
182
+
183
+ /** The glass strip - deliberately four controls and a clock, nothing more. */
184
+ function Player({ src, poster, style, autoStart = false }: { src: string; poster?: string; style?: CSSProperties; autoStart?: boolean }) {
185
+ const ref = useRef<HTMLVideoElement>(null)
186
+ const [playing, setPlaying] = useState(false)
187
+ const [muted, setMuted] = useState(false)
188
+ const [t, setT] = useState(0)
189
+ const [dur, setDur] = useState(0)
190
+ const [idle, setIdle] = useState(false)
191
+ const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
192
+ const poke = () => {
193
+ setIdle(false)
194
+ if (idleTimer.current) clearTimeout(idleTimer.current)
195
+ idleTimer.current = setTimeout(() => setIdle(true), 2200)
196
+ }
197
+ useEffect(() => () => { if (idleTimer.current) clearTimeout(idleTimer.current); ref.current?.pause() }, [])
198
+ // armed by a click on the poster: start now - still inside that gesture's activation
199
+ useEffect(() => { if (autoStart) { void ref.current?.play().catch(() => {}); poke() } }, [autoStart])
200
+ const toggle = () => { const v = ref.current; if (!v) return; if (v.paused) { void v.play(); poke() } else v.pause() }
201
+ return (
202
+ <div className="mv-block mv-video" style={style} onPointerMove={poke} onClick={(e) => { if (e.target === ref.current) toggle() }}>
203
+ <video ref={ref} src={src} poster={poster} preload="metadata" muted={muted} playsInline
204
+ onPlay={() => { setPlaying(true); poke() }} onPause={() => { setPlaying(false); setIdle(false) }}
205
+ onTimeUpdate={(e) => setT(e.currentTarget.currentTime)}
206
+ onLoadedMetadata={(e) => setDur(e.currentTarget.duration || 0)} />
207
+ {!playing && <div className="glyph"><span><PlayGlyph /></span></div>}
208
+ <div className={`strip${playing && idle ? ' hidden' : ''}`}>
209
+ <button aria-label={playing ? 'Pause' : 'Play'} onClick={toggle}>{playing ? <PauseGlyph /> : <PlayGlyph size={18} />}</button>
210
+ <span className="t">{fmt(t)}</span>
211
+ <input type="range" min={0} max={dur || 0} step={0.1} value={t} aria-label="Seek"
212
+ onChange={(e) => { const v = ref.current; if (v) { v.currentTime = Number(e.target.value); setT(v.currentTime) } }} />
213
+ <span className="t">{fmt(dur)}</span>
214
+ <button aria-label={muted ? 'Unmute' : 'Mute'} onClick={() => setMuted((m) => !m)}>
215
+ <P d={muted ? SPEAKER_SLASH : SPEAKER} size={18} />
216
+ </button>
217
+ <button aria-label="Fullscreen" onClick={(e) => void (e.currentTarget.closest('.mv-video') as HTMLElement | null)?.requestFullscreen?.()}>
218
+ <P d={CORNERS} size={18} />
219
+ </button>
220
+ </div>
221
+ </div>
222
+ )
223
+ }
@@ -69,7 +69,12 @@ window.addEventListener('message', (e) => {
69
69
  if (e.origin && e.origin !== location.origin) return // a navigated frame must not spoof commands
70
70
  if (e?.data?.type === 'sh:set-theme') setTheme(e.data.theme)
71
71
  // B0.2: interact/play target owns its own wheel; passive frames forward it to the canvas
72
- if (e?.data?.type === 'sh:interactive') { interactiveOn = !!e.data.on }
72
+ if (e?.data?.type === 'sh:interactive') {
73
+ interactiveOn = !!e.data.on
74
+ // mirrored on the document so content primitives can observe it (Video disarms its
75
+ // player when the human leaves interact mode - a playing clip must not outlive the mode)
76
+ document.documentElement.toggleAttribute('data-sh-interactive', interactiveOn)
77
+ }
73
78
  })
74
79
 
75
80
  if (isHtmlFrame) {
@@ -1,13 +1,13 @@
1
1
  import { Component, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { useStore, CONFIG, LOCKED_SHELL, PUBLISHED, SOURCE_REVEALED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, landingMode, type FrameEntry } from './store.ts'
3
+ import { useStore, CONFIG, LOCKED_SHELL, PUBLISHED, SOURCE_REVEALED, boardLabel, boardFrames, cap, humanize, fetchBoardNames, hydrateBoardPolicy, landingMode, playsAsSlides, type FrameEntry } from './store.ts'
4
4
  import { Tip } from './Tip.tsx'
5
5
  import { PKG, ROUTE } from '../const.ts'
6
6
  import { animateLayout, Canvas, canvasCtl } from './canvas/Canvas.tsx'
7
7
  import { frameByWindow } from './canvas/frame-registry.ts'
8
- import { enterFocus, enterPlay, playCtl, PlayOverlay } from './Play.tsx'
8
+ import { enterFocus, enterPlay, enterSlides, playCtl, PlayOverlay } from './Play.tsx'
9
9
  import { bootHash, parseHash, writeHash } from './hash.ts'
10
- import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PencilSimpleIcon, PlayIcon, SignpostIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
10
+ import { CardsIcon, CardsThreeIcon, CaretIcon, CheckIcon, ColumnsIcon, FrameRectIcon, ImagesSquareIcon, IntentGlyph, MoonIcon, PanelFilledIcon, PanelHollowIcon, ParallelogramDuoIcon, ParallelogramFillIcon, PencilSimpleIcon, PlayIcon, SignpostIcon, SlideFrameIcon, SunIcon, VariantsIcon, XIcon, deviceIcon } from './icons.tsx'
11
11
  import { CommentsController, revealThread } from './Comments.tsx'
12
12
  import { poweredByUrl } from '../../shared/utm.ts'
13
13
  import { avatarFallback, useComments } from './comments-store.ts'
@@ -323,6 +323,17 @@ function SelectionBar() {
323
323
  const t = setTimeout(() => setCopied(false), 1400)
324
324
  return () => clearTimeout(t)
325
325
  }, [pathPulse])
326
+ // copy-as-image: same flash contract (imagePulse bumps only on a real clipboard write),
327
+ // plus a busy state for the 1-4s headless render - the icon dims and the button locks
328
+ const imagePulse = useStore((s) => s.imagePulse)
329
+ const imageBusy = useStore((s) => s.imageBusy)
330
+ const [copiedImage, setCopiedImage] = useState(false)
331
+ useEffect(() => {
332
+ if (!imagePulse) return
333
+ setCopiedImage(true)
334
+ const t = setTimeout(() => setCopiedImage(false), 1400)
335
+ return () => clearTimeout(t)
336
+ }, [imagePulse])
326
337
  if (!node || !frame || node.missing) return null
327
338
  // anchor: centered over the bounding box of ALL selected frames, above the topmost
328
339
  const selNodes = nodes.filter((n) => selection.includes(n.key))
@@ -401,6 +412,17 @@ function SelectionBar() {
401
412
  () => toast('copy blocked - click the canvas first'))
402
413
  }}>{copied ? <CheckIcon size={15} /> : <SignpostIcon size={15} />}</button>
403
414
  </Tip>
415
+ {/* copy as image: dev only (the renderer is the dev server's headless Chrome) and one
416
+ frame at a time (a clipboard holds one image). I = 2x, Shift+I = 4x. */}
417
+ {!PUBLISHED && (
418
+ <Tip label={multi ? 'Select one frame to copy as image' : <><b>Copy as image</b><span className="k">⇧i</span></>}>
419
+ <button className={`icon${imageBusy ? ' busy' : ''}`} disabled={multi || imageBusy} aria-label="Copy as image"
420
+ data-state={copiedImage ? 'copied' : imageBusy ? 'busy' : 'idle'}
421
+ onClick={(e) => useStore.getState().copyFrameImage(e.shiftKey ? 4 : 2)}>
422
+ {copiedImage ? <CheckIcon size={15} /> : <ImagesSquareIcon size={15} />}
423
+ </button>
424
+ </Tip>
425
+ )}
404
426
  </div>
405
427
  )
406
428
  }
@@ -553,6 +575,14 @@ export function App() {
553
575
  if (booted) return
554
576
  booted = true
555
577
  const start = async () => {
578
+ if (!PUBLISHED) {
579
+ // dev: hydrate board policy (slides/type/open) from the repo so P and
580
+ // deep links preview exactly what viewers get
581
+ try {
582
+ const r = await fetch(`${ROUTE}/api/policy`)
583
+ if (r.ok) hydrateBoardPolicy(((await r.json()) as { boards?: Record<string, never> }).boards ?? {})
584
+ } catch { /* dev server without the route - fine */ }
585
+ }
556
586
  if (bootHash.board) {
557
587
  if (bootHash.board !== useStore.getState().board) // a deep link wins
558
588
  useStore.setState({ board: bootHash.board, boardAuto: bootHash.board === 'all-scenes' })
@@ -569,7 +599,8 @@ export function App() {
569
599
  // (01-sharing §6.1 step 3) - the stage mounts any manifest frame, so no
570
600
  // board lookup is needed
571
601
  enterFocus(bootHash.focus.at, { ...bootHash.focus, deep: true })
572
- } else if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
602
+ } else if (ok && bootHash.play?.slides) enterSlides(bootHash.play)
603
+ else if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
573
604
  else if (ok && PUBLISHED) {
574
605
  // published landing: explicit `open` beats the type default; canvas and
575
606
  // board land on the canvas surface (board-scoped already); a lock will
@@ -577,6 +608,7 @@ export function App() {
577
608
  const mode = landingMode(useStore.getState().board)
578
609
  if (mode === 'present') enterPlay()
579
610
  else if (mode === 'focus') enterFocus()
611
+ else if (mode === 'slides') enterSlides()
580
612
  }
581
613
  }
582
614
  void start()
@@ -619,7 +651,10 @@ export function App() {
619
651
  if (h.focus) {
620
652
  enterFocus(h.focus.at, { ...h.focus, deep: true })
621
653
  } else if (h.play) {
622
- if (s.play) playCtl.sync(h.play)
654
+ // sync only holds WITHIN a mode: a slides hash over a present stage (or
655
+ // the reverse) re-enters, which remounts the stage with the right src
656
+ if (s.play && !!h.play.slides === !!s.play.slides) playCtl.sync(h.play)
657
+ else if (h.play.slides) enterSlides(h.play)
623
658
  else enterPlay(h.play)
624
659
  } else {
625
660
  if (s.play) s.setPlay(null)
@@ -822,7 +857,11 @@ export function App() {
822
857
  // keyboard: t tidy · d theme · ⌘\ panel · Escape exit · shift+0/1/2 zoom
823
858
  useEffect(() => {
824
859
  const onKey = (e: KeyboardEvent) => {
825
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
860
+ // anything that edits text owns its keys: inputs, textareas, selects, contenteditable (the
861
+ // comment composer), and an IME mid-composition
862
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return
863
+ if (e.target instanceof HTMLElement && e.target.isContentEditable) return
864
+ if (e.isComposing) return
826
865
  const s = useStore.getState()
827
866
  if (s.play) return // play mode owns the keyboard (Play.tsx)
828
867
  if ((e.metaKey || e.ctrlKey) && e.key === '\\') { e.preventDefault(); togglePanel(); return }
@@ -835,7 +874,9 @@ export function App() {
835
874
  else if (s.laser) s.setLaser(false) // Escape also exits laser mode (parity with comment)
836
875
  else s.interact ? setInteract(null) : select(null)
837
876
  }
838
- if (e.key === 'p') enterPlay()
877
+ // a slides board PLAYS as slides even when its landing is the canvas -
878
+ // the deck is what the board IS; landing only says where viewers arrive
879
+ if (e.key === 'p') { if (playsAsSlides(useStore.getState().board)) enterSlides(); else enterPlay() }
839
880
  // H hides all chrome (shared binary Hide-UI); press again to reveal. Not persisted,
840
881
  // so a refresh always restores it (the safety net for a forgotten toggle).
841
882
  if (e.key === 'h') { toggleHideUI(); return }
@@ -863,6 +904,8 @@ export function App() {
863
904
  c.setShowAnchor(!c.showAnchor)
864
905
  toast(c.showAnchor ? 'laser comment off' : 'laser comment on')
865
906
  }
907
+ // I = copy the selected frame as a 2x PNG · Shift+I = 4x (dev only; one frame at a time)
908
+ if ((e.key === 'i' || e.key === 'I') && !PUBLISHED && !e.altKey && !e.repeat) { s.copyFrameImage(e.shiftKey ? 4 : 2); return }
866
909
  if (e.key === 'P' && e.shiftKey && s.selection.length) {
867
910
  const paths = s.selection
868
911
  .map((k) => { const n = s.nodes.find((x) => x.key === k); const f = n && s.frameFor(n); return f ? framePath(s.board, f) : undefined })
@@ -1067,11 +1110,14 @@ export function App() {
1067
1110
  const on = !!n && selection.includes(n.key)
1068
1111
  rows.push(
1069
1112
  <div key={f.id} className={`sub${on ? ' on' : ''}`} onClick={(e) => go(f.id, e.shiftKey)} onContextMenu={(e) => cm.open(e, frameMenu(f))} title={f.intent}>
1070
- {/* every frame leads with an icon: intent glyph for content
1071
- frames, the plain frame rectangle for UI frames */}
1072
- {f.intent
1073
- ? <IntentGlyph intent={f.intent} size={13} className="iicon" aria-label={f.intent} />
1074
- : <FrameRectIcon size={13} className="iicon" />}
1113
+ {/* every frame leads with an icon: the slide badge first (a deck
1114
+ frame is a deck frame whatever it imports), intent glyph for
1115
+ content frames, the plain rectangle for UI frames */}
1116
+ {f.slide
1117
+ ? <SlideFrameIcon size={13} className="iicon" role="img" aria-hidden={false} aria-label="slide" />
1118
+ : f.intent
1119
+ ? <IntentGlyph intent={f.intent} size={13} className="iicon" aria-label={f.intent} />
1120
+ : <FrameRectIcon size={13} className="iicon" />}
1075
1121
  {cap(f.id.split('/').slice(1).join('/') || f.id)}
1076
1122
  </div>,
1077
1123
  )
@@ -1104,7 +1150,7 @@ export function App() {
1104
1150
  <i className="sep" />
1105
1151
  {/* far right: view management - prototype, hide, collapse */}
1106
1152
  <Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
1107
- <button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
1153
+ <button className="sh-pill-btn" onClick={() => (playsAsSlides(useStore.getState().board) ? enterSlides() : enterPlay())}><PlayIcon size={15} /></button>
1108
1154
  </Tip>
1109
1155
  <HideUIButton />
1110
1156
  <Tip side="bottom" label={<><b>Collapse toolbar</b><span>⌘/</span></>}>