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,148 @@
1
+ /* Launch page for `mdeck run`. It is a tool page like the presenter view, the
2
+ editor and the reader's bar, and shares their dark chrome: neutral greys,
3
+ small uppercase labels, grey buttons and the editor's blue for the main
4
+ action. The teal of the mdeck mark stays in the mark. */
5
+ :root {
6
+ --home-bg: #111;
7
+ --home-bar: #161616;
8
+ --home-panel: #171717;
9
+ --home-raised: #1e1e1e;
10
+ --home-line: #262626;
11
+ --home-control: #3a3a3a;
12
+ --home-ink: #e6e6e6;
13
+ --home-strong: #fff;
14
+ --home-muted: #a0a0a0;
15
+ --home-faint: #6b6b6b;
16
+ --home-button: #222;
17
+ --home-primary: #27436a;
18
+ --home-primary-line: #3d6094;
19
+ --home-primary-ink: #e8f0ff;
20
+ --home-selected: #23384f;
21
+ --home-focus: rgba(138, 180, 248, 0.5);
22
+ --home-ok: #81c995;
23
+ --home-warn: #e3c36d;
24
+ --home-warn-soft: #2e2715;
25
+ --home-bad: #f28b82;
26
+ --home-bad-soft: #3a1d1b;
27
+ --home-radius: 6px;
28
+ color-scheme: dark;
29
+ }
30
+
31
+ * { box-sizing: border-box; }
32
+ html, body { margin: 0; background: var(--home-bg); color: var(--home-ink); }
33
+ body { font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; }
34
+ code, pre, kbd { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.88em; }
35
+ a { color: inherit; }
36
+ h1, h2, h3 { margin: 0; line-height: 1.25; }
37
+ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); outline-offset: 1px; }
38
+
39
+ .home { padding: 0 0 28px; }
40
+ .home > :not(.home-header) { max-width: 1180px; margin-left: auto; margin-right: auto; padding-left: 20px; padding-right: 20px; }
41
+ .home--empty { min-height: 100vh; display: grid; place-content: center; justify-items: center; text-align: center; color: var(--home-muted); }
42
+
43
+ /* ── Top bar, like the editor's and the reader's ─────────────────────── */
44
+ .home-header { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; gap: 12px; margin-bottom: 20px; padding: 10px 20px; background: var(--home-bar); border-bottom: 1px solid var(--home-line); }
45
+ .home-mark { flex: none; border-radius: 6px; }
46
+ .home-heading { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 4px 12px; flex-wrap: wrap; }
47
+ .home-heading h1 { font-size: 15px; font-weight: 600; color: var(--home-strong); }
48
+ .home-meta { margin: 0; color: var(--home-muted); font-size: 13px; }
49
+ .home-file { margin: 0; color: var(--home-faint); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex-basis: 100%; }
50
+ .home-health { flex: none; display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border: 1px solid #2c4a35; border-radius: 999px; color: var(--home-ok); text-decoration: none; font-size: 12px; }
51
+ .home-health.is-warn { border-color: #5a4a1a; color: var(--home-warn); }
52
+ .home-health.is-bad { border-color: #5c2b28; color: var(--home-bad); }
53
+
54
+ .home > .home-error { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 16px; padding: 10px 14px; border: 1px solid #5c2b28; border-radius: var(--home-radius); background: var(--home-bad-soft); color: var(--home-bad); }
55
+
56
+ /* ── Sections ─────────────────────────────────────────────────────────── */
57
+ .home-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
58
+ .home-section { background: var(--home-panel); border: 1px solid var(--home-line); border-radius: 8px; padding: 14px; min-width: 0; }
59
+ .home-section--wide { grid-column: 1 / -1; }
60
+ .home-section h2 { font-size: 11px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--home-muted); margin-bottom: 10px; }
61
+ .home-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
62
+ .home-section-head h2 { margin: 0; }
63
+
64
+ /* ── Deck preview ─────────────────────────────────────────────────────── */
65
+ .home-preview { display: block; position: relative; overflow: hidden; border: 1px solid var(--home-line); border-radius: var(--home-radius); background: #000; }
66
+ .home-preview iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; pointer-events: none; }
67
+ .home-preview-open { position: absolute; inset: 0; }
68
+ .home-preview-bar { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
69
+ .home-preview-count { color: var(--home-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
70
+ .home-spacer { flex: 1; }
71
+
72
+ /* ── Tiles ────────────────────────────────────────────────────────────── */
73
+ .home-tiles { display: grid; gap: 6px; }
74
+ .home-tile { display: flex; align-items: center; gap: 12px; width: 100%; padding: 8px 10px; border: 1px solid var(--home-line); border-radius: var(--home-radius); background: var(--home-raised); color: inherit; font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
75
+ .home-tile:hover { border-color: var(--home-control); background: #232323; }
76
+ .home-tile:disabled { cursor: progress; }
77
+ .home-tile > .home-icon { flex: none; color: var(--home-faint); }
78
+ .home-tile-icon { flex: none; display: grid; place-items: center; width: 32px; height: 32px; border-radius: var(--home-radius); background: var(--home-button); color: var(--home-muted); }
79
+ .home-tile-body { flex: 1; min-width: 0; display: grid; }
80
+ .home-tile-body strong { font-weight: 600; color: var(--home-strong); font-size: 13px; }
81
+ .home-tile-body span { color: var(--home-muted); font-size: 12px; overflow-wrap: anywhere; }
82
+
83
+ /* ── Share ────────────────────────────────────────────────────────────── */
84
+ .home-outputs { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
85
+ .home-output { display: flex; gap: 12px; align-items: center; padding: 8px 10px; border: 1px solid var(--home-line); border-radius: var(--home-radius); background: var(--home-raised); }
86
+ .home-output-body { flex: 1; min-width: 0; display: grid; }
87
+ .home-output-body strong { font-size: 13px; color: var(--home-strong); }
88
+ .home-output-body > span { color: var(--home-muted); font-size: 12px; }
89
+ .home-output-body code { color: var(--home-faint); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
90
+ .home-output-body details { margin-top: 6px; font-size: 12px; }
91
+ .home-output-body pre { max-height: 220px; overflow: auto; padding: 8px; border-radius: 4px; background: var(--home-bg); white-space: pre-wrap; }
92
+ .home-note { color: var(--home-warn) !important; }
93
+ .home-relay--up { color: var(--home-ok) !important; }
94
+ .home-relay--refused { color: var(--home-bad) !important; }
95
+ .home-output-actions { flex: none; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
96
+ .home-status { font-size: 12px; color: var(--home-faint); min-width: 90px; text-align: right; }
97
+ .home-status--done { color: var(--home-ok); }
98
+ .home-status--failed { color: var(--home-bad); }
99
+ .home-status--running { color: var(--home-warn); }
100
+
101
+ /* ── Buttons, as in the editor ────────────────────────────────────────── */
102
+ .home-btn { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border: 1px solid var(--home-control); border-radius: 5px; background: var(--home-button); color: var(--home-ink); font: inherit; font-size: 13px; line-height: 1.4; cursor: pointer; text-decoration: none; }
103
+ .home-btn:hover:not(:disabled) { border-color: #5a5a5a; color: #fff; }
104
+ .home-btn:disabled { opacity: .45; cursor: default; }
105
+ .home-btn--primary { background: var(--home-primary); border-color: var(--home-primary-line); color: var(--home-primary-ink); }
106
+ .home-btn--small { padding: 2px 8px; font-size: 12px; }
107
+ .home-link { border: 0; background: none; padding: 0; font: inherit; color: var(--home-muted); cursor: pointer; text-decoration: underline; }
108
+ .home-link:hover { color: var(--home-strong); }
109
+
110
+ /* ── Check ────────────────────────────────────────────────────────────── */
111
+ .home-ok { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--home-ok); font-size: 13px; }
112
+ .home-diagnostics { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
113
+ .home-diagnostic { display: flex; gap: 10px; align-items: baseline; padding: 6px 10px; border-radius: 4px; background: var(--home-warn-soft); font-size: 13px; }
114
+ .home-diagnostic--error { background: var(--home-bad-soft); }
115
+ .home-badge { flex: none; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: var(--home-warn); }
116
+ .home-diagnostic--error .home-badge { color: var(--home-bad); }
117
+ .home-line { margin-left: auto; flex: none; color: var(--home-faint); font-size: 12px; }
118
+
119
+ /* ── Live polls ───────────────────────────────────────────────────────── */
120
+ .home-live { display: grid; gap: 10px; margin: 0; }
121
+ .home-live > div { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 12px; align-items: baseline; }
122
+ .home-live dt { color: var(--home-muted); font-size: 12px; }
123
+ .home-live dd { margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; min-width: 0; font-size: 13px; }
124
+ .home-live dd > p { flex-basis: 100%; margin: 0; color: var(--home-muted); font-size: 12px; }
125
+ .home-live code { overflow-wrap: anywhere; }
126
+ .home-live a { color: var(--home-ink); }
127
+ .home-code { padding: 1px 6px; border: 1px solid var(--home-control); border-radius: 4px; background: var(--home-button); color: var(--home-strong); }
128
+ .home-pill { display: inline-block; padding: 0 8px; border: 1px solid var(--home-control); border-radius: 999px; font-size: 11px; color: var(--home-muted); }
129
+ .home-pill.is-ok { border-color: #2c4a35; color: var(--home-ok); }
130
+ .home-pill.is-bad { border-color: #5c2b28; color: var(--home-bad); }
131
+
132
+ @media (max-width: 760px) {
133
+ .home > :not(.home-header) { padding-left: 12px; padding-right: 12px; }
134
+ .home-header { margin-bottom: 14px; padding: 10px 12px; }
135
+ .home-grid { grid-template-columns: minmax(0, 1fr); }
136
+ .home-output { flex-wrap: wrap; }
137
+ .home-output-actions { width: 100%; justify-content: flex-start; }
138
+ .home-status { text-align: left; min-width: 0; }
139
+ .home-live > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
140
+ }
141
+
142
+ .home-pair { --qr-ink: #000; --qr-bg: #fff; display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }
143
+ .home-pair canvas, .home-pair img, .home-pair svg { background: #fff; padding: 8px; border-radius: 6px; flex-shrink: 0; }
144
+ .home-pair p, .home-tablet > p { font-size: 13px; margin: 0 0 8px; }
145
+
146
+ /* A view that is open now, next to its title; the section head lists who is connected. */
147
+ .home-open { margin-left: 8px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 500; color: var(--home-ok); background: rgba(129, 201, 149, 0.12); white-space: nowrap; }
148
+ .home-section-head .connected-views { color: var(--home-muted); }
@@ -0,0 +1,205 @@
1
+ import { h } from 'preact'
2
+ import { useEffect, useRef } from 'preact/hooks'
3
+ import { render } from 'preact'
4
+ import { marked, Parser } from 'marked'
5
+ import { registry } from '../runtime/registry'
6
+ import { InkLayer } from '../runtime/ink/InkLayer.jsx'
7
+ import themedSvgs from 'virtual:deck-svgs'
8
+
9
+ // ─── Content extraction ────────────────────────────────────────────────────
10
+
11
+ const FN_DEF_RE = /^\[\^([^\]\n]+)\]:\s+([^\n]+)/gm
12
+ const FN_REF_RE = /\[\^([^\]\n]+)\]/g
13
+
14
+ function preprocessFootnotes(markdown) {
15
+ const defs = {}
16
+ const stripped = markdown.replace(FN_DEF_RE, (_, label, text) => {
17
+ defs[label] = text.trim()
18
+ return ''
19
+ })
20
+
21
+ let counter = 0
22
+ const labelToNum = {}
23
+ const processed = stripped.replace(FN_REF_RE, (_, label) => {
24
+ if (labelToNum[label] === undefined) labelToNum[label] = ++counter
25
+ return `<sup>${labelToNum[label]}</sup>`
26
+ })
27
+
28
+ if (counter === 0) return { processed: markdown, footnotesHtml: '' }
29
+
30
+ const items = Object.keys(labelToNum)
31
+ .map(label => `<li>${marked.parseInline(defs[label] ?? label)}</li>`)
32
+ .join('')
33
+
34
+ return { processed, footnotesHtml: `<ol>${items}</ol>` }
35
+ }
36
+
37
+ // The deck's words for the audience (`slide.chapter` …), for layouts that show any.
38
+ export { t } from '../core/labels.js'
39
+
40
+ export function extractContent(markdown, { headingLevels = [], paragraph = false } = {}) {
41
+ const processed = markdown
42
+ const footnotesHtml = ''
43
+ const tokens = marked.lexer(processed)
44
+ const headings = {}
45
+ const paragraphs = []
46
+ const remaining = []
47
+
48
+ for (const token of tokens) {
49
+ if (token.type === 'heading' && headingLevels.includes(token.depth) && headings[token.depth] == null) {
50
+ headings[token.depth] = Parser.parseInline(token.tokens)
51
+ } else if (token.type === 'paragraph' && paragraph && paragraphs.length === 0 && !headings[2]) {
52
+ paragraphs.push(Parser.parseInline(token.tokens))
53
+ } else remaining.push(token)
54
+ }
55
+
56
+ const fullHtml = marked.parse(processed)
57
+ const bodyHtml = marked.parser(Object.assign(remaining, { links: tokens.links }))
58
+ return { headings, paragraphs, bodyHtml, fullHtml, footnotesHtml }
59
+ }
60
+
61
+ // ─── Pictures ─────────────────────────────────────────────────────────────
62
+
63
+ // An SVG picture that uses the theme's colour variables is drawn inline, so it
64
+ // follows the theme, palette and light or dark. The build lists them by the
65
+ // path the deck uses.
66
+ export const themedSvg = src => themedSvgs[src] ?? themedSvgs[String(src ?? '').split(/[?#]/)[0]]
67
+
68
+ const attribute = (tag, name) => tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i'))?.slice(1).find(v => v != null)
69
+
70
+ // Pictures in slide text keep the drawing's own size, like an <img>.
71
+ export function inlineThemedPictures(html) {
72
+ if (!html || !html.includes('<img')) return html
73
+ return html.replace(/<img\b[^>]*>/gi, tag => {
74
+ const markup = themedSvg(attribute(tag, 'src'))
75
+ if (!markup) return tag
76
+ const alt = attribute(tag, 'alt') ?? ''
77
+ return `<span class="themed-picture themed-picture--inline" role="img" aria-label="${alt}">${markup}</span>`
78
+ })
79
+ }
80
+
81
+ // A picture from the slide's settings; `fit: cover` fills its box like a
82
+ // photo, otherwise the drawing fits inside.
83
+ export function Picture({ src, alt, fit, position, class: className, ...attributes }) {
84
+ const markup = themedSvg(src)
85
+ if (!markup) return <img class={className} src={src} alt={alt} style={{ objectFit: fit, objectPosition: position }} {...attributes} />
86
+ const aspect = fit === 'cover' ? 'xMidYMid slice' : 'xMidYMid meet'
87
+ // The box sets the size; the drawing scales inside it.
88
+ const html = markup.replace(/<svg\b[^>]*>/, tag => tag.replace(/\s(width|height|preserveAspectRatio)="[^"]*"/g, '').replace('<svg', `<svg preserveAspectRatio="${aspect}"`))
89
+ return <div class={['themed-picture', className].filter(Boolean).join(' ')} role="img" aria-label={alt} dangerouslySetInnerHTML={{ __html: html }} {...attributes} />
90
+ }
91
+
92
+ // ─── Hydration for inline components ──────────────────────────────────────
93
+
94
+ export function HtmlContent({ html, class: className, ...attributes }) {
95
+ const ref = useRef()
96
+
97
+ useEffect(() => {
98
+ if (!ref.current) return
99
+ const mounted = []
100
+ for (const [name, Component] of Object.entries(registry)) {
101
+ for (const el of [...ref.current.querySelectorAll(name)]) {
102
+ const props = Object.fromEntries([...el.attributes].map(a => [a.name, a.value]))
103
+ const children = el.textContent
104
+ const wrapper = document.createElement('div')
105
+ el.replaceWith(wrapper)
106
+ render(h(Component, { ...props, children }), wrapper)
107
+ mounted.push(wrapper)
108
+ }
109
+ }
110
+ return () => mounted.forEach(wrapper => render(null, wrapper))
111
+ }, [html])
112
+
113
+ return <div class={className} {...attributes} ref={ref} dangerouslySetInnerHTML={{ __html: inlineThemedPictures(html) }} />
114
+ }
115
+
116
+ // ─── Header / footer / footnotes rails ────────────────────────────────────
117
+
118
+ function SlideHeader({ deckConfig, right, logo, isTitle = false }) {
119
+ const org = deckConfig.meta?.organization ?? ''
120
+
121
+ // show.organization: 'title' (default) | 'all' | 'none'
122
+ const instSetting = deckConfig.show?.organization ?? 'title'
123
+ const showOrg = instSetting === 'all' || (instSetting === 'title' && isTitle)
124
+
125
+ // show.sections: 'all' (default) | 'none'
126
+ const showSection = (deckConfig.show?.sections ?? 'all') !== 'none'
127
+
128
+ return (
129
+ <div class="slide-header">
130
+ <span class="brand">{showOrg ? org : ''}</span>
131
+ {logo && <img class="slide-logo" src={logo} alt="" />}
132
+ {showSection && right != null && <span>{right}</span>}
133
+ </div>
134
+ )
135
+ }
136
+
137
+ function SlideFooter({ deckConfig, left, right, isTitle = false }) {
138
+ const { author, date } = deckConfig.meta ?? {}
139
+ const authorStr = [author, date].filter(Boolean).join(' · ')
140
+
141
+ // show.author (the author and date line): 'title' (default) | 'all' | 'none'
142
+ const adSetting = deckConfig.show?.author ?? 'title'
143
+ const showAuthor = adSetting === 'all' || (adSetting === 'title' && isTitle)
144
+
145
+ // show.numbers: 'slides' (default = all-except-title) | 'all' | 'none'
146
+ const pnSetting = deckConfig.show?.numbers ?? 'slides'
147
+ const showRight = pnSetting === 'all' || (pnSetting === 'slides' && !isTitle)
148
+
149
+ const leftContent = left ?? (showAuthor ? authorStr : '')
150
+ const footerClass = leftContent ? 'slide-footer' : 'slide-footer slide-footer--no-meta'
151
+
152
+ return (
153
+ <div class={footerClass}>
154
+ <span>{leftContent}</span>
155
+ {right != null && showRight && <span>{right}</span>}
156
+ </div>
157
+ )
158
+ }
159
+
160
+ function SlideFootnotes({ html }) {
161
+ if (!html) return null
162
+ return <div class="slide-footnotes" dangerouslySetInnerHTML={{ __html: html }} />
163
+ }
164
+
165
+ function slideNum(index) {
166
+ return String(index + 1).padStart(2, '0')
167
+ }
168
+
169
+
170
+ export function prepareSlide(slide) {
171
+ const position = region => region.source?.start ?? region.ranges?.[0]?.start ?? 0
172
+ const entries = Object.entries(slide.regions ?? { body: { name: 'body', content: slide.content } })
173
+ .sort((a, b) => position(a[1]) - position(b[1]))
174
+ let separator = '\u0000mdeck-region\u0000'
175
+ while (entries.some(([, r]) => r.content.includes(separator))) separator += '\u0000'
176
+ const { processed, footnotesHtml } = preprocessFootnotes(entries.map(([, r]) => r.content).join(separator))
177
+ const pieces = processed.split(separator)
178
+ const regions = Object.fromEntries(entries.map(([name, region], i) => [name, { ...region, content: pieces[i] }]))
179
+ return { ...slide, regions, content: regions.body?.content ?? '', footnotesHtml }
180
+ }
181
+
182
+ export function MarkdownRegion({ region, content, class: className, ...attributes }) {
183
+ const name = typeof region === 'object' ? region.name : undefined
184
+ const markdown = content ?? (typeof region === 'string' ? region : region?.content) ?? ''
185
+ return <HtmlContent class={className} data-region={name} {...attributes} html={marked.parse(markdown)} />
186
+ }
187
+
188
+ // Layouts supply body content; every layout receives the same outer frame.
189
+ export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0, id, manifest, footnotesHtml, children }) {
190
+ const layout = manifest.id === 'generic' ? meta.layout : manifest.id
191
+ const frame = manifest.frame ?? 'standard'
192
+ const title = frame === 'title'
193
+ const image = props.image
194
+ const classes = ['slide', layout && 'slide--' + String(layout).replace(/[^a-zA-Z0-9_-]/g, '-'),
195
+ image && ['title', 'chapter'].includes(layout) && 'has-image',
196
+ layout === 'full-bleed-image' && props.overlay && 'has-overlay'].filter(Boolean).join(' ')
197
+ return <section class={classes} data-slide-id={id} data-label={`${slideNum(index)} ${manifest.title}`}>
198
+ {frame !== 'none' && <SlideHeader deckConfig={deckConfig} logo={title ? deckConfig.meta?.logo : undefined}
199
+ isTitle={title} right={frame === 'chapter' ? meta.part ?? '' : meta.section ?? ''} />}
200
+ {children}
201
+ <SlideFootnotes html={footnotesHtml} />
202
+ {frame !== 'none' && <SlideFooter deckConfig={deckConfig} right={slideNum(index)} isTitle={title} />}
203
+ <InkLayer slideId={id} />
204
+ </section>
205
+ }
@@ -0,0 +1,30 @@
1
+ // Typed layout properties: shared by CLI validation, discovery and rendering.
2
+ // Built-in layout manifests live in assets/extensions/layouts/*/extension.toml.
3
+
4
+ export function resolveLayoutProps(manifest, meta = {}) {
5
+ const result = {}
6
+ for (const [key, schema] of Object.entries(manifest.properties ?? {})) {
7
+ if (schema.default !== undefined) result[key] = structuredClone(schema.default)
8
+ // Preserve legacy top-level fields for built-in layouts.
9
+ if (meta[key] !== undefined) result[key] = meta[key]
10
+ }
11
+ return { ...result, ...(meta.props ?? {}) }
12
+ }
13
+
14
+ export function propertyErrors(value, schema, path) {
15
+ const errors = []
16
+ const valid = schema.type === 'array' ? Array.isArray(value)
17
+ : schema.type === 'object' ? value !== null && typeof value === 'object' && !Array.isArray(value)
18
+ : schema.type === 'integer' ? Number.isInteger(value)
19
+ : typeof value === schema.type && (schema.type !== 'number' || Number.isFinite(value))
20
+ if (!valid) return [`${path} must be ${schema.type}`]
21
+ if (schema.enum && !schema.enum.includes(value)) errors.push(`${path} must be one of: ${schema.enum.join(', ')}`)
22
+ if (typeof value === 'number' && schema.minimum != null && value < schema.minimum) errors.push(`${path} must be at least ${schema.minimum}`)
23
+ if (typeof value === 'number' && schema.maximum != null && value > schema.maximum) errors.push(`${path} must be at most ${schema.maximum}`)
24
+ if (Array.isArray(value)) {
25
+ if (schema.minItems != null && value.length < schema.minItems) errors.push(`${path} needs at least ${schema.minItems} items`)
26
+ if (schema.maxItems != null && value.length > schema.maxItems) errors.push(`${path} allows at most ${schema.maxItems} items`)
27
+ if (schema.items) value.forEach((item, index) => errors.push(...propertyErrors(item, schema.items, `${path}[${index}]`)))
28
+ }
29
+ return errors
30
+ }
@@ -0,0 +1,17 @@
1
+ import { h } from 'preact'
2
+ import { layouts } from 'virtual:mdeck-extensions'
3
+ import { resolveLayoutProps } from './layoutProps'
4
+ import { SlideFrame, prepareSlide } from './layoutApi'
5
+
6
+ // Every layout — built-in or deck-local — arrives through the same registry.
7
+ export { layouts }
8
+ export const manifests = Object.fromEntries(Object.entries(layouts).map(([id, t]) => [id, t.manifest]))
9
+
10
+ export function SlideRenderer(slide) {
11
+ const id = slide.meta.layout ?? 'generic'
12
+ const entry = Object.hasOwn(layouts, id) ? layouts[id] : layouts.generic
13
+ const prepared = prepareSlide(slide)
14
+ const props = resolveLayoutProps(entry.manifest, slide.meta)
15
+ const context = { ...prepared, props, manifest: entry.manifest }
16
+ return <SlideFrame {...context}>{h(entry.render, context)}</SlideFrame>
17
+ }
@@ -0,0 +1,34 @@
1
+ /* The phones' answer page. The presenter's screen sends the deck's colours
2
+ and fonts as CSS variables; these are only fallbacks. */
3
+ :root {
4
+ --bg: #fff; --ink: #111; --muted: #666; --rule: #d6d6d6; --accent: #0d9488;
5
+ --font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
6
+ }
7
+ * { box-sizing: border-box; }
8
+ html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); }
9
+ body { min-height: 100vh; display: flex; flex-direction: column; font: 18px/1.45 var(--font-body); -webkit-font-smoothing: antialiased; }
10
+ #answer { flex: 1; display: flex; flex-direction: column; }
11
+ .answer { flex: 1; width: 100%; max-width: 520px; margin: 0 auto; padding: 32px 20px; }
12
+ .answer h1 {
13
+ margin: 0 0 22px; font-size: clamp(24px, 7vw, 34px); line-height: 1.15;
14
+ font-family: var(--answer-heading-family, var(--font-display, var(--font-body)));
15
+ font-weight: var(--answer-heading-weight, 700);
16
+ letter-spacing: var(--answer-heading-spacing, normal);
17
+ text-transform: var(--answer-heading-transform, none);
18
+ }
19
+ .answer--waiting p { font-family: var(--answer-heading-family, var(--font-display, var(--font-body))); font-size: 22px; font-weight: 600; }
20
+ .answer-options, .answer-scale { display: grid; gap: 12px; }
21
+ .answer-scale { grid-template-columns: repeat(auto-fit, minmax(48px, 1fr)); }
22
+ .answer button {
23
+ padding: 18px 20px; border: 2px solid var(--rule); border-radius: 14px; background: var(--bg); color: var(--ink);
24
+ font: inherit; font-size: 20px; font-weight: 600; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent;
25
+ }
26
+ .answer-scale button { padding: 16px 0; text-align: center; }
27
+ .answer button:hover { border-color: color-mix(in srgb, var(--accent) 60%, var(--rule)); }
28
+ .answer button.is-picked { border-color: var(--accent); background: var(--accent); color: var(--on-accent, var(--bg)); }
29
+ .answer-scale-labels { display: flex; justify-content: space-between; margin-top: 8px; color: var(--muted); font-size: 15px; }
30
+ .answer-text { display: grid; gap: 12px; }
31
+ .answer-text textarea { width: 100%; padding: 14px; border: 2px solid var(--rule); border-radius: 14px; background: var(--bg); color: var(--ink); font: inherit; resize: vertical; }
32
+ .answer-text button[type=submit] { justify-self: start; background: var(--accent); border-color: var(--accent); color: var(--on-accent, var(--bg)); }
33
+ .answer-status { margin: 18px 0 0; color: var(--muted); min-height: 2.8em; }
34
+ footer { padding: 16px 20px; text-align: center; color: var(--muted); font-size: 14px; }
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="robots" content="noindex" />
7
+ <title>Answer</title>
8
+ <link rel="stylesheet" href="answer.css" />
9
+ </head>
10
+ <body>
11
+ <div id="answer"></div>
12
+ <script src="answer.js"></script>
13
+ </body>
14
+ </html>
@@ -0,0 +1,130 @@
1
+ // The phone page the server serves at <server>/<code>. It needs no deck:
2
+ // the presenter's screen sends the activity on screen (what to ask and how to
3
+ // answer), the deck's look and the words in the deck's language, and this page
4
+ // shows them. Plain browser JavaScript, no build step.
5
+ (() => {
6
+ const base = new URL('.', location.href)
7
+ const code = location.pathname.split('/').filter(Boolean).pop()
8
+ const root = document.getElementById('answer')
9
+ const FALLBACK = {
10
+ en: { waiting: 'The next question will appear here.', pick: 'Tap one answer.', thanks: 'Thanks! You chose “{choice}”. Tap another to change.', send: 'Send', sent: 'Sent. Thank you!', offline: 'Connecting…' },
11
+ de: { waiting: 'Die nächste Frage erscheint hier.', pick: 'Eine Antwort antippen.', thanks: 'Danke! Gewählt: „{choice}“. Zum Ändern eine andere antippen.', send: 'Senden', sent: 'Gesendet. Danke!', offline: 'Verbinde…' },
12
+ }
13
+ let words = FALLBACK[navigator.language?.slice(0, 2) === 'de' ? 'de' : 'en']
14
+ let current = null
15
+ let connected = false
16
+ let status = ''
17
+
18
+ const me = (() => {
19
+ try {
20
+ let id = localStorage.getItem('mdeck-live-client')
21
+ // randomUUID only exists on https or localhost, not at `mdeck run --network`'s http://192.168…
22
+ if (!id) localStorage.setItem('mdeck-live-client', id = crypto.randomUUID?.() ?? Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join(''))
23
+ return id
24
+ } catch { return String(Math.random()).slice(2) }
25
+ })()
26
+ const chosenKey = room => `mdeck-answer:${code}.${room}`
27
+ const remembered = room => { try { return localStorage.getItem(chosenKey(room)) } catch { return null } }
28
+ const remember = (room, value) => { try { localStorage.setItem(chosenKey(room), value) } catch {} }
29
+ const fill = (text, vars) => text.replace(/\{(\w+)\}/g, (all, name) => vars[name] ?? all)
30
+ const el = (tag, props = {}, ...children) => {
31
+ const node = document.createElement(tag)
32
+ for (const [key, value] of Object.entries(props)) {
33
+ if (key.startsWith('on')) node.addEventListener(key.slice(2), value)
34
+ else if (value != null && value !== false) node.setAttribute(key, value === true ? '' : value)
35
+ }
36
+ node.append(...children.flat().filter(child => child != null))
37
+ return node
38
+ }
39
+
40
+ // The deck's colours and fonts, as the presenter's screen shows them.
41
+ function applyLook(look, lang) {
42
+ if (lang) document.documentElement.lang = lang
43
+ // No look: keep the one the page has, fonts included.
44
+ if (!look?.tokens) return
45
+ const style = document.documentElement.style
46
+ for (const [name, value] of Object.entries(look?.tokens ?? {})) if (/^--[\w-]+$/.test(name)) style.setProperty(name, value)
47
+ for (const [name, value] of Object.entries(look?.heading ?? {})) style.setProperty(`--answer-heading-${name}`, value)
48
+ const wanted = (look?.fonts ?? []).filter(url => /^https:\/\//.test(url))
49
+ for (const link of [...document.querySelectorAll('link[data-theme-font]')]) if (!wanted.includes(link.href)) link.remove()
50
+ for (const url of wanted) if (!document.querySelector(`link[data-theme-font][href="${CSS.escape(url)}"]`)) document.head.append(el('link', { rel: 'stylesheet', href: url, 'data-theme-font': true }))
51
+ }
52
+
53
+ async function answer(room, value) {
54
+ const response = await fetch(new URL(`rooms/${encodeURIComponent(`${code}.${room}`)}`, base), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: me, data: { value } }) })
55
+ if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? String(response.status))
56
+ }
57
+
58
+ const FORMS = {
59
+ // { question, options: [..] }: one of several answers, which can be changed.
60
+ choice(activity, room) {
61
+ const picked = remembered(room)
62
+ return [
63
+ el('div', { class: 'answer-options' }, activity.options.map(option => el('button', {
64
+ class: option === picked ? 'is-picked' : null, 'aria-pressed': String(option === picked),
65
+ onclick: async () => {
66
+ try { await answer(room, option); remember(room, option); status = fill(words.thanks, { choice: option }) } catch (error) { status = error.message }
67
+ render()
68
+ },
69
+ }, option))),
70
+ el('p', { class: 'answer-status', role: 'status' }, status || (picked ? fill(words.thanks, { choice: picked }) : words.pick)),
71
+ ]
72
+ },
73
+ // { question, placeholder, maxLength }: short text, as often as people like.
74
+ text(activity, room) {
75
+ const input = el('textarea', { rows: 3, maxlength: activity.maxLength ?? 200, placeholder: activity.placeholder ?? '' })
76
+ const form = el('form', { class: 'answer-text', onsubmit: async event => {
77
+ event.preventDefault()
78
+ const value = input.value.trim()
79
+ if (!value) return
80
+ try { await answer(room, value); status = words.sent } catch (error) { status = error.message }
81
+ render()
82
+ } }, input, el('button', { type: 'submit' }, words.send))
83
+ return [form, el('p', { class: 'answer-status', role: 'status' }, status)]
84
+ },
85
+ // { question, min, max, minLabel, maxLabel }: a number on a scale.
86
+ scale(activity, room) {
87
+ const picked = remembered(room)
88
+ const steps = []
89
+ for (let n = activity.min ?? 1; n <= (activity.max ?? 5); n++) steps.push(String(n))
90
+ return [
91
+ el('div', { class: 'answer-scale' }, steps.map(step => el('button', {
92
+ class: step === picked ? 'is-picked' : null, 'aria-pressed': String(step === picked),
93
+ onclick: async () => {
94
+ try { await answer(room, Number(step)); remember(room, step); status = fill(words.thanks, { choice: step }) } catch (error) { status = error.message }
95
+ render()
96
+ },
97
+ }, step))),
98
+ (activity.minLabel || activity.maxLabel) && el('div', { class: 'answer-scale-labels' }, el('span', {}, activity.minLabel ?? ''), el('span', {}, activity.maxLabel ?? '')),
99
+ el('p', { class: 'answer-status', role: 'status' }, status || (picked ? fill(words.thanks, { choice: picked }) : words.pick)),
100
+ ]
101
+ },
102
+ }
103
+
104
+ function render() {
105
+ const activity = current?.activity
106
+ const form = activity && FORMS[activity.type]
107
+ root.replaceChildren(
108
+ form
109
+ ? el('main', { class: 'answer' }, activity.question && el('h1', {}, activity.question), form(activity, current.room))
110
+ : el('main', { class: 'answer answer--waiting' }, el('p', {}, connected ? words.waiting : words.offline)),
111
+ current?.title ? el('footer', {}, current.title) : null,
112
+ )
113
+ }
114
+
115
+ function follow(state) {
116
+ const previous = current?.room
117
+ current = state
118
+ if (state?.labels) words = { ...words, ...state.labels }
119
+ applyLook(state?.look, state?.lang)
120
+ if (state?.room !== previous) status = ''
121
+ render()
122
+ }
123
+
124
+ render()
125
+ // view=phone: the presenter sees how many phones are connected.
126
+ const source = new EventSource(new URL(`rooms/${encodeURIComponent(code)}/events?view=phone&device=phone`, base))
127
+ source.addEventListener('snapshot', event => { connected = true; follow(JSON.parse(event.data).state) })
128
+ source.addEventListener('error', () => { connected = false; render() })
129
+ source.addEventListener('state', event => follow(JSON.parse(event.data).state))
130
+ })()