fb-slides 0.10.0 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,62 @@ this — which makes it worth being able to see what arrived.
7
7
  Entries are written as the work lands, under **Unreleased**; the release commit
8
8
  that stamps the version renames that heading to the version and its date.
9
9
 
10
+ ## 0.11.0 — 2026-09-23
11
+
12
+ - **Two named themes, and a picker to try them with.** `themePack:` in
13
+ slides.config.js takes one of the looks this package now ships —
14
+ `custom-aurora`, atmospheric, coloured light behind glass panels under a
15
+ display serif, and `custom-editoriale`, flat and typographic, hairlines and
16
+ almost no colour. They are neither reveal themes nor a second renderer: a pack
17
+ is written against the same tokens as the base theme and dresses the same slide
18
+ vocabulary, so both of them fit the *same Markdown*, and the three layers
19
+ compose — reveal's theme, then the pack, then your own `theme.css`, which still
20
+ has the last word. Neither pack fetches a webfont.
21
+ - **A theme picker in the toolbar, under `dev`.** The last button on the strip
22
+ lists everything the deck could wear under two headings — the packs, then
23
+ reveal's fifteen, with the bare base theme above both. Picking one swaps a
24
+ single `<link>` (and, for a reveal theme, the `data-reveal-theme` the chrome
25
+ follows), with no reload and no restart, and a refresh keeps it. It is one
26
+ choice rather than two: the config will let you name a pack *and* a reveal
27
+ theme, but layering them gives neither look. It previews rather than saves —
28
+ the panel prints the `themePack:` or `revealTheme:` line to paste once you
29
+ have decided, because slides.config.js is a module with your comments in it.
30
+ It exists only under `dev` by construction: `build` renders the page without
31
+ the list the picker reads, so a published deck never has one.
32
+ - Reveal's themes are now served one URL each, `reveal-themes/<name>.css`,
33
+ rather than all of them through a single `reveal-theme.css` whose contents
34
+ depended on the config. `dev` rewrites any of them on request — that is what
35
+ the picker switches between — while `build` still writes only the one the
36
+ config names, with only the font folders that one imports. A stylesheet whose
37
+ bytes change under a fixed URL is one a browser is right to keep serving
38
+ stale, and the picker changes it between two keystrokes.
39
+ - **Four blocks the packs are built around**, all of them in the edit drawer's
40
+ `+` picker: `chips` (the line above the title), `rows` and `rows--cta` (two or
41
+ three things with a glyph and a sentence each, or the last slide's ways out),
42
+ and `<!-- file: cart.component.ts -->` above a fence, which draws a header bar
43
+ on the code block — the language comes from the file's own extension, so there
44
+ is nothing extra to write. A Markdown table with an empty header row is now
45
+ laid out as a definition list instead of drawing an empty grey bar.
46
+ - **A published deck no longer asks for the edit API.** The edit button was
47
+ already dev-server only — a static host 404s the probe and the drawer never
48
+ exists — but the probe itself went out on every load, and on GitHub Pages
49
+ that was a red `404 /api/editable` in the console of a deck with nothing
50
+ wrong. The runtime now looks at the address bar first and asks only from
51
+ `localhost` (or `127.0.0.1`), the one place the server would answer; from a
52
+ LAN address or a tunnel it stays quiet, as the server would have refused it
53
+ anyway.
54
+ - **The toolbar stays down until you put it away.** It still comes down when
55
+ the mouse goes for the top edge, a tool arms or focus lands on it — but it
56
+ no longer slides back up when the mouse wanders off, nor when the pen or the
57
+ spotlight is switched off. A presenter who has just picked a colour and moved
58
+ down to draw should not have the bar leave from under the next click. An `X`
59
+ chip at the end of the strip puts it away; the `X` key does the same, and
60
+ brings it back.
61
+ - Docs: the key table said `T` for the drawer; it has been `Shift+E` since 0.9.0.
62
+ - The starter deck gains an about-me deck of its own, `decks/02-author.md`, in
63
+ place of the two placeholder photos on the intro slide, and its demos deck
64
+ shows the chips, rows and file-headed code blocks the packs are built around.
65
+
10
66
  ## 0.10.0 — 2026-09-08
11
67
 
12
68
  - **A file in the assets panel opens on click.** The name on a row carries the
package/README.md CHANGED
@@ -293,8 +293,10 @@ one layer per press.
293
293
  whatever else `static:` says, so a path the drawer hands out works in the build
294
294
  too.
295
295
 
296
- Both the drawer and the drop exist only under `fb-slides dev`: a built deck is
297
- static files and never shows the button.
296
+ Both the drawer and the drop exist only under `fb-slides dev`, and only at
297
+ `localhost` (or `127.0.0.1`): a built deck is static files and never shows the
298
+ button, and from any other address — a phone on the LAN, a tunnel, GitHub
299
+ Pages — the page does not even ask for the edit API.
298
300
 
299
301
  ## Presenting
300
302
 
@@ -305,7 +307,8 @@ static files and never shows the button.
305
307
  | `W` | the arrow — click the tail, then the tip; same colours as the pen |
306
308
  | `E` | the spotlight — drag a rectangle, the rest of the slide dims and blurs |
307
309
  | `R` | the pointer — replaces the cursor: a glowing halo, a dart aimed at the centre, or the normal mouse |
308
- | `T` | edit the slide — markdown and notes, saved back into the `.md` (dev server only) |
310
+ | `X` | put the toolbar away, or bring it back — it comes down when the mouse goes for the top edge or a tool arms, and stays until this |
311
+ | `Shift+E` | edit the slide — markdown and notes, saved back into the `.md` (dev server only) |
309
312
  | `S` | speaker view — notes, timer, next slide |
310
313
  | `Esc` / `O` | overview — the slides as a grid |
311
314
  | `V` | the vertical navigator — every slide by title, down the left edge |
@@ -330,7 +333,8 @@ export default {
330
333
  static: ['assets', 'demo'], // served and published; auto-detected when omitted
331
334
  revealTheme: 'dracula', // one of reveal.js's own themes; omit for this one
332
335
  webfonts: false, // let the reveal themes fetch their Google fonts
333
- theme: 'theme.css', // loaded after the base theme; auto when it exists
336
+ themePack: 'custom-aurora', // one of this package's own named themes
337
+ theme: 'theme.css', // loaded last, over both; auto when it exists
334
338
  favicon: 'assets/favicon.png',
335
339
 
336
340
  signature: { // the corner logo. Omit the key, omit the logo
@@ -439,7 +443,8 @@ export default { revealTheme: 'dracula' };
439
443
  `serif` · `simple` · `sky` · `solarized` · `white` · `white-contrast`
440
444
 
441
445
  Only the chosen one is ever loaded — one `<link>`, ~7 KB — and the build copies that file
442
- and nothing else. Two things happen beyond the link:
446
+ and nothing else. Under `dev` all fifteen are reachable, one URL each, so the picker below
447
+ can try them without a restart. Two things happen beyond the link:
443
448
 
444
449
  **It is served from the deck, not from a CDN.** Six of the themes open with
445
450
  `@import url(https://fonts.googleapis.com/…)`. Those lines are stripped, so a talk still
@@ -454,6 +459,92 @@ deck. Every reveal 5 theme declares its palette as `--r-*` custom properties, an
454
459
  theme points its own tokens at them, so the whole page moves together. A `theme.css` in the
455
460
  project still has the last word over both.
456
461
 
462
+ ### The named themes
463
+
464
+ Two whole looks ship with the package, picked by name:
465
+
466
+ ```js
467
+ export default { themePack: 'custom-aurora' };
468
+ ```
469
+
470
+ `custom-aurora` — atmospheric. Coloured light pooling behind the slide, glass panels
471
+ floating on it, a display serif carrying the titles.
472
+
473
+ `custom-editoriale` — flat and typographic. No panels and almost no colour: one serif,
474
+ hairlines for dividing, and the accent kept for the two or three words a slide is about.
475
+
476
+ They are not reveal themes and they are not a second renderer. A pack is written against
477
+ the same `--bg` / `--accent` / `--text` tokens as the base theme and styles the same slide
478
+ vocabulary, which is what lets both of them dress the *same Markdown*: the `chips` line
479
+ that is a row of pills under `custom-aurora` is a `// LIKE THIS` rule under
480
+ `custom-editoriale`, and the `.md` does not change a character.
481
+
482
+ The three layers compose, in this order — reveal's theme, then the pack, then your
483
+ `theme.css`. Wearing a pack and still overriding two of its colours is a two-line file.
484
+
485
+ Neither pack fetches a webfont. The serif is whatever the machine already has, closest
486
+ first; a talk that has to survive the conference wifi cannot open with a request to
487
+ fonts.googleapis.com.
488
+
489
+ ### Switching themes while you work
490
+
491
+ `fb-slides dev` puts a theme picker in the toolbar — the last button, next to the pen. It
492
+ lists everything the deck could wear, under two headings: the packs above, reveal's fifteen
493
+ below, with the bare base theme at the top. Picking swaps one `<link>`: no reload, no
494
+ restart, and the choice survives a refresh.
495
+
496
+ One choice, not two. The config will let you name a pack and a reveal theme at once, but
497
+ layering them gives you neither look — only whichever rule happened to come last — so the
498
+ picker treats them as one question. Your `theme.css` still lands on top of whatever is
499
+ picked, as it always does.
500
+
501
+ It is a preview, not a setting. Nothing is written to disk — `slides.config.js` is a module
502
+ with your own comments in it, and a picker that rewrites it is a worse problem than the one
503
+ it solves — so the panel prints the line to paste when you have decided.
504
+
505
+ The picker exists only under `dev`, and by construction rather than by a flag: `dev` renders
506
+ the page with the theme list in it and `build` does not, so a published deck has nothing for
507
+ the switcher to read and it never builds itself. The fifteen are the same way: `dev` can
508
+ rewrite any of them on request, `build` writes only the one the config names.
509
+
510
+ ### The slide vocabulary the packs dress
511
+
512
+ Four blocks both packs know. All four are in the edit drawer's `+` picker, so none of this
513
+ has to be remembered:
514
+
515
+ ```html
516
+ <p class="chips"><span>Angular</span><span>Signals</span><span>v22</span></p>
517
+ ```
518
+
519
+ The line above the title. The first chip is the loud one — in a row of equals nothing is
520
+ being asked.
521
+
522
+ ```html
523
+ <div class="rows">
524
+ <div class="row">
525
+ <span class="row-icon">( )</span>
526
+ <div><strong>Signals</strong><p>UI state, derived values, clean templates.</p></div>
527
+ </div>
528
+ </div>
529
+ ```
530
+
531
+ Two or three things, each with a glyph and one sentence. `class="rows rows--cta"` turns the
532
+ same block into the last slide's ways out: every row grows an arrow, and the first is filled.
533
+
534
+ ````md
535
+ <!-- file: cart.component.ts -->
536
+ ```ts
537
+ …
538
+ ```
539
+ ````
540
+
541
+ A header bar on the code block: the name on the left, the language on the right. The
542
+ language is never written down — it is the file's own extension. The marker is a comment,
543
+ in the shape the demo slides already use, so the `.md` still renders anywhere else.
544
+
545
+ A two-column Markdown table with an **empty header row** is a definition list — the term on
546
+ the left, one sentence on the right — and the empty bar is dropped rather than drawn.
547
+
457
548
  ## Publishing
458
549
 
459
550
  ```bash
package/lib/build.mjs CHANGED
@@ -14,7 +14,7 @@ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
14
14
  import { assertUsable } from './config.mjs';
15
15
  import { listDecks } from './decks.mjs';
16
16
  import { renderIndex } from './render.mjs';
17
- import { REVEAL_THEME_URL, readRevealTheme } from './theme.mjs';
17
+ import { readRevealTheme, revealThemeUrl } from './theme.mjs';
18
18
  import { VENDOR_FILES, VENDOR_MOUNTS, packageDir } from './vendor.mjs';
19
19
 
20
20
  const copyFile = async (from, to) => {
@@ -64,7 +64,9 @@ export const build = async (config, runtimeDir, { quiet = false } = {}) => {
64
64
  // only those: source-sans-pro is 1.8 MB and most themes never ask for it.
65
65
  if (config.revealTheme) {
66
66
  const { css, fonts, dir } = await readRevealTheme(config.revealTheme, { webfonts: config.webfonts });
67
- await writeFile(join(out, REVEAL_THEME_URL), css);
67
+ const themeFile = join(out, revealThemeUrl(config.revealTheme));
68
+ await mkdir(dirname(themeFile), { recursive: true });
69
+ await writeFile(themeFile, css);
68
70
  for (const font of fonts) {
69
71
  await cp(join(dir, 'fonts', font), join(out, 'vendor', VENDOR_MOUNTS['reveal.js'], 'dist/theme/fonts', font), {
70
72
  recursive: true,
package/lib/config.mjs CHANGED
@@ -10,7 +10,7 @@ import { existsSync, statSync } from 'node:fs';
10
10
  import { basename, join, resolve } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
 
13
- import { assertRevealTheme } from './theme.mjs';
13
+ import { assertDeckTheme, assertRevealTheme } from './theme.mjs';
14
14
 
15
15
  const CONFIG_FILES = ['slides.config.js', 'slides.config.mjs', 'slides.config.json'];
16
16
 
@@ -110,8 +110,14 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
110
110
  // default — the deck is served whole or not at all — at the cost of falling
111
111
  // back to the theme's second-choice font stack.
112
112
  webfonts: user.webfonts ?? false,
113
- // The project's own CSS, loaded *after* the base theme: an override, not a
114
- // replacement. `theme.css` at the root is picked up without being declared.
113
+ // One of this package's own named themes — a whole skin over the base one,
114
+ // from runtime/themes/. It sits between reveal's theme and the project's CSS,
115
+ // so all three compose: a pack can be worn and still be overridden. Null is
116
+ // the base look, bare.
117
+ themePack: user.themePack ?? null,
118
+ // The project's own CSS, loaded *after* the base theme and after any pack:
119
+ // an override, not a replacement. `theme.css` at the root is picked up
120
+ // without being declared.
115
121
  theme: user.theme ?? (existsSync(join(root, 'theme.css')) ? 'theme.css' : null),
116
122
  favicon: user.favicon ?? null,
117
123
  signature: user.signature ?? null,
@@ -136,6 +142,7 @@ export const loadConfig = async (root = process.cwd(), overrides = {}) => {
136
142
  // Named early: a typo in the config should be a message, not a deck that
137
143
  // silently wears the wrong clothes.
138
144
  if (config.revealTheme) await assertRevealTheme(config.revealTheme);
145
+ if (config.themePack) await assertDeckTheme(config.themePack);
139
146
 
140
147
  // `--no-editor` is its own flag rather than a part of `--no-servers`: that one
141
148
  // is for skipping a demo's dev server, and a demo that is not running is
package/lib/dev.mjs CHANGED
@@ -15,7 +15,7 @@ import { EDITOR_PORT_TRIES, assertUsable, portOf } from './config.mjs';
15
15
  import { createEditApi } from './edit.mjs';
16
16
  import { createDeckServer, listen } from './server.mjs';
17
17
  import { renderIndex } from './render.mjs';
18
- import { REVEAL_THEME_URL, readRevealTheme } from './theme.mjs';
18
+ import { listDeckThemes, listRevealThemes, readRevealTheme, revealThemeUrl } from './theme.mjs';
19
19
  import { VENDOR_MOUNTS, packageDir } from './vendor.mjs';
20
20
 
21
21
  const OPEN = { darwin: 'open', win32: 'start' };
@@ -197,6 +197,9 @@ export const dev = async (config, runtimeDir, reload) => {
197
197
  assertUsable(config);
198
198
  const current = reader(config, reload);
199
199
  let editorUrl = null;
200
+ // Fixed for the life of the server: they come out of node_modules, which does
201
+ // not change while a talk is being given.
202
+ const revealThemes = await listRevealThemes();
200
203
 
201
204
  const mounts = [
202
205
  // The project first: a file next to the decks shadows the one this package
@@ -221,20 +224,38 @@ export const dev = async (config, runtimeDir, reload) => {
221
224
  // Read at request time, not at wiring time: the editor is started below,
222
225
  // after the port is bound, and the first page load comes later still.
223
226
  const editor = editorUrl ? { url: editorUrl, root: config.root, sources: now.demoSources } : null;
224
- return renderIndex(now, runtimeDir, editor ? { editor } : {});
227
+ // The theme switcher's whole list, and what the config currently says. It
228
+ // rides in `extra` rather than behind an /api/ probe because that is what
229
+ // makes it dev-only *by construction*: `build` calls renderIndex without
230
+ // it, so a published page has no list, and the switcher never builds.
231
+ const themes = {
232
+ packs: await listDeckThemes(),
233
+ reveal: revealThemes,
234
+ // What the config says the deck is wearing. A project that names both —
235
+ // nothing stops it — is showing the pack, so that is what the switcher
236
+ // opens on.
237
+ current: now.themePack ?? now.revealTheme ?? null,
238
+ };
239
+ return renderIndex(now, runtimeDir, { ...(editor ? { editor } : {}), themes });
225
240
  },
226
241
  // Editing a slide in the browser saves it back into its .md, and dropping
227
242
  // a file on the deck puts it in assets/ — dev only, which is what makes the
228
243
  // edit button appear at all (the runtime probes /api/editable). Build and
229
244
  // preview never mount this.
230
245
  api: createEditApi(config.decksPath, { dir: config.assetsDir, path: config.assetsPath }),
231
- generated: {
232
- [`/${REVEAL_THEME_URL}`]: async () => {
233
- const now = await current();
234
- if (!now.revealTheme) return '/* no revealTheme in slides.config.js */';
235
- return (await readRevealTheme(now.revealTheme, { webfonts: now.webfonts })).css;
236
- },
237
- },
246
+ // All fifteen, each at its own URL, rewritten on request. `build` writes only
247
+ // the one the config names — it has no switcher to feed — but here any of
248
+ // them can be asked for at any moment, and the rewrite is a string replace
249
+ // over a 7 KB file, so there is nothing to gain by being clever about it.
250
+ //
251
+ // `webfonts` is read per request like everything else: flipping it in the
252
+ // config and reloading changes what these hand back.
253
+ generated: Object.fromEntries(
254
+ revealThemes.map((name) => [
255
+ `/${revealThemeUrl(name)}`,
256
+ async () => (await readRevealTheme(name, { webfonts: (await current()).webfonts })).css,
257
+ ]),
258
+ ),
238
259
  });
239
260
 
240
261
  await listen(server, config.port);
package/lib/edit.mjs CHANGED
@@ -78,7 +78,9 @@ const readBody = (req, limit = 1024 * 1024) =>
78
78
  // web page you have open can POST to it. Cross-origin requests cannot carry a
79
79
  // custom header without a CORS preflight — which is never answered — so
80
80
  // requiring one shuts that door; the Host check covers DNS rebinding, where
81
- // the request arrives same-origin under a name that is not ours.
81
+ // the request arrives same-origin under a name that is not ours. The runtime
82
+ // keeps a copy of this list (runtime/edit.js) and does not ask from anywhere
83
+ // else: change one, change the other.
82
84
  const LOCAL_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
83
85
 
84
86
  // `assets` is `{ dir, path }` — the folder a dropped file lands in. Without it
package/lib/render.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  import { readFile } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
9
 
10
- import { REVEAL_THEME_URL } from './theme.mjs';
10
+ import { deckThemeUrl, revealThemeUrl } from './theme.mjs';
11
11
 
12
12
  const escapeHtml = (value) =>
13
13
  String(value).replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
@@ -32,9 +32,21 @@ const headHtml = (config) => {
32
32
  if (config.favicon) tags.push(`<link rel="icon" href="${escapeHtml(config.favicon)}" />`);
33
33
  // After the base theme, before the project's: reveal's theme dresses the
34
34
  // slides, and whatever the project says still has the last word.
35
- if (config.revealTheme) tags.push(`<link rel="stylesheet" href="${REVEAL_THEME_URL}" />`);
36
- // After the base theme on purpose: the project's CSS overrides, it does not replace.
37
- if (config.theme) tags.push(`<link rel="stylesheet" href="${escapeHtml(config.theme)}" />`);
35
+ // The id, like the pack's below, is the handle the dev-only switcher swaps.
36
+ if (config.revealTheme)
37
+ tags.push(
38
+ `<link id="deck-reveal-theme" rel="stylesheet" href="${escapeHtml(revealThemeUrl(config.revealTheme))}" />`,
39
+ );
40
+ // A named pack from this package, between reveal's theme and the project's.
41
+ // The id is the handle the dev-only switcher swaps; a built deck carries it
42
+ // too and simply never has anything that looks it up.
43
+ if (config.themePack)
44
+ tags.push(`<link id="deck-theme" rel="stylesheet" href="${escapeHtml(deckThemeUrl(config.themePack))}" />`);
45
+ // After the base theme and after any pack, on purpose: the project's CSS
46
+ // overrides, it does not replace. The id is what the switcher inserts a pack
47
+ // *before* on a deck that started with none — order is the whole contract here.
48
+ if (config.theme)
49
+ tags.push(`<link id="deck-theme-project" rel="stylesheet" href="${escapeHtml(config.theme)}" />`);
38
50
  return tags.join('\n ');
39
51
  };
40
52
 
package/lib/theme.mjs CHANGED
@@ -13,17 +13,28 @@
13
13
  // ---------------------------------------------------------------------------
14
14
 
15
15
  import { readdir, readFile } from 'node:fs/promises';
16
- import { basename, join } from 'node:path';
16
+ import { basename, dirname, join } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
17
18
 
18
19
  import { VENDOR_MOUNTS, packageDir } from './vendor.mjs';
19
20
 
20
- export const REVEAL_THEME_URL = 'reveal-theme.css';
21
+ // One URL per theme rather than one URL for "the theme". A single
22
+ // `reveal-theme.css` was enough while the answer could only change by editing
23
+ // the config and reloading; the dev switcher changes it between two keystrokes,
24
+ // and a stylesheet whose bytes depend on state the browser cannot see is a
25
+ // stylesheet the browser is right to keep serving stale.
26
+ //
27
+ // Relative, like every other URL the page holds: dist/ has to survive being
28
+ // published in a subfolder.
29
+ export const revealThemeUrl = (name) => `reveal-themes/${name}.css`;
21
30
 
22
31
  const themeDir = () => join(packageDir('reveal.js'), 'dist', 'theme');
23
32
 
24
- // The theme is served from the site root, so reveal's own `./fonts/…` would
25
- // resolve one folder too high: point it at the vendor mount instead.
26
- const FONT_BASE = `vendor/${VENDOR_MOUNTS['reveal.js']}/dist/theme/fonts/`;
33
+ // A url() in a stylesheet resolves against the stylesheet, not the page. These
34
+ // are served from `reveal-themes/`, one folder down from the deck, so the hop
35
+ // back up is part of the path — reveal's own `./fonts/…` would otherwise look
36
+ // for them inside that folder.
37
+ const FONT_BASE = `../vendor/${VENDOR_MOUNTS['reveal.js']}/dist/theme/fonts/`;
27
38
 
28
39
  const REMOTE_IMPORT = /@import\s+url\(\s*['"]?https?:[^)]*\)\s*;?[ \t]*\n?/gi;
29
40
  const LOCAL_FONT_URL = /url\(\s*['"]?\.\/fonts\//gi;
@@ -55,3 +66,40 @@ export const readRevealTheme = async (name, { webfonts = false } = {}) => {
55
66
  const css = (webfonts ? source : source.replace(REMOTE_IMPORT, '')).replace(LOCAL_FONT_URL, `url(${FONT_BASE}`);
56
67
  return { css, fonts: fontDirs(source), dir: themeDir() };
57
68
  };
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // This package's own named themes, as opposed to reveal's fifteen.
72
+ //
73
+ // The difference is what they dress. A reveal theme takes the slides and leaves
74
+ // the chrome to the bridge at the top of theme.base.css; these are written
75
+ // against that file's own tokens, so the whole page moves at once and nothing
76
+ // has to be bridged. They add no markup either: everything they style is the
77
+ // slide vocabulary theme.base.css already ships, which is what keeps a pack a
78
+ // skin rather than a second renderer.
79
+ //
80
+ // They live in runtime/themes/, so they are served by the runtime mount in dev
81
+ // and copied by the build's first step — a published deck carries the one it
82
+ // wears without either command knowing they exist.
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const deckThemeDir = () => join(dirname(fileURLToPath(import.meta.url)), '..', 'runtime', 'themes');
86
+
87
+ // Relative like every other URL the page holds: dist/ has to survive being
88
+ // published in a subfolder.
89
+ export const deckThemeUrl = (name) => `themes/${name}.css`;
90
+
91
+ export const listDeckThemes = async () =>
92
+ (await readdir(deckThemeDir()).catch(() => []))
93
+ .filter((file) => file.endsWith('.css'))
94
+ .map((file) => basename(file, '.css'))
95
+ .sort();
96
+
97
+ export const assertDeckTheme = async (name) => {
98
+ const themes = await listDeckThemes();
99
+ if (themes.includes(name)) return name;
100
+ throw new Error(
101
+ `unknown themePack: ${name}\n` +
102
+ ` fb-slides ships: ${themes.join(', ')}\n` +
103
+ ` drop the key for the base look, or put your own CSS in \`theme:\``,
104
+ );
105
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fb-slides",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "description": "Markdown-driven reveal.js decks: live demo embeds, annotation, mermaid, and a zero-config dev server",
6
6
  "keywords": [
@@ -76,6 +76,8 @@ const QUANTA = 24;
76
76
  // nothing between Q and R (P, H/J/K/L, B, V, F, G, A, C, O are all elsewhere).
77
77
  const PEN_KEY_CODE = 81; // Q
78
78
  const ARROW_KEY_CODE = 87; // W
79
+ // The toolbar's own key, off the row: X puts the bar away, or brings it back.
80
+ const CLOSE_KEY_CODE = 88; // X
79
81
 
80
82
  const PEN_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
81
83
  stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@@ -87,6 +89,11 @@ const ARROW_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
87
89
  <path d="M5 19L19 5" /><path d="M9.5 5H19v9.5" />
88
90
  </svg>`;
89
91
 
92
+ const CLOSE_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
93
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
94
+ <path d="M6 6l12 12" /><path d="M18 6L6 18" />
95
+ </svg>`;
96
+
90
97
  // Black text on a light chip, light text on a dark one — the armed pen button
91
98
  // wears the ink's own colour, and two of the six are the extremes.
92
99
  const readable = (hex) => {
@@ -144,6 +151,9 @@ const RevealAnnotate = () => ({
144
151
  <button id="tool-arrow" class="deck-tool" type="button" aria-pressed="false"
145
152
  title="Arrow — click the tail, then the tip (W)" aria-label="Arrow">${ARROW_ICON}<span
146
153
  class="deck-key" aria-hidden="true">W</span></button>
154
+ <button id="tool-close" class="deck-tool deck-tools-close" type="button"
155
+ title="Put the toolbar away (X)" aria-label="Put the toolbar away">${CLOSE_ICON}<span
156
+ class="deck-key" aria-hidden="true">X</span></button>
147
157
  <div id="deck-swatches" role="radiogroup" aria-label="Ink colour" hidden>
148
158
  ${COLOURS.map(
149
159
  (colour, i) => `<button class="deck-swatch" type="button" role="radio"
@@ -156,9 +166,35 @@ const RevealAnnotate = () => ({
156
166
  document.body.append(canvas, toolbar);
157
167
  const penButton = toolbar.querySelector('#tool-pen');
158
168
  const arrowButton = toolbar.querySelector('#tool-arrow');
169
+ const closeButton = toolbar.querySelector('#tool-close');
159
170
  const swatches = toolbar.querySelector('#deck-swatches');
160
171
  const ctx = canvas.getContext('2d');
161
172
 
173
+ // ---- showing and hiding ---------------------------------------------
174
+ // The bar sleeps above the top edge and comes down when the pointer goes
175
+ // for it, a tool arms, or focus lands on it — and then it *stays*. Leaving
176
+ // with the mouse, or disarming the tool that brought it down, does not put
177
+ // it away: only the X chip and its key do. A latch rather than :hover,
178
+ // because a presenter who has just picked a colour and moved off to draw
179
+ // should not have the bar slide out from under the next click.
180
+ const showTools = () => toolbar.classList.add('is-open');
181
+ const hideTools = () => {
182
+ toolbar.classList.remove('is-open');
183
+ // Focus left on the X would bring it straight back through `focusin`.
184
+ if (toolbar.contains(document.activeElement)) document.activeElement.blur();
185
+ };
186
+ const toggleTools = () => (toolbar.classList.contains('is-open') ? hideTools() : showTools());
187
+ // `pointerenter` fires for the wake strip too — a pseudo-element is hit
188
+ // tested as its owner — which is how parking the mouse along the top edge
189
+ // still wakes the bar.
190
+ toolbar.addEventListener('pointerenter', showTools);
191
+ toolbar.addEventListener('focusin', showTools);
192
+ closeButton.addEventListener('click', hideTools);
193
+ // The other plugins' tools arm through the same handshake, so one listener
194
+ // covers the spotlight and the pointer as well as the pen and the arrow.
195
+ document.addEventListener('deck:tool-armed', showTools);
196
+ deck.addKeyBinding({ keyCode: CLOSE_KEY_CODE, key: 'X', description: 'Show or hide the toolbar' }, toggleTools);
197
+
162
198
  // ---- the canvas -----------------------------------------------------
163
199
 
164
200
  let width = 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