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,290 @@
1
+ // Audience interaction for slide components: `import { useRoom } from 'mdeck/live'`.
2
+ //
3
+ // Relay mode: the server connects the presenter's screen and the phones
4
+ // and never needs the deck. Every activity in a deck shares one join link,
5
+ // <server>/<code>. The presenter's screen announces the activity on the current
6
+ // slide together with what the phone should show (a component's `phone`
7
+ // description), the deck's look and the words in its language; the room
8
+ // server's answer page shows that. On the slides, `useRoom` follows the
9
+ // answers.
10
+ import { useCallback, useEffect, useState } from 'preact/hooks'
11
+ import { sessionCode } from './code.js'
12
+ import { pairToken } from './pairing.js'
13
+ import { devPath } from '../core/devPath.js'
14
+ import { t, deckLanguage } from '../core/labels.js'
15
+
16
+ // The QR code component the built-in poll uses, for your own activities.
17
+ export { default as QrCode } from '../components/QrCode.jsx'
18
+
19
+ let settings = { server: null, code: '000000' }
20
+
21
+ // Called by the runtime with the deck settings.
22
+ // `mdeck run --server <address>` and MDECK_SERVER name the server without the
23
+ // deck; the dev server passes it in as __MDECK_SERVER__ and it wins.
24
+ const givenServer = typeof __MDECK_SERVER__ === 'string' ? __MDECK_SERVER__ : null
25
+
26
+ export function configureLive(deckConfig = {}) {
27
+ const server = givenServer ?? deckConfig.server
28
+ settings = { server: typeof server === 'string' ? server.replace(/\/+$/, '') : null, code: sessionCode(deckConfig) }
29
+ infoRequest = null
30
+ takeKeyFromAddress()
31
+ }
32
+
33
+ /** The deck's session code, the last part of its join link. */
34
+ export const liveCode = () => settings.code
35
+
36
+ const proxyControls = typeof __MDECK_PROXY_CONTROLS__ !== 'undefined' && __MDECK_PROXY_CONTROLS__
37
+ const controlBase = () => proxyControls ? new URL(devPath('__mdeck/live'), window.location.origin).href : serverBase()
38
+ const controlRoomPath = room => `${controlBase()}/rooms/${encodeURIComponent(`${settings.code}.${room}`)}`
39
+ const controlSessionPath = () => `${controlBase()}/rooms/${encodeURIComponent(settings.code)}`
40
+
41
+ const page = () => new URL(window.location.href)
42
+ // Without a `server` setting, the server built into `mdeck run`.
43
+ const serverBase = () => settings.server ?? new URL(devPath('__mdeck/live'), window.location.origin).href
44
+ const roomPath = room => `${serverBase()}/rooms/${encodeURIComponent(`${settings.code}.${room}`)}`
45
+ const sessionPath = () => `${serverBase()}/rooms/${encodeURIComponent(settings.code)}`
46
+
47
+ // Which rooms keep a live connection: those on the current slide, set by the
48
+ // runtime for each deck it shows. Browsers allow only six connections per
49
+ // server, shared by all tabs, so a deck must not hold one per activity.
50
+ // null means every room is live (Read mode, print, pages without slides).
51
+ let activeRooms = null
52
+ const activeListeners = new Set()
53
+ export function setActiveRooms(rooms) {
54
+ activeRooms = rooms == null ? null : new Set(rooms.map(String))
55
+ for (const listener of activeListeners) listener()
56
+ }
57
+ function useActive(room) {
58
+ const [, redraw] = useState(0)
59
+ useEffect(() => {
60
+ const listener = () => redraw(n => n + 1)
61
+ activeListeners.add(listener)
62
+ return () => activeListeners.delete(listener)
63
+ }, [])
64
+ return activeRooms == null || activeRooms.has(String(room))
65
+ }
66
+
67
+ // The presenter unlocks a standalone server once with ?serverkey=… in the
68
+ // address. The code is kept in this browser, where the presenter view's other
69
+ // windows find it, and taken out of the address bar at once, so it never shows
70
+ // on a projector.
71
+ let givenKey = null
72
+ function takeKeyFromAddress() {
73
+ const url = page()
74
+ const given = url.searchParams.get('serverkey')
75
+ if (!given) return
76
+ givenKey = given
77
+ try { localStorage.setItem(`mdeck-server-key:${serverBase()}`, given) } catch {}
78
+ url.searchParams.delete('serverkey')
79
+ try { history.replaceState(history.state, '', url) } catch {}
80
+ }
81
+ // The server built into `mdeck run` also lets a paired iPad in, and so
82
+ // does the server that relays `mdeck run --server`: this page then comes
83
+ // from it, so it is the same origin. The token is never sent to another server.
84
+ function ownServer() {
85
+ if (!settings.server) return true
86
+ try { return new URL(settings.server).origin === window.location.origin } catch { return false }
87
+ }
88
+ function presenterKey() {
89
+ // A relayed page always uses its own pairing, even if this origin once
90
+ // stored a master key for a different presentation.
91
+ if (/^\/t\/[^/]+\/$/.test(devPath('')) || proxyControls || (import.meta.env?.DEV && ownServer())) return pairToken()
92
+ let key = givenKey
93
+ try { key = localStorage.getItem(`mdeck-server-key:${serverBase()}`) ?? givenKey } catch {}
94
+ return key ?? (ownServer() ? pairToken() : null)
95
+ }
96
+ const withKey = (headers = {}) => { const key = presenterKey(); return key ? { ...headers, Authorization: `Bearer ${key}` } : headers }
97
+
98
+ // Only an answer is kept; after a failure the next call asks again, so a room
99
+ // server that starts later is found. `reachable` is false when it did not answer.
100
+ let infoRequest = null
101
+ function serverInfo() {
102
+ infoRequest ??= fetch(`${controlBase()}/info?session=${encodeURIComponent(settings.code)}`, { headers: withKey() })
103
+ .then(response => { if (!response.ok) throw new Error(String(response.status)); return response.json() })
104
+ .then(info => ({ ...info, reachable: true }))
105
+ .catch(() => { infoRequest = null; return { reachable: false } })
106
+ return infoRequest
107
+ }
108
+
109
+ // The join link phones can open: the `server` setting, or `mdeck run --network`'s
110
+ // network address. null when only this computer can reach the server;
111
+ // `localJoinUrl` then still opens the answer page here, for trying it out.
112
+ function joinUrl(info) {
113
+ if (settings.server) return `${settings.server}/${settings.code}`
114
+ return info.network ? new URL(`__mdeck/live/${settings.code}`, info.network).href : null
115
+ }
116
+ const localJoinUrl = () => `${serverBase()}/${settings.code}`
117
+ // Until the server has answered or failed, `known` is false and activities
118
+ // show no join code, so a quick capture never shows a passing warning.
119
+ const reach = info => ({ known: info.reachable != null, offline: info.reachable === false })
120
+
121
+ /**
122
+ * Follow a room's answers on a slide.
123
+ * messages every answer so far: { n, at, from, data: { value } }
124
+ * joinUrl the deck's join link for the QR code, or null if phones cannot
125
+ * reach the server; localJoinUrl works on this computer
126
+ * canReset / reset() presenter only
127
+ * connected false until the live stream is open
128
+ * offline no room server answered: a PDF, a file sent to readers or a
129
+ * build hosted without one. Activities then show their question
130
+ * as a record of the talk, without a join code.
131
+ * Off the current slide the room keeps its last messages but disconnects.
132
+ */
133
+ export function useRoom(room) {
134
+ const live = useActive(room)
135
+ const [messages, setMessages] = useState([])
136
+ const [connected, setConnected] = useState(false)
137
+ const [info, setInfo] = useState({})
138
+
139
+ useEffect(() => { serverInfo().then(setInfo) }, [])
140
+
141
+ useEffect(() => {
142
+ if (!live || !room || typeof EventSource === 'undefined') return
143
+ const source = new EventSource(`${roomPath(room)}/events`)
144
+ source.addEventListener('open', () => setConnected(true))
145
+ source.addEventListener('error', () => setConnected(false))
146
+ source.addEventListener('snapshot', event => setMessages(JSON.parse(event.data).messages))
147
+ source.addEventListener('message', event => { const message = JSON.parse(event.data); setMessages(list => list.some(m => m.n === message.n) ? list : [...list, message]) })
148
+ source.addEventListener('reset', () => setMessages([]))
149
+ return () => { source.close(); setConnected(false) }
150
+ }, [room, live])
151
+
152
+ const reset = useCallback(async () => {
153
+ const response = await fetch(`${controlRoomPath(room)}/reset`, { method: 'POST', headers: withKey({ 'Content-Type': 'application/json' }), body: '{}' })
154
+ if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? 'Could not reset')
155
+ }, [room])
156
+
157
+ return { messages, reset, connected, canReset: !!info.canReset, joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
158
+ }
159
+
160
+ /** The deck's join link alone, for a slide that only invites people in (<qrcode join />). */
161
+ export function useJoinLink() {
162
+ const [info, setInfo] = useState({})
163
+ useEffect(() => { serverInfo().then(setInfo) }, [])
164
+ return { joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
165
+ }
166
+
167
+ /** The latest answer of each device, e.g. to count votes that can be changed. */
168
+ export function latestByDevice(messages) {
169
+ const latest = new Map()
170
+ for (const message of messages) latest.set(message.from ?? `#${message.n}`, message)
171
+ return [...latest.values()]
172
+ }
173
+
174
+ // ── The presenter's screen ──────────────────────────────────────────────────
175
+
176
+ // The deck's look as the answer page needs it: theme variables, font
177
+ // stylesheets and the style of a slide heading, read from the page that shows
178
+ // the slides (the presenter's current-slide frame, or the deck itself).
179
+ const LOOK_TOKENS = ['--bg', '--ink', '--ink-soft', '--muted', '--rule', '--surface', '--accent', '--accent-2', '--on-accent', '--font-body', '--font-display']
180
+ let lookSource = () => document
181
+ export function setLookSource(source) { lookSource = source }
182
+ export function captureLook(doc = lookSource()) {
183
+ if (!doc?.documentElement) return null
184
+ const style = getComputedStyle(doc.documentElement)
185
+ const tokens = Object.fromEntries(LOOK_TOKENS.map(name => [name, style.getPropertyValue(name).trim()]).filter(([, value]) => value))
186
+ const fonts = [...doc.querySelectorAll('link[data-deck-theme-font]')].map(link => link.href)
187
+ // A frame that has not loaded its slides yet has no theme: send nothing, so
188
+ // phones keep the look they have.
189
+ if (!tokens['--bg'] && !tokens['--font-body']) return null
190
+ const h1 = doc.querySelector('.slide-body h1')
191
+ const hs = h1 && doc.defaultView.getComputedStyle(h1)
192
+ // Letter spacing relative to the font size, so a slide heading's spacing
193
+ // fits the much smaller heading on a phone.
194
+ const spacing = hs && (hs.letterSpacing === 'normal' ? 'normal' : `${(parseFloat(hs.letterSpacing) / parseFloat(hs.fontSize)).toFixed(3)}em`)
195
+ const heading = hs ? { family: hs.fontFamily, weight: hs.fontWeight, spacing, transform: hs.textTransform } : null
196
+ return { tokens, fonts, heading }
197
+ }
198
+
199
+ // The words the answer page shows, in the deck's language.
200
+ const phoneWords = () => ({ waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent') })
201
+
202
+ // The presenter's screen announces the activity on the current slide and
203
+ // repeats it every few seconds, so phones that join late and a server
204
+ // that restarted catch up; the server passes on only changes. Only the
205
+ // presenter may: `steering` says whether this screen reaches the phones.
206
+ const REPEAT_MS = 4000
207
+ // This window, so the server can tell its repeats from new changes.
208
+ const SCREEN = Math.random().toString(36).slice(2, 10)
209
+ let current = { room: null, activity: null, title: '', at: 0, screen: SCREEN }, repeat = null
210
+ let steering = null
211
+ let lastLook = null
212
+ const steeringListeners = new Set()
213
+ function report(next) {
214
+ if (steering && steering.ok === next.ok && steering.reason === next.reason) return
215
+ steering = next
216
+ for (const listener of steeringListeners) listener(next)
217
+ }
218
+
219
+ // A presenter view outranks other deck windows in the same browser: while it
220
+ // runs, they stay quiet, so two screens on different slides or looks do not
221
+ // make the phones flip between them.
222
+ let role = 'screen'
223
+ export function actAsPresenter() { role = 'presenter' }
224
+ const presenterMark = () => `mdeck-live-presenter:${settings.code}`
225
+ function outranked() {
226
+ try {
227
+ if (role === 'presenter') { localStorage.setItem(presenterMark(), String(Date.now())); return false }
228
+ return Date.now() - Number(localStorage.getItem(presenterMark()) ?? 0) < REPEAT_MS * 2.5
229
+ } catch { return false }
230
+ }
231
+
232
+ async function sendCurrent() {
233
+ if (outranked()) return
234
+ const info = await serverInfo()
235
+ if (!info.reachable) return report({ ok: false, reason: 'unreachable' })
236
+ if (!info.canReset) return report({ ok: false, reason: presenterKey() ? 'wrong-code' : 'no-code' })
237
+ const look = captureLook()
238
+ if (look) lastLook = look
239
+ const state = { ...current, look: lastLook, lang: deckLanguage(), labels: phoneWords() }
240
+ try {
241
+ const response = await fetch(`${controlSessionPath()}/state`, { method: 'POST', headers: withKey({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state }) })
242
+ const body = response.ok ? await response.json().catch(() => ({})) : {}
243
+ report(!response.ok ? { ok: false, reason: response.status === 403 ? 'wrong-code' : 'unreachable' } : body.ignored ? { ok: false, reason: 'other-screen' } : { ok: true })
244
+ } catch { report({ ok: false, reason: 'unreachable' }) }
245
+ }
246
+
247
+ /**
248
+ * The activity on the current slide: { room, activity, title }, where
249
+ * `activity` is what the phones show ({ type: 'choice' | 'text' | 'scale', … }),
250
+ * or nothing between activities.
251
+ */
252
+ export function announce({ room = null, activity = null, title = '', initial = false } = {}) {
253
+ // `at` marks this change; the server keeps the latest change of all
254
+ // screens. A screen that just opened says 0, so opening a tab to look at
255
+ // the deck does not take the phones away from the presenter.
256
+ current = { room, activity, title, at: initial ? 0 : Date.now(), screen: SCREEN }
257
+ sendCurrent()
258
+ repeat ??= setInterval(sendCurrent, REPEAT_MS)
259
+ }
260
+
261
+ /**
262
+ * The deck's stage room, which carries the presenter's position and live ink
263
+ * to screens on other devices (src/runtime/ink/room.js).
264
+ */
265
+ // The stage room's own `info`: with a standalone server behind `mdeck run`,
266
+ // the stage room stays on `mdeck run` (src/live/server.js), so it asks there.
267
+ let stageRequest = null
268
+ function stageInfo() {
269
+ if (!proxyControls) return serverInfo()
270
+ stageRequest ??= fetch(`${controlBase()}/info?session=${encodeURIComponent(settings.code)}&stage=1`, { headers: withKey() })
271
+ .then(response => { if (!response.ok) throw new Error(String(response.status)); return response.json() })
272
+ .then(info => ({ ...info, reachable: true }))
273
+ .catch(() => { stageRequest = null; return { reachable: false } })
274
+ return stageRequest
275
+ }
276
+
277
+ export function stageRoom() {
278
+ return { url: `${controlSessionPath()}.stage`, headers: withKey, info: stageInfo, screen: SCREEN }
279
+ }
280
+
281
+ /** For the presenter view: null before the first announcement, else { ok, reason }. */
282
+ export function useSteering() {
283
+ const [state, setState] = useState(steering)
284
+ useEffect(() => {
285
+ steeringListeners.add(setState)
286
+ setState(steering)
287
+ return () => steeringListeners.delete(setState)
288
+ }, [])
289
+ return state
290
+ }
@@ -0,0 +1,19 @@
1
+ // A deck's session code: six digits that name its rooms on the server
2
+ // and end its join link (`<server>/482113`). It is derived from the deck, so
3
+ // the presenter's screen, the QR code on the slides and the launch page agree
4
+ // without asking the server. `session.code` sets it; otherwise `session.id` or the
5
+ // title picks it.
6
+
7
+ export const slug = text => String(text ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
8
+
9
+ export const CODE_RE = /^\d{4,8}$/
10
+
11
+ export function sessionCode(deckConfig = {}) {
12
+ const session = deckConfig.session ?? {}
13
+ if (session.code != null && CODE_RE.test(String(session.code))) return String(session.code)
14
+ const name = slug(session.id ?? deckConfig.meta?.title) || 'deck'
15
+ // FNV-1a, then six digits that never start with 0.
16
+ let hash = 0x811c9dc5
17
+ for (const char of name) hash = Math.imul(hash ^ char.codePointAt(0), 0x01000193) >>> 0
18
+ return String(100000 + (hash % 900000))
19
+ }
@@ -0,0 +1,17 @@
1
+ // Only the current slide's activities keep a live connection; while printing,
2
+ // all of them do. Returns a function that stops following.
3
+ import { setActiveRooms } from './client.js'
4
+ import { roomsOnSlide } from './roomTag.js'
5
+
6
+ export function followActiveRooms(stage, slides) {
7
+ if (!stage || !slides.some(slide => roomsOnSlide(slide).length)) return () => {}
8
+ const update = () => setActiveRooms(stage.printing ? null : roomsOnSlide(slides[stage.index]))
9
+ update()
10
+ stage.addEventListener('slidechange', update)
11
+ stage.addEventListener('printchange', update)
12
+ return () => {
13
+ stage.removeEventListener('slidechange', update)
14
+ stage.removeEventListener('printchange', update)
15
+ setActiveRooms(null)
16
+ }
17
+ }
@@ -0,0 +1,34 @@
1
+ // The browser side of pairing with `mdeck run` (src/build/pairing.js): a
2
+ // device that opened the launch page's QR code trades the one-time token in
3
+ // the address for a token of its own, kept in this browser for this server.
4
+ import { devPath } from '../core/devPath.js'
5
+
6
+ export const pairStorageKey = (origin, base) => `mdeck-pair:${origin}${base}`
7
+ const storageKey = () => pairStorageKey(location.origin, devPath(''))
8
+
9
+ export function pairToken() {
10
+ try { return localStorage.getItem(storageKey()) } catch { return null }
11
+ }
12
+
13
+ /** Authorization for a paired device, or nothing. */
14
+ export const pairHeaders = (headers = {}) => { const token = pairToken(); return token ? { ...headers, Authorization: `Bearer ${token}` } : headers }
15
+
16
+ /** Claims `?pair=…` if the address has one; resolves to whether it worked. */
17
+ export async function claimPairing() {
18
+ const url = new URL(location.href)
19
+ const offer = url.searchParams.get('pair')
20
+ if (!offer) return false
21
+ // Out of the address at once, so it is not bookmarked or shown.
22
+ url.searchParams.delete('pair')
23
+ try { history.replaceState(history.state, '', url) } catch {}
24
+ try {
25
+ const response = await fetch(devPath('__mdeck/pair/claim'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: offer }) })
26
+ const body = await response.json().catch(() => ({}))
27
+ if (!response.ok) { console.warn('mdeck: pairing failed:', body.error ?? response.status); return false }
28
+ localStorage.setItem(storageKey(), body.token)
29
+ return true
30
+ } catch (error) {
31
+ console.warn('mdeck: pairing failed:', error.message)
32
+ return false
33
+ }
34
+ }
@@ -0,0 +1,33 @@
1
+ // Who is connected to a deck: each presenter, audience and deck window says
2
+ // what it is when it connects to the stage room, and the answer page counts
3
+ // as a phone. The server tells windows that ask (?presence=1) who is there;
4
+ // see src/live/rooms.js and src/components/ConnectedViews.jsx.
5
+
6
+ /** This device, roughly: an iPad says it is a Mac, but has a touch screen. */
7
+ export function deviceKind(nav = typeof navigator === 'undefined' ? {} : navigator) {
8
+ const agent = nav.userAgent ?? ''
9
+ if (/iPad/.test(agent) || (/Macintosh/.test(agent) && nav.maxTouchPoints > 1)) return 'ipad'
10
+ if (/iPhone|iPod|Android.*Mobile/.test(agent)) return 'phone'
11
+ if (/Android|Tablet/.test(agent)) return 'tablet'
12
+ return 'computer'
13
+ }
14
+
15
+ /** The query that counts this window as `view`, and asks for presence too. */
16
+ export function presenceQuery({ view = null, listen = false } = {}) {
17
+ const query = new URLSearchParams()
18
+ if (view) { query.set('view', view); query.set('device', deviceKind()) }
19
+ if (listen) query.set('presence', '1')
20
+ return query.toString()
21
+ }
22
+
23
+ const DEVICE_NAME = { computer: 'a computer', ipad: 'an iPad', tablet: 'a tablet', phone: 'a phone' }
24
+ const list = words => words.length < 2 ? words.join('') : `${words.slice(0, -1).join(', ')} and ${words.at(-1)}`
25
+
26
+ /** The devices of some connected views, as words: "an iPad and 2 computers". */
27
+ export function devicesText(views) {
28
+ const counts = {}
29
+ for (const entry of views) counts[entry.device] = (counts[entry.device] ?? 0) + 1
30
+ return list(Object.entries(counts).map(([device, n]) => n === 1 ? DEVICE_NAME[device] : `${n} ${device === 'ipad' ? 'iPads' : `${device}s`}`))
31
+ }
32
+
33
+ export const phonesText = n => `${n} phone${n === 1 ? '' : 's'}`
@@ -0,0 +1,38 @@
1
+ // Finds the component tag a phone should see: the one whose `room` attribute
2
+ // matches, with its content if it has any.
3
+ const escape = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
4
+
5
+ // Code is not an activity: fenced blocks and `inline code` are blanked out
6
+ // (keeping every other character where it was) before searching.
7
+ const blank = text => text.replace(/[^\n]/g, ' ')
8
+ export function maskCode(source = '') {
9
+ return source.replace(/^(```+|~~~+)[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, blank).replace(/`[^`\n]+`/g, blank)
10
+ }
11
+
12
+ // Every activity in a source, in order: [{ tag, room }].
13
+ export function roomsIn(source = '') {
14
+ return [...maskCode(source).matchAll(/<([a-z][a-z0-9-]*)\b[^>]*?\broom\s*=\s*(["'])([^"']+)\2/gi)].map(match => ({ tag: match[1].toLowerCase(), room: match[3] }))
15
+ }
16
+
17
+ // The rooms on one slide, from its body and its named regions (the body is
18
+ // also a region, so each room is listed once).
19
+ export function roomsOnSlide(slide) {
20
+ if (!slide) return []
21
+ const rooms = [slide.content ?? '', ...Object.values(slide.regions ?? {}).map(region => region.content ?? '')].flatMap(roomsIn).map(found => found.room)
22
+ return [...new Set(rooms)]
23
+ }
24
+
25
+ // The heading of the slide that holds `room` (what the audience saw), else
26
+ // its `title:` setting, or ''.
27
+ export function slideTitleFor(deck, room) {
28
+ const slide = deck.slides.find(slide => findRoomTag(slide.content ?? '', room) || Object.values(slide.regions ?? {}).some(region => findRoomTag(region.content ?? '', room)))
29
+ return slide?.content?.match(/^\s*#{1,3}\s+(.+?)\s*#*\s*$/m)?.[1]?.replace(/[*_`]/g, '') ?? slide?.meta.title ?? ''
30
+ }
31
+
32
+ export function findRoomTag(source, room) {
33
+ const open = new RegExp(`<([a-z][a-z0-9-]*)\\b[^>]*?\\broom\\s*=\\s*(["'])${escape(room)}\\2[^>]*>`, 'i').exec(maskCode(source))
34
+ if (!open) return null
35
+ if (open[0].endsWith('/>')) return open[0]
36
+ const close = source.indexOf(`</${open[1]}>`, open.index + open[0].length)
37
+ return close < 0 ? open[0] : source.slice(open.index, close + open[1].length + 3)
38
+ }