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,28 @@
1
+ import { h } from 'preact'
2
+ import { useRoom } from '../live/client.js'
3
+ import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
4
+ import { t } from '../core/labels.js'
5
+ import './poll.css'
6
+
7
+ // <question room="ask" question="What should we cover next?" placeholder="Your question" />
8
+ // Open answers: phones get a text field and may send as often as they like;
9
+ // the slide shows the newest answers as cards (`limit`, 8 by default).
10
+
11
+ export default function Question({ room = 'question', question = '', limit = '8', qr }) {
12
+ const live = useRoom(room)
13
+ const answers = live.messages.map(message => ({ n: message.n, text: String(message.data?.value ?? '').trim() })).filter(answer => answer.text)
14
+ const shown = answers.slice(-Math.max(1, Number(limit) || 8)).reverse()
15
+ const code = wantsQr(qr)
16
+ return <div class="poll poll--question">
17
+ <div class="poll-main">
18
+ {question && <p class="poll-question">{question}</p>}
19
+ {shown.length
20
+ ? <ul class="question-cards">{shown.map(answer => <li key={answer.n}>{answer.text}</li>)}</ul>
21
+ : !live.offline && <p class="question-empty">{t('question.empty')}</p>}
22
+ <ActivityFooter count={answers.length} {...live} />
23
+ </div>
24
+ <JoinCode room={room} {...live} qr={code} size={240} />
25
+ </div>
26
+ }
27
+
28
+ Question.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', question: question || slideTitle, placeholder: placeholder ?? '', maxLength: 200 })
@@ -0,0 +1,47 @@
1
+ import { h } from 'preact'
2
+ import { useRoom, latestByDevice } from '../live/client.js'
3
+ import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
4
+ import { t } from '../core/labels.js'
5
+ import './poll.css'
6
+
7
+ // <scale room="pace" question="How is the pace?" min="1" max="5" low="Too slow" high="Too fast" />
8
+ // Phones pick a number; each device's latest answer counts. The slide shows
9
+ // how many chose each number, and the average.
10
+
11
+ const bounds = ({ min, max }) => {
12
+ const low = Number.isFinite(Number(min)) && min !== '' && min != null ? Math.round(Number(min)) : 1
13
+ const high = Number.isFinite(Number(max)) && max !== '' && max != null ? Math.round(Number(max)) : 5
14
+ return high > low && high - low <= 10 ? [low, high] : [1, 5]
15
+ }
16
+
17
+ export default function Scale({ room = 'scale', question = '', low = '', high = '', qr, ...rest }) {
18
+ const [min, max] = bounds(rest)
19
+ const steps = Array.from({ length: max - min + 1 }, (_, i) => min + i)
20
+ const live = useRoom(room)
21
+ const values = latestByDevice(live.messages).map(message => Number(message.data?.value)).filter(value => steps.includes(value))
22
+ const counts = steps.map(step => values.filter(value => value === step).length)
23
+ const most = Math.max(1, ...counts)
24
+ const average = values.length ? (values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(1) : null
25
+ const code = wantsQr(qr)
26
+ return <div class={`poll poll--scale${live.offline && !values.length ? ' is-static' : ''}`}>
27
+ <div class="poll-main">
28
+ {question && <p class="poll-question">{question}</p>}
29
+ <div class="scale-columns" role="list">
30
+ {steps.map((step, i) => <div class="scale-column" role="listitem" key={step}>
31
+ <span class="scale-count">{counts[i]}</span>
32
+ <span class="scale-track"><span class="scale-fill" style={{ height: `${(counts[i] / most) * 100}%` }} /></span>
33
+ <span class="scale-step">{step}</span>
34
+ </div>)}
35
+ </div>
36
+ {(low || high) && <p class="scale-labels"><span>{low}</span><span>{high}</span></p>}
37
+ <ActivityFooter count={values.length} {...live} />
38
+ {average && <p class="scale-average">{t('scale.average', { n: average })}</p>}
39
+ </div>
40
+ <JoinCode room={room} {...live} qr={code} size={240} />
41
+ </div>
42
+ }
43
+
44
+ Scale.phone = ({ question, low, high, ...rest }, { slideTitle = '' } = {}) => {
45
+ const [min, max] = bounds(rest)
46
+ return { type: 'scale', question: question || slideTitle, min, max, minLabel: low ?? '', maxLabel: high ?? '' }
47
+ }
@@ -0,0 +1,108 @@
1
+ import { h } from 'preact'
2
+ import { useEffect, useRef, useState } from 'preact/hooks'
3
+ import './video-player.css'
4
+
5
+ function getEmbedUrl(rawUrl, autoplay = false) {
6
+ const ap = autoplay
7
+ const yt = rawUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/)
8
+ if (yt) return `https://www.youtube.com/embed/${yt[1]}?rel=0${ap ? '&autoplay=1&mute=1' : ''}`
9
+ const vi = rawUrl.match(/vimeo\.com\/(\d+)/)
10
+ if (vi) return `https://player.vimeo.com/video/${vi[1]}${ap ? '?autoplay=1&muted=1' : ''}`
11
+ const sw = rawUrl.match(/tube\.switch\.ch\/(?:videos|embed)\/([\w-]+)/)
12
+ if (sw) return `https://tube.switch.ch/embed/${sw[1]}${ap ? '?autoplay=1' : ''}`
13
+ return rawUrl
14
+ }
15
+
16
+ export default function VideoPlayer({ src, url, play = 'click', aspect, muted }) {
17
+ const containerRef = useRef(null)
18
+ const videoRef = useRef(null)
19
+
20
+ // The HtmlContent hydration wrapper has no width; stretch it so percentage widths work
21
+ useEffect(() => {
22
+ if (containerRef.current?.parentElement) {
23
+ containerRef.current.parentElement.style.width = '100%'
24
+ }
25
+ }, [])
26
+
27
+ // Preact doesn't reliably apply the muted attribute via props — set it on the DOM node directly
28
+ useEffect(() => {
29
+ if (videoRef.current) videoRef.current.muted = (play === 'auto' || muted != null)
30
+ }, [play, muted])
31
+ // Web video: click mode renders immediately; auto mode starts null and is set by slidechange
32
+ const [iframeSrc, setIframeSrc] = useState(
33
+ url && play === 'click' ? getEmbedUrl(url, false) : null
34
+ )
35
+
36
+ // Local video: pause on slide deactivation for all modes; also play on activation for "auto"
37
+ useEffect(() => {
38
+ if (!src) return
39
+ const stage = document.querySelector('deck-stage')
40
+ if (!stage) return
41
+
42
+ // If already on the active slide when we mount, play immediately for auto mode
43
+ if (play === 'auto') {
44
+ const active = stage.querySelector('[data-deck-active]')
45
+ if (active?.contains(containerRef.current)) videoRef.current?.play()
46
+ }
47
+
48
+ function onSlideChange(e) {
49
+ const active = e.detail.slide?.contains(containerRef.current)
50
+ if (active && play === 'auto') {
51
+ videoRef.current?.play()
52
+ } else if (!active) {
53
+ videoRef.current?.pause()
54
+ if (play === 'auto' && videoRef.current) videoRef.current.currentTime = 0
55
+ }
56
+ }
57
+ stage.addEventListener('slidechange', onSlideChange)
58
+ return () => stage.removeEventListener('slidechange', onSlideChange)
59
+ }, [play, src])
60
+
61
+ // Web video auto mode: clear/restore src on slide change to stop/start playback
62
+ useEffect(() => {
63
+ if (play !== 'auto' || !url) return
64
+ const stage = document.querySelector('deck-stage')
65
+ if (!stage) return
66
+ const embedUrl = getEmbedUrl(url, true)
67
+
68
+ // Check immediately in case we mounted after the init slidechange fired
69
+ const active = stage.querySelector('[data-deck-active]')
70
+ if (active?.contains(containerRef.current)) setIframeSrc(embedUrl)
71
+
72
+ function onSlideChange(e) {
73
+ const active = e.detail.slide?.contains(containerRef.current)
74
+ setIframeSrc(active ? embedUrl : null)
75
+ }
76
+ stage.addEventListener('slidechange', onSlideChange)
77
+ return () => stage.removeEventListener('slidechange', onSlideChange)
78
+ }, [play, url])
79
+
80
+ const fixedAspect = typeof aspect === 'string' && aspect.trim() !== ''
81
+
82
+ return (
83
+ <div
84
+ ref={containerRef}
85
+ class="video-player-container"
86
+ // max-height: 800px würde das aspectRatio sonst überstimmen: der Kasten
87
+ // wird flacher als das Video und dieses innen eingepasst -> schwarze
88
+ // Ränder. Also die Breite passend zur Höhenbegrenzung mitdeckeln.
89
+ style={fixedAspect
90
+ ? { aspectRatio: aspect, maxWidth: `calc(800px * (${aspect}))`, margin: '0 auto' }
91
+ : undefined}
92
+ >
93
+ {url ? (
94
+ iframeSrc
95
+ ? <iframe src={iframeSrc} allow="autoplay; fullscreen" allowFullScreen
96
+ style={{ width: '100%', height: fixedAspect ? '100%' : 'auto', aspectRatio: fixedAspect ? undefined : '16 / 9', border: '0', display: 'block' }} />
97
+ : <div class="video-player-placeholder" />
98
+ ) : (
99
+ <video
100
+ ref={videoRef}
101
+ src={src}
102
+ controls
103
+ style={{ width: '100%', height: fixedAspect ? '100%' : 'auto', display: 'block' }}
104
+ />
105
+ )}
106
+ </div>
107
+ )
108
+ }
@@ -0,0 +1,106 @@
1
+ import { h } from 'preact'
2
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
+ import cloud from 'd3-cloud'
4
+ import { useRoom } from '../live/client.js'
5
+ import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
6
+ import { t } from '../core/labels.js'
7
+ import './poll.css'
8
+
9
+ // <wordcloud room="mood" question="One word for today?" />
10
+ // Phones send a word or two, as often as they like. The slide packs every
11
+ // answer into a cloud around its middle: the more often it was sent, the
12
+ // larger; some stand upright. Answers that differ only in case or spacing
13
+ // count together. `height` is the cloud's height in slide pixels (520),
14
+ // `limit` the most words shown (60).
15
+
16
+ // The same answers always give the same cloud, and a new word moves the
17
+ // others as little as the layout allows.
18
+ function seeded(seed) {
19
+ let state = seed >>> 0 || 1
20
+ return () => { state = (state * 1664525 + 1013904223) >>> 0; return state / 2 ** 32 }
21
+ }
22
+ const hash = text => [...text].reduce((sum, char) => (sum * 31 + char.codePointAt(0)) >>> 0, 7)
23
+
24
+ function countWords(messages) {
25
+ const counts = new Map()
26
+ for (const message of messages) {
27
+ const text = String(message.data?.value ?? '').trim().replace(/\s+/g, ' ')
28
+ if (!text) continue
29
+ const key = text.toLocaleLowerCase()
30
+ const entry = counts.get(key) ?? { text, n: 0 }
31
+ entry.n++
32
+ counts.set(key, entry)
33
+ }
34
+ return [...counts.values()].sort((a, b) => b.n - a.n || a.text.localeCompare(b.text))
35
+ }
36
+
37
+ // Places the words (d3-cloud: an Archimedean spiral from the middle, tested
38
+ // against the letters' real shapes). When not every word fits, it tries again
39
+ // smaller, so no answer is left out.
40
+ function layout(words, { width, height, font }) {
41
+ if (!words.length || width < 50) return []
42
+ const most = words[0].n
43
+ const largest = Math.min(150, height * 0.3)
44
+ let placed = []
45
+ for (let scale = 1; scale > 0.3; scale *= 0.82) {
46
+ const top = largest * scale, bottom = Math.max(18, 26 * scale)
47
+ cloud()
48
+ .size([width, height])
49
+ .words(words.map((word, i) => ({
50
+ text: word.text, n: word.n,
51
+ size: bottom + (top - bottom) * Math.sqrt(word.n / most),
52
+ rotate: i > 0 && hash(word.text) % 5 === 0 ? -90 : 0,
53
+ })))
54
+ .font(font).fontWeight('700').fontSize(word => word.size).rotate(word => word.rotate)
55
+ .padding(Math.max(2, 6 * scale)).spiral('archimedean')
56
+ .random(seeded(words.length * 7919 + hash(words[0].text)))
57
+ .timeInterval(Infinity)
58
+ .on('end', output => { placed = output })
59
+ .start()
60
+ if (placed.length === words.length) break
61
+ }
62
+ return placed
63
+ }
64
+
65
+ export default function WordCloud({ room = 'words', question = '', limit = '60', height = '520', qr }) {
66
+ const live = useRoom(room)
67
+ const all = countWords(live.messages)
68
+ const words = all.slice(0, Math.max(1, Number(limit) || 60))
69
+ const total = all.reduce((sum, word) => sum + word.n, 0)
70
+ const cloudHeight = Math.max(200, Number(height) || 520)
71
+ const ref = useRef(null)
72
+ // Slide pixels: the stage scales the whole slide, offsetWidth is unscaled.
73
+ const [measure, setMeasure] = useState({ width: 0, font: 'sans-serif', fontsReady: 0 })
74
+ useEffect(() => {
75
+ const element = ref.current
76
+ if (!element) return
77
+ const read = () => setMeasure(current => ({ ...current, width: element.offsetWidth, font: getComputedStyle(element).fontFamily || 'sans-serif' }))
78
+ read()
79
+ // The theme's font must be there before letters are measured.
80
+ document.fonts?.ready.then(() => setMeasure(current => ({ ...current, fontsReady: current.fontsReady + 1 })))
81
+ const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(read) : null
82
+ observer?.observe(element)
83
+ return () => observer?.disconnect()
84
+ }, [])
85
+ const key = words.map(word => `${word.text}:${word.n}`).join('|')
86
+ const placed = useMemo(() => layout(words, { width: measure.width, height: cloudHeight, font: measure.font }), [key, measure.width, measure.font, measure.fontsReady, cloudHeight])
87
+ const code = wantsQr(qr)
88
+ return <div class="poll poll--cloud">
89
+ <div class="poll-main">
90
+ {question && <p class="poll-question">{question}</p>}
91
+ {/* Without a server and words there is nothing to place: no empty box. */}
92
+ <div class="word-cloud" ref={ref} style={{ height: live.offline && !words.length ? 0 : `${cloudHeight}px` }}>
93
+ {words.length
94
+ ? <svg viewBox={`${-measure.width / 2} ${-cloudHeight / 2} ${measure.width || 1} ${cloudHeight}`} width="100%" height="100%" role="img" aria-label={words.map(word => word.text).join(', ')}>
95
+ {placed.map(word => <text key={word.text} class={`word word--${word.text === words[0].text ? 0 : hash(word.text) % 6}`} text-anchor="middle"
96
+ style={{ fontSize: `${word.size}px`, fontFamily: measure.font, transform: `translate(${word.x}px, ${word.y}px) rotate(${word.rotate}deg)` }}>{word.text}</text>)}
97
+ </svg>
98
+ : !live.offline && <p class="question-empty">{t('question.empty')}</p>}
99
+ </div>
100
+ <ActivityFooter count={total} {...live} />
101
+ </div>
102
+ <JoinCode room={room} {...live} qr={code} size={240} />
103
+ </div>
104
+ }
105
+
106
+ WordCloud.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', question: question || slideTitle, placeholder: placeholder ?? '', maxLength: 40 })
@@ -0,0 +1,35 @@
1
+ import { h } from 'preact'
2
+ import QrCode from './QrCode.jsx'
3
+ import { t } from '../core/labels.js'
4
+
5
+ // Parts every activity on a slide shares (<poll>, <question>, <wordcloud>,
6
+ // <scale>, and <qrcode join />): the deck's join code and the line with the number of
7
+ // answers, the live dot and Reset.
8
+
9
+ export const shortLink = url => url.replace(/^https?:\/\//, '')
10
+ // `qr="false"` (or "no", "off") leaves the code out: it was on an earlier
11
+ // slide (<qrcode join />), and people's phones follow along.
12
+ export const wantsQr = qr => !/^(false|no|off|0)$/i.test(String(qr ?? 'true'))
13
+
14
+ /** The join code with the short link, or how to make phones reach the server. */
15
+ export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = true, size = 300, label = t('poll.scan') }) {
16
+ if (!qr || offline || (!known && !joinUrl)) return null
17
+ if (!joinUrl) return <p class="poll-join poll-join--local">{t('poll.unreachable', { command: <code>mdeck run --network</code>, setting: <code>server</code> })} <a href={localJoinUrl} target="_blank" rel="noopener">{t('poll.tryHere')}</a></p>
18
+ return <a class="poll-join" href={joinUrl} target="_blank" rel="noopener" data-room={room}>
19
+ <QrCode url={joinUrl} size={String(size)} />
20
+ <span>{label}</span>
21
+ <span class="poll-link">{shortLink(joinUrl)}</span>
22
+ </a>
23
+ }
24
+
25
+ /** Number of answers, whether the room is live, and Reset for the presenter; `children` go at the end. */
26
+ export function ActivityFooter({ count, connected, canReset, reset, offline, children }) {
27
+ // Without a server this is a record of the talk: no count, dot or buttons.
28
+ if (offline && !count) return <p class="poll-total poll-static">{t('poll.static')}</p>
29
+ return <p class="poll-total">
30
+ <span class={`poll-dot${connected ? ' is-live' : ''}`} title={connected ? t('poll.live') : t('poll.offline')} />
31
+ {count === 1 ? t('poll.answer') : t('poll.answers', { n: count })}
32
+ {canReset && <button class="poll-reset" disabled={count === 0} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
33
+ {children}
34
+ </p>
35
+ }
@@ -0,0 +1,78 @@
1
+ :root {
2
+ --callout-note: #0969da;
3
+ --callout-tip: #1a7f37;
4
+ --callout-important: #8250df;
5
+ --callout-warning: #eab308;
6
+ --callout-caution: #cf222e;
7
+ }
8
+
9
+ .callout {
10
+ border-left: 4px solid var(--rule);
11
+ padding: 20px 28px;
12
+ background: var(--surface, var(--bg));
13
+ margin: var(--gap-item) 0;
14
+ }
15
+ .callout-title {
16
+ font-weight: 600;
17
+ font-size: var(--fs-eyebrow);
18
+ letter-spacing: 0.06em;
19
+ text-transform: uppercase;
20
+ margin: 0 0 8px 0;
21
+ }
22
+ .callout p { margin: 0; color: var(--ink-soft); padding-left: 28px; }
23
+
24
+ .callout-note { border-left-color: var(--callout-note); }
25
+ .callout-tip { border-left-color: var(--callout-tip); }
26
+ .callout-important { border-left-color: var(--callout-important); }
27
+ .callout-warning { border-left-color: var(--callout-warning); }
28
+ .callout-caution { border-left-color: var(--callout-caution); }
29
+
30
+ /* Titles are text: the signal colour mixed with the text colour, darker on
31
+ light slides and lighter on dark ones, so they can be read on both. The
32
+ yellow of a warning needs more of the text colour. */
33
+ .callout-note .callout-title { color: color-mix(in oklab, var(--callout-note) 65%, var(--ink)); }
34
+ .callout-tip .callout-title { color: color-mix(in oklab, var(--callout-tip) 65%, var(--ink)); }
35
+ .callout-important .callout-title { color: color-mix(in oklab, var(--callout-important) 65%, var(--ink)); }
36
+ .callout-warning .callout-title { color: color-mix(in oklab, var(--callout-warning) 45%, var(--ink)); }
37
+ .callout-caution .callout-title { color: color-mix(in oklab, var(--callout-caution) 65%, var(--ink)); }
38
+
39
+ /* Blocks for lectures, in every theme. Theorems, lemmas and corollaries in
40
+ the accent with the statement in italics, definitions in the second
41
+ accent; examples and remarks stay plain. A proof is no box: "Proof." runs
42
+ into its text, and a square ends it. Themes with their own callout style
43
+ colour these blocks their way; the proof is shared, and themes that set
44
+ it differently (academic, sketch) use the same selectors. */
45
+ .callout:is(.callout-theorem, .callout-lemma, .callout-corollary) { border-left-color: var(--accent); }
46
+ .callout.callout-definition { border-left-color: var(--accent-2); }
47
+ .callout:is(.callout-theorem, .callout-lemma, .callout-corollary) .callout-title { color: color-mix(in oklab, var(--accent) 75%, var(--ink)); }
48
+ .callout.callout-definition .callout-title { color: color-mix(in oklab, var(--accent-2) 75%, var(--ink)); }
49
+ .callout:is(.callout-theorem, .callout-lemma, .callout-corollary) > :not(.callout-title) { font-style: italic; }
50
+
51
+ .slide-body .callout.callout-proof {
52
+ display: block;
53
+ background: none;
54
+ border: 0;
55
+ border-radius: 0;
56
+ box-shadow: none;
57
+ padding: 0;
58
+ max-width: none;
59
+ }
60
+ .slide-body .callout.callout-proof::before { display: none; }
61
+ .slide-body .callout.callout-proof .callout-title {
62
+ display: inline;
63
+ float: left;
64
+ margin: 0 0.4em 0 0;
65
+ padding: 0;
66
+ background: none;
67
+ font-size: inherit;
68
+ font-style: italic;
69
+ font-weight: 700;
70
+ line-height: inherit;
71
+ letter-spacing: 0;
72
+ text-transform: none;
73
+ color: var(--ink);
74
+ }
75
+ .slide-body .callout.callout-proof .callout-title::before { content: none; }
76
+ .slide-body .callout.callout-proof .callout-title::after { content: '.'; }
77
+ .slide-body .callout.callout-proof > :not(.callout-title) { padding-left: 0; font-style: normal; }
78
+ .slide-body .callout.callout-proof > :last-child::after { content: '\220E'; margin-left: 0.6em; color: var(--ink); }
@@ -0,0 +1,123 @@
1
+ :root {
2
+ --token-default: #0a0a0a;
3
+ --token-comment: #708090;
4
+ --token-string: #669900;
5
+ --token-number: #905050;
6
+ --token-keyword: #0077aa;
7
+ --token-function: #dd4a68;
8
+ --token-operator: #9a6e3a;
9
+ }
10
+
11
+ code[class*="language-"] { color: var(--token-default); }
12
+ .token.comment, .token.prolog, .token.doctype, .token.cdata { color: var(--token-comment); font-style: italic; }
13
+ .token.punctuation { color: var(--token-default); opacity: 0.7; }
14
+ .token.property, .token.tag, .token.boolean, .token.number, .token.constant, .token.symbol, .token.deleted { color: var(--token-number); }
15
+ .token.selector, .token.attr-name, .token.string, .token.char, .token.builtin, .token.inserted { color: var(--token-string); }
16
+ .token.operator, .token.entity, .token.url, .token.regex, .token.variable { color: var(--token-operator); }
17
+ .token.atrule, .token.attr-value, .token.keyword { color: var(--token-keyword); }
18
+ .token.function, .token.class-name { color: var(--token-function); }
19
+ .token.important, .token.bold { font-weight: bold; }
20
+ .token.italic { font-style: italic; }
21
+
22
+ /* ── Wrapper ────────────────────────────────────────────────────────────── */
23
+ .code-block-wrapper {
24
+ position: relative;
25
+ margin: 40px 0;
26
+ width: 100%;
27
+ /* Centred layouts (focus, title) must not centre the lines of code. */
28
+ text-align: left;
29
+ }
30
+
31
+ /* ── Action buttons ─────────────────────────────────────────────────────── */
32
+ .code-block-actions {
33
+ display: flex;
34
+ justify-content: flex-end;
35
+ gap: 8px;
36
+ padding: 8px 12px;
37
+ }
38
+
39
+ .code-btn {
40
+ display: inline-flex;
41
+ align-items: center;
42
+ justify-content: center;
43
+ width: 36px;
44
+ height: 36px;
45
+ background: var(--bg, #fff);
46
+ color: var(--ink, #0a0a0a);
47
+ border: 1.5px solid var(--rule, #e5e5e5);
48
+ border-radius: 6px;
49
+ cursor: pointer;
50
+ transition: background 0.1s;
51
+ }
52
+ .code-btn:hover { background: var(--rule, #e5e5e5); }
53
+ .code-btn:disabled { opacity: 0.45; cursor: not-allowed; }
54
+ .code-btn-spin { animation: code-btn-spin 0.9s linear infinite; }
55
+ @keyframes code-btn-spin { to { transform: rotate(360deg); } }
56
+ @media (prefers-reduced-motion: reduce) { .code-btn-spin { animation: none; } }
57
+
58
+
59
+ /* ── Editable: highlighted pre + transparent textarea overlay ───────────── */
60
+ .code-editable-container {
61
+ position: relative;
62
+ }
63
+ .code-editable-highlight {
64
+ overflow: hidden;
65
+ pointer-events: none;
66
+ user-select: none;
67
+ margin: 0;
68
+ }
69
+ /* The textarea sits on top; .code-block class makes it inherit the theme's
70
+ padding/line-height/font so cursor positions align exactly with the highlight layer.
71
+ The double-class selector overrides any single-class .code-block theme rules. */
72
+ .code-block.code-editable-input {
73
+ position: absolute;
74
+ inset: 0;
75
+ background: transparent;
76
+ color: transparent;
77
+ caret-color: var(--ink, #0a0a0a);
78
+ border-color: transparent; /* keep border width for layout alignment, hide color */
79
+ box-shadow: none;
80
+ resize: none;
81
+ overflow: hidden;
82
+ outline: none;
83
+ tab-size: 2;
84
+ }
85
+ .code-block.code-editable-input::before { display: none; }
86
+ .code-block.code-editable-input::selection {
87
+ color: transparent;
88
+ background-color: rgba(100, 150, 255, 0.25);
89
+ }
90
+
91
+ /* ── Output area ────────────────────────────────────────────────────────── */
92
+ .code-output {
93
+ margin: 0;
94
+ padding: 28px 48px;
95
+ background: var(--surface, var(--rule));
96
+ color: var(--ink);
97
+ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
98
+ font-size: var(--code-size, 30px);
99
+ line-height: 1.55;
100
+ white-space: pre;
101
+ /* The height includes the padding, as the fitted height does (fit.js). */
102
+ box-sizing: border-box;
103
+ max-height: 10em;
104
+ overflow-y: auto;
105
+ border-radius: 0 8px 8px 8px;
106
+ border-left: 4px solid var(--rule);
107
+ }
108
+ .code-output--error {
109
+ color: #f87171;
110
+ border-left-color: #ef4444;
111
+ }
112
+
113
+ /* ── Code taller than its place on the slide (src/runtime/fit.js) ───────── */
114
+ /* The runtime caps the height; the code scrolls inside. For editable code
115
+ the text field scrolls, and the highlighted layer follows it. */
116
+ /* The capped height is the whole box, padding and rules included. */
117
+ .code-block.is-capped { box-sizing: border-box; }
118
+ pre.code-block.is-capped { overflow-y: auto; }
119
+ .code-block.code-editable-highlight.is-capped { overflow: hidden; }
120
+ .code-editable-container.is-capped .code-block.code-editable-input { overflow-y: auto; }
121
+ /* While there is more below, the lower edge fades: the audience sees that
122
+ the code goes on. */
123
+ :is(.code-block, .code-output).is-capped:not(.is-at-end) { -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 72px), transparent); mask-image: linear-gradient(to bottom, #000 calc(100% - 72px), transparent); }
@@ -0,0 +1,11 @@
1
+ .columns {
2
+ display: flex;
3
+ gap: var(--gap-col, 4rem);
4
+ align-items: start;
5
+ width: 100%;
6
+ }
7
+
8
+ .column {
9
+ flex: 1;
10
+ min-width: 0;
11
+ }
@@ -0,0 +1,81 @@
1
+ /* ── Poll on the slide ─────────────────────────────────────────────────── */
2
+ .poll { display: flex; gap: 72px; align-items: center; width: 100%; font-size: var(--fs-body, 36px); color: var(--ink, #111); }
3
+ .poll-main { flex: 1; min-width: 0; }
4
+ .poll .poll-question { margin: 0 0 32px; font-weight: 600; }
5
+ .poll-bars { display: grid; gap: 20px; }
6
+ .poll-row { display: grid; grid-template-columns: minmax(120px, 28%) 1fr 3ch; gap: 24px; align-items: center; }
7
+ .poll-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
8
+ .poll-track { height: 1.1em; background: color-mix(in srgb, var(--ink, #111) 8%, transparent); border-radius: 6px; overflow: hidden; }
9
+ .poll-fill { display: block; height: 100%; background: var(--accent, #0d9488); border-radius: 6px; transition: width .6s cubic-bezier(.2, .8, .2, 1); }
10
+ .poll-count { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
11
+ .poll .poll-total { display: flex; align-items: center; gap: 14px; margin: 36px 0 0; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
12
+ .poll-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted, #999); opacity: .5; }
13
+ .poll-dot.is-live { background: #16a34a; opacity: 1; }
14
+ .poll-reset { margin-left: auto; padding: 4px 14px; border: 1px solid var(--rule, #ccc); border-radius: 6px; background: none; color: inherit; font: inherit; cursor: pointer; }
15
+ .poll-reset:disabled { opacity: .4; cursor: default; }
16
+ /* The audience sees the results, not the presenter's controls. */
17
+ deck-stage[data-follower] .poll-reset { display: none; }
18
+ /* answer="…": the tick outlines the right answer's row (label, bar, count) in green. */
19
+ .poll-solve { display: grid; place-items: center; width: 44px; height: 44px; margin-left: auto; padding: 0; border: 1px solid var(--rule, #ccc); border-radius: 50%; background: none; color: var(--muted, #666); cursor: pointer; opacity: .55; transition: opacity .2s, background .2s, color .2s; }
20
+ .poll-reset + .poll-solve { margin-left: 0; }
21
+ .poll-solve svg { width: 24px; height: 24px; }
22
+ .poll-solve:hover, .poll-solve:focus-visible { opacity: 1; }
23
+ .poll-solve.is-on { opacity: 1; background: var(--poll-correct, #16a34a); border-color: var(--poll-correct, #16a34a); color: #fff; }
24
+ deck-stage[data-follower] .poll-solve { display: none; }
25
+ .poll.is-solved .poll-row.is-correct { outline: 4px solid var(--poll-correct, #16a34a); outline-offset: 10px; border-radius: 10px; }
26
+ .poll.is-solved .poll-row.is-correct .poll-label { font-weight: 700; color: var(--poll-correct, #16a34a); }
27
+ .poll-link { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; color: var(--muted, #555); }
28
+ /* The join code in the slide's own colours (see QrCode.jsx). */
29
+ .poll-join { flex: none; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 18px; border: 1px solid var(--rule, rgba(0,0,0,.12)); border-radius: 12px; color: var(--ink, #111); text-decoration: none; font-size: var(--fs-small, 24px); }
30
+ .poll-join--local { display: block; max-width: 460px; text-align: left; line-height: 1.4; color: var(--ink-soft, var(--ink, #333)); }
31
+ .poll-join--local code { display: inline; font-size: .9em; }
32
+ .poll-join--local a { color: var(--accent, #0d9488); }
33
+
34
+ /* <question>: the newest answers as cards */
35
+ .question-cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 18px; }
36
+ .slide-body .question-cards li::before, .question-cards li::before, .question-cards li::marker { content: none; display: none; }
37
+ .slide-body .question-cards li { padding-left: 22px; }
38
+ .question-cards li { margin: 0; padding: 18px 22px; border-radius: 10px; background: var(--surface, color-mix(in srgb, var(--ink, #111) 5%, transparent)); border-left: 5px solid var(--accent, #0d9488); font-size: .8em; line-height: 1.35; overflow-wrap: anywhere; animation: poll-in .4s ease both; }
39
+ .question-cards li:first-child { border-left-color: var(--accent-2, var(--accent, #0d9488)); }
40
+ .question-empty { margin: 0; color: var(--muted, #666); font-style: italic; }
41
+ @keyframes poll-in { from { opacity: 0; transform: translateY(8px); } }
42
+
43
+ /* <wordcloud>: SVG text placed by d3-cloud */
44
+ .word-cloud { position: relative; width: 100%; }
45
+ .word-cloud svg { display: block; overflow: visible; }
46
+ .word { font-weight: 700; transition: transform .7s cubic-bezier(.2, .8, .2, 1), font-size .7s cubic-bezier(.2, .8, .2, 1); }
47
+ /* Six colours from the palette: its accents and inks, and two blends of
48
+ them, all readable on the slide. A theme or deck may set --cloud-1…6. */
49
+ .word--0 { fill: var(--cloud-1, var(--accent, #0d9488)); }
50
+ .word--1 { fill: var(--cloud-2, var(--accent-2, #7c3aed)); }
51
+ .word--2 { fill: var(--cloud-3, var(--ink, #111)); }
52
+ .word--3 { fill: var(--cloud-4, color-mix(in oklab, var(--accent, #0d9488) 50%, var(--accent-2, #7c3aed))); }
53
+ .word--4 { fill: var(--cloud-5, var(--ink-soft, #444)); }
54
+ .word--5 { fill: var(--cloud-6, color-mix(in oklab, var(--accent, #0d9488) 55%, var(--ink, #111))); }
55
+
56
+ /* <scale> */
57
+ .scale-columns { display: flex; gap: 20px; align-items: stretch; height: 320px; }
58
+ .scale-column { flex: 1; display: grid; grid-template-rows: auto 1fr auto; gap: 10px; justify-items: center; }
59
+ .scale-track { width: 100%; display: flex; align-items: flex-end; background: color-mix(in srgb, var(--ink, #111) 6%, transparent); border-radius: 6px; overflow: hidden; }
60
+ .scale-fill { display: block; width: 100%; background: var(--accent, #0d9488); border-radius: 6px 6px 0 0; transition: height .6s cubic-bezier(.2, .8, .2, 1); }
61
+ .scale-count { font-variant-numeric: tabular-nums; font-weight: 600; font-size: var(--fs-small, 24px); }
62
+ .scale-step { font-variant-numeric: tabular-nums; color: var(--muted, #666); }
63
+ .poll .scale-labels { display: flex; justify-content: space-between; margin: 12px 0 0; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
64
+ .poll .scale-average { margin: 10px 0 0; font-size: var(--fs-small, 24px); font-weight: 600; }
65
+
66
+ /* <qrcode join />: the join code, large, on a slide of its own */
67
+ .poll-join-slide { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; }
68
+ .poll-join-slide .poll-join { font-size: var(--fs-body, 32px); gap: 16px; padding: 24px 32px; }
69
+ .poll-join-slide .poll-link { font-size: 28px; }
70
+ @media print { .poll-reset, .poll-dot, .poll-solve { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
71
+
72
+ /* A record of the talk (no room server: a PDF, a file sent to readers): the
73
+ question and its options, without counts, the live dot or a join code. */
74
+ .poll.is-static .poll-count, .poll.is-static .scale-count { visibility: hidden; }
75
+ .poll .poll-static, .poll-join-slide .poll-static { margin: 36px 0 0; color: var(--muted, #666); font-style: italic; font-size: var(--fs-small, 24px); }
76
+ .poll-join-slide .poll-static { margin: 0; font-size: var(--fs-body, 32px); }
77
+
78
+ /* On paper the join code and the presenter's controls have no use. */
79
+ @media print {
80
+ .poll-join, .poll-reset, .poll-solve, .poll-dot { display: none !important; }
81
+ }
@@ -0,0 +1,14 @@
1
+ .video-player-container {
2
+ position: relative;
3
+ width: 100%;
4
+ max-height: 800px;
5
+ background: #000;
6
+ border-radius: var(--radius, 4px);
7
+ overflow: hidden;
8
+ }
9
+
10
+ .video-player-placeholder {
11
+ width: 100%;
12
+ height: 100%;
13
+ background: #111;
14
+ }
@@ -0,0 +1,4 @@
1
+ // The dev server's own addresses (/__mdeck/…) for pages it serves. With
2
+ // `mdeck run --server` it serves at /t/<id>/ instead of /, so they follow
3
+ // Vite's base path; built pages and Node tests have no dev server and use /.
4
+ export const devPath = path => `${import.meta.env?.DEV ? (import.meta.env.BASE_URL ?? '/') : '/'}${path.replace(/^\//, '')}`