mdeck 3.11.0 → 3.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +3 -3
  3. package/assets/base.css +2 -0
  4. package/docs/reference/authoring.md +48 -7
  5. package/docs/site/content/appearance.md +1 -1
  6. package/docs/site/content/audience.md +36 -8
  7. package/docs/site/content/commands.md +4 -2
  8. package/docs/site/content/drawing.md +4 -4
  9. package/docs/site/content/getting-started.md +2 -32
  10. package/docs/site/content/install.md +3 -1
  11. package/docs/site/content/presenting.md +29 -8
  12. package/docs/site/content/writing.md +27 -0
  13. package/examples/showcase/slides.ar.md +5 -5
  14. package/examples/showcase/slides.de.md +5 -5
  15. package/examples/showcase/slides.de.results.json +27 -0
  16. package/examples/showcase/slides.en.md +5 -5
  17. package/package.json +1 -1
  18. package/skills/write-slides/SKILL.md +4 -1
  19. package/src/build/check.js +27 -6
  20. package/src/build/components.js +5 -3
  21. package/src/build/homePlugin.js +64 -14
  22. package/src/build/include.js +249 -0
  23. package/src/build/inkPlugin.js +12 -2
  24. package/src/build/relayAccess.js +3 -1
  25. package/src/build/renderCheck.js +23 -3
  26. package/src/build/slidesPlugin.js +64 -16
  27. package/src/build/timingPlugin.js +34 -18
  28. package/src/cli/args.js +3 -3
  29. package/src/cli/index.js +49 -10
  30. package/src/components/Bibliography.jsx +7 -2
  31. package/src/components/Poll.jsx +2 -2
  32. package/src/components/QrCodeTag.jsx +2 -2
  33. package/src/components/WordCloud.jsx +7 -6
  34. package/src/components/activity.jsx +11 -8
  35. package/src/components/poll.css +2 -0
  36. package/src/core/icons.js +6 -1
  37. package/src/core/labels.js +3 -0
  38. package/src/core/parseSlides.js +14 -4
  39. package/src/core/reactions.js +27 -0
  40. package/src/core/resultsReport.js +153 -0
  41. package/src/core/source.js +8 -1
  42. package/src/core/validateDeck.js +6 -2
  43. package/src/home/Home.jsx +38 -93
  44. package/src/home/home.css +5 -17
  45. package/src/layouts/renderSlide.jsx +13 -1
  46. package/src/live/answer/answer.css +12 -1
  47. package/src/live/answer/answer.js +32 -4
  48. package/src/live/client.js +23 -4
  49. package/src/live/rooms.js +29 -7
  50. package/src/live/server.js +15 -1
  51. package/src/runtime/KeyHelp.jsx +115 -0
  52. package/src/runtime/PresenterChrome.jsx +168 -0
  53. package/src/runtime/Reactions.jsx +56 -0
  54. package/src/runtime/deck-stage.js +322 -17
  55. package/src/runtime/helpKeys.js +75 -0
  56. package/src/runtime/ink/room.js +6 -6
  57. package/src/runtime/main.jsx +396 -303
  58. package/src/runtime/slideControls.js +60 -0
  59. package/src/runtime/useTalkTimer.js +4 -2
@@ -6,6 +6,7 @@ import { resolveLayoutProps, propertyErrors } from '../layouts/layoutProps.js'
6
6
  import { movedHint } from '../extensions/moved.js'
7
7
  import { paramValue } from '../extensions/params.js'
8
8
  import { parseTime } from './timing.js'
9
+ import { reactionsProblem } from './reactions.js'
9
10
 
10
11
  // Hotspots (runtime/hotspots.js): the deck's setting and a slide's own.
11
12
  const HOTSPOT_SETTINGS = ['auto', 'marked', 'off', true, false]
@@ -56,6 +57,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
56
57
  }
57
58
  if (config.hotspots != null && !HOTSPOT_SETTINGS.includes(config.hotspots)) add('invalid-config', 'hotspots must be auto, marked, off, true or false', deck.configSource?.start)
58
59
  if (config.time != null && parseTime(config.time) == null) add('invalid-config', TIME_HINT(config.time), deck.configSource?.start)
60
+ if (reactionsProblem(config.reactions)) add('invalid-config', reactionsProblem(config.reactions), deck.configSource?.start)
59
61
  const enums = { organization: ['title', 'all', 'none'], author: ['title', 'all', 'none'], numbers: ['slides', 'all', 'none'], sections: ['all', 'none'] }
60
62
  const switches = ['citations']
61
63
  if (isPlainObject(config.show)) {
@@ -102,6 +104,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
102
104
  if (slide.meta.hotspots != null && !HOTSPOT_SETTINGS.includes(slide.meta.hotspots)) add('invalid-metadata', 'hotspots must be auto, marked, off, true or false', offset)
103
105
  if (slide.meta.time != null && parseTime(slide.meta.time) == null) add('invalid-metadata', TIME_HINT(slide.meta.time), offset)
104
106
  for (const node of directivesIn(deck.source.slice(slide.source.start, slide.source.end))) {
107
+ if (node.name === 'include' && !slide.include) add('misplaced-include', ':::include stands alone on its slide, with no settings or other content: :::include ./part.md, or :::include ./part.md slides=2-5', slide.source.start + node.start, 'warning')
105
108
  if (DIRECTIVES.includes(node.name) || calloutTypes.includes(node.name.toLowerCase())) continue
106
109
  const near = closest(node.name.toLowerCase(), [...DIRECTIVES, ...calloutTypes])
107
110
  add('unknown-callout', `Unknown block "${node.name}"${near ? `; did you mean "${near}"?` : ''} It shows as a plain callout. Known: ${CALLOUT_TYPES.join(', ')}; name your own under callouts: in the settings`, slide.source.start + node.start, 'warning')
@@ -134,7 +137,7 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
134
137
  }
135
138
 
136
139
  // The `:::` blocks mdeck reads itself; any other name is a callout type.
137
- const DIRECTIVES = ['meta', 'notes', 'slot', 'steps', 'columns']
140
+ const DIRECTIVES = ['meta', 'notes', 'slot', 'steps', 'columns', 'include']
138
141
 
139
142
  function directivesIn(source) {
140
143
  const all = []
@@ -159,6 +162,7 @@ function closest(name, names) {
159
162
  return best && best[1] <= 2 ? best[0] : null
160
163
  }
161
164
 
165
+ // A diagnostic with a `file` of its own (one in an included deck) names it.
162
166
  export function formatDiagnostics(diagnostics, filename = 'slides.md') {
163
- return diagnostics.map(d => `${filename}:${d.line}:${d.column ?? 1}: ${d.severity} [${d.code}] ${d.message}`).join('\n')
167
+ return diagnostics.map(d => `${d.file ?? filename}:${d.line}:${d.column ?? 1}: ${d.severity} [${d.code}] ${d.message}`).join('\n')
164
168
  }
package/src/home/Home.jsx CHANGED
@@ -1,10 +1,9 @@
1
1
  import { h } from 'preact'
2
2
  import { useEffect, useRef, useState } from 'preact/hooks'
3
3
  import mark from '../../assets/logo/mark.svg'
4
- import QrCode from '../components/QrCode.jsx'
5
4
  import { Icon } from '../components/Icon.jsx'
6
5
  import { ConnectedViews } from '../components/ConnectedViews.jsx'
7
- import { devicesText, phonesText } from '../live/presence.js'
6
+ import { devicesText } from '../live/presence.js'
8
7
  import { devPath } from '../core/devPath.js'
9
8
 
10
9
  // Where the dev server serves from: / normally, /t/<id>/ with `mdeck run --server`.
@@ -118,8 +117,6 @@ function Preview({ info }) {
118
117
  <button class="home-btn home-btn--small" onClick={() => control('prev')} aria-label="Previous"><HomeIcon name="prev" size={14} /></button>
119
118
  <button class="home-btn home-btn--small" onClick={() => control('next')} aria-label="Next"><HomeIcon name="next" size={14} /></button>
120
119
  <span class="home-preview-count">{index + 1} / {info.slides}</span>
121
- <span class="home-spacer" />
122
- <a class="home-btn home-btn--small" href={audienceUrl} target="_blank" rel="noopener">Open audience window <HomeIcon name="external" size={13} /></a>
123
120
  </div>
124
121
  </section>
125
122
  }
@@ -171,102 +168,45 @@ function Diagnostics({ diagnostics, name }) {
171
168
  </ul>
172
169
  }
173
170
 
174
- function CopyButton({ text, label = 'Copy' }) {
175
- const [copied, setCopied] = useState(false)
176
- const copy = async () => {
177
- try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1400) } catch {}
178
- }
179
- return <button class="home-btn home-btn--small" onClick={copy}><HomeIcon name={copied ? 'check' : 'copy'} size={14} />{copied ? 'Copied' : label}</button>
180
- }
181
171
 
182
172
  // One session pairs the presenter view with the audience window, so both
183
173
  // links on this page drive each other.
184
174
  const SESSION = typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)
185
175
 
186
- // For decks with a `server` setting: the server's health, the viewers' link
187
- // and the key (MDECK_SERVER_KEY) that lets a browser steer phones and reset
188
- // polls.
189
- function LivePolls({ live, presence, onRecheck }) {
190
- const presenterUrl = `${BASE}?view=presenter&session=${SESSION}${live.key ? `&serverkey=${encodeURIComponent(live.key)}` : ''}`
191
- const keyState = !live.key ? null : live.keyAccepted ? ['is-ok', 'Accepted by the server'] : live.reachable ? ['is-bad', 'Not accepted by the server'] : ['is-idle', 'Not checked']
192
- return <section class="home-section">
176
+ // The answers kept in the talk: each question with how it came out, and
177
+ // the files to save them as (the same as `mdeck results`).
178
+ function Answers({ results, file, onRefresh }) {
179
+ const href = format => `${devPath('__mdeck/home/results')}?format=${format}`
180
+ return <section class="home-section home-section--wide home-answers">
193
181
  <div class="home-section-head">
194
- <h2>Polls</h2>
195
- <button class="home-btn home-btn--small" onClick={onRecheck}>Check again</button>
182
+ <h2>Answers from the talk</h2>
183
+ <button class="home-btn home-btn--small" onClick={onRefresh}>Refresh</button>
196
184
  </div>
197
- <dl class="home-live">
198
- <div>
199
- <dt>Server</dt>
200
- <dd>
201
- {live.server
202
- ? <><code>{live.server}</code><span class={`home-pill ${live.reachable ? 'is-ok' : 'is-bad'}`}>{live.reachable ? `Reachable · ${live.ms} ms` : `Not reachable · ${live.error}`}</span></>
203
- : <span>Built into this dev server</span>}
204
- </dd>
205
- </div>
206
- <div>
207
- <dt>Join link for phones</dt>
208
- <dd>
209
- {live.joinUrl
210
- ? <><a href={live.joinUrl} target="_blank" rel="noopener"><code>{live.joinUrl}</code></a> <CopyButton text={live.joinUrl} /></>
211
- : <>
212
- <p class="home-note">Phones cannot reach this computer. Start with <code>--network</code>, or set <code>server</code> in the deck.</p>
213
- {live.localJoinUrl && <p><a href={live.localJoinUrl} target="_blank" rel="noopener">Try the answer page here</a> <span>in another browser tab or window.</span></p>}
214
- </>}
215
- <p>Session code <code class="home-code">{live.code}</code>, the same for every poll in this deck.</p>
216
- {presence && <p>{presence.phones ? `${phonesText(presence.phones)} connected now.` : 'No phones connected yet.'}</p>}
217
- </dd>
218
- </div>
219
- {live.server && <div>
220
- <dt>Server key</dt>
221
- <dd>
222
- {live.key
223
- ? <>
224
- <code class="home-code">{live.key}</code> <CopyButton text={live.key} />
225
- <span class={`home-pill ${keyState[0]}`}>{keyState[1]}</span>
226
- <p>The presenter view and audience window buttons above carry it. Or open <a href={presenterUrl} target="_blank" rel="noopener">the presenter view with the key</a>.</p>
227
- </>
228
- : <p>Start <code>mdeck run</code> with <code>MDECK_SERVER_KEY</code> set to the server's key to see it here.</p>}
229
- </dd>
230
- </div>}
231
- </dl>
232
- </section>
233
- }
234
-
235
- // Presenting from an iPad: a one-time QR code opens the presenter view there,
236
- // paired with this server so it may save ink and steer the projector, which
237
- // shows the audience window on this computer.
238
- function Tablet({ pairing, relay, presence, onChange, onError }) {
239
- const away = (presence?.views ?? []).filter(entry => entry.view === 'presenter' && entry.device !== 'computer')
240
- const [url, setUrl] = useState(null)
241
- const offer = () => api('/action', { action: 'pair' }).then(result => { setUrl(result.url); onChange() }).catch(error => onError(error.message))
242
- const unpair = () => api('/action', { action: 'unpair' }).then(() => { setUrl(null); onChange() }).catch(error => onError(error.message))
243
- return <section class="home-section home-tablet">
244
- <div class="home-section-head">
245
- <h2>Present from an iPad</h2>
246
- {pairing.devices > 0 && <button class="home-btn home-btn--small" onClick={unpair}>Unpair {pairing.devices === 1 ? 'the device' : `${pairing.devices} devices`}</button>}
247
- </div>
248
- {relay && <p class={`home-note home-relay home-relay--${relay.state}`}>{relay.state === 'up' ? 'Connected to your server: the iPad can reach this computer from any network.' : relay.state === 'refused' ? `Connecting to the server failed: ${relay.reason}` : relay.state === 'down' ? 'Lost the connection to the server; reconnecting…' : 'Connecting to your server…'}</p>}
249
- {away.length > 0 && <p class="home-ok"><HomeIcon name="check" /> Presenter view open on {devicesText(away)}.</p>}
250
- {!pairing.available
251
- ? <p class="home-note">Start <code>mdeck run</code> with <code>--network</code>, so an iPad in the same network can reach it, or with <code>--server</code> to reach it through your server.</p>
252
- : url
253
- ? <div class="home-pair">
254
- <QrCode url={url} size="180" />
255
- <div>
256
- <p>Scan this with the iPad's camera. It opens the presenter view there, where you draw and go through the slides.</p>
257
- <p>Show the <a href={`${BASE}?view=audience&session=${SESSION}`} target="_blank" rel="noopener">audience window</a> on the projector from this computer: it follows the iPad.</p>
258
- <p class="home-note">The code works once, for ten minutes. {pairing.devices > 0 ? `${pairing.devices} paired so far.` : ''}</p>
185
+ <ul class="home-outputs">
186
+ {results.map(question => {
187
+ const { counts = [], voted, phones, average } = question.summary
188
+ const top = counts.slice(0, 3).map(row => `${row.answer} ${row.count}`).join(' · ')
189
+ return <li class="home-output" key={question.room}>
190
+ <div class="home-output-body">
191
+ <strong>{question.slide ? `Slide ${question.slide}: ` : ''}{question.question || question.room}</strong>
192
+ <span>{phones === 1 ? '1 phone' : `${phones} phones`}{voted != null && voted !== phones ? `, ${voted} counted` : ''}{question.closed ? ', closed' : ''}{average != null ? ` · average ${average}` : ''}{top ? ` · ${top}` : ''}</span>
259
193
  </div>
260
- </div>
261
- : <>
262
- <p>The iPad opens the same presenter view as this computer, laid out for its screen: you draw and go through the slides there while this computer drives the projector. Drawings are saved in the drawings file beside the deck.</p>
263
- <p class="home-note">Pairing is what lets the iPad save drawings and steer the audience window here. Other devices in the network can open the presenter view too, but only watch.</p>
264
- <button class="home-btn" onClick={offer}>Show pairing code</button>
265
- {pairing.devices > 0 && <p class="home-note">{pairing.devices === 1 ? 'One device' : `${pairing.devices} devices`} paired.</p>}
266
- </>}
194
+ </li>
195
+ })}
196
+ </ul>
197
+ <div class="home-answer-files">
198
+ <a class="home-btn home-btn--primary" href={href('csv')} download><HomeIcon name="download" size={15} />Answers (CSV)</a>
199
+ <a class="home-btn" href={href('summary')} download><HomeIcon name="download" size={15} />Summary (CSV)</a>
200
+ <a class="home-btn" href={href('json')} download><HomeIcon name="download" size={15} />Everything (JSON)</a>
201
+ </div>
202
+ <p class="home-hint">Kept in <code>{file}</code> beside the deck while <code>mdeck run</code> runs; phones are numbered per question, never named.</p>
267
203
  </section>
268
204
  }
269
205
 
206
+ // For decks with a `server` setting: the server's health, the viewers' link
207
+ // and the key (MDECK_SERVER_KEY) that lets a browser steer phones and reset
208
+ // polls.
209
+
270
210
  export function Home() {
271
211
  const [info, setInfo] = useState(null)
272
212
  const [error, setError] = useState('')
@@ -313,6 +253,11 @@ export function Home() {
313
253
  <p class="home-meta">{meta.join(' · ')}</p>
314
254
  <p class="home-file"><code>{info.file}</code> <button class="home-link" onClick={() => api('/action', { action: 'reveal', output: 'deck' }).catch(e => setError(e.message))}>Show</button></p>
315
255
  </div>
256
+ {/* mdeck run --server: whether the connection to the server is up. */}
257
+ {info.relay && <span class={`home-health home-relay${info.relay.state === 'up' ? '' : info.relay.state === 'refused' ? ' is-bad' : ' is-warn'}`} title={info.relay.reason ?? info.relay.url}>
258
+ <HomeIcon name={info.relay.state === 'up' ? 'check' : 'alert'} />
259
+ {info.relay.state === 'up' ? 'Connected to your server' : info.relay.state === 'refused' ? 'Server refused the connection' : info.relay.state === 'down' ? 'Reconnecting to your server…' : 'Connecting to your server…'}
260
+ </span>}
316
261
  <a class={`home-health${errors ? ' is-bad' : warnings ? ' is-warn' : ''}`} href="#check">
317
262
  <HomeIcon name={errors || warnings ? 'alert' : 'check'} />
318
263
  {errors ? `${errors} error${errors === 1 ? '' : 's'}` : warnings ? `${warnings} warning${warnings === 1 ? '' : 's'}` : 'All good'}
@@ -330,14 +275,14 @@ export function Home() {
330
275
  <ConnectedViews presence={presence} phones={!!info.live} />
331
276
  </div>
332
277
  <div class="home-tiles">
333
- <ViewTile icon="presenter" title="Presenter view" text="Present and draw, with notes, timer and next slide. Switch to slides only or fullscreen." href={`${BASE}?view=presenter&session=${SESSION}${code}`} open={openOn('presenter')} />
278
+ <ViewTile icon="presenter" title="Presenter view" text="Present and draw, with notes, timer and next slide. Pair an iPad or a phone clicker from it." href={`${BASE}?view=presenter&session=${SESSION}${code}`} open={openOn('presenter')} />
334
279
  <ViewTile icon="rehearse" title="Rehearse" text="Go through the talk as if giving it: each slide is timed, and the times can be saved into the deck." href={`${BASE}?view=presenter&rehearse=1&session=${SESSION}${code}`} />
335
280
  <ViewTile icon="projector" title="Audience window" text="Slides for the projector, without notes. Navigation stays in sync with the presenter." href={`${BASE}?view=audience&session=${SESSION}${code}`} open={openOn('audience')} />
336
281
  <ViewTile icon="reader" title="Reader view" text="Outline, reading mode and look picker, as people you send it to see it." href={`${BASE}?view=reader`} />
337
282
  </div>
338
283
  </section>
339
284
 
340
- <section class={`home-section${info.live ? '' : ' home-section--wide'}`}>
285
+ <section class="home-section home-section--wide">
341
286
  <h2>Write and learn</h2>
342
287
  <div class="home-tiles">
343
288
  <ServiceTile icon="editor" title="Visual editor" text="Forms and a live preview; saves into the file and keeps a backup." action="editor" url={info.services.editor} onError={setError} onStarted={load} />
@@ -346,9 +291,9 @@ export function Home() {
346
291
  </div>
347
292
  </section>
348
293
 
349
- {info.live && <LivePolls live={info.live} presence={presence} onRecheck={load} />}
350
294
 
351
- {info.pairing && <Tablet pairing={info.pairing} relay={info.relay} presence={presence} onChange={load} onError={setError} />}
295
+ {info.results?.length > 0 && <Answers results={info.results} file={info.name.replace(/\.md$/i, '') + '.results.json'} onRefresh={load} />}
296
+
352
297
 
353
298
  <section class="home-section home-section--wide">
354
299
  <h2>Share</h2>
package/src/home/home.css CHANGED
@@ -80,6 +80,11 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); ou
80
80
  .home-tile-body strong { font-weight: 600; color: var(--home-strong); font-size: 13px; }
81
81
  .home-tile-body span { color: var(--home-muted); font-size: 12px; overflow-wrap: anywhere; }
82
82
 
83
+ /* ── Answers from the talk ─────────────────────────────────────────── */
84
+ .home-answer-files { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
85
+ .home-answer-files a { text-decoration: none; }
86
+ .home-answers .home-hint { color: var(--home-faint); font-size: 12px; margin: 8px 0 0; }
87
+
83
88
  /* ── Share ────────────────────────────────────────────────────────────── */
84
89
  .home-outputs { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
85
90
  .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); }
@@ -90,8 +95,6 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); ou
90
95
  .home-output-body details { margin-top: 6px; font-size: 12px; }
91
96
  .home-output-body pre { max-height: 220px; overflow: auto; padding: 8px; border-radius: 4px; background: var(--home-bg); white-space: pre-wrap; }
92
97
  .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
98
  .home-output-actions { flex: none; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
96
99
  .home-status { font-size: 12px; color: var(--home-faint); min-width: 90px; text-align: right; }
97
100
  .home-status--done { color: var(--home-ok); }
@@ -117,17 +120,6 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); ou
117
120
  .home-line { margin-left: auto; flex: none; color: var(--home-faint); font-size: 12px; }
118
121
 
119
122
  /* ── 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
123
 
132
124
  @media (max-width: 760px) {
133
125
  .home > :not(.home-header) { padding-left: 12px; padding-right: 12px; }
@@ -136,12 +128,8 @@ button:focus-visible, a:focus-visible { outline: 2px solid var(--home-focus); ou
136
128
  .home-output { flex-wrap: wrap; }
137
129
  .home-output-actions { width: 100%; justify-content: flex-start; }
138
130
  .home-status { text-align: left; min-width: 0; }
139
- .home-live > div { grid-template-columns: minmax(0, 1fr); gap: 2px; }
140
131
  }
141
132
 
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
133
 
146
134
  /* A view that is open now, next to its title; the section head lists who is connected. */
147
135
  .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; }
@@ -2,12 +2,24 @@ import { h } from 'preact'
2
2
  import { layouts } from 'virtual:mdeck-extensions'
3
3
  import { resolveLayoutProps } from './layoutProps'
4
4
  import { SlideFrame, prepareSlide } from './layoutApi'
5
+ import { includeOf } from '../core/parseSlides.js'
5
6
 
6
7
  // Every layout — built-in or deck-local — arrives through the same registry.
7
8
  export { layouts }
8
9
  export const manifests = Object.fromEntries(Object.entries(layouts).map(([id, t]) => [id, t.manifest]))
9
10
 
10
- export function SlideRenderer(slide) {
11
+ // An include that reaches the page is one the deck was not expanded for: the
12
+ // editor works on the file as written (build/include.js). It shows as a card
13
+ // naming the file, in the editor's own words.
14
+ function includeCard(slide) {
15
+ const include = includeOf(slide.content)
16
+ if (!include) return slide
17
+ const content = `## Slides from \`${include.file}\`\n\n${include.slides ? `Slides ${include.slides} of that file appear` : 'The slides of that file appear'} here when the deck is presented. Edit them in that file.`
18
+ return { ...slide, content, regions: { ...slide.regions, body: { ...slide.regions?.body, name: 'body', content, explicit: false } } }
19
+ }
20
+
21
+ export function SlideRenderer(given) {
22
+ const slide = includeCard(given)
11
23
  const id = slide.meta.layout ?? 'generic'
12
24
  const entry = Object.hasOwn(layouts, id) ? layouts[id] : layouts.generic
13
25
  const prepared = prepareSlide(slide)
@@ -6,7 +6,9 @@
6
6
  }
7
7
  * { box-sizing: border-box; }
8
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; }
9
+ /* The visible height (dvh): on a phone, 100vh is taller than what shows beside
10
+ the browser's bars, and the reactions at the bottom slid out of sight. */
11
+ body { min-height: 100vh; min-height: 100dvh; padding-bottom: env(safe-area-inset-bottom); box-sizing: border-box; display: flex; flex-direction: column; font: 18px/1.45 var(--font-body); -webkit-font-smoothing: antialiased; }
10
12
  #answer { flex: 1; display: flex; flex-direction: column; }
11
13
  .answer { flex: 1; width: 100%; max-width: 520px; margin: 0 auto; padding: 32px 20px; }
12
14
  .answer h1 {
@@ -52,3 +54,12 @@ footer { padding: 16px 20px; text-align: center; color: var(--muted); font-size:
52
54
  .answer-number { display: grid; grid-template-columns: 1fr auto; gap: 12px; }
53
55
  .answer-number input { width: 100%; min-width: 0; padding: 14px; border: 2px solid var(--rule); border-radius: 14px; background: var(--bg); color: var(--ink); font: inherit; font-size: 22px; font-variant-numeric: tabular-nums; }
54
56
  .answer-number button[type=submit] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, var(--bg)); }
57
+ /* Reactions: a row of emoji that float up on the presenter's screen. */
58
+ .answer-reactions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; width: 100%; max-width: 520px; margin: 0 auto; padding: 0 20px 8px; }
59
+ .answer-reactions button {
60
+ min-width: 46px; height: 46px; padding: 0 10px; border: 1px solid var(--rule); border-radius: 23px; background: var(--bg); color: var(--ink);
61
+ font: inherit; font-size: 24px; line-height: 1; cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent;
62
+ }
63
+ .answer-reactions button.is-sent { animation: answer-react .5s ease-out; }
64
+ @keyframes answer-react { 30% { transform: scale(1.25); border-color: var(--accent); } 100% { transform: scale(1); } }
65
+ @media (prefers-reduced-motion: reduce) { .answer-reactions button.is-sent { animation: none; border-color: var(--accent); } }
@@ -12,12 +12,14 @@
12
12
  pickSeveral: 'Tap all that apply.', thanksSeveral: 'Thanks! You chose {choice}. Tap to change.', closed: 'Closed: no more answers.',
13
13
  right: 'Right!', wrong: 'Not quite. The right answer: {answer}', solution: 'The right answer: {answer}',
14
14
  number: 'A number', invalid: 'Enter a number, such as 0.5 or 1/2.', numberSent: 'Sent: {value}. Send another to change.',
15
+ react: 'React',
15
16
  },
16
17
  de: {
17
18
  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…',
18
19
  pickSeveral: 'Alle zutreffenden antippen.', thanksSeveral: 'Danke! Gewählt: {choice}. Zum Ändern antippen.', closed: 'Geschlossen: keine weiteren Antworten.',
19
20
  right: 'Richtig!', wrong: 'Leider nicht. Richtig ist: {answer}', solution: 'Richtig ist: {answer}',
20
21
  number: 'Eine Zahl', invalid: 'Eine Zahl eingeben, z. B. 0,5 oder 1/2.', numberSent: 'Gesendet: {value}. Zum Ändern eine andere senden.',
22
+ react: 'Reagieren',
21
23
  },
22
24
  }
23
25
  let words = FALLBACK[navigator.language?.slice(0, 2) === 'de' ? 'de' : 'en']
@@ -191,17 +193,23 @@
191
193
  solution ? verdict(sent != null, right, drawn(solution.html, solution.text)) : null,
192
194
  ]
193
195
  },
194
- // { question, placeholder, maxLength }: short text, as often as people like.
196
+ // { question, placeholder, maxLength, once }: short text, as often as
197
+ // people like; with `once` (a word cloud) the latest one counts, and the
198
+ // phone says which it sent.
195
199
  text(activity, room) {
196
- const input = el('textarea', { rows: 3, maxlength: activity.maxLength ?? 200, placeholder: activity.placeholder ?? '', disabled: !!controls.closed })
200
+ const sent = activity.once ? remembered(room) : null
201
+ const input = el('textarea', { rows: activity.once ? 1 : 3, maxlength: activity.maxLength ?? 200, placeholder: activity.placeholder ?? '', disabled: !!controls.closed })
197
202
  const form = el('form', { class: 'answer-text', onsubmit: async event => {
198
203
  event.preventDefault()
199
204
  const value = input.value.trim()
200
205
  if (!value) return
201
- try { await answer(room, value); status = words.sent } catch (error) { status = error.message }
206
+ try {
207
+ await answer(room, value)
208
+ if (activity.once) { remember(room, value); status = '' } else status = words.sent
209
+ } catch (error) { status = error.message }
202
210
  render()
203
211
  } }, input, el('button', { type: 'submit', disabled: !!controls.closed }, words.send))
204
- return [form, statusLine('')]
212
+ return [form, statusLine(sent != null ? fill(words.numberSent, { value: sent }) : '')]
205
213
  },
206
214
  // { question, min, max, minLabel, maxLabel }: a number on a scale.
207
215
  scale(activity, room) {
@@ -222,6 +230,25 @@
222
230
  },
223
231
  }
224
232
 
233
+ // Reactions (src/core/reactions.js): a row of buttons whenever the deck
234
+ // offers them; a tap floats up on the presenter's screen. One tap a
235
+ // second at most, so a thumb on a button is not a flood.
236
+ const REACT_MS = 1000
237
+ let reactedAt = 0
238
+ async function react(value, button) {
239
+ const now = Date.now()
240
+ if (now - reactedAt < REACT_MS) return
241
+ reactedAt = now
242
+ button.classList.remove('is-sent')
243
+ void button.offsetWidth
244
+ button.classList.add('is-sent')
245
+ try {
246
+ await fetch(new URL(`rooms/${encodeURIComponent(`${code}.reactions`)}`, base), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: me, data: { value } }) })
247
+ } catch {}
248
+ }
249
+ const reactions = list => el('nav', { class: 'answer-reactions', 'aria-label': words.react ?? 'React' },
250
+ list.map(value => el('button', { type: 'button', 'aria-label': value, onclick: event => react(value, event.currentTarget) }, value)))
251
+
225
252
  function render() {
226
253
  const activity = current?.activity
227
254
  const form = activity && FORMS[activity.type]
@@ -230,6 +257,7 @@
230
257
  form
231
258
  ? el('main', { class: 'answer' }, activity.question && el('h1', {}, drawn(activity.questionHtml, activity.question)), form(activity, current.room))
232
259
  : el('main', { class: 'answer answer--waiting' }, el('p', {}, connected ? words.waiting : words.offline)),
260
+ connected && Array.isArray(current?.reactions) && current.reactions.length ? reactions(current.reactions.filter(value => typeof value === 'string').slice(0, 8)) : null,
233
261
  current?.title ? el('footer', {}, current.title) : null,
234
262
  // The presenter's screen sends the link when other devices can follow the slides.
235
263
  /^https?:\/\//.test(current?.follow ?? '') ? el('a', { class: 'answer-follow', href: current.follow }, words.follow ?? 'Follow the slides') : null,
@@ -13,11 +13,12 @@ import { pairToken, pairHeaders } from './pairing.js'
13
13
  import { keptAnswers, answersAsMessages } from '../core/results.js'
14
14
  import { devPath } from '../core/devPath.js'
15
15
  import { t, deckLanguage } from '../core/labels.js'
16
+ import { reactionsOf, REACTIONS_ROOM } from '../core/reactions.js'
16
17
 
17
18
  // The QR code component the built-in poll uses, for your own activities.
18
19
  export { default as QrCode } from '../components/QrCode.jsx'
19
20
 
20
- let settings = { server: null, code: '000000' }
21
+ let settings = { server: null, code: '000000', reactions: null }
21
22
 
22
23
  // Called by the runtime with the deck settings.
23
24
  // `mdeck run --server <address>` and MDECK_SERVER name the server without the
@@ -26,7 +27,7 @@ const givenServer = typeof __MDECK_SERVER__ === 'string' ? __MDECK_SERVER__ : nu
26
27
 
27
28
  export function configureLive(deckConfig = {}) {
28
29
  const server = givenServer ?? deckConfig.server
29
- settings = { server: typeof server === 'string' ? server.replace(/\/+$/, '') : null, code: sessionCode(deckConfig) }
30
+ settings = { server: typeof server === 'string' ? server.replace(/\/+$/, '') : null, code: sessionCode(deckConfig), reactions: reactionsOf(deckConfig) }
30
31
  infoRequest = null
31
32
  takeKeyFromAddress()
32
33
  }
@@ -211,6 +212,22 @@ export function useRoom(room) {
211
212
  return { messages: kept ? answersAsMessages(kept.answers) : messages, reset, state, setState, present, connected, canReset: !!info.canReset, joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
212
213
  }
213
214
 
215
+ /**
216
+ * Reactions sent from the phones, for the presenter's screen:
217
+ * `onReaction({ value, at, n })` for each one as it arrives. Returns a
218
+ * function that stops listening. Nothing happens without a room server.
219
+ */
220
+ export function listenToReactions(onReaction) {
221
+ if (typeof EventSource === 'undefined') return () => {}
222
+ const source = new EventSource(`${roomPath(REACTIONS_ROOM)}/events`)
223
+ source.addEventListener('message', event => {
224
+ const message = JSON.parse(event.data)
225
+ const value = message.data?.value
226
+ if (typeof value === 'string' && settings.reactions?.includes(value)) onReaction({ value, at: message.at, n: message.n })
227
+ })
228
+ return () => source.close()
229
+ }
230
+
214
231
  /** The deck's join link alone, for a slide that only invites people in (<qrcode join />). */
215
232
  export function useJoinLink() {
216
233
  const [info, setInfo] = useState({})
@@ -259,7 +276,7 @@ export function captureLook(doc = lookSource()) {
259
276
 
260
277
  // The words the answer page shows, in the deck's language.
261
278
  const phoneWords = () => ({
262
- waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent'), follow: t('follow.open'),
279
+ waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent'), follow: t('follow.open'), react: t('respond.react'),
263
280
  pickSeveral: t('poll.pickSeveral'), thanksSeveral: t('poll.thanksSeveral'), several: t('poll.several'),
264
281
  closed: t('respond.closed'), right: t('respond.right'), wrong: t('respond.wrong'), solution: t('respond.solution'),
265
282
  number: t('numeric.placeholder'), invalid: t('numeric.invalid'), numberSent: t('numeric.sent'),
@@ -304,7 +321,9 @@ async function sendCurrent() {
304
321
  if (look) lastLook = look
305
322
  // Phones that answer can also follow the slides, where that works.
306
323
  const follow = followUrl(info)
307
- const state = { ...current, look: lastLook, lang: deckLanguage(), labels: phoneWords(), ...(follow ? { follow } : {}) }
324
+ // The reactions the phones offer, whatever is on the slide.
325
+ const reactions = settings.reactions ? { reactions: settings.reactions } : {}
326
+ const state = { ...current, look: lastLook, lang: deckLanguage(), labels: phoneWords(), ...reactions, ...(follow ? { follow } : {}) }
308
327
  try {
309
328
  const response = await fetch(`${controlSessionPath()}/state`, { method: 'POST', headers: withKey({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state }) })
310
329
  const body = response.ok ? await response.json().catch(() => ({})) : {}
package/src/live/rooms.js CHANGED
@@ -9,11 +9,17 @@ import { timingSafeEqual } from 'node:crypto'
9
9
  import { readFileSync } from 'node:fs'
10
10
  import { CODE_RE } from './code.js'
11
11
 
12
+ // This mdeck's version, in /info: a presenter on a newer mdeck can tell that
13
+ // this server lacks what its deck uses (src/build/homePlugin.js).
14
+ const VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version
15
+
12
16
  // The phones' answer page, served by the server itself at /<code>.
13
17
  const ANSWER_FILES = Object.fromEntries(['answer.html', 'answer.js', 'answer.css'].map(name => [name, readFileSync(new URL(`./answer/${name}`, import.meta.url))]))
14
18
  const ANSWER_TYPES = { html: 'text/html; charset=utf-8', js: 'text/javascript; charset=utf-8', css: 'text/css; charset=utf-8' }
15
19
 
16
20
  export const ROOM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
21
+ // A session's reactions room: `<code>.reactions`.
22
+ const isReactionRoom = id => id.endsWith('.reactions')
17
23
  // What a connected window may say it is, for the presenter's overview of
18
24
  // who is connected (src/components/ConnectedViews.jsx); phones only count.
19
25
  export const VIEWS = ['presenter', 'audience', 'deck', 'phone']
@@ -37,18 +43,30 @@ export function createRooms({ maxMessages = 5000, maxInk = 5000, maxRooms = 500,
37
43
  const broadcast = (room, event) => { for (const listener of room.listeners) listener(event) }
38
44
  // A session is a room and the rooms named after it (`<code>.stage`, …).
39
45
  const sessionRooms = id => { const session = id.split('.')[0]; return [...rooms].filter(([name]) => name === session || name.startsWith(`${session}.`)).map(([, room]) => room) }
46
+ // Phones connected elsewhere, by session: mdeck run keeps the stage room
47
+ // here but the phones join a standalone server (src/live/server.js).
48
+ const awayPhones = new Map()
40
49
  const presence = id => {
41
50
  const present = sessionRooms(id).flatMap(room => [...room.present.values()])
42
51
  const views = present.filter(entry => entry.view !== 'phone').sort((a, b) => VIEWS.indexOf(a.view) - VIEWS.indexOf(b.view))
43
- return { views, phones: present.length - views.length }
52
+ return { views, phones: present.length - views.length + (awayPhones.get(id.split('.')[0]) ?? 0) }
44
53
  }
45
54
  const announcePresence = id => { const event = { type: 'presence', presence: presence(id) }; for (const room of sessionRooms(id)) broadcast(room, event) }
46
55
  return {
56
+ /** Phones of session `id` that joined another server; announced when the number changes. */
57
+ setAwayPhones(id, n) {
58
+ const session = id.split('.')[0]
59
+ if ((awayPhones.get(session) ?? 0) === n) return
60
+ awayPhones.set(session, n)
61
+ announcePresence(session)
62
+ },
47
63
  post(id, { from = null, data }) {
48
64
  const room = get(id)
49
- if (room.messages.length >= maxMessages) throw Object.assign(new Error('This room is full'), { status: 429 })
65
+ // Reactions (src/core/reactions.js) are passed on and forgotten.
66
+ const keep = !isReactionRoom(id)
67
+ if (keep && room.messages.length >= maxMessages) throw Object.assign(new Error('This room is full'), { status: 429 })
50
68
  const message = { n: room.next++, at: new Date(now()).toISOString(), from, data }
51
- room.messages.push(message)
69
+ if (keep) room.messages.push(message)
52
70
  broadcast(room, { type: 'message', message })
53
71
  return message
54
72
  },
@@ -181,12 +199,13 @@ function reply(response, status, body) {
181
199
  // ?presence=1 adds who is connected to the session
182
200
  // ?only=state sends the room's state alone
183
201
  // GET /rooms/<id>/presence who is connected to the session, once
184
- // POST /rooms/<id> { from, data } → { n }
202
+ // POST /rooms/<id> { from, data } → { n }; in <code>.reactions
203
+ // passed on without being kept
185
204
  // POST /rooms/<id>/reset presenter only
186
205
  // POST /rooms/<id>/state { state } — presenter only
187
206
  // POST /rooms/<id>/ink { messages } — presenter only, live ink
188
207
  // Answers carry no cookies and no personal data, so any page may use them.
189
- export function liveHandler({ rooms = createRooms(), canReset = () => false, info = () => ({}), limit = createLimiter(), inkLimit = createLimiter({ burst: 60, perMs: 3000 }), heartbeatMs = 20000 } = {}) {
208
+ export function liveHandler({ rooms = createRooms(), canReset = () => false, info = () => ({}), limit = createLimiter(), inkLimit = createLimiter({ burst: 60, perMs: 3000 }), reactionLimit = createLimiter({ burst: 120, perMs: 10000 }), heartbeatMs = 20000 } = {}) {
190
209
  return async (request, response, next = () => reply(response, 404, { error: 'Not found' })) => {
191
210
  const { pathname, searchParams } = new URL(request.url, 'http://localhost')
192
211
  response.setHeader('Access-Control-Allow-Origin', '*')
@@ -194,7 +213,7 @@ export function liveHandler({ rooms = createRooms(), canReset = () => false, inf
194
213
  response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
195
214
  if (request.method === 'OPTIONS') { response.writeHead(204); response.end(); return }
196
215
  try {
197
- if (pathname === '/info' && request.method === 'GET') return reply(response, 200, { canReset: !!canReset(request, searchParams.get('session')), ...info(request) })
216
+ if (pathname === '/info' && request.method === 'GET') return reply(response, 200, { canReset: !!canReset(request, searchParams.get('session')), version: VERSION, ...info(request) })
198
217
  const page = pathname.slice(1)
199
218
  if (request.method === 'GET' && (CODE_RE.test(page) || Object.hasOwn(ANSWER_FILES, page))) {
200
219
  const name = CODE_RE.test(page) ? 'answer.html' : page
@@ -240,8 +259,11 @@ export function liveHandler({ rooms = createRooms(), canReset = () => false, inf
240
259
  else if (!rooms.setState(id, (await readBody(request, MAX_STATE)).state)) return reply(response, 200, { ok: true, ignored: true })
241
260
  return reply(response, 200, { ok: true })
242
261
  }
243
- if (!limit(clientAddress(request))) return reply(response, 429, { error: 'Too many answers at once; wait a moment' })
262
+ // Reactions have a limit of their own: a room behind one address (a
263
+ // lecture hall's network) must not use up its answers with them.
264
+ if (!(isReactionRoom(id) ? reactionLimit : limit)(clientAddress(request))) return reply(response, 429, { error: 'Too many answers at once; wait a moment' })
244
265
  const body = await readBody(request)
266
+ if (isReactionRoom(id) && (typeof body.data?.value !== 'string' || body.data.value.length > 16)) return reply(response, 400, { error: 'A reaction is a short text' })
245
267
  if (!('data' in body)) return reply(response, 400, { error: 'Send { data }' })
246
268
  const from = typeof body.from === 'string' ? body.from.slice(0, 64) : null
247
269
  return reply(response, 201, { n: rooms.post(id, { from, data: body.data }).n })
@@ -14,11 +14,25 @@ export function livePlugin({ pairing = null, upstream = null, key = null, sessio
14
14
  return {
15
15
  name: 'vite-plugin-mdeck-live',
16
16
  configureServer(server) {
17
+ const rooms = createRooms()
17
18
  const handler = liveHandler({
18
- rooms: createRooms(),
19
+ rooms,
19
20
  canReset: (request, room) => isAllowedRequest(request) || (!!pairing?.allows(request) && (room == null || room === session() || room.startsWith(`${session()}.`))),
20
21
  info: () => ({ network: server.resolvedUrls?.network?.[0] ?? null }),
21
22
  })
23
+ // With polls on a standalone server, the phones join there: its count
24
+ // joins the local stage room's, so the presenter sees them.
25
+ const awayTimer = setInterval(async () => {
26
+ const address = typeof upstream === 'function' ? upstream() : upstream
27
+ const code = session()
28
+ if (!address || !code) return
29
+ try {
30
+ const response = await fetch(`${address.replace(/\/+$/, '')}/rooms/${encodeURIComponent(code)}.stage/presence`, { signal: AbortSignal.timeout(3000) })
31
+ if (response.ok) rooms.setAwayPhones(code, Number((await response.json()).phones) || 0)
32
+ } catch {}
33
+ }, 4000)
34
+ awayTimer.unref?.()
35
+ server.httpServer?.once('close', () => clearInterval(awayTimer))
22
36
  server.middlewares.use('/__mdeck/live', (request, response, next) => {
23
37
  const address = typeof upstream === 'function' ? upstream() : upstream
24
38
  // The stage room (position and live ink between the presenter's