@marver-design/marver 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +162 -0
- package/README.md +44 -20
- package/dist/{build-BxGrHFT2.mjs → build-DfuTQZlY.mjs} +46 -6
- package/dist/cli.mjs +21 -7
- package/dist/{daemon-BChkzDqQ.mjs → daemon-DalgvoA9.mjs} +1 -1
- package/dist/{dev-DLwt3Brb.mjs → dev-BxCmeU_H.mjs} +14 -4
- package/dist/{init-BpitOqRQ.mjs → init-QKNi9gvF.mjs} +66 -2
- package/dist/{manifest-CS6krOTe.mjs → manifest-BzxSMoDB.mjs} +24 -6
- package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
- package/dist/{plugin-DNc4Jpae.mjs → plugin-DJyjmQeh.mjs} +60 -10
- package/dist/poster-CbpzSzJu.mjs +143 -0
- package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
- package/dist/{shot-Cyv3GN79.mjs → shot-BWhoz6cU.mjs} +204 -57
- package/dist/{shot-DkkwuCZ2.mjs → shot-By1AItpD.mjs} +3 -2
- package/docs/live-jam.md +177 -0
- package/docs/publish.md +270 -0
- package/docs/sharing.md +333 -0
- package/docs/slides.md +140 -0
- package/package.json +3 -1
- package/src/client/const.ts +13 -0
- package/src/client/content/chart-engine.ts +33 -0
- package/src/client/content/chart.tsx +138 -0
- package/src/client/content/index.tsx +30 -6
- package/src/client/content/slide.tsx +238 -0
- package/src/client/content/video.tsx +223 -0
- package/src/client/frame-host/bridge.js +6 -1
- package/src/client/shell/App.tsx +59 -13
- package/src/client/shell/LockedApp.tsx +7 -2
- package/src/client/shell/Play.tsx +138 -24
- package/src/client/shell/Toolbar.tsx +12 -3
- package/src/client/shell/canvas/FrameNode.tsx +5 -3
- package/src/client/shell/hash.ts +3 -1
- package/src/client/shell/icons.tsx +3 -0
- package/src/client/shell/play-order.ts +22 -0
- package/src/client/shell/store.ts +80 -9
- package/src/client/shell/styles.css +23 -27
- package/src/client/stage/main.tsx +54 -3
- package/src/shared/utm.ts +3 -2
- package/templates/AGENTS-embedded.md +20 -4
- package/templates/AGENTS-studio.md +20 -4
- package/templates/instructions/boards.md +47 -5
- package/templates/instructions/craft.md +17 -0
- package/templates/instructions/iterate.md +109 -14
- package/templates/instructions/jam.md +18 -2
- package/templates/instructions/publish.md +7 -0
- package/templates/instructions/reference/deck-layouts.md +230 -0
- package/templates/instructions/reference/deck-story.md +110 -0
- package/templates/instructions/shape.md +15 -1
- package/templates/instructions/slides.md +402 -0
|
@@ -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') {
|
|
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) {
|
package/src/client/shell/App.tsx
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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></>}>
|