mdeck 3.3.0 → 3.4.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 (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/assets/base.css +23 -2
  3. package/assets/extensions/themes/academic/styles.css +18 -5
  4. package/assets/extensions/themes/aurora/styles.css +32 -6
  5. package/assets/extensions/themes/minimal/styles.css +16 -4
  6. package/assets/extensions/themes/neue/styles.css +17 -4
  7. package/docs/reference/authoring.md +27 -4
  8. package/docs/site/content/audience.md +35 -4
  9. package/docs/site/content/commands.md +2 -0
  10. package/package.json +1 -1
  11. package/skills/write-slides/SKILL.md +1 -1
  12. package/src/build/bibliography.js +29 -11
  13. package/src/build/resultsPlugin.js +77 -0
  14. package/src/build/slidesPlugin.js +28 -5
  15. package/src/build/themeCheck.js +2 -1
  16. package/src/cli/args.js +2 -1
  17. package/src/cli/index.js +17 -4
  18. package/src/components/Numeric.jsx +59 -0
  19. package/src/components/Poll.jsx +58 -55
  20. package/src/components/Question.jsx +10 -6
  21. package/src/components/Scale.jsx +13 -8
  22. package/src/components/WordCloud.jsx +10 -6
  23. package/src/components/activity.jsx +88 -7
  24. package/src/components/inlineText.js +40 -0
  25. package/src/components/numbers.js +39 -0
  26. package/src/components/poll.css +23 -13
  27. package/src/core/editDeck.js +2 -2
  28. package/src/core/icons.js +2 -0
  29. package/src/core/labels.js +32 -0
  30. package/src/core/results.js +64 -0
  31. package/src/editor/sampleDeck.js +34 -8
  32. package/src/layouts/layoutApi.jsx +4 -3
  33. package/src/live/answer/answer.css +19 -0
  34. package/src/live/answer/answer.js +147 -24
  35. package/src/live/client.js +51 -6
  36. package/src/live/roomTag.js +6 -2
  37. package/src/live/rooms.js +6 -1
  38. package/src/runtime/citations.js +15 -5
  39. package/src/runtime/ink/bus.js +7 -13
  40. package/src/runtime/main.jsx +3 -1
  41. package/src/runtime/registry.jsx +2 -0
@@ -1,15 +1,25 @@
1
1
  import { h } from 'preact'
2
+ import { useEffect, useRef, useState } from 'preact/hooks'
2
3
  import QrCode from './QrCode.jsx'
4
+ import { Icon } from './Icon.jsx'
3
5
  import { t } from '../core/labels.js'
6
+ import { keepAnswers } from '../live/client.js'
7
+ import { slideHtml } from './inlineText.js'
8
+ import 'katex/dist/katex.min.css'
4
9
 
5
10
  // Parts every activity on a slide shares (<poll>, <question>, <wordcloud>,
6
- // <scale>, and <qrcode join />): the deck's join code and the line with the number of
7
- // answers, the live dot and Reset.
11
+ // <scale>, <numeric> and <qrcode join />): the deck's join code, the line with
12
+ // the number of answers, the live dot, Reset and the presenter's controls.
8
13
 
9
14
  export const shortLink = url => url.replace(/^https?:\/\//, '')
10
15
  // `qr="false"` (or "no", "off") leaves the code out: it was on an earlier
11
16
  // slide (<qrcode join />), and people's phones follow along.
12
17
  export const wantsQr = qr => !/^(false|no|off|0)$/i.test(String(qr ?? 'true'))
18
+ // A switch such as `multiple`: on when given, unless it says false.
19
+ export const isSet = value => value != null && !/^(false|no|off|0)$/i.test(String(value))
20
+
21
+ /** An activity's question, Markdown with $…$ maths. */
22
+ export const ActivityQuestion = ({ text }) => text ? <p class="poll-question" dangerouslySetInnerHTML={{ __html: slideHtml(text) }} /> : null
13
23
 
14
24
  /** The join code with the short link, or how to make phones reach the server. */
15
25
  export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = true, size = 300, label = t('poll.scan'), tryHere = t('poll.tryHere') }) {
@@ -22,14 +32,85 @@ export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = tru
22
32
  </a>
23
33
  }
24
34
 
25
- /** Number of answers, whether the room is live, and Reset for the presenter; `children` go at the end. */
26
- export function ActivityFooter({ count, connected, canReset, reset, offline, children }) {
35
+ // What every screen of the talk shares about an activity: whether it still
36
+ // takes answers (`closed`; `upTo` is the last answer that counts), whether
37
+ // its right answer shows (`revealed`) and whether its results show. The
38
+ // activity room's state on the server holds them, so the audience window, an
39
+ // iPad, a reloaded presenter view and the phones follow: phones say the
40
+ // activity is closed and, once the answer shows, whether theirs was right
41
+ // (`solution`, sent only then). Between windows of one browser, and where
42
+ // this one may not change the room, the stage event `pollcontrols` carries
43
+ // them too (src/runtime/ink/bus.js).
44
+ const CONTROLS = ['closed', 'upTo', 'revealed', 'results']
45
+ const controlsIn = state => Object.fromEntries(CONTROLS.filter(key => state && typeof state === 'object' && key in state).map(key => [key, state[key]]))
46
+
47
+ export function useControls(room, live, { results = true, solution = null } = {}) {
48
+ const ref = useRef()
49
+ const [controls, setControls] = useState({ closed: false, upTo: 0, revealed: false, results })
50
+ const kept = JSON.stringify(controlsIn(live.state))
51
+ useEffect(() => { if (kept !== '{}') setControls(now => ({ ...now, ...JSON.parse(kept) })) }, [kept])
52
+ useEffect(() => {
53
+ const stage = ref.current?.closest('deck-stage')
54
+ if (!stage) return
55
+ const follow = event => { if (event.detail?.room === room && !event.detail.local) setControls(now => ({ ...now, ...controlsIn(event.detail.controls) })) }
56
+ stage.addEventListener('pollcontrols', follow)
57
+ return () => stage.removeEventListener('pollcontrols', follow)
58
+ }, [room])
59
+ const change = patch => {
60
+ const next = { ...controls, ...patch }
61
+ setControls(next)
62
+ ref.current?.closest('deck-stage')?.dispatchEvent(new CustomEvent('pollcontrols', { detail: { room, controls: next, local: true } }))
63
+ if (live.canReset) live.setState({ ...next, ...(next.revealed && solution != null ? { solution } : {}) }).catch(() => {})
64
+ }
65
+ // The answers that count: once closed, those that came before.
66
+ const counted = messages => controls.closed ? messages.filter(message => message.n <= controls.upTo) : messages
67
+ // They are kept beside the deck while `mdeck run` runs. A room that
68
+ // starts empty (a new server) leaves what was kept alone; one that was
69
+ // reset takes it out.
70
+ const answered = useRef(false)
71
+ const counting = counted(live.messages)
72
+ useEffect(() => {
73
+ if (!live.canReset || !live.connected || live.offline || (!counting.length && !answered.current)) return
74
+ answered.current = true
75
+ keepAnswers(room, counting, { closed: controls.closed })
76
+ }, [room, counting.length, counting.at(-1)?.n, controls.closed, live.canReset, live.connected])
77
+ const close = messages => change(controls.closed ? { closed: false } : { closed: true, upTo: Math.max(0, ...messages.map(message => message.n)) })
78
+ // Reset starts over: open, the answer and results as at the start.
79
+ const reset = () => { change({ closed: false, upTo: 0, revealed: false, results }); return live.reset() }
80
+ return { ...controls, ref, change, counted, close, reset }
81
+ }
82
+
83
+ /**
84
+ * The presenter's buttons: close (a lock), show or hide the results (an
85
+ * eye, with `results`), show the right answer (a tick, with `solve`).
86
+ * A record of the talk (`offline`) can still show the answer and results.
87
+ */
88
+ export function ActivityControls({ controls, messages, offline = false, results = false, solve = false }) {
89
+ const button = (name, on, label, onClick, icon) => <button type="button" class={`poll-control poll-${name}${on ? ' is-on' : ''}`} title={label} aria-label={label} aria-pressed={on} onClick={onClick}>{icon}</button>
90
+ return <>
91
+ {!offline && button('close', controls.closed, t(controls.closed ? 'poll.open' : 'poll.close'), () => controls.close(messages), <Icon name={controls.closed ? 'lock' : 'lock-open'} size={22} />)}
92
+ {results && button('results', !controls.results, t(controls.results ? 'poll.hideResults' : 'poll.showResults'), () => controls.change({ results: !controls.results }), <Icon name={controls.results ? 'eye' : 'eye-off'} size={24} />)}
93
+ {solve && button('solve', controls.revealed, t(controls.revealed ? 'poll.hideAnswer' : 'poll.showAnswer'), () => controls.change({ revealed: !controls.revealed }), <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10" /></svg>)}
94
+ </>
95
+ }
96
+
97
+ /**
98
+ * Number of answers, whether the room is live, and Reset for the presenter;
99
+ * `children` go at the end. With `perPhone` (one answer per phone) the
100
+ * count says of how many phones; `closed` says the activity takes no more.
101
+ */
102
+ export function ActivityFooter({ count, connected, canReset, reset, offline, present = null, perPhone = false, closed = false, children }) {
27
103
  // Without a server this is a record of the talk: no count, dot or buttons.
28
104
  if (offline && !count) return <p class="poll-total poll-static">{t('poll.static')}</p>
105
+ // Followers count as phones too; with more answers than phones, some left.
106
+ const of = perPhone && !offline && present > 0 && present >= count ? present : null
29
107
  return <p class="poll-total">
30
- <span class={`poll-dot${connected ? ' is-live' : ''}`} title={connected ? t('poll.live') : t('poll.offline')} />
31
- {count === 1 ? t('poll.answer') : t('poll.answers', { n: count })}
32
- {canReset && <button class="poll-reset" disabled={count === 0} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
108
+ {/* The answers kept in the talk, without a server: no live dot. */}
109
+ {!offline && <span class={`poll-dot${connected ? ' is-live' : ''}`} title={connected ? t('poll.live') : t('poll.offline')} />}
110
+ {of != null ? t('poll.answeredOf', { n: count, of }) : count === 1 ? t('poll.answer') : t('poll.answers', { n: count })}
111
+ {closed && <span class="poll-closed">{t('poll.closed')}</span>}
112
+ <span class="poll-spacer" />
113
+ {canReset && <button class="poll-reset" disabled={count === 0 && !closed} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
33
114
  {children}
34
115
  </p>
35
116
  }
@@ -0,0 +1,40 @@
1
+ import { Marked } from 'marked'
2
+ import markedKatex from 'marked-katex-extension'
3
+
4
+ // Short texts in a tag's attributes (a poll's question and options) as one
5
+ // line of Markdown with $…$ maths, like the text on a slide. The slide gets
6
+ // KaTeX's HTML; the phones' answer page has no KaTeX, so it gets MathML,
7
+ // which browsers draw themselves (src/live/answer/answer.js).
8
+ const slide = new Marked().use(markedKatex({ throwOnError: false, output: 'html' }))
9
+ const phone = new Marked().use(markedKatex({ throwOnError: false, output: 'mathml' }))
10
+
11
+ export const slideHtml = text => slide.parseInline(String(text ?? ''))
12
+ export const phoneHtml = text => phone.parseInline(String(text ?? ''))
13
+
14
+ /** An activity's question for the phones: the text, and drawn when the tag has one (else the slide's heading, as text). */
15
+ export const phoneQuestion = (question, slideTitle) => question ? { question, questionHtml: phoneHtml(question) } : { question: slideTitle }
16
+
17
+ // A formula, as KaTeX's inline rule finds it: $…$ or $$…$$, not starting
18
+ // with a space, and ending before a space, punctuation, a bar or the end.
19
+ const MATH = /^(\${1,2})(?!\$|\s)(?:\\.|[^\\\n$])*?\1(?=[\s?!.,:|]|$)/
20
+
21
+ /**
22
+ * "A|B|C" as ["A", "B", "C"]. A bar in a formula belongs to it ($|x|$,
23
+ * $\{x \mid x > 0\}$), and \| is a bar in the text. "$5|$10" is two
24
+ * options: neither $ starts a formula.
25
+ */
26
+ export function choicesOf(options) {
27
+ const text = String(options ?? '')
28
+ const parts = []
29
+ let part = ''
30
+ for (let i = 0; i < text.length; i++) {
31
+ const math = text[i] === '$' && text.slice(i).match(MATH)
32
+ if (math) { part += math[0]; i += math[0].length - 1 }
33
+ else if (text[i] === '\\' && text[i + 1] === '|') { part += '|'; i++ }
34
+ else if (text[i] === '\\' && i + 1 < text.length) { part += text[i] + text[i + 1]; i++ }
35
+ else if (text[i] === '|') { parts.push(part); part = '' }
36
+ else part += text[i]
37
+ }
38
+ parts.push(part)
39
+ return parts.map(choice => choice.trim()).filter(Boolean)
40
+ }
@@ -0,0 +1,39 @@
1
+ // Numbers typed on a phone for <numeric>. The answer page has its own copy
2
+ // of parseNumber (src/live/answer/answer.js), as it has no build step.
3
+
4
+ /** 0.5, 0,5, −3, 1e-3 or 1/2 as a number; null for anything else. */
5
+ export function parseNumber(text) {
6
+ const plain = String(text ?? '').trim().replace(/\s+/g, '').replace(/−/g, '-').replace(/(\d),(\d)/g, '$1.$2')
7
+ const parts = plain.match(/^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(?:\/((?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?))?$/i)
8
+ if (!parts) return null
9
+ const value = parts[2] ? Number(parts[1]) / Number(parts[2]) : Number(parts[1])
10
+ return Number.isFinite(value) ? value : null
11
+ }
12
+
13
+ /**
14
+ * The answers grouped by value, most frequent first: [{ value, text, n }].
15
+ * `text` is how most people typed it (0.5, or 1/2). Values that differ only
16
+ * in the last digits of a float (1/3 and 0.333333333333) count as one.
17
+ */
18
+ export function groupNumbers(texts) {
19
+ const groups = new Map()
20
+ for (const text of texts) {
21
+ const value = parseNumber(text)
22
+ if (value == null) continue
23
+ const key = Number(value.toPrecision(12))
24
+ const group = groups.get(key) ?? { value, n: 0, spellings: new Map() }
25
+ const spelling = String(text).trim()
26
+ group.n++
27
+ group.spellings.set(spelling, (group.spellings.get(spelling) ?? 0) + 1)
28
+ groups.set(key, group)
29
+ }
30
+ return [...groups.values()]
31
+ .map(({ value, n, spellings }) => ({ value, n, text: [...spellings].sort((a, b) => b[1] - a[1])[0][0] }))
32
+ .sort((a, b) => b.n - a.n || a.value - b.value)
33
+ }
34
+
35
+ /** Whether `value` is the answer, give or take `tolerance` (0: equal, as far as floats go). */
36
+ export function isRightNumber(value, answer, tolerance = 0) {
37
+ if (value == null || answer == null) return false
38
+ return Math.abs(value - answer) <= Math.max(tolerance, 1e-9 * Math.max(1, Math.abs(answer)))
39
+ }
@@ -5,32 +5,37 @@
5
5
  .poll-bars { display: grid; gap: 20px; }
6
6
  .poll-row { display: grid; grid-template-columns: minmax(120px, 28%) 1fr 3ch; gap: 24px; align-items: center; }
7
7
  .poll-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
8
+ /* buttons="letters|numbers": the key phones show, before the option. */
9
+ .poll-key { display: inline-grid; place-items: center; min-width: 1.6em; height: 1.6em; margin-right: .5em; padding: 0 .3em; border-radius: .4em; background: color-mix(in srgb, var(--accent, #0d9488) 14%, transparent); color: var(--accent, #0d9488); font-weight: 700; font-size: .8em; vertical-align: .1em; }
10
+ /* A picture as an option: a thumbnail beside its bar. */
11
+ .poll-label:has(img) { white-space: normal; }
12
+ .poll-label img { max-height: 3.2em; max-width: 100%; vertical-align: middle; }
13
+ .poll .poll-several { margin: -20px 0 24px; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
8
14
  .poll-track { height: 1.1em; background: color-mix(in srgb, var(--ink, #111) 8%, transparent); border-radius: 6px; overflow: hidden; }
9
15
  .poll-fill { display: block; height: 100%; background: var(--accent, #0d9488); border-radius: 6px; transition: width .6s cubic-bezier(.2, .8, .2, 1); }
10
16
  .poll-count { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
11
17
  .poll .poll-total { display: flex; align-items: center; gap: 14px; margin: 36px 0 0; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
12
18
  .poll-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted, #999); opacity: .5; }
13
19
  .poll-dot.is-live { background: #16a34a; opacity: 1; }
14
- .poll-reset { margin-left: auto; padding: 4px 14px; border: 1px solid var(--rule, #ccc); border-radius: 6px; background: none; color: inherit; font: inherit; cursor: pointer; }
20
+ .poll-spacer { flex: 1; }
21
+ .poll-reset { padding: 4px 14px; border: 1px solid var(--rule, #ccc); border-radius: 6px; background: none; color: inherit; font: inherit; cursor: pointer; }
15
22
  .poll-reset:disabled { opacity: .4; cursor: default; }
16
23
  /* The audience sees the results, not the presenter's controls. */
17
24
  deck-stage[data-follower] .poll-reset { display: none; }
18
25
  /* answer="…": the tick outlines the right answer's row (label, bar, count) in green. */
19
- .poll-solve { display: grid; place-items: center; width: 44px; height: 44px; margin-left: auto; padding: 0; border: 1px solid var(--rule, #ccc); border-radius: 50%; background: none; color: var(--muted, #666); cursor: pointer; opacity: .55; transition: opacity .2s, background .2s, color .2s; }
20
- .poll-reset + .poll-solve { margin-left: 0; }
21
- .poll-results { display: grid; place-items: center; width: 44px; height: 44px; margin-left: auto; padding: 0; border: 1px solid var(--rule, #ccc); border-radius: 50%; background: none; color: var(--muted, #666); cursor: pointer; opacity: .55; transition: opacity .2s, background .2s, color .2s; }
22
- .poll-reset + .poll-results, .poll-results + .poll-solve { margin-left: 0; }
23
- .poll-results:hover, .poll-results:focus-visible { opacity: 1; }
24
- .poll-results.is-off { opacity: 1; background: var(--muted, #666); border-color: var(--muted, #666); color: #fff; }
25
- deck-stage[data-follower] .poll-results { display: none; }
26
+ /* The presenter's controls: close (lock), results (eye), right answer (tick). */
27
+ .poll-control { display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--rule, #ccc); border-radius: 50%; background: none; color: var(--muted, #666); cursor: pointer; opacity: .55; transition: opacity .2s, background .2s, color .2s; }
28
+ .poll-control:hover, .poll-control:focus-visible { opacity: 1; }
29
+ .poll-close.is-on, .poll-results.is-on { opacity: 1; background: var(--muted, #666); border-color: var(--muted, #666); color: #fff; }
30
+ deck-stage[data-follower] .poll-control { display: none; }
31
+ /* Closed: no more answers count. */
32
+ .poll-closed { padding: 2px 12px; border-radius: 999px; background: color-mix(in srgb, var(--ink, #111) 10%, transparent); color: var(--ink, #111); font-weight: 600; }
26
33
  /* results="hidden" or the eye off: the presenter's screens show the results faint and hatched, the audience none. */
27
34
  .poll.is-results-hidden .poll-fill { opacity: .35; background: repeating-linear-gradient(45deg, var(--accent, #0d9488) 0 8px, transparent 8px 14px); }
28
35
  .poll.is-results-hidden .poll-count { opacity: .35; }
29
36
  deck-stage[data-follower] .poll.is-results-hidden .poll-fill, deck-stage[data-follower] .poll.is-results-hidden .poll-count { visibility: hidden; }
30
37
  .poll-solve svg { width: 24px; height: 24px; }
31
- .poll-solve:hover, .poll-solve:focus-visible { opacity: 1; }
32
38
  .poll-solve.is-on { opacity: 1; background: var(--poll-correct, #16a34a); border-color: var(--poll-correct, #16a34a); color: #fff; }
33
- deck-stage[data-follower] .poll-solve { display: none; }
34
39
  .poll.is-solved .poll-row.is-correct { outline: 4px solid var(--poll-correct, #16a34a); outline-offset: 10px; border-radius: 10px; }
35
40
  .poll.is-solved .poll-row.is-correct .poll-label { font-weight: 700; color: var(--poll-correct, #16a34a); }
36
41
  .poll-link { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; color: var(--muted, #555); }
@@ -76,16 +81,21 @@ deck-stage[data-follower] .poll-solve { display: none; }
76
81
  .poll-join-slide { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; }
77
82
  .poll-join-slide .poll-join { font-size: var(--fs-body, 32px); gap: 16px; padding: 24px 32px; }
78
83
  .poll-join-slide .poll-link { font-size: 28px; }
79
- @media print { .poll-reset, .poll-dot, .poll-solve, .poll-results { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
84
+ @media print { .poll-reset, .poll-dot, .poll-control { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
80
85
 
81
86
  /* A record of the talk (no room server: a PDF, a file sent to readers): the
82
87
  question and its options, without counts, the live dot or a join code. */
83
88
  .poll.is-static .poll-count, .poll.is-static .scale-count { visibility: hidden; }
84
- .poll.is-static .poll-results { display: none; }
89
+ .poll.is-static .poll-close { display: none; }
85
90
  .poll .poll-static, .poll-join-slide .poll-static { margin: 36px 0 0; color: var(--muted, #666); font-style: italic; font-size: var(--fs-small, 24px); }
86
91
  .poll-join-slide .poll-static { margin: 0; font-size: var(--fs-body, 32px); }
87
92
 
88
93
  /* On paper the join code and the presenter's controls have no use. */
89
94
  @media print {
90
- .poll-join, .poll-reset, .poll-solve, .poll-dot, .poll-results { display: none !important; }
95
+ .poll-join, .poll-reset, .poll-dot, .poll-control { display: none !important; }
91
96
  }
97
+
98
+ /* <numeric>: the most frequent answers; Other for the rest. */
99
+ .poll-row.is-other .poll-label { color: var(--muted, #666); font-style: italic; }
100
+ .poll--numeric .poll-label { font-variant-numeric: tabular-nums; }
101
+ .poll .numeric-answer { margin: 24px 0 0; font-weight: 600; color: var(--poll-correct, #16a34a); }
@@ -233,7 +233,7 @@ export function stripNotes(source) {
233
233
  // The live activities, and the code to join them (<qrcode join />): a slide
234
234
  // with one of them is left out of shared builds. <qrcode follow /> and other
235
235
  // QR codes stay.
236
- const ACTIVITY_RE = /<(poll|question|wordcloud|scale)\b/i
236
+ const ACTIVITY_RE = /<(poll|question|wordcloud|scale|numeric)\b/i
237
237
  const JOIN_RE = /<qrcode\b[^>]*?\sjoin(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\{?([^\s/>}]*)\}?))?(?=[\s/>])/i
238
238
  const joins = text => { const m = JOIN_RE.exec(text); return !!m && !/^(false|no|off|0)$/i.test(m[1] ?? m[2] ?? m[3] ?? '') }
239
239
  export function hasActivity(slide) {
@@ -242,7 +242,7 @@ export function hasActivity(slide) {
242
242
  }
243
243
 
244
244
  // Removes every slide with a live activity (<poll>, <question>, <wordcloud>,
245
- // <scale>) for shared builds: a reader cannot vote, and the results belong to
245
+ // <scale>, <numeric>) for shared builds: a reader cannot vote, and the results belong to
246
246
  // the talk. Returns { source, ids, removed }: `ids` maps each kept slide's id
247
247
  // to its id afterwards (an automatic `slide-N` moves up), `removed` the ids
248
248
  // that are gone, so saved drawings can follow their slides.
package/src/core/icons.js CHANGED
@@ -41,6 +41,8 @@ export const ICONS = {
41
41
  trash: '<path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>', // trash-2
42
42
  eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>', // eye
43
43
  'eye-off': '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>', // eye-off
44
+ lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', // lock
45
+ 'lock-open': '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>', // lock-open
44
46
  finger: '<path d="M22 14a8 8 0 0 1-8 8"/><path d="M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1"/><path d="M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10"/><path d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>', // pointer
45
47
  pen: '<path d="M13 21h8"/><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/>', // pen-line
46
48
  highlighter: '<path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>', // highlighter
@@ -55,6 +55,22 @@ export const LABELS = {
55
55
  'follow.waiting': 'The talk has not started yet.',
56
56
  'follow.tryHere': 'Try following here',
57
57
  'join.static': 'During the talk, phones joined here to answer.',
58
+ 'poll.answeredOf': '{n} of {of} answered',
59
+ 'poll.close': 'Close: later answers do not count',
60
+ 'poll.open': 'Open again',
61
+ 'poll.closed': 'Closed',
62
+ 'poll.several': 'Several answers possible.',
63
+ 'poll.pickSeveral': 'Tap all that apply.',
64
+ 'poll.thanksSeveral': 'Thanks! You chose {choice}. Tap to change.',
65
+ 'respond.closed': 'Closed: no more answers.',
66
+ 'respond.right': 'Right!',
67
+ 'respond.wrong': 'Not quite. The right answer: {answer}',
68
+ 'respond.solution': 'The right answer: {answer}',
69
+ 'numeric.placeholder': 'A number',
70
+ 'numeric.invalid': 'Enter a number, such as 0.5 or 1/2.',
71
+ 'numeric.sent': 'Sent: {value}. Send another to change.',
72
+ 'numeric.other': 'Other',
73
+ 'numeric.right': '{n} right',
58
74
  'question.empty': 'Answers appear here.',
59
75
  'scale.average': 'Average {n}',
60
76
  },
@@ -108,6 +124,22 @@ export const LABELS = {
108
124
  'follow.waiting': 'Der Vortrag hat noch nicht begonnen.',
109
125
  'follow.tryHere': 'Mitverfolgen hier ausprobieren',
110
126
  'join.static': 'Im Vortrag haben sich hier die Handys zum Antworten verbunden.',
127
+ 'poll.answeredOf': '{n} von {of} haben geantwortet',
128
+ 'poll.close': 'Schliessen: spätere Antworten zählen nicht',
129
+ 'poll.open': 'Wieder öffnen',
130
+ 'poll.closed': 'Geschlossen',
131
+ 'poll.several': 'Mehrere Antworten möglich.',
132
+ 'poll.pickSeveral': 'Alle zutreffenden antippen.',
133
+ 'poll.thanksSeveral': 'Danke! Gewählt: {choice}. Zum Ändern antippen.',
134
+ 'respond.closed': 'Geschlossen: keine weiteren Antworten.',
135
+ 'respond.right': 'Richtig!',
136
+ 'respond.wrong': 'Leider nicht. Richtig ist: {answer}',
137
+ 'respond.solution': 'Richtig ist: {answer}',
138
+ 'numeric.placeholder': 'Eine Zahl',
139
+ 'numeric.invalid': 'Eine Zahl eingeben, z. B. 0,5 oder 1/2.',
140
+ 'numeric.sent': 'Gesendet: {value}. Zum Ändern eine andere senden.',
141
+ 'numeric.other': 'Andere',
142
+ 'numeric.right': '{n} richtig',
111
143
  'question.empty': 'Hier erscheinen die Antworten.',
112
144
  'scale.average': 'Durchschnitt {n}',
113
145
  },
@@ -0,0 +1,64 @@
1
+ // The answers given during a talk, kept beside the deck as
2
+ // <deck>.results.json, so they outlast the room server (which forgets them)
3
+ // and show in builds, PDFs and files sent to readers.
4
+ //
5
+ // { "version": 1, "rooms": { "<room>": { "savedAt": "…", "closed": false,
6
+ // "answers": [{ "phone": 1, "at": "…", "value": … }] } } }
7
+ //
8
+ // Phones are numbered per room in the order they first answered; their own
9
+ // ids never leave the room server. `answers` are those that counted: once an
10
+ // activity was closed, the ones before.
11
+
12
+ export const resultsFileFor = deckPath => String(deckPath).replace(/\.md$/i, '') + '.results.json'
13
+
14
+ const ROOM_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
15
+
16
+ /** One room's answers as kept: room messages ({ n, at, from, data }) with numbered phones. */
17
+ export function keptAnswers(messages) {
18
+ const phones = new Map()
19
+ return messages.map(message => {
20
+ const from = message.from ?? `#${message.n}`
21
+ if (!phones.has(from)) phones.set(from, phones.size + 1)
22
+ return { phone: phones.get(from), at: message.at ?? null, value: message.data?.value ?? null }
23
+ })
24
+ }
25
+
26
+ /** Kept answers as room messages again, for the slides that show them. */
27
+ export const answersAsMessages = answers => answers.map((answer, i) => ({ n: i + 1, at: answer.at, from: `phone-${answer.phone}`, data: { value: answer.value } }))
28
+
29
+ /** A results file as read, with anything unexpected left out. */
30
+ export function normalizeResults(data) {
31
+ const rooms = {}
32
+ for (const [room, entry] of Object.entries(data?.rooms ?? {})) {
33
+ if (!ROOM_RE.test(room) || !Array.isArray(entry?.answers)) continue
34
+ rooms[room] = {
35
+ savedAt: typeof entry.savedAt === 'string' ? entry.savedAt : null,
36
+ closed: !!entry.closed,
37
+ answers: entry.answers.filter(answer => answer && Number.isInteger(answer.phone)).map(({ phone, at, value }) => ({ phone, at: typeof at === 'string' ? at : null, value: value ?? null })),
38
+ }
39
+ }
40
+ return { version: 1, rooms }
41
+ }
42
+
43
+ /** One room's answers in, or (none) out; returns the new results. */
44
+ export function withRoom(results, room, { answers, closed = false, savedAt = new Date().toISOString() }) {
45
+ if (!ROOM_RE.test(room)) throw Object.assign(new Error('Room names use letters, digits, dots, hyphens and underscores'), { status: 400 })
46
+ const rooms = { ...results.rooms }
47
+ if (Array.isArray(answers) && answers.length) rooms[room] = { savedAt, closed: !!closed, answers }
48
+ else delete rooms[room]
49
+ return normalizeResults({ rooms })
50
+ }
51
+
52
+ export const serializeResults = results => JSON.stringify(results, null, 2) + '\n'
53
+
54
+ const csvCell = value => {
55
+ const text = value == null ? '' : Array.isArray(value) ? value.join('|') : String(value)
56
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
57
+ }
58
+
59
+ /** All answers as CSV: room, phone, time, answer (several picks joined by |). */
60
+ export function resultsCsv(results) {
61
+ const rows = [['room', 'phone', 'at', 'answer']]
62
+ for (const [room, entry] of Object.entries(results.rooms)) for (const answer of entry.answers) rows.push([room, answer.phone, answer.at, answer.value])
63
+ return rows.map(row => row.map(csvCell).join(',')).join('\n') + '\n'
64
+ }
@@ -6,6 +6,26 @@
6
6
  // over hills under a sun, for every layout that shows a picture.
7
7
  export const SAMPLE_PICTURE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500"><rect width="800" height="500" fill="var(--surface, #eef1f4)"/><g stroke="var(--rule, #d5dbe1)" stroke-width="2"><path d="M0 100H800M0 200H800M0 300H800M0 400H800M100 0V500M200 0V500M300 0V500M400 0V500M500 0V500M600 0V500M700 0V500"/></g><circle cx="580" cy="165" r="128" fill="none" stroke="var(--accent, #3d5a73)" stroke-width="4" opacity="0.35"/><circle cx="580" cy="165" r="88" fill="var(--accent, #3d5a73)"/><path d="M0 360C140 292 262 300 384 338S628 424 800 330V500H0Z" fill="var(--accent-2, #c2410c)"/><path d="M0 418C168 366 330 392 470 422S690 474 800 434V500H0Z" fill="var(--accent, #3d5a73)" opacity="0.6"/><polyline points="80,262 170,214 260,236 350,170 440,196" fill="none" stroke="var(--ink, #1b2733)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/><g fill="var(--bg, #ffffff)" stroke="var(--ink, #1b2733)" stroke-width="5"><circle cx="80" cy="262" r="10"/><circle cx="170" cy="214" r="10"/><circle cx="260" cy="236" r="10"/><circle cx="350" cy="170" r="10"/><circle cx="440" cy="196" r="10"/></g></svg>'
8
8
 
9
+ // The references the sample deck cites. Builds find them in a file beside the
10
+ // deck; the dev and edit servers read them from here where there is none.
11
+ export const SAMPLE_REFERENCES_FILE = 'sample-references.bib'
12
+ export const SAMPLE_REFERENCES = `@article{doe2024,
13
+ author = {Doe, Jane and Roe, Richard},
14
+ title = {Measuring how slides are read},
15
+ journal = {Journal of Sample Studies},
16
+ year = {2024},
17
+ volume = {12},
18
+ pages = {1--10}
19
+ }
20
+
21
+ @book{roe2023,
22
+ author = {Roe, Richard},
23
+ title = {Quotations and Their Sources},
24
+ publisher = {Example Press},
25
+ year = {2023}
26
+ }
27
+ `
28
+
9
29
  // `picture` is where the slides find the picture: a file beside the deck, or
10
30
  // a data URL where there is no such file.
11
31
  export function sampleDeck(picture = './picture.svg') {
@@ -15,6 +35,7 @@ meta:
15
35
  author: "A. Author"
16
36
  organization: "Institute"
17
37
  date: "2026-10-06"
38
+ bibliography: ${SAMPLE_REFERENCES_FILE}
18
39
  ---
19
40
 
20
41
  ---
@@ -51,10 +72,10 @@ alt: "Shapes"
51
72
  ---
52
73
  # Lists, emphasis and code
53
74
 
54
- - A point with *emphasis* and **strong** words
55
- - A second point with \`inline code\`
56
- - A nested point
57
- - Another nested point
75
+ 1. A step with *emphasis* and **strong** words
76
+ 2. A step with \`inline code\` and a [link](https://example.com)
77
+ - A nested point
78
+ - Another nested point
58
79
 
59
80
  \`\`\`js
60
81
  const total = values.reduce((sum, value) => sum + value, 0)
@@ -69,7 +90,7 @@ const total = values.reduce((sum, value) => sum + value, 0)
69
90
  | Treatment | 5.1 | 0.6 |
70
91
  | Follow-up | 5.0 | 0.7 |
71
92
 
72
- The difference is $\\Delta = 0.9$, with a footnote.[^1]
93
+ The difference is $\\Delta = 0.9$ [@doe2024, p. 4], with a footnote.[^1]
73
94
 
74
95
  [^1]: A source for the claim.
75
96
 
@@ -115,9 +136,9 @@ $$
115
136
 
116
137
  +++
117
138
 
118
- 1. First step
119
- 2. Second step
120
- 3. Third step
139
+ > A quotation set apart from the text.
140
+
141
+ As @roe2023 notes, a quote needs its source.
121
142
  :::
122
143
 
123
144
  ---
@@ -158,6 +179,11 @@ alt: "Shapes"
158
179
  overlay: true
159
180
  ---
160
181
  # A picture that fills the slide.
182
+
183
+ ---
184
+ # References
185
+
186
+ <bibliography />
161
187
  `.replaceAll('./picture.svg', picture)
162
188
  }
163
189
 
@@ -5,7 +5,7 @@ import { marked, Parser } from 'marked'
5
5
  import { registry } from '../runtime/registry'
6
6
  import { InkLayer } from '../runtime/ink/InkLayer.jsx'
7
7
  import themedSvgs from 'virtual:deck-svgs'
8
- import { renderCitations, citationsHtml as referencesOf } from '../runtime/citations.js'
8
+ import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '../runtime/citations.js'
9
9
 
10
10
  // ─── Content extraction ────────────────────────────────────────────────────
11
11
 
@@ -188,11 +188,12 @@ export function prepareSlide(slide) {
188
188
  .sort((a, b) => position(a[1]) - position(b[1]))
189
189
  let separator = '\u0000mdeck-region\u0000'
190
190
  while (entries.some(([, r]) => r.content.includes(separator))) separator += '\u0000'
191
- const cited = renderCitations(entries.map(([, r]) => r.content).join(separator))
191
+ const bib = bibliographyOf(slide.deckConfig)
192
+ const cited = renderCitations(entries.map(([, r]) => r.content).join(separator), bib)
192
193
  const { processed, footnotesHtml } = preprocessFootnotes(cited.markdown)
193
194
  const pieces = processed.split(separator)
194
195
  const regions = Object.fromEntries(entries.map(([name, region], i) => [name, { ...region, content: pieces[i] }]))
195
- const citationsHtml = slide.deckConfig?.show?.citations === false ? '' : referencesOf(cited.ids)
196
+ const citationsHtml = slide.deckConfig?.show?.citations === false ? '' : referencesOf(cited.ids, bib)
196
197
  return { ...slide, regions, content: regions.body?.content ?? '', footnotesHtml, citationsHtml }
197
198
  }
198
199
 
@@ -33,3 +33,22 @@ body { min-height: 100vh; display: flex; flex-direction: column; font: 18px/1.45
33
33
  .answer-status { margin: 18px 0 0; color: var(--muted); min-height: 2.8em; }
34
34
  footer { padding: 16px 20px; text-align: center; color: var(--muted); font-size: 14px; }
35
35
  .answer-follow { display: block; margin: 0 20px 24px; padding: 12px 16px; text-align: center; border: 1px solid var(--rule); border-radius: 10px; color: var(--ink); text-decoration: none; font-size: 15px; }
36
+ /* Maths in a question or option (MathML, see answer.js). */
37
+ .answer-rich math { font-size: 1.1em; }
38
+ /* buttons="letters|numbers": big keys; the options are on the slide. */
39
+ .answer-keys { display: grid; grid-template-columns: repeat(auto-fit, minmax(72px, 1fr)); gap: 12px; }
40
+ .answer .answer-keys button { padding: 22px 0; text-align: center; font-size: 28px; }
41
+ /* `multiple`: a box that is ticked when picked. */
42
+ .answer-check { display: inline-block; width: 1em; height: 1em; margin-right: 12px; border: 2px solid currentColor; border-radius: 4px; vertical-align: -.12em; opacity: .6; }
43
+ .answer button.is-picked .answer-check { opacity: 1; background: currentColor; box-shadow: inset 0 0 0 2px var(--accent); }
44
+ /* Closed: no more answers. */
45
+ .answer button:disabled, .answer textarea:disabled, .answer input:disabled { opacity: .45; cursor: default; }
46
+ .answer button.is-picked:disabled { opacity: .75; }
47
+ /* Once the right answer shows. */
48
+ .answer-verdict { margin: 4px 0 0; padding: 14px 16px; border-radius: 12px; background: color-mix(in srgb, var(--ink) 6%, transparent); font-weight: 600; }
49
+ .answer-verdict.is-right { background: color-mix(in srgb, #16a34a 16%, transparent); color: color-mix(in srgb, #16a34a 75%, var(--ink)); }
50
+ .answer-verdict.is-wrong { background: color-mix(in srgb, #dc2626 12%, transparent); }
51
+ /* <numeric>: a number field and Send. */
52
+ .answer-number { display: grid; grid-template-columns: 1fr auto; gap: 12px; }
53
+ .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
+ .answer-number button[type=submit] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, var(--bg)); }