mdeck 3.2.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 (67) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/assets/base.css +23 -2
  3. package/assets/extensions/themes/academic/styles.css +33 -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 +89 -6
  8. package/docs/site/content/audience.md +41 -4
  9. package/docs/site/content/commands.md +30 -4
  10. package/docs/site/content/drawing.md +1 -1
  11. package/docs/site/content/more-content.md +22 -0
  12. package/docs/site/content/sharing.md +1 -0
  13. package/docs/site/content/troubleshooting.md +1 -1
  14. package/docs/site/content/views.md +3 -1
  15. package/examples/citations/refs.bib +30 -0
  16. package/examples/citations/refs.yml +21 -0
  17. package/examples/citations/slides.md +43 -0
  18. package/examples/citations/slides.pdf +0 -0
  19. package/package.json +5 -1
  20. package/skills/write-slides/SKILL.md +7 -3
  21. package/src/build/bibliography.js +307 -0
  22. package/src/build/check.js +12 -0
  23. package/src/build/renderCheck.js +98 -0
  24. package/src/build/resultsPlugin.js +77 -0
  25. package/src/build/slidesPlugin.js +70 -7
  26. package/src/build/themeCheck.js +8 -5
  27. package/src/cli/args.js +22 -5
  28. package/src/cli/index.js +54 -12
  29. package/src/components/Bibliography.jsx +23 -0
  30. package/src/components/Numeric.jsx +59 -0
  31. package/src/components/Poll.jsx +59 -33
  32. package/src/components/QrCodeTag.jsx +11 -2
  33. package/src/components/Question.jsx +10 -6
  34. package/src/components/Scale.jsx +13 -8
  35. package/src/components/VideoPlayer.jsx +16 -0
  36. package/src/components/WordCloud.jsx +10 -6
  37. package/src/components/activity.jsx +90 -9
  38. package/src/components/citation.css +28 -0
  39. package/src/components/inlineText.js +40 -0
  40. package/src/components/numbers.js +39 -0
  41. package/src/components/poll.css +27 -7
  42. package/src/core/citations.js +108 -0
  43. package/src/core/editDeck.js +43 -0
  44. package/src/core/icons.js +2 -0
  45. package/src/core/ink.js +16 -0
  46. package/src/core/labels.js +48 -0
  47. package/src/core/parseSlides.js +1 -1
  48. package/src/core/results.js +64 -0
  49. package/src/core/urls.js +2 -2
  50. package/src/core/validateDeck.js +7 -1
  51. package/src/editor/sampleDeck.js +34 -8
  52. package/src/layouts/layoutApi.jsx +14 -3
  53. package/src/live/answer/answer.css +20 -0
  54. package/src/live/answer/answer.js +149 -24
  55. package/src/live/client.js +75 -7
  56. package/src/live/roomTag.js +6 -2
  57. package/src/live/rooms.js +6 -1
  58. package/src/runtime/ReaderView.jsx +2 -1
  59. package/src/runtime/citations.js +70 -0
  60. package/src/runtime/deck-stage.js +5 -2
  61. package/src/runtime/editorBridge.js +7 -1
  62. package/src/runtime/follow.js +89 -0
  63. package/src/runtime/ink/bus.js +7 -6
  64. package/src/runtime/ink/room.js +8 -0
  65. package/src/runtime/ink/select.js +4 -2
  66. package/src/runtime/main.jsx +22 -8
  67. package/src/runtime/registry.jsx +4 -0
@@ -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,23 +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; }
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; }
33
+ /* results="hidden" or the eye off: the presenter's screens show the results faint and hatched, the audience none. */
34
+ .poll.is-results-hidden .poll-fill { opacity: .35; background: repeating-linear-gradient(45deg, var(--accent, #0d9488) 0 8px, transparent 8px 14px); }
35
+ .poll.is-results-hidden .poll-count { opacity: .35; }
36
+ deck-stage[data-follower] .poll.is-results-hidden .poll-fill, deck-stage[data-follower] .poll.is-results-hidden .poll-count { visibility: hidden; }
21
37
  .poll-solve svg { width: 24px; height: 24px; }
22
- .poll-solve:hover, .poll-solve:focus-visible { opacity: 1; }
23
38
  .poll-solve.is-on { opacity: 1; background: var(--poll-correct, #16a34a); border-color: var(--poll-correct, #16a34a); color: #fff; }
24
- deck-stage[data-follower] .poll-solve { display: none; }
25
39
  .poll.is-solved .poll-row.is-correct { outline: 4px solid var(--poll-correct, #16a34a); outline-offset: 10px; border-radius: 10px; }
26
40
  .poll.is-solved .poll-row.is-correct .poll-label { font-weight: 700; color: var(--poll-correct, #16a34a); }
27
41
  .poll-link { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; color: var(--muted, #555); }
@@ -67,15 +81,21 @@ deck-stage[data-follower] .poll-solve { display: none; }
67
81
  .poll-join-slide { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; }
68
82
  .poll-join-slide .poll-join { font-size: var(--fs-body, 32px); gap: 16px; padding: 24px 32px; }
69
83
  .poll-join-slide .poll-link { font-size: 28px; }
70
- @media print { .poll-reset, .poll-dot, .poll-solve { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
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; } }
71
85
 
72
86
  /* A record of the talk (no room server: a PDF, a file sent to readers): the
73
87
  question and its options, without counts, the live dot or a join code. */
74
88
  .poll.is-static .poll-count, .poll.is-static .scale-count { visibility: hidden; }
89
+ .poll.is-static .poll-close { display: none; }
75
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); }
76
91
  .poll-join-slide .poll-static { margin: 0; font-size: var(--fs-body, 32px); }
77
92
 
78
93
  /* On paper the join code and the presenter's controls have no use. */
79
94
  @media print {
80
- .poll-join, .poll-reset, .poll-solve, .poll-dot { display: none !important; }
95
+ .poll-join, .poll-reset, .poll-dot, .poll-control { display: none !important; }
81
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); }
@@ -0,0 +1,108 @@
1
+ import { sourceLines, fenceState } from './source.js'
2
+
3
+ // Pandoc's citation syntax, shared by the build (which formats the deck's
4
+ // citations with citeproc) and the runtime (which puts the formatted text in
5
+ // place): `[@smith2020]`, `[see @smith2020, p. 12; @doe2019]`, `[-@smith2020]`
6
+ // and narrative `@smith2020` (`Smith (2020)`), optionally `@smith2020 [p. 12]`.
7
+ // A narrative `@key` counts only when `isKey` knows it, so e-mail addresses and
8
+ // handles stay text. Fenced and inline code are left alone.
9
+
10
+ const KEY = String.raw`[\w][\w:.#$%&+?<>~/-]*`
11
+ // Not a link (`](`, `][ref]`); a footnote may follow (`[@a][^1]`).
12
+ const NOT_LINK = String.raw`(?!\(|\[(?!\^))`
13
+ const BRACKETED = new RegExp(String.raw`\[([^\[\]\n]*?-?@[\w][^\[\]\n]*?)\]${NOT_LINK}`, 'g')
14
+ // The key starts a word: `[info@fhnw.ch]` is an address, not a citation.
15
+ const ITEM = new RegExp(String.raw`^((?:.*?\s)?)(-?)@(${KEY})(.*)$`, 's')
16
+ const NARRATIVE = new RegExp(String.raw`(?<![\w@\[\]\`])@(${KEY})(?:\s\[([^\[\]\n]+)\]${NOT_LINK})?`, 'g')
17
+
18
+ // Locator terms readers write after a comma, mapped to CSL labels.
19
+ const LOCATORS = [
20
+ [/^(pp?\.?|pages?|S\.|Seiten?)\s*/i, 'page'],
21
+ [/^(chaps?\.?|ch\.|chapters?|Kap\.)\s*/i, 'chapter'],
22
+ [/^(secs?\.?|sections?|§§?)\s*/i, 'section'],
23
+ [/^(figs?\.?|figures?|Abb\.)\s*/i, 'figure'],
24
+ [/^(vols?\.?|volumes?|Bd\.)\s*/i, 'volume'],
25
+ [/^(paras?\.?|paragraphs?|¶¶?)\s*/i, 'paragraph'],
26
+ [/^(nos?\.?|numbers?|Nr\.)\s*/i, 'issue'],
27
+ [/^(ll?\.|lines?)\s*/i, 'line'],
28
+ [/^(eqs?\.?|equations?)\s*/i, 'equation'],
29
+ ]
30
+
31
+ // Keys end before trailing punctuation: `@smith2020.` cites `smith2020`.
32
+ const trimKey = key => key.replace(/[:.#$%&+?<>~/-]+$/, '')
33
+
34
+ // `, p. 12, emphasis added` → locator `12`, label `page`, suffix `, emphasis added`.
35
+ export function parseLocator(text) {
36
+ const rest = text.replace(/^\s*,?\s*/, '')
37
+ if (!rest) return {}
38
+ for (const [pattern, label] of LOCATORS) {
39
+ const term = rest.match(pattern)
40
+ if (!term) continue
41
+ const value = rest.slice(term[0].length).match(/^[^\s,;]+(?:\s*[-–]\s*[^\s,;]+)?/)
42
+ if (value) return withSuffix({ label, locator: value[0] }, rest.slice(term[0].length + value[0].length))
43
+ }
44
+ const number = rest.match(/^\d[\w]*(?:\s*[-–]\s*\w+)?/)
45
+ if (number) return withSuffix({ label: 'page', locator: number[0] }, rest.slice(number[0].length))
46
+ return { suffix: text.trim() }
47
+ }
48
+
49
+ const withSuffix = (item, suffix) => suffix.trim() ? { ...item, suffix: suffix.trim() } : item
50
+
51
+ function parseItem(part) {
52
+ const match = part.match(ITEM)
53
+ if (!match) return null
54
+ const id = trimKey(match[3])
55
+ const after = match[3].slice(id.length) + match[4]
56
+ const item = { id, ...parseLocator(after) }
57
+ if (match[1].trim()) item.prefix = match[1].trim()
58
+ if (match[2]) item.suppressAuthor = true
59
+ return item
60
+ }
61
+
62
+ // Ranges of `code` and link targets in a line, which hold no citations.
63
+ function codeSpans(text) {
64
+ const spans = []
65
+ const pattern = /(`+)[\s\S]*?\1|\]\([^)\s]*(?:\s[^)]*)?\)/g
66
+ for (let match; (match = pattern.exec(text));) spans.push([match.index, match.index + match[0].length])
67
+ return spans
68
+ }
69
+
70
+ // Every citation in `markdown`, in order: `{ start, end, raw, items, narrative }`.
71
+ // `raw` is the text as written, the key the build's formatted output is filed under.
72
+ export function scanCitations(markdown, { isKey = () => true } = {}) {
73
+ const found = []
74
+ let fence = null
75
+ for (const line of sourceLines(markdown)) {
76
+ const before = fence
77
+ fence = fenceState(line.text, fence)
78
+ if (before || fence || !line.text.includes('@')) continue
79
+ const code = codeSpans(line.text)
80
+ const taken = []
81
+ const free = (start, end) => ![...code, ...taken].some(([a, b]) => start < b && end > a)
82
+ for (const match of line.text.matchAll(BRACKETED)) {
83
+ const end = match.index + match[0].length
84
+ // `[@key]: …` at the start of a line defines a link reference.
85
+ const definition = line.text[end] === ':' && !line.text.slice(0, match.index).trim()
86
+ if (line.text[match.index - 1] === '!' || definition || !free(match.index, end)) continue
87
+ const items = match[1].split(';').map(parseItem)
88
+ if (items.some(item => !item)) continue
89
+ taken.push([match.index, end])
90
+ found.push({ start: line.start + match.index, end: line.start + end, raw: match[0], items, narrative: false })
91
+ }
92
+ for (const match of line.text.matchAll(NARRATIVE)) {
93
+ const id = trimKey(match[1])
94
+ const keyEnd = match.index + 1 + id.length
95
+ const end = match[2] != null ? match.index + match[0].length : keyEnd
96
+ if (!isKey(id) || !free(match.index, end)) continue
97
+ taken.push([match.index, end])
98
+ const item = { id, ...(match[2] != null ? parseLocator(match[2]) : {}) }
99
+ found.push({ start: line.start + match.index, end: line.start + end, raw: line.text.slice(match.index, end), items: [item], narrative: true })
100
+ }
101
+ }
102
+ return found.sort((a, b) => a.start - b.start)
103
+ }
104
+
105
+ // The cited keys, each once, in order of first citation.
106
+ export function citedKeys(clusters) {
107
+ return [...new Set(clusters.flatMap(cluster => cluster.items.map(item => item.id)))]
108
+ }
@@ -4,6 +4,7 @@
4
4
  // identical; callers reparse the result.
5
5
  import { parseSlides, SLIDE_KEYS, DECK_KEYS } from './parseSlides.js'
6
6
  import { applySourceEdits, replaceRegion, normalizeBlock, detectNewline, patchYamlMapping, firstYamlKey } from './source.js'
7
+ import { maskCode } from '../live/roomTag.js'
7
8
 
8
9
  const REGION_RE = /^[a-z][a-z0-9-]*$/
9
10
 
@@ -228,3 +229,45 @@ export function stripNotes(source) {
228
229
  .map(block => blockRemoval(deck, block)))
229
230
  return edit(deck, edits)
230
231
  }
232
+
233
+ // The live activities, and the code to join them (<qrcode join />): a slide
234
+ // with one of them is left out of shared builds. <qrcode follow /> and other
235
+ // QR codes stay.
236
+ const ACTIVITY_RE = /<(poll|question|wordcloud|scale|numeric)\b/i
237
+ const JOIN_RE = /<qrcode\b[^>]*?\sjoin(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\{?([^\s/>}]*)\}?))?(?=[\s/>])/i
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
+ export function hasActivity(slide) {
240
+ return [slide.content ?? '', ...Object.values(slide.regions ?? {}).map(region => region.content ?? '')]
241
+ .some(text => { const masked = maskCode(text); return ACTIVITY_RE.test(masked) || joins(masked) })
242
+ }
243
+
244
+ // Removes every slide with a live activity (<poll>, <question>, <wordcloud>,
245
+ // <scale>, <numeric>) for shared builds: a reader cannot vote, and the results belong to
246
+ // the talk. Returns { source, ids, removed }: `ids` maps each kept slide's id
247
+ // to its id afterwards (an automatic `slide-N` moves up), `removed` the ids
248
+ // that are gone, so saved drawings can follow their slides.
249
+ export function stripActivities(source) {
250
+ const deck = parseSlides(source)
251
+ const dropped = deck.slides.filter(hasActivity)
252
+ const same = () => ({ source, ids: Object.fromEntries(deck.slides.map(slide => [slide.id, slide.id])), removed: [] })
253
+ // A deck of nothing but activities stays as it is rather than becoming empty.
254
+ if (!dropped.length || dropped.length === deck.slides.length) return same()
255
+ const ranges = dropped.map(slide => {
256
+ const bounds = slideBounds(deck, slide.id)
257
+ const next = deck.slides[deck.slides.indexOf(slide) + 1]
258
+ return bounds.delimiterStart != null
259
+ ? { start: bounds.delimiterStart, end: bounds.end }
260
+ : { start: bounds.start, end: next ? next.source.start : deck.source.length }
261
+ }).sort((a, b) => a.start - b.start)
262
+ // Neighbouring slides can share a delimiter: join ranges that touch.
263
+ const merged = []
264
+ for (const range of ranges) {
265
+ const last = merged.at(-1)
266
+ if (last && range.start <= last.end) last.end = Math.max(last.end, range.end)
267
+ else merged.push({ ...range })
268
+ }
269
+ const next = edit(deck, merged.map(range => ({ ...range, text: '', expected: expectedText(deck, range) })))
270
+ const after = parseSlides(next).slides
271
+ const kept = deck.slides.filter(slide => !dropped.includes(slide))
272
+ return { source: next, ids: Object.fromEntries(kept.map((slide, i) => [slide.id, after[i]?.id ?? slide.id])), removed: dropped.map(slide => slide.id) }
273
+ }
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
package/src/core/ink.js CHANGED
@@ -188,6 +188,22 @@ export const inkPaint = color => PEN_PAINT[color] ?? color
188
188
  /** Strokes in drawing order: highlighter below the pen, as on paper. */
189
189
  export const inkOrder = strokes => [...strokes.filter(s => s.tool === 'highlighter'), ...strokes.filter(s => s.tool !== 'highlighter')]
190
190
 
191
+ /**
192
+ * The end of a straight line from `from` to `to`, turned onto the nearest
193
+ * multiple of `step` degrees when it lies within `tolerance` of it (0°, 15°,
194
+ * 30° … by default); its length stays. Further off, the end stays as drawn.
195
+ */
196
+ export function snapAngle([x0, y0], [x, y], { step = 15, tolerance = 4 } = {}) {
197
+ const dx = x - x0, dy = y - y0
198
+ const length = Math.hypot(dx, dy)
199
+ if (!length) return [x, y]
200
+ const angle = Math.atan2(dy, dx) * 180 / Math.PI
201
+ const snapped = Math.round(angle / step) * step
202
+ if (Math.abs(angle - snapped) > tolerance) return [x, y]
203
+ const rad = snapped * Math.PI / 180
204
+ return [Math.round((x0 + Math.cos(rad) * length) * 10) / 10, Math.round((y0 + Math.sin(rad) * length) * 10) / 10]
205
+ }
206
+
191
207
  /** The stroke moved by dx, dy design pixels; the same id, so it replaces the original. */
192
208
  export const translateStroke = (stroke, dx, dy) => ({ ...stroke, points: stroke.points.map(([x, y, p]) => [x + dx, y + dy, p]) })
193
209
 
@@ -35,6 +35,8 @@ export const LABELS = {
35
35
  'poll.reset': 'Reset',
36
36
  'poll.showAnswer': 'Show the right answer',
37
37
  'poll.hideAnswer': 'Hide the right answer',
38
+ 'poll.showResults': 'Show the results',
39
+ 'poll.hideResults': 'Hide the results',
38
40
  'poll.live': 'Live',
39
41
  'poll.offline': 'Not connected to the server',
40
42
  'poll.unreachable': 'Phones cannot reach this computer. Start with {command}, or set {setting}.',
@@ -46,7 +48,29 @@ export const LABELS = {
46
48
  'respond.send': 'Send',
47
49
  'respond.sent': 'Sent. Thank you!',
48
50
  'join.scan': 'Scan to join',
51
+ 'follow.scan': 'Scan to follow the slides',
52
+ 'follow.open': 'Follow the slides',
53
+ 'follow.live': 'Live',
54
+ 'follow.back': 'Back to live',
55
+ 'follow.waiting': 'The talk has not started yet.',
56
+ 'follow.tryHere': 'Try following here',
49
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',
50
74
  'question.empty': 'Answers appear here.',
51
75
  'scale.average': 'Average {n}',
52
76
  },
@@ -80,6 +104,8 @@ export const LABELS = {
80
104
  'poll.reset': 'Zurücksetzen',
81
105
  'poll.showAnswer': 'Richtige Antwort zeigen',
82
106
  'poll.hideAnswer': 'Richtige Antwort ausblenden',
107
+ 'poll.showResults': 'Ergebnis zeigen',
108
+ 'poll.hideResults': 'Ergebnis ausblenden',
83
109
  'poll.live': 'Live',
84
110
  'poll.offline': 'Keine Verbindung zum Server',
85
111
  'poll.unreachable': 'Handys erreichen diesen Computer nicht. Mit {command} starten oder {setting} setzen.',
@@ -91,7 +117,29 @@ export const LABELS = {
91
117
  'respond.send': 'Senden',
92
118
  'respond.sent': 'Gesendet. Danke!',
93
119
  'join.scan': 'Scannen und mitmachen',
120
+ 'follow.scan': 'Scannen und Folien mitverfolgen',
121
+ 'follow.open': 'Folien mitverfolgen',
122
+ 'follow.live': 'Live',
123
+ 'follow.back': 'Zur aktuellen Folie',
124
+ 'follow.waiting': 'Der Vortrag hat noch nicht begonnen.',
125
+ 'follow.tryHere': 'Mitverfolgen hier ausprobieren',
94
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',
95
143
  'question.empty': 'Hier erscheinen die Antworten.',
96
144
  'scale.average': 'Durchschnitt {n}',
97
145
  },
@@ -2,7 +2,7 @@ import yaml from 'js-yaml'
2
2
  import { sourceLines, fenceState, scanDirectives, diagnostic } from './source.js'
3
3
  export { sourceLines, fenceState } from './source.js'
4
4
 
5
- export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session'.split(' '))
5
+ export const DECK_KEYS = new Set('theme palette appearance params meta width height show lang labels callouts reader components server session bibliography csl nocite'.split(' '))
6
6
  export const SLIDE_KEYS = new Set('layout id title section number part description label image alt overlay eyebrow attribution props'.split(' '))
7
7
 
8
8
  // Names from before 2.0. They are still recognised, so that an old deck gets a
@@ -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
+ }
package/src/core/urls.js CHANGED
@@ -1,4 +1,4 @@
1
- const VIEWS = new Set(['deck', 'reader', 'presenter', 'audience'])
1
+ const VIEWS = new Set(['deck', 'reader', 'presenter', 'audience', 'follow'])
2
2
  const OLD_VIEWS = { d: 'deck', s: 'reader', p: 'presenter', a: 'audience', share: 'reader' }
3
3
 
4
4
  // Old URLs must be corrected explicitly: guessing a view can expose notes or
@@ -21,7 +21,7 @@ export function validatePageUrl(url) {
21
21
  for (const secret of ['serverkey', 'livekey', 'pair']) corrected.searchParams.delete(secret)
22
22
  if (replacements.length) return { message: `This URL uses settings from before API 2.0: ${replacements.join(', ')}. Open the updated address below. If you supplied a server key, add it again as ?serverkey=… on your own computer.`, corrected: corrected.href }
23
23
  const view = url.searchParams.get('view')
24
- if (view !== null && !VIEWS.has(view)) return { message: `Unknown view "${view}". Choose deck, reader, presenter or audience.` }
24
+ if (view !== null && !VIEWS.has(view)) return { message: `Unknown view "${view}". Choose deck, reader, presenter, audience or follow.` }
25
25
  return null
26
26
  }
27
27
 
@@ -38,14 +38,20 @@ export function validateDeck(deck, { layouts = null, themes = null, palettes = n
38
38
  else if (typeof value !== 'string') add('invalid-config', `labels.${key} must be text`, deck.configSource?.start)
39
39
  }
40
40
  }
41
+ // References: bibliography.js reads the files and reports what it cannot use.
42
+ if (config.bibliography != null && ![config.bibliography].flat().every(file => typeof file === 'string' && file.trim())) add('invalid-config', 'bibliography must be a file name such as refs.bib, or a list of them', deck.configSource?.start)
43
+ if (config.csl != null && (typeof config.csl !== 'string' || !config.csl.trim())) add('invalid-config', 'csl must be a style name (apa, vancouver, harvard1) or a .csl file', deck.configSource?.start)
44
+ if (config.nocite != null && (!Array.isArray(config.nocite) || config.nocite.some(key => typeof key !== 'string'))) add('invalid-config', "nocite must be a list of reference keys, or ['*'] for all", deck.configSource?.start)
41
45
  if (config.server != null && (typeof config.server !== 'string' || !/^https?:\/\//.test(config.server))) add('invalid-config', 'server must be an http:// or https:// address', deck.configSource?.start)
42
46
  if (isPlainObject(config.session)) {
43
47
  for (const key of Object.keys(config.session)) if (!['id', 'code'].includes(key)) add('invalid-config', `session.${key} is not a setting; available: id, code`, deck.configSource?.start, 'warning')
44
48
  if (config.session.code != null && !/^\d{4,8}$/.test(String(config.session.code))) add('invalid-config', 'session.code must be 4 to 8 digits', deck.configSource?.start)
45
49
  }
46
50
  const enums = { organization: ['title', 'all', 'none'], author: ['title', 'all', 'none'], numbers: ['slides', 'all', 'none'], sections: ['all', 'none'] }
51
+ const switches = ['citations']
47
52
  if (isPlainObject(config.show)) {
48
- for (const key of Object.keys(config.show)) if (!Object.hasOwn(enums, key)) add('invalid-config', `show.${key} is not a setting; available: ${Object.keys(enums).join(', ')}`, deck.configSource?.start, 'warning')
53
+ for (const key of Object.keys(config.show)) if (!Object.hasOwn(enums, key) && !switches.includes(key)) add('invalid-config', `show.${key} is not a setting; available: ${[...Object.keys(enums), ...switches].join(', ')}`, deck.configSource?.start, 'warning')
54
+ for (const key of switches) if (config.show[key] != null && typeof config.show[key] !== 'boolean') add('invalid-config', `show.${key} must be true or false`, deck.configSource?.start)
49
55
  for (const [key, values] of Object.entries(enums)) {
50
56
  if (config.show[key] != null && !values.includes(config.show[key])) add('invalid-config', `show.${key} must be one of: ${values.join(', ')}`, deck.configSource?.start)
51
57
  }
@@ -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,6 +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, bibliographyOf } from '../runtime/citations.js'
8
9
 
9
10
  // ─── Content extraction ────────────────────────────────────────────────────
10
11
 
@@ -170,6 +171,12 @@ function SlideFootnotes({ html }) {
170
171
  return <div class="slide-footnotes" dangerouslySetInnerHTML={{ __html: html }} />
171
172
  }
172
173
 
174
+ // The full references of the works this slide cites (show.citations: false hides them).
175
+ function SlideCitations({ html }) {
176
+ if (!html) return null
177
+ return <div class="slide-citations" dangerouslySetInnerHTML={{ __html: html }} />
178
+ }
179
+
173
180
  function slideNum(index) {
174
181
  return String(index + 1).padStart(2, '0')
175
182
  }
@@ -181,10 +188,13 @@ export function prepareSlide(slide) {
181
188
  .sort((a, b) => position(a[1]) - position(b[1]))
182
189
  let separator = '\u0000mdeck-region\u0000'
183
190
  while (entries.some(([, r]) => r.content.includes(separator))) separator += '\u0000'
184
- const { processed, footnotesHtml } = preprocessFootnotes(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)
193
+ const { processed, footnotesHtml } = preprocessFootnotes(cited.markdown)
185
194
  const pieces = processed.split(separator)
186
195
  const regions = Object.fromEntries(entries.map(([name, region], i) => [name, { ...region, content: pieces[i] }]))
187
- return { ...slide, regions, content: regions.body?.content ?? '', footnotesHtml }
196
+ const citationsHtml = slide.deckConfig?.show?.citations === false ? '' : referencesOf(cited.ids, bib)
197
+ return { ...slide, regions, content: regions.body?.content ?? '', footnotesHtml, citationsHtml }
188
198
  }
189
199
 
190
200
  export function MarkdownRegion({ region, content, class: className, ...attributes }) {
@@ -194,7 +204,7 @@ export function MarkdownRegion({ region, content, class: className, ...attribute
194
204
  }
195
205
 
196
206
  // Layouts supply body content; every layout receives the same outer frame.
197
- export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0, id, manifest, footnotesHtml, children }) {
207
+ export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0, id, manifest, footnotesHtml, citationsHtml, children }) {
198
208
  const layout = manifest.id === 'generic' ? meta.layout : manifest.id
199
209
  const frame = manifest.frame ?? 'standard'
200
210
  const title = frame === 'title'
@@ -207,6 +217,7 @@ export function SlideFrame({ meta = {}, props = {}, deckConfig = {}, index = 0,
207
217
  isTitle={title} right={frame === 'chapter' ? meta.part ?? '' : meta.section ?? ''} />}
208
218
  {children}
209
219
  <SlideFootnotes html={footnotesHtml} />
220
+ <SlideCitations html={citationsHtml} />
210
221
  {frame !== 'none' && <SlideFooter deckConfig={deckConfig} right={slideNum(index)} isTitle={title} />}
211
222
  <InkLayer slideId={id} />
212
223
  </section>