mdeck 2.3.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.
Files changed (268) hide show
  1. package/CHANGELOG.md +259 -0
  2. package/LICENSE +21 -0
  3. package/README.md +156 -0
  4. package/assets/base.css +196 -0
  5. package/assets/extensions/layouts/chapter/extension.toml +26 -0
  6. package/assets/extensions/layouts/chapter/layout.jsx +21 -0
  7. package/assets/extensions/layouts/chapter/starter.md +8 -0
  8. package/assets/extensions/layouts/focus/extension.toml +8 -0
  9. package/assets/extensions/layouts/focus/layout.jsx +10 -0
  10. package/assets/extensions/layouts/focus/starter.md +4 -0
  11. package/assets/extensions/layouts/full-bleed-image/extension.toml +31 -0
  12. package/assets/extensions/layouts/full-bleed-image/layout.jsx +19 -0
  13. package/assets/extensions/layouts/full-bleed-image/starter.md +6 -0
  14. package/assets/extensions/layouts/generic/extension.toml +8 -0
  15. package/assets/extensions/layouts/generic/layout.jsx +8 -0
  16. package/assets/extensions/layouts/generic/starter.md +4 -0
  17. package/assets/extensions/layouts/image-text/extension.toml +25 -0
  18. package/assets/extensions/layouts/image-text/layout.jsx +17 -0
  19. package/assets/extensions/layouts/image-text/starter.md +6 -0
  20. package/assets/extensions/layouts/split/extension.toml +25 -0
  21. package/assets/extensions/layouts/split/layout.jsx +19 -0
  22. package/assets/extensions/layouts/split/starter.md +14 -0
  23. package/assets/extensions/layouts/title/extension.toml +26 -0
  24. package/assets/extensions/layouts/title/layout.jsx +14 -0
  25. package/assets/extensions/layouts/title/starter.md +5 -0
  26. package/assets/extensions/palettes/brand/extension.toml +29 -0
  27. package/assets/extensions/palettes/cobalt/extension.toml +27 -0
  28. package/assets/extensions/palettes/ember/extension.toml +27 -0
  29. package/assets/extensions/palettes/forest/extension.toml +27 -0
  30. package/assets/extensions/palettes/graphite/extension.toml +27 -0
  31. package/assets/extensions/palettes/lagoon/extension.toml +27 -0
  32. package/assets/extensions/palettes/neon/extension.toml +27 -0
  33. package/assets/extensions/palettes/nordic/extension.toml +27 -0
  34. package/assets/extensions/palettes/pastel/extension.toml +30 -0
  35. package/assets/extensions/palettes/phosphor/extension.toml +27 -0
  36. package/assets/extensions/palettes/swiss/extension.toml +27 -0
  37. package/assets/extensions/palettes/terra/extension.toml +27 -0
  38. package/assets/extensions/themes/academic/extension.toml +53 -0
  39. package/assets/extensions/themes/academic/styles.css +631 -0
  40. package/assets/extensions/themes/aurora/extension.toml +37 -0
  41. package/assets/extensions/themes/aurora/styles.css +644 -0
  42. package/assets/extensions/themes/duet/extension.toml +46 -0
  43. package/assets/extensions/themes/duet/styles.css +573 -0
  44. package/assets/extensions/themes/editorial/extension.toml +46 -0
  45. package/assets/extensions/themes/editorial/styles.css +574 -0
  46. package/assets/extensions/themes/fhnw/extension.toml +61 -0
  47. package/assets/extensions/themes/fhnw/styles.css +604 -0
  48. package/assets/extensions/themes/neue/extension.toml +46 -0
  49. package/assets/extensions/themes/neue/styles.css +489 -0
  50. package/assets/extensions/themes/sketch/extension.toml +48 -0
  51. package/assets/extensions/themes/sketch/styles.css +633 -0
  52. package/assets/extensions/themes/terminal/extension.toml +38 -0
  53. package/assets/extensions/themes/terminal/styles.css +550 -0
  54. package/assets/logo/avatar.png +0 -0
  55. package/assets/logo/logo-dark.svg +10 -0
  56. package/assets/logo/logo-light.svg +10 -0
  57. package/assets/logo/mark.svg +15 -0
  58. package/bin/mdeck.js +2 -0
  59. package/docs/README.md +8 -0
  60. package/docs/reference/authoring.md +795 -0
  61. package/docs/reference/claude-skill.md +96 -0
  62. package/docs/reference/extensions.md +250 -0
  63. package/docs/reference/layouts.md +153 -0
  64. package/docs/reference/palettes.md +106 -0
  65. package/docs/reference/structured-slides.md +120 -0
  66. package/docs/reference/themes.md +133 -0
  67. package/docs/site/assets/favicon.svg +15 -0
  68. package/docs/site/assets/github-logo.svg +1 -0
  69. package/docs/site/assets/site.css +162 -0
  70. package/docs/site/assets/site.js +123 -0
  71. package/docs/site/build.js +148 -0
  72. package/docs/site/content/appearance.md +203 -0
  73. package/docs/site/content/audience.md +178 -0
  74. package/docs/site/content/commands.md +153 -0
  75. package/docs/site/content/components.md +134 -0
  76. package/docs/site/content/drawing.md +72 -0
  77. package/docs/site/content/editing.md +59 -0
  78. package/docs/site/content/getting-started.md +103 -0
  79. package/docs/site/content/layouts.md +126 -0
  80. package/docs/site/content/media.md +107 -0
  81. package/docs/site/content/more-content.md +130 -0
  82. package/docs/site/content/presenting.md +93 -0
  83. package/docs/site/content/reusable-layouts.md +74 -0
  84. package/docs/site/content/sharing.md +88 -0
  85. package/docs/site/content/theme-authoring.md +144 -0
  86. package/docs/site/content/theme-repository.md +76 -0
  87. package/docs/site/content/troubleshooting.md +92 -0
  88. package/docs/site/content/views.md +56 -0
  89. package/docs/site/content/welcome.md +71 -0
  90. package/docs/site/content/writing.md +121 -0
  91. package/docs/site/examples/first-talk.md +88 -0
  92. package/docs/site/examples/notebook.svg +58 -0
  93. package/docs/site/images/themes/academic-dark.webp +0 -0
  94. package/docs/site/images/themes/academic-light.webp +0 -0
  95. package/docs/site/images/themes/academic.webp +0 -0
  96. package/docs/site/images/themes/aurora-dark.webp +0 -0
  97. package/docs/site/images/themes/aurora-light.webp +0 -0
  98. package/docs/site/images/themes/aurora.webp +0 -0
  99. package/docs/site/images/themes/duet-dark.webp +0 -0
  100. package/docs/site/images/themes/duet-light.webp +0 -0
  101. package/docs/site/images/themes/duet.webp +0 -0
  102. package/docs/site/images/themes/editorial-dark.webp +0 -0
  103. package/docs/site/images/themes/editorial-light.webp +0 -0
  104. package/docs/site/images/themes/editorial.webp +0 -0
  105. package/docs/site/images/themes/fhnw.webp +0 -0
  106. package/docs/site/images/themes/neue-dark.webp +0 -0
  107. package/docs/site/images/themes/neue-light.webp +0 -0
  108. package/docs/site/images/themes/neue.webp +0 -0
  109. package/docs/site/images/themes/sketch-dark.webp +0 -0
  110. package/docs/site/images/themes/sketch-light.webp +0 -0
  111. package/docs/site/images/themes/sketch.webp +0 -0
  112. package/docs/site/images/themes/terminal-dark.webp +0 -0
  113. package/docs/site/images/themes/terminal-light.webp +0 -0
  114. package/docs/site/images/themes/terminal.webp +0 -0
  115. package/docs/site/package.json +9 -0
  116. package/docs/site/pages.js +27 -0
  117. package/docs/site/server.js +58 -0
  118. package/examples/custom-layouts/extensions/comparison/extension.toml +35 -0
  119. package/examples/custom-layouts/extensions/comparison/layout.jsx +15 -0
  120. package/examples/custom-layouts/extensions/comparison/starter.md +17 -0
  121. package/examples/custom-layouts/extensions/comparison/styles.css +4 -0
  122. package/examples/custom-layouts/slides.md +60 -0
  123. package/examples/poll/slides.md +127 -0
  124. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T08-36-47Z.json +19 -0
  125. package/examples/python/.mdeck-backups/slides.drawings-2026-10-06T11-27-30Z.json +6 -0
  126. package/examples/python/slides.drawings.json +6 -0
  127. package/examples/python/slides.md +493 -0
  128. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T08-08-30Z.json +10 -0
  129. package/examples/showcase/.mdeck-backups/slides.drawings-2026-10-06T11-41-35Z.json +13 -0
  130. package/examples/showcase/img/image.jpg +0 -0
  131. package/examples/showcase/img/mdeck.svg +66 -0
  132. package/examples/showcase/slides.drawings.json +11 -0
  133. package/examples/showcase/slides.md +427 -0
  134. package/package.json +75 -0
  135. package/skills/write-slides/SKILL.md +144 -0
  136. package/src/build/check.js +49 -0
  137. package/src/build/chrome.js +130 -0
  138. package/src/build/components.js +44 -0
  139. package/src/build/config.js +61 -0
  140. package/src/build/drawings.js +14 -0
  141. package/src/build/editorPlugin.js +272 -0
  142. package/src/build/editorServer.js +36 -0
  143. package/src/build/fonts.js +84 -0
  144. package/src/build/homePlugin.js +208 -0
  145. package/src/build/inkPlugin.js +192 -0
  146. package/src/build/ownWrites.js +18 -0
  147. package/src/build/pairing.js +81 -0
  148. package/src/build/pdf.js +34 -0
  149. package/src/build/relayAccess.js +85 -0
  150. package/src/build/slidesPlugin.js +371 -0
  151. package/src/build/themeCheck.js +151 -0
  152. package/src/build/tunnelClient.js +184 -0
  153. package/src/cli/args.js +76 -0
  154. package/src/cli/index.js +758 -0
  155. package/src/cli/migrate.js +123 -0
  156. package/src/cli/skill.js +40 -0
  157. package/src/cli/themes.js +140 -0
  158. package/src/components/CodeBlock.jsx +168 -0
  159. package/src/components/ConnectedViews.jsx +25 -0
  160. package/src/components/Icon.jsx +8 -0
  161. package/src/components/Poll.jsx +67 -0
  162. package/src/components/QrCode.jsx +29 -0
  163. package/src/components/QrCodeTag.jsx +27 -0
  164. package/src/components/Question.jsx +28 -0
  165. package/src/components/Scale.jsx +47 -0
  166. package/src/components/VideoPlayer.jsx +108 -0
  167. package/src/components/WordCloud.jsx +106 -0
  168. package/src/components/activity.jsx +35 -0
  169. package/src/components/callout.css +78 -0
  170. package/src/components/code-block.css +123 -0
  171. package/src/components/columns.css +11 -0
  172. package/src/components/poll.css +81 -0
  173. package/src/components/video-player.css +14 -0
  174. package/src/core/devPath.js +4 -0
  175. package/src/core/editDeck.js +230 -0
  176. package/src/core/icons.js +69 -0
  177. package/src/core/ink.js +221 -0
  178. package/src/core/labels.js +144 -0
  179. package/src/core/outline.js +33 -0
  180. package/src/core/parseSlides.js +152 -0
  181. package/src/core/source.js +137 -0
  182. package/src/core/urls.js +34 -0
  183. package/src/core/validateDeck.js +139 -0
  184. package/src/editor/api.js +67 -0
  185. package/src/editor/components/App.jsx +75 -0
  186. package/src/editor/components/ConflictBanner.jsx +10 -0
  187. package/src/editor/components/DeckSettings.jsx +108 -0
  188. package/src/editor/components/DesignApp.jsx +252 -0
  189. package/src/editor/components/Diagnostics.jsx +10 -0
  190. package/src/editor/components/ExtensionEditor.jsx +66 -0
  191. package/src/editor/components/ExtensionForms.jsx +134 -0
  192. package/src/editor/components/ExtensionsPanel.jsx +114 -0
  193. package/src/editor/components/Field.jsx +24 -0
  194. package/src/editor/components/Inspector.jsx +36 -0
  195. package/src/editor/components/LayoutPicker.jsx +23 -0
  196. package/src/editor/components/LayoutSelect.jsx +25 -0
  197. package/src/editor/components/LookThumbnail.jsx +8 -0
  198. package/src/editor/components/MetaFields.jsx +24 -0
  199. package/src/editor/components/Outline.jsx +28 -0
  200. package/src/editor/components/PalettePicker.jsx +32 -0
  201. package/src/editor/components/Preview.jsx +63 -0
  202. package/src/editor/components/PropsForm.jsx +46 -0
  203. package/src/editor/components/RawSlideEditor.jsx +9 -0
  204. package/src/editor/components/RegionEditor.jsx +15 -0
  205. package/src/editor/components/Resizer.jsx +18 -0
  206. package/src/editor/designLink.js +19 -0
  207. package/src/editor/editor.css +139 -0
  208. package/src/editor/extensions.js +86 -0
  209. package/src/editor/keyboard.js +21 -0
  210. package/src/editor/model.js +38 -0
  211. package/src/editor/sampleDeck.js +165 -0
  212. package/src/editor/saveQueue.js +47 -0
  213. package/src/editor/schema.js +36 -0
  214. package/src/editor/state.js +92 -0
  215. package/src/editor/useDeckDocument.js +67 -0
  216. package/src/extensions/appearance.js +79 -0
  217. package/src/extensions/contrast.js +57 -0
  218. package/src/extensions/discover.js +126 -0
  219. package/src/extensions/manifest.js +209 -0
  220. package/src/extensions/packs.js +268 -0
  221. package/src/extensions/tokens.js +25 -0
  222. package/src/home/Home.jsx +362 -0
  223. package/src/home/home.css +148 -0
  224. package/src/layouts/layoutApi.jsx +205 -0
  225. package/src/layouts/layoutProps.js +30 -0
  226. package/src/layouts/renderSlide.jsx +17 -0
  227. package/src/live/answer/answer.css +34 -0
  228. package/src/live/answer/answer.html +14 -0
  229. package/src/live/answer/answer.js +130 -0
  230. package/src/live/client.js +290 -0
  231. package/src/live/code.js +19 -0
  232. package/src/live/follow.js +17 -0
  233. package/src/live/pairing.js +34 -0
  234. package/src/live/presence.js +33 -0
  235. package/src/live/roomTag.js +38 -0
  236. package/src/live/rooms.js +249 -0
  237. package/src/live/server.js +82 -0
  238. package/src/live/tunnel.js +180 -0
  239. package/src/paths.js +9 -0
  240. package/src/runtime/ReaderView.jsx +193 -0
  241. package/src/runtime/SlideErrorBoundary.jsx +28 -0
  242. package/src/runtime/chrome.jsx +55 -0
  243. package/src/runtime/deck-stage.js +1533 -0
  244. package/src/runtime/design.html +12 -0
  245. package/src/runtime/design.jsx +5 -0
  246. package/src/runtime/editor.html +12 -0
  247. package/src/runtime/editor.jsx +5 -0
  248. package/src/runtime/editorBridge.js +58 -0
  249. package/src/runtime/fit.js +149 -0
  250. package/src/runtime/home.html +13 -0
  251. package/src/runtime/home.jsx +5 -0
  252. package/src/runtime/index.html +15 -0
  253. package/src/runtime/ink/InkLayer.jsx +17 -0
  254. package/src/runtime/ink/Toolbar.jsx +145 -0
  255. package/src/runtime/ink/attach.js +69 -0
  256. package/src/runtime/ink/bus.js +126 -0
  257. package/src/runtime/ink/controller.js +132 -0
  258. package/src/runtime/ink/room.js +101 -0
  259. package/src/runtime/ink/select.js +124 -0
  260. package/src/runtime/ink/server.js +75 -0
  261. package/src/runtime/ink/store.js +55 -0
  262. package/src/runtime/ink/toolbar.css +79 -0
  263. package/src/runtime/main.jsx +863 -0
  264. package/src/runtime/markedSetup.js +147 -0
  265. package/src/runtime/reader.css +59 -0
  266. package/src/runtime/registry.jsx +26 -0
  267. package/src/runtime/syncOrder.js +32 -0
  268. package/src/runtime/themeLoader.js +68 -0
@@ -0,0 +1,863 @@
1
+ import { validatePageUrl } from '../core/urls.js'
2
+ import { h, render } from 'preact'
3
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
4
+ import { marked } from 'marked'
5
+ import deckFileContent from 'virtual:slides'
6
+ import { parseSlides } from '../core/parseSlides'
7
+ import { validateDeck } from '../core/validateDeck'
8
+ import { loadTheme, setExtensionOverrides, THEME_NAMES, THEME_METAS, PALETTES } from './themeLoader'
9
+ import { resolvePalette } from '../extensions/appearance.js'
10
+ import { palettesFor } from '../extensions/tokens.js'
11
+ import { S, PaletteSwatches } from './chrome.jsx'
12
+ import { SlideErrorBoundary } from './SlideErrorBoundary.jsx'
13
+ import { createEditorBridge } from './editorBridge.js'
14
+ import { ReaderView } from './ReaderView.jsx'
15
+ import { configureLive, announce, setLookSource, actAsPresenter, useSteering } from '../live/client.js'
16
+ import { registry } from './registry'
17
+ import { followActiveRooms } from '../live/follow.js'
18
+ import { createOrder } from './syncOrder.js'
19
+ import { fitDeck } from './fit.js'
20
+ import { attachInk, watchInk } from './ink/attach.js'
21
+ import { roomTransport, followStageRoom, announceStagePosition } from './ink/room.js'
22
+ import { claimPairing } from '../live/pairing.js'
23
+ import { strokePath } from '../core/ink.js'
24
+ import { inkFileName } from 'virtual:deck-ink'
25
+ import { roomsIn, roomsOnSlide, findRoomTag, slideTitleFor } from '../live/roomTag.js'
26
+ import './reader.css'
27
+ import { SlideRenderer, manifests } from '../layouts/renderSlide'
28
+ import { setCalloutLabels } from './markedSetup'
29
+ import { setDeckLanguage, deckLanguage, stageLabels, t } from '../core/labels.js'
30
+ import { Icon } from '../components/Icon.jsx'
31
+ import { ConnectedViews } from '../components/ConnectedViews.jsx'
32
+ import './deck-stage.js'
33
+
34
+ const PresenterIcon = ({ name, size = 18 }) => <Icon name={name} size={size} style={{ display: 'block' }} />
35
+
36
+ // One address parameter selects the view: ?view=deck, reader, presenter or audience.
37
+ export function requestedView(url, fallback = 'deck') {
38
+ return url.searchParams.get('view') ?? fallback
39
+ }
40
+
41
+ // Scope controls to this file and presenter session, including separate tabs.
42
+ const controlUrl = new URL(window.location.href)
43
+ if (requestedView(controlUrl) === 'presenter' && !controlUrl.searchParams.has('session')) {
44
+ // randomUUID only exists on https or localhost, not on an iPad at http://192.168…
45
+ controlUrl.searchParams.set('session', crypto.randomUUID?.() ?? Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join(''))
46
+ history.replaceState(null, '', controlUrl)
47
+ }
48
+ const DECK_CHANNEL = `deck-control:${controlUrl.pathname}:${controlUrl.searchParams.get('session') ?? 'default'}`
49
+
50
+ function withConfigOverrides(deckConfig) {
51
+ const url = new URL(window.location.href)
52
+ const theme = url.searchParams.get('theme')
53
+ const palette = url.searchParams.get('palette')
54
+ const appearance = url.searchParams.get('appearance')
55
+ // The presenter view and previews pass their choices in the address. An
56
+ // empty ?palette= or ?appearance= means the theme's default, whatever the
57
+ // deck sets; a missing one keeps the deck's.
58
+ return {
59
+ ...deckConfig,
60
+ // A file sent to readers carries only the deck's own theme and palette;
61
+ // others named in the address are left out rather than failing.
62
+ ...(theme && THEME_METAS[theme] ? { theme } : {}),
63
+ ...(palette !== null && (!palette || PALETTES[palette]) ? { palette: palette || undefined } : {}),
64
+ ...(appearance !== null ? { appearance: appearance || undefined } : {}),
65
+ }
66
+ }
67
+
68
+ // The presenter's screen tells phones on the deck's answer link which
69
+ // activity is on the current slide. Decks without activities never call the
70
+ // server.
71
+ // The deck's text: the file's, or on the dev server with ?sample=1 the sample
72
+ // deck, which the design page's pictures show.
73
+ let slidesContent = deckFileContent
74
+ const hasActivities = () => roomsIn(slidesContent).length > 0
75
+ // What the phones show for the activity on a slide: its component's `phone`
76
+ // description, from the tag's attributes and the slide's heading.
77
+ function activityOn(deck, slide) {
78
+ const room = roomsOnSlide(slide)[0]
79
+ if (!room) return { room: null, activity: null }
80
+ const tag = findRoomTag(slide.content ?? '', room) ?? Object.values(slide.regions ?? {}).map(region => findRoomTag(region.content ?? '', room)).find(Boolean)
81
+ const element = tag && new DOMParser().parseFromString(tag, 'text/html').body.firstElementChild
82
+ const component = element && registry[element.localName]
83
+ if (typeof component?.phone !== 'function') return { room, activity: null }
84
+ const props = Object.fromEntries([...element.attributes].map(attribute => [attribute.name, attribute.value]))
85
+ try { return { room, activity: component.phone(props, { slideTitle: slideTitleFor(deck, room) }) } } catch (error) { console.warn(error); return { room, activity: null } }
86
+ }
87
+ function announceSlide(deck, index, { initial = false } = {}) {
88
+ if (hasActivities()) announce({ ...activityOn(deck, deck.slides[index]), title: deck.deckConfig?.meta?.title ?? '', initial })
89
+ }
90
+
91
+ // Whether this presenter screen moves the phones along, shown only for decks
92
+ // with activities: a failure used to be silent.
93
+ const STEERING = {
94
+ '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).',
95
+ 'wrong-code': "Phones do not follow: the server does not accept this browser's presenter code.",
96
+ unreachable: 'Phones do not follow: the server does not answer.',
97
+ 'other-screen': 'Phones follow another screen, where this presentation was moved on more recently. Change the slide here to take them back.',
98
+ }
99
+ function SteeringNote() {
100
+ const steering = useSteering()
101
+ if (!hasActivities() || !steering) return null
102
+ 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' }}>
103
+ {steering.ok ? 'Phones follow this presentation.' : STEERING[steering.reason]}
104
+ </div>
105
+ }
106
+
107
+ function injectSpeakerNotes(slides) {
108
+ let tag = document.getElementById('speaker-notes')
109
+ if (!tag) {
110
+ tag = document.createElement('script')
111
+ tag.id = 'speaker-notes'
112
+ tag.type = 'application/json'
113
+ document.body.appendChild(tag)
114
+ }
115
+ tag.textContent = JSON.stringify(slides.map(s => s.meta?.notes ?? ''))
116
+ }
117
+
118
+ // URL for the presenter's own iframe and preview pane (uses postMessage)
119
+ function buildChildUrl(theme, palette, appearance, slideIndex = null, { draw = false } = {}) {
120
+ const url = new URL(window.location.href)
121
+ url.searchParams.delete('v')
122
+ url.searchParams.set('view', 'deck')
123
+ url.searchParams.set('embedded', '1')
124
+ if (draw) url.searchParams.set('draw', '1')
125
+ if (theme) url.searchParams.set('theme', theme)
126
+ else url.searchParams.delete('theme')
127
+ setLookParams(url, palette, appearance)
128
+ if (slideIndex != null) url.hash = String(slideIndex + 1)
129
+ return url.toString()
130
+ }
131
+
132
+ // The palette and light or dark chosen in the presenter view, for its frames
133
+ // and the audience window.
134
+ function setLookParams(url, palette, appearance) {
135
+ // Always set: empty means the theme's default (see withConfigOverrides).
136
+ url.searchParams.set('palette', palette ?? '')
137
+ url.searchParams.set('appearance', appearance ?? '')
138
+ }
139
+
140
+ // URL for the audience window — view=audience makes it listen on BroadcastChannel
141
+ function buildAudienceUrl(theme, palette, appearance, slideIndex = null) {
142
+ const url = new URL(window.location.href)
143
+ url.searchParams.delete('embedded')
144
+ url.searchParams.delete('v')
145
+ url.searchParams.set('view', 'audience')
146
+ if (theme) url.searchParams.set('theme', theme)
147
+ else url.searchParams.delete('theme')
148
+ setLookParams(url, palette, appearance)
149
+ if (slideIndex != null) url.hash = String(slideIndex + 1)
150
+ return url.toString()
151
+ }
152
+
153
+ function sendTo(win, command, value) {
154
+ if (!win || win.closed) return
155
+ win.postMessage({ deckControl: { command, value } }, '*')
156
+ }
157
+
158
+ // Who is connected, as the presenter's main frame hears it from the server.
159
+ // Kept from the first message on, since a presenter view opened in a
160
+ // background tab runs its effects only once it is shown.
161
+ let lastPresence = null
162
+ const presenceListeners = new Set()
163
+ function followPresence() {
164
+ window.addEventListener('message', ({ data, origin }) => {
165
+ if (origin !== window.location.origin || !data?.presence) return
166
+ lastPresence = data.presence
167
+ for (const listener of presenceListeners) listener(lastPresence)
168
+ })
169
+ }
170
+ function usePresence() {
171
+ const [presence, setPresence] = useState(lastPresence)
172
+ useEffect(() => {
173
+ presenceListeners.add(setPresence)
174
+ setPresence(lastPresence)
175
+ return () => presenceListeners.delete(setPresence)
176
+ }, [])
177
+ return presence
178
+ }
179
+
180
+ function PresenterView({ deckConfig, slides }) {
181
+ const [index, setIndex] = useState(0)
182
+ const [theme, setTheme] = useState(deckConfig.theme ?? 'neue')
183
+ // '' means the theme's own default, for both.
184
+ const [palette, setPalette] = useState(deckConfig.palette ?? '')
185
+ const [appearance, setAppearance] = useState(deckConfig.appearance ?? '')
186
+ const [audienceConnected, setAudienceConnected] = useState(false)
187
+ const [noteSize, setNoteSize] = useState(13)
188
+ const [inking, setInking] = useState(false)
189
+ const presence = usePresence()
190
+ // Drawing in the slide's frame: this page around it must not pan or bounce either.
191
+ useEffect(() => { document.documentElement.classList.toggle('is-inking', inking) }, [inking])
192
+ // The slide zooms itself (two fingers in its frame); Safari's page zoom
193
+ // would enlarge the bars around it as well.
194
+ useEffect(() => {
195
+ const stop = event => event.preventDefault()
196
+ document.addEventListener('gesturestart', stop, { passive: false })
197
+ return () => document.removeEventListener('gesturestart', stop)
198
+ }, [])
199
+ // 'slide': the slide fills the screen (an iPad); the notes open as a drawer.
200
+ const [layout, setLayout] = useState(() => {
201
+ try { const saved = localStorage.getItem('mdeck-presenter-layout'); if (saved) return saved } catch {}
202
+ return matchMedia('(pointer: coarse)').matches ? 'slide' : 'speaker'
203
+ })
204
+ const [drawerOpen, setDrawerOpen] = useState(false)
205
+ const slideLayout = layout === 'slide'
206
+ function chooseLayout(next) {
207
+ setLayout(next)
208
+ setDrawerOpen(false)
209
+ try { localStorage.setItem('mdeck-presenter-layout', next) } catch {}
210
+ }
211
+ const fullscreen = window.mdeckFullscreen
212
+ const canFullscreen = !!fullscreen?.available()
213
+ // The button enters and leaves full screen, and shows which it will do.
214
+ const [isFullscreen, setIsFullscreen] = useState(() => !!fullscreen?.element())
215
+ useEffect(() => fullscreen?.onChange?.(setIsFullscreen), [])
216
+ const fullscreenLabel = isFullscreen ? 'Exit full screen (F)' : 'Full screen (F)'
217
+ const fullscreenIcon = isFullscreen ? 'fullscreen-exit' : 'fullscreen'
218
+
219
+ const [paletteOpen, setPaletteOpen] = useState(false)
220
+ // The theme row shows unless the presenter folded it, like the ink toolbar,
221
+ // into one round button.
222
+ const [lookFolded, setLookFolded] = useState(() => { try { return localStorage.getItem('mdeck-presenter-look') === 'folded' } catch { return false } })
223
+ function foldLook(folded) {
224
+ setLookFolded(folded)
225
+ setPaletteOpen(false)
226
+ try { localStorage.setItem('mdeck-presenter-look', folded ? 'folded' : 'open') } catch {}
227
+ }
228
+ const [themeModalOpen, setThemeModalOpen] = useState(false)
229
+ const [elapsed, setElapsed] = useState(0)
230
+ const [timerRunning, setTimerRunning] = useState(false)
231
+ const timerRef = useRef(null)
232
+
233
+ const iframeRef = useRef(null)
234
+ const previewRef = useRef(null)
235
+ const audienceRef = useRef(null)
236
+ const bcRef = useRef(null)
237
+ // Positions sent to and taken from audience windows are ordered (syncOrder.js).
238
+ const orderRef = useRef(null)
239
+ orderRef.current ??= createOrder()
240
+ const indexRef = useRef(0)
241
+ const stateRef = useRef({ index: 0, step: -1 })
242
+ const paletteDropRef = useRef(null)
243
+ indexRef.current = index
244
+ // Phones see the look of the slide this view shows, read from its frame.
245
+ useEffect(() => { setLookSource(() => iframeRef.current?.contentDocument) }, [])
246
+ const announcedOnce = useRef(false)
247
+ useEffect(() => {
248
+ announceSlide({ slides, deckConfig }, index, { initial: !announcedOnce.current })
249
+ announcedOnce.current = true
250
+ }, [index])
251
+
252
+ const themeMeta = THEME_METAS[theme]
253
+ // The palettes this theme offers, and what the deck shows right now.
254
+ const offered = palettesFor(themeMeta, PALETTES)
255
+ const shown = resolvePalette({ theme: themeMeta, palettes: PALETTES, palette, appearance })
256
+ // A theme that does not offer the chosen palette falls back to its own.
257
+ useEffect(() => { if (palette && !offered.some(p => p.id === palette)) setPalette('') }, [theme])
258
+ const notes = useMemo(() => slides.map(s => s.meta?.notes ?? ''), [slides])
259
+ // Preserve current slide when theme/palette causes an iframe reload
260
+ const iframeSrc = useMemo(() => buildChildUrl(theme, palette, appearance, indexRef.current, { draw: true }), [theme, palette, appearance])
261
+ // previewSrc only recomputes on a change of look; slide changes use postMessage
262
+ const previewSrc = useMemo(
263
+ () => buildChildUrl(theme, palette, appearance, indexRef.current + 1),
264
+ [theme, palette, appearance]
265
+ )
266
+
267
+ // BroadcastChannel for audience sync — more reliable than cross-window postMessage
268
+ useEffect(() => {
269
+ bcRef.current = new BroadcastChannel(DECK_CHANNEL)
270
+ bcRef.current.onmessage = ({ data }) => {
271
+ if (data?.audienceReady) bcRef.current?.postMessage({ deckControl: { command: 'setState', value: stateRef.current, order: orderRef.current.current() } })
272
+ // An audience window moved: follow it, unless this view has moved on since.
273
+ if (data?.deckStateChanged && orderRef.current.accept(data.order)) sendTo(iframeRef.current?.contentWindow, 'setState', data.deckStateChanged)
274
+ }
275
+ return () => bcRef.current?.close()
276
+ }, [])
277
+
278
+ // When the presenter iframe navigates: update displayed index, sync audience + preview.
279
+ // Skip audience/preview sync on 'init' (iframe load/reload) — the audience already
280
+ // opened at the correct slide, and previewSrc encodes the right hash on reload.
281
+ useEffect(() => {
282
+ function onMessage({ data, source }) {
283
+ if (source === previewRef.current?.contentWindow) {
284
+ if (data?.deckStateChanged && data.reason === 'init') sendTo(source, 'goTo', indexRef.current + 1)
285
+ return
286
+ }
287
+ if (source !== iframeRef.current?.contentWindow) return
288
+ if (typeof data?.inkMode === 'boolean') { setInking(data.inkMode); return }
289
+ if (!data?.deckStateChanged) return
290
+ const state = data.deckStateChanged
291
+ const i = state.index
292
+ // Theme reloads must not reset the current reveal position.
293
+ if (data.reason === 'init' && stateRef.current.index === i) {
294
+ sendTo(iframeRef.current?.contentWindow, 'setState', stateRef.current)
295
+ return
296
+ }
297
+ stateRef.current = state
298
+ setIndex(i)
299
+ if (data.reason !== 'sync') {
300
+ bcRef.current?.postMessage({ deckControl: { command: 'setState', value: state, order: orderRef.current.stamp() } })
301
+ }
302
+ sendTo(previewRef.current?.contentWindow, 'goTo', i + 1)
303
+ }
304
+ window.addEventListener('message', onMessage)
305
+ // The frame may have started drawing before this listener was there.
306
+ try { if (iframeRef.current?.contentWindow?.document.querySelector('deck-stage')?.inking) setInking(true) } catch {}
307
+ return () => window.removeEventListener('message', onMessage)
308
+ }, [])
309
+
310
+ // Keyboard nav in the presenter sidebar → drive the iframe only;
311
+ // audience follows automatically via the slideIndexChanged → BroadcastChannel path
312
+ useEffect(() => {
313
+ function onKey(e) {
314
+ if (e.target?.matches?.('input, select, textarea')) return
315
+ if (e.metaKey || e.ctrlKey || e.altKey) return
316
+ const cmd =
317
+ (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') ? 'next' :
318
+ (e.key === 'ArrowLeft' || e.key === 'PageUp') ? 'prev' :
319
+ (e.key === 'Home' || e.key === 'r' || e.key === 'R') ? 'reset' :
320
+ (e.key === 'd' || e.key === 'D') ? 'ink' : null
321
+ if ((e.key === 'f' || e.key === 'F') && fullscreen?.available()) { e.preventDefault(); fullscreen.toggle(); return }
322
+ if (e.key === 'n' || e.key === 'N') { e.preventDefault(); setDrawerOpen(open => !open); return }
323
+ if (!cmd) return
324
+ e.preventDefault()
325
+ sendTo(iframeRef.current?.contentWindow, cmd, cmd === 'ink' ? 'toggle' : undefined)
326
+ }
327
+ window.addEventListener('keydown', onKey)
328
+ return () => window.removeEventListener('keydown', onKey)
329
+ }, [])
330
+
331
+ // Escape to close theme modal
332
+ useEffect(() => {
333
+ if (!themeModalOpen) return
334
+ function handler(e) {
335
+ if (e.key === 'Escape') setThemeModalOpen(false)
336
+ }
337
+ window.addEventListener('keydown', handler)
338
+ return () => window.removeEventListener('keydown', handler)
339
+ }, [themeModalOpen])
340
+
341
+ useEffect(() => {
342
+ if (timerRunning) {
343
+ timerRef.current = setInterval(() => setElapsed(s => s + 1), 1000)
344
+ } else {
345
+ clearInterval(timerRef.current)
346
+ }
347
+ return () => clearInterval(timerRef.current)
348
+ }, [timerRunning])
349
+
350
+ // Close palette dropdown on outside click
351
+ useEffect(() => {
352
+ if (!paletteOpen) return
353
+ function handler(e) {
354
+ if (!paletteDropRef.current?.contains(e.target)) setPaletteOpen(false)
355
+ }
356
+ document.addEventListener('mousedown', handler)
357
+ return () => document.removeEventListener('mousedown', handler)
358
+ }, [paletteOpen])
359
+
360
+ // When theme/palette changes, tell the audience to hot-swap its theme without
361
+ // a reload. Phones pick it up with the next announcement.
362
+ useEffect(() => {
363
+ bcRef.current?.postMessage({ deckControl: { command: 'setTheme', theme, palette, appearance } })
364
+ }, [theme, palette, appearance])
365
+
366
+ // Nav buttons drive the presenter iframe; audience follows via slideIndexChanged
367
+ function navCommand(cmd) {
368
+ sendTo(iframeRef.current?.contentWindow, cmd)
369
+ }
370
+
371
+ function openAudienceWindow() {
372
+ const aw = window.open(
373
+ buildAudienceUrl(theme, palette, appearance, indexRef.current),
374
+ 'deck-audience-view'
375
+ )
376
+ if (!aw) return
377
+ audienceRef.current = aw
378
+ setAudienceConnected(true)
379
+ }
380
+
381
+ const note = notes[index] || ''
382
+ const hasNext = index + 1 < slides.length
383
+
384
+ const clock = `${String(Math.floor(elapsed / 60)).padStart(2, '0')}:${String(elapsed % 60).padStart(2, '0')}`
385
+ const pill = { ...S.btn, minWidth: '40px', height: '40px', padding: '0 10px', fontSize: '15px' }
386
+
387
+ return (
388
+ <div class={`presenter presenter--${layout}`} style={{ display: 'grid', gridTemplateColumns: slideLayout ? '1fr' : '2fr 1fr', height: '100dvh', background: '#111', overflow: 'hidden', touchAction: 'pan-x pan-y' }}>
389
+ <iframe
390
+ ref={iframeRef}
391
+ title="Presenter deck"
392
+ src={iframeSrc}
393
+ style={{ width: '100%', height: '100%', border: '0' }}
394
+ />
395
+ {slideLayout && (
396
+ <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' }}>
397
+ <span style={{ padding: '0 8px', color: '#888' }}>{index + 1}/{slides.length}</span>
398
+ <ConnectedViews presence={presence} phones={hasActivities()} style={{ color: '#888', padding: '0 6px' }} />
399
+ <button style={{ ...pill, color: timerRunning ? '#f0f0f0' : '#777' }} title="Start or pause the timer" onClick={() => setTimerRunning(r => !r)}>{clock}</button>
400
+ <button style={pill} title="Previous" aria-label="Previous" onClick={() => navCommand('prev')}><PresenterIcon name="prev" /></button>
401
+ <button style={pill} title="Next" aria-label="Next" onClick={() => navCommand('next')}><PresenterIcon name="next" /></button>
402
+ <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>
403
+ <button class="presenter-notes" title="Notes (N)" aria-pressed={drawerOpen} style={{ ...pill, ...(drawerOpen ? { background: '#2a2a2a', color: '#fff' } : {}) }} onClick={() => setDrawerOpen(open => !open)}>Notes</button>
404
+ {canFullscreen && <button class="presenter-fullscreen" style={pill} title={fullscreenLabel} aria-label={fullscreenLabel} aria-pressed={isFullscreen} onClick={() => fullscreen.toggle()}><PresenterIcon name={fullscreenIcon} /></button>}
405
+ <button style={pill} title="Speaker layout: slide, notes and next slide side by side" onClick={() => chooseLayout('speaker')}><PresenterIcon name="speaker" /></button>
406
+ </div>
407
+ )}
408
+ <aside class="presenter-aside" style={{
409
+ display: slideLayout && !drawerOpen ? 'none' : 'flex',
410
+ flexDirection: 'column',
411
+ gap: '14px',
412
+ padding: '16px',
413
+ borderLeft: '1px solid #2a2a2a',
414
+ background: '#111',
415
+ color: '#ccc',
416
+ fontFamily: 'ui-sans-serif, system-ui, sans-serif',
417
+ fontSize: '13px',
418
+ overflow: 'hidden',
419
+ ...(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)' } : {}),
420
+ }}>
421
+
422
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
423
+ <span style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
424
+ <span style={{ fontWeight: 600, color: '#f0f0f0', fontSize: '14px' }}>Speaker View</span>
425
+ <ConnectedViews presence={presence} phones={hasActivities()} style={{ color: '#888' }} />
426
+ </span>
427
+ <span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
428
+ {!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>}
429
+ {!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>}
430
+ <span style={{ color: '#666', fontVariantNumeric: 'tabular-nums' }}>
431
+ {String(index + 1).padStart(2, '0')} / {String(slides.length).padStart(2, '0')}
432
+ </span>
433
+ </span>
434
+ </div>
435
+
436
+ <SteeringNote />
437
+
438
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
439
+ <span style={{ fontVariantNumeric: 'tabular-nums', fontSize: '22px', letterSpacing: '0.05em', color: timerRunning ? '#f0f0f0' : '#555', fontWeight: 300 }}>
440
+ {String(Math.floor(elapsed / 60)).padStart(2, '0')}:{String(elapsed % 60).padStart(2, '0')}
441
+ </span>
442
+ <div style={{ display: 'flex', gap: '4px' }}>
443
+ <button style={S.btn} title={timerRunning ? 'Pause the timer' : 'Start the timer'} aria-label={timerRunning ? 'Pause the timer' : 'Start the timer'} onClick={() => setTimerRunning(r => !r)}>
444
+ <PresenterIcon name={timerRunning ? 'pause' : 'play'} size={16} />
445
+ </button>
446
+ <button style={S.btn} title="Reset the timer" aria-label="Reset the timer" onClick={() => { setTimerRunning(false); setElapsed(0) }}><PresenterIcon name="reset" size={16} /></button>
447
+ </div>
448
+ </div>
449
+
450
+ {hasNext && (
451
+ <div style={{ flexShrink: 0 }}>
452
+ <span style={S.label}>Next slide</span>
453
+ <div style={{ aspectRatio: '16/9', borderRadius: '4px', overflow: 'hidden', border: '1px solid #2a2a2a', background: '#000' }}>
454
+ <iframe
455
+ ref={previewRef}
456
+ title="Next slide preview"
457
+ src={previewSrc}
458
+ scrolling="no"
459
+ style={{ width: '100%', height: '100%', border: '0', pointerEvents: 'none' }}
460
+ />
461
+ </div>
462
+ </div>
463
+ )}
464
+
465
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
466
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '5px' }}>
467
+ <span style={{ ...S.label, marginBottom: 0 }}>Notes</span>
468
+ <input
469
+ type="range" min="11" max="22" step="1" value={noteSize}
470
+ onInput={e => setNoteSize(Number(e.currentTarget.value))}
471
+ style={{ width: '80px', accentColor: '#444', cursor: 'pointer', opacity: 0.6 }}
472
+ />
473
+ </div>
474
+ <div class="notes-md" style={{
475
+ flex: 1,
476
+ margin: 0,
477
+ padding: '10px 12px',
478
+ lineHeight: 1.6,
479
+ background: '#0a0a0a',
480
+ border: '1px solid #2a2a2a',
481
+ borderRadius: '5px',
482
+ fontSize: `${noteSize}px`,
483
+ color: note ? '#c8c8c8' : '#3a3a3a',
484
+ fontFamily: 'inherit',
485
+ overflow: 'auto',
486
+ }}
487
+ dangerouslySetInnerHTML={{
488
+ __html: note
489
+ ? marked.parse(note)
490
+ : '<p>No notes — add a <code>:::notes</code> block to the slide.</p>',
491
+ }}
492
+ />
493
+ </div>
494
+
495
+ {!lookFolded && <div class="presenter-look" style={{ flexShrink: 0, display: 'flex', gap: '6px', marginBottom: '8px', alignItems: 'flex-end' }}>
496
+ <label style={{ flex: 1 }}>
497
+ <span style={S.label}>Theme</span>
498
+ <button
499
+ onClick={() => setThemeModalOpen(true)}
500
+ style={{ ...S.select, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '4px', cursor: 'pointer' }}
501
+ >
502
+ <span>{theme}</span>
503
+ <span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="grid" size={13} /></span>
504
+ </button>
505
+ </label>
506
+ <label style={{ flex: 1 }}>
507
+ <span style={S.label}>Palette</span>
508
+ <div ref={paletteDropRef} style={{ position: 'relative' }}>
509
+ <button
510
+ class="presenter-palette"
511
+ onClick={() => setPaletteOpen(o => !o)}
512
+ style={{ ...S.select, display: 'flex', alignItems: 'center', gap: '5px', cursor: 'pointer', textAlign: 'left' }}
513
+ >
514
+ <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{shown.palette?.title ?? ''}</span>
515
+ <PaletteSwatches tokens={shown.palette?.[shown.appearance] ?? {}} />
516
+ <span style={{ opacity: 0.5, flexShrink: 0 }}><PresenterIcon name="dropdown" size={13} /></span>
517
+ </button>
518
+ {paletteOpen && (
519
+ <div style={{ position: 'absolute', bottom: 'calc(100% + 4px)', left: 0, right: 0, background: '#1a1a1a', border: '1px solid #2e2e2e', borderRadius: '5px', zIndex: 100, overflow: 'hidden', boxShadow: '0 4px 20px rgba(0,0,0,0.6)' }}>
520
+ {offered.map(p => (
521
+ <div
522
+ key={p.id}
523
+ class="presenter-palette-option"
524
+ title={p.description}
525
+ onClick={() => { setPalette(p.id === themeMeta?.palette ? '' : p.id); setPaletteOpen(false) }}
526
+ style={{ padding: '6px 8px', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '6px', fontSize: '13px', color: shown.palette?.id === p.id ? '#f0f0f0' : '#aaa', background: shown.palette?.id === p.id ? '#2a2a2a' : 'transparent' }}
527
+ >
528
+ <span>{p.title}{p.id === themeMeta?.palette ? <span style={{ color: '#666' }}> · theme default</span> : null}</span>
529
+ <PaletteSwatches tokens={p[shown.appearance] ?? {}} />
530
+ </div>
531
+ ))}
532
+ </div>
533
+ )}
534
+ </div>
535
+ </label>
536
+ <label style={{ flexShrink: 0 }}>
537
+ <span style={S.label}>Light or dark</span>
538
+ <div class="presenter-appearance" role="group" aria-label="Light or dark" style={{ display: 'flex', gap: '2px', height: '28px' }}>
539
+ {['light', 'dark'].map(mode => (
540
+ <button
541
+ key={mode}
542
+ aria-pressed={shown.appearance === mode}
543
+ title={mode === 'light' ? 'Light: for bright rooms' : 'Dark: for dark rooms'}
544
+ aria-label={mode === 'light' ? 'Light' : 'Dark'}
545
+ onClick={() => setAppearance(mode === (themeMeta?.appearance ?? 'light') ? '' : mode)}
546
+ style={{ ...S.btn, padding: '3px 7px', ...(shown.appearance === mode ? { background: '#2a2a2a', color: '#f0f0f0', borderColor: '#444' } : {}) }}
547
+ ><PresenterIcon name={mode === 'light' ? 'sun' : 'moon'} size={16} /></button>
548
+ ))}
549
+ </div>
550
+ </label>
551
+ <button class="presenter-look-fold" title="Fold theme and palette" aria-label="Fold theme and palette" onClick={() => foldLook(true)}
552
+ style={{ ...S.btn, padding: 0, width: '28px', height: '28px', flexShrink: 0, background: 'none', borderColor: 'transparent', color: '#888' }}
553
+ ><PresenterIcon name="fold" size={16} /></button>
554
+ </div>}
555
+
556
+ <div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
557
+ <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
558
+ <button style={S.btn} onClick={() => navCommand('prev')}><PresenterIcon name="prev" size={15} />Prev</button>
559
+ <button style={S.btn} onClick={() => navCommand('next')}>Next<PresenterIcon name="next" size={15} /></button>
560
+ <button style={S.btn} onClick={() => navCommand('reset')}>Reset</button>
561
+ <button
562
+ class="presenter-draw"
563
+ title="Draw on the slide (D)"
564
+ aria-pressed={inking}
565
+ style={{ ...S.btn, ...(inking ? { background: '#e11d48', borderColor: '#e11d48', color: '#fff' } : {}) }}
566
+ onClick={() => sendTo(iframeRef.current?.contentWindow, 'ink', 'toggle')}
567
+ ><PresenterIcon name="pen" size={15} />Draw</button>
568
+ {lookFolded && <button
569
+ class="presenter-look-unfold"
570
+ title={`Theme and palette: ${themeMeta?.title ?? theme}, ${shown.palette?.title ?? ''}`}
571
+ aria-label="Show theme and palette"
572
+ onClick={() => foldLook(false)}
573
+ style={{ ...S.btn, position: 'relative', padding: 0, width: '32px', height: '32px', borderRadius: '50%' }}
574
+ >
575
+ <PresenterIcon name="look" size={16} />
576
+ <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' }} />
577
+ </button>}
578
+ <button
579
+ style={{
580
+ ...S.btn,
581
+ marginLeft: 'auto',
582
+ color: audienceConnected ? '#444' : '#bbb',
583
+ }}
584
+ onClick={openAudienceWindow}
585
+ >
586
+ {audienceConnected ? 'Reconnect' : 'Audience'}
587
+ </button>
588
+ </div>
589
+ <div style={{ color: '#444', fontSize: '11px', textAlign: 'center' }}>
590
+ Arrow keys · Space · PgUp/PgDn · R to reset
591
+ </div>
592
+ </div>
593
+
594
+ </aside>
595
+
596
+ {themeModalOpen && (
597
+ <div
598
+ style={{ position: 'fixed', inset: 0, zIndex: 200, background: 'rgba(0,0,0,0.85)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
599
+ onClick={() => setThemeModalOpen(false)}
600
+ >
601
+ <div
602
+ style={{ background: '#161616', border: '1px solid #2a2a2a', borderRadius: '12px', padding: '24px', width: 'min(90vw, 1000px)', maxHeight: '85vh', overflow: 'auto', display: 'flex', flexDirection: 'column', gap: '16px' }}
603
+ onClick={e => e.stopPropagation()}
604
+ >
605
+ <span style={S.label}>Choose Theme</span>
606
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '12px' }}>
607
+ {THEME_NAMES.map(n => (
608
+ <div
609
+ key={n}
610
+ onClick={() => { setTheme(n); setThemeModalOpen(false) }}
611
+ style={{ cursor: 'pointer', borderRadius: '8px', overflow: 'hidden', border: `2px solid ${n === theme ? '#60a5fa' : '#2a2a2a'}`, background: '#111' }}
612
+ >
613
+ <div style={{ aspectRatio: '16/9', overflow: 'hidden' }}>
614
+ <iframe
615
+ src={buildChildUrl(n, palette, appearance, index)}
616
+ scrolling="no"
617
+ style={{ width: '100%', height: '100%', border: 0, pointerEvents: 'none', display: 'block' }}
618
+ />
619
+ </div>
620
+ <div style={{ padding: '8px 10px', fontSize: '14px', fontFamily: 'inherit', color: n === theme ? '#f0f0f0' : '#aaa', background: n === theme ? '#1c2a3a' : 'transparent' }}>
621
+ {n}
622
+ </div>
623
+ </div>
624
+ ))}
625
+ </div>
626
+ </div>
627
+ </div>
628
+ )}
629
+ </div>
630
+ )
631
+ }
632
+
633
+ // Renders (or re-renders) the deck stage. Slides are keyed by id so an edit to
634
+ // one slide leaves the others and the stage's position untouched.
635
+ let unfit = null
636
+ let followDeck = null
637
+ function mountDeck({ deck, deckConfig, selection = null, editor = false }) {
638
+ const { slides } = deck
639
+ injectSpeakerNotes(slides)
640
+ const seen = new Map()
641
+ const app = (
642
+ <deck-stage width={deckConfig.width ?? 1920} height={deckConfig.height ?? 1080}>
643
+ {slides.map((slide, i) => {
644
+ const count = (seen.get(slide.id) ?? 0) + 1
645
+ seen.set(slide.id, count)
646
+ const key = count > 1 ? `${slide.id}#${i}` : slide.id
647
+ const props = { id: slide.id, regions: slide.regions, meta: slide.meta, content: slide.content, deckConfig, index: i, total: slides.length }
648
+ if (!editor) return <SlideRenderer key={key} {...props} />
649
+ return <SlideErrorBoundary key={key} id={slide.id} source={deck.source.slice(slide.source.start, slide.source.end)}><SlideRenderer {...props} /></SlideErrorBoundary>
650
+ })}
651
+ </deck-stage>
652
+ )
653
+ render(app, document.body)
654
+ document.querySelector('deck-stage')?.setLabels(stageLabels())
655
+ const stage = document.querySelector('deck-stage')
656
+ if (stage) stage.inkRenderer = strokePath
657
+ followDeck?.()
658
+ followDeck = followActiveRooms(document.querySelector('deck-stage'), slides)
659
+ // Code taller than its place scrolls; too-full slides are reported (fit.js).
660
+ unfit?.()
661
+ unfit = fitDeck(document.querySelector('deck-stage'))
662
+ if (!selection) return
663
+ // The stage re-collects its slides asynchronously after structural changes.
664
+ setTimeout(() => {
665
+ const stage = document.querySelector('deck-stage')
666
+ if (stage && stage.index !== selection.index) stage.setState({ index: selection.index, slideId: selection.slideId, step: -1 })
667
+ }, 0)
668
+ }
669
+
670
+ async function init() {
671
+ // An iPad that opened the launch page's QR code pairs first.
672
+ await claimPairing()
673
+ const url = new URL(window.location.href)
674
+ if (import.meta.env.DEV && url.searchParams.get('sample') === '1') {
675
+ const { sampleDeck, sampleDataUrl } = await import('../editor/sampleDeck.js')
676
+ slidesContent = sampleDeck(sampleDataUrl())
677
+ }
678
+ const parsed = parseSlides(slidesContent)
679
+ const editorMode = url.searchParams.get('editor') === '1'
680
+ // One switch selects the view: deck, reader, presenter or audience.
681
+ const defaultView = typeof __MDECK_DEFAULT_VIEW__ !== 'undefined' ? __MDECK_DEFAULT_VIEW__ : 'deck'
682
+ const view = requestedView(url, defaultView)
683
+ const errors = validateDeck(parsed, { layouts: manifests }).filter(d => d.severity === 'error')
684
+ if (errors.length && !editorMode) {
685
+ document.body.textContent = errors.map(d => `Line ${d.line}: ${d.message}`).join('\n')
686
+ document.body.style.whiteSpace = 'pre-wrap'
687
+ return
688
+ }
689
+ const deckConfig = withConfigOverrides(parsed.deckConfig)
690
+ setCalloutLabels(deckConfig)
691
+ setDeckLanguage(deckConfig)
692
+ document.documentElement.lang = deckLanguage()
693
+ configureLive(deckConfig)
694
+ const { slides } = parsed
695
+ const presenterMode = view === 'presenter'
696
+ const audienceMode = view === 'audience'
697
+ const embedded = url.searchParams.get('embedded') === '1'
698
+ const readerMode = view === 'reader' && !editorMode && !embedded
699
+ // The deck window and the presenter's main frame draw; the audience window
700
+ // and the next-slide preview show what is drawn.
701
+ const drawHere = !audienceMode && (!embedded || url.searchParams.get('draw') === '1')
702
+ const inkStorageKey = `mdeck-ink:${inkFileName}:${location.pathname}`
703
+
704
+ injectSpeakerNotes(slides)
705
+
706
+ if (presenterMode) {
707
+ actAsPresenter()
708
+ followPresence()
709
+ document.body.style.margin = '0'
710
+ const s = document.createElement('style')
711
+ s.textContent = `
712
+ .notes-md p { margin: 0 0 6px; }
713
+ .notes-md p:last-child { margin-bottom: 0; }
714
+ .notes-md ul, .notes-md ol { margin: 0 0 6px; padding-left: 1.4em; }
715
+ .notes-md li { margin-bottom: 2px; }
716
+ .notes-md strong, .notes-md b { color: #f0f0f0; font-weight: 600; }
717
+ .notes-md em, .notes-md i { font-style: italic; color: #bbb; }
718
+ .notes-md code { font-family: ui-monospace, monospace; font-size: 0.9em; background: #222; padding: 1px 4px; border-radius: 3px; }
719
+ .notes-md a { color: #aaa; }
720
+ `
721
+ document.head.appendChild(s)
722
+ render(<PresenterView deckConfig={deckConfig} slides={slides} />, document.body)
723
+ return
724
+ }
725
+
726
+ function handleDeckControl(ctrl) {
727
+ if (!ctrl) return
728
+ const stage = document.querySelector('deck-stage')
729
+ if (!stage) return
730
+ if (ctrl.command === 'next') stage.next()
731
+ else if (ctrl.command === 'prev') stage.prev()
732
+ else if (ctrl.command === 'reset') stage.reset()
733
+ else if (ctrl.command === 'goTo' && Number.isInteger(ctrl.value)) stage.goTo(ctrl.value)
734
+ else if (ctrl.command === 'setState') stage.setState(ctrl.value)
735
+ else if (ctrl.command === 'ink' && drawHere) stage.inking = ctrl.value === 'toggle' ? !stage.inking : !!ctrl.value
736
+ }
737
+
738
+ // Editor preview: the editor pushes whole sources; render best effort and
739
+ // report back. Bad decks never blank the preview here.
740
+ if (editorMode) {
741
+ const post = message => window.parent.postMessage(message, window.location.origin)
742
+ const bridge = createEditorBridge({
743
+ parse: parseSlides, validate: deck => validateDeck(deck, { layouts: manifests }), loadTheme, setExtensionOverrides,
744
+ setCalloutLabels: config => { setCalloutLabels(config); setDeckLanguage(config); document.documentElement.lang = deckLanguage() },
745
+ applyOverrides: withConfigOverrides, mount: context => mountDeck({ ...context, editor: true }), post,
746
+ })
747
+ window.addEventListener('message', event => {
748
+ if (event.source !== window.parent || event.origin !== window.location.origin) return
749
+ if (!bridge.handleMessage(event.data)) handleDeckControl(event.data?.deckControl)
750
+ })
751
+ await bridge.render(slidesContent)
752
+ post({ deckEditorReady: true })
753
+ return
754
+ }
755
+
756
+ if (readerMode) {
757
+ document.body.style.margin = '0'
758
+ await loadTheme(deckConfig)
759
+ render(<ReaderView deck={parsed} deckConfig={deckConfig} />, document.body)
760
+ return
761
+ }
762
+
763
+ await loadTheme(deckConfig)
764
+ mountDeck({ deck: parsed, deckConfig })
765
+ if (!embedded && !audienceMode) {
766
+ // A quiet way into the reader view for anyone who opened the file directly.
767
+ const entry = document.createElement('a')
768
+ entry.className = 'reader-entry'
769
+ entry.href = '?view=reader'
770
+ entry.textContent = t('deck.overview')
771
+ document.body.appendChild(entry)
772
+ }
773
+
774
+ // Drawing (D) in the full-screen deck and the presenter's main frame.
775
+ const inkStage = document.querySelector('deck-stage')
776
+ // Main slide frames and audience windows both send and follow navigation.
777
+ // Share their position connection with ink; previews stay on local channels.
778
+ if (inkStage && (drawHere || audienceMode)) {
779
+ followStageRoom(inkStage)
780
+ announceStagePosition(inkStage)
781
+ }
782
+ if (inkStage && drawHere) {
783
+ attachInk(inkStage, {
784
+ storageKey: inkStorageKey,
785
+ // The presenter's main frame tells the presenter view who is connected.
786
+ transports: [roomTransport({ receive: true, view: embedded ? 'presenter' : 'deck', onPresence: embedded ? presence => window.parent.postMessage({ presence }, window.location.origin) : null })],
787
+ onMode: inking => { if (embedded) window.parent.postMessage({ inkMode: inking }, window.location.origin) },
788
+ })
789
+ // Drawing turns itself on, with the laser and the toolbar folded into
790
+ // its corner button, where it is wanted: in the presenter view on a
791
+ // touch screen (an iPad), and in a deck window as soon as a pen touches
792
+ // it. A phone or tablet only browsing the deck keeps its taps. D or the
793
+ // close button turns it off.
794
+ const startDrawing = () => { inkStage._inkCollapsed = true; inkStage.inking = true }
795
+ if (embedded && matchMedia('(pointer: coarse)').matches) startDrawing()
796
+ else if (!embedded) {
797
+ const onPen = event => {
798
+ if (event.pointerType !== 'pen') return
799
+ window.removeEventListener('pointerdown', onPen, true)
800
+ if (!inkStage.inking) startDrawing()
801
+ }
802
+ window.addEventListener('pointerdown', onPen, true)
803
+ }
804
+ } else if (inkStage && audienceMode) {
805
+ // The audience window shows the presenter's zoom and a poll's answer, without their controls.
806
+ inkStage.setAttribute('data-follower', '')
807
+ watchInk(inkStage, { storageKey: inkStorageKey, transports: [roomTransport({ receive: true, view: 'audience' })] })
808
+ } else if (inkStage) watchInk(inkStage, { storageKey: inkStorageKey })
809
+
810
+ // A full deck or audience window is a presenter's screen; previews are embedded.
811
+ if (!embedded) {
812
+ const stage = document.querySelector('deck-stage')
813
+ stage?.addEventListener('slidechange', event => announceSlide(parsed, event.detail.index, { initial: event.detail.reason === 'init' }))
814
+ if (stage) announceSlide(parsed, stage.index, { initial: true })
815
+ }
816
+
817
+ // Embedded iframes (presenter view + preview pane) receive commands via postMessage
818
+ window.addEventListener('message', event => {
819
+ if (event.source === window.parent && event.origin === window.location.origin) handleDeckControl(event.data?.deckControl)
820
+ })
821
+
822
+ // Audience window receives commands via BroadcastChannel — reliable same-origin sync
823
+ // that doesn't depend on keeping a live cross-window reference
824
+ if (audienceMode) {
825
+ const bc = new BroadcastChannel(DECK_CHANNEL)
826
+ const order = createOrder()
827
+ bc.onmessage = ({ data }) => {
828
+ const ctrl = data?.deckControl
829
+ if (!ctrl) return
830
+ // A position older than this window's own last change is ignored.
831
+ if (ctrl.command === 'setState' && !order.accept(ctrl.order)) return
832
+ if (ctrl.command === 'setTheme') {
833
+ loadTheme({ theme: ctrl.theme, palette: ctrl.palette || undefined, appearance: ctrl.appearance || undefined })
834
+ } else {
835
+ handleDeckControl(ctrl)
836
+ }
837
+ }
838
+ document.querySelector('deck-stage')?.addEventListener('slidechange', event => {
839
+ if (event.detail.reason === 'init') bc.postMessage({ audienceReady: true })
840
+ })
841
+ document.querySelector('deck-stage')?.addEventListener('statechange', event => {
842
+ if (!['init', 'sync'].includes(event.detail.reason)) bc.postMessage({ deckStateChanged: event.detail, order: order.stamp() })
843
+ })
844
+ bc.postMessage({ audienceReady: true })
845
+ }
846
+ }
847
+
848
+ const urlProblem = validatePageUrl(new URL(location.href))
849
+ if (urlProblem) {
850
+ const message = document.createElement('p')
851
+ message.textContent = urlProblem.message
852
+ const panel = document.createElement('main')
853
+ panel.style.cssText = 'font:16px/1.5 system-ui;max-width:48rem;margin:3rem auto;padding:2rem'
854
+ panel.setAttribute('role', 'alert')
855
+ panel.append(message)
856
+ if (urlProblem.corrected) {
857
+ const link = document.createElement('a')
858
+ link.href = urlProblem.corrected
859
+ link.textContent = 'Open updated address'
860
+ panel.append(link)
861
+ }
862
+ document.body.replaceChildren(panel)
863
+ } else init()