mdeck 3.11.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +3 -3
  3. package/assets/base.css +2 -0
  4. package/docs/reference/authoring.md +48 -7
  5. package/docs/site/content/appearance.md +1 -1
  6. package/docs/site/content/audience.md +36 -8
  7. package/docs/site/content/commands.md +4 -2
  8. package/docs/site/content/drawing.md +4 -4
  9. package/docs/site/content/getting-started.md +2 -32
  10. package/docs/site/content/install.md +3 -1
  11. package/docs/site/content/presenting.md +29 -8
  12. package/docs/site/content/writing.md +27 -0
  13. package/examples/showcase/slides.ar.md +5 -5
  14. package/examples/showcase/slides.de.md +5 -5
  15. package/examples/showcase/slides.de.results.json +27 -0
  16. package/examples/showcase/slides.en.md +5 -5
  17. package/package.json +1 -1
  18. package/skills/write-slides/SKILL.md +4 -1
  19. package/src/build/check.js +27 -6
  20. package/src/build/components.js +5 -3
  21. package/src/build/homePlugin.js +64 -14
  22. package/src/build/include.js +249 -0
  23. package/src/build/inkPlugin.js +12 -2
  24. package/src/build/relayAccess.js +3 -1
  25. package/src/build/renderCheck.js +23 -3
  26. package/src/build/slidesPlugin.js +64 -16
  27. package/src/build/timingPlugin.js +34 -18
  28. package/src/cli/args.js +3 -3
  29. package/src/cli/index.js +49 -10
  30. package/src/components/Bibliography.jsx +7 -2
  31. package/src/components/Poll.jsx +2 -2
  32. package/src/components/QrCodeTag.jsx +2 -2
  33. package/src/components/WordCloud.jsx +7 -6
  34. package/src/components/activity.jsx +11 -8
  35. package/src/components/poll.css +2 -0
  36. package/src/core/icons.js +6 -1
  37. package/src/core/labels.js +3 -0
  38. package/src/core/parseSlides.js +14 -4
  39. package/src/core/reactions.js +27 -0
  40. package/src/core/resultsReport.js +153 -0
  41. package/src/core/source.js +8 -1
  42. package/src/core/validateDeck.js +6 -2
  43. package/src/home/Home.jsx +38 -93
  44. package/src/home/home.css +5 -17
  45. package/src/layouts/renderSlide.jsx +13 -1
  46. package/src/live/answer/answer.css +12 -1
  47. package/src/live/answer/answer.js +32 -4
  48. package/src/live/client.js +23 -4
  49. package/src/live/rooms.js +29 -7
  50. package/src/live/server.js +15 -1
  51. package/src/runtime/KeyHelp.jsx +115 -0
  52. package/src/runtime/PresenterChrome.jsx +168 -0
  53. package/src/runtime/Reactions.jsx +56 -0
  54. package/src/runtime/deck-stage.js +322 -17
  55. package/src/runtime/helpKeys.js +75 -0
  56. package/src/runtime/ink/room.js +6 -6
  57. package/src/runtime/main.jsx +396 -303
  58. package/src/runtime/slideControls.js +60 -0
  59. package/src/runtime/useTalkTimer.js +4 -2
@@ -33,14 +33,21 @@ import { setCalloutLabels } from './markedSetup'
33
33
  import { setDeckLanguage, deckLanguage, stageLabels, t } from '../core/labels.js'
34
34
  import { textDirection } from '../core/languages.js'
35
35
  import { Icon } from '../components/Icon.jsx'
36
- import { ConnectedViews } from '../components/ConnectedViews.jsx'
37
36
  import './deck-stage.js'
38
37
  import { normalMode, hotspotMode } from './hotspots.js'
39
38
  import { useTalkTimer } from './useTalkTimer.js'
40
39
  import { devPath } from '../core/devPath.js'
41
40
  import { setView } from './shared.js'
41
+ import { watchSlideControls, pressSlideControl } from './slideControls.js'
42
+ import QrCodeTag from '../components/QrCodeTag.jsx'
43
+ import { ToolButton, Popover, PanelSection, SlideControls, Connections, tool } from './PresenterChrome.jsx'
44
+ import mdeckMark from '../../assets/logo/mark.svg'
42
45
  import { RehearsalSummary } from './RehearsalSummary.jsx'
43
46
  import { planFor, scheduleAt, formatClock } from '../core/timing.js'
47
+ import { KeyHelp, useKeyHelp, attachKeyHelp } from './KeyHelp.jsx'
48
+ import { isHelpKey } from './helpKeys.js'
49
+ import { Reactions } from './Reactions.jsx'
50
+ import { reactionsOf } from '../core/reactions.js'
44
51
 
45
52
  const PresenterIcon = ({ name, size = 18 }) => <Icon name={name} size={size} style={{ display: 'block' }} />
46
53
 
@@ -108,10 +115,11 @@ function languageName(lang) {
108
115
  // announcement, the views connected), so the answer is kept until the text
109
116
  // changes: finding the tags reads the whole deck, which on an iPad made each
110
117
  // slide change noticeably slower.
111
- let activitiesOf = null, activitiesFound = false
118
+ // A deck with reactions (`reactions:`) wants the phones too, between activities.
119
+ let activitiesOf = null, activitiesFound = false, reactionsWanted = false
112
120
  const hasActivities = () => {
113
121
  if (activitiesOf !== slidesContent) { activitiesOf = slidesContent; activitiesFound = roomsIn(slidesContent).length > 0 }
114
- return activitiesFound
122
+ return activitiesFound || reactionsWanted
115
123
  }
116
124
  // The heading the phones show for a room, found once per deck.
117
125
  const titlesByDeck = new WeakMap()
@@ -140,17 +148,10 @@ function announceSlide(deck, index, { initial = false } = {}) {
140
148
  // Whether this presenter screen moves the phones along, shown only for decks
141
149
  // with activities: a failure used to be silent.
142
150
  const STEERING = {
143
- 'no-code': 'Phones do not follow: this browser has no server key. Open the presenter view once with ?serverkey=… (the launch page has a link).',
144
- 'wrong-code': "Phones do not follow: the server does not accept this browser's presenter code.",
145
- unreachable: 'Phones do not follow: the server does not answer.',
146
- 'other-screen': 'Phones follow another screen, where this presentation was moved on more recently. Change the slide here to take them back.',
147
- }
148
- function SteeringNote() {
149
- const steering = useSteering()
150
- if (!hasActivities() || !steering) return null
151
- return <div role="status" style={{ flexShrink: 0, padding: '6px 10px', borderRadius: '5px', fontSize: '12px', lineHeight: 1.4, border: `1px solid ${steering.ok ? '#2c4a35' : '#5c2b28'}`, color: steering.ok ? '#81c995' : '#f28b82' }}>
152
- {steering.ok ? 'Phones follow this presentation.' : STEERING[steering.reason]}
153
- </div>
151
+ 'no-code': 'Phones don’t follow: no server key here.',
152
+ 'wrong-code': 'Phones don’t follow: key refused.',
153
+ unreachable: 'Phones don’t follow: server not answering.',
154
+ 'other-screen': 'Phones follow another screen. Change slide to take them back.',
154
155
  }
155
156
 
156
157
  function injectSpeakerNotes(slides) {
@@ -165,12 +166,15 @@ function injectSpeakerNotes(slides) {
165
166
  }
166
167
 
167
168
  // URL for the presenter's own iframe and preview pane (uses postMessage)
168
- function buildChildUrl(theme, palette, appearance, slideIndex = null, { draw = false } = {}) {
169
+ function buildChildUrl(theme, palette, appearance, slideIndex = null, { draw = false, remote = false } = {}) {
169
170
  const url = new URL(window.location.href)
170
171
  url.searchParams.delete('v')
171
172
  url.searchParams.set('view', 'deck')
172
173
  url.searchParams.set('embedded', '1')
173
174
  if (draw) url.searchParams.set('draw', '1')
175
+ // A phone's small slide is for looking; it does not start drawing.
176
+ if (remote) url.searchParams.set('remote', '1')
177
+ else url.searchParams.delete('remote')
174
178
  if (theme) url.searchParams.set('theme', theme)
175
179
  else url.searchParams.delete('theme')
176
180
  setLookParams(url, palette, appearance)
@@ -275,6 +279,32 @@ function SlideJump({ index, total, onJump, pad = 0, style }) {
275
279
  )
276
280
  }
277
281
 
282
+ const PRESENTER_LAYOUTS = ['speaker', 'slide', 'remote']
283
+ // A slide's first heading, as plain words, for the phone's "Next:" line.
284
+ function slideTitleOf(slide) {
285
+ const texts = [slide?.content ?? '', ...Object.values(slide?.regions ?? {}).map(region => region.content ?? '')]
286
+ for (const text of texts) {
287
+ const heading = text.replace(/^(```|~~~)[\s\S]*?^\1/gm, '').match(/^#{1,3}\s+(.+)$/m)
288
+ if (heading) return heading[1].replace(/[*_`]/g, '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').trim()
289
+ }
290
+ return ''
291
+ }
292
+ // A touch screen gets the slide alone, a computer the speaker layout. The
293
+ // phone remote is chosen, not guessed: its pairing code opens ?layout=remote.
294
+ function defaultLayout() {
295
+ return matchMedia('(pointer: coarse)').matches ? 'slide' : 'speaker'
296
+ }
297
+ // ?layout= in the address (from a pairing code) chooses the layout; the
298
+ // device then remembers it like a layout chosen with the buttons.
299
+ function requestedLayout() {
300
+ const url = new URL(window.location.href)
301
+ const asked = url.searchParams.get('layout')
302
+ if (!asked) return null
303
+ url.searchParams.delete('layout')
304
+ try { history.replaceState(history.state, '', url) } catch {}
305
+ return PRESENTER_LAYOUTS.includes(asked) ? asked : null
306
+ }
307
+
278
308
  function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
279
309
  const [index, setIndex] = useState(0)
280
310
  // The notes, slide count and phones follow the language shown.
@@ -289,8 +319,25 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
289
319
  const [palette, setPalette] = useState(deckConfig.palette ?? '')
290
320
  const [appearance, setAppearance] = useState(deckConfig.appearance ?? '')
291
321
  const [audienceConnected, setAudienceConnected] = useState(false)
292
- const [noteSize, setNoteSize] = useState(13)
322
+ // Larger notes in the phone remote, whether the address or the device chose it.
323
+ const [noteSize, setNoteSize] = useState(() => {
324
+ let saved = null
325
+ try { saved = localStorage.getItem('mdeck-presenter-layout') } catch {}
326
+ return (new URL(window.location.href).searchParams.get('layout') ?? saved) === 'remote' ? 17 : 13
327
+ })
293
328
  const [inking, setInking] = useState(false)
329
+ // Black screen (B): the audience's screens are black, this view veils its slide.
330
+ const [blank, setBlank] = useState(false)
331
+ // The join code over the slide (J), on every screen.
332
+ const [join, setJoin] = useState(false)
333
+ // The phone clicker: the slide's controls, as its frame reports them (slideControls.js).
334
+ const [slideControls, setSlideControls] = useState({ items: [], hotspots: 0, hotspot: null })
335
+ // The overview (O) in this view's slide frame: keys go to it while it is open.
336
+ const [overviewOpen, setOverviewOpen] = useState(false)
337
+ const overviewRef = useRef(false)
338
+ overviewRef.current = overviewOpen
339
+ // The keys that work now, over everything (?).
340
+ const [helpOpen, setHelpOpen] = useKeyHelp()
294
341
  // Hotspots: the deck's setting until the presenter switches them (H).
295
342
  const deckHotspots = (normalMode(deckConfig.hotspots) ?? 'off') !== 'off'
296
343
  const [hotspotsForced, setHotspotsForced] = useState(null)
@@ -312,10 +359,25 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
312
359
  return () => document.removeEventListener('gesturestart', stop)
313
360
  }, [])
314
361
  // 'slide': the slide fills the screen (an iPad); the notes open as a drawer.
362
+ // 'remote': a phone in the hand, with the slide small, the notes and big
363
+ // buttons to go on and back.
315
364
  const [layout, setLayout] = useState(() => {
316
- try { const saved = localStorage.getItem('mdeck-presenter-layout'); if (saved) return saved } catch {}
317
- return matchMedia('(pointer: coarse)').matches ? 'slide' : 'speaker'
365
+ const asked = requestedLayout()
366
+ if (asked) { try { localStorage.setItem('mdeck-presenter-layout', asked) } catch {} return asked }
367
+ try { const saved = localStorage.getItem('mdeck-presenter-layout'); if (PRESENTER_LAYOUTS.includes(saved)) return saved } catch {}
368
+ return defaultLayout()
318
369
  })
370
+ const remoteLayout = layout === 'remote'
371
+ // A phone's screen must not lock in the middle of the talk (where the
372
+ // browser lets a page ask: https or this computer).
373
+ useEffect(() => {
374
+ if (!remoteLayout || !navigator.wakeLock) return
375
+ let lock = null, gone = false
376
+ const ask = () => { if (document.visibilityState === 'visible') navigator.wakeLock.request('screen').then(held => { if (gone) held.release(); else lock = held }, () => {}) }
377
+ ask()
378
+ document.addEventListener('visibilitychange', ask)
379
+ return () => { gone = true; document.removeEventListener('visibilitychange', ask); lock?.release().catch(() => {}) }
380
+ }, [remoteLayout])
319
381
  const [drawerOpen, setDrawerOpen] = useState(false)
320
382
  const slideLayout = layout === 'slide'
321
383
  function chooseLayout(next) {
@@ -331,15 +393,6 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
331
393
  const fullscreenLabel = isFullscreen ? 'Exit full screen (F)' : 'Full screen (F)'
332
394
  const fullscreenIcon = isFullscreen ? 'fullscreen-exit' : 'fullscreen'
333
395
 
334
- const [paletteOpen, setPaletteOpen] = useState(false)
335
- // The theme row shows unless the presenter folded it, like the ink toolbar,
336
- // into one round button.
337
- const [lookFolded, setLookFolded] = useState(() => { try { return localStorage.getItem('mdeck-presenter-look') === 'folded' } catch { return false } })
338
- function foldLook(folded) {
339
- setLookFolded(folded)
340
- setPaletteOpen(false)
341
- try { localStorage.setItem('mdeck-presenter-look', folded ? 'folded' : 'open') } catch {}
342
- }
343
396
  const [themeModalOpen, setThemeModalOpen] = useState(false)
344
397
  // The clock, and the slides' times (`time:`) to hold it against. Times a
345
398
  // rehearsal saved count at once; the deck file is not read again mid-talk.
@@ -370,6 +423,7 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
370
423
  summaryRef.current = !!summary
371
424
 
372
425
  const iframeRef = useRef(null)
426
+ const toggleOverviewRef = useRef(null)
373
427
  const previewRef = useRef(null)
374
428
  const audienceRef = useRef(null)
375
429
  const bcRef = useRef(null)
@@ -380,7 +434,6 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
380
434
  const langRef = useRef(lang)
381
435
  langRef.current = lang
382
436
  const stateRef = useRef({ index: 0, step: -1 })
383
- const paletteDropRef = useRef(null)
384
437
  indexRef.current = index
385
438
  // Phones see the look of the slide this view shows, read from its frame.
386
439
  useEffect(() => { setLookSource(() => iframeRef.current?.contentDocument) }, [])
@@ -398,7 +451,7 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
398
451
  useEffect(() => { if (palette && !offered.some(p => p.id === palette)) setPalette('') }, [theme])
399
452
  const notes = useMemo(() => slides.map(s => s.meta?.notes ?? ''), [slides])
400
453
  // Preserve current slide when theme/palette causes an iframe reload
401
- const iframeSrc = useMemo(() => buildChildUrl(theme, palette, appearance, indexRef.current, { draw: true }), [theme, palette, appearance])
454
+ const iframeSrc = useMemo(() => buildChildUrl(theme, palette, appearance, indexRef.current, { draw: true, remote: remoteLayout }), [theme, palette, appearance, remoteLayout])
402
455
  // previewSrc only recomputes on a change of look; slide changes use postMessage
403
456
  const previewSrc = useMemo(
404
457
  () => buildChildUrl(theme, palette, appearance, indexRef.current + 1),
@@ -432,6 +485,11 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
432
485
  }
433
486
  if (source !== iframeRef.current?.contentWindow) return
434
487
  if (typeof data?.inkMode === 'boolean') { setInking(data.inkMode); return }
488
+ // The clicker's slide frame says what can be done on the slide shown.
489
+ if (data?.slideControls) { setSlideControls(data.slideControls); return }
490
+ if (typeof data?.overview === 'boolean') { setOverviewOpen(data.overview); return }
491
+ // ? typed while the slide's frame had the keyboard.
492
+ if (data?.keyHelp === 'toggle') { setHelpOpen(open => !open); return }
435
493
  // Another screen (a paired iPad) switched the language; the notes follow.
436
494
  if (LANGUAGES.includes(data?.deckLanguage)) { setLang(data.deckLanguage); return }
437
495
  if (!data?.deckStateChanged) return
@@ -454,6 +512,8 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
454
512
  stateRef.current = state
455
513
  atEndRef.current = !!data.atEnd
456
514
  setIndex(i)
515
+ setBlank(!!state.blank)
516
+ setJoin(!!state.join)
457
517
  if (data.reason !== 'sync') {
458
518
  bcRef.current?.postMessage({ deckControl: { command: 'setState', value: state, order: orderRef.current.stamp() } })
459
519
  }
@@ -473,6 +533,12 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
473
533
  if (e.target?.matches?.('input, select, textarea')) return
474
534
  if (e.metaKey || e.ctrlKey || e.altKey) return
475
535
  if (summaryRef.current) return
536
+ if (overviewRef.current && ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown', 'PageDown', 'PageUp', 'Home', 'End', 'Enter', ' ', 'Escape', 'o', 'O'].includes(e.key)) {
537
+ e.preventDefault()
538
+ sendTo(iframeRef.current?.contentWindow, 'overviewKey', e.key)
539
+ return
540
+ }
541
+ if (e.key === 'o' || e.key === 'O') { e.preventDefault(); toggleOverviewRef.current(); return }
476
542
  if (e.key === 't' || e.key === 'T') { e.preventDefault(); timerRef.current.toggle(); return }
477
543
  const cmd =
478
544
  (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') ? 'next' :
@@ -483,7 +549,10 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
483
549
  if (e.key === 'n' || e.key === 'N') { e.preventDefault(); setDrawerOpen(open => !open); return }
484
550
  if ((e.key === 'l' || e.key === 'L') && LANGUAGES.length) { e.preventDefault(); nextLanguage(); return }
485
551
  if (e.key === 'h' || e.key === 'H') { e.preventDefault(); toggleHotspots(); return }
486
- // Digits zoom to hotspots (or jump to slides without them); Escape zooms out.
552
+ // B, or a clicker's blank button (a full stop).
553
+ if (e.key === 'b' || e.key === 'B' || e.key === '.') { e.preventDefault(); sendTo(iframeRef.current?.contentWindow, 'blank', 'toggle'); return }
554
+ if ((e.key === 'j' || e.key === 'J') && (hasActivities() || reactionsOf(firstConfig))) { e.preventDefault(); sendTo(iframeRef.current?.contentWindow, 'join', 'toggle'); return }
555
+ // Digits zoom to hotspots; Escape zooms out.
487
556
  if (/^[0-9]$/.test(e.key) || e.key === 'Escape') { if (e.key !== 'Escape') e.preventDefault(); sendTo(iframeRef.current?.contentWindow, 'hotspotKey', e.key); return }
488
557
  if (!cmd) return
489
558
  e.preventDefault()
@@ -511,15 +580,6 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
511
580
  return () => window.removeEventListener('keydown', handler)
512
581
  }, [themeModalOpen])
513
582
 
514
- // Close palette dropdown on outside click
515
- useEffect(() => {
516
- if (!paletteOpen) return
517
- function handler(e) {
518
- if (!paletteDropRef.current?.contains(e.target)) setPaletteOpen(false)
519
- }
520
- document.addEventListener('mousedown', handler)
521
- return () => document.removeEventListener('mousedown', handler)
522
- }, [paletteOpen])
523
583
 
524
584
  // When theme/palette changes, tell the audience to hot-swap its theme without
525
585
  // a reload. Phones pick it up with the next announcement.
@@ -540,6 +600,13 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
540
600
  }, [lang])
541
601
  const nextLanguage = () => setLang(current => LANGUAGES[(LANGUAGES.indexOf(current) + 1) % LANGUAGES.length])
542
602
 
603
+ const toggleBlank = () => sendTo(iframeRef.current?.contentWindow, 'blank', 'toggle')
604
+ // In the slide-only layout the notes drawer would cover the grid.
605
+ const toggleOverview = () => { setDrawerOpen(false); sendTo(iframeRef.current?.contentWindow, 'overview', 'toggle') }
606
+ toggleOverviewRef.current = toggleOverview
607
+ const blankLabel = blank ? 'Show the slide again (B)' : 'Black screen for the audience (B)'
608
+ const blankOn = { background: '#f0f0f0', borderColor: '#f0f0f0', color: '#111' }
609
+
543
610
  // Nav buttons drive the presenter iframe; audience follows via slideIndexChanged
544
611
  function navCommand(cmd, value) {
545
612
  if (cmd === 'next' && atRehearsalEnd()) return finishRehearsal()
@@ -556,6 +623,8 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
556
623
  setAudienceConnected(true)
557
624
  }
558
625
 
626
+ // Whether the phones follow this screen; told only when they do not.
627
+ const steering = useSteering()
559
628
  const note = notes[index] || ''
560
629
  const hasNext = index + 1 < slides.length
561
630
 
@@ -569,268 +638,30 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
569
638
  const deltaText = Math.abs(delta) < 30 ? 'on time' : delta > 0 ? `${formatClock(delta)} behind` : `${formatClock(-delta)} ahead`
570
639
  const deltaColor = Math.abs(delta) < 30 ? '#888' : delta > 0 ? '#f28b82' : '#81c995'
571
640
  const pill = { ...S.btn, minWidth: '40px', height: '40px', padding: '0 10px', fontSize: '15px' }
641
+ const notesHtml = note
642
+ ? marked.parse(renderCitations(note).markdown)
643
+ : '<p>No notes — add a <code>:::notes</code> block to the slide.</p>'
644
+ const notesDir = textDirection(deckConfig.lang) === 'rtl' ? 'rtl' : undefined
572
645
 
573
- return (
574
- <div class={`presenter presenter--${layout}`} style={{ display: 'grid', gridTemplateColumns: slideLayout ? '1fr' : '2fr 1fr', height: '100dvh', background: '#111', overflow: 'hidden', touchAction: 'pan-x pan-y' }}>
575
- <iframe
576
- ref={iframeRef}
577
- title="Presenter deck"
578
- src={iframeSrc}
579
- style={{ width: '100%', height: '100%', border: '0' }}
580
- />
581
- {slideLayout && (
582
- <div class="presenter-pill" role="toolbar" aria-label="Presenter" style={{ position: 'fixed', top: 'max(10px, env(safe-area-inset-top))', right: 'max(10px, env(safe-area-inset-right))', zIndex: 20, display: 'flex', gap: '4px', alignItems: 'center', padding: '4px', borderRadius: '12px', background: 'rgba(17,17,17,0.88)', border: '1px solid #2a2a2a', color: '#ccc', fontFamily: 'ui-sans-serif, system-ui, sans-serif', fontSize: '13px', fontVariantNumeric: 'tabular-nums' }}>
583
- <SlideJump index={index} total={slides.length} onJump={i => navCommand('goTo', i)} style={{ padding: '0 4px', color: '#888' }} />
584
- <ConnectedViews presence={presence} phones={hasActivities()} style={{ color: '#888', padding: '0 6px' }} />
585
- {timer.rehearsing && <button class="presenter-rehearsing" style={{ ...pill, color: '#f28b82' }} title="Finish the rehearsal" onClick={finishRehearsal}>● Finish</button>}
586
- <button class="presenter-clock" style={{ ...pill, color: timer.running ? '#f0f0f0' : '#777', gap: '8px' }} title="Start or pause the timer (T)" onClick={timer.toggle}>
587
- {clock}{timed && <span class="presenter-slide-time" style={{ color: slideColor, fontSize: '12px' }}>{slideText}</span>}
588
- </button>
589
- <button style={pill} title="Previous" aria-label="Previous" onClick={() => navCommand('prev')}><PresenterIcon name="prev" /></button>
590
- <button style={pill} title="Next" aria-label="Next" onClick={() => navCommand('next')}><PresenterIcon name="next" /></button>
591
- <button class="presenter-draw" title="Draw on the slide (D)" aria-pressed={inking} style={{ ...pill, ...(inking ? { background: '#e11d48', borderColor: '#e11d48', color: '#fff' } : {}) }} onClick={() => sendTo(iframeRef.current?.contentWindow, 'ink', 'toggle')}><PresenterIcon name="pen" /></button>
592
- <button class="presenter-hotspots" title={hotspotsOn ? 'Hotspots on: 1–9 zoom in (H)' : 'Hotspots off: 1–9 jump to slides (H)'} aria-label="Hotspots" aria-pressed={hotspotsOn} style={{ ...pill, ...(hotspotsOn ? { background: '#2a2a2a', color: '#fff' } : {}) }} onClick={toggleHotspots}><PresenterIcon name="hotspots" /></button>
593
- <button class="presenter-notes" title="Notes (N)" aria-pressed={drawerOpen} style={{ ...pill, ...(drawerOpen ? { background: '#2a2a2a', color: '#fff' } : {}) }} onClick={() => setDrawerOpen(open => !open)}>Notes</button>
594
- {canFullscreen && <button class="presenter-fullscreen" style={pill} title={fullscreenLabel} aria-label={fullscreenLabel} aria-pressed={isFullscreen} onClick={() => fullscreen.toggle()}><PresenterIcon name={fullscreenIcon} /></button>}
595
- <button style={pill} title="Speaker layout: slide, notes and next slide side by side" onClick={() => chooseLayout('speaker')}><PresenterIcon name="speaker" /></button>
596
- {launchPage && <a class="presenter-home" href={launchPage} style={{ ...pill, boxSizing: 'border-box', textDecoration: 'none' }} title="Back to the launch page" aria-label="Back to the launch page"><PresenterIcon name="home" /></a>}
597
- </div>
598
- )}
599
- <aside class="presenter-aside" style={{
600
- display: slideLayout && !drawerOpen ? 'none' : 'flex',
601
- flexDirection: 'column',
602
- gap: '14px',
603
- padding: '16px',
604
- borderLeft: '1px solid #2a2a2a',
605
- background: '#111',
606
- color: '#ccc',
607
- fontFamily: 'ui-sans-serif, system-ui, sans-serif',
608
- fontSize: '13px',
609
- overflow: 'hidden',
610
- ...(slideLayout ? { position: 'fixed', top: 0, right: 0, bottom: 0, width: 'min(380px, 92vw)', zIndex: 19, paddingTop: '64px', boxShadow: '-12px 0 32px rgba(0,0,0,0.5)' } : {}),
611
- }}>
612
-
613
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
614
- <span style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
615
- <span style={{ fontWeight: 600, color: '#f0f0f0', fontSize: '14px' }}>Speaker View</span>
616
- <ConnectedViews presence={presence} phones={hasActivities()} style={{ color: '#888' }} />
617
- </span>
618
- <span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
619
- {!slideLayout && canFullscreen && <button class="presenter-fullscreen" style={{ ...S.btn, padding: '3px 8px' }} title={fullscreenLabel} aria-label={fullscreenLabel} aria-pressed={isFullscreen} onClick={() => fullscreen.toggle()}><PresenterIcon name={fullscreenIcon} /></button>}
620
- {!slideLayout && launchPage && <a class="presenter-home" href={launchPage} style={{ ...S.btn, boxSizing: 'border-box', padding: '3px 8px', textDecoration: 'none' }} title="Back to the launch page" aria-label="Back to the launch page"><PresenterIcon name="home" /></a>}
621
- {!slideLayout && <button class="presenter-layout" style={{ ...S.btn, padding: '3px 8px' }} title="Slide only, notes in a drawer (for an iPad)" onClick={() => chooseLayout('slide')}><PresenterIcon name="tablet" /></button>}
622
- <SlideJump index={index} total={slides.length} pad={2} onJump={i => navCommand('goTo', i)} style={{ color: '#666', fontVariantNumeric: 'tabular-nums' }} />
623
- </span>
624
- </div>
625
-
626
- <SteeringNote />
627
-
628
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
629
- <span style={{ display: 'flex', alignItems: 'baseline', gap: '8px' }}>
630
- <span class="presenter-clock" style={{ fontVariantNumeric: 'tabular-nums', fontSize: '22px', letterSpacing: '0.05em', color: timer.running ? '#f0f0f0' : '#555', fontWeight: 300 }}>{clock}</span>
631
- {timed && <span style={{ color: '#666', fontVariantNumeric: 'tabular-nums' }}>of {formatClock(plan.total)}</span>}
632
- </span>
633
- <div style={{ display: 'flex', gap: '4px' }}>
634
- <button style={S.btn} title={timer.running ? 'Pause the timer (T)' : 'Start the timer (T)'} aria-label={timer.running ? 'Pause the timer' : 'Start the timer'} onClick={timer.toggle}>
635
- <PresenterIcon name={timer.running ? 'pause' : 'play'} size={16} />
636
- </button>
637
- <button style={S.btn} title="Reset the timer" aria-label="Reset the timer" onClick={timer.reset}><PresenterIcon name="reset" size={16} /></button>
638
- {!timer.rehearsing && <button class="presenter-rehearse" style={S.btn} title="Rehearse: time each slide, then save the times into the deck" onClick={timer.startRehearsal}><PresenterIcon name="rehearse" size={16} />Rehearse</button>}
639
- </div>
640
- </div>
641
- {timed && <div class="presenter-schedule" style={{ display: 'flex', justifyContent: 'space-between', flexShrink: 0, marginTop: '-8px', fontVariantNumeric: 'tabular-nums' }}>
642
- <span>Slide <span class="presenter-slide-time" style={{ color: slideColor }}>{slideText}</span>{planned ? <span style={{ color: '#555' }}> of {formatClock(planned)}</span> : null}</span>
643
- <span class="presenter-delta" style={{ color: deltaColor }}>{deltaText}</span>
644
- </div>}
645
- {timer.rehearsing && <div class="presenter-rehearsing" role="status" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, padding: '6px 10px', borderRadius: '5px', border: '1px solid #5c2b28', color: '#f28b82' }}>
646
- <span>● Rehearsing{timer.running ? '' : ' (paused)'}: this slide {formatClock(timer.slideElapsed)}</span>
647
- <button style={{ ...S.btn, padding: '2px 10px' }} onClick={finishRehearsal}>Finish</button>
648
- </div>}
649
- {savedNote && <div role="status" style={{ flexShrink: 0, color: '#81c995' }}>{savedNote}</div>}
650
-
651
- {hasNext && (
652
- <div style={{ flexShrink: 0 }}>
653
- <span style={S.label}>Next slide</span>
654
- <div style={{ aspectRatio: '16/9', borderRadius: '4px', overflow: 'hidden', border: '1px solid #2a2a2a', background: '#000' }}>
655
- <iframe
656
- ref={previewRef}
657
- title="Next slide preview"
658
- src={previewSrc}
659
- scrolling="no"
660
- style={{ width: '100%', height: '100%', border: '0', pointerEvents: 'none' }}
661
- />
662
- </div>
663
- </div>
664
- )}
665
-
666
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
667
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '5px' }}>
668
- <span style={{ ...S.label, marginBottom: 0 }}>Notes</span>
669
- <input
670
- type="range" min="11" max="22" step="1" value={noteSize}
671
- onInput={e => setNoteSize(Number(e.currentTarget.value))}
672
- style={{ width: '80px', accentColor: '#444', cursor: 'pointer', opacity: 0.6 }}
673
- />
674
- </div>
675
- <div class="notes-md" dir={textDirection(deckConfig.lang) === 'rtl' ? 'rtl' : undefined} style={{
676
- flex: 1,
677
- margin: 0,
678
- padding: '10px 12px',
679
- lineHeight: 1.6,
680
- background: '#0a0a0a',
681
- border: '1px solid #2a2a2a',
682
- borderRadius: '5px',
683
- fontSize: `${noteSize}px`,
684
- color: note ? '#c8c8c8' : '#3a3a3a',
685
- fontFamily: 'inherit',
686
- overflow: 'auto',
687
- }}
688
- dangerouslySetInnerHTML={{
689
- __html: note
690
- ? marked.parse(renderCitations(note).markdown)
691
- : '<p>No notes — add a <code>:::notes</code> block to the slide.</p>',
692
- }}
693
- />
694
- </div>
695
-
696
- {!lookFolded && <div class="presenter-look" style={{ flexShrink: 0, display: 'flex', gap: '6px', marginBottom: '8px', alignItems: 'flex-end' }}>
697
- <label style={{ flex: '1 1 auto', minWidth: 0 }}>
698
- <span style={S.label}>Theme</span>
699
- <button
700
- onClick={() => setThemeModalOpen(true)}
701
- style={{ ...S.select, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '4px', cursor: 'pointer' }}
702
- >
703
- <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{theme}</span>
704
- <span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="grid" size={13} /></span>
705
- </button>
706
- </label>
707
- <label style={{ flex: '1 1 auto', minWidth: 0 }}>
708
- <span style={S.label}>Palette</span>
709
- <div ref={paletteDropRef} style={{ position: 'relative' }}>
710
- <button
711
- class="presenter-palette"
712
- onClick={() => setPaletteOpen(o => !o)}
713
- style={{ ...S.select, display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer', textAlign: 'left' }}
714
- >
715
- <PaletteSwatches tokens={shown.palette?.[shown.appearance] ?? {}} />
716
- <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{shown.palette?.title ?? ''}</span>
717
- <span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="dropdown" size={13} /></span>
718
- </button>
719
- {paletteOpen && (
720
- <div style={{ position: 'absolute', bottom: 'calc(100% + 4px)', left: 0, minWidth: '100%', whiteSpace: 'nowrap', background: '#1a1a1a', border: '1px solid #2e2e2e', borderRadius: '5px', zIndex: 100, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.6)' }}>
721
- {offered.map(p => (
722
- <div
723
- key={p.id}
724
- class="presenter-palette-option"
725
- title={p.description}
726
- onClick={() => { setPalette(p.id === themeMeta?.palette ? '' : p.id); setPaletteOpen(false) }}
727
- style={{ padding: '6px 8px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '13px', color: shown.palette?.id === p.id ? '#f0f0f0' : '#aaa', background: shown.palette?.id === p.id ? '#2a2a2a' : 'transparent' }}
728
- >
729
- <PaletteSwatches tokens={p[shown.appearance] ?? {}} />
730
- <span>{p.title}{p.id === themeMeta?.palette ? <span style={{ color: '#666' }}> · theme default</span> : null}</span>
731
- </div>
732
- ))}
733
- </div>
734
- )}
735
- </div>
736
- </label>
737
- <label style={{ flexShrink: 0 }}>
738
- <span style={S.label}>Mode</span>
739
- <div class="presenter-appearance" role="group" aria-label="Light or dark" style={{ display: 'flex', gap: '2px', height: '28px' }}>
740
- {['light', 'dark'].map(mode => (
741
- <button
742
- key={mode}
743
- aria-pressed={shown.appearance === mode}
744
- title={mode === 'light' ? 'Light: for bright rooms' : 'Dark: for dark rooms'}
745
- aria-label={mode === 'light' ? 'Light' : 'Dark'}
746
- onClick={() => setAppearance(mode === (themeMeta?.appearance ?? 'light') ? '' : mode)}
747
- style={{ ...S.btn, padding: '3px 7px', ...(shown.appearance === mode ? { background: '#2a2a2a', color: '#f0f0f0', borderColor: '#444' } : {}) }}
748
- ><PresenterIcon name={mode === 'light' ? 'sun' : 'moon'} size={16} /></button>
749
- ))}
750
- </div>
751
- </label>
752
- {LANGUAGES.length > 0 && <label style={{ flexShrink: 0 }}>
753
- <span style={S.label}>Language</span>
754
- <div class="presenter-language" role="group" aria-label="Language" style={{ display: 'flex', gap: '2px', height: '28px' }}>
755
- {LANGUAGES.map(code => (
756
- <button
757
- key={code}
758
- aria-pressed={lang === code}
759
- title={`${languageName(code)} (L switches)`}
760
- onClick={() => setLang(code)}
761
- style={{ ...S.btn, padding: '3px 7px', ...(lang === code ? { background: '#2a2a2a', color: '#f0f0f0', borderColor: '#444' } : {}) }}
762
- >{code.toUpperCase()}</button>
763
- ))}
764
- </div>
765
- </label>}
766
- <button class="presenter-look-fold" title="Fold theme and palette" aria-label="Fold theme and palette" onClick={() => foldLook(true)}
767
- style={{ ...S.btn, padding: 0, width: '28px', height: '28px', flexShrink: 0, background: 'none', borderColor: 'transparent', color: '#888' }}
768
- ><PresenterIcon name="fold" size={16} /></button>
769
- </div>}
770
-
771
- <div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
772
- <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
773
- <button style={S.btn} onClick={() => navCommand('prev')}><PresenterIcon name="prev" size={15} />Prev</button>
774
- <button style={S.btn} onClick={() => navCommand('next')}>Next<PresenterIcon name="next" size={15} /></button>
775
- <button style={S.btn} onClick={() => navCommand('reset')}>Reset</button>
776
- <button
777
- class="presenter-draw"
778
- title="Draw on the slide (D)"
779
- aria-pressed={inking}
780
- style={{ ...S.btn, ...(inking ? { background: '#e11d48', borderColor: '#e11d48', color: '#fff' } : {}) }}
781
- onClick={() => sendTo(iframeRef.current?.contentWindow, 'ink', 'toggle')}
782
- ><PresenterIcon name="pen" size={15} />Draw</button>
783
- <button
784
- class="presenter-hotspots"
785
- title={hotspotsOn ? 'Hotspots on: 1–9 zoom in, 0 shows the whole slide (H)' : 'Hotspots off: 1–9 jump to slides (H)'}
786
- aria-pressed={hotspotsOn}
787
- aria-label="Hotspots"
788
- style={{ ...S.btn, ...(hotspotsOn ? { background: '#2a2a2a', borderColor: '#444', color: '#f0f0f0' } : {}) }}
789
- onClick={toggleHotspots}
790
- ><PresenterIcon name="hotspots" size={15} /></button>
791
- {lookFolded && <button
792
- class="presenter-look-unfold"
793
- title={`Theme and palette: ${themeMeta?.title ?? theme}, ${shown.palette?.title ?? ''}`}
794
- aria-label="Show theme and palette"
795
- onClick={() => foldLook(false)}
796
- style={{ ...S.btn, position: 'relative', padding: 0, width: '32px', height: '32px', borderRadius: '50%' }}
797
- >
798
- <PresenterIcon name="look" size={16} />
799
- <span style={{ position: 'absolute', right: '2px', bottom: '2px', width: '9px', height: '9px', borderRadius: '50%', background: shown.palette?.[shown.appearance]?.['--accent'] ?? '#888', boxShadow: '0 0 0 1.5px #222' }} />
800
- </button>}
801
- <button
802
- style={{
803
- ...S.btn,
804
- marginLeft: 'auto',
805
- color: audienceConnected ? '#444' : '#bbb',
806
- }}
807
- onClick={openAudienceWindow}
808
- >
809
- {audienceConnected ? 'Reconnect' : 'Audience'}
810
- </button>
811
- </div>
812
- <div style={{ color: '#444', fontSize: '11px', textAlign: 'center' }}>
813
- Arrow keys · Space · PgUp/PgDn · R to reset · T timer · H hotspots{slideHotspots ? ' · 1–9 zoom · 0 whole slide' : ''}
814
- </div>
815
- </div>
816
-
817
- </aside>
646
+ // Reactions from the phones float up here, never on the projector.
647
+ const reacting = !!reactionsOf(deckConfig)
648
+ const summaryPanel = summary && <RehearsalSummary
649
+ slides={slides}
650
+ plan={plan}
651
+ spent={summary}
652
+ onClose={() => { setSummary(null); timer.toggle() }}
653
+ onDiscard={() => { setSummary(null); timer.endRehearsal() }}
654
+ onSaved={(times, changed) => {
655
+ setSummary(null)
656
+ timer.endRehearsal()
657
+ setSavedTimes(saved => ({ ...saved, ...Object.fromEntries(Object.entries(times).map(([id, t]) => [id, t ?? undefined])) }))
658
+ setSavedNote(`Saved the times of ${changed} slide${changed === 1 ? '' : 's'} into the deck.`)
659
+ setTimeout(() => setSavedNote(null), 6000)
660
+ }}
661
+ />
818
662
 
819
- {summary && <RehearsalSummary
820
- slides={slides}
821
- plan={plan}
822
- spent={summary}
823
- onClose={() => { setSummary(null); timer.toggle() }}
824
- onDiscard={() => { setSummary(null); timer.endRehearsal() }}
825
- onSaved={(times, changed) => {
826
- setSummary(null)
827
- timer.endRehearsal()
828
- setSavedTimes(saved => ({ ...saved, ...Object.fromEntries(Object.entries(times).map(([id, t]) => [id, t ?? undefined])) }))
829
- setSavedNote(`Saved the times of ${changed} slide${changed === 1 ? '' : 's'} into the deck.`)
830
- setTimeout(() => setSavedNote(null), 6000)
831
- }}
832
- />}
833
- {themeModalOpen && (
663
+ // The theme picker, from the settings.
664
+ const themeModal = (
834
665
  <div
835
666
  style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
836
667
  onClick={() => setThemeModalOpen(false)}
@@ -862,11 +693,218 @@ function PresenterView({ deckConfig: firstConfig, slides: firstSlides }) {
862
693
  </div>
863
694
  </div>
864
695
  </div>
865
- )}
696
+ )
697
+ // The controls, the same in every layout (PresenterChrome.jsx); the layouts
698
+ // differ in where they put them.
699
+ const frame = () => iframeRef.current?.contentWindow
700
+ const problem = hasActivities() && steering && !steering.ok ? STEERING[steering.reason] : null
701
+ const slideBar = big => <SlideControls controls={slideControls} press={i => sendTo(frame(), 'slideControl', i)} zoom={key => sendTo(frame(), 'hotspotKey', key)} big={big} />
702
+ // The audience window, one click away (not on a phone: it would open there).
703
+ const audienceButton = <ToolButton class="presenter-audience" icon="projector" label={audienceConnected ? 'Open the audience window again' : 'Open the audience window, for the projector'} onClick={openAudienceWindow} />
704
+ const connections = <Connections presence={presence} phones={hasActivities()} problem={problem} />
705
+ const timerChip = <button class="presenter-clock" title={timer.running ? 'Pause the timer (T)' : 'Start the timer (T)'} aria-pressed={timer.running} onClick={timer.toggle}
706
+ style={{ ...tool, gap: '8px', whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', color: timer.running ? '#f0f0f0' : '#777' }}>
707
+ <Icon name={timer.running ? 'pause' : 'play'} size={14} style={{ display: 'block' }} />{clock}
708
+ {timed && !remoteLayout && <span class="presenter-slide-time" style={{ color: slideColor, fontSize: '12px' }}>{slideText}</span>}
709
+ </button>
710
+ const finishButton = timer.rehearsing && <button class="presenter-rehearsing" style={{ ...tool, color: '#f28b82' }} title="Finish the rehearsal" onClick={finishRehearsal}>● Finish</button>
711
+ const nav = <>
712
+ <ToolButton icon="prev" label="Previous (←)" onClick={() => navCommand('prev')} />
713
+ <ToolButton icon="next" label="Next (→, Space)" onClick={() => navCommand('next')} />
714
+ </>
715
+ const drawButton = <ToolButton class="presenter-draw" icon="pen" label="Draw on the slide (D)" pressed={inking} style={inking ? { background: '#e11d48', borderColor: '#e11d48', color: '#fff' } : undefined} onClick={() => sendTo(frame(), 'ink', 'toggle')} />
716
+ const blankButton = <ToolButton class="presenter-blank" icon="blank" label={blankLabel} pressed={blank} onClick={toggleBlank} />
717
+ // Only where phones join: decks with activities or reactions.
718
+ const joinButton = (hasActivities() || reacting) && <ToolButton class="presenter-join" icon="join" label={join ? 'Hide the join code (J)' : 'Show the join code on the slide (J)'} pressed={join} onClick={() => sendTo(frame(), 'join', 'toggle')} />
719
+ const overviewButton = <ToolButton class="presenter-overview" icon="overview" label="All slides (O)" pressed={overviewOpen} onClick={toggleOverview} />
720
+ const hotspotsButton = <ToolButton class="presenter-hotspots" icon="hotspots" label={hotspotsOn ? 'Hotspots on: 1–9 zoom in (H)' : 'Hotspots off (H)'} pressed={hotspotsOn} onClick={toggleHotspots} />
721
+ const fullscreenButton = canFullscreen && <ToolButton class="presenter-fullscreen" icon={fullscreenIcon} label={fullscreenLabel} pressed={isFullscreen} onClick={() => fullscreen.toggle()} />
722
+ const separator = <span aria-hidden="true" style={{ width: '1px', alignSelf: 'stretch', margin: '6px 4px', background: '#2a2a2a' }} />
723
+ const LAYOUTS = [['speaker', 'speaker', 'Speaker', 'Slide, notes and next slide side by side (a computer)'], ['slide', 'tablet', 'Slide only', 'The slide fills the screen, notes in a drawer (an iPad)'], ['remote', 'phone', 'Clicker', 'Notes, time and big buttons (a phone)']]
724
+ const settings = <Popover class="presenter-settings" label="Settings" trigger={<Icon name="settings" size={18} style={{ display: 'block' }} />}>
725
+ <PanelSection title="Layout">
726
+ <span role="group" aria-label="Layout" style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
727
+ {LAYOUTS.map(([id, icon, name, hint]) => <button key={id} type="button" class={`presenter-layout presenter-layout--${id}`} title={hint} aria-pressed={layout === id} onClick={() => chooseLayout(id)}
728
+ style={{ ...S.btn, ...(layout === id ? { background: '#2a2a2a', color: '#fff', borderColor: '#444' } : {}) }}><Icon name={icon} size={16} />{name}</button>)}
729
+ </span>
730
+ </PanelSection>
731
+ {remoteLayout && <PanelSection title="During the talk">
732
+ <span style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>{overviewButton}{hotspotsButton}{fullscreenButton}</span>
733
+ </PanelSection>}
734
+ <PanelSection title="Look" class="presenter-look">
735
+ <span style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap' }}>
736
+ <button type="button" onClick={() => setThemeModalOpen(true)} style={{ ...S.btn }} title="Choose the theme"><Icon name="grid" size={14} />{themeMeta?.title ?? theme}</button>
737
+ <span class="presenter-appearance" role="group" aria-label="Light or dark" style={{ display: 'flex', gap: '2px' }}>
738
+ {['light', 'dark'].map(mode => <button key={mode} type="button" aria-pressed={shown.appearance === mode} title={mode === 'light' ? 'Light: for bright rooms' : 'Dark: for dark rooms'} aria-label={mode === 'light' ? 'Light' : 'Dark'}
739
+ onClick={() => setAppearance(mode === (themeMeta?.appearance ?? 'light') ? '' : mode)}
740
+ style={{ ...S.btn, padding: '5px 8px', ...(shown.appearance === mode ? { background: '#2a2a2a', color: '#f0f0f0', borderColor: '#444' } : {}) }}><Icon name={mode === 'light' ? 'sun' : 'moon'} size={16} /></button>)}
741
+ </span>
742
+ </span>
743
+ <span class="presenter-palette" role="group" aria-label="Palette" style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
744
+ {offered.map(p => <button key={p.id} type="button" class="presenter-palette-option" title={p.description} aria-pressed={shown.palette?.id === p.id}
745
+ onClick={() => setPalette(p.id === themeMeta?.palette ? '' : p.id)}
746
+ style={{ ...S.btn, justifyContent: 'flex-start', gap: '8px', border: 'none', background: shown.palette?.id === p.id ? '#2a2a2a' : 'transparent', color: shown.palette?.id === p.id ? '#f0f0f0' : '#aaa' }}>
747
+ <PaletteSwatches tokens={p[shown.appearance] ?? {}} /><span>{p.title}{p.id === themeMeta?.palette ? <span style={{ color: '#666' }}> · theme default</span> : null}</span>
748
+ </button>)}
749
+ </span>
750
+ {LANGUAGES.length > 0 && <span class="presenter-language" role="group" aria-label="Language" style={{ display: 'flex', gap: '2px', flexWrap: 'wrap' }}>
751
+ {LANGUAGES.map(code => <button key={code} type="button" aria-pressed={lang === code} title={`${languageName(code)} (L switches)`} onClick={() => setLang(code)}
752
+ style={{ ...S.btn, padding: '5px 9px', ...(lang === code ? { background: '#2a2a2a', color: '#f0f0f0', borderColor: '#444' } : {}) }}>{code.toUpperCase()}</button>)}
753
+ </span>}
754
+ </PanelSection>
755
+ <PanelSection title="Timer">
756
+ <span style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
757
+ <button type="button" class="presenter-timer-reset" style={S.btn} onClick={timer.reset}><Icon name="reset" size={16} />Reset the timer</button>
758
+ {!timer.rehearsing && <button type="button" class="presenter-rehearse" style={S.btn} title="Time each slide, then save the times into the deck" onClick={timer.startRehearsal}><Icon name="rehearse" size={16} />Rehearse</button>}
759
+ </span>
760
+ </PanelSection>
761
+ <label class="presenter-note-size" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
762
+ <span>Size of the notes</span>
763
+ <span aria-hidden="true" style={{ marginInlineStart: 'auto', fontSize: '11px', color: '#888' }}>A</span>
764
+ <input type="range" min="11" max="26" step="1" value={noteSize} aria-label="Size of the notes" onInput={e => setNoteSize(Number(e.currentTarget.value))} style={{ width: '96px', accentColor: '#888', cursor: 'pointer' }} />
765
+ <span aria-hidden="true" style={{ fontSize: '17px', color: '#888' }}>A</span>
766
+ </label>
767
+ {launchPage && <a class="presenter-home" href={launchPage} style={{ ...S.btn, alignSelf: 'flex-start', boxSizing: 'border-box', textDecoration: 'none' }}><Icon name="home" size={16} />Back to the launch page</a>}
768
+ </Popover>
769
+ const notesBox = <div class="notes-md" dir={notesDir} style={{ flex: 1, minHeight: 0, overflow: 'auto', margin: 0, padding: '12px 14px', lineHeight: 1.55, background: '#0a0a0a', border: '1px solid #2a2a2a', borderRadius: '8px', fontSize: `${noteSize}px`, color: note ? '#c8c8c8' : '#3a3a3a' }}
770
+ dangerouslySetInnerHTML={{ __html: notesHtml }} />
771
+ const nextTitle = hasNext ? `Next: ${slideTitleOf(slides[index + 1]) || `slide ${index + 2}`}` : 'Last slide'
772
+ const schedule = timed && <div class="presenter-schedule" style={{ display: 'flex', justifyContent: 'space-between', gap: '8px', flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
773
+ <span>Slide <span class="presenter-slide-time" style={{ color: slideColor }}>{slideText}</span>{planned ? <span style={{ color: '#555' }}> of {formatClock(planned)}</span> : null}</span>
774
+ <span><span class="presenter-delta" style={{ color: deltaColor }}>{deltaText}</span><span style={{ color: '#555' }}> · of {formatClock(plan.total)}</span></span>
775
+ </div>
776
+ const rehearsing = timer.rehearsing && <div class="presenter-rehearsing" role="status" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0, padding: '6px 10px', borderRadius: '8px', border: '1px solid #5c2b28', color: '#f28b82' }}>
777
+ <span>● Rehearsing{timer.running ? '' : ' (paused)'}: this slide {formatClock(timer.slideElapsed)}</span>
778
+ <button style={{ ...S.btn, padding: '2px 10px' }} onClick={finishRehearsal}>Finish</button>
779
+ </div>
780
+ const blankNote = blank && <div class="presenter-blank-note" role="status" style={{ position: 'fixed', left: layout === 'speaker' ? 'calc(100% / 3)' : '50%', bottom: remoteLayout ? '110px' : '76px', transform: 'translateX(-50%)', zIndex: 18, display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 14px', borderRadius: '999px', background: 'rgba(17,17,17,0.92)', border: '1px solid #3a3a3a', color: '#f0f0f0', fontFamily: 'ui-sans-serif, system-ui, sans-serif', fontSize: '14px', pointerEvents: 'none' }}>
781
+ <PresenterIcon name="blank" size={16} />The audience sees a black screen
782
+ </div>
783
+ const font = { fontFamily: 'ui-sans-serif, system-ui, sans-serif', fontSize: '13px', color: '#ccc' }
784
+ const extras = <>
785
+ {blankNote}
786
+ {reacting && <Reactions style={remoteLayout ? { left: '16px', bottom: '100px' } : undefined} />}
787
+ {helpOpen && <KeyHelp
788
+ context={{ view: 'presenter', blank, join: hasActivities() || reacting, joinShown: join, overview: overviewOpen, hotspots: slideHotspots, hotspotSwitch: hotspotsOn, inking, fullscreen: canFullscreen, drawer: slideLayout, languages: LANGUAGES.length > 0 }}
789
+ onClose={() => setHelpOpen(false)}
790
+ />}
791
+ {summaryPanel}
792
+ {themeModalOpen && themeModal}
793
+ </>
794
+
795
+ // A phone in the hand: the slide small at the top, what can be done on it,
796
+ // the notes, and the buttons to go on and back where the thumb is.
797
+ if (remoteLayout) {
798
+ const big = { ...S.btn, height: '72px', fontSize: '18px', fontWeight: 600, borderRadius: '14px' }
799
+ return (
800
+ <div class="presenter presenter--remote" style={{ ...font, fontSize: '14px', display: 'flex', flexDirection: 'column', gap: '10px', height: '100dvh', boxSizing: 'border-box', padding: 'max(10px, env(safe-area-inset-top)) max(10px, env(safe-area-inset-right)) max(10px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-left))', background: '#111', overflow: 'hidden', touchAction: 'manipulation' }}>
801
+ <div class="presenter-remote-bar" style={{ display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
802
+ <SlideJump index={index} total={slides.length} onJump={i => navCommand('goTo', i)} style={{ color: '#888', whiteSpace: 'nowrap' }} />
803
+ <span style={{ flex: 1 }} />
804
+ {finishButton}{timerChip}{joinButton}{blankButton}{connections}{settings}
805
+ </div>
806
+ <div style={{ position: 'relative', flexShrink: 0, width: '100%', maxHeight: '32dvh', aspectRatio: '16/9', margin: '0 auto', borderRadius: '8px', overflow: 'hidden', border: '1px solid #2a2a2a', background: '#000' }}>
807
+ <iframe ref={iframeRef} title="Presenter deck" src={iframeSrc} style={{ width: '100%', height: '100%', border: '0', display: 'block' }} />
808
+ </div>
809
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px', flexShrink: 0, fontSize: '13px', color: '#888', fontVariantNumeric: 'tabular-nums' }}>
810
+ <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{nextTitle}</span>
811
+ {timed && <span style={{ flexShrink: 0 }}><span class="presenter-slide-time" style={{ color: slideColor }}>{slideText}</span> · <span class="presenter-delta" style={{ color: deltaColor }}>{deltaText}</span></span>}
812
+ </div>
813
+ {savedNote && <div role="status" style={{ flexShrink: 0, color: '#81c995' }}>{savedNote}</div>}
814
+ {slideBar(true)}
815
+ {notesBox}
816
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '10px', flexShrink: 0 }}>
817
+ <button class="presenter-remote-prev" style={big} aria-label="Previous" onClick={() => navCommand('prev')}><PresenterIcon name="prev" size={28} /></button>
818
+ <button class="presenter-remote-next" style={{ ...big, background: '#f0f0f0', borderColor: '#f0f0f0', color: '#111' }} aria-label="Next" onClick={() => navCommand('next')}>Next<PresenterIcon name="next" size={28} /></button>
819
+ </div>
820
+ {extras}
821
+ </div>
822
+ )
823
+ }
824
+
825
+ // The next slide and the notes: the speaker layout's column, the iPad's drawer.
826
+ const aside = <aside class="presenter-aside" style={{
827
+ ...font, display: slideLayout && !drawerOpen ? 'none' : 'flex', flexDirection: 'column', gap: '12px', padding: '14px', boxSizing: 'border-box',
828
+ borderLeft: '1px solid #2a2a2a', background: '#111', overflow: 'hidden', minHeight: 0,
829
+ ...(slideLayout ? { position: 'fixed', top: 0, right: 0, bottom: 0, width: 'min(400px, 92vw)', zIndex: 19, paddingTop: '68px', boxShadow: '-12px 0 32px rgba(0,0,0,0.5)' } : {}),
830
+ }}>
831
+ {!slideLayout && <div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0 }}>
832
+ <SlideJump index={index} total={slides.length} pad={2} onJump={i => navCommand('goTo', i)} style={{ color: '#888', fontVariantNumeric: 'tabular-nums' }} />
833
+ <span style={{ flex: 1 }} />
834
+ {finishButton}{timerChip}{audienceButton}{connections}{settings}
835
+ </div>}
836
+ {slideLayout && <span style={{ display: 'none' }}><SlideJump index={index} total={slides.length} pad={2} onJump={i => navCommand('goTo', i)} /></span>}
837
+ {schedule}
838
+ {rehearsing}
839
+ {savedNote && <div role="status" style={{ flexShrink: 0, color: '#81c995' }}>{savedNote}</div>}
840
+ {hasNext ? <div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexShrink: 0 }}>
841
+ <div style={{ width: '46%', flexShrink: 0, aspectRatio: '16/9', borderRadius: '6px', overflow: 'hidden', border: '1px solid #2a2a2a', background: '#000' }}>
842
+ <iframe ref={previewRef} title="Next slide preview" src={previewSrc} scrolling="no" style={{ width: '100%', height: '100%', border: '0', pointerEvents: 'none' }} />
843
+ </div>
844
+ <span style={{ color: '#888', lineHeight: 1.4 }}>{nextTitle}</span>
845
+ </div> : <span style={{ color: '#666', flexShrink: 0 }}>Last slide</span>}
846
+ {notesBox}
847
+ </aside>
848
+
849
+ // The iPad: the slide fills the screen, one slim bar on top.
850
+ if (slideLayout) return (
851
+ <div class="presenter presenter--slide" style={{ height: '100dvh', background: '#111', overflow: 'hidden', touchAction: 'pan-x pan-y' }}>
852
+ <iframe ref={iframeRef} title="Presenter deck" src={iframeSrc} style={{ width: '100%', height: '100%', border: '0', display: 'block' }} />
853
+ <div class="presenter-pill" role="toolbar" aria-label="Presenter" style={{ ...font, position: 'fixed', top: 'max(10px, env(safe-area-inset-top))', right: 'max(10px, env(safe-area-inset-right))', zIndex: 20, display: 'flex', gap: '4px', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end', maxWidth: 'calc(100vw - 20px)', padding: '4px', borderRadius: '14px', background: 'rgba(17,17,17,0.9)', border: '1px solid #2a2a2a', fontVariantNumeric: 'tabular-nums' }}>
854
+ <SlideJump index={index} total={slides.length} onJump={i => navCommand('goTo', i)} style={{ padding: '0 4px', color: '#888' }} />
855
+ {finishButton}{timerChip}
856
+ {separator}{nav}
857
+ {separator}{drawButton}{blankButton}{joinButton}{overviewButton}{hotspotsButton}
858
+ {(slideControls.items.length > 0 || slideControls.hotspots > 0) && <>{separator}{slideBar(false)}</>}
859
+ {separator}
860
+ <ToolButton class="presenter-notes" icon="file-text" label="Notes and the next slide (N)" pressed={drawerOpen} onClick={() => setDrawerOpen(open => !open)} />
861
+ {fullscreenButton}{audienceButton}{connections}{settings}
862
+ </div>
863
+ {aside}
864
+ {extras}
865
+ </div>
866
+ )
867
+
868
+ // A computer: the slide with its toolbar, the time, next slide and notes beside it.
869
+ return (
870
+ <div class="presenter presenter--speaker" style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', height: '100dvh', background: '#111', overflow: 'hidden' }}>
871
+ <div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
872
+ <iframe ref={iframeRef} title="Presenter deck" src={iframeSrc} style={{ flex: 1, minHeight: 0, width: '100%', border: '0', display: 'block' }} />
873
+ <div class="presenter-toolbar" role="toolbar" aria-label="Presenter" style={{ ...font, display: 'flex', alignItems: 'center', gap: '4px', flexWrap: 'wrap', padding: '8px 12px', borderTop: '1px solid #2a2a2a', background: '#111' }}>
874
+ {nav}
875
+ {separator}{drawButton}{blankButton}{joinButton}{overviewButton}{hotspotsButton}{fullscreenButton}
876
+ {(slideControls.items.length > 0 || slideControls.hotspots > 0) && <>{separator}<span style={{ ...S.label, marginBottom: 0, marginInlineEnd: '4px' }}>This slide</span>{slideBar(false)}</>}
877
+ <span style={{ flex: 1 }} />
878
+ <ToolButton class="presenter-help" icon="guides" label="All keys (?)" onClick={() => setHelpOpen(open => !open)} />
879
+ </div>
880
+ </div>
881
+ {aside}
882
+ {extras}
866
883
  </div>
867
884
  )
868
885
  }
869
886
 
887
+ // The join code over the slide (J in the presenter view): the room scans it
888
+ // from the projector, a phone following the talk shows it too. A click on it,
889
+ // or the next step, hides it again where the screen is not only watching.
890
+ function JoinOverlay({ stage }) {
891
+ const [on, setOn] = useState(stage.join)
892
+ useEffect(() => {
893
+ const follow = () => setOn(stage.join)
894
+ stage.addEventListener('joinchange', follow)
895
+ return () => stage.removeEventListener('joinchange', follow)
896
+ }, [])
897
+ if (!on) return null
898
+ const size = Math.round(Math.min(window.innerHeight * 0.55, window.innerWidth * 0.5, 520))
899
+ return <div class="join-overlay export-hidden" role="dialog" aria-label={t('join.scan')}
900
+ onClick={() => { if (!stage.hasAttribute('data-watching')) stage.setJoin(false, 'click') }}
901
+ style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0, 0, 0, 0.72)' }}>
902
+ <div style={{ background: '#fff', color: '#111', borderRadius: '18px', padding: '24px 32px', '--qr-ink': '#000', '--qr-bg': '#fff', '--ink': '#111', '--muted': '#555' }}>
903
+ <QrCodeTag join size={String(size)} />
904
+ </div>
905
+ </div>
906
+ }
907
+
870
908
  // Renders (or re-renders) the deck stage. Slides are keyed by id so an edit to
871
909
  // one slide leaves the others and the stage's position untouched.
872
910
  let unfit = null
@@ -928,6 +966,7 @@ async function init() {
928
966
  setDeckLanguage(deckConfig)
929
967
  document.documentElement.lang = deckLanguage()
930
968
  configureLive(deckConfig)
969
+ reactionsWanted = !!reactionsOf(deckConfig)
931
970
  setSavedResults(savedResults)
932
971
  const { slides } = parsed
933
972
  const presenterMode = view === 'presenter'
@@ -947,6 +986,9 @@ async function init() {
947
986
 
948
987
  if (presenterMode) {
949
988
  actAsPresenter()
989
+ // A tool of mdeck's, like the launch page: its mark in the tab.
990
+ const icon = document.querySelector('link[rel=icon]') ?? document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon' }))
991
+ icon.href = mdeckMark
950
992
  followPresence()
951
993
  document.body.style.margin = '0'
952
994
  const s = document.createElement('style')
@@ -979,6 +1021,14 @@ async function init() {
979
1021
  // Digits and Escape typed in the presenter view, and its hotspots switch.
980
1022
  else if (ctrl.command === 'hotspotKey' && typeof ctrl.value === 'string') stage.hotspotKey(ctrl.value)
981
1023
  else if (ctrl.command === 'hotspots') stage.hotspotsForced = ctrl.value
1024
+ // The phone clicker pressed one of the slide's controls.
1025
+ else if (ctrl.command === 'slideControl' && Number.isInteger(ctrl.value)) pressSlideControl(stage, ctrl.value)
1026
+ // B in the presenter view: the screens the audience sees go black.
1027
+ // O in the presenter view: the overview opens in its own slide frame.
1028
+ else if (ctrl.command === 'overview') stage.overview = ctrl.value === 'toggle' ? !stage.overview : !!ctrl.value
1029
+ else if (ctrl.command === 'overviewKey' && typeof ctrl.value === 'string') stage.overviewKey(ctrl.value)
1030
+ else if (ctrl.command === 'blank') stage.setBlank(ctrl.value === 'toggle' ? !stage.blank : !!ctrl.value, 'keyboard')
1031
+ else if (ctrl.command === 'join') stage.setJoin(ctrl.value === 'toggle' ? !stage.join : !!ctrl.value, 'keyboard')
982
1032
  }
983
1033
 
984
1034
  // The same slide and step in another language, its words and mdeck's own;
@@ -1042,6 +1092,15 @@ async function init() {
1042
1092
 
1043
1093
  // Drawing (D) in the full-screen deck and the presenter's main frame.
1044
1094
  const inkStage = document.querySelector('deck-stage')
1095
+ // The join code over the slide, on every screen but the next-slide preview;
1096
+ // J shows it in a deck phones join.
1097
+ if (inkStage && (hasActivities() || reactionsOf(deckConfig))) inkStage.setAttribute('data-joinable', '')
1098
+ if (inkStage && !(embedded && !drawHere)) {
1099
+ const host = document.createElement('div')
1100
+ host.className = 'join-overlay-host'
1101
+ document.body.appendChild(host)
1102
+ render(<JoinOverlay stage={inkStage} />, host)
1103
+ }
1045
1104
  // Main slide frames and audience windows both send and follow navigation.
1046
1105
  // Share their position connection with ink; previews stay on local channels.
1047
1106
  if (inkStage && (drawHere || audienceMode)) {
@@ -1067,8 +1126,37 @@ async function init() {
1067
1126
  }
1068
1127
 
1069
1128
  // The presenter's slide shows the hotspots' keys; no screen the audience sees does.
1070
- if (inkStage && embedded && drawHere) inkStage.setAttribute('data-hotspot-badges', '')
1071
- if (inkStage && drawHere) {
1129
+ // A black screen only veils it.
1130
+ // The phone remote's small slide only shows: no keys, no drawing.
1131
+ const remoteFrame = embedded && url.searchParams.get('remote') === '1'
1132
+ if (inkStage && embedded && drawHere) {
1133
+ if (!remoteFrame) inkStage.setAttribute('data-hotspot-badges', '')
1134
+ inkStage.setAttribute('data-presenter', '')
1135
+ // The presenter view routes the keys to the overview while it is open.
1136
+ inkStage.addEventListener('overviewchange', event => window.parent.postMessage({ overview: event.detail.open }, window.location.origin))
1137
+ // A frame that reloaded (another theme) has none open.
1138
+ window.parent.postMessage({ overview: false }, window.location.origin)
1139
+ }
1140
+ // ? lists the keys that work now: in a deck window over the slide; in the
1141
+ // presenter's slide frame it goes to the presenter view, which shows them.
1142
+ if (inkStage && !embedded && !audienceMode && !followMode) attachKeyHelp(inkStage, { canDraw: drawHere })
1143
+ else if (embedded && drawHere) {
1144
+ window.addEventListener('keydown', event => {
1145
+ if (!isHelpKey(event) || event.target?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]')) return
1146
+ event.preventDefault()
1147
+ window.parent.postMessage({ keyHelp: 'toggle' }, window.location.origin)
1148
+ })
1149
+ }
1150
+ // The presenter view shows the slide's own controls in its toolbar, the
1151
+ // clicker as big buttons (slideControls.js).
1152
+ if (inkStage && embedded && drawHere) watchSlideControls(inkStage, controls => window.parent.postMessage({ slideControls: controls }, window.location.origin))
1153
+ if (inkStage && remoteFrame) {
1154
+ watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'presenter', onPresence: presence => {
1155
+ watchedOnServer = (presence?.views ?? []).some(entry => entry.view === 'audience' || entry.view === 'deck')
1156
+ markWatched()
1157
+ window.parent.postMessage({ presence }, window.location.origin)
1158
+ } })] })
1159
+ } else if (inkStage && drawHere) {
1072
1160
  attachInk(inkStage, {
1073
1161
  storageKey: inkStorageKey,
1074
1162
  // The presenter's main frame tells the presenter view who is connected.
@@ -1097,7 +1185,12 @@ async function init() {
1097
1185
  } else if (inkStage && followMode) {
1098
1186
  // The presenter's slide and ink, read from the stage room; counted as a phone.
1099
1187
  inkStage.setAttribute('data-follower', '')
1100
- onStagePosition(position => { if (position.lang) switchLanguage(position.lang, { announce: false }) })
1188
+ inkStage.setAttribute('data-watching', '')
1189
+ onStagePosition(position => {
1190
+ if (position.lang) switchLanguage(position.lang, { announce: false })
1191
+ if (typeof position.blank === 'boolean') inkStage.setBlank(position.blank, 'sync')
1192
+ if (typeof position.join === 'boolean') inkStage.setJoin(position.join, 'sync')
1193
+ })
1101
1194
  startFollowing(inkStage, { onStagePosition, words: { live: t('follow.live'), back: t('follow.back'), waiting: t('follow.waiting') } })
1102
1195
  watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'phone' })] })
1103
1196
  } else if (inkStage && audienceMode) {