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
@@ -2,7 +2,8 @@ import { h } from 'preact'
2
2
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
3
  import cloud from 'd3-cloud'
4
4
  import { useRoom } from '../live/client.js'
5
- import { JoinCode, ActivityFooter, wantsQr } from './activity.jsx'
5
+ import { JoinCode, ActivityFooter, ActivityQuestion, ActivityControls, useControls, wantsQr } from './activity.jsx'
6
+ import { phoneQuestion } from './inlineText.js'
6
7
  import { t } from '../core/labels.js'
7
8
  import './poll.css'
8
9
 
@@ -64,7 +65,8 @@ function layout(words, { width, height, font }) {
64
65
 
65
66
  export default function WordCloud({ room = 'words', question = '', limit = '60', height = '520', qr }) {
66
67
  const live = useRoom(room)
67
- const all = countWords(live.messages)
68
+ const controls = useControls(room, live)
69
+ const all = countWords(controls.counted(live.messages))
68
70
  const words = all.slice(0, Math.max(1, Number(limit) || 60))
69
71
  const total = all.reduce((sum, word) => sum + word.n, 0)
70
72
  const cloudHeight = Math.max(200, Number(height) || 520)
@@ -85,9 +87,9 @@ export default function WordCloud({ room = 'words', question = '', limit = '60',
85
87
  const key = words.map(word => `${word.text}:${word.n}`).join('|')
86
88
  const placed = useMemo(() => layout(words, { width: measure.width, height: cloudHeight, font: measure.font }), [key, measure.width, measure.font, measure.fontsReady, cloudHeight])
87
89
  const code = wantsQr(qr)
88
- return <div class="poll poll--cloud">
90
+ return <div class={`poll poll--cloud${controls.closed ? ' is-closed' : ''}`} ref={controls.ref}>
89
91
  <div class="poll-main">
90
- {question && <p class="poll-question">{question}</p>}
92
+ <ActivityQuestion text={question} />
91
93
  {/* Without a server and words there is nothing to place: no empty box. */}
92
94
  <div class="word-cloud" ref={ref} style={{ height: live.offline && !words.length ? 0 : `${cloudHeight}px` }}>
93
95
  {words.length
@@ -97,10 +99,12 @@ export default function WordCloud({ room = 'words', question = '', limit = '60',
97
99
  </svg>
98
100
  : !live.offline && <p class="question-empty">{t('question.empty')}</p>}
99
101
  </div>
100
- <ActivityFooter count={total} {...live} />
102
+ <ActivityFooter count={total} {...live} reset={controls.reset} closed={controls.closed}>
103
+ <ActivityControls controls={controls} messages={live.messages} offline={live.offline} />
104
+ </ActivityFooter>
101
105
  </div>
102
106
  <JoinCode room={room} {...live} qr={code} size={240} />
103
107
  </div>
104
108
  }
105
109
 
106
- WordCloud.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', question: question || slideTitle, placeholder: placeholder ?? '', maxLength: 40 })
110
+ WordCloud.phone = ({ question, placeholder }, { slideTitle = '' } = {}) => ({ type: 'text', ...phoneQuestion(question, slideTitle), placeholder: placeholder ?? '', maxLength: 40 })
@@ -1,15 +1,25 @@
1
1
  import { h } from 'preact'
2
+ import { useEffect, useRef, useState } from 'preact/hooks'
2
3
  import QrCode from './QrCode.jsx'
4
+ import { Icon } from './Icon.jsx'
3
5
  import { t } from '../core/labels.js'
6
+ import { keepAnswers } from '../live/client.js'
7
+ import { slideHtml } from './inlineText.js'
8
+ import 'katex/dist/katex.min.css'
4
9
 
5
10
  // Parts every activity on a slide shares (<poll>, <question>, <wordcloud>,
6
- // <scale>, and <qrcode join />): the deck's join code and the line with the number of
7
- // answers, the live dot and Reset.
11
+ // <scale>, <numeric> and <qrcode join />): the deck's join code, the line with
12
+ // the number of answers, the live dot, Reset and the presenter's controls.
8
13
 
9
14
  export const shortLink = url => url.replace(/^https?:\/\//, '')
10
15
  // `qr="false"` (or "no", "off") leaves the code out: it was on an earlier
11
16
  // slide (<qrcode join />), and people's phones follow along.
12
17
  export const wantsQr = qr => !/^(false|no|off|0)$/i.test(String(qr ?? 'true'))
18
+ // A switch such as `multiple`: on when given, unless it says false.
19
+ export const isSet = value => value != null && !/^(false|no|off|0)$/i.test(String(value))
20
+
21
+ /** An activity's question, Markdown with $…$ maths. */
22
+ export const ActivityQuestion = ({ text }) => text ? <p class="poll-question" dangerouslySetInnerHTML={{ __html: slideHtml(text) }} /> : null
13
23
 
14
24
  /** The join code with the short link, or how to make phones reach the server. */
15
25
  export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = true, size = 300, label = t('poll.scan'), tryHere = t('poll.tryHere') }) {
@@ -22,14 +32,85 @@ export function JoinCode({ room, joinUrl, localJoinUrl, known, offline, qr = tru
22
32
  </a>
23
33
  }
24
34
 
25
- /** Number of answers, whether the room is live, and Reset for the presenter; `children` go at the end. */
26
- export function ActivityFooter({ count, connected, canReset, reset, offline, children }) {
35
+ // What every screen of the talk shares about an activity: whether it still
36
+ // takes answers (`closed`; `upTo` is the last answer that counts), whether
37
+ // its right answer shows (`revealed`) and whether its results show. The
38
+ // activity room's state on the server holds them, so the audience window, an
39
+ // iPad, a reloaded presenter view and the phones follow: phones say the
40
+ // activity is closed and, once the answer shows, whether theirs was right
41
+ // (`solution`, sent only then). Between windows of one browser, and where
42
+ // this one may not change the room, the stage event `pollcontrols` carries
43
+ // them too (src/runtime/ink/bus.js).
44
+ const CONTROLS = ['closed', 'upTo', 'revealed', 'results']
45
+ const controlsIn = state => Object.fromEntries(CONTROLS.filter(key => state && typeof state === 'object' && key in state).map(key => [key, state[key]]))
46
+
47
+ export function useControls(room, live, { results = true, solution = null } = {}) {
48
+ const ref = useRef()
49
+ const [controls, setControls] = useState({ closed: false, upTo: 0, revealed: false, results })
50
+ const kept = JSON.stringify(controlsIn(live.state))
51
+ useEffect(() => { if (kept !== '{}') setControls(now => ({ ...now, ...JSON.parse(kept) })) }, [kept])
52
+ useEffect(() => {
53
+ const stage = ref.current?.closest('deck-stage')
54
+ if (!stage) return
55
+ const follow = event => { if (event.detail?.room === room && !event.detail.local) setControls(now => ({ ...now, ...controlsIn(event.detail.controls) })) }
56
+ stage.addEventListener('pollcontrols', follow)
57
+ return () => stage.removeEventListener('pollcontrols', follow)
58
+ }, [room])
59
+ const change = patch => {
60
+ const next = { ...controls, ...patch }
61
+ setControls(next)
62
+ ref.current?.closest('deck-stage')?.dispatchEvent(new CustomEvent('pollcontrols', { detail: { room, controls: next, local: true } }))
63
+ if (live.canReset) live.setState({ ...next, ...(next.revealed && solution != null ? { solution } : {}) }).catch(() => {})
64
+ }
65
+ // The answers that count: once closed, those that came before.
66
+ const counted = messages => controls.closed ? messages.filter(message => message.n <= controls.upTo) : messages
67
+ // They are kept beside the deck while `mdeck run` runs. A room that
68
+ // starts empty (a new server) leaves what was kept alone; one that was
69
+ // reset takes it out.
70
+ const answered = useRef(false)
71
+ const counting = counted(live.messages)
72
+ useEffect(() => {
73
+ if (!live.canReset || !live.connected || live.offline || (!counting.length && !answered.current)) return
74
+ answered.current = true
75
+ keepAnswers(room, counting, { closed: controls.closed })
76
+ }, [room, counting.length, counting.at(-1)?.n, controls.closed, live.canReset, live.connected])
77
+ const close = messages => change(controls.closed ? { closed: false } : { closed: true, upTo: Math.max(0, ...messages.map(message => message.n)) })
78
+ // Reset starts over: open, the answer and results as at the start.
79
+ const reset = () => { change({ closed: false, upTo: 0, revealed: false, results }); return live.reset() }
80
+ return { ...controls, ref, change, counted, close, reset }
81
+ }
82
+
83
+ /**
84
+ * The presenter's buttons: close (a lock), show or hide the results (an
85
+ * eye, with `results`), show the right answer (a tick, with `solve`).
86
+ * A record of the talk (`offline`) can still show the answer and results.
87
+ */
88
+ export function ActivityControls({ controls, messages, offline = false, results = false, solve = false }) {
89
+ const button = (name, on, label, onClick, icon) => <button type="button" class={`poll-control poll-${name}${on ? ' is-on' : ''}`} title={label} aria-label={label} aria-pressed={on} onClick={onClick}>{icon}</button>
90
+ return <>
91
+ {!offline && button('close', controls.closed, t(controls.closed ? 'poll.open' : 'poll.close'), () => controls.close(messages), <Icon name={controls.closed ? 'lock' : 'lock-open'} size={22} />)}
92
+ {results && button('results', !controls.results, t(controls.results ? 'poll.hideResults' : 'poll.showResults'), () => controls.change({ results: !controls.results }), <Icon name={controls.results ? 'eye' : 'eye-off'} size={24} />)}
93
+ {solve && button('solve', controls.revealed, t(controls.revealed ? 'poll.hideAnswer' : 'poll.showAnswer'), () => controls.change({ revealed: !controls.revealed }), <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12l5 5 9-10" /></svg>)}
94
+ </>
95
+ }
96
+
97
+ /**
98
+ * Number of answers, whether the room is live, and Reset for the presenter;
99
+ * `children` go at the end. With `perPhone` (one answer per phone) the
100
+ * count says of how many phones; `closed` says the activity takes no more.
101
+ */
102
+ export function ActivityFooter({ count, connected, canReset, reset, offline, present = null, perPhone = false, closed = false, children }) {
27
103
  // Without a server this is a record of the talk: no count, dot or buttons.
28
104
  if (offline && !count) return <p class="poll-total poll-static">{t('poll.static')}</p>
105
+ // Followers count as phones too; with more answers than phones, some left.
106
+ const of = perPhone && !offline && present > 0 && present >= count ? present : null
29
107
  return <p class="poll-total">
30
- <span class={`poll-dot${connected ? ' is-live' : ''}`} title={connected ? t('poll.live') : t('poll.offline')} />
31
- {count === 1 ? t('poll.answer') : t('poll.answers', { n: count })}
32
- {canReset && <button class="poll-reset" disabled={count === 0} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
108
+ {/* The answers kept in the talk, without a server: no live dot. */}
109
+ {!offline && <span class={`poll-dot${connected ? ' is-live' : ''}`} title={connected ? t('poll.live') : t('poll.offline')} />}
110
+ {of != null ? t('poll.answeredOf', { n: count, of }) : count === 1 ? t('poll.answer') : t('poll.answers', { n: count })}
111
+ {closed && <span class="poll-closed">{t('poll.closed')}</span>}
112
+ <span class="poll-spacer" />
113
+ {canReset && <button class="poll-reset" disabled={count === 0 && !closed} onClick={() => reset().catch(() => {})}>{t('poll.reset')}</button>}
33
114
  {children}
34
115
  </p>
35
116
  }
@@ -0,0 +1,63 @@
1
+ import { Marked } from 'marked'
2
+ import markedKatex from 'marked-katex-extension'
3
+
4
+ // Short texts in a tag's attributes (a poll's question and options) as one
5
+ // line of Markdown with $…$ maths, like the text on a slide. The slide gets
6
+ // KaTeX's HTML; the phones' answer page has no KaTeX, so it gets MathML,
7
+ // which browsers draw themselves (src/live/answer/answer.js).
8
+ const slide = new Marked().use(markedKatex({ throwOnError: false, output: 'html' }))
9
+ const phone = new Marked().use(markedKatex({ throwOnError: false, output: 'mathml' }))
10
+
11
+ export const slideHtml = text => slide.parseInline(String(text ?? ''))
12
+ export const phoneHtml = text => phone.parseInline(String(text ?? ''))
13
+
14
+ /** An activity's question for the phones: the text, and drawn when the tag has one (else the slide's heading, as text). */
15
+ export const phoneQuestion = (question, slideTitle) => question ? { question, questionHtml: phoneHtml(question) } : { question: slideTitle }
16
+
17
+ // A formula, as KaTeX's inline rule finds it: $…$ or $$…$$, not starting
18
+ // with a space, and ending before a space, punctuation, a bar or the end.
19
+ const MATH = /^(\${1,2})(?!\$|\s)(?:\\.|[^\\\n$])*?\1(?=[\s?!.,:|]|$)/
20
+
21
+ // Reads options as Markdown, with formulas as one piece each, to find the
22
+ // bars between them.
23
+ const splitter = new Marked().use({ extensions: [{
24
+ name: 'math',
25
+ level: 'inline',
26
+ start: src => src.indexOf('$') < 0 ? undefined : src.indexOf('$'),
27
+ tokenizer: src => { const math = MATH.exec(src); if (math) return { type: 'math', raw: math[0] } },
28
+ }] })
29
+
30
+ // Text, emphasis and strikethrough are split at their bars; code, formulas,
31
+ // links, pictures and tags are whole, and `\|` is a bar in the text.
32
+ const SPLIT = new Set(['text', 'em', 'strong', 'del'])
33
+ function splitTokens(tokens, parts) {
34
+ for (const token of tokens) {
35
+ if (token.type === 'escape' && token.text === '|') parts[parts.length - 1] += '|'
36
+ else if (SPLIT.has(token.type) && token.tokens?.length) {
37
+ const inner = token.tokens.map(child => child.raw).join('')
38
+ const at = token.raw.indexOf(inner)
39
+ if (at < 0) { splitText(token.raw, parts); continue }
40
+ parts[parts.length - 1] += token.raw.slice(0, at)
41
+ splitTokens(token.tokens, parts)
42
+ parts[parts.length - 1] += token.raw.slice(at + inner.length)
43
+ } else if (SPLIT.has(token.type)) splitText(token.raw, parts)
44
+ else parts[parts.length - 1] += token.raw
45
+ }
46
+ }
47
+ function splitText(text, parts) {
48
+ const [first, ...rest] = text.split('|')
49
+ parts[parts.length - 1] += first
50
+ parts.push(...rest)
51
+ }
52
+
53
+ /**
54
+ * "A|B|C" as ["A", "B", "C"], each as written. A bar in a formula belongs to
55
+ * it ($|x|$, $\{x \mid x > 0\}$), as does one in `code`, a [link](…) or a
56
+ * picture; \| is a bar in the text. "$5|$10" is two options: neither $
57
+ * starts a formula. Emphasis does not hold a bar: "2*3|4*5" is two options.
58
+ */
59
+ export function choicesOf(options) {
60
+ const parts = ['']
61
+ splitTokens(splitter.Lexer.lexInline(String(options ?? ''), splitter.defaults), parts)
62
+ return parts.map(choice => choice.trim()).filter(Boolean)
63
+ }
@@ -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
+ }
@@ -1,36 +1,44 @@
1
1
  /* ── Poll on the slide ─────────────────────────────────────────────────── */
2
+ /* What follows an activity (or a join code) on the slide has room above it,
3
+ as after a list: the component's wrapper has no margin of its own. */
4
+ :is(.slide-body, [data-region]) div:has(> .poll, > .poll-join-slide) + * { margin-top: var(--gap-item, 32px); }
2
5
  .poll { display: flex; gap: 72px; align-items: center; width: 100%; font-size: var(--fs-body, 36px); color: var(--ink, #111); }
3
6
  .poll-main { flex: 1; min-width: 0; }
4
7
  .poll .poll-question { margin: 0 0 32px; font-weight: 600; }
5
8
  .poll-bars { display: grid; gap: 20px; }
6
9
  .poll-row { display: grid; grid-template-columns: minmax(120px, 28%) 1fr 3ch; gap: 24px; align-items: center; }
7
10
  .poll-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
11
+ /* buttons="letters|numbers": the key phones show, before the option. */
12
+ .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; }
13
+ /* A picture as an option: a thumbnail beside its bar. */
14
+ .poll-label:has(img) { white-space: normal; }
15
+ .poll-label img { max-height: 3.2em; max-width: 100%; vertical-align: middle; }
16
+ .poll .poll-several { margin: -20px 0 24px; font-size: var(--fs-small, 24px); color: var(--muted, #666); }
8
17
  .poll-track { height: 1.1em; background: color-mix(in srgb, var(--ink, #111) 8%, transparent); border-radius: 6px; overflow: hidden; }
9
18
  .poll-fill { display: block; height: 100%; background: var(--accent, #0d9488); border-radius: 6px; transition: width .6s cubic-bezier(.2, .8, .2, 1); }
10
19
  .poll-count { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
11
20
  .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
21
  .poll-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--muted, #999); opacity: .5; }
13
22
  .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; }
23
+ .poll-spacer { flex: 1; }
24
+ .poll-reset { padding: 4px 14px; border: 1px solid var(--rule, #ccc); border-radius: 6px; background: none; color: inherit; font: inherit; cursor: pointer; }
15
25
  .poll-reset:disabled { opacity: .4; cursor: default; }
16
26
  /* The audience sees the results, not the presenter's controls. */
17
27
  deck-stage[data-follower] .poll-reset { display: none; }
18
28
  /* answer="…": the tick outlines the right answer's row (label, bar, count) in green. */
19
- .poll-solve { display: grid; place-items: center; width: 44px; height: 44px; margin-left: auto; padding: 0; border: 1px solid var(--rule, #ccc); border-radius: 50%; background: none; color: var(--muted, #666); cursor: pointer; opacity: .55; transition: opacity .2s, background .2s, color .2s; }
20
- .poll-reset + .poll-solve { margin-left: 0; }
21
- .poll-results { display: grid; place-items: center; width: 44px; height: 44px; margin-left: auto; padding: 0; border: 1px solid var(--rule, #ccc); border-radius: 50%; background: none; color: var(--muted, #666); cursor: pointer; opacity: .55; transition: opacity .2s, background .2s, color .2s; }
22
- .poll-reset + .poll-results, .poll-results + .poll-solve { margin-left: 0; }
23
- .poll-results:hover, .poll-results:focus-visible { opacity: 1; }
24
- .poll-results.is-off { opacity: 1; background: var(--muted, #666); border-color: var(--muted, #666); color: #fff; }
25
- deck-stage[data-follower] .poll-results { display: none; }
29
+ /* The presenter's controls: close (lock), results (eye), right answer (tick). */
30
+ .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; }
31
+ .poll-control:hover, .poll-control:focus-visible { opacity: 1; }
32
+ .poll-close.is-on, .poll-results.is-on { opacity: 1; background: var(--muted, #666); border-color: var(--muted, #666); color: #fff; }
33
+ deck-stage[data-follower] .poll-control { display: none; }
34
+ /* Closed: no more answers count. */
35
+ .poll-closed { padding: 2px 12px; border-radius: 999px; background: color-mix(in srgb, var(--ink, #111) 10%, transparent); color: var(--ink, #111); font-weight: 600; }
26
36
  /* results="hidden" or the eye off: the presenter's screens show the results faint and hatched, the audience none. */
27
37
  .poll.is-results-hidden .poll-fill { opacity: .35; background: repeating-linear-gradient(45deg, var(--accent, #0d9488) 0 8px, transparent 8px 14px); }
28
38
  .poll.is-results-hidden .poll-count { opacity: .35; }
29
39
  deck-stage[data-follower] .poll.is-results-hidden .poll-fill, deck-stage[data-follower] .poll.is-results-hidden .poll-count { visibility: hidden; }
30
40
  .poll-solve svg { width: 24px; height: 24px; }
31
- .poll-solve:hover, .poll-solve:focus-visible { opacity: 1; }
32
41
  .poll-solve.is-on { opacity: 1; background: var(--poll-correct, #16a34a); border-color: var(--poll-correct, #16a34a); color: #fff; }
33
- deck-stage[data-follower] .poll-solve { display: none; }
34
42
  .poll.is-solved .poll-row.is-correct { outline: 4px solid var(--poll-correct, #16a34a); outline-offset: 10px; border-radius: 10px; }
35
43
  .poll.is-solved .poll-row.is-correct .poll-label { font-weight: 700; color: var(--poll-correct, #16a34a); }
36
44
  .poll-link { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 20px; color: var(--muted, #555); }
@@ -76,16 +84,21 @@ deck-stage[data-follower] .poll-solve { display: none; }
76
84
  .poll-join-slide { display: flex; flex-direction: column; align-items: center; gap: 20px; width: 100%; }
77
85
  .poll-join-slide .poll-join { font-size: var(--fs-body, 32px); gap: 16px; padding: 24px 32px; }
78
86
  .poll-join-slide .poll-link { font-size: 28px; }
79
- @media print { .poll-reset, .poll-dot, .poll-solve, .poll-results { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
87
+ @media print { .poll-reset, .poll-dot, .poll-control { display: none; } .poll-fill, .scale-fill, .word { transition: none; animation: none; } .question-cards li { animation: none; } }
80
88
 
81
89
  /* A record of the talk (no room server: a PDF, a file sent to readers): the
82
90
  question and its options, without counts, the live dot or a join code. */
83
91
  .poll.is-static .poll-count, .poll.is-static .scale-count { visibility: hidden; }
84
- .poll.is-static .poll-results { display: none; }
92
+ .poll.is-static .poll-close { display: none; }
85
93
  .poll .poll-static, .poll-join-slide .poll-static { margin: 36px 0 0; color: var(--muted, #666); font-style: italic; font-size: var(--fs-small, 24px); }
86
94
  .poll-join-slide .poll-static { margin: 0; font-size: var(--fs-body, 32px); }
87
95
 
88
96
  /* On paper the join code and the presenter's controls have no use. */
89
97
  @media print {
90
- .poll-join, .poll-reset, .poll-solve, .poll-dot, .poll-results { display: none !important; }
98
+ .poll-join, .poll-reset, .poll-dot, .poll-control { display: none !important; }
91
99
  }
100
+
101
+ /* <numeric>: the most frequent answers; Other for the rest. */
102
+ .poll-row.is-other .poll-label { color: var(--muted, #666); font-style: italic; }
103
+ .poll--numeric .poll-label { font-variant-numeric: tabular-nums; }
104
+ .poll .numeric-answer { margin: 24px 0 0; font-weight: 600; color: var(--poll-correct, #16a34a); }
@@ -4,7 +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
+ import { openTagsIn } from './tags.js'
8
8
 
9
9
  const REGION_RE = /^[a-z][a-z0-9-]*$/
10
10
 
@@ -233,16 +233,15 @@ export function stripNotes(source) {
233
233
  // The live activities, and the code to join them (<qrcode join />): a slide
234
234
  // with one of them is left out of shared builds. <qrcode follow /> and other
235
235
  // QR codes stay.
236
- const ACTIVITY_RE = /<(poll|question|wordcloud|scale)\b/i
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] ?? '') }
236
+ const ACTIVITIES = new Set(['poll', 'question', 'wordcloud', 'scale', 'numeric'])
237
+ const joins = tag => tag.name === 'qrcode' && 'join' in tag.attrs && !/^\{?(false|no|off|0)\}?$/i.test(tag.attrs.join.trim())
239
238
  export function hasActivity(slide) {
240
239
  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) })
240
+ .some(text => openTagsIn(text).some(tag => ACTIVITIES.has(tag.name) || joins(tag)))
242
241
  }
243
242
 
244
243
  // Removes every slide with a live activity (<poll>, <question>, <wordcloud>,
245
- // <scale>) for shared builds: a reader cannot vote, and the results belong to
244
+ // <scale>, <numeric>) for shared builds: a reader cannot vote, and the results belong to
246
245
  // the talk. Returns { source, ids, removed }: `ids` maps each kept slide's id
247
246
  // to its id afterwards (an automatic `slide-N` moves up), `removed` the ids
248
247
  // that are gone, so saved drawings can follow their slides.
package/src/core/icons.js CHANGED
@@ -41,6 +41,8 @@ export const ICONS = {
41
41
  trash: '<path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>', // trash-2
42
42
  eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>', // eye
43
43
  'eye-off': '<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/>', // eye-off
44
+ lock: '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', // lock
45
+ 'lock-open': '<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/>', // lock-open
44
46
  finger: '<path d="M22 14a8 8 0 0 1-8 8"/><path d="M18 11v-1a2 2 0 0 0-2-2a2 2 0 0 0-2 2"/><path d="M14 10V9a2 2 0 0 0-2-2a2 2 0 0 0-2 2v1"/><path d="M10 9.5V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v10"/><path d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>', // pointer
45
47
  pen: '<path d="M13 21h8"/><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/>', // pen-line
46
48
  highlighter: '<path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>', // highlighter
@@ -55,6 +55,22 @@ export const LABELS = {
55
55
  'follow.waiting': 'The talk has not started yet.',
56
56
  'follow.tryHere': 'Try following here',
57
57
  'join.static': 'During the talk, phones joined here to answer.',
58
+ 'poll.answeredOf': '{n} of {of} answered',
59
+ 'poll.close': 'Close: later answers do not count',
60
+ 'poll.open': 'Open again',
61
+ 'poll.closed': 'Closed',
62
+ 'poll.several': 'Several answers possible.',
63
+ 'poll.pickSeveral': 'Tap all that apply.',
64
+ 'poll.thanksSeveral': 'Thanks! You chose {choice}. Tap to change.',
65
+ 'respond.closed': 'Closed: no more answers.',
66
+ 'respond.right': 'Right!',
67
+ 'respond.wrong': 'Not quite. The right answer: {answer}',
68
+ 'respond.solution': 'The right answer: {answer}',
69
+ 'numeric.placeholder': 'A number',
70
+ 'numeric.invalid': 'Enter a number, such as 0.5 or 1/2.',
71
+ 'numeric.sent': 'Sent: {value}. Send another to change.',
72
+ 'numeric.other': 'Other',
73
+ 'numeric.right': '{n} right',
58
74
  'question.empty': 'Answers appear here.',
59
75
  'scale.average': 'Average {n}',
60
76
  },
@@ -108,6 +124,22 @@ export const LABELS = {
108
124
  'follow.waiting': 'Der Vortrag hat noch nicht begonnen.',
109
125
  'follow.tryHere': 'Mitverfolgen hier ausprobieren',
110
126
  'join.static': 'Im Vortrag haben sich hier die Handys zum Antworten verbunden.',
127
+ 'poll.answeredOf': '{n} von {of} haben geantwortet',
128
+ 'poll.close': 'Schliessen: spätere Antworten zählen nicht',
129
+ 'poll.open': 'Wieder öffnen',
130
+ 'poll.closed': 'Geschlossen',
131
+ 'poll.several': 'Mehrere Antworten möglich.',
132
+ 'poll.pickSeveral': 'Alle zutreffenden antippen.',
133
+ 'poll.thanksSeveral': 'Danke! Gewählt: {choice}. Zum Ändern antippen.',
134
+ 'respond.closed': 'Geschlossen: keine weiteren Antworten.',
135
+ 'respond.right': 'Richtig!',
136
+ 'respond.wrong': 'Leider nicht. Richtig ist: {answer}',
137
+ 'respond.solution': 'Richtig ist: {answer}',
138
+ 'numeric.placeholder': 'Eine Zahl',
139
+ 'numeric.invalid': 'Eine Zahl eingeben, z. B. 0,5 oder 1/2.',
140
+ 'numeric.sent': 'Gesendet: {value}. Zum Ändern eine andere senden.',
141
+ 'numeric.other': 'Andere',
142
+ 'numeric.right': '{n} richtig',
111
143
  'question.empty': 'Hier erscheinen die Antworten.',
112
144
  'scale.average': 'Durchschnitt {n}',
113
145
  },
@@ -0,0 +1,66 @@
1
+ import { Marked } from 'marked'
2
+ import { tagsIn, closingTagOf } from './tags.js'
3
+
4
+ // A poll's options as a Markdown list inside the tag, for options that are
5
+ // more than a word: pictures, long formulas, text with bars in it.
6
+ //
7
+ // <poll room="deriv" question="What is $\frac{d}{dx} x^2$?">
8
+ // - [x] $2x$
9
+ // - [ ] $x^2$
10
+ // </poll>
11
+ //
12
+ // Each item is one option, as written; `[x]` marks a right answer. Before a
13
+ // slide is drawn, and before the phones read the tag, such a poll becomes
14
+ // `<poll … data-choices="[…]">` with the options as JSON, so Poll needs no
15
+ // Markdown of its own to find them. A poll whose content is not a list
16
+ // stays as it is.
17
+
18
+ const lexer = new Marked()
19
+
20
+ const attributeText = text => text.replace(/&/g, '&amp;').replace(/"/g, '&quot;')
21
+
22
+ /**
23
+ * The options of a list, [{ text, right }], or null when `markdown` is not
24
+ * a list. A change of bullet (`-` to `*`) starts a new list in Markdown;
25
+ * here it goes on with the same options.
26
+ */
27
+ export function listChoices(markdown) {
28
+ const blocks = lexer.lexer(markdown).filter(token => token.type !== 'space')
29
+ if (!blocks.length || blocks.some(block => block.type !== 'list')) return null
30
+ const choices = blocks.flatMap(block => block.items)
31
+ .map(item => ({ text: item.text.replace(/\s*\n\s*/g, ' ').trim(), right: !!(item.task && item.checked) }))
32
+ // An empty `- [ ]` is no task to marked, but no option either.
33
+ .filter(choice => choice.text && !/^\[[ xX]\]$/.test(choice.text))
34
+ return choices.length ? choices : null
35
+ }
36
+
37
+ /** `markdown` with every poll that holds a list given its options as `data-choices`. */
38
+ export function expandPollLists(markdown) {
39
+ const source = String(markdown ?? '')
40
+ if (!/<poll\b/i.test(source)) return source
41
+ const text = source.replace(/\r\n?/g, '\n')
42
+ const tags = tagsIn(text)
43
+ let out = ''
44
+ let last = 0
45
+ tags.forEach((open, at) => {
46
+ if (open.name !== 'poll' || open.closing || open.start == null || open.start < last) return
47
+ const close = closingTagOf(tags, at)
48
+ if (close?.start == null) return
49
+ const choices = listChoices(text.slice(open.end, close.start))
50
+ if (!choices) return
51
+ // The closing tag on a line of its own keeps the tag one block of HTML,
52
+ // as `<poll … />` on its line is, rather than a paragraph.
53
+ out += text.slice(last, open.start) + `${open.raw.slice(0, -1).trimEnd()} data-choices="${attributeText(JSON.stringify(choices))}">\n</poll>`
54
+ last = close.end
55
+ })
56
+ return last ? out + text.slice(last) : source
57
+ }
58
+
59
+ /** The options of a poll from its attributes: the list's, else `options`. */
60
+ export function pollChoices(listed) {
61
+ try {
62
+ const choices = JSON.parse(listed)
63
+ if (Array.isArray(choices)) return choices.filter(choice => typeof choice?.text === 'string').map(choice => ({ text: choice.text, right: choice.right === true }))
64
+ } catch {}
65
+ return null
66
+ }
@@ -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
+ }