fb-slides 0.9.1 → 0.11.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 (33) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +96 -5
  3. package/lib/build.mjs +4 -2
  4. package/lib/config.mjs +10 -3
  5. package/lib/dev.mjs +30 -9
  6. package/lib/edit.mjs +3 -1
  7. package/lib/render.mjs +16 -4
  8. package/lib/theme.mjs +53 -5
  9. package/package.json +1 -1
  10. package/runtime/annotate.js +36 -0
  11. package/runtime/assets.js +42 -4
  12. package/runtime/deck.js +48 -3
  13. package/runtime/edit.js +45 -21
  14. package/runtime/snippets.js +43 -0
  15. package/runtime/theme.base.css +320 -14
  16. package/runtime/themes/custom-aurora.css +184 -0
  17. package/runtime/themes/custom-editoriale.css +185 -0
  18. package/runtime/themes.js +250 -0
  19. package/templates/starter/assets/author/JSDAY-26.jpg +0 -0
  20. package/templates/starter/assets/author/devfest-pisa-26.jpg +0 -0
  21. package/templates/starter/assets/author/devfestroma-25.jpg +0 -0
  22. package/templates/starter/assets/author/fb-mountain.jpg +0 -0
  23. package/templates/starter/assets/author/fb-mtb.jpeg +0 -0
  24. package/templates/starter/assets/author/gemini-masterclass-26.jpg +0 -0
  25. package/templates/starter/assets/author/webday-26.jpg +0 -0
  26. package/templates/starter/assets/jshd-sticker.png +0 -0
  27. package/templates/starter/decks/01-intro.md +4 -57
  28. package/templates/starter/decks/02-author.md +64 -0
  29. package/templates/starter/decks/02-demos.md +36 -0
  30. package/templates/starter/slides.config.js +26 -6
  31. package/templates/starter/assets/author/photo-1.svg +0 -11
  32. package/templates/starter/assets/author/photo-2.svg +0 -11
  33. package/templates/starter/assets/screen_2026_08_05_00_24_16.png +0 -0
package/runtime/deck.js CHANGED
@@ -3,6 +3,7 @@ import RevealSpotlight from './spotlight.js';
3
3
  import RevealPointer from './pointer.js';
4
4
  import RevealOutline from './outline.js';
5
5
  import RevealEdit from './edit.js';
6
+ import RevealThemes from './themes.js';
6
7
 
7
8
  // ---------------------------------------------------------------------------
8
9
  // The decks are the Markdown files. This file only assembles them into reveal.js
@@ -225,11 +226,55 @@ await Reveal.initialize({
225
226
  pdfSeparateFragments: false,
226
227
  // Anything the project wants to change, from `reveal:` in slides.config.js.
227
228
  ...(CFG.reveal ?? {}),
228
- // Spotlight, Pointer and Edit after Annotate: they hang their buttons on
229
- // the toolbar the pen builds, in this order.
230
- plugins: [RevealMarkdown, RevealHighlight, RevealNotes, RevealAnnotate(), RevealSpotlight(), RevealPointer(), RevealOutline(), RevealEdit()],
229
+ // Spotlight, Pointer, Edit and Themes after Annotate: they hang their buttons
230
+ // on the toolbar the pen builds, in this order. Themes is last because it is
231
+ // the only one that is not there at all in a built deck.
232
+ plugins: [RevealMarkdown, RevealHighlight, RevealNotes, RevealAnnotate(), RevealSpotlight(), RevealPointer(), RevealOutline(), RevealEdit(), RevealThemes()],
231
233
  });
232
234
 
235
+ // ---------------------------------------------------------------------------
236
+ // `<!-- file: cart.component.ts -->` on the line above a fence puts a header bar
237
+ // on that code block. It follows the `<!-- demo: … -->` marker the decks already
238
+ // use rather than inventing a second shape, and being a comment it costs nothing
239
+ // anywhere else: the same .md still renders in Marp, header and all ignored.
240
+ //
241
+ // The language on the right of the bar is never written down either: it is the
242
+ // file's own extension. Reading it off the highlighter's class instead would
243
+ // have meant `language-typescript` where the fence said `ts` — the name hljs
244
+ // settled on rather than the one anybody types. The bar itself is CSS
245
+ // (`pre[data-file]::before` in theme.base.css); all that happens here is the
246
+ // move from a comment node onto the element the stylesheet can reach.
247
+ // ---------------------------------------------------------------------------
248
+
249
+ const FILE_MARKER = /^\s*file:\s*(.+?)\s*$/;
250
+
251
+ const labelCode = (root) => {
252
+ if (!root) return;
253
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
254
+ const markers = [];
255
+ while (walker.nextNode()) markers.push(walker.currentNode);
256
+
257
+ for (const comment of markers) {
258
+ const name = FILE_MARKER.exec(comment.nodeValue)?.[1];
259
+ if (!name) continue;
260
+ // The fence renders as the <pre> right after the comment. Anything else
261
+ // means the marker is floating on its own, with nothing to label.
262
+ const pre = comment.nextElementSibling;
263
+ comment.remove();
264
+ if (pre?.tagName !== 'PRE') continue;
265
+ pre.dataset.file = name;
266
+ const extension = /\.([a-z0-9]+)$/i.exec(name)?.[1];
267
+ if (extension) pre.dataset.lang = extension.toUpperCase();
268
+ }
269
+ };
270
+
271
+ // Straight away for the deck as the markdown plugin has just left it — not on
272
+ // `ready`, which reveal has already fired by the time initialize() resolves and
273
+ // this line runs — and again per slide, because the edit drawer re-renders the
274
+ // one it is showing and would otherwise drop the bar.
275
+ labelCode(slidesEl);
276
+ Reveal.on('slidechanged', ({ currentSlide }) => labelCode(currentSlide));
277
+
233
278
  // ---------------------------------------------------------------------------
234
279
  // Embedded apps that measure their container once, at mount, come up blank in a
235
280
  // deck: reveal scales slides with a CSS transform, which fires no resize inside
package/runtime/edit.js CHANGED
@@ -1,10 +1,13 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // RevealEdit — edit the slide you are looking at, save it back into its .md.
3
3
  //
4
- // A reveal.js plugin, dev-server only in effect: at init it probes the edit
5
- // API that `fb-slides dev` mounts, and only an answer makes the button appear.
6
- // A built deck is static files, the probe 404s, and this plugin does nothing —
7
- // which is how the same runtime ships in both without configuration.
4
+ // A reveal.js plugin, localhost and dev-server only in effect. At init it first
5
+ // looks at the address bar: anywhere but localhost — a published deck, a phone
6
+ // on the LAN — it stops right there and never asks. On localhost it probes the
7
+ // edit API that `fb-slides dev` mounts, and only an answer makes the button
8
+ // appear: `preview` and any other static server 404 the probe, and this plugin
9
+ // does nothing — which is how the same runtime ships in both without
10
+ // configuration.
8
11
  //
9
12
  // The drawer opens down the right edge with the slide still on stage, one
10
13
  // textarea for the slide and one for the speaker notes. Typing re-renders the
@@ -56,6 +59,12 @@ const joinNotes = ({ content, label, notes }) =>
56
59
  // one without a CORS preflight, which the dev server never answers.
57
60
  const API_HEADERS = { 'x-fb-slides': '1' };
58
61
 
62
+ // Mirror of LOCAL_HOST in lib/edit.mjs: the edit API refuses any other Host,
63
+ // so asking from a published deck — or a phone on the LAN — could only ever
64
+ // cost a 404 in the console. location.hostname carries no port, `[::1]` keeps
65
+ // its brackets, and a file:// page has an empty one.
66
+ const LOCAL_HOST = /^(localhost|127\.0\.0\.1|\[::1\])$/;
67
+
59
68
  const EDIT_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
60
69
  stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
61
70
  <path d="M11 5H6a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-5" />
@@ -84,10 +93,14 @@ const RevealEdit = () => ({
84
93
  id: 'edit',
85
94
 
86
95
  init(deck) {
87
- // Nothing is built until the API answers: on a static host the fetch fails
88
- // or comes back as someone's 404 page, the JSON parse throws, and the deck
89
- // never knows this plugin existed. Init itself stays synchronous so a slow
90
- // or absent server cannot hold up the whole deck.
96
+ // Not on localhost: not even a question. The server would refuse the
97
+ // answer anyway, and a published deck should not log a 404 on every load.
98
+ if (!LOCAL_HOST.test(location.hostname)) return;
99
+
100
+ // Nothing is built until the API answers: under `preview` or any static
101
+ // server the fetch comes back as a 404 page, the JSON parse throws, and
102
+ // the deck never knows this plugin existed. Init itself stays synchronous
103
+ // so a slow or absent server cannot hold up the whole deck.
91
104
  fetch('/api/editable', { headers: API_HEADERS, cache: 'no-cache' })
92
105
  .then((response) => response.json())
93
106
  .then((answer) => {
@@ -132,6 +145,12 @@ const build = (deck, assetsDir) => {
132
145
  <button type="button" class="deck-edit-nav" data-go="1" title="Next slide" aria-label="Next slide">${NEXT_ICON}</button>
133
146
  <button type="button" class="deck-edit-close" aria-label="Close">${CLOSE_ICON}</button>
134
147
  </header>
148
+ <div class="deck-edit-actions">
149
+ <button type="button" class="deck-edit-add" title="Add a slide after this one">Add page</button>
150
+ <button type="button" class="deck-edit-delete" title="Delete this slide">Delete page</button>
151
+ <span class="deck-edit-status" role="status"></span>
152
+ <button type="button" class="deck-edit-save">Save</button>
153
+ </div>
135
154
  <div class="deck-edit-row">
136
155
  <label class="deck-edit-label" for="deck-edit-source">slide</label>
137
156
  <span class="deck-edit-row-acts">
@@ -143,12 +162,6 @@ const build = (deck, assetsDir) => {
143
162
  <textarea id="deck-edit-source" spellcheck="false"></textarea>
144
163
  <label class="deck-edit-label" for="deck-edit-notes">speaker notes</label>
145
164
  <textarea id="deck-edit-notes" spellcheck="false"></textarea>
146
- <footer>
147
- <button type="button" class="deck-edit-add" title="Add a slide after this one">Add page</button>
148
- <button type="button" class="deck-edit-delete" title="Delete this slide">Delete page</button>
149
- <span class="deck-edit-status" role="status"></span>
150
- <button type="button" class="deck-edit-save">Save</button>
151
- </footer>
152
165
  <div class="deck-edit-confirm" hidden>
153
166
  <p>Delete this slide from <code></code>?<br />There is no undo.</p>
154
167
  <div>
@@ -303,10 +316,12 @@ const build = (deck, assetsDir) => {
303
316
  deck.layout();
304
317
  };
305
318
 
306
- // `focus` names the box that gets the room and the cursor — navigation and
307
- // save-reloads pass the one the drawer already had, so following the deck
308
- // never folds the notes away under a writing hand.
309
- const open = async (focus = 'source') => {
319
+ // `focus` names the box that gets the room — navigation and save-reloads
320
+ // pass the one the drawer already had, so following the deck never folds
321
+ // the notes away under a writing hand. `takeFocus` says whether that box
322
+ // also gets the cursor: opening by hand always does, following the deck
323
+ // only when the cursor was already in the drawer.
324
+ const open = async (focus = 'source', { takeFocus = true } = {}) => {
310
325
  const slide = deck.getCurrentSlide();
311
326
  const src = SRC(slide);
312
327
  if (!src) return;
@@ -354,7 +369,7 @@ const build = (deck, assetsDir) => {
354
369
  // stand this drawer down in turn when one of them arms (see below).
355
370
  document.dispatchEvent(new CustomEvent('deck:tool-armed', { detail: 'edit' }));
356
371
  setOpen(true);
357
- (focus === 'notes' ? notesArea : sourceArea).focus();
372
+ if (takeFocus) (focus === 'notes' ? notesArea : sourceArea).focus();
358
373
  };
359
374
 
360
375
  const toggle = () => (editing ? close() : open());
@@ -442,6 +457,11 @@ const build = (deck, assetsDir) => {
442
457
  // stage thing — and the drawer follows through `slidechanged` like any other
443
458
  // navigation.
444
459
  for (const nav of panel.querySelectorAll('.deck-edit-nav')) {
460
+ // The press itself would move the focus onto the button (Chrome does),
461
+ // and the drawer could no longer tell a writing hand from a browsing one
462
+ // when the new slide lands. Held back, the cursor stays where it was —
463
+ // in a box, or out on the deck.
464
+ nav.addEventListener('mousedown', (event) => event.preventDefault());
445
465
  nav.addEventListener('click', () => {
446
466
  if (Number(nav.dataset.go) < 0) deck.left({ skipFragments: true });
447
467
  else deck.right({ skipFragments: true });
@@ -541,12 +561,16 @@ const build = (deck, assetsDir) => {
541
561
 
542
562
  // The drawer follows the deck: landing on another slide loads that one,
543
563
  // and whatever was typed on the old one goes the way of a cancel. The
544
- // overview is a different surface altogether, so there it does close.
564
+ // cursor only comes back to the box it was in — arrowing through the deck
565
+ // from outside the drawer must not pull it in. The overview is a different
566
+ // surface altogether, so there it does close.
545
567
  deck.on('slidechanged', async () => {
546
568
  if (!editing) return;
547
569
  const focus = panel.dataset.focus;
570
+ const active = document.activeElement;
571
+ const takeFocus = active === sourceArea || active === notesArea;
548
572
  discard();
549
- await open(focus);
573
+ await open(focus, { takeFocus });
550
574
  // The new slide would not load — a drawer left standing would lie about
551
575
  // what it edits.
552
576
  if (!editing) setOpen(false);
@@ -192,6 +192,49 @@ const BUILT_IN = [
192
192
  keys: 'appear step reveal element attach',
193
193
  body: '${content}\n<!-- .element: class="fragment" -->',
194
194
  },
195
+ {
196
+ group: 'Text',
197
+ label: 'Eyebrow chips',
198
+ hint: 'the line above the title — pills, or a // rule, depending on the theme',
199
+ keys: 'tag label kicker pill badge topic',
200
+ top: true,
201
+ body: '<p class="chips"><span>${Angular}</span><span>Signals</span></p>',
202
+ },
203
+ {
204
+ group: 'Text',
205
+ label: 'Rows',
206
+ hint: 'two or three things, each with a glyph and one sentence',
207
+ keys: 'cards list compare items icon',
208
+ body:
209
+ '<div class="rows">\n' +
210
+ ' <div class="row">\n' +
211
+ ' <span class="row-icon">${1}</span>\n' +
212
+ ' <div><strong>Name</strong><p>What it is for.</p></div>\n' +
213
+ ' </div>\n' +
214
+ ' <div class="row">\n' +
215
+ ' <span class="row-icon">2</span>\n' +
216
+ ' <div><strong>Name</strong><p>What it is for.</p></div>\n' +
217
+ ' </div>\n' +
218
+ '</div>',
219
+ },
220
+ {
221
+ group: 'Text',
222
+ label: 'Call to action',
223
+ hint: 'the last slide — the first one is filled, both grow an arrow',
224
+ keys: 'cta end follow subscribe outro links',
225
+ body:
226
+ '<div class="rows rows--cta">\n' +
227
+ ' <div class="row"><div><strong>${Save the post}</strong></div></div>\n' +
228
+ ' <div class="row"><div><strong>Follow me</strong></div></div>\n' +
229
+ '</div>',
230
+ },
231
+ {
232
+ group: 'Text',
233
+ label: 'Code, with its file name',
234
+ hint: 'the marker draws the header bar — the language comes from the extension',
235
+ keys: 'fence filename snippet header tab',
236
+ body: '<!-- file: ${cart.component.ts} -->\n```ts\nconst total = computed(() => 0);\n```',
237
+ },
195
238
  {
196
239
  group: 'Text',
197
240
  label: 'Table',
@@ -122,7 +122,14 @@ body,
122
122
  -webkit-background-clip: text;
123
123
  background-clip: text;
124
124
  color: transparent;
125
- margin-bottom: 0.15em;
125
+ /* The gradient only paints inside the element's box, and with
126
+ `background-clip: text` a glyph is invisible wherever it is not painted.
127
+ A pack that tightens line-height under a display serif (both shipped
128
+ packs do) leaves descenders and tall accents outside the line box, so
129
+ they were cut. Padding widens the painted box; the margins take the
130
+ same amount back so nothing on the slide moves. */
131
+ padding: 0.15em 0.1em 0.3em 0;
132
+ margin: -0.15em -0.1em -0.15em 0;
126
133
  }
127
134
  .reveal .slides section.deck-title h2 {
128
135
  color: var(--muted);
@@ -266,6 +273,10 @@ html[data-reveal-theme] .reveal strong { color: var(--r-heading-color, var(--tex
266
273
  .reveal .slides section > table tbody tr:nth-child(even) { background: rgb(255 255 255 / 0.018); }
267
274
  .reveal .slides section > table td:first-child { color: var(--muted); white-space: nowrap; }
268
275
  .reveal .slides section > table th:first-child:empty { min-width: 8ch; }
276
+ /* A table written with an empty header row (`| | |`) is a two-column layout —
277
+ a term and what it means — not a table with nameless columns. Drawing the bar
278
+ anyway puts an empty grey strip across the slide, so it goes. */
279
+ .reveal .slides section > table thead:not(:has(th:not(:empty))) { display: none; }
269
280
 
270
281
  /* --- fragments ----------------------------------------------------------- */
271
282
 
@@ -424,6 +435,163 @@ html[data-reveal-theme] .reveal strong { color: var(--r-heading-color, var(--tex
424
435
  }
425
436
  .reveal .slides .box > :last-child { margin-bottom: 0; }
426
437
 
438
+ /* --- eyebrow chips ------------------------------------------------------- */
439
+ /* `<p class="chips"><span>Angular</span><span>Signals</span></p>` — the line
440
+ above the title that says which talk this is, and where in it you are. The
441
+ first chip is the loud one; the rest ride along quieter, because a row of
442
+ equally loud labels says nothing. One span is simply one pill.
443
+
444
+ A theme pack is free to make this a `// ANGULAR · SIGNALS` rule instead —
445
+ same markup, same meaning, different voice. */
446
+ .reveal .slides .chips {
447
+ display: flex;
448
+ flex-wrap: wrap;
449
+ align-items: center;
450
+ gap: 0.45em;
451
+ margin: 0 0 1.1em;
452
+ font-size: 0.46em;
453
+ line-height: 1;
454
+ }
455
+ .reveal .slides .chips span {
456
+ padding: 0.62em 1em;
457
+ border: 1px solid var(--line);
458
+ border-radius: 999px;
459
+ background: color-mix(in srgb, var(--card) 60%, transparent);
460
+ color: var(--muted);
461
+ font-family: var(--mono);
462
+ letter-spacing: 0.01em;
463
+ white-space: nowrap;
464
+ }
465
+ .reveal .slides .chips span:first-child {
466
+ background: var(--accent);
467
+ border-color: var(--accent);
468
+ color: var(--bg);
469
+ font-weight: 650;
470
+ }
471
+
472
+ /* --- rows ---------------------------------------------------------------- */
473
+ /* A list that reads as cards: one line each, an optional glyph in a tile at the
474
+ left, the name and one sentence about it. It is the shape a comparison slide
475
+ keeps wanting — two things, what each is for — and a <ul> cannot hold it
476
+ without turning into a table nobody can read from the back of the room.
477
+
478
+ `<div class="rows">`, then per item `<div class="row">` holding an optional
479
+ `<span class="row-icon">`, a `<strong>` and a `<p>`. */
480
+ .reveal .slides .rows {
481
+ display: flex;
482
+ flex-direction: column;
483
+ gap: 0.55em;
484
+ margin: 0 0 0.7em;
485
+ }
486
+ .reveal .slides .row {
487
+ display: flex;
488
+ align-items: flex-start;
489
+ gap: 0.75em;
490
+ padding: 0.75em 0.9em;
491
+ border: 1px solid var(--line);
492
+ border-radius: 14px;
493
+ background: color-mix(in srgb, var(--card) 70%, transparent);
494
+ }
495
+ .reveal .slides .row-icon {
496
+ flex: none;
497
+ display: grid;
498
+ place-items: center;
499
+ width: 1.9em;
500
+ height: 1.9em;
501
+ border-radius: 12px;
502
+ background: var(--accent);
503
+ color: var(--bg);
504
+ font-family: var(--mono);
505
+ font-size: 0.85em;
506
+ font-weight: 700;
507
+ line-height: 1;
508
+ }
509
+ /* The text column, whether or not it was wrapped in one. */
510
+ .reveal .slides .row > :not(.row-icon) { min-width: 0; }
511
+ .reveal .slides .row strong {
512
+ display: block;
513
+ margin-bottom: 0.1em;
514
+ font-size: 0.92em;
515
+ }
516
+ .reveal .slides .row p {
517
+ margin: 0;
518
+ color: var(--muted);
519
+ font-size: 0.7em;
520
+ line-height: 1.4;
521
+ }
522
+
523
+ /* `<div class="rows rows--cta">` — the last slide's two ways out. Each row
524
+ grows an arrow at its right edge, and the first one is the one you actually
525
+ want pressed, so it is the one that is filled. Same rule as the chips: in a
526
+ row of equals nothing is being asked. */
527
+ .reveal .slides .rows--cta .row { align-items: center; }
528
+ .reveal .slides .rows--cta .row::after {
529
+ content: '\2192';
530
+ margin-left: auto;
531
+ padding-left: 0.7em;
532
+ font-size: 0.85em;
533
+ opacity: 0.7;
534
+ }
535
+ .reveal .slides .rows--cta .row:first-child {
536
+ background: var(--accent);
537
+ border-color: var(--accent);
538
+ color: var(--bg);
539
+ }
540
+ .reveal .slides .rows--cta .row:first-child p,
541
+ .reveal .slides .rows--cta .row:first-child::after {
542
+ color: color-mix(in srgb, var(--bg) 72%, var(--accent));
543
+ opacity: 1;
544
+ }
545
+ /* `.reveal strong` is white, which on a filled accent row is the one colour it
546
+ must not be: the label has to read against the fill, not against the deck. */
547
+ .reveal .slides .rows--cta .row:first-child strong { color: inherit; }
548
+
549
+ /* --- a code block that says which file it is ----------------------------- */
550
+ /* `<!-- file: cart.component.ts -->` on the line above a fence (deck.js lifts
551
+ it onto the <pre>) draws a header bar: the name on the left, the language on
552
+ the right. The language is not written anywhere — it is the `language-*`
553
+ class the highlighter already applied, uppercased.
554
+
555
+ The bar is the <pre>'s own ::before rather than an element, so the markup a
556
+ deck carries stays a plain fenced code block and Marp still renders it. */
557
+ .reveal pre[data-file] { padding-top: 2.1em; }
558
+ .reveal pre[data-file]::before {
559
+ content: attr(data-file);
560
+ position: absolute;
561
+ top: 0;
562
+ left: 0;
563
+ right: 0;
564
+ height: 2.1em;
565
+ display: flex;
566
+ align-items: center;
567
+ padding: 0 1.2em;
568
+ box-sizing: border-box;
569
+ border-bottom: 1px solid var(--line);
570
+ background: color-mix(in srgb, var(--card) 70%, #14181f);
571
+ color: var(--muted);
572
+ font-family: var(--mono);
573
+ font-size: 0.85em;
574
+ /* Above the stack of absolutely positioned <code> steps, which start at the
575
+ top of the block and would otherwise slide under the bar. */
576
+ z-index: 1;
577
+ }
578
+ .reveal pre[data-lang]::after {
579
+ content: attr(data-lang);
580
+ position: absolute;
581
+ top: 0;
582
+ right: 1.2em;
583
+ height: 2.1em;
584
+ display: flex;
585
+ align-items: center;
586
+ color: var(--accent);
587
+ font-family: var(--mono);
588
+ font-size: 0.72em;
589
+ letter-spacing: 0.08em;
590
+ z-index: 2;
591
+ }
592
+ /* The header sits in the <pre>'s padding; the code keeps its own. */
593
+ .reveal pre[data-file] code { border-radius: 0 0 10px 10px; }
594
+
427
595
  /* --- the author slide ---------------------------------------------------- */
428
596
  /* `<!-- .slide: class="author-slide" -->` turns a slide into the speaker page:
429
597
  photos filling the left edge to edge, the bio centred on the right. Who you
@@ -630,7 +798,9 @@ html[data-reveal-theme] .reveal strong { color: var(--r-heading-color, var(--tex
630
798
 
631
799
  /* Hidden until it is wanted: on stage the slide is the point, so the toolbar
632
800
  sleeps just above the top edge of the screen. Parking the mouse along that
633
- edge — or arming a tool, or tabbing into it — slides it back down. */
801
+ edge — or arming a tool, or tabbing into it — slides it back down, and down
802
+ it stays: `is-open` is a latch annotate.js sets, and only the X chip (or the
803
+ X key) lifts it. Neither the mouse wandering off nor a tool disarming does. */
634
804
  #deck-tools {
635
805
  position: fixed;
636
806
  top: 14px;
@@ -686,19 +856,13 @@ html[data-reveal-theme] .reveal strong { color: var(--r-heading-color, var(--tex
686
856
  0%, 100% { transform: translateX(-50%) translateY(0); }
687
857
  50% { transform: translateX(-50%) translateY(3px); }
688
858
  }
689
- #deck-tools:hover,
690
- #deck-tools:focus-within,
691
- #deck-tools.is-armed {
859
+ #deck-tools.is-open {
692
860
  transform: translateX(-50%) translateY(0);
693
861
  }
694
862
  /* Once the toolbar is out, the chevron has done its job — the strip stays as
695
- a silent hover target. */
696
- #deck-tools:hover::before,
697
- #deck-tools:focus-within::before,
698
- #deck-tools.is-armed::before { cursor: default; }
699
- #deck-tools:hover::after,
700
- #deck-tools:focus-within::after,
701
- #deck-tools.is-armed::after { opacity: 0; }
863
+ a silent surface. */
864
+ #deck-tools.is-open::before { cursor: default; }
865
+ #deck-tools.is-open::after { opacity: 0; }
702
866
 
703
867
  .deck-tool {
704
868
  position: relative;
@@ -739,6 +903,21 @@ html[data-reveal-theme] .reveal strong { color: var(--r-heading-color, var(--tex
739
903
  background: color-mix(in srgb, var(--text) 10%, transparent);
740
904
  color: var(--text);
741
905
  }
906
+ /* The X: last on the strip whatever order the plugins hung their chips in,
907
+ and set a little apart from the tools it puts away. */
908
+ .deck-tools-close {
909
+ order: 1;
910
+ margin-left: 6px;
911
+ }
912
+ .deck-tools-close::before {
913
+ content: '';
914
+ position: absolute;
915
+ left: -5px;
916
+ top: 8px;
917
+ bottom: 8px;
918
+ width: 1px;
919
+ background: var(--line);
920
+ }
742
921
  /* An armed tool wears the colour it draws in. annotate.js owns the pen's
743
922
  palette — it is a list the toolbar enumerates, not a theme token — and sets
744
923
  `--pen` and its readable foreground on #deck-tools as the colour changes. */
@@ -791,6 +970,90 @@ html[data-reveal-theme] .reveal strong { color: var(--r-heading-color, var(--tex
791
970
  0 0 14px color-mix(in srgb, var(--ink) 65%, transparent);
792
971
  }
793
972
 
973
+ /* --- the theme switcher (themes.js, dev only) ---------------------------- */
974
+ /* Hung below the toolbar like the swatches, for the same reason: the toolbar
975
+ comes down from the top edge, so down is where the room is. It exists only
976
+ under `fb-slides dev` — a built deck is rendered without the list this reads,
977
+ and the plugin never gets as far as creating any of it. */
978
+ #deck-themes {
979
+ position: absolute;
980
+ top: calc(100% + 9px);
981
+ left: 50%;
982
+ transform: translateX(-50%);
983
+ min-width: 200px;
984
+ padding: 6px;
985
+ /* Reveal ships fifteen themes and the packs sit above them: the list is
986
+ taller than a toolbar menu should be, so it scrolls rather than running off
987
+ the bottom of a laptop screen. */
988
+ max-height: min(60vh, 420px);
989
+ overflow-y: auto;
990
+ overscroll-behavior: contain;
991
+ background: color-mix(in srgb, var(--card) 92%, transparent);
992
+ border: 1px solid var(--line);
993
+ border-radius: 12px;
994
+ backdrop-filter: blur(6px);
995
+ box-shadow: 0 14px 40px rgb(0 0 0 / 0.4);
996
+ scrollbar-width: thin;
997
+ scrollbar-color: var(--outline-bar) transparent;
998
+ }
999
+ #deck-themes::-webkit-scrollbar { width: 5px; }
1000
+ #deck-themes::-webkit-scrollbar-track { background: transparent; }
1001
+ #deck-themes::-webkit-scrollbar-thumb { background: var(--outline-bar); border-radius: 999px; }
1002
+ #deck-themes::-webkit-scrollbar-thumb:hover { background: var(--outline-bar-hover); }
1003
+
1004
+ /* The two kinds, told apart. Not sticky, unlike the outline's deck headers:
1005
+ there are only two of them, and two sticky headings at the same offset pile
1006
+ up on each other the moment the list is scrolled. */
1007
+ #deck-themes > h2 {
1008
+ margin: 12px 0 4px;
1009
+ padding: 6px 10px 5px;
1010
+ font: 600 9.5px/1 var(--sans);
1011
+ letter-spacing: 0.12em;
1012
+ text-transform: uppercase;
1013
+ color: var(--muted);
1014
+ background: var(--bg-2);
1015
+ border: 0;
1016
+ }
1017
+ #deck-themes[hidden] { display: none; }
1018
+
1019
+ /* Open, not armed — the pressed look belongs to the tools that draw. */
1020
+ #tool-theme[aria-expanded='true'] {
1021
+ background: color-mix(in srgb, var(--text) 12%, transparent);
1022
+ color: var(--text);
1023
+ }
1024
+
1025
+ .deck-theme-row {
1026
+ display: block;
1027
+ width: 100%;
1028
+ padding: 7px 10px;
1029
+ border: 0;
1030
+ border-radius: 8px;
1031
+ background: transparent;
1032
+ color: var(--muted);
1033
+ font: 400 12.5px/1.2 var(--mono);
1034
+ text-align: left;
1035
+ cursor: pointer;
1036
+ transition: background 0.14s ease, color 0.14s ease;
1037
+ }
1038
+ .deck-theme-row:hover { background: color-mix(in srgb, var(--text) 10%, transparent); color: var(--text); }
1039
+ .deck-theme-row[aria-selected='true'] {
1040
+ background: color-mix(in srgb, var(--accent) 22%, transparent);
1041
+ color: var(--text);
1042
+ }
1043
+
1044
+ /* What it takes to keep the choice: the switcher previews, slides.config.js
1045
+ decides. Saying so here is cheaper than being asked. */
1046
+ .deck-theme-note {
1047
+ position: sticky;
1048
+ bottom: -6px;
1049
+ margin: 8px 4px 0;
1050
+ padding: 5px 0 2px;
1051
+ background: var(--bg-2);
1052
+ color: var(--muted);
1053
+ font: 400 10.5px/1.35 var(--mono);
1054
+ opacity: 0.75;
1055
+ }
1056
+
794
1057
  /* --- the outline sidebar (outline.js) ------------------------------------ */
795
1058
 
796
1059
  /* The panel does not float over the deck, it takes room from it: the slide
@@ -1321,11 +1584,14 @@ body.edit-open #deck-edit { transform: none; }
1321
1584
  .deck-edit-ink .hljs-comment,
1322
1585
  .deck-edit-ink .hljs-meta { color: var(--muted); font-weight: 400; }
1323
1586
 
1324
- #deck-edit > footer {
1587
+ /* The file operations sit in a bar under the header, over the boxes: what a
1588
+ save or a delete acts on is the whole slide, not the box the cursor is in. */
1589
+ .deck-edit-actions {
1325
1590
  display: flex;
1326
1591
  align-items: center;
1327
1592
  gap: 10px;
1328
- padding: 12px 16px 14px;
1593
+ padding: 10px 16px;
1594
+ border-bottom: 1px solid var(--line);
1329
1595
  }
1330
1596
  .deck-edit-status {
1331
1597
  flex: 1;
@@ -1683,6 +1949,12 @@ body.edit-open #deck-edit { transform: none; }
1683
1949
  text-overflow: ellipsis;
1684
1950
  white-space: nowrap;
1685
1951
  }
1952
+ /* A file's name shows the file — an image over the deck, the rest in a new
1953
+ tab; the cursor and the underline are the whole affordance, the row stays a
1954
+ row. */
1955
+ .deck-assets-list code.is-viewable { cursor: pointer; }
1956
+ .deck-assets-list code.is-image { cursor: zoom-in; }
1957
+ .deck-assets-list code.is-viewable:hover { color: var(--accent); text-decoration: underline; }
1686
1958
  .deck-assets-file {
1687
1959
  min-width: 0;
1688
1960
  font: 400 12px/1.5 var(--sans);
@@ -1869,6 +2141,40 @@ body.edit-open #deck-edit { transform: none; }
1869
2141
  transform: scale(1.04);
1870
2142
  }
1871
2143
 
2144
+ /* The image preview — over everything the way the drop sheet is: the image is
2145
+ the whole point while it is up, and a click anywhere puts it away. */
2146
+ .deck-assets-preview {
2147
+ position: fixed;
2148
+ inset: 0;
2149
+ z-index: 72;
2150
+ display: grid;
2151
+ place-items: center;
2152
+ background: color-mix(in srgb, var(--bg) 74%, transparent);
2153
+ backdrop-filter: blur(8px);
2154
+ cursor: zoom-out;
2155
+ }
2156
+ .deck-assets-preview[hidden] { display: none; }
2157
+ .deck-assets-preview figure {
2158
+ margin: 0;
2159
+ display: flex;
2160
+ flex-direction: column;
2161
+ align-items: center;
2162
+ gap: 12px;
2163
+ }
2164
+ .deck-assets-preview img {
2165
+ max-width: min(1100px, 84vw);
2166
+ max-height: 78vh;
2167
+ border: 1px solid var(--line);
2168
+ border-radius: 10px;
2169
+ /* A card behind it, so a transparent PNG reads as an image and not a hole. */
2170
+ background: var(--card);
2171
+ box-shadow: 0 18px 60px color-mix(in srgb, #000 45%, transparent);
2172
+ }
2173
+ .deck-assets-preview figcaption code {
2174
+ font: 500 12.5px/1.4 var(--mono);
2175
+ color: var(--accent-2);
2176
+ }
2177
+
1872
2178
  /* The open drawer's button wears the accent — it draws in nothing, like the
1873
2179
  pointer's; the id outweighs the generic armed-tool rule. */
1874
2180
  #tool-edit[aria-pressed='true'] {