@marver-design/marver 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README.md +41 -19
- package/dist/{build-BxGrHFT2.mjs → build-ByYafIhj.mjs} +37 -5
- package/dist/cli.mjs +19 -5
- package/dist/{daemon-BChkzDqQ.mjs → daemon-Bfucyf1o.mjs} +1 -1
- package/dist/{dev-DLwt3Brb.mjs → dev-yZMyQeUj.mjs} +4 -4
- package/dist/{init-BpitOqRQ.mjs → init-Dvaso7YO.mjs} +66 -2
- package/dist/{manifest-CS6krOTe.mjs → manifest-DvOmglFp.mjs} +7 -0
- package/dist/{marver-id-gate-B2uraTHS.mjs → marver-id-gate-D6By7XHj.mjs} +1 -1
- package/dist/{plugin-DNc4Jpae.mjs → plugin-BsmG5i2X.mjs} +21 -3
- package/dist/{serve-EjEqsiYa.mjs → serve-Bcwfpvhl.mjs} +3 -2
- package/dist/{shot-Cyv3GN79.mjs → shot-kbR_xzJH.mjs} +7 -1
- package/docs/live-jam.md +173 -0
- package/docs/publish.md +270 -0
- package/docs/sharing.md +333 -0
- package/docs/slides.md +134 -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 +100 -0
- package/src/client/content/index.tsx +25 -4
- package/src/client/content/slide.tsx +238 -0
- package/src/client/content/video.tsx +126 -0
- package/src/client/shell/App.tsx +30 -12
- 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 +2 -0
- package/src/client/shell/play-order.ts +22 -0
- package/src/client/shell/store.ts +29 -8
- package/src/client/shell/styles.css +17 -27
- package/src/client/stage/main.tsx +54 -3
- package/src/shared/utm.ts +3 -2
- package/templates/AGENTS-embedded.md +1 -0
- package/templates/AGENTS-studio.md +1 -0
- 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/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
|
-
|
|
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 || !
|
|
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
|
-
{
|
|
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
|
+
}
|
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, 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'
|
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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></>}>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { Component, useEffect, useRef, type ReactNode } from 'react'
|
|
13
13
|
import { useStore, boardLabel, landingMode } from './store.ts'
|
|
14
|
-
import { enterFocus, enterPlay, PlayOverlay, playCtl } from './Play.tsx'
|
|
14
|
+
import { enterFocus, enterPlay, enterSlides, PlayOverlay, playCtl } from './Play.tsx'
|
|
15
15
|
import { bootHash, parseHash, writeHash } from './hash.ts'
|
|
16
16
|
import { useComments } from './comments-store.ts'
|
|
17
17
|
|
|
@@ -43,6 +43,7 @@ export function App() {
|
|
|
43
43
|
if (s.play) return
|
|
44
44
|
const mode = landingMode(s.board)
|
|
45
45
|
if (mode === 'focus') enterFocus()
|
|
46
|
+
else if (mode === 'slides') enterSlides()
|
|
46
47
|
else enterPlay()
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -56,6 +57,7 @@ export function App() {
|
|
|
56
57
|
urlReady.current = true
|
|
57
58
|
if (!ok) return
|
|
58
59
|
if (bootHash.focus) enterFocus(bootHash.focus.at, { ...bootHash.focus, deep: true })
|
|
60
|
+
else if (bootHash.play?.slides) enterSlides(bootHash.play)
|
|
59
61
|
else if (bootHash.play) enterPlay(bootHash.play)
|
|
60
62
|
else land()
|
|
61
63
|
}
|
|
@@ -86,7 +88,10 @@ export function App() {
|
|
|
86
88
|
return
|
|
87
89
|
}
|
|
88
90
|
if (h.focus) enterFocus(h.focus.at, { ...h.focus, deep: true })
|
|
89
|
-
|
|
91
|
+
// sync only holds WITHIN a mode: same-mode restores drive the MOUNTED
|
|
92
|
+
// stage (sh:stage-set); crossing present↔slides re-enters and remounts
|
|
93
|
+
else if (h.play && s.play && !!h.play.slides === !!s.play.slides) playCtl.sync(h.play)
|
|
94
|
+
else if (h.play?.slides) enterSlides(h.play)
|
|
90
95
|
else if (h.play) enterPlay(h.play)
|
|
91
96
|
}
|
|
92
97
|
window.addEventListener('popstate', onPop)
|