@marver-design/marver 0.12.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 (53) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +41 -19
  3. package/dist/{build-DmfxJc6L.mjs → build-ByYafIhj.mjs} +37 -5
  4. package/dist/cli.mjs +21 -7
  5. package/dist/{collab-BmaOjwP1.mjs → collab-mAiNZlDj.mjs} +232 -43
  6. package/dist/{comments-pNxjTrGN.mjs → comments-DHB_8BRa.mjs} +3 -3
  7. package/dist/{daemon-WjonKlPz.mjs → daemon-Bfucyf1o.mjs} +3 -3
  8. package/dist/{dev-BXq7yE1p.mjs → dev-yZMyQeUj.mjs} +5 -5
  9. package/dist/{events-C9k-ceOj.mjs → events-B3LBn74P.mjs} +3 -1
  10. package/dist/{init-BpitOqRQ.mjs → init-Dvaso7YO.mjs} +66 -2
  11. package/dist/{manifest-CS6krOTe.mjs → manifest-DvOmglFp.mjs} +7 -0
  12. package/dist/{marver-id-gate-DOjnZakC.mjs → marver-id-gate-D6By7XHj.mjs} +8 -5
  13. package/dist/{plugin-Boy7I_JH.mjs → plugin-BsmG5i2X.mjs} +25 -7
  14. package/dist/{profile-ChpTPd-X.mjs → profile-BjAPAJSb.mjs} +1 -1
  15. package/dist/{serve-BeLLizNg.mjs → serve-Bcwfpvhl.mjs} +4 -3
  16. package/dist/{share-C64b1tdN.mjs → share-Gqo_Ygqw.mjs} +14 -15
  17. package/dist/{shot-Cyv3GN79.mjs → shot-kbR_xzJH.mjs} +7 -1
  18. package/dist/{summary-Bf9tiGrE.mjs → summary-C7CAypbB.mjs} +1 -1
  19. package/dist/{sync-WYxDN9IT.mjs → sync-BZaCWqK-.mjs} +1 -1
  20. package/docs/live-jam.md +173 -0
  21. package/docs/publish.md +270 -0
  22. package/docs/sharing.md +333 -0
  23. package/docs/slides.md +134 -0
  24. package/package.json +3 -1
  25. package/src/client/const.ts +13 -0
  26. package/src/client/content/chart-engine.ts +33 -0
  27. package/src/client/content/chart.tsx +100 -0
  28. package/src/client/content/index.tsx +25 -4
  29. package/src/client/content/slide.tsx +238 -0
  30. package/src/client/content/video.tsx +126 -0
  31. package/src/client/shell/App.tsx +49 -15
  32. package/src/client/shell/Comments.tsx +94 -17
  33. package/src/client/shell/LockedApp.tsx +7 -2
  34. package/src/client/shell/Play.tsx +138 -24
  35. package/src/client/shell/Toolbar.tsx +12 -3
  36. package/src/client/shell/canvas/FrameNode.tsx +5 -3
  37. package/src/client/shell/comments-store.ts +59 -4
  38. package/src/client/shell/hash.ts +3 -1
  39. package/src/client/shell/icons.tsx +2 -0
  40. package/src/client/shell/mentions.ts +108 -1
  41. package/src/client/shell/play-order.ts +22 -0
  42. package/src/client/shell/sound.ts +32 -0
  43. package/src/client/shell/store.ts +41 -12
  44. package/src/client/shell/styles.css +58 -27
  45. package/src/client/stage/main.tsx +54 -3
  46. package/src/shared/events.ts +12 -3
  47. package/src/shared/utm.ts +3 -2
  48. package/templates/AGENTS-embedded.md +1 -0
  49. package/templates/AGENTS-studio.md +1 -0
  50. package/templates/instructions/publish.md +7 -0
  51. package/templates/instructions/reference/deck-layouts.md +230 -0
  52. package/templates/instructions/reference/deck-story.md +110 -0
  53. package/templates/instructions/slides.md +398 -0
@@ -8,6 +8,7 @@
8
8
  * can give the frame a natural size (the shell alone owns node dimensions).
9
9
  */
10
10
  import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
11
+ import { useInSlide } from './slide.tsx'
11
12
  import { CONTENT_WIDTH } from '../const.ts'
12
13
  import { assetUrl, renderMarkdown, FAMILIES } from './md.ts'
13
14
  import { lodSupported, registerLodImage } from './img-lod.ts'
@@ -16,7 +17,18 @@ import { lodSupported, registerLodImage } from './img-lod.ts'
16
17
  const FAMILY_CSS = Object.entries(FAMILIES).map(([f, c]) =>
17
18
  `.mv-md .mv-c-${f}{color:${c.light}}.dark .mv-md .mv-c-${f},[data-theme="dark"] .mv-md .mv-c-${f}{color:${c.dark}}`).join('\n')
18
19
 
19
- export { Diagram } from './diagram.tsx'
20
+ import { Diagram as DiagramRoot } from './diagram.tsx'
21
+ export function Diagram(props: Parameters<typeof DiagramRoot>[0]) { ensureStyles(); return <DiagramRoot {...props} /> }
22
+ import { Slide as SlideRoot } from './slide.tsx'
23
+ import { Chart as ChartRoot } from './chart.tsx'
24
+ import { Video as VideoRoot } from './video.tsx'
25
+ export { SLIDE_W, SLIDE_H } from './slide.tsx'
26
+ // the shared stylesheet used to ride in with Doc alone; a slide composes Img,
27
+ // Chart, and Video straight inside <Slide> with no Doc, so every public
28
+ // primitive installs it - once per document, idempotent
29
+ export function Slide(props: Parameters<typeof SlideRoot>[0]) { ensureStyles(); return <SlideRoot {...props} /> }
30
+ export function Chart(props: Parameters<typeof ChartRoot>[0]) { ensureStyles(); return <ChartRoot {...props} /> }
31
+ export function Video(props: Parameters<typeof VideoRoot>[0]) { ensureStyles(); return <VideoRoot {...props} /> }
20
32
 
21
33
  const UNIT = 16 // one gap unit, px - plain adjacency on boards is one gutter; same feel here
22
34
 
@@ -56,20 +68,24 @@ export function Doc({ layout = 'document', children }: { layout?: 'document' | '
56
68
  /* ----------------------------- layout blocks ------------------------------ */
57
69
 
58
70
  export function Row({ space = 1, children }: { space?: number; children?: ReactNode }) {
71
+ ensureStyles()
59
72
  return <div className="mv-row" style={{ gap: space * UNIT }}>{children}</div>
60
73
  }
61
74
 
62
75
  export function Col({ space = 1, children }: { space?: number; children?: ReactNode }) {
76
+ ensureStyles()
63
77
  return <div className="mv-col" style={{ gap: space * UNIT }}>{children}</div>
64
78
  }
65
79
 
66
80
  export function Space({ n = 1 }: { n?: number }) {
81
+ ensureStyles()
67
82
  return <div aria-hidden className="mv-space" style={{ flex: `0 0 ${n * UNIT}px`, minWidth: n * UNIT, minHeight: n * UNIT }} />
68
83
  }
69
84
 
70
85
  /* ---------------------------------- Md ------------------------------------ */
71
86
 
72
87
  export function Md({ children }: { children?: ReactNode }) {
88
+ ensureStyles()
73
89
  const src = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : String(children ?? '')
74
90
  const html = useMemo(() => renderMarkdown(src), [src])
75
91
  return <div className="mv-md" dangerouslySetInnerHTML={{ __html: html }} />
@@ -78,16 +94,21 @@ export function Md({ children }: { children?: ReactNode }) {
78
94
  /* ---------------------------------- Img ----------------------------------- */
79
95
 
80
96
  export function Img({ src, caption, alt, h }: { src: string; caption?: string; alt?: string; h?: number }) {
97
+ ensureStyles()
81
98
  const url = assetUrl(src)
82
99
  const [err, setErr] = useState(false)
83
100
  const canvasRef = useRef<HTMLCanvasElement>(null)
101
+ // inside a <Slide>, the LOD canvas is OFF: a resting slide must serialize to
102
+ // the lean-DOM path, and a <canvas> element pins its frame live (v1.5 §7)
103
+ const inSlide = useInSlide()
84
104
  // LOD: paint the image on a <canvas> decoded to its on-screen size (never the full 17MB bitmap), and
85
105
  // re-pick resolution only when the canvas settles after a zoom. See img-lod.ts. Falls back to a plain
86
106
  // <img> where createImageBitmap/bitmaprenderer isn't available (correctness over the optimization).
107
+ const lodOn = lodSupported && !inSlide
87
108
  useEffect(() => {
88
- if (!url || err || !lodSupported || !canvasRef.current) return
109
+ if (!url || err || !lodOn || !canvasRef.current) return
89
110
  return registerLodImage(canvasRef.current, url)
90
- }, [url, err])
111
+ }, [url, err, lodOn])
91
112
  if (!url || err) {
92
113
  return (
93
114
  <div className="mv-block mv-imgerr">
@@ -106,7 +127,7 @@ export function Img({ src, caption, alt, h }: { src: string; caption?: string; a
106
127
  const style = undefined
107
128
  return (
108
129
  <figure className="mv-block mv-img">
109
- {lodSupported
130
+ {lodOn
110
131
  ? <canvas ref={canvasRef} className="mv-img-el" role="img" aria-label={alt ?? caption ?? ''} style={style} />
111
132
  : <img className="mv-img-el" src={url} alt={alt ?? caption ?? ''} loading="lazy" style={style} onError={() => setErr(true)} />}
112
133
  {caption && <figcaption>{caption}</figcaption>}
@@ -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,126 @@
1
+ /**
2
+ * Video (v1.5) - poster-first, glass-controlled.
3
+ *
4
+ * At rest there is NO <video> element and no media fetch: the poster <img>
5
+ * plus a play glyph IS the slide (lean-DOM safe - a <video> element would
6
+ * pin the frame live). In slides mode (useSlidePlay) the real element
7
+ * mounts with the glass control strip: play/pause, seekable progress, mute,
8
+ * fullscreen, auto-hiding. Leaving the slide (play flips off / unmount)
9
+ * pauses everything.
10
+ *
11
+ * Sources: a design-asset path (poster REQUIRED - it is the canvas
12
+ * rendering) or a public https direct-file URL (mp4/webm; HLS where the
13
+ * browser supports it natively).
14
+ */
15
+ import { useEffect, useRef, useState } from 'react'
16
+ import { assetUrl } from './md.ts'
17
+ import { useSlidePlay } from './slide.tsx'
18
+
19
+ const isRemote = (s: string) => /^https:\/\//.test(s)
20
+ const resolve = (s: string): string | null => (isRemote(s) ? s : assetUrl(s))
21
+
22
+ const VIDEO_CSS = `
23
+ .mv-video { position: relative; width: 100%; border-radius: 14px; overflow: hidden; background: #000; aspect-ratio: 16 / 9 }
24
+ .mv-video > img, .mv-video > video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block }
25
+ .mv-video .glyph { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none }
26
+ .mv-video .glyph span { width: 72px; height: 72px; border-radius: 999px; display: flex; align-items: center; justify-content: center;
27
+ background: rgba(16, 16, 20, .55); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, .28) }
28
+ .mv-video .glyph svg { margin-left: 4px }
29
+ .mv-video .strip { position: absolute; left: 10px; right: 10px; bottom: 10px; display: flex; align-items: center; gap: 10px;
30
+ padding: 8px 12px; border-radius: 999px; background: rgba(16, 16, 20, .55); backdrop-filter: blur(12px);
31
+ border: 1px solid rgba(255, 255, 255, .22); color: #fff; opacity: 1; transition: opacity .25s }
32
+ .mv-video .strip.hidden { opacity: 0; pointer-events: none }
33
+ .mv-video .strip button { border: 0; background: none; color: #fff; cursor: pointer; display: flex; padding: 2px }
34
+ .mv-video .strip input[type=range] { flex: 1; accent-color: #fff; height: 3px; cursor: pointer }
35
+ .mv-video .strip .t { font: 500 12px/1 -apple-system, system-ui, sans-serif; opacity: .85; min-width: 34px }
36
+ `
37
+ let injected = false
38
+ const ensureCss = () => {
39
+ if (injected || typeof document === 'undefined') return
40
+ injected = true
41
+ const el = document.createElement('style')
42
+ el.setAttribute('data-mv-video', '')
43
+ el.textContent = VIDEO_CSS
44
+ document.head.appendChild(el)
45
+ }
46
+
47
+ const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
48
+
49
+ const PlayGlyph = ({ size = 26 }: { size?: number }) => (
50
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="#fff"><path d="M8 5.5 19 12 8 18.5 Z" /></svg>
51
+ )
52
+ const PauseGlyph = () => (
53
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="#fff"><rect x="6" y="5" width="4" height="14" rx="1" /><rect x="14" y="5" width="4" height="14" rx="1" /></svg>
54
+ )
55
+
56
+ export function Video({ src, poster }: { src: string; poster?: string }) {
57
+ ensureCss()
58
+ const play = useSlidePlay()
59
+ const posterUrl = poster ? resolve(poster) : null
60
+ const srcUrl = resolve(src)
61
+ const bad = !srcUrl || (!isRemote(src) && !posterUrl)
62
+ if (bad) {
63
+ return (
64
+ <div className="mv-block mv-imgerr">
65
+ <b>video unavailable</b>
66
+ <span>{src}</span>
67
+ <span className="dim">{!srcUrl ? 'must be a design/assets/ path or an https file URL' : 'a local video needs a poster - it IS the slide at rest'}</span>
68
+ </div>
69
+ )
70
+ }
71
+ // AT REST: the poster and nothing else - no <video>, no fetch, still.
72
+ if (!play) {
73
+ return (
74
+ <div className="mv-block mv-video">
75
+ {posterUrl ? <img src={posterUrl} alt="" loading="lazy" /> : null}
76
+ <div className="glyph"><span><PlayGlyph /></span></div>
77
+ </div>
78
+ )
79
+ }
80
+ return <Player src={srcUrl!} poster={posterUrl ?? undefined} />
81
+ }
82
+
83
+ /** The glass strip - deliberately four controls and a clock, nothing more. */
84
+ function Player({ src, poster }: { src: string; poster?: string }) {
85
+ const ref = useRef<HTMLVideoElement>(null)
86
+ const [playing, setPlaying] = useState(false)
87
+ const [muted, setMuted] = useState(false)
88
+ const [t, setT] = useState(0)
89
+ const [dur, setDur] = useState(0)
90
+ const [idle, setIdle] = useState(false)
91
+ const idleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
92
+ const poke = () => {
93
+ setIdle(false)
94
+ if (idleTimer.current) clearTimeout(idleTimer.current)
95
+ idleTimer.current = setTimeout(() => setIdle(true), 2200)
96
+ }
97
+ useEffect(() => () => { if (idleTimer.current) clearTimeout(idleTimer.current); ref.current?.pause() }, [])
98
+ const toggle = () => { const v = ref.current; if (!v) return; if (v.paused) { void v.play(); poke() } else v.pause() }
99
+ return (
100
+ <div className="mv-block mv-video" onPointerMove={poke} onClick={(e) => { if (e.target === ref.current) toggle() }}>
101
+ <video ref={ref} src={src} poster={poster} preload="metadata" muted={muted} playsInline
102
+ onPlay={() => { setPlaying(true); poke() }} onPause={() => { setPlaying(false); setIdle(false) }}
103
+ onTimeUpdate={(e) => setT(e.currentTarget.currentTime)}
104
+ onLoadedMetadata={(e) => setDur(e.currentTarget.duration || 0)} />
105
+ {!playing && <div className="glyph"><span><PlayGlyph /></span></div>}
106
+ <div className={`strip${playing && idle ? ' hidden' : ''}`}>
107
+ <button aria-label={playing ? 'Pause' : 'Play'} onClick={toggle}>{playing ? <PauseGlyph /> : <PlayGlyph size={18} />}</button>
108
+ <span className="t">{fmt(t)}</span>
109
+ <input type="range" min={0} max={dur || 0} step={0.1} value={t} aria-label="Seek"
110
+ onChange={(e) => { const v = ref.current; if (v) { v.currentTime = Number(e.target.value); setT(v.currentTime) } }} />
111
+ <span className="t">{fmt(dur)}</span>
112
+ <button aria-label={muted ? 'Unmute' : 'Mute'} onClick={() => setMuted((m) => !m)}>
113
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
114
+ <path d="M11 5 6 9 H3 v6 h3 l5 4 Z" fill="#fff" stroke="none" />
115
+ {muted ? <path d="m16 9 6 6 M22 9 l-6 6" /> : <path d="M15.5 8.5 a5 5 0 0 1 0 7 M18.5 6 a9 9 0 0 1 0 12" />}
116
+ </svg>
117
+ </button>
118
+ <button aria-label="Fullscreen" onClick={(e) => void (e.currentTarget.closest('.mv-video') as HTMLElement | null)?.requestFullscreen?.()}>
119
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
120
+ <path d="M8 3 H4 a1 1 0 0 0 -1 1 v4 M16 3 h4 a1 1 0 0 1 1 1 v4 M8 21 H4 a1 1 0 0 1 -1 -1 v-4 M16 21 h4 a1 1 0 0 0 1 -1 v-4" />
121
+ </svg>
122
+ </button>
123
+ </div>
124
+ </div>
125
+ )
126
+ }
@@ -1,16 +1,16 @@
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, 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
- import { useComments } from './comments-store.ts'
13
+ import { avatarFallback, useComments } from './comments-store.ts'
14
14
  import { CommentButton, DevicePicker, HideUIButton, LaserButton, Popover, ThemePicker, toggleHideUI, usePopover } from './Toolbar.tsx'
15
15
 
16
16
  const commentsStore = () => useComments.getState()
@@ -553,6 +553,14 @@ export function App() {
553
553
  if (booted) return
554
554
  booted = true
555
555
  const start = async () => {
556
+ if (!PUBLISHED) {
557
+ // dev: hydrate board policy (slides/type/open) from the repo so P and
558
+ // deep links preview exactly what viewers get
559
+ try {
560
+ const r = await fetch(`${ROUTE}/api/policy`)
561
+ if (r.ok) hydrateBoardPolicy(((await r.json()) as { boards?: Record<string, never> }).boards ?? {})
562
+ } catch { /* dev server without the route - fine */ }
563
+ }
556
564
  if (bootHash.board) {
557
565
  if (bootHash.board !== useStore.getState().board) // a deep link wins
558
566
  useStore.setState({ board: bootHash.board, boardAuto: bootHash.board === 'all-scenes' })
@@ -569,7 +577,8 @@ export function App() {
569
577
  // (01-sharing §6.1 step 3) - the stage mounts any manifest frame, so no
570
578
  // board lookup is needed
571
579
  enterFocus(bootHash.focus.at, { ...bootHash.focus, deep: true })
572
- } else if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
580
+ } else if (ok && bootHash.play?.slides) enterSlides(bootHash.play)
581
+ else if (ok && bootHash.play) enterPlay(bootHash.play) // #/p/<board> alone = board start
573
582
  else if (ok && PUBLISHED) {
574
583
  // published landing: explicit `open` beats the type default; canvas and
575
584
  // board land on the canvas surface (board-scoped already); a lock will
@@ -577,6 +586,7 @@ export function App() {
577
586
  const mode = landingMode(useStore.getState().board)
578
587
  if (mode === 'present') enterPlay()
579
588
  else if (mode === 'focus') enterFocus()
589
+ else if (mode === 'slides') enterSlides()
580
590
  }
581
591
  }
582
592
  void start()
@@ -619,7 +629,10 @@ export function App() {
619
629
  if (h.focus) {
620
630
  enterFocus(h.focus.at, { ...h.focus, deep: true })
621
631
  } else if (h.play) {
622
- if (s.play) playCtl.sync(h.play)
632
+ // sync only holds WITHIN a mode: a slides hash over a present stage (or
633
+ // the reverse) re-enters, which remounts the stage with the right src
634
+ if (s.play && !!h.play.slides === !!s.play.slides) playCtl.sync(h.play)
635
+ else if (h.play.slides) enterSlides(h.play)
623
636
  else enterPlay(h.play)
624
637
  } else {
625
638
  if (s.play) s.setPlay(null)
@@ -835,7 +848,9 @@ export function App() {
835
848
  else if (s.laser) s.setLaser(false) // Escape also exits laser mode (parity with comment)
836
849
  else s.interact ? setInteract(null) : select(null)
837
850
  }
838
- if (e.key === 'p') enterPlay()
851
+ // a slides board PLAYS as slides even when its landing is the canvas -
852
+ // the deck is what the board IS; landing only says where viewers arrive
853
+ if (e.key === 'p') { if (playsAsSlides(useStore.getState().board)) enterSlides(); else enterPlay() }
839
854
  // H hides all chrome (shared binary Hide-UI); press again to reveal. Not persisted,
840
855
  // so a refresh always restores it (the safety net for a forgotten toggle).
841
856
  if (e.key === 'h') { toggleHideUI(); return }
@@ -1067,11 +1082,14 @@ export function App() {
1067
1082
  const on = !!n && selection.includes(n.key)
1068
1083
  rows.push(
1069
1084
  <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" />}
1085
+ {/* every frame leads with an icon: the slide badge first (a deck
1086
+ frame is a deck frame whatever it imports), intent glyph for
1087
+ content frames, the plain rectangle for UI frames */}
1088
+ {f.slide
1089
+ ? <SlideFrameIcon size={13} className="iicon" role="img" aria-hidden={false} aria-label="slide" />
1090
+ : f.intent
1091
+ ? <IntentGlyph intent={f.intent} size={13} className="iicon" aria-label={f.intent} />
1092
+ : <FrameRectIcon size={13} className="iicon" />}
1075
1093
  {cap(f.id.split('/').slice(1).join('/') || f.id)}
1076
1094
  </div>,
1077
1095
  )
@@ -1104,7 +1122,7 @@ export function App() {
1104
1122
  <i className="sep" />
1105
1123
  {/* far right: view management - prototype, hide, collapse */}
1106
1124
  <Tip side="bottom" label={<><b>Prototype view</b><span>P</span></>}>
1107
- <button className="sh-pill-btn" onClick={() => enterPlay()}><PlayIcon size={15} /></button>
1125
+ <button className="sh-pill-btn" onClick={() => (playsAsSlides(useStore.getState().board) ? enterSlides() : enterPlay())}><PlayIcon size={15} /></button>
1108
1126
  </Tip>
1109
1127
  <HideUIButton />
1110
1128
  <Tip side="bottom" label={<><b>Collapse toolbar</b><span>⌘/</span></>}>
@@ -1169,6 +1187,15 @@ function JamToasts({ toasts }: { toasts: import('./store.ts').Toast[] }) {
1169
1187
  )
1170
1188
  }
1171
1189
 
1190
+ /** The pill's face for a human notification - the author's picture, or the
1191
+ * house initials fallback (same ladder the thread avatars use). */
1192
+ function JamAvatar({ author }: { author: { name?: string; avatar?: string; id?: string; email?: string } }) {
1193
+ const { initials, hue } = avatarFallback(author)
1194
+ return author.avatar
1195
+ ? <img className="sh-jam-ava" src={author.avatar} alt="" referrerPolicy="no-referrer" />
1196
+ : <span className="sh-jam-ava init" style={{ background: `hsl(${hue} 55% 45%)` }}>{initials}</span>
1197
+ }
1198
+
1172
1199
  /** A data-goto whose target frame is not on the current board follows the frame HOME:
1173
1200
  * the first curated board (switcher rank) that pins it is switched to and the frame
1174
1201
  * focused there - a link is navigation, and navigation never edits a board. Only a
@@ -1239,11 +1266,18 @@ async function viewNote(note: import('./store.ts').JamNote, dismiss: () => void)
1239
1266
  * `badge` shows the +N deck count (replacing View); `inert` disables inner clicks (the deck handles it). */
1240
1267
  function JamToast({ id, note, badge, inert }: { id: number; note: import('./store.ts').JamNote; badge?: number; inert?: boolean }) {
1241
1268
  const dismiss = () => useStore.getState().dismissToast(id)
1269
+ // human pills are person-first: their avatar, "name mentioned you"; the
1270
+ // agent pill keeps its frame-first shape with the mark
1271
+ const human = note.kind && note.author
1242
1272
  return (
1243
1273
  <div className="sh-toast jam">
1244
- <span className="sh-jam-mark"><ParallelogramFillIcon size={17} /></span>
1274
+ {human
1275
+ ? <JamAvatar author={note.author!} />
1276
+ : <span className="sh-jam-mark"><ParallelogramFillIcon size={17} /></span>}
1245
1277
  <div className="sh-jam-txt">
1246
- <b className="sh-jam-frame"><span className="t">{note.frameTitle ?? note.board}</span></b>
1278
+ {human
1279
+ ? <b className="sh-jam-frame"><span className="t">{note.author!.name ?? 'Someone'}</span> <span className="v">{note.kind === 'mention' ? 'mentioned you' : 'replied'}</span></b>
1280
+ : <b className="sh-jam-frame"><span className="t">{note.frameTitle ?? note.board}</span></b>}
1247
1281
  <span className="sh-jam-prev">{note.preview}</span>
1248
1282
  </div>
1249
1283
  {badge != null