mdeck 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/assets/base.css +23 -2
  3. package/assets/extensions/themes/academic/styles.css +18 -5
  4. package/assets/extensions/themes/aurora/styles.css +32 -6
  5. package/assets/extensions/themes/minimal/styles.css +16 -4
  6. package/assets/extensions/themes/neue/styles.css +17 -4
  7. package/docs/reference/authoring.md +27 -4
  8. package/docs/site/content/audience.md +35 -4
  9. package/docs/site/content/commands.md +2 -0
  10. package/package.json +1 -1
  11. package/skills/write-slides/SKILL.md +1 -1
  12. package/src/build/bibliography.js +29 -11
  13. package/src/build/resultsPlugin.js +77 -0
  14. package/src/build/slidesPlugin.js +28 -5
  15. package/src/build/themeCheck.js +2 -1
  16. package/src/cli/args.js +2 -1
  17. package/src/cli/index.js +17 -4
  18. package/src/components/Numeric.jsx +59 -0
  19. package/src/components/Poll.jsx +58 -55
  20. package/src/components/Question.jsx +10 -6
  21. package/src/components/Scale.jsx +13 -8
  22. package/src/components/WordCloud.jsx +10 -6
  23. package/src/components/activity.jsx +88 -7
  24. package/src/components/inlineText.js +40 -0
  25. package/src/components/numbers.js +39 -0
  26. package/src/components/poll.css +23 -13
  27. package/src/core/editDeck.js +2 -2
  28. package/src/core/icons.js +2 -0
  29. package/src/core/labels.js +32 -0
  30. package/src/core/results.js +64 -0
  31. package/src/editor/sampleDeck.js +34 -8
  32. package/src/layouts/layoutApi.jsx +4 -3
  33. package/src/live/answer/answer.css +19 -0
  34. package/src/live/answer/answer.js +147 -24
  35. package/src/live/client.js +51 -6
  36. package/src/live/roomTag.js +6 -2
  37. package/src/live/rooms.js +6 -1
  38. package/src/runtime/citations.js +15 -5
  39. package/src/runtime/ink/bus.js +7 -13
  40. package/src/runtime/main.jsx +3 -1
  41. package/src/runtime/registry.jsx +2 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.4.0 — 2026-10-08
4
+
5
+ - What follows a list has room above it in every theme, so a sentence after a list no longer sits right against its last item.
6
+ - Fixed: a bullet list inside a numbered list took numbers too, and in some themes the number sat on top of the bullet; it also pushed the numbering of the items after it. Every built-in theme now styles only a list's own items.
7
+ - A list inside a list item has room above it, as much as between its own items and less than between the outer ones, instead of sitting right against its item. Themes with their own spacing for nested lists keep it.
8
+ - Links and quotations look the part in every theme: links take the palette's accent colour instead of the browser's blue, and a `> quotation` is set in each theme's own way instead of as a bare indent: academic as an indented serif quotation, aurora with a quotation mark in its gradient, minimal and neue with a bar at its side (grey and red). A quotation's paragraphs now take the quotation's type, which themes that set every paragraph's font used to undo.
9
+ - The sample deck, on which `mdeck design`, the theme gallery and the theme checks show every theme, now also has a link, a quotation, a bullet list inside a numbered list, citations with their references at the foot of the slide, and a reference list.
10
+ - A poll's question and options may use Markdown and `$…$` maths, as on a slide: `options="$2x$|$x^2$|$\frac{x^3}{3}$"`. The slide draws them with KaTeX, the phones' answer page as MathML, which needs no fonts or scripts there; it keeps only text, emphasis and MathML from what the presenter's screen sends. A `|` inside a formula belongs to it (`$|x|$`), and `\|` is a bar in the text. Votes and `answer` still use the text as written. The same goes for the `question` of every activity (`<scale>`, `<question>`, `<wordcloud>`) and the scale's `low` and `high` labels. An answer page from an older `mdeck server` shows the source text.
11
+ - Fixed: the phones' answer page showed the word “null” below the question when there was no link for following the slides.
12
+ - Every activity has a lock button that closes it: answers after that do not count, and the phones say it is closed until it opens again. Together with the eye (`results="hidden"`) and the tick this makes a peer-instruction round: vote, close, show the bars, discuss, show the answer.
13
+ - Showing a poll's right answer also tells each phone whether its vote was right, or what the right answer is. The lock, the eye and the tick are kept in the activity room's state on the server, so every screen of the talk and the phones follow them, also after a reload.
14
+ - Polls, scales and numbers count “12 of 31 answered” while phones are connected.
15
+ - `multiple` lets each phone pick several options of a poll; the slide and the phones say so. `buttons="letters"` (or `"numbers"`) puts A, B, C before the options on the slide and shows only those on the phones, for options that are pictures or long formulas.
16
+ - New activity `<numeric>`: phones type a number (`0.5`, `0,5`, `1/2`), the slide shows the most frequent answers as bars. `answer` and `tolerance` mark the right ones and tell each phone whether it was right.
17
+ - The answers of a talk are kept beside the deck in `<slides>.results.json` while `mdeck run` runs: anonymous, phones numbered per question. Builds, `mdeck send` and `mdeck pdf` show them as the record of the talk (`--no-results` leaves them out), and `mdeck results` prints them as CSV.
18
+ - Fixed: a `>` or `<` in an activity's attributes (`question="Is $x > 0$?"`) cut its tag short, and the phones lost the options. Pictures in a tag's attributes, such as a poll's options, are now copied into folder builds.
19
+
3
20
  ## 3.3.0 — 2026-10-08
4
21
 
5
22
  - Citations and references: `bibliography: refs.bib` in the deck settings, then cite in Pandoc's syntax, `[@smith2020, p. 12]`, `[see @a; @b]`, `[-@a]` or `@smith2020` in a sentence. Each citation shows in the style's short form, each slide lists the full references of the works it cites above its footer (`show.citations: false` leaves them out), and `<bibliography />` lists them all on a slide (`part="1/2"` splits a long list). Reference files may be BibTeX (`.bib`), CSL-JSON (`.json`) or Hayagriva (`.yml`, Typst's format), several at once. `csl:` picks the style, `apa` by default, `vancouver`, `harvard1` or any `.csl` file; the style's words follow `lang`. `nocite:` lists works without citing them. Formatting happens at build time, so a deck carries only the finished text. Unknown keys are marked on the slide and reported by `mdeck check`.
package/assets/base.css CHANGED
@@ -105,7 +105,10 @@ deck-stage[data-ink-hidden] .slide-ink { display: none; }
105
105
  .split-right { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: safe center; gap: var(--gap-item); }
106
106
 
107
107
  /* ── List containers ─────────────────────────────────────────────────────── */
108
+ /* Lists have no margins of their own, and paragraphs only one below them, so
109
+ whatever follows a list needs room above it. */
108
110
  .slide-body :is(ul, ol) + :is(ul, ol) { margin-top: var(--gap-item); }
111
+ .slide-body :is(ul, ol) + :not(ul, ol) { margin-top: var(--gap-item); }
109
112
  /* Markers (::before), padding, and font styling are visual — defined per theme */
110
113
  .slide-body ul {
111
114
  list-style: none;
@@ -115,7 +118,7 @@ deck-stage[data-ink-hidden] .slide-ink { display: none; }
115
118
  flex-direction: column;
116
119
  gap: var(--gap-item);
117
120
  }
118
- .slide-body ul li { position: relative; }
121
+ .slide-body ul > li { position: relative; }
119
122
  .slide-body ol {
120
123
  list-style: none;
121
124
  padding: 0;
@@ -125,7 +128,10 @@ deck-stage[data-ink-hidden] .slide-ink { display: none; }
125
128
  flex-direction: column;
126
129
  gap: var(--gap-item);
127
130
  }
128
- .slide-body ol li { counter-increment: slide-ol; position: relative; }
131
+ .slide-body ol > li { counter-increment: slide-ol; position: relative; }
132
+ /* A list inside an item: closer to its item than items are to each other,
133
+ and as close inside. Themes with their own (same selector) win. */
134
+ .slide-body li :is(ul, ol) { margin-top: calc(var(--gap-item) * 0.5); gap: calc(var(--gap-item) * 0.5); }
129
135
 
130
136
  /* ── Full-bleed image ────────────────────────────────────────────────────── */
131
137
  .slide--full-bleed-image { padding: 0; }
@@ -159,6 +165,21 @@ deck-stage[data-ink-hidden] .slide-ink { display: none; }
159
165
  .slide-footnotes p { margin: 0; }
160
166
  .slide-footnotes .footnote-backref { display: none; }
161
167
 
168
+ /* ── Links and quotations ───────────────────────────────────────────────── */
169
+ /* Defaults in the palette's colours instead of the browser's blue and a bare
170
+ indent. :where keeps them weaker than any rule of the theme's own. */
171
+ :where(.slide) a { color: var(--accent); text-underline-offset: 0.15em; text-decoration-thickness: 0.06em; }
172
+ :where(.slide) blockquote {
173
+ margin: 0 0 var(--gap-item);
174
+ padding-left: 0.9em;
175
+ border-left: 4px solid var(--accent);
176
+ color: var(--ink-soft);
177
+ }
178
+ :where(.slide) blockquote > p:last-child { margin-bottom: 0; }
179
+ /* The quote's paragraphs take its type: themes set the face and size of
180
+ every paragraph, which would undo a quote set in its own. */
181
+ .slide-body blockquote > p { font: inherit; color: inherit; letter-spacing: inherit; }
182
+
162
183
  /* ── Code blocks ─────────────────────────────────────────────────────────── */
163
184
  .code-block { white-space: pre-wrap; overflow-x: hidden; }
164
185
 
@@ -180,12 +180,12 @@ strong { color: var(--ink); font-weight: 700; }
180
180
  the text size, so long lists stay compact
181
181
  ───────────────────────────────────────────── */
182
182
  .slide-body ul, .slide-body ol { max-width: 1560px; }
183
- .slide-body ul li,
184
- .slide-body ol li {
183
+ .slide-body ul > li,
184
+ .slide-body ol > li {
185
185
  line-height: 1.45;
186
186
  padding-left: 44px;
187
187
  }
188
- .slide-body ul li::before {
188
+ .slide-body ul > li::before {
189
189
  content: '';
190
190
  position: absolute;
191
191
  left: 4px;
@@ -195,7 +195,7 @@ strong { color: var(--ink); font-weight: 700; }
195
195
  background: var(--accent);
196
196
  clip-path: polygon(0 0, 100% 50%, 0 100%);
197
197
  }
198
- .slide-body ol li::before {
198
+ .slide-body ol > li::before {
199
199
  content: counter(slide-ol) '.';
200
200
  position: absolute;
201
201
  left: 0;
@@ -206,7 +206,7 @@ strong { color: var(--ink); font-weight: 700; }
206
206
  }
207
207
  /* Second level: an en dash, a little smaller */
208
208
  .slide-body li :is(ul, ol) { margin-top: 8px; gap: 6px; font-size: 0.88em; }
209
- .slide-body li ul li::before {
209
+ .slide-body li ul > li::before {
210
210
  width: 0.5em;
211
211
  height: 2px;
212
212
  top: 0.72em;
@@ -644,3 +644,16 @@ strong { color: var(--ink); font-weight: 700; }
644
644
  overflow-y: hidden;
645
645
  }
646
646
  .slide .katex-display > .katex { font-size: 1.2em; }
647
+
648
+ /* ─────────────────────────────────────────────
649
+ QUOTATIONS — the quote environment of a printed text: set in from
650
+ both sides, in the serif, without ornament
651
+ ───────────────────────────────────────────── */
652
+ .slide-body blockquote {
653
+ margin: 0 0 var(--gap-item);
654
+ padding: 0 64px;
655
+ border: 0;
656
+ font-family: var(--font-serif);
657
+ font-style: italic;
658
+ color: var(--ink-soft);
659
+ }
@@ -66,13 +66,13 @@ deck-stage[data-deck-static] .slide::before { animation: none; }
66
66
  /* Print and PDF: gradient text prints with a hairline box around it, and
67
67
  glows print as dark halos; solid colours instead. */
68
68
  @media print {
69
- em, .slide-footer > span:last-child:not(:first-child), .chapter-num, .slide-body ol li::before {
69
+ em, .slide-footer > span:last-child:not(:first-child), .chapter-num, .slide-body ol > li::before {
70
70
  background: none !important;
71
71
  -webkit-text-fill-color: currentColor !important;
72
72
  color: var(--aurora-a) !important;
73
73
  filter: none !important;
74
74
  }
75
- .slide-body ul li::before, .focus-eyebrow::before, .slide-header > span:last-child:not(:empty)::after { box-shadow: none !important; }
75
+ .slide-body ul > li::before, .focus-eyebrow::before, .slide-header > span:last-child:not(:empty)::after { box-shadow: none !important; }
76
76
  /* Frosted glass blurs what lies behind it, which prints as an image. */
77
77
  .slide *, .slide *::before { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }
78
78
  }
@@ -457,13 +457,13 @@ strong { color: var(--ink); font-weight: 700; }
457
457
  /* ─────────────────────────────────────────────
458
458
  LISTS — glowing dots, gradient numbers
459
459
  ───────────────────────────────────────────── */
460
- .slide-body ul li {
460
+ .slide-body ul > li {
461
461
  font-weight: 400;
462
462
  line-height: 1.35;
463
463
  padding-left: 46px;
464
464
  position: relative;
465
465
  }
466
- .slide-body ul li::before {
466
+ .slide-body ul > li::before {
467
467
  content: '';
468
468
  position: absolute;
469
469
  left: 0;
@@ -474,11 +474,11 @@ strong { color: var(--ink); font-weight: 700; }
474
474
  background: var(--aurora-gradient);
475
475
  box-shadow: 0 0 14px color-mix(in oklab, var(--aurora-a) 55%, transparent);
476
476
  }
477
- .slide-body ol li {
477
+ .slide-body ol > li {
478
478
  padding-left: 60px;
479
479
  position: relative;
480
480
  }
481
- .slide-body ol li::before {
481
+ .slide-body ol > li::before {
482
482
  content: counter(slide-ol);
483
483
  position: absolute;
484
484
  left: 0;
@@ -642,3 +642,29 @@ strong { color: var(--ink); font-weight: 700; }
642
642
  overflow-x: auto;
643
643
  overflow-y: hidden;
644
644
  }
645
+
646
+ /* ─────────────────────────────────────────────
647
+ QUOTATIONS — no pane, only a large quotation mark in the colours of
648
+ the lights beside the quote
649
+ ───────────────────────────────────────────── */
650
+ .slide-body blockquote {
651
+ position: relative;
652
+ margin: 0 0 var(--gap-item);
653
+ padding: 0 0 0 76px;
654
+ border: 0;
655
+ max-width: 1300px;
656
+ color: var(--ink);
657
+ }
658
+ .slide-body blockquote::before {
659
+ content: '\201C';
660
+ position: absolute;
661
+ left: 0;
662
+ top: -18px;
663
+ font-size: 120px;
664
+ font-weight: 800;
665
+ line-height: 1;
666
+ background: var(--aurora-gradient);
667
+ -webkit-background-clip: text;
668
+ background-clip: text;
669
+ color: transparent;
670
+ }
@@ -215,8 +215,8 @@ strong { color: var(--ink); font-weight: 600; }
215
215
  /* ─────────────────────────────────────────────
216
216
  LISTS — a short dash, plain numbers
217
217
  ───────────────────────────────────────────── */
218
- .slide-body ul li { line-height: 1.4; padding-left: 40px; position: relative; }
219
- .slide-body ul li::before {
218
+ .slide-body ul > li { line-height: 1.4; padding-left: 40px; position: relative; }
219
+ .slide-body ul > li::before {
220
220
  content: '';
221
221
  position: absolute;
222
222
  left: 0;
@@ -225,8 +225,8 @@ strong { color: var(--ink); font-weight: 600; }
225
225
  height: 2px;
226
226
  background: var(--muted);
227
227
  }
228
- .slide-body ol li { padding-left: 52px; position: relative; }
229
- .slide-body ol li::before {
228
+ .slide-body ol > li { padding-left: 52px; position: relative; }
229
+ .slide-body ol > li::before {
230
230
  content: counter(slide-ol) '.';
231
231
  position: absolute;
232
232
  left: 0;
@@ -340,3 +340,15 @@ strong { color: var(--ink); font-weight: 600; }
340
340
  overflow-x: auto;
341
341
  overflow-y: hidden;
342
342
  }
343
+
344
+ /* ─────────────────────────────────────────────
345
+ QUOTATIONS — a thin bar in the quiet grey, the quote set in a little,
346
+ in the softer ink
347
+ ───────────────────────────────────────────── */
348
+ .slide-body blockquote {
349
+ margin: 0 0 var(--gap-item);
350
+ padding: 2px 0 2px 28px;
351
+ border: 0;
352
+ border-left: 3px solid var(--muted);
353
+ color: var(--ink-soft);
354
+ }
@@ -336,13 +336,13 @@ strong { color: var(--ink); font-weight: 600; }
336
336
  /* ─────────────────────────────────────────────
337
337
  LISTS — square markers in the accent, numbers in the display face
338
338
  ───────────────────────────────────────────── */
339
- .slide-body ul li {
339
+ .slide-body ul > li {
340
340
  font-weight: 400;
341
341
  line-height: 1.32;
342
342
  padding-left: 44px;
343
343
  position: relative;
344
344
  }
345
- .slide-body ul li::before {
345
+ .slide-body ul > li::before {
346
346
  content: '';
347
347
  position: absolute;
348
348
  left: 0;
@@ -351,12 +351,12 @@ strong { color: var(--ink); font-weight: 600; }
351
351
  height: 14px;
352
352
  background: var(--accent);
353
353
  }
354
- .slide-body ol li {
354
+ .slide-body ol > li {
355
355
  counter-increment: slide-ol;
356
356
  padding-left: 64px;
357
357
  position: relative;
358
358
  }
359
- .slide-body ol li::before {
359
+ .slide-body ol > li::before {
360
360
  content: counter(slide-ol, decimal-leading-zero);
361
361
  position: absolute;
362
362
  left: 0;
@@ -487,3 +487,16 @@ strong { color: var(--ink); font-weight: 600; }
487
487
  overflow-x: auto;
488
488
  overflow-y: hidden;
489
489
  }
490
+
491
+ /* ─────────────────────────────────────────────
492
+ QUOTATIONS — a rule, not a box: a red bar to the left, the quote set
493
+ in a little, in the text's own size
494
+ ───────────────────────────────────────────── */
495
+ .slide-body blockquote {
496
+ margin: 0 0 var(--gap-item);
497
+ padding: 2px 0 2px 32px;
498
+ border: 0;
499
+ border-left: 6px solid var(--accent);
500
+ max-width: 1200px;
501
+ color: var(--ink);
502
+ }
@@ -592,6 +592,20 @@ labels:
592
592
  | `follow.tryHere` | Try following here | Mitverfolgen hier ausprobieren |
593
593
  | `question.empty` | Answers appear here. | Hier erscheinen die Antworten. |
594
594
  | `scale.average` | Average {n} | Durchschnitt {n} |
595
+ | `poll.answeredOf` | {n} of {of} answered | {n} von {of} haben geantwortet |
596
+ | `poll.closed` | Closed | Geschlossen |
597
+ | `poll.several` | Several answers possible. | Mehrere Antworten möglich. |
598
+ | `poll.pickSeveral` | Tap all that apply. | Alle zutreffenden antippen. |
599
+ | `poll.thanksSeveral` | Thanks! You chose {choice}. Tap to change. | Danke! Gewählt: {choice}. Zum Ändern antippen. |
600
+ | `respond.closed` | Closed: no more answers. | Geschlossen: keine weiteren Antworten. |
601
+ | `respond.right` | Right! | Richtig! |
602
+ | `respond.wrong` | Not quite. The right answer: {answer} | Leider nicht. Richtig ist: {answer} |
603
+ | `respond.solution` | The right answer: {answer} | Richtig ist: {answer} |
604
+ | `numeric.placeholder` | A number | Eine Zahl |
605
+ | `numeric.invalid` | Enter a number, such as 0.5 or 1/2. | Eine Zahl eingeben, z. B. 0,5 oder 1/2. |
606
+ | `numeric.sent` | Sent: {value}. Send another to change. | Gesendet: {value}. Zum Ändern eine andere senden. |
607
+ | `numeric.other` | Other | Andere |
608
+ | `numeric.right` | {n} right | {n} richtig |
595
609
 
596
610
  ---
597
611
 
@@ -742,24 +756,33 @@ For `play="auto"` the video pauses and resets to the beginning when you navigate
742
756
  | Attribute | Default | Description |
743
757
  |---|---|---|
744
758
  | `room` | `poll` | Name of the room that collects the answers; unique per poll in a deck. Letters, digits, `.`, `-`, `_` |
745
- | `options` | — | Answers separated by `\|` |
746
- | `question` | — | Question text; phones fall back to the slide heading |
759
+ | `options` | — | Answers separated by `\|`. Markdown and `$…$` maths, as on a slide (see below) |
760
+ | `question` | — | Question text, Markdown and maths like `options`; phones fall back to the slide heading |
747
761
  | `qr` | `true` | `false` leaves out the QR code (shown earlier with `<qrcode join />`) |
748
- | `answer` | — | The right answer (several separated by `\|`): a button with a tick outlines its label, bar and count in green, also in the audience window and on paired devices. `--poll-correct` sets the colour |
762
+ | `answer` | — | The right answer (several separated by `\|`): a button with a tick outlines its label, bar and count in green, also in the audience window and on paired devices, and each phone says whether its vote was right. `--poll-correct` sets the colour |
763
+ | `multiple` | — | Each phone may pick several options; the slide and the phones say so. A vote is then the list of options picked, and is right when it is exactly the `answer` options |
764
+ | `buttons` | — | `letters` puts A, B, C … before the options, and the phones show only those; `numbers` does the same with 1, 2, 3 …. For options better read on the slide, such as pictures (`options="![](a.svg)\|![](b.svg)"`) |
749
765
  | `results` | — | `hidden` starts with the results hidden, e.g. for peer instruction. A button with an eye shows or hides them (bars and counts) at any time, also in the audience window and on paired devices. Hidden, the audience window shows no bars; the presenter's own screens show them faint |
750
766
 
751
- Three more activities take the same `room`, `question` and `qr`:
767
+ Four more activities take the same `room`, `question` and `qr`:
752
768
 
753
769
  | Tag | Attributes | Phones | Slide |
754
770
  |---|---|---|---|
755
771
  | `<scale>` | `min` (1), `max` (5), `low`, `high` | One button per number, `low`/`high` as labels | Count per number and the average; latest answer per device |
772
+ | `<numeric>` | `answer`, `tolerance` (0), `limit` (6), `placeholder`, `results` | A number field: `0.5`, `0,5`, `1/2`, `1e-3`; latest answer per device | The most frequent answers as bars, the rest as Other; with `answer`, the tick marks the right ones, counts them and tells each phone |
756
773
  | `<wordcloud>` | `placeholder`, `limit` (60), `height` (520) | Text field, up to 40 characters, repeatable | A packed word cloud (d3-cloud): size by frequency, some words upright, case-insensitive |
757
774
  | `<question>` | `placeholder`, `limit` (8) | Text field, up to 200 characters, repeatable | The newest answers as cards |
758
775
 
759
776
  `<qrcode follow />` shows the link for following the slides on phones and laptops (`?view=follow`), with `mdeck run --network` or a hosted deck with a `server` setting; the phones' answer page links to it as well. `<qrcode join />` shows the deck's join code large with its link (`size`, default 420), for a slide that invites everyone once; activities after it can use `qr="false"`. QR codes (`<qrcode>`, activities) are SVG in the slide's `--ink` on a transparent background; `--qr-ink` and `--qr-bg` override the colours.
760
777
 
778
+ Options and the question may use Markdown and maths, e.g. `options="$2x$|$x^2$|$\frac{x^3}{3}$"`. A `|` inside a formula belongs to it (`$|x|$` is one option), and `\|` is a bar in plain text. The slide draws maths with KaTeX, the phones as MathML; votes and `answer` use the text as written. The other activities' `question`, and the scale's `low` and `high`, take Markdown and maths too.
779
+
761
780
  The slide shows live bars, the number of answers, a QR code and a short link. Phones never load the deck: they open the server's own answer page at `<server>/<code>`, where the six-digit session code is the same for every poll in the deck. The presenter's screen (the presenter view or a full deck window, never an embedded preview) announces the poll on the current slide with what the phones should show and the deck's look; the server only accepts that from the presenter. Between polls the phones wait. Each device's latest vote counts. The presenter can reset the room (hover over the results).
762
781
 
782
+ Every activity has a lock button that closes it: answers after that do not count, and the phones say it is closed until it opens again. Polls and `<numeric>` also have the eye that hides or shows the results. Below the results, polls, scales and `<numeric>` count “12 of 31 answered” while phones are connected (people following the slides count as phones). The lock, the eye and the tick reach every screen of the talk, also after a reload: they are kept in the activity room's state on the server, which only the presenter may set, and phones read it there. Reset clears the answers and opens the activity again.
783
+
784
+ While `mdeck run` runs, the answers are kept beside the deck in `<slides>.results.json`: per activity room, each answer with its time, the phones numbered per room (their ids are not kept), and, once an activity was closed, only the answers that counted. Reset takes a room out of the file; a new, empty server leaves the file alone. Builds, `mdeck send` and `mdeck pdf` show the kept answers where no room server answers (`--no-results` leaves them out), and `mdeck results [slides.md] [-o file.csv]` prints them as CSV: `room,phone,at,answer`, several picks joined by `|`.
785
+
763
786
  `--no-polls` leaves slides with an activity or a join code (`<qrcode join />`) out of `mdeck send`, `mdeck build --reader` and `mdeck pdf`; without it they show as a record of the talk. Saved drawings follow the remaining slides.
764
787
 
765
788
  Rooms run inside `mdeck run` (add `--network` so phones can reach it) or on a server started with `mdeck server`, set in the deck:
@@ -28,19 +28,47 @@ Every poll in a presentation shows the same code, so people scan once. Their pho
28
28
  | Setting | Meaning |
29
29
  |---|---|
30
30
  | `room` | A short name for this question, different for each poll in the deck. Letters, digits, hyphens and underscores. |
31
- | `answer` | Optional: the right answer, written exactly like the option. A button with a tick then appears under the bars; tapping it outlines the right answer, its bar and its number in green, and tapping again hides it. The audience window shows it too. |
31
+ | `answer` | Optional: the right answer, written exactly like the option (several separated by `\|`). A button with a tick then appears under the bars; tapping it outlines the right answer, its bar and its number in green, and each phone says whether its vote was right. Tapping again hides it. |
32
32
  | `options` | The answers, separated by `\|` |
33
33
  | `question` | Optional question text. Without it, phones show the slide's heading. |
34
+ | `multiple` | Each phone may pick several options. The slide and the phones say “Several answers possible”. Without it, one option per phone. |
35
+ | `buttons` | `letters` puts A, B, C … before the options on the slide, and the phones show only those letters; `numbers` does the same with 1, 2, 3 … For options that are pictures or long formulas, better read on the big screen. |
36
+ | `results` | `hidden` starts with the bars hidden, e.g. to let people vote before they see what others chose. |
34
37
  | `qr` | `false` leaves out the QR code, when an earlier slide showed it with `<qrcode join />`. |
35
38
 
39
+ The question and the options may contain formatting and formulas, written as on a slide, so a maths lecture can ask:
40
+
41
+ ```markdown
42
+ <poll room="derivative" question="What is $\frac{d}{dx}\, x^2$?" options="$2x$|$x^2$|$\frac{x^3}{3}$" answer="$2x$" />
43
+ ```
44
+
45
+ Both the slide and the phones show the formulas. A `|` inside a formula stays in it, so `$|x|$` is one option; to put a `|` in plain text, write `\|`.
46
+
47
+ Options can also be pictures. Show only letters on the phones then:
48
+
49
+ ```markdown
50
+ <poll room="graph" question="Which graph shows $f(x) = e^{-x}$?" options="![](a.svg)|![](b.svg)|![](c.svg)" answer="![](b.svg)" buttons="letters" />
51
+ ```
52
+
53
+ ### During the poll
54
+
55
+ Below the bars, the slide counts the answers, “12 of 31 answered” while phones are connected. The presenter has three buttons there (the audience window does not show them):
56
+
57
+ - **The lock** closes the poll. Answers after that do not count, and the phones say the poll is closed until you open it again.
58
+ - **The eye** hides or shows the bars. Hidden, the audience sees no bars; your own screens show them faint.
59
+ - **The tick** (with `answer`) shows the right answer: on the slide, and on each phone, which says whether its answer was right.
60
+
61
+ Together they make a peer-instruction round: start with `results="hidden"`, let people vote, close, show the bars, let them discuss, and show the answer. **Reset** clears the answers and opens the poll again.
62
+
36
63
  With `lang: de` in the settings, the slide and the phones show German words, such as “Scannen und abstimmen”. The phones use the presentation's colours and fonts; if you pick another theme or palette in the presenter view, the phones change with it.
37
64
 
38
65
  ## Other kinds of questions
39
66
 
40
- Besides a poll, three more kinds work the same way: one line on a slide, answered on the phones.
67
+ Besides a poll, four more kinds work the same way: one line on a slide, answered on the phones.
41
68
 
42
69
  ```markdown
43
70
  <scale room="pace" min="1" max="5" low="Too slow" high="Too fast" />
71
+ <numeric room="limit" question="What is $\lim_{x \to 0} \frac{\sin x}{x}$?" answer="1" />
44
72
  <wordcloud room="mood" placeholder="One word" />
45
73
  <question room="ask" placeholder="Your question" />
46
74
  ```
@@ -48,10 +76,11 @@ Besides a poll, three more kinds work the same way: one line on a slide, answere
48
76
  | Tag | Phones show | The slide shows |
49
77
  |---|---|---|
50
78
  | `<scale>` | The numbers from `min` to `max` (1 to 5 unless set, at most 11 steps), with `low` and `high` as labels at the ends | How many chose each number, and the average. Each phone's latest answer counts |
79
+ | `<numeric>` | A field for a number: `0.5`, `0,5`, `1/2` or `1e-3` | The most frequent answers as bars, six unless `limit` says otherwise, and the rest as Other. With `answer`, the tick marks the right ones and counts them, and each phone hears whether it was right; `tolerance` (0 unless set) says how far off still counts. Each phone's latest answer counts |
51
80
  | `<wordcloud>` | A short text field | A cloud of every answer, packed around the middle, larger the more often it came in and some upright. Capitals and extra spaces do not matter. People may send several. `height` sets its height (520). Colours come from the palette; `--cloud-1` to `--cloud-6` override them |
52
81
  | `<question>` | A text field | The newest answers as cards, eight unless `limit` says otherwise. People may send several |
53
82
 
54
- All of them take `room`, `question` and `qr` like a poll, and `placeholder` for the text fields.
83
+ All of them take `room`, `question` and `qr` like a poll, and `placeholder` for the text fields. Their `question`, and the scale's `low` and `high`, may contain formatting and formulas like a poll's options. All of them have the lock that closes them; `<numeric>` also has the eye and the tick, and takes `results="hidden"`.
55
84
 
56
85
  ## Show the code once
57
86
 
@@ -175,9 +204,11 @@ Two presentations with the same title share a session code. Give one of them `se
175
204
 
176
205
  Answers are anonymous. Each phone gets a random number so it can change its vote; nothing else identifies it. The server keeps answers in memory only. It forgets a room after twelve hours without visitors and everything when it restarts. Each phone can send a limited number of answers in a short time.
177
206
 
207
+ While you present with `mdeck run`, the answers are also kept beside the presentation, in `my-talk.results.json`. It holds each answer with its time, and the phones numbered 1, 2, 3 … per question instead of their random numbers. Once a question was closed, only the answers that counted. **Reset** takes a question's answers out of the file; a new server that starts empty leaves the file as it is. `mdeck results my-talk.md` prints the answers as CSV (question, phone, time, answer), to open in a spreadsheet; `-o answers.csv` saves them to a file. A deck hosted with its own `server` keeps nothing beside it.
208
+
178
209
  ## In a PDF or a shared file
179
210
 
180
- A PDF, a file made with `mdeck send` and a folder hosted without a poll server keep your questions as a record of the talk: each shows its question and options, with a note that it was answered live, and no join code. If the deck's `server` still answers when the file is made or opened, the results it holds appear instead. Printed slides never show the join code or the presenter's buttons.
211
+ A PDF, a file made with `mdeck send` and a folder hosted without a poll server keep your questions as a record of the talk, without a join code. With answers kept in `my-talk.results.json`, each shows its results as they were at the end of the talk; without, its question and options with a note that it was answered live. `--no-results` leaves the kept answers out. If the deck's `server` still answers when the file is opened, the results it holds appear instead. Printed slides never show the join code or the presenter's buttons.
181
212
 
182
213
  ## Make your own activity
183
214
 
@@ -97,6 +97,8 @@ The design page shows themes and palettes on a sample deck with every kind of sl
97
97
  | `mdeck send my-talk.md --no-pdf` | The same, without rendering the PDF |
98
98
  | `mdeck pdf my-talk.md -o talk.pdf` | Render the slides to a PDF file (`--no-polls` leaves out the slides with polls) |
99
99
  | `--no-drawings` | Leave out the drawings from `my-talk.drawings.json`, for `build`, `send` and `pdf` |
100
+ | `--no-results` | Leave out the answers kept in `my-talk.results.json`, for `build`, `send` and `pdf`; the polls then show their questions only |
101
+ | `mdeck results my-talk.md` | The answers kept during the talk, as CSV; `-o answers.csv` saves them to a file |
100
102
 
101
103
  See [Send, host or print your slides](sharing.html) for help choosing between them. Unknown options, missing option values and extra filenames stop the command before it changes any output. `--output=path` and `-o path` are both accepted. `mdeck preview --no-open --port 4200` previews without opening a browser and chooses a port.
102
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mdeck",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "type": "module",
5
5
  "description": "Write slides as a plain text file, present them in the browser, share them as one HTML file or PDF.",
6
6
  "license": "MIT",
@@ -94,7 +94,7 @@ Right column
94
94
  - Named areas use `:::slot name … :::`; the layout's regions and settings come from `mdeck list layouts --json`.
95
95
  - `:::notes … :::` holds speaker notes. `:::steps … :::` reveals list items one at a time. `:::note`, `:::tip`, `:::important`, `:::warning` and `:::caution` are callouts (`mdeck check` warns about other names); for lectures, `:::definition`, `:::theorem`, `:::lemma`, `:::proof`, `:::example` and `:::remark`, numbered in the title (`::: theorem Theorem 2.4 (Lagrange)`). `:::columns … +++ … :::` makes columns inside a slide.
96
96
  - Code fences get syntax highlighting; `$…$` and `$$…$$` render math.
97
- - `<poll room="lunch" options="Mensa|Thai|Pizza" />` lets the audience vote on their phones through a QR code on the slide; each poll needs its own `room`. `<scale room="pace" min="1" max="5" low="Too slow" high="Too fast" />`, `<wordcloud room="mood" />` and `<question room="ask" />` ask for a rating, short words or open answers the same way. With several of them, put `<qrcode join />` (the deck's join code) on an early slide to show the QR code once, and add `qr="false"` to the questions. It works when the deck is presented with `mdeck run <deck>.md --network` or with a server set as `server:` in the deck settings.
97
+ - `<poll room="lunch" options="Mensa|Thai|Pizza" />` lets the audience vote on their phones through a QR code on the slide; each poll needs its own `room`. `question` and `options` may use Markdown and `$…$` maths (`options="$2x$|$x^2$"`); `answer` repeats the right option's text. `multiple` allows several picks; `buttons="letters"` shows only A, B, C on the phones, for options that are pictures or long formulas. `<numeric room="lim" question="…" answer="1/2" tolerance="0.01" />` asks for a number. `<scale room="pace" min="1" max="5" low="Too slow" high="Too fast" />`, `<wordcloud room="mood" />` and `<question room="ask" />` ask for a rating, short words or open answers the same way. With several of them, put `<qrcode join />` (the deck's join code) on an early slide to show the QR code once, and add `qr="false"` to the questions. It works when the deck is presented with `mdeck run <deck>.md --network` or with a server set as `server:` in the deck settings.
98
98
 
99
99
  ## 3. Write the deck
100
100
 
@@ -1,8 +1,9 @@
1
- import { readFileSync, statSync } from 'fs'
2
- import { resolve, dirname, extname } from 'path'
1
+ import { readFileSync, statSync, existsSync } from 'fs'
2
+ import { resolve, dirname, extname, basename } from 'path'
3
3
  import yaml from 'js-yaml'
4
4
  import { parseSlides } from '../core/parseSlides.js'
5
5
  import { scanCitations, citedKeys } from '../core/citations.js'
6
+ import { sampleDeck, SAMPLE_REFERENCES, SAMPLE_REFERENCES_FILE } from '../editor/sampleDeck.js'
6
7
 
7
8
  // The deck's references, formatted here in Node with citeproc so that none of
8
9
  // it travels in the deck: the page receives finished HTML for every citation
@@ -26,8 +27,14 @@ async function readItems(file) {
26
27
  const { mtimeMs } = statSync(file)
27
28
  const cached = parsed.get(file)
28
29
  if (cached?.mtimeMs === mtimeMs) return cached.items
29
- const text = readFileSync(file, 'utf-8')
30
- const ext = extname(file).toLowerCase()
30
+ const items = await parseReferences(readFileSync(file, 'utf-8'), extname(file))
31
+ parsed.set(file, { mtimeMs, items })
32
+ return items
33
+ }
34
+
35
+ // Reference file text as CSL-JSON items; `ext` names the format.
36
+ export async function parseReferences(text, ext) {
37
+ ext = ext.toLowerCase()
31
38
  let items
32
39
  if (ext === '.json') items = JSON.parse(text)
33
40
  else if (ext === '.yml' || ext === '.yaml') {
@@ -40,18 +47,20 @@ async function readItems(file) {
40
47
  items = new Cite(text, { forceType: '@biblatex/text' }).data
41
48
  } else throw new Error(`unknown reference format "${ext}" (use .bib, .json or .yml)`)
42
49
  if (!Array.isArray(items)) throw new Error('expected a list of references')
43
- items = items.filter(item => item && item.id != null).map(item => ({ ...item, id: String(item.id) }))
44
- parsed.set(file, { mtimeMs, items })
45
- return items
50
+ return items.filter(item => item && item.id != null).map(item => ({ ...item, id: String(item.id) }))
46
51
  }
47
52
 
48
- export async function loadBibliography(files) {
53
+ // `sample`: the sample deck's references stand in for a missing file of
54
+ // theirs (the dev and edit servers show the sample deck without a folder).
55
+ export async function loadBibliography(files, { sample = false } = {}) {
49
56
  const items = []
50
57
  const seen = new Set()
51
58
  const diagnostics = []
52
59
  for (const file of files) {
53
60
  let found
54
- try { found = await readItems(file) } catch (error) {
61
+ try {
62
+ found = sample && basename(file) === SAMPLE_REFERENCES_FILE && !existsSync(file) ? await parseReferences(SAMPLE_REFERENCES, '.bib') : await readItems(file)
63
+ } catch (error) {
55
64
  diagnostics.push({ severity: 'warning', code: 'bibliography-file', message: error.code === 'ENOENT' ? `Bibliography ${file} not found` : `Bibliography ${file}: ${error.message}` })
56
65
  continue
57
66
  }
@@ -275,15 +284,24 @@ export async function formatBibliography({ items, texts, csl, lang, nocite = [],
275
284
 
276
285
  // Everything the deck's `bibliography`, `csl`, `nocite` and `lang` ask for,
277
286
  // or null when the deck has no bibliography.
278
- export async function resolveBibliography(deckPath, source) {
287
+ export async function resolveBibliography(deckPath, source, { sample = false } = {}) {
279
288
  let deckConfig
280
289
  try { deckConfig = parseSlides(source).deckConfig ?? {} } catch { return null }
281
290
  const files = bibliographyFiles(deckPath, deckConfig)
282
291
  if (!files.length) return null
283
- const loaded = await loadBibliography(files)
292
+ const loaded = await loadBibliography(files, { sample })
284
293
  const formatted = await formatBibliography({
285
294
  items: loaded.items, texts: deckTexts(source), csl: deckConfig.csl, lang: deckConfig.lang,
286
295
  nocite: deckConfig.nocite ?? [], deckDir: dirname(resolve(deckPath)),
287
296
  })
288
297
  return { ...formatted, diagnostics: [...loaded.diagnostics, ...formatted.diagnostics], files, style: styleFile(deckPath, deckConfig) }
289
298
  }
299
+
300
+ // The sample deck's citations, formatted once: the design page previews it
301
+ // in the editor of any deck, whose own references are another matter.
302
+ let sampleFormatted = null
303
+ export function sampleBibliography() {
304
+ sampleFormatted ??= parseReferences(SAMPLE_REFERENCES, '.bib')
305
+ .then(items => formatBibliography({ items, texts: deckTexts(sampleDeck()) }))
306
+ return sampleFormatted
307
+ }
@@ -0,0 +1,77 @@
1
+ // Keeping the answers while `mdeck run` runs: the presenter's screens send
2
+ // each activity's answers as they come in (src/live/client.js), and they go
3
+ // into <deck>.results.json beside the deck (src/core/results.js).
4
+ //
5
+ // POST /__mdeck/results { room, closed, answers } → { ok }
6
+ //
7
+ // Answers of none take the room out of the file, as Reset does. Writes wait
8
+ // a moment, so a burst of votes is one write, and do not reload the open
9
+ // windows (ownWrites.js).
10
+ import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
11
+ import { resolve } from 'node:path'
12
+ import { resultsFileFor, normalizeResults, withRoom, serializeResults } from '../core/results.js'
13
+ import { isAllowedRequest, send, readJson } from './editorPlugin.js'
14
+ import { markOwnWrite } from './ownWrites.js'
15
+
16
+ export function createResultsFile(deckPath, { debounceMs = 500 } = {}) {
17
+ const path = resultsFileFor(resolve(deckPath))
18
+ let results = null, timer = null
19
+
20
+ const current = () => {
21
+ if (results) return results
22
+ try { results = existsSync(path) ? normalizeResults(JSON.parse(readFileSync(path, 'utf8'))) : normalizeResults({}) }
23
+ catch { results = normalizeResults({}) }
24
+ return results
25
+ }
26
+
27
+ function flush() {
28
+ clearTimeout(timer)
29
+ timer = null
30
+ if (!results) return
31
+ // A deck that never kept answers gets no empty file.
32
+ if (!Object.keys(results.rooms).length && !existsSync(path)) return
33
+ const text = serializeResults(results)
34
+ markOwnWrite(path, text)
35
+ const temp = `${path}.${process.pid}.tmp`
36
+ writeFileSync(temp, text, 'utf8')
37
+ renameSync(temp, path)
38
+ }
39
+
40
+ return {
41
+ path,
42
+ read: current,
43
+ save(room, entry) {
44
+ results = withRoom(current(), room, entry)
45
+ clearTimeout(timer)
46
+ timer = setTimeout(flush, debounceMs)
47
+ },
48
+ flush,
49
+ }
50
+ }
51
+
52
+ export function resultsMiddleware(file, { authorize = isAllowedRequest } = {}) {
53
+ return async (request, response, next) => {
54
+ const { pathname } = new URL(request.url, 'http://localhost')
55
+ if (pathname !== '/') return next()
56
+ if (request.method !== 'POST') { response.setHeader('Allow', 'POST'); return send(response, 405, { error: 'Method not allowed' }) }
57
+ if (!authorize(request)) return send(response, 403, { error: 'Only the presenter keeps the results' })
58
+ try {
59
+ const { room, closed, answers } = await readJson(request)
60
+ file.save(String(room ?? ''), { closed, answers })
61
+ return send(response, 200, { ok: true })
62
+ } catch (error) {
63
+ return send(response, error.status ?? 500, { error: error.message })
64
+ }
65
+ }
66
+ }
67
+
68
+ export function resultsPlugin(slidesPath, { authorize } = {}) {
69
+ return {
70
+ name: 'vite-plugin-mdeck-results',
71
+ configureServer(server) {
72
+ const file = createResultsFile(slidesPath)
73
+ server.middlewares.use('/__mdeck/results', resultsMiddleware(file, { authorize }))
74
+ server.httpServer?.once('close', () => file.flush())
75
+ },
76
+ }
77
+ }