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
@@ -0,0 +1,184 @@
1
+ /* ---------------------------------------------------------------------------
2
+ custom-aurora — atmospheric. Coloured light pooling behind the slide, glass
3
+ panels floating on it, a high-contrast serif carrying the titles.
4
+
5
+ Loaded after theme.base.css and before the project's own CSS, so most of what
6
+ follows is token redefinition: the chrome, the outline, the edit drawer and
7
+ the toolbar all move with it without being named once. The rules past the
8
+ tokens are the ones that are genuinely a different shape, not a different
9
+ colour — and they are deliberately few.
10
+
11
+ No webfont is fetched. The serif is whatever the machine already has, in
12
+ order of how close it comes to the intent; a talk that has to survive the
13
+ conference wifi cannot open with a request to fonts.googleapis.com.
14
+ --------------------------------------------------------------------------- */
15
+
16
+ :root {
17
+ --bg: #07070a;
18
+ --bg-2: #0d0d13;
19
+ --card: #16161f;
20
+ --line: #2a2a38;
21
+ --text: #f2f0ea;
22
+ --muted: #9a97a8;
23
+ --accent: #c8f24f;
24
+ --accent-2: #c8f24f;
25
+
26
+ /* The two lights behind the deck, as tokens: a pack that wants a different
27
+ atmosphere changes these two lines and nothing else. */
28
+ --aurora-1: #4b3a94;
29
+ --aurora-2: #2f4a1f;
30
+
31
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
32
+ --serif: "Playfair Display", Didot, "Bodoni 72", "Bodoni MT", "Times New Roman", Georgia, serif;
33
+ }
34
+
35
+ /* The base theme paints two cool halos here. These are the same idea turned up:
36
+ a violet pooling top-left and a green rising from the floor, both far enough
37
+ off the text to stay atmosphere rather than decoration. */
38
+ /* On <html> alone, with <body> cleared. The base theme paints the same gradient
39
+ on both, which is invisible at its strength but not at this one: two identical
40
+ translucent layers are darker than one, so the deck grows a seam exactly where
41
+ <body> stops being as tall as the page. */
42
+ html {
43
+ background:
44
+ radial-gradient(1100px 760px at 22% 8%, color-mix(in srgb, var(--aurora-1) 78%, transparent) 0%, transparent 62%),
45
+ radial-gradient(900px 620px at 88% 92%, color-mix(in srgb, var(--aurora-2) 70%, transparent) 0%, transparent 58%),
46
+ var(--bg);
47
+ background-attachment: fixed;
48
+ }
49
+ body,
50
+ .reveal-viewport { background: none; }
51
+
52
+ /* --- the serif ----------------------------------------------------------- */
53
+ /* Titles only. Body copy, lists and everything the eye has to read quickly stay
54
+ on the sans the base theme sets — a display serif is for the line people
55
+ photograph, not for the four lines under it. */
56
+ .reveal h1,
57
+ .reveal h2,
58
+ .reveal .slides section.deck-title h1 {
59
+ font-family: var(--serif);
60
+ font-weight: 500;
61
+ letter-spacing: -0.015em;
62
+ line-height: 1.02;
63
+ }
64
+ .reveal h1 { font-size: 2.5em; }
65
+ .reveal h2 { font-size: 1.62em; }
66
+
67
+ /* The italic inside a title, in the accent: the one word the slide is about.
68
+ `*like this*` in the Markdown — no class, no wrapper. */
69
+ .reveal h1 em,
70
+ .reveal h2 em {
71
+ font-family: var(--serif);
72
+ font-style: italic;
73
+ font-weight: 500;
74
+ color: var(--accent);
75
+ }
76
+
77
+ /* --- glass --------------------------------------------------------------- */
78
+ /* Panels do not sit on the background, they sit *in front of* it: a touch of
79
+ the light behind them comes through, which is the whole reason for the
80
+ aurora being there at all. */
81
+ .reveal .slides .box,
82
+ .reveal .slides .row {
83
+ background: color-mix(in srgb, var(--card) 55%, transparent);
84
+ border-color: color-mix(in srgb, var(--text) 12%, transparent);
85
+ backdrop-filter: blur(14px);
86
+ }
87
+ .reveal .slides .row { border-radius: 18px; padding: 0.85em 1em; }
88
+ .reveal .slides .row-icon {
89
+ border-radius: 14px;
90
+ /* The second tile takes the violet — two lights, two rows. The nth-child is
91
+ on the row rather than the icon so the rule reads in the order the slide
92
+ does. */
93
+ box-shadow: 0 8px 22px color-mix(in srgb, var(--accent) 30%, transparent);
94
+ }
95
+ .reveal .slides .row:nth-child(even) .row-icon {
96
+ background: color-mix(in srgb, var(--aurora-1) 80%, var(--text));
97
+ color: var(--text);
98
+ box-shadow: 0 8px 22px color-mix(in srgb, var(--aurora-1) 45%, transparent);
99
+ }
100
+
101
+ .reveal pre {
102
+ background: color-mix(in srgb, #0b0b11 82%, transparent);
103
+ border-color: color-mix(in srgb, var(--text) 12%, transparent);
104
+ border-radius: 16px;
105
+ backdrop-filter: blur(14px);
106
+ }
107
+ .reveal pre code { background: transparent; }
108
+ .reveal pre[data-file]::before {
109
+ background: color-mix(in srgb, var(--text) 5%, transparent);
110
+ border-bottom-color: color-mix(in srgb, var(--text) 10%, transparent);
111
+ }
112
+ /* The language, as a lit dot and its name — the tab the code would have had in
113
+ the editor it came from. */
114
+ .reveal pre[data-lang]::after {
115
+ content: '● ' attr(data-lang);
116
+ color: var(--accent);
117
+ }
118
+
119
+ /* --- chips --------------------------------------------------------------- */
120
+ /* Pills. The base theme's shape, on glass. */
121
+ .reveal .slides .chips span {
122
+ background: color-mix(in srgb, var(--card) 55%, transparent);
123
+ border-color: color-mix(in srgb, var(--text) 14%, transparent);
124
+ backdrop-filter: blur(10px);
125
+ }
126
+ .reveal .slides .chips span:first-child {
127
+ background: var(--accent);
128
+ border-color: var(--accent);
129
+ color: #12160a;
130
+ box-shadow: 0 6px 20px color-mix(in srgb, var(--accent) 35%, transparent);
131
+ }
132
+
133
+ /* --- the progress, as segments ------------------------------------------- */
134
+ /* reveal's progress is one filled bar. Cut into dashes it reads as "four of
135
+ these, you are on the first" from the back of the room, which a hairline
136
+ never does — and it stays one element, so nothing has to count slides. */
137
+ .reveal .progress {
138
+ height: 6px;
139
+ bottom: 14px;
140
+ left: auto;
141
+ right: 22px;
142
+ width: 78px;
143
+ background: transparent;
144
+ --dash: repeating-linear-gradient(
145
+ to right,
146
+ currentColor 0 14px,
147
+ transparent 14px 22px
148
+ );
149
+ }
150
+ .reveal .progress::after {
151
+ content: '';
152
+ position: absolute;
153
+ inset: 0;
154
+ color: color-mix(in srgb, var(--text) 22%, transparent);
155
+ background: var(--dash);
156
+ border-radius: 999px;
157
+ }
158
+ .reveal .progress span {
159
+ position: relative;
160
+ z-index: 1;
161
+ height: 100%;
162
+ color: var(--accent);
163
+ background: var(--dash);
164
+ border-radius: 999px;
165
+ }
166
+
167
+ /* The signature moves to the left corner, opposite the progress, and loses the
168
+ pill: on this much light a second bordered shape is one too many. */
169
+ #deck-sig {
170
+ right: auto;
171
+ left: 16px;
172
+ flex-direction: row;
173
+ }
174
+ #deck-sig img { height: 54px; }
175
+ #deck-sig .sig-name {
176
+ margin: 0 0 12px -22px;
177
+ padding: 5px 13px 5px 30px;
178
+ background: none;
179
+ border-color: transparent;
180
+ backdrop-filter: none;
181
+ color: var(--accent);
182
+ font-family: var(--mono);
183
+ font-size: 11px;
184
+ }
@@ -0,0 +1,185 @@
1
+ /* ---------------------------------------------------------------------------
2
+ custom-editoriale — flat and typographic. No panels, no glass, almost no
3
+ colour: one serif doing the talking, hairlines doing the dividing, and the
4
+ accent kept for the two or three words a slide is actually about.
5
+
6
+ It is the same vocabulary as custom-aurora wearing the opposite voice, which
7
+ is the point of a pack being a skin: the chips that are pills over there are
8
+ a `// LIKE THIS` rule here, and the Markdown does not change a character.
9
+
10
+ No webfont is fetched — see the note in custom-aurora.css.
11
+ --------------------------------------------------------------------------- */
12
+
13
+ :root {
14
+ --bg: #0a0a09;
15
+ --bg-2: #101010;
16
+ --card: #141413;
17
+ --line: #2b2b28;
18
+ --text: #f4f1e8;
19
+ --muted: #8d8a80;
20
+ --accent: #cdf551;
21
+ --accent-2: #cdf551;
22
+
23
+ --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
24
+ --serif: "Playfair Display", Didot, "Bodoni 72", "Bodoni MT", "Times New Roman", Georgia, serif;
25
+ }
26
+
27
+ /* One light, low and green, in the corner the eye leaves last. The base theme's
28
+ two cool halos would read as depth, and there is no depth in this one. */
29
+ /* <html> alone — see the note on the same rule in custom-aurora.css. */
30
+ html {
31
+ background:
32
+ radial-gradient(760px 520px at 18% 108%, rgb(120 168 40 / 0.16) 0%, transparent 62%),
33
+ var(--bg);
34
+ background-attachment: fixed;
35
+ }
36
+ body,
37
+ .reveal-viewport { background: none; }
38
+
39
+ /* --- the serif ----------------------------------------------------------- */
40
+
41
+ .reveal h1,
42
+ .reveal h2,
43
+ .reveal .slides section.deck-title h1 {
44
+ font-family: var(--serif);
45
+ font-weight: 500;
46
+ letter-spacing: -0.02em;
47
+ line-height: 0.98;
48
+ }
49
+ .reveal h1 { font-size: 2.7em; }
50
+ .reveal h2 { font-size: 1.7em; }
51
+
52
+ .reveal h1 em,
53
+ .reveal h2 em {
54
+ font-family: var(--serif);
55
+ font-style: italic;
56
+ font-weight: 500;
57
+ color: var(--accent);
58
+ }
59
+
60
+
61
+ /* --- chips, as a rule line ----------------------------------------------- */
62
+ /* Same markup as the pills in custom-aurora: `<p class="chips"><span>…</span></p>`.
63
+ Here it is the slug a magazine runs above the headline — the comment slashes,
64
+ the section in the accent, the rest of the trail after it. */
65
+ .reveal .slides .chips {
66
+ gap: 0.7em;
67
+ margin: 0 0 1.5em;
68
+ font-size: 0.4em;
69
+ letter-spacing: 0.16em;
70
+ }
71
+ .reveal .slides .chips::before {
72
+ content: '//';
73
+ color: var(--muted);
74
+ font-family: var(--mono);
75
+ opacity: 0.6;
76
+ }
77
+ .reveal .slides .chips span {
78
+ padding: 0;
79
+ border: 0;
80
+ border-radius: 0;
81
+ background: none;
82
+ backdrop-filter: none;
83
+ color: var(--muted);
84
+ text-transform: uppercase;
85
+ letter-spacing: inherit;
86
+ }
87
+ .reveal .slides .chips span:first-child {
88
+ background: none;
89
+ color: var(--accent);
90
+ font-weight: 700;
91
+ }
92
+ /* The separator lives on the gap rather than in the markup, so a chips line of
93
+ any length reads the same and the Markdown stays free of punctuation that is
94
+ really styling. */
95
+ .reveal .slides .chips span + span::before {
96
+ content: '·';
97
+ margin-right: 0.7em;
98
+ opacity: 0.55;
99
+ }
100
+
101
+ /* --- the definition table ------------------------------------------------ */
102
+ /* The shape this theme keeps wanting: the term on the left in the accent, in
103
+ mono because it is code, and one sentence about it on the right. It is a
104
+ plain Markdown table — the base theme already mutes the first column, this
105
+ only changes what muted means here. */
106
+ .reveal .slides section > table {
107
+ border-collapse: collapse;
108
+ }
109
+ .reveal .slides section > table th { display: none; }
110
+ .reveal .slides section > table tbody tr { background: none; }
111
+ .reveal .slides section > table td {
112
+ padding: 0.85em 0;
113
+ border-bottom: 1px solid var(--line);
114
+ vertical-align: top;
115
+ }
116
+ .reveal .slides section > table td:first-child {
117
+ width: 34%;
118
+ padding-right: 1.4em;
119
+ color: var(--accent);
120
+ font-family: var(--mono);
121
+ font-size: 0.85em;
122
+ font-weight: 600;
123
+ }
124
+ /* `signal()` in that column is already the loudest thing on the row. The chip
125
+ the base theme gives inline code would be a second frame around it. */
126
+ .reveal .slides section > table td:first-child :not(pre) > code,
127
+ .reveal .slides section > table td:first-child code {
128
+ padding: 0;
129
+ background: none;
130
+ border: 0;
131
+ color: inherit;
132
+ font-size: 1em;
133
+ }
134
+ .reveal .slides section > table td:not(:first-child) {
135
+ color: var(--muted);
136
+ font-size: 0.82em;
137
+ line-height: 1.45;
138
+ }
139
+
140
+ /* --- flat surfaces ------------------------------------------------------- */
141
+ /* No borders and no shadow: the code block is a slightly lighter rectangle and
142
+ that is the whole of it. The filename sits in it rather than on a bar. */
143
+ .reveal pre {
144
+ background: #131312;
145
+ border: 0;
146
+ border-radius: 12px;
147
+ box-shadow: none;
148
+ }
149
+ .reveal pre code { background: transparent; }
150
+ .reveal pre[data-file]::before {
151
+ border-bottom: 0;
152
+ background: none;
153
+ color: var(--muted);
154
+ font-size: 0.8em;
155
+ opacity: 0.8;
156
+ }
157
+ /* The language belongs to the editor tab, and there is no tab here. */
158
+ .reveal pre[data-lang]::after { content: none; }
159
+
160
+ .reveal .slides .box,
161
+ .reveal .slides .row {
162
+ background: none;
163
+ border-color: var(--line);
164
+ border-radius: 10px;
165
+ backdrop-filter: none;
166
+ }
167
+ .reveal .slides .row { padding: 0.9em 1.1em; }
168
+ .reveal .slides .row-icon { border-radius: 8px; box-shadow: none; }
169
+ .reveal .slides .rows--cta .row:first-child { border-radius: 10px; }
170
+ .reveal .slides .rows--cta .row strong { font-family: var(--serif); font-size: 1.15em; font-weight: 500; }
171
+
172
+ /* --- the corner ---------------------------------------------------------- */
173
+ /* The wordmark set in the same serif as the titles, so the signature reads as
174
+ part of the page rather than as a badge stuck on it. */
175
+ #deck-sig .sig-name {
176
+ background: none;
177
+ border-color: transparent;
178
+ backdrop-filter: none;
179
+ color: var(--text);
180
+ font: 500 17px/1 var(--serif);
181
+ letter-spacing: 0;
182
+ }
183
+ #deck-sig img { height: 62px; }
184
+
185
+ .reveal .progress { height: 2px; color: var(--accent); }
@@ -0,0 +1,250 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The theme switcher — dev only, and dev only by construction.
3
+ //
4
+ // `dev` renders the page with a `themes:` list in window.__FB_SLIDES__; `build`
5
+ // calls the same renderer without one. So there is no probe here and no flag to
6
+ // get wrong: on a published deck `CFG.themes` is undefined, this plugin returns
7
+ // on its first line, and nothing it would have built exists.
8
+ //
9
+ // Two kinds of theme sit in the list, under their own headings. A pack is this
10
+ // package's own look, a stylesheet from runtime/themes/. A reveal theme is one
11
+ // of the fifteen, rewritten by the dev server and served from this deck. They
12
+ // are offered as one choice rather than two axes because that is the question
13
+ // being asked — what is this deck wearing — and because layering one over the
14
+ // other gives you neither look, just whichever rule happened to come last.
15
+ //
16
+ // Switching is a swap of one <link>'s href. Nothing is written to disk — the
17
+ // choice is a preview, remembered in localStorage so a reload keeps it, and the
18
+ // popover prints the line to paste into slides.config.js to make it real. That
19
+ // is deliberate: slides.config.js is a module with your comments in it, and a
20
+ // picker that rewrites it is a different and much worse problem.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const CFG = window.__FB_SLIDES__ ?? {};
24
+
25
+ // Per deck rather than per browser: two talks open side by side on different
26
+ // ports should not fight over one key.
27
+ const STORE = `fb-slides:theme:${location.pathname}`;
28
+
29
+ // Mirrors deckThemeUrl() and revealThemeUrl() in lib/theme.mjs.
30
+ const HREF = {
31
+ pack: (name) => `themes/${name}.css`,
32
+ reveal: (name) => `reveal-themes/${name}.css`,
33
+ };
34
+
35
+ // The config key each kind is written with, which is the whole of what the note
36
+ // under the list has to say.
37
+ const KEY = { pack: 'themePack', reveal: 'revealTheme' };
38
+
39
+ // What the base look is called in the list. It is not a file — it is the absence
40
+ // of one, which is why picking it removes the <link>s rather than pointing them
41
+ // somewhere empty.
42
+ const NONE = 'the base theme';
43
+
44
+ const THEME_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
45
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
46
+ <circle cx="12" cy="12" r="8.5" />
47
+ <path d="M12 3.5v17" />
48
+ <path d="M12 3.5a8.5 8.5 0 0 1 0 17z" fill="currentColor" stroke="none" />
49
+ </svg>`;
50
+
51
+ const read = () => {
52
+ try {
53
+ return localStorage.getItem(STORE);
54
+ } catch {
55
+ // Private windows and blocked site data both throw here. A switcher that
56
+ // forgets is still a switcher.
57
+ return null;
58
+ }
59
+ };
60
+
61
+ const write = (value) => {
62
+ try {
63
+ if (value === null) localStorage.removeItem(STORE);
64
+ else localStorage.setItem(STORE, value);
65
+ } catch {
66
+ /* see read() */
67
+ }
68
+ };
69
+
70
+ // A <link> created on demand, in the right place. Order is the contract the
71
+ // whole theming story rests on — reveal's theme, then the pack, then the
72
+ // project's own CSS — and a deck that started with none of them in its head has
73
+ // to have them put back in that order rather than appended as they are picked.
74
+ const linkEl = (id) => {
75
+ const existing = document.querySelector(`#${id}`);
76
+ if (existing) return existing;
77
+ const link = document.createElement('link');
78
+ link.id = id;
79
+ link.rel = 'stylesheet';
80
+ const after =
81
+ id === 'deck-reveal-theme'
82
+ ? document.querySelector('#deck-theme') ?? document.querySelector('#deck-theme-project')
83
+ : document.querySelector('#deck-theme-project');
84
+ if (after) after.before(link);
85
+ else document.head.append(link);
86
+ return link;
87
+ };
88
+
89
+ const drop = (id) => document.querySelector(`#${id}`)?.remove();
90
+
91
+ // `data-reveal-theme` is what points the base theme's tokens at reveal's `--r-*`
92
+ // ones, which is how the navigator and the signature follow a borrowed theme
93
+ // instead of staying black down the side of a white deck. It has to move with
94
+ // the stylesheet, or the chrome ends up dressed for the theme before last.
95
+ const apply = (choice) => {
96
+ if (choice?.kind === 'reveal') {
97
+ drop('deck-theme');
98
+ linkEl('deck-reveal-theme').href = HREF.reveal(choice.name);
99
+ document.documentElement.dataset.revealTheme = choice.name;
100
+ return;
101
+ }
102
+ drop('deck-reveal-theme');
103
+ delete document.documentElement.dataset.revealTheme;
104
+ if (choice?.kind === 'pack') linkEl('deck-theme').href = HREF.pack(choice.name);
105
+ else drop('deck-theme');
106
+ };
107
+
108
+ const same = (a, b) => a?.kind === b?.kind && a?.name === b?.name;
109
+
110
+ const RevealThemes = () => ({
111
+ id: 'themes',
112
+
113
+ init() {
114
+ const themes = CFG.themes;
115
+ if (!themes) return;
116
+ const packs = themes.packs ?? [];
117
+ const reveal = themes.reveal ?? [];
118
+ if (!packs.length && !reveal.length) return;
119
+
120
+ const kindOf = (name) =>
121
+ packs.includes(name) ? 'pack' : reveal.includes(name) ? 'reveal' : null;
122
+ const choiceOf = (name) => {
123
+ const kind = name ? kindOf(name) : null;
124
+ return kind ? { kind, name } : null;
125
+ };
126
+
127
+ const configured = choiceOf(themes.current);
128
+
129
+ // A name saved against a theme that has since been renamed or removed would
130
+ // point the <link> at a 404 and leave the deck looking broken for no reason
131
+ // the page could explain. Anything the list no longer knows falls back to
132
+ // the config; the empty string is a saved "base", which is not the same as
133
+ // no saved choice at all.
134
+ const saved = read();
135
+ let current = configured;
136
+ if (saved === '') current = null;
137
+ else if (saved !== null) {
138
+ const choice = choiceOf(saved);
139
+ if (choice) current = choice;
140
+ else write(null);
141
+ }
142
+ if (!same(current, configured)) apply(current);
143
+
144
+ // ---- chrome ---------------------------------------------------------
145
+ // The toolbar is annotate's; every plugin that wants a button hangs it on
146
+ // whatever is there and builds one if it has to. Registered after Edit in
147
+ // deck.js, so this button sits last.
148
+ let toolbar = document.querySelector('#deck-tools');
149
+ if (!toolbar) {
150
+ toolbar = document.createElement('div');
151
+ toolbar.id = 'deck-tools';
152
+ toolbar.setAttribute('role', 'toolbar');
153
+ toolbar.setAttribute('aria-label', 'Slide tools');
154
+ document.body.append(toolbar);
155
+ }
156
+
157
+ const button = document.createElement('button');
158
+ button.id = 'tool-theme';
159
+ button.className = 'deck-tool';
160
+ button.type = 'button';
161
+ // `aria-expanded` and not `aria-pressed`: a pressed tool is a *drawing* tool
162
+ // in this toolbar — it wears the pen's colour, and annotate.js counts it when
163
+ // deciding whether the deck is being marked up. An open menu is neither.
164
+ button.setAttribute('aria-expanded', 'false');
165
+ button.title = 'Theme (dev only)';
166
+ button.innerHTML = THEME_ICON;
167
+ toolbar.append(button);
168
+
169
+ const panel = document.createElement('div');
170
+ panel.id = 'deck-themes';
171
+ panel.setAttribute('role', 'listbox');
172
+ panel.setAttribute('aria-label', 'Theme');
173
+ panel.hidden = true;
174
+ // Inside the toolbar, like the pen's swatches: #deck-tools is fixed and
175
+ // hides itself until the pointer goes looking for it, and a panel on <body>
176
+ // would be left hanging in mid-air when it slides away.
177
+ toolbar.append(panel);
178
+
179
+ const rows = [];
180
+ const addRow = (choice, label) => {
181
+ const row = document.createElement('button');
182
+ row.className = 'deck-theme-row';
183
+ row.type = 'button';
184
+ row.setAttribute('role', 'option');
185
+ row.textContent = label;
186
+ row.addEventListener('click', () => pick(choice));
187
+ panel.append(row);
188
+ rows.push({ row, choice });
189
+ };
190
+ const addGroup = (label) => {
191
+ const heading = document.createElement('h2');
192
+ heading.textContent = label;
193
+ panel.append(heading);
194
+ };
195
+
196
+ addRow(null, NONE);
197
+ if (packs.length) {
198
+ addGroup('fb-slides');
199
+ for (const name of packs) addRow({ kind: 'pack', name }, name);
200
+ }
201
+ if (reveal.length) {
202
+ addGroup('reveal.js');
203
+ for (const name of reveal) addRow({ kind: 'reveal', name }, name);
204
+ }
205
+
206
+ // What it takes to keep the choice. The switcher is a preview; this line is
207
+ // the part that outlives the browser tab.
208
+ const note = document.createElement('p');
209
+ note.className = 'deck-theme-note';
210
+ panel.append(note);
211
+
212
+ const paint = () => {
213
+ for (const { row, choice } of rows) {
214
+ row.setAttribute('aria-selected', String(same(choice, current)));
215
+ }
216
+ note.textContent = same(current, configured)
217
+ ? 'from slides.config.js'
218
+ : `preview — keep it with ${current ? `${KEY[current.kind]}: '${current.name}'` : 'no theme key at all'}`;
219
+ // A row well down the fifteen is worth scrolling to when the panel opens
220
+ // on it.
221
+ rows.find(({ choice }) => same(choice, current))?.row.scrollIntoView({ block: 'nearest' });
222
+ };
223
+
224
+ const open = (show) => {
225
+ panel.hidden = !show;
226
+ button.setAttribute('aria-expanded', String(show));
227
+ if (show) paint();
228
+ };
229
+
230
+ const pick = (choice) => {
231
+ current = choice;
232
+ apply(choice);
233
+ write(same(choice, configured) ? null : (choice?.name ?? ''));
234
+ paint();
235
+ };
236
+
237
+ button.addEventListener('click', (event) => {
238
+ event.stopPropagation();
239
+ open(panel.hidden);
240
+ });
241
+ document.addEventListener('click', (event) => {
242
+ if (!panel.hidden && !panel.contains(event.target)) open(false);
243
+ });
244
+ document.addEventListener('keydown', (event) => {
245
+ if (event.key === 'Escape' && !panel.hidden) open(false);
246
+ });
247
+ },
248
+ });
249
+
250
+ export default RevealThemes;