mdeck 3.3.0 → 3.5.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 (83) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/assets/base.css +23 -2
  4. package/assets/extensions/palettes/cobalt/extension.toml +30 -0
  5. package/assets/extensions/palettes/greyscale/extension.toml +57 -0
  6. package/assets/extensions/themes/academic/styles.css +18 -5
  7. package/assets/extensions/themes/glass/extension.toml +59 -0
  8. package/assets/extensions/themes/glass/styles.css +754 -0
  9. package/assets/extensions/themes/minimal/styles.css +16 -4
  10. package/assets/extensions/themes/neue/styles.css +17 -4
  11. package/assets/extensions/themes/plain/extension.toml +58 -0
  12. package/assets/extensions/themes/plain/styles.css +471 -0
  13. package/assets/extensions/themes/work/extension.toml +57 -0
  14. package/assets/extensions/themes/work/styles.css +512 -0
  15. package/docs/reference/authoring.md +50 -10
  16. package/docs/reference/palettes.md +4 -3
  17. package/docs/reference/themes.md +5 -3
  18. package/docs/site/content/appearance.md +24 -10
  19. package/docs/site/content/audience.md +49 -4
  20. package/docs/site/content/commands.md +2 -0
  21. package/docs/site/content/theme-repository.md +7 -7
  22. package/docs/site/images/themes/academic-dark.webp +0 -0
  23. package/docs/site/images/themes/academic-light.webp +0 -0
  24. package/docs/site/images/themes/academic.webp +0 -0
  25. package/docs/site/images/themes/glass-dark.webp +0 -0
  26. package/docs/site/images/themes/glass-light.webp +0 -0
  27. package/docs/site/images/themes/glass.webp +0 -0
  28. package/docs/site/images/themes/minimal-dark.webp +0 -0
  29. package/docs/site/images/themes/minimal-light.webp +0 -0
  30. package/docs/site/images/themes/minimal.webp +0 -0
  31. package/docs/site/images/themes/neue-dark.webp +0 -0
  32. package/docs/site/images/themes/neue-light.webp +0 -0
  33. package/docs/site/images/themes/neue.webp +0 -0
  34. package/docs/site/images/themes/plain-dark.webp +0 -0
  35. package/docs/site/images/themes/plain-light.webp +0 -0
  36. package/docs/site/images/themes/plain.webp +0 -0
  37. package/docs/site/images/themes/work-dark.webp +0 -0
  38. package/docs/site/images/themes/work-light.webp +0 -0
  39. package/docs/site/images/themes/work.webp +0 -0
  40. package/examples/showcase/slides.md +2 -2
  41. package/package.json +4 -1
  42. package/skills/write-slides/SKILL.md +2 -2
  43. package/src/build/bibliography.js +29 -11
  44. package/src/build/resultsPlugin.js +77 -0
  45. package/src/build/slidesPlugin.js +42 -18
  46. package/src/build/themeCheck.js +2 -1
  47. package/src/cli/args.js +2 -1
  48. package/src/cli/index.js +17 -4
  49. package/src/components/Numeric.jsx +59 -0
  50. package/src/components/Poll.jsx +72 -57
  51. package/src/components/Question.jsx +10 -6
  52. package/src/components/Scale.jsx +13 -8
  53. package/src/components/WordCloud.jsx +10 -6
  54. package/src/components/activity.jsx +88 -7
  55. package/src/components/inlineText.js +63 -0
  56. package/src/components/numbers.js +39 -0
  57. package/src/components/poll.css +26 -13
  58. package/src/core/editDeck.js +5 -6
  59. package/src/core/icons.js +2 -0
  60. package/src/core/labels.js +32 -0
  61. package/src/core/pollList.js +66 -0
  62. package/src/core/results.js +64 -0
  63. package/src/core/tags.js +195 -0
  64. package/src/editor/sampleDeck.js +34 -8
  65. package/src/extensions/discover.js +2 -2
  66. package/src/extensions/moved.js +4 -4
  67. package/src/layouts/layoutApi.jsx +24 -13
  68. package/src/live/answer/answer.css +19 -0
  69. package/src/live/answer/answer.js +147 -24
  70. package/src/live/client.js +51 -6
  71. package/src/live/roomTag.js +11 -15
  72. package/src/live/rooms.js +6 -1
  73. package/src/runtime/citations.js +15 -5
  74. package/src/runtime/ink/bus.js +7 -13
  75. package/src/runtime/main.jsx +5 -2
  76. package/src/runtime/markedSetup.js +3 -0
  77. package/src/runtime/registry.jsx +2 -0
  78. package/assets/extensions/palettes/neon/extension.toml +0 -30
  79. package/assets/extensions/themes/aurora/extension.toml +0 -40
  80. package/assets/extensions/themes/aurora/styles.css +0 -644
  81. package/docs/site/images/themes/aurora-dark.webp +0 -0
  82. package/docs/site/images/themes/aurora-light.webp +0 -0
  83. package/docs/site/images/themes/aurora.webp +0 -0
@@ -0,0 +1,195 @@
1
+ import { Marked } from 'marked'
2
+ import markedKatex from 'marked-katex-extension'
3
+
4
+ // The HTML tags in a deck's Markdown, found the way the slide finds them:
5
+ // marked decides what is HTML (not code, not a tag a blank line cut in two),
6
+ // and the tags in that HTML are read the way a browser reads them, so a `>`
7
+ // in a quoted value (question="Is $x > 0$?") does not end its tag. Every
8
+ // part of mdeck that looks for tags in a deck's text goes through here.
9
+
10
+ const lexer = new Marked().use(markedKatex({ throwOnError: false, output: 'html' }))
11
+
12
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
13
+ const decode = value => value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/gi, (entity, dec, hex, name) =>
14
+ dec ? String.fromCodePoint(Number(dec)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : ENTITIES[name.toLowerCase()] ?? entity)
15
+
16
+ const SPACE = /[\t\n\f\r ]/
17
+ // Elements whose content is text, not tags.
18
+ const RAW_TEXT = new Set(['script', 'style', 'textarea', 'title'])
19
+
20
+ /**
21
+ * The tags in a piece of HTML, in order: { name, attrs, spans, raw, index,
22
+ * end, closing, selfClosing }, `spans` giving where each value is in `raw`. Attribute values are decoded and a repeated
23
+ * attribute keeps its first value, as in the browser. Comments are skipped,
24
+ * and a `<` that starts no tag is text.
25
+ */
26
+ export function scanTags(html) {
27
+ const tags = []
28
+ let i = 0
29
+ while ((i = html.indexOf('<', i)) >= 0) {
30
+ if (html.startsWith('<!--', i)) {
31
+ const close = html.indexOf('-->', i + 4)
32
+ i = close < 0 ? html.length : close + 3
33
+ continue
34
+ }
35
+ const tag = readTag(html, i)
36
+ if (!tag) { i++; continue }
37
+ tags.push(tag)
38
+ i = tag.end
39
+ if (!tag.closing && !tag.selfClosing && RAW_TEXT.has(tag.name)) {
40
+ const close = html.toLowerCase().indexOf(`</${tag.name}`, i)
41
+ i = close < 0 ? html.length : close
42
+ }
43
+ }
44
+ return tags
45
+ }
46
+
47
+ function readTag(html, start) {
48
+ let i = start + 1
49
+ const closing = html[i] === '/'
50
+ if (closing) i++
51
+ const name = /^[a-z][^\t\n\f\r />]*/i.exec(html.slice(i))?.[0]
52
+ if (!name) return null
53
+ i += name.length
54
+ const attrs = {}
55
+ // Where each attribute's value is in `raw`, as [from, to].
56
+ const spans = {}
57
+ let selfClosing = false
58
+ while (i < html.length) {
59
+ while (SPACE.test(html[i] ?? '')) i++
60
+ if (html[i] === '>') return { name: name.toLowerCase(), attrs, spans, raw: html.slice(start, i + 1), index: start, end: i + 1, closing, selfClosing }
61
+ if (html[i] === '/') { selfClosing = html[i + 1] === '>'; i++; continue }
62
+ if (i >= html.length) break
63
+ selfClosing = false
64
+ let attr = html[i++]
65
+ while (i < html.length && !SPACE.test(html[i]) && !'/>='.includes(html[i])) attr += html[i++]
66
+ while (SPACE.test(html[i] ?? '')) i++
67
+ let value = ''
68
+ let span = [i - start, i - start]
69
+ if (html[i] === '=') {
70
+ i++
71
+ while (SPACE.test(html[i] ?? '')) i++
72
+ const quote = html[i]
73
+ if (quote === '"' || quote === "'") {
74
+ const close = html.indexOf(quote, i + 1)
75
+ if (close < 0) return null
76
+ value = html.slice(i + 1, close)
77
+ span = [i + 1 - start, close - start]
78
+ i = close + 1
79
+ } else {
80
+ const from = i
81
+ while (i < html.length && !SPACE.test(html[i]) && html[i] !== '>') i++
82
+ value = html.slice(from, i)
83
+ span = [from - start, i - start]
84
+ }
85
+ }
86
+ attr = attr.toLowerCase()
87
+ if (!(attr in attrs)) { attrs[attr] = decode(value); spans[attr] = span }
88
+ }
89
+ return null
90
+ }
91
+
92
+ // The children of a token, whatever kind it is.
93
+ const childrenOf = token => [
94
+ ...token.tokens ?? [],
95
+ ...token.items ?? [],
96
+ ...(token.header ?? []).flatMap(cell => cell.tokens ?? []),
97
+ ...(token.rows ?? []).flat().flatMap(cell => cell.tokens ?? []),
98
+ ]
99
+
100
+ /**
101
+ * The HTML in a piece of Markdown, as marked reads it: [{ text, start }],
102
+ * where `start` is the HTML's offset in the source, or null where it cannot
103
+ * be told (such as a tag spread over several lines of a quotation).
104
+ */
105
+ export function htmlIn(markdown = '') {
106
+ const source = String(markdown ?? '').replace(/\r\n?/g, '\n')
107
+ const found = []
108
+ // A child's raw text is found inside its parent's, after its older sibling.
109
+ const walk = (tokens, parent, parentStart) => {
110
+ let cursor = 0
111
+ for (const token of tokens) {
112
+ const at = parentStart == null || token.raw == null ? -1 : parent.indexOf(token.raw, cursor)
113
+ const start = at < 0 ? null : parentStart + at
114
+ if (at >= 0) cursor = at + token.raw.length
115
+ if (token.type === 'html') found.push({ text: token.raw ?? token.text, start })
116
+ const children = childrenOf(token)
117
+ if (children.length) walk(children, token.raw ?? '', start)
118
+ }
119
+ }
120
+ walk(lexer.lexer(source), source, 0)
121
+ return found
122
+ }
123
+
124
+ /**
125
+ * The tags in a piece of Markdown, opening and closing, in order, each with
126
+ * `start` and `end` in the source (null where it cannot be told). Tags in
127
+ * code are not tags.
128
+ */
129
+ export function tagsIn(markdown = '') {
130
+ return htmlIn(markdown).flatMap(({ text, start }) => scanTags(text).map(tag => ({
131
+ ...tag,
132
+ start: start == null ? null : start + tag.index,
133
+ end: start == null ? null : start + tag.end,
134
+ })))
135
+ }
136
+
137
+ /**
138
+ * The closing tag of `tags[at]` in a list from tagsIn or scanTags, past any
139
+ * of the same name inside it; null when it has none.
140
+ */
141
+ export function closingTagOf(tags, at) {
142
+ const open = tags[at]
143
+ if (open.closing || open.selfClosing) return null
144
+ let depth = 0
145
+ for (const tag of tags.slice(at + 1)) {
146
+ if (tag.name !== open.name || tag.selfClosing) continue
147
+ if (!tag.closing) depth++
148
+ else if (depth) depth--
149
+ else return tag
150
+ }
151
+ return null
152
+ }
153
+
154
+ /**
155
+ * Self-closing tags of the named elements given a closing tag. A browser
156
+ * takes `<poll />` for `<poll>`, so what follows would end up inside it.
157
+ */
158
+ export function closeTags(html, names) {
159
+ let out = ''
160
+ let last = 0
161
+ for (const tag of scanTags(html)) {
162
+ if (!tag.selfClosing || !names.has(tag.name)) continue
163
+ out += html.slice(last, tag.index) + tag.raw.replace(/\s*\/>$/, '>') + `</${tag.name}>`
164
+ last = tag.end
165
+ }
166
+ return out + html.slice(last)
167
+ }
168
+
169
+ /** The opening tags (and self-closing ones) in a piece of Markdown. */
170
+ export const openTagsIn = markdown => tagsIn(markdown).filter(tag => !tag.closing)
171
+
172
+ /**
173
+ * Calls `fn(tag)` for every tag in a piece of Markdown and puts what it
174
+ * returns in its place (the tag stays when it returns undefined). Line ends
175
+ * come back as \n.
176
+ */
177
+ export function replaceTagsIn(markdown, fn) {
178
+ const source = String(markdown ?? '').replace(/\r\n?/g, '\n')
179
+ let out = ''
180
+ let last = 0
181
+ for (const tag of tagsIn(source)) {
182
+ if (tag.start == null || tag.start < last) continue
183
+ const replacement = fn(tag)
184
+ if (replacement == null) continue
185
+ out += source.slice(last, tag.start) + replacement
186
+ last = tag.end
187
+ }
188
+ return out + source.slice(last)
189
+ }
190
+
191
+ /** A tag's text with one attribute's value replaced (as written, not encoded). */
192
+ export function withAttribute(tag, name, value) {
193
+ const [from, to] = tag.spans[name]
194
+ return tag.raw.slice(0, from) + value + tag.raw.slice(to)
195
+ }
@@ -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
 
@@ -21,8 +21,8 @@ export function userExtensionsDir() {
21
21
  }
22
22
 
23
23
  // The built-in themes and palettes this user removed (`mdeck themes remove
24
- // aurora`): they stay in mdeck's folder but are not offered. Kept as
25
- // "theme:aurora" and "palette:neon".
24
+ // glass`): they stay in mdeck's folder but are not offered. Kept as
25
+ // "theme:glass" and "palette:nordic".
26
26
  const removedFile = () => resolve(mdeckHome(), 'removed.json')
27
27
  export function removedBuiltIns() {
28
28
  let saved = {}
@@ -1,9 +1,9 @@
1
- // Themes and palettes that came with mdeck before 3.0 and now live in the
2
- // theme repository. A deck that names one is told how to install it instead
1
+ // Themes and palettes that once came with mdeck and now live in the theme
2
+ // repository. A deck that names one is told how to install it instead
3
3
  // of being told the name is unknown.
4
4
  export const MOVED = {
5
- themes: ['duet', 'editorial', 'fhnw', 'terminal', 'sketch'],
6
- palettes: ['cobalt', 'terra', 'brand', 'phosphor', 'pastel', 'forest'],
5
+ themes: ['duet', 'editorial', 'fhnw', 'terminal', 'sketch', 'aurora'],
6
+ palettes: ['terra', 'brand', 'phosphor', 'pastel', 'forest', 'neon'],
7
7
  }
8
8
 
9
9
  // Palettes that were removed because another one looks almost the same, by
@@ -3,9 +3,11 @@ import { useEffect, useRef } from 'preact/hooks'
3
3
  import { render } from 'preact'
4
4
  import { marked, Parser } from 'marked'
5
5
  import { registry } from '../runtime/registry'
6
+ import { scanTags, closeTags } from '../core/tags.js'
7
+ import { expandPollLists } from '../core/pollList.js'
6
8
  import { InkLayer } from '../runtime/ink/InkLayer.jsx'
7
9
  import themedSvgs from 'virtual:deck-svgs'
8
- import { renderCitations, citationsHtml as referencesOf } from '../runtime/citations.js'
10
+ import { renderCitations, citationsHtml as referencesOf, bibliographyOf } from '../runtime/citations.js'
9
11
 
10
12
  // ─── Content extraction ────────────────────────────────────────────────────
11
13
 
@@ -39,7 +41,9 @@ function preprocessFootnotes(markdown) {
39
41
  export { t } from '../core/labels.js'
40
42
 
41
43
  export function extractContent(markdown, { headingLevels = [], paragraph = false } = {}) {
42
- const processed = markdown
44
+ // marked's preprocess hook (src/runtime/markedSetup.js) does not run for
45
+ // marked.lexer, so the lists of polls become their options here.
46
+ const processed = expandPollLists(markdown)
43
47
  const footnotesHtml = ''
44
48
  const tokens = marked.lexer(processed)
45
49
  const headings = {}
@@ -74,17 +78,19 @@ function themedDataSvg(src) {
74
78
  return /var\(--/.test(markup) && /^\s*<svg\b/.test(markup) ? markup : null
75
79
  }
76
80
 
77
- const attribute = (tag, name) => tag.match(new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i'))?.slice(1).find(v => v != null)
78
-
79
81
  // Pictures in slide text keep the drawing's own size, like an <img>.
80
82
  export function inlineThemedPictures(html) {
81
83
  if (!html || !html.includes('<img')) return html
82
- return html.replace(/<img\b[^>]*>/gi, tag => {
83
- const markup = themedSvg(attribute(tag, 'src'))
84
- if (!markup) return tag
85
- const alt = attribute(tag, 'alt') ?? ''
86
- return `<span class="themed-picture themed-picture--inline" role="img" aria-label="${alt}">${markup}</span>`
87
- })
84
+ let out = ''
85
+ let last = 0
86
+ for (const tag of scanTags(html)) {
87
+ const markup = tag.name === 'img' && !tag.closing && themedSvg(tag.attrs.src)
88
+ if (!markup) continue
89
+ const alt = (tag.attrs.alt ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;')
90
+ out += html.slice(last, tag.index) + `<span class="themed-picture themed-picture--inline" role="img" aria-label="${alt}">${markup}</span>`
91
+ last = tag.end
92
+ }
93
+ return out + html.slice(last)
88
94
  }
89
95
 
90
96
  // A picture from the slide's settings; `fit: cover` fills its box like a
@@ -100,6 +106,10 @@ export function Picture({ src, alt, fit, position, class: className, ...attribut
100
106
 
101
107
  // ─── Hydration for inline components ──────────────────────────────────────
102
108
 
109
+ // Components may be written `<poll … />`, which a browser takes for an open
110
+ // tag, putting what follows inside the component, where it is lost.
111
+ const COMPONENT_TAGS = new Set(Object.keys(registry))
112
+
103
113
  export function HtmlContent({ html, class: className, ...attributes }) {
104
114
  const ref = useRef()
105
115
 
@@ -119,7 +129,7 @@ export function HtmlContent({ html, class: className, ...attributes }) {
119
129
  return () => mounted.forEach(wrapper => render(null, wrapper))
120
130
  }, [html])
121
131
 
122
- return <div class={className} {...attributes} ref={ref} dangerouslySetInnerHTML={{ __html: inlineThemedPictures(html) }} />
132
+ return <div class={className} {...attributes} ref={ref} dangerouslySetInnerHTML={{ __html: closeTags(inlineThemedPictures(html), COMPONENT_TAGS) }} />
123
133
  }
124
134
 
125
135
  // ─── Header / footer / footnotes rails ────────────────────────────────────
@@ -188,11 +198,12 @@ export function prepareSlide(slide) {
188
198
  .sort((a, b) => position(a[1]) - position(b[1]))
189
199
  let separator = '\u0000mdeck-region\u0000'
190
200
  while (entries.some(([, r]) => r.content.includes(separator))) separator += '\u0000'
191
- const cited = renderCitations(entries.map(([, r]) => r.content).join(separator))
201
+ const bib = bibliographyOf(slide.deckConfig)
202
+ const cited = renderCitations(entries.map(([, r]) => r.content).join(separator), bib)
192
203
  const { processed, footnotesHtml } = preprocessFootnotes(cited.markdown)
193
204
  const pieces = processed.split(separator)
194
205
  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)
206
+ const citationsHtml = slide.deckConfig?.show?.citations === false ? '' : referencesOf(cited.ids, bib)
196
207
  return { ...slide, regions, content: regions.body?.content ?? '', footnotesHtml, citationsHtml }
197
208
  }
198
209
 
@@ -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)); }