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
@@ -7,13 +7,25 @@
7
7
  const code = location.pathname.split('/').filter(Boolean).pop()
8
8
  const root = document.getElementById('answer')
9
9
  const FALLBACK = {
10
- en: { waiting: 'The next question will appear here.', pick: 'Tap one answer.', thanks: 'Thanks! You chose “{choice}”. Tap another to change.', send: 'Send', sent: 'Sent. Thank you!', offline: 'Connecting…' },
11
- de: { waiting: 'Die nächste Frage erscheint hier.', pick: 'Eine Antwort antippen.', thanks: 'Danke! Gewählt: „{choice}“. Zum Ändern eine andere antippen.', send: 'Senden', sent: 'Gesendet. Danke!', offline: 'Verbinde…' },
10
+ en: {
11
+ waiting: 'The next question will appear here.', pick: 'Tap one answer.', thanks: 'Thanks! You chose “{choice}”. Tap another to change.', send: 'Send', sent: 'Sent. Thank you!', offline: 'Connecting…',
12
+ pickSeveral: 'Tap all that apply.', thanksSeveral: 'Thanks! You chose {choice}. Tap to change.', closed: 'Closed: no more answers.',
13
+ right: 'Right!', wrong: 'Not quite. The right answer: {answer}', solution: 'The right answer: {answer}',
14
+ number: 'A number', invalid: 'Enter a number, such as 0.5 or 1/2.', numberSent: 'Sent: {value}. Send another to change.',
15
+ },
16
+ de: {
17
+ waiting: 'Die nächste Frage erscheint hier.', pick: 'Eine Antwort antippen.', thanks: 'Danke! Gewählt: „{choice}“. Zum Ändern eine andere antippen.', send: 'Senden', sent: 'Gesendet. Danke!', offline: 'Verbinde…',
18
+ pickSeveral: 'Alle zutreffenden antippen.', thanksSeveral: 'Danke! Gewählt: {choice}. Zum Ändern antippen.', closed: 'Geschlossen: keine weiteren Antworten.',
19
+ right: 'Richtig!', wrong: 'Leider nicht. Richtig ist: {answer}', solution: 'Richtig ist: {answer}',
20
+ number: 'Eine Zahl', invalid: 'Eine Zahl eingeben, z. B. 0,5 oder 1/2.', numberSent: 'Gesendet: {value}. Zum Ändern eine andere senden.',
21
+ },
12
22
  }
13
23
  let words = FALLBACK[navigator.language?.slice(0, 2) === 'de' ? 'de' : 'en']
14
24
  let current = null
15
25
  let connected = false
16
26
  let status = ''
27
+ // What is typed into a number field, kept while the page redraws.
28
+ let draft = ''
17
29
 
18
30
  const me = (() => {
19
31
  try {
@@ -33,7 +45,7 @@
33
45
  if (key.startsWith('on')) node.addEventListener(key.slice(2), value)
34
46
  else if (value != null && value !== false) node.setAttribute(key, value === true ? '' : value)
35
47
  }
36
- node.append(...children.flat().filter(child => child != null))
48
+ node.append(...children.flat(Infinity).filter(child => child != null))
37
49
  return node
38
50
  }
39
51
 
@@ -50,37 +62,146 @@
50
62
  for (const url of wanted) if (!document.querySelector(`link[data-theme-font][href="${CSS.escape(url)}"]`)) document.head.append(el('link', { rel: 'stylesheet', href: url, 'data-theme-font': true }))
51
63
  }
52
64
 
65
+ // A question or option drawn by the deck (Markdown and maths as MathML,
66
+ // src/components/inlineText.js), kept to text, emphasis and MathML. The
67
+ // nodes are moved out of the parsed document, never parsed again.
68
+ const SAFE_TAGS = new Set('em strong b i code del s sub sup br math semantics annotation mrow mi mo mn ms mtext mspace msup msub msubsup mfrac msqrt mroot mstyle mover munder munderover mtable mtr mtd mpadded mphantom menclose'.split(' '))
69
+ const SAFE_ATTRIBUTES = new Set('mathvariant display displaystyle scriptlevel stretchy fence separator symmetric largeop movablelimits accent accentunder lspace rspace minsize maxsize linethickness width height depth voffset columnalign rowalign columnspacing rowspacing notation encoding'.split(' '))
70
+ const DROPPED = new Set('script style template noscript iframe object embed svg textarea title'.split(' '))
71
+ function clean(node) {
72
+ for (const child of [...node.childNodes]) {
73
+ if (child.nodeType === Node.TEXT_NODE) continue
74
+ if (child.nodeType !== Node.ELEMENT_NODE || DROPPED.has(child.localName)) { child.remove(); continue }
75
+ clean(child)
76
+ if (!SAFE_TAGS.has(child.localName)) { child.replaceWith(...child.childNodes); continue }
77
+ for (const { name } of [...child.attributes]) if (!SAFE_ATTRIBUTES.has(name)) child.removeAttribute(name)
78
+ }
79
+ return node
80
+ }
81
+ const drawn = (html, text) => {
82
+ if (typeof html !== 'string') return text
83
+ const body = clean(new DOMParser().parseFromString(html, 'text/html').body)
84
+ return el('span', { class: 'answer-rich' }, ...body.childNodes)
85
+ }
86
+ // An option as this phone shows it: its key (A, B… with `buttons`), or drawn.
87
+ const shown = (activity, value) => {
88
+ const i = activity.options.indexOf(value)
89
+ return activity.keys?.[i] ?? drawn(activity.optionsHtml?.[i], value)
90
+ }
91
+ // A sentence with nodes put in for {name}; without {name}, the sentence.
92
+ const sentence = (text, name, part) => {
93
+ if (!text.includes(`{${name}}`)) return text
94
+ const [before, after] = text.split(`{${name}}`)
95
+ return [before, part, after]
96
+ }
97
+ const listed = parts => parts.flatMap((part, i) => i ? [', ', part] : [part])
98
+
99
+ // A number as src/components/Numeric.jsx reads it: 0.5, 0,5, −3, 1e-3, 1/2.
100
+ function parseNumber(text) {
101
+ const plain = String(text ?? '').trim().replace(/\s+/g, '').replace(/\u2212/g, '-').replace(/(\d),(\d)/g, '$1.$2')
102
+ const parts = plain.match(/^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(?:\/((?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?))?$/i)
103
+ if (!parts) return null
104
+ const value = parts[2] ? Number(parts[1]) / Number(parts[2]) : Number(parts[1])
105
+ return Number.isFinite(value) ? value : null
106
+ }
107
+
108
+ // The activity's controls (src/components/activity.jsx), from its room's
109
+ // state: `closed`, and `revealed` with the `solution` once the right
110
+ // answer shows. Only the state, not everyone's answers (?only=state).
111
+ let controls = {}, controlRoom = null, controlSource = null
112
+ function followControls(room) {
113
+ if (room === controlRoom) return
114
+ controlSource?.close()
115
+ controlSource = null
116
+ controls = {}
117
+ controlRoom = room
118
+ if (!room) return
119
+ controlSource = new EventSource(new URL(`rooms/${encodeURIComponent(`${code}.${room}`)}/events?only=state`, base))
120
+ const take = event => {
121
+ const state = JSON.parse(event.data).state
122
+ controls = state && typeof state === 'object' ? state : {}
123
+ render()
124
+ }
125
+ controlSource.addEventListener('snapshot', take)
126
+ controlSource.addEventListener('state', take)
127
+ }
128
+ // Once the right answer shows: whether this phone's was right, or what it is.
129
+ const verdict = (answered, right, solution) => el('p', { class: `answer-verdict${answered ? (right ? ' is-right' : ' is-wrong') : ''}`, role: 'status' },
130
+ answered ? (right ? words.right : sentence(words.wrong, 'answer', solution)) : sentence(words.solution, 'answer', solution))
131
+ const statusLine = text => el('p', { class: 'answer-status', role: 'status' }, status || (controls.closed ? words.closed : text))
132
+
53
133
  async function answer(room, value) {
54
134
  const response = await fetch(new URL(`rooms/${encodeURIComponent(`${code}.${room}`)}`, base), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from: me, data: { value } }) })
55
135
  if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? String(response.status))
56
136
  }
57
137
 
58
138
  const FORMS = {
59
- // { question, options: [..] }: one of several answers, which can be changed.
139
+ // { question, options, optionsHtml | keys, multiple }: one answer, or with
140
+ // `multiple` several, which can be changed. With `keys` the buttons show
141
+ // A, B… (or 1, 2…) and the options are on the slide.
60
142
  choice(activity, room) {
61
- const picked = remembered(room)
143
+ const several = !!activity.multiple
144
+ const saved = remembered(room)
145
+ let picks = []
146
+ if (several) { try { picks = JSON.parse(saved ?? '[]') } catch {} }
147
+ else if (saved != null) picks = [saved]
148
+ picks = activity.options.filter(option => Array.isArray(picks) && picks.includes(option))
149
+ const vote = async next => {
150
+ try {
151
+ await answer(room, several ? next : next[0])
152
+ remember(room, several ? JSON.stringify(next) : next[0])
153
+ status = ''
154
+ } catch (error) { status = error.message }
155
+ render()
156
+ }
157
+ const solution = controls.revealed && Array.isArray(controls.solution) ? activity.options.filter(option => controls.solution.includes(option)) : null
158
+ const right = solution && (several ? picks.length === solution.length && picks.every(pick => solution.includes(pick)) : solution.includes(picks[0]))
62
159
  return [
63
- el('div', { class: 'answer-options' }, activity.options.map(option => el('button', {
64
- class: option === picked ? 'is-picked' : null, 'aria-pressed': String(option === picked),
65
- onclick: async () => {
66
- try { await answer(room, option); remember(room, option); status = fill(words.thanks, { choice: option }) } catch (error) { status = error.message }
67
- render()
68
- },
69
- }, option))),
70
- el('p', { class: 'answer-status', role: 'status' }, status || (picked ? fill(words.thanks, { choice: picked }) : words.pick)),
160
+ el('div', { class: activity.keys ? 'answer-keys' : 'answer-options' }, activity.options.map(option => {
161
+ const on = picks.includes(option)
162
+ return el('button', {
163
+ class: on ? 'is-picked' : null, 'aria-pressed': String(on), disabled: !!controls.closed,
164
+ onclick: () => vote(several ? activity.options.filter(o => o === option ? !on : picks.includes(o)) : [option]),
165
+ }, several && !activity.keys ? el('span', { class: 'answer-check', 'aria-hidden': 'true' }) : null, shown(activity, option))
166
+ })),
167
+ statusLine(picks.length
168
+ ? sentence(several ? words.thanksSeveral : words.thanks, 'choice', listed(picks.map(pick => shown(activity, pick))))
169
+ : several ? words.pickSeveral : words.pick),
170
+ solution?.length ? verdict(picks.length > 0, right, listed(solution.map(option => shown(activity, option)))) : null,
171
+ ]
172
+ },
173
+ // { question, placeholder }: a number, typed; the latest one counts.
174
+ numeric(activity, room) {
175
+ const sent = remembered(room)
176
+ const input = el('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', enterkeyhint: 'send', placeholder: activity.placeholder || words.number, disabled: !!controls.closed })
177
+ input.value = draft
178
+ input.addEventListener('input', () => { draft = input.value })
179
+ const form = el('form', { class: 'answer-number', onsubmit: async event => {
180
+ event.preventDefault()
181
+ const text = input.value.trim()
182
+ if (parseNumber(text) == null) { status = words.invalid; render(); return }
183
+ try { await answer(room, text); remember(room, text); draft = ''; status = '' } catch (error) { status = error.message }
184
+ render()
185
+ } }, input, el('button', { type: 'submit', disabled: !!controls.closed }, words.send))
186
+ const solution = controls.revealed && controls.solution && Number.isFinite(controls.solution.value) ? controls.solution : null
187
+ const right = solution && sent != null && Math.abs(parseNumber(sent) - solution.value) <= (solution.tolerance ?? 0)
188
+ return [
189
+ form,
190
+ statusLine(sent != null ? fill(words.numberSent, { value: sent }) : ''),
191
+ solution ? verdict(sent != null, right, drawn(solution.html, solution.text)) : null,
71
192
  ]
72
193
  },
73
194
  // { question, placeholder, maxLength }: short text, as often as people like.
74
195
  text(activity, room) {
75
- const input = el('textarea', { rows: 3, maxlength: activity.maxLength ?? 200, placeholder: activity.placeholder ?? '' })
196
+ const input = el('textarea', { rows: 3, maxlength: activity.maxLength ?? 200, placeholder: activity.placeholder ?? '', disabled: !!controls.closed })
76
197
  const form = el('form', { class: 'answer-text', onsubmit: async event => {
77
198
  event.preventDefault()
78
199
  const value = input.value.trim()
79
200
  if (!value) return
80
201
  try { await answer(room, value); status = words.sent } catch (error) { status = error.message }
81
202
  render()
82
- } }, input, el('button', { type: 'submit' }, words.send))
83
- return [form, el('p', { class: 'answer-status', role: 'status' }, status)]
203
+ } }, input, el('button', { type: 'submit', disabled: !!controls.closed }, words.send))
204
+ return [form, statusLine('')]
84
205
  },
85
206
  // { question, min, max, minLabel, maxLabel }: a number on a scale.
86
207
  scale(activity, room) {
@@ -89,14 +210,14 @@
89
210
  for (let n = activity.min ?? 1; n <= (activity.max ?? 5); n++) steps.push(String(n))
90
211
  return [
91
212
  el('div', { class: 'answer-scale' }, steps.map(step => el('button', {
92
- class: step === picked ? 'is-picked' : null, 'aria-pressed': String(step === picked),
213
+ class: step === picked ? 'is-picked' : null, 'aria-pressed': String(step === picked), disabled: !!controls.closed,
93
214
  onclick: async () => {
94
- try { await answer(room, Number(step)); remember(room, step); status = fill(words.thanks, { choice: step }) } catch (error) { status = error.message }
215
+ try { await answer(room, Number(step)); remember(room, step); status = '' } catch (error) { status = error.message }
95
216
  render()
96
217
  },
97
218
  }, step))),
98
- (activity.minLabel || activity.maxLabel) && el('div', { class: 'answer-scale-labels' }, el('span', {}, activity.minLabel ?? ''), el('span', {}, activity.maxLabel ?? '')),
99
- el('p', { class: 'answer-status', role: 'status' }, status || (picked ? fill(words.thanks, { choice: picked }) : words.pick)),
219
+ (activity.minLabel || activity.maxLabel) && el('div', { class: 'answer-scale-labels' }, el('span', {}, drawn(activity.minLabelHtml, activity.minLabel ?? '')), el('span', {}, drawn(activity.maxLabelHtml, activity.maxLabel ?? ''))),
220
+ statusLine(picked ? fill(words.thanks, { choice: picked }) : words.pick),
100
221
  ]
101
222
  },
102
223
  }
@@ -104,14 +225,15 @@
104
225
  function render() {
105
226
  const activity = current?.activity
106
227
  const form = activity && FORMS[activity.type]
107
- root.replaceChildren(
228
+ // Without the filter, replaceChildren() writes a missing part out as "null".
229
+ root.replaceChildren(...[
108
230
  form
109
- ? el('main', { class: 'answer' }, activity.question && el('h1', {}, activity.question), form(activity, current.room))
231
+ ? el('main', { class: 'answer' }, activity.question && el('h1', {}, drawn(activity.questionHtml, activity.question)), form(activity, current.room))
110
232
  : el('main', { class: 'answer answer--waiting' }, el('p', {}, connected ? words.waiting : words.offline)),
111
233
  current?.title ? el('footer', {}, current.title) : null,
112
234
  // The presenter's screen sends the link when other devices can follow the slides.
113
235
  /^https?:\/\//.test(current?.follow ?? '') ? el('a', { class: 'answer-follow', href: current.follow }, words.follow ?? 'Follow the slides') : null,
114
- )
236
+ ].filter(Boolean))
115
237
  }
116
238
 
117
239
  function follow(state) {
@@ -119,7 +241,8 @@
119
241
  current = state
120
242
  if (state?.labels) words = { ...words, ...state.labels }
121
243
  applyLook(state?.look, state?.lang)
122
- if (state?.room !== previous) status = ''
244
+ if (state?.room !== previous) { status = ''; draft = '' }
245
+ followControls(state?.activity ? state.room : null)
123
246
  render()
124
247
  }
125
248
 
@@ -9,7 +9,8 @@
9
9
  // answers.
10
10
  import { useCallback, useEffect, useState } from 'preact/hooks'
11
11
  import { sessionCode } from './code.js'
12
- import { pairToken } from './pairing.js'
12
+ import { pairToken, pairHeaders } from './pairing.js'
13
+ import { keptAnswers, answersAsMessages } from '../core/results.js'
13
14
  import { devPath } from '../core/devPath.js'
14
15
  import { t, deckLanguage } from '../core/labels.js'
15
16
 
@@ -132,32 +133,64 @@ const localFollowUrl = () => `${window.location.origin}${devPath('')}?view=follo
132
133
  // show no join code, so a quick capture never shows a passing warning.
133
134
  const reach = info => ({ known: info.reachable != null, offline: info.reachable === false })
134
135
 
136
+ // The answers kept in the talk (<deck>.results.json), set by the runtime: a
137
+ // build shows them where no room server answers, instead of no answers.
138
+ let savedRooms = {}
139
+ export function setSavedResults(results) { savedRooms = results?.rooms ?? {} }
140
+
141
+ // While `mdeck run` runs, the presenter's screens keep each activity's
142
+ // answers in that file (src/build/resultsPlugin.js), a moment after the last
143
+ // one came in. Elsewhere there is nothing to keep them in.
144
+ const keeping = new Map()
145
+ export function keepAnswers(room, messages, { closed = false } = {}) {
146
+ if (!import.meta.env?.DEV) return
147
+ clearTimeout(keeping.get(room))
148
+ keeping.set(room, setTimeout(() => {
149
+ keeping.delete(room)
150
+ fetch(devPath('__mdeck/results'), { method: 'POST', headers: pairHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ room, closed, answers: keptAnswers(messages) }) }).catch(() => {})
151
+ }, 800))
152
+ }
153
+
135
154
  /**
136
155
  * Follow a room's answers on a slide.
137
156
  * messages every answer so far: { n, at, from, data: { value } }
138
157
  * joinUrl the deck's join link for the QR code, or null if phones cannot
139
158
  * reach the server; localJoinUrl works on this computer
140
159
  * canReset / reset() presenter only
160
+ * state the room's state, which only the presenter sets (setState):
161
+ * an activity's controls, such as closed or revealed
162
+ * present how many phones (and followers) are connected to the deck,
163
+ * or null before the server said
141
164
  * connected false until the live stream is open
142
165
  * offline no room server answered: a PDF, a file sent to readers or a
143
166
  * build hosted without one. Activities then show their question
144
- * as a record of the talk, without a join code.
167
+ * as a record of the talk, without a join code, and `messages`
168
+ * are the answers kept in the talk, if any.
145
169
  * Off the current slide the room keeps its last messages but disconnects.
146
170
  */
147
171
  export function useRoom(room) {
148
172
  const live = useActive(room)
149
173
  const [messages, setMessages] = useState([])
150
174
  const [connected, setConnected] = useState(false)
175
+ const [state, setRoomState] = useState(null)
176
+ const [present, setPresent] = useState(null)
151
177
  const [info, setInfo] = useState({})
152
178
 
153
179
  useEffect(() => { serverInfo().then(setInfo) }, [])
154
180
 
155
181
  useEffect(() => {
156
182
  if (!live || !room || typeof EventSource === 'undefined') return
157
- const source = new EventSource(`${roomPath(room)}/events`)
183
+ const source = new EventSource(`${roomPath(room)}/events?presence=1`)
158
184
  source.addEventListener('open', () => setConnected(true))
159
185
  source.addEventListener('error', () => setConnected(false))
160
- source.addEventListener('snapshot', event => setMessages(JSON.parse(event.data).messages))
186
+ source.addEventListener('snapshot', event => {
187
+ const snapshot = JSON.parse(event.data)
188
+ setMessages(snapshot.messages)
189
+ setRoomState(snapshot.state ?? null)
190
+ if (snapshot.presence) setPresent(snapshot.presence.phones)
191
+ })
192
+ source.addEventListener('state', event => setRoomState(JSON.parse(event.data).state ?? null))
193
+ source.addEventListener('presence', event => setPresent(JSON.parse(event.data).phones))
161
194
  source.addEventListener('message', event => { const message = JSON.parse(event.data); setMessages(list => list.some(m => m.n === message.n) ? list : [...list, message]) })
162
195
  source.addEventListener('reset', () => setMessages([]))
163
196
  return () => { source.close(); setConnected(false) }
@@ -168,7 +201,14 @@ export function useRoom(room) {
168
201
  if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? 'Could not reset')
169
202
  }, [room])
170
203
 
171
- return { messages, reset, connected, canReset: !!info.canReset, joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
204
+ // Ordered like the session's state: the latest change of any screen wins.
205
+ const setState = useCallback(async next => {
206
+ const response = await fetch(`${controlRoomPath(room)}/state`, { method: 'POST', headers: withKey({ 'Content-Type': 'application/json' }), body: JSON.stringify({ state: { ...next, at: Date.now(), screen: SCREEN } }) })
207
+ if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? 'Could not change the room')
208
+ }, [room])
209
+
210
+ const kept = info.reachable === false ? savedRooms[room] : null
211
+ return { messages: kept ? answersAsMessages(kept.answers) : messages, reset, state, setState, present, connected, canReset: !!info.canReset, joinUrl: joinUrl(info), localJoinUrl: localJoinUrl(), code: settings.code, ...reach(info) }
172
212
  }
173
213
 
174
214
  /** The deck's join link alone, for a slide that only invites people in (<qrcode join />). */
@@ -218,7 +258,12 @@ export function captureLook(doc = lookSource()) {
218
258
  }
219
259
 
220
260
  // The words the answer page shows, in the deck's language.
221
- const phoneWords = () => ({ waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent'), follow: t('follow.open') })
261
+ const phoneWords = () => ({
262
+ waiting: t('respond.waiting'), pick: t('poll.pick'), thanks: t('poll.thanks'), send: t('respond.send'), sent: t('respond.sent'), follow: t('follow.open'),
263
+ pickSeveral: t('poll.pickSeveral'), thanksSeveral: t('poll.thanksSeveral'), several: t('poll.several'),
264
+ closed: t('respond.closed'), right: t('respond.right'), wrong: t('respond.wrong'), solution: t('respond.solution'),
265
+ number: t('numeric.placeholder'), invalid: t('numeric.invalid'), numberSent: t('numeric.sent'),
266
+ })
222
267
 
223
268
  // The presenter's screen announces the activity on the current slide and
224
269
  // repeats it every few seconds, so phones that join late and a server
@@ -1,17 +1,11 @@
1
1
  // Finds the component tag a phone should see: the one whose `room` attribute
2
2
  // matches, with its content if it has any.
3
- const escape = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
3
+ import { tagsIn, closingTagOf } from '../core/tags.js'
4
4
 
5
- // Code is not an activity: fenced blocks and `inline code` are blanked out
6
- // (keeping every other character where it was) before searching.
7
- const blank = text => text.replace(/[^\n]/g, ' ')
8
- export function maskCode(source = '') {
9
- return source.replace(/^(```+|~~~+)[^\n]*\n[\s\S]*?^\1[ \t]*$/gm, blank).replace(/`[^`\n]+`/g, blank)
10
- }
11
-
12
- // Every activity in a source, in order: [{ tag, room }].
5
+ // Every activity in a source, in order: [{ tag, room }]. Tags in code are
6
+ // not activities (src/core/tags.js).
13
7
  export function roomsIn(source = '') {
14
- return [...maskCode(source).matchAll(/<([a-z][a-z0-9-]*)\b[^>]*?\broom\s*=\s*(["'])([^"']+)\2/gi)].map(match => ({ tag: match[1].toLowerCase(), room: match[3] }))
8
+ return tagsIn(source).filter(tag => !tag.closing && tag.attrs.room).map(tag => ({ tag: tag.name, room: tag.attrs.room }))
15
9
  }
16
10
 
17
11
  // The rooms on one slide, from its body and its named regions (the body is
@@ -30,9 +24,11 @@ export function slideTitleFor(deck, room) {
30
24
  }
31
25
 
32
26
  export function findRoomTag(source, room) {
33
- const open = new RegExp(`<([a-z][a-z0-9-]*)\\b[^>]*?\\broom\\s*=\\s*(["'])${escape(room)}\\2[^>]*>`, 'i').exec(maskCode(source))
34
- if (!open) return null
35
- if (open[0].endsWith('/>')) return open[0]
36
- const close = source.indexOf(`</${open[1]}>`, open.index + open[0].length)
37
- return close < 0 ? open[0] : source.slice(open.index, close + open[1].length + 3)
27
+ const text = String(source ?? '').replace(/\r\n?/g, '\n')
28
+ const tags = tagsIn(text)
29
+ const at = tags.findIndex(tag => !tag.closing && tag.attrs.room === room)
30
+ if (at < 0) return null
31
+ const open = tags[at]
32
+ const close = open.start == null ? null : closingTagOf(tags, at)
33
+ return close?.end == null ? open.raw : text.slice(open.start, close.end)
38
34
  }
package/src/live/rooms.js CHANGED
@@ -179,6 +179,7 @@ function reply(response, status, body) {
179
179
  // GET /rooms/<id>/events Server-Sent Events: snapshot, message, reset, state;
180
180
  // ?view=…&device=… counts this window as connected,
181
181
  // ?presence=1 adds who is connected to the session
182
+ // ?only=state sends the room's state alone
182
183
  // GET /rooms/<id>/presence who is connected to the session, once
183
184
  // POST /rooms/<id> { from, data } → { n }
184
185
  // POST /rooms/<id>/reset presenter only
@@ -211,9 +212,13 @@ export function liveHandler({ rooms = createRooms(), canReset = () => false, inf
211
212
  const view = searchParams.get('view'), device = searchParams.get('device')
212
213
  const present = VIEWS.includes(view) ? { view, device: DEVICES.includes(device) ? device : 'computer' } : null
213
214
  const wantsPresence = searchParams.get('presence') === '1'
214
- write('snapshot', { messages: rooms.snapshot(id), state: rooms.state(id), ...(ink.length ? { ink } : {}), ...(wantsPresence ? { presence: rooms.presence(id) } : {}) })
215
+ // ?only=state: the room's state alone, as phones follow an activity's
216
+ // controls (closed, the right answer) without everyone's answers.
217
+ const onlyState = searchParams.get('only') === 'state'
218
+ write('snapshot', { messages: onlyState ? [] : rooms.snapshot(id), state: rooms.state(id), ...(ink.length ? { ink } : {}), ...(wantsPresence ? { presence: rooms.presence(id) } : {}) })
215
219
  const unsubscribe = rooms.subscribe(id, event => {
216
220
  if (event.type === 'presence') { if (wantsPresence) write('presence', event.presence); return }
221
+ if (onlyState && event.type !== 'state') return
217
222
  write(event.type, event.type === 'message' ? event.message : event.type === 'state' ? { state: event.state } : event.type === 'ink' ? { messages: event.messages } : {})
218
223
  }, present)
219
224
  const beat = setInterval(() => response.write(': ping\n\n'), heartbeatMs)
@@ -1,14 +1,23 @@
1
- import initial from 'virtual:bibliography'
1
+ import initial, { sample as initialSample } from 'virtual:bibliography'
2
2
  import { scanCitations } from '../core/citations.js'
3
+ import { SAMPLE_REFERENCES_FILE } from '../editor/sampleDeck.js'
3
4
  import '../components/citation.css'
4
5
 
5
6
  // The deck's formatted references (built by src/build/bibliography.js), or
6
7
  // null without a `bibliography`. The editor receives new ones as the deck or
7
8
  // its reference file changes.
8
9
  let bibliography = initial
10
+ // The sample deck's, on the dev and edit servers (the design page shows it).
11
+ let sample = initialSample ?? null
12
+ let showsSample = false
9
13
  const listeners = new Set()
10
14
 
11
- export const currentBibliography = () => bibliography
15
+ // The references of the deck on screen: the sample deck's for the sample.
16
+ export const currentBibliography = () => showsSample && sample ? sample : bibliography
17
+ export function bibliographyOf(deckConfig) {
18
+ showsSample = [deckConfig?.bibliography].flat().includes(SAMPLE_REFERENCES_FILE)
19
+ return currentBibliography()
20
+ }
12
21
  export function onBibliography(listener) {
13
22
  listeners.add(listener)
14
23
  return () => listeners.delete(listener)
@@ -18,7 +27,8 @@ if (import.meta.hot) {
18
27
  import.meta.hot.accept('virtual:bibliography', next => {
19
28
  if (!next) return
20
29
  bibliography = next.default
21
- for (const listener of listeners) listener(bibliography)
30
+ sample = next.sample ?? null
31
+ for (const listener of listeners) listener(currentBibliography())
22
32
  })
23
33
  }
24
34
 
@@ -33,7 +43,7 @@ function fallback(cluster, bib) {
33
43
 
34
44
  // Puts each citation's formatted text in place; returns the keys the text
35
45
  // cites, in order, for the slide's reference footer.
36
- export function renderCitations(markdown, bib = bibliography) {
46
+ export function renderCitations(markdown, bib = currentBibliography()) {
37
47
  if (!markdown || !markdown.includes('@')) return { markdown, ids: [] }
38
48
  const known = new Set(bib?.ids ?? [])
39
49
  const clusters = scanCitations(markdown, { isKey: id => known.has(id) })
@@ -54,7 +64,7 @@ export function renderCitations(markdown, bib = bibliography) {
54
64
  }
55
65
 
56
66
  // The full references of `ids`, for a slide's footer.
57
- export function citationsHtml(ids, bib = bibliography) {
67
+ export function citationsHtml(ids, bib = currentBibliography()) {
58
68
  const entries = ids.map(id => bib?.entries[id]).filter(Boolean)
59
69
  return entries.length ? `<div class="csl-bib-body">${entries.join('')}</div>` : ''
60
70
  }
@@ -12,8 +12,8 @@
12
12
  // a hovering pen moves, at most every ~50 ms
13
13
  // { type: 'zoom', key, slideId, scale, x, y } the part of the slide this
14
14
  // window shows (stage zoom), at most every ~50 ms
15
- // { type: 'reveal', key, room, shown } a poll's right answer shown or hidden
16
- // { type: 'results', key, room, shown } a poll's results shown or hidden
15
+ // { type: 'controls', key, room, controls } an activity closed or opened,
16
+ // its right answer or results shown or hidden (components/activity.jsx)
17
17
  // { type: 'op', op } a change of the saved ink (applyOp)
18
18
  // Keys start with the sending window's id, so strokes never mix.
19
19
  import { changeInk, renameSlide } from './store.js'
@@ -50,10 +50,8 @@ export function createInkBus(stage, transports) {
50
50
  if (message.from <= known.points.length) known.points = [...known.points.slice(0, message.from), ...message.points]
51
51
  incoming.set(message.key, known)
52
52
  stage.liveStroke(message.key, known)
53
- } else if (message.type === 'reveal') {
54
- stage.dispatchEvent(new CustomEvent('pollreveal', { detail: { room: message.room, shown: !!message.shown } }))
55
- } else if (message.type === 'results') {
56
- stage.dispatchEvent(new CustomEvent('pollresults', { detail: { room: message.room, shown: !!message.shown } }))
53
+ } else if (message.type === 'controls') {
54
+ stage.dispatchEvent(new CustomEvent('pollcontrols', { detail: { room: message.room, controls: message.controls } }))
57
55
  } else if (message.type === 'zoom') {
58
56
  if (message.slideId && message.slideId === stage._inkSlideId()) stage.setZoom(message.scale > 1 ? message : null, 'remote', { announce: false })
59
57
  } else if (message.type === 'dot') {
@@ -102,13 +100,9 @@ export function createInkBus(stage, transports) {
102
100
  dotTimer ??= setTimeout(sendDot, DOT_MS)
103
101
  })
104
102
 
105
- // A poll's right answer, shown here: the others show it too.
106
- stage.addEventListener('pollreveal', ({ detail }) => {
107
- if (detail?.local) send({ type: 'reveal', key: `${me}:reveal`, room: detail.room, shown: !!detail.shown })
108
- })
109
- // A poll's results shown or hidden here: the others follow.
110
- stage.addEventListener('pollresults', ({ detail }) => {
111
- if (detail?.local) send({ type: 'results', key: `${me}:results`, room: detail.room, shown: !!detail.shown })
103
+ // An activity's controls changed here: the others follow.
104
+ stage.addEventListener('pollcontrols', ({ detail }) => {
105
+ if (detail?.local) send({ type: 'controls', key: `${me}:controls`, room: detail.room, controls: detail.controls })
112
106
  })
113
107
 
114
108
  // The zoom: the latest view, not every step of a pinch.
@@ -13,7 +13,7 @@ import { S, PaletteSwatches } from './chrome.jsx'
13
13
  import { SlideErrorBoundary } from './SlideErrorBoundary.jsx'
14
14
  import { createEditorBridge } from './editorBridge.js'
15
15
  import { ReaderView } from './ReaderView.jsx'
16
- import { configureLive, announce, setLookSource, actAsPresenter, useSteering } from '../live/client.js'
16
+ import { configureLive, announce, setLookSource, actAsPresenter, useSteering, setSavedResults } from '../live/client.js'
17
17
  import { registry } from './registry'
18
18
  import { followActiveRooms } from '../live/follow.js'
19
19
  import { createOrder } from './syncOrder.js'
@@ -24,7 +24,9 @@ import { startFollowing } from './follow.js'
24
24
  import { claimPairing } from '../live/pairing.js'
25
25
  import { strokePath } from '../core/ink.js'
26
26
  import { inkFileName } from 'virtual:deck-ink'
27
+ import savedResults from 'virtual:deck-results'
27
28
  import { roomsIn, roomsOnSlide, findRoomTag, slideTitleFor } from '../live/roomTag.js'
29
+ import { expandPollLists } from '../core/pollList.js'
28
30
  import './reader.css'
29
31
  import { SlideRenderer, manifests } from '../layouts/renderSlide'
30
32
  import { setCalloutLabels } from './markedSetup'
@@ -80,7 +82,7 @@ function activityOn(deck, slide) {
80
82
  const room = roomsOnSlide(slide)[0]
81
83
  if (!room) return { room: null, activity: null }
82
84
  const tag = findRoomTag(slide.content ?? '', room) ?? Object.values(slide.regions ?? {}).map(region => findRoomTag(region.content ?? '', room)).find(Boolean)
83
- const element = tag && new DOMParser().parseFromString(tag, 'text/html').body.firstElementChild
85
+ const element = tag && new DOMParser().parseFromString(expandPollLists(tag), 'text/html').body.firstElementChild
84
86
  const component = element && registry[element.localName]
85
87
  if (typeof component?.phone !== 'function') return { room, activity: null }
86
88
  const props = Object.fromEntries([...element.attributes].map(attribute => [attribute.name, attribute.value]))
@@ -693,6 +695,7 @@ async function init() {
693
695
  setDeckLanguage(deckConfig)
694
696
  document.documentElement.lang = deckLanguage()
695
697
  configureLive(deckConfig)
698
+ setSavedResults(savedResults)
696
699
  const { slides } = parsed
697
700
  const presenterMode = view === 'presenter'
698
701
  const audienceMode = view === 'audience'
@@ -5,6 +5,7 @@ import '../components/callout.css'
5
5
  import '../components/columns.css'
6
6
  import { scanDirectives, sourceLines, fenceState } from '../core/source.js'
7
7
  import { CALLOUT_LABELS, labelSetFor } from '../core/labels.js'
8
+ import { expandPollLists } from '../core/pollList.js'
8
9
 
9
10
  function readDirective(src, name) {
10
11
  if (!/^:::\s*[\w-]+/.test(src)) return null
@@ -36,6 +37,8 @@ function escapeHtml(text) {
36
37
  }
37
38
 
38
39
  marked.use(markedKatex({ throwOnError: false, output: 'html' }))
40
+ // A poll's options written as a list inside the tag (src/core/pollList.js).
41
+ marked.use({ hooks: { preprocess: expandPollLists } })
39
42
 
40
43
  // marked.use() prepends via unshift, so last-registered = highest priority.
41
44
  // Register callout first (lowest priority) so columns and steps match before it.
@@ -5,6 +5,7 @@ import Poll from '../components/Poll.jsx'
5
5
  import Question from '../components/Question.jsx'
6
6
  import WordCloud from '../components/WordCloud.jsx'
7
7
  import Scale from '../components/Scale.jsx'
8
+ import Numeric from '../components/Numeric.jsx'
8
9
  import Bibliography from '../components/Bibliography.jsx'
9
10
  import deckComponents from 'virtual:deck-components'
10
11
 
@@ -23,6 +24,7 @@ export const registry = {
23
24
  question: Question,
24
25
  wordcloud: WordCloud,
25
26
  scale: Scale,
27
+ numeric: Numeric,
26
28
  bibliography: Bibliography,
27
29
  ...deckComponents,
28
30
  }
@@ -1,30 +0,0 @@
1
- schema = 1
2
- kind = "palette"
3
- id = "neon"
4
- title = "Neon"
5
- description = "Violet and magenta; loud, for workshops."
6
- version = "1.0.0"
7
- author = "Tilman Schieber"
8
- license = "MIT"
9
-
10
- [light]
11
- "--bg" = "#ffffff"
12
- "--surface" = "#f4f0fb"
13
- "--ink" = "#14072b"
14
- "--ink-soft" = "#3a2d55"
15
- "--muted" = "#6a6084"
16
- "--rule" = "#e4ddf3"
17
- "--accent" = "#9b00d9"
18
- "--accent-2" = "#d6106f"
19
- "--on-accent" = "#ffffff"
20
-
21
- [dark]
22
- "--bg" = "#0d0d0d"
23
- "--surface" = "#1a1a1a"
24
- "--ink" = "#f0f0f0"
25
- "--ink-soft" = "#cccccc"
26
- "--muted" = "#8a8a8a"
27
- "--rule" = "#2a2a2a"
28
- "--accent" = "#cc17ff"
29
- "--accent-2" = "#ff1791"
30
- "--on-accent" = "#0d0d0d"