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