@marver-design/marver 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -0
- package/README.md +41 -19
- package/dist/{build-DmfxJc6L.mjs → build-ByYafIhj.mjs} +37 -5
- package/dist/cli.mjs +21 -7
- package/dist/{collab-BmaOjwP1.mjs → collab-mAiNZlDj.mjs} +232 -43
- package/dist/{comments-pNxjTrGN.mjs → comments-DHB_8BRa.mjs} +3 -3
- package/dist/{daemon-WjonKlPz.mjs → daemon-Bfucyf1o.mjs} +3 -3
- package/dist/{dev-BXq7yE1p.mjs → dev-yZMyQeUj.mjs} +5 -5
- package/dist/{events-C9k-ceOj.mjs → events-B3LBn74P.mjs} +3 -1
- 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-DOjnZakC.mjs → marver-id-gate-D6By7XHj.mjs} +8 -5
- package/dist/{plugin-Boy7I_JH.mjs → plugin-BsmG5i2X.mjs} +25 -7
- package/dist/{profile-ChpTPd-X.mjs → profile-BjAPAJSb.mjs} +1 -1
- package/dist/{serve-BeLLizNg.mjs → serve-Bcwfpvhl.mjs} +4 -3
- package/dist/{share-C64b1tdN.mjs → share-Gqo_Ygqw.mjs} +14 -15
- package/dist/{shot-Cyv3GN79.mjs → shot-kbR_xzJH.mjs} +7 -1
- package/dist/{summary-Bf9tiGrE.mjs → summary-C7CAypbB.mjs} +1 -1
- package/dist/{sync-WYxDN9IT.mjs → sync-BZaCWqK-.mjs} +1 -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 +49 -15
- package/src/client/shell/Comments.tsx +94 -17
- 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/comments-store.ts +59 -4
- package/src/client/shell/hash.ts +3 -1
- package/src/client/shell/icons.tsx +2 -0
- package/src/client/shell/mentions.ts +108 -1
- package/src/client/shell/play-order.ts +22 -0
- package/src/client/shell/sound.ts +32 -0
- package/src/client/shell/store.ts +41 -12
- package/src/client/shell/styles.css +58 -27
- package/src/client/stage/main.tsx +54 -3
- package/src/shared/events.ts +12 -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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* top-right stack; the thread card opens beside its pin. Pins keep a screen-space
|
|
5
5
|
* size via --sh-inv (the vbadge pattern) so zoom never shrinks them away.
|
|
6
6
|
*/
|
|
7
|
-
import { useEffect, useRef, useState } from 'react'
|
|
7
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
8
8
|
import { createPortal } from 'react-dom'
|
|
9
9
|
import { avatarFallback, useComments } from './comments-store.ts'
|
|
10
10
|
import { useStore, type Node } from './store.ts'
|
|
@@ -13,7 +13,7 @@ import { canvasCtl } from './canvas/ctl.ts'
|
|
|
13
13
|
import { bootHash, buildHash, parseHash, writeHash } from './hash.ts'
|
|
14
14
|
import { ArrowUpIcon, CheckIcon, CheckSquareOffsetIcon, LinkIcon, ParallelogramFillIcon, PencilSimpleIcon, PlusIcon, XIcon } from './icons.tsx'
|
|
15
15
|
import { Tip } from './Tip.tsx'
|
|
16
|
-
import {
|
|
16
|
+
import { mentionAlerts, mentionPeople, mentionQueryAt, parseBody, type MentionPerson } from './mentions.ts'
|
|
17
17
|
import { ROUTE } from '../const.ts'
|
|
18
18
|
import type { AgentMeta, Thread } from '../../shared/events.ts'
|
|
19
19
|
|
|
@@ -182,13 +182,15 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
182
182
|
const flankBadge = useStore((s) => !!s.frameFor(node)?.variantGroup)
|
|
183
183
|
const flankShim = useStore((s) => s.working.includes(frameId))
|
|
184
184
|
const drafting = draft?.nodeKey === node.key
|
|
185
|
+
const alertIds = useMentionAlerts()
|
|
185
186
|
if (!show || (!open.length && !drafting)) return null
|
|
186
187
|
|
|
187
188
|
// inactive frame: the stack - count + avatars, top-right
|
|
188
189
|
const engaged = selected || open.some((t) => t.id === active) || drafting
|
|
189
190
|
if (!engaged && open.length) {
|
|
190
191
|
return (
|
|
191
|
-
<button className=
|
|
192
|
+
<button className={`cm-stack sh-no-pan${open.some((t) => alertIds.has(t.id)) ? ' mention' : ''}`}
|
|
193
|
+
onClick={(e) => { e.stopPropagation(); useStore.getState().select(node.key); const hit = open.find((t) => alertIds.has(t.id)) ?? open[0]; if (hit) setActive(hit.id) }}>
|
|
192
194
|
<MarkerFace threads={open} />
|
|
193
195
|
</button>
|
|
194
196
|
)
|
|
@@ -234,7 +236,7 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
234
236
|
const { x, y, orphan } = pinPos(t)
|
|
235
237
|
const isActive = t.id === active
|
|
236
238
|
return (
|
|
237
|
-
<div key={t.id} className={`cm-pin sh-no-pan${isActive ? ' on' : ''}${isActive && cardSide === 'r' ? ' tail-r' : ''}${orphan ? ' orphan' : ''}`}
|
|
239
|
+
<div key={t.id} className={`cm-pin sh-no-pan${isActive ? ' on' : ''}${isActive && cardSide === 'r' ? ' tail-r' : ''}${orphan ? ' orphan' : ''}${alertIds.has(t.id) ? ' mention' : ''}`}
|
|
238
240
|
style={{ left: x, top: y, ...hueVars(anchorHue(t.anchor)) }}
|
|
239
241
|
onPointerDown={(e) => e.stopPropagation()}
|
|
240
242
|
onClick={(e) => { e.stopPropagation(); setActive(t.id === active ? null : t.id) }}
|
|
@@ -258,17 +260,51 @@ export function CommentLayer({ node, frameId, iframe }: { node: Node; frameId: s
|
|
|
258
260
|
|
|
259
261
|
const AT_TIP = "Read like any other comment. Marver won't act on this unless the owner promotes it."
|
|
260
262
|
|
|
261
|
-
/**
|
|
262
|
-
*
|
|
263
|
+
/** The mentionable set, derived from everything the client has seen (published:
|
|
264
|
+
* projected ids + names; dev: canonical emails). The viewer themself is out -
|
|
265
|
+
* this is the COMPOSER's set (you cannot mention yourself). */
|
|
266
|
+
function usePeople(): MentionPerson[] {
|
|
267
|
+
const events = useComments((s) => s.events)
|
|
268
|
+
const me = useComments((s) => s.me)
|
|
269
|
+
return mentionPeople(events, me ?? undefined)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Everyone, self included - the RENDERING set: a mention of you must light up
|
|
273
|
+
* hardest of all, not disappear (the bug that shipped: the excluded self made
|
|
274
|
+
* your own mentions render as plain text exactly for the person they were for). */
|
|
275
|
+
function useRenderPeople(): { people: MentionPerson[]; isMe: (p: MentionPerson) => boolean } {
|
|
276
|
+
const events = useComments((s) => s.events)
|
|
277
|
+
const me = useComments((s) => s.me)
|
|
278
|
+
const people = useMemo(() => mentionPeople(events), [events])
|
|
279
|
+
const isMe = (p: MentionPerson) =>
|
|
280
|
+
(!!p.id && p.id === me?.id) || (!!p.email && !!me?.email && p.email.toLowerCase() === me.email.toLowerCase())
|
|
281
|
+
return { people, isMe }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Threads holding an unseen mention of me - drives the pin/stack pulse. */
|
|
285
|
+
function useMentionAlerts(): Set<string> {
|
|
286
|
+
const events = useComments((s) => s.events)
|
|
287
|
+
const me = useComments((s) => s.me)
|
|
288
|
+
const seen = useComments((s) => s.mentionSeen)
|
|
289
|
+
return useMemo(() => new Set(mentionAlerts(events, me, seen)), [events, me, seen])
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A comment body with mentions styled. `@marver`: owner-authored → bold accent (a live
|
|
293
|
+
* trigger); anyone else's → plain + a teaching tooltip (context, not a command).
|
|
294
|
+
* `@Person` (v1.1) → the person chip; the same segmentation that derived the
|
|
295
|
+
* event's mentions, so what lit up is what notified. */
|
|
263
296
|
function CommentBody({ body, owner }: { body?: string; owner: boolean }) {
|
|
297
|
+
const { people, isMe } = useRenderPeople()
|
|
264
298
|
if (!body) return null
|
|
265
299
|
return (
|
|
266
300
|
<p className="cm-body">
|
|
267
|
-
{
|
|
268
|
-
? <span key={i}>{s.text}</span>
|
|
269
|
-
:
|
|
270
|
-
? <span key={i}
|
|
271
|
-
:
|
|
301
|
+
{parseBody(body, people).map((s, i) => s.person
|
|
302
|
+
? <span key={i} className={isMe(s.person) ? 'cm-at person me' : 'cm-at person'}>{s.text}</span>
|
|
303
|
+
: !s.marver
|
|
304
|
+
? <span key={i}>{s.text}</span>
|
|
305
|
+
: owner
|
|
306
|
+
? <span key={i} className="cm-at owner">{s.text}</span>
|
|
307
|
+
: <Tip key={i} side="top" label={<span className="cm-at-tip">{AT_TIP}</span>}><span className="cm-at">{s.text}</span></Tip>)}
|
|
272
308
|
</p>
|
|
273
309
|
)
|
|
274
310
|
}
|
|
@@ -339,6 +375,27 @@ function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFo
|
|
|
339
375
|
const ref = useRef<HTMLTextAreaElement>(null)
|
|
340
376
|
const hlRef = useRef<HTMLDivElement>(null)
|
|
341
377
|
const [busy, setBusy] = useState(false)
|
|
378
|
+
// ---- @-mention typeahead (v1.1): the token under the caret filters the people the
|
|
379
|
+
// client has seen; picking completes `@Label ` in place. Escape dismisses THIS token
|
|
380
|
+
// only (the composer keeps its own Escape = cancel semantics when no list is open).
|
|
381
|
+
const people = usePeople()
|
|
382
|
+
const [caret, setCaret] = useState(0)
|
|
383
|
+
const [pick, setPick] = useState(0)
|
|
384
|
+
const [dismissed, setDismissed] = useState<number | null>(null)
|
|
385
|
+
const q = mentionQueryAt(value, caret)
|
|
386
|
+
const matches = q && dismissed !== q.start
|
|
387
|
+
? people.filter((p) => p.label.toLowerCase().startsWith(q.query.toLowerCase())).slice(0, 5)
|
|
388
|
+
: []
|
|
389
|
+
const listOpen = matches.length > 0
|
|
390
|
+
useEffect(() => { setPick(0) }, [q?.start, q?.query])
|
|
391
|
+
const track = () => setCaret(ref.current?.selectionStart ?? 0)
|
|
392
|
+
const choose = (p: MentionPerson) => {
|
|
393
|
+
if (!q) return
|
|
394
|
+
const pos = q.start + p.label.length + 2 // '@' + label + ' '
|
|
395
|
+
onChange(value.slice(0, q.start) + '@' + p.label + ' ' + value.slice(caret))
|
|
396
|
+
setCaret(pos)
|
|
397
|
+
requestAnimationFrame(() => { const el = ref.current; if (el) { el.focus(); el.setSelectionRange(pos, pos) } })
|
|
398
|
+
}
|
|
342
399
|
// +2: scrollHeight excludes the 1px borders, but border-box height includes them - without
|
|
343
400
|
// it every line is squeezed 2px and the single-line pill renders off-center
|
|
344
401
|
const grow = () => { const el = ref.current; if (el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight + 2, 132) + 'px' } }
|
|
@@ -352,18 +409,38 @@ function CommentInput({ value, onChange, onSubmit, onCancel, placeholder, autoFo
|
|
|
352
409
|
}
|
|
353
410
|
return (
|
|
354
411
|
<div className="cm-inputwrap">
|
|
355
|
-
{/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver
|
|
356
|
-
its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted)
|
|
412
|
+
{/* Live mention highlight: a mirror layer behind the transparent-text textarea, so @marver
|
|
413
|
+
shows its trigger colour AS YOU TYPE (owner = accent-blue "this will run"; else muted)
|
|
414
|
+
and a completed @Person wears the chip it will render with. */}
|
|
357
415
|
<div className="cm-hl" ref={hlRef} aria-hidden>
|
|
358
|
-
{
|
|
359
|
-
? <span key={i} className=
|
|
360
|
-
:
|
|
416
|
+
{parseBody(value, people).map((s, i) => s.person
|
|
417
|
+
? <span key={i} className="cm-at person">{s.text}</span>
|
|
418
|
+
: s.marver
|
|
419
|
+
? <span key={i} className={owner ? 'cm-at owner' : 'cm-at'}>{s.text}</span>
|
|
420
|
+
: <span key={i}>{s.text}</span>)}
|
|
361
421
|
{value.endsWith('\n') ? '' : ''}
|
|
362
422
|
</div>
|
|
423
|
+
{listOpen && (
|
|
424
|
+
<div className="cm-mentionlist" role="listbox">
|
|
425
|
+
{matches.map((p, i) => (
|
|
426
|
+
<button key={p.id ?? p.email} type="button" className={i === pick ? 'on' : ''} role="option" aria-selected={i === pick}
|
|
427
|
+
onMouseDown={(e) => { e.preventDefault(); choose(p) }} onMouseEnter={() => setPick(i)}>
|
|
428
|
+
<Avatar author={{ ...p, name: p.label }} size={18} /><span>{p.label}</span>
|
|
429
|
+
</button>
|
|
430
|
+
))}
|
|
431
|
+
</div>
|
|
432
|
+
)}
|
|
363
433
|
<textarea ref={ref} rows={1} value={value} placeholder={placeholder} disabled={busy} spellCheck={false}
|
|
364
|
-
onChange={(e) => onChange(e.target.value)
|
|
434
|
+
onChange={(e) => { onChange(e.target.value); setDismissed(null); setCaret(e.target.selectionStart ?? 0) }}
|
|
435
|
+
onInput={grow} onScroll={syncScroll} onClick={track} onKeyUp={track}
|
|
365
436
|
onKeyDown={(e) => {
|
|
366
437
|
e.stopPropagation()
|
|
438
|
+
if (listOpen) {
|
|
439
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); setPick((v) => (v + 1) % matches.length); return }
|
|
440
|
+
if (e.key === 'ArrowUp') { e.preventDefault(); setPick((v) => (v + matches.length - 1) % matches.length); return }
|
|
441
|
+
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); choose(matches[pick] ?? matches[0]); return }
|
|
442
|
+
if (e.key === 'Escape') { setDismissed(q!.start); return }
|
|
443
|
+
}
|
|
367
444
|
if (e.key === 'Escape') { onCancel?.(); return }
|
|
368
445
|
if (e.key === 'Enter') {
|
|
369
446
|
if ((e.nativeEvent as unknown as { isComposing?: boolean }).isComposing) return // IME: mid-composition Enter must not send
|
|
@@ -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)
|
|
@@ -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>
|