@liebstoeckel/engine 0.3.5 → 0.3.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liebstoeckel/engine",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "The compiler, React runtime, and single-file build pipeline at the core of liebstoeckel.",
5
5
  "keywords": [
6
6
  "liebstoeckel",
package/src/Deck.tsx CHANGED
@@ -24,6 +24,8 @@ import { normalizeSlides, type SlideInput } from "./slides";
24
24
  import { resolveTransition, mobileTransitionsDisabled, type SlideDirection, type SlideTransition } from "./transitions";
25
25
  import type { Theme } from "@liebstoeckel/theme";
26
26
  import { useCoarsePointer } from "./useCoarsePointer";
27
+ import { moveSelection, gridCols, type GridDir } from "./overview";
28
+ import type { NavMode } from "./interaction";
27
29
 
28
30
  export type DeckProps = {
29
31
  slides: SlideInput[];
@@ -98,8 +100,34 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
98
100
  const [overview, setOverview] = useState(false);
99
101
  const [qr, setQr] = useState(false);
100
102
  const [jump, setJump] = useState("");
103
+ // Terminal "end of deck" screen: advancing past the final slide's last step shows
104
+ // a deliberate end state (like PowerPoint) instead of replaying the last slide's
105
+ // steps. Local to the driving window; cleared on any navigation away.
106
+ const [ended, setEnded] = useState(false);
107
+ // Keyboard selection within the overview grid (seeded to the current slide on open).
108
+ const [sel, setSel] = useState(0);
101
109
  const brand = brands[brandIdx % brands.length];
102
110
 
111
+ // The active interaction layer drives keyboard routing: a modal layer (overview,
112
+ // end) owns its keys so deck nav never leaks through it.
113
+ const mode: NavMode = overview ? "overview" : ended ? "end" : "slide";
114
+
115
+ // Refs mirror the composing state so the keyboard handlers stay stable and never
116
+ // read a stale closure when layers interact.
117
+ const overviewRef = useRef(overview);
118
+ overviewRef.current = overview;
119
+ const selRef = useRef(sel);
120
+ selRef.current = sel;
121
+ const jumpRef = useRef(jump);
122
+ jumpRef.current = jump;
123
+ const stepRef = useRef(step);
124
+ stepRef.current = step;
125
+ const totalRef = useRef(total);
126
+ totalRef.current = total;
127
+ const selectedThumbRef = useRef<HTMLButtonElement | null>(null);
128
+ // True while a Restart's masked crossfade-to-slide-1 is in flight (see onRestart).
129
+ const pendingRestartRef = useRef(false);
130
+
103
131
  useEffect(() => {
104
132
  document.body.dataset.brand = brand;
105
133
  }, [brand]);
@@ -113,33 +141,121 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
113
141
  return () => window.removeEventListener("contextmenu", onCtx);
114
142
  }, []);
115
143
 
144
+ // Overview open/close. Opening seeds the selection to the current slide and clears
145
+ // the end state + jump buffer — the modal layers are mutually exclusive.
146
+ const openOverview = useCallback(() => {
147
+ setEnded(false);
148
+ setJump("");
149
+ setSel(indexRef.current);
150
+ setOverview(true);
151
+ }, []);
152
+ const closeOverview = useCallback(() => {
153
+ setOverview(false);
154
+ setJump("");
155
+ }, []);
156
+ const toggleOverview = useCallback(() => {
157
+ if (overviewRef.current) closeOverview();
158
+ else openOverview();
159
+ }, [openOverview, closeOverview]);
160
+
161
+ // Numeric jump is layer-aware: in the overview it moves the live selection (the
162
+ // grid IS the jump surface); otherwise it drives the floating HUD and commits.
116
163
  const onDigit = useCallback(
117
164
  (key: string) => {
118
- const r = accumulateDigits(jump, key);
165
+ if (overviewRef.current) {
166
+ const buffer = (jumpRef.current + key).slice(0, 3);
167
+ setJump(buffer);
168
+ const target = parseInt(buffer, 10) - 1;
169
+ if (!Number.isNaN(target)) setSel(Math.min(Math.max(target, 0), Math.max(count - 1, 0)));
170
+ return;
171
+ }
172
+ const r = accumulateDigits(jumpRef.current, key);
119
173
  setJump(r.buffer);
120
174
  if (r.commit != null) ctrl.setIndex(r.commit);
121
175
  },
122
- [jump, ctrl],
176
+ [count, ctrl],
123
177
  );
124
178
 
179
+ // Overview keyboard: arrows move the selection, Enter opens it.
180
+ const onGridMove = useCallback(
181
+ (dir: GridDir) => {
182
+ setJump("");
183
+ const width = typeof window === "undefined" ? 1280 : window.innerWidth;
184
+ setSel((s) => moveSelection(s, count, gridCols(width), dir));
185
+ },
186
+ [count],
187
+ );
188
+ const onSelect = useCallback(() => {
189
+ ctrl.setIndex(selRef.current);
190
+ closeOverview();
191
+ }, [ctrl, closeOverview]);
192
+ // Esc / back: close the overview, otherwise leave the end screen.
193
+ const onExitModal = useCallback(() => {
194
+ if (overviewRef.current) closeOverview();
195
+ else setEnded(false);
196
+ }, [closeOverview]);
197
+ const onRestart = useCallback(() => {
198
+ // Restart to slide 1 without the last slide flashing into view. The end card is
199
+ // an opaque full-screen layer, so we keep it up (do NOT clear `ended`) while the
200
+ // slide layer crossfades to slide 1 *behind* it — masked — and drop the card only
201
+ // once that exit completes (AnimatePresence onExitComplete). `pendingRestartRef`
202
+ // makes the index-change effect skip its usual end-clear during that window.
203
+ if (indexRef.current === 0) {
204
+ setEnded(false); // already on slide 1 — nothing to mask
205
+ return;
206
+ }
207
+ pendingRestartRef.current = true;
208
+ ctrl.setIndex(0);
209
+ // Safety net: if onExitComplete never fires (interrupted animation), reveal anyway.
210
+ window.setTimeout(() => {
211
+ if (pendingRestartRef.current) {
212
+ pendingRestartRef.current = false;
213
+ setEnded(false);
214
+ }
215
+ }, 700);
216
+ }, [ctrl]);
217
+
218
+ // Advancing past the last slide's final step enters the end screen rather than
219
+ // calling ctrl.next (which would reset the last slide's step to 0 and replay it).
220
+ // Only the deck's driver gets it; a live viewer just follows. In a modal layer
221
+ // routeKey never yields "next", so this only fires while actually presenting.
222
+ const handleNext = useCallback(() => {
223
+ if (canDrive && indexRef.current >= count - 1 && stepRef.current >= totalRef.current) {
224
+ setEnded(true);
225
+ return;
226
+ }
227
+ ctrl.next();
228
+ }, [canDrive, count, ctrl]);
229
+ const handlePrev = useCallback(() => ctrl.prev(), [ctrl]);
230
+ // A jump elsewhere (overview select, numeric commit) clears the end state — except
231
+ // during a Restart, where the end card intentionally stays up to mask the crossfade.
232
+ useEffect(() => {
233
+ if (!pendingRestartRef.current) setEnded(false);
234
+ }, [index]);
235
+
125
236
  useDeckNav({
126
237
  count,
127
238
  setIndex: ctrl.setIndex,
128
- onNext: ctrl.next,
129
- onPrev: ctrl.prev,
239
+ mode,
240
+ onNext: handleNext,
241
+ onPrev: handlePrev,
130
242
  onToggleBrand: brands.length > 1 ? () => setBrandIdx((n) => n + 1) : undefined,
131
243
  onOpenPresenter: canDrive ? openPresenter : undefined,
132
244
  onToggleHelp: () => setHelp((v) => !v),
133
245
  onFullscreen: () => void toggleFullscreen(document.documentElement),
134
246
  onBlur: () => setBlurred((v) => !v),
135
- onOverview: canDrive ? () => setOverview((v) => !v) : undefined,
247
+ onOverview: canDrive ? toggleOverview : undefined,
136
248
  onQr: () => setQr((v) => !v),
137
249
  onDigit,
250
+ onGridMove,
251
+ onSelect,
252
+ onExitModal,
253
+ onRestart,
138
254
  });
139
255
 
140
- // Touch nav for everyone who drives their own deck (standalone + presenter); a
141
- // live viewer follows the presenter, so it isn't bound for them.
142
- useTouchNav({ enabled: role !== "viewer", onNext: ctrl.next, onPrev: ctrl.prev });
256
+ // Touch nav drives the deck only while presenting suppressed under any modal
257
+ // layer so a swipe can't move the slide behind the overview / end screen.
258
+ useTouchNav({ enabled: role !== "viewer" && !overview && !ended, onNext: handleNext, onPrev: handlePrev });
143
259
 
144
260
  const Current = norm[index]?.Component ?? (() => null);
145
261
 
@@ -160,6 +276,12 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
160
276
  : (norm[index]?.transition ?? deckTransition);
161
277
  const slideTransition = resolveTransition(requested, !!reduceMotion);
162
278
 
279
+ // Keep the keyboard-selected overview thumbnail in view as you arrow through a
280
+ // long deck.
281
+ useEffect(() => {
282
+ if (overview) selectedThumbRef.current?.scrollIntoView({ block: "nearest", behavior: reduceMotion ? "auto" : "smooth" });
283
+ }, [sel, overview, reduceMotion]);
284
+
163
285
  return (
164
286
  <MDXProvider components={mdxComponents}>
165
287
  <PersistentProvider>
@@ -169,7 +291,17 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
169
291
  <div className="relative h-dvh w-screen overflow-hidden bg-bg">
170
292
  <ScaledStage className="absolute inset-0">
171
293
  <div data-deck-root className="absolute inset-0">
172
- <AnimatePresence custom={direction}>
294
+ <AnimatePresence
295
+ custom={direction}
296
+ onExitComplete={() => {
297
+ // Restart kept the end card up to mask the crossfade to slide 1; the
298
+ // exiting last slide is now gone, so it's safe to drop the card.
299
+ if (pendingRestartRef.current) {
300
+ pendingRestartRef.current = false;
301
+ setEnded(false);
302
+ }
303
+ }}
304
+ >
173
305
  <motion.div
174
306
  key={index}
175
307
  custom={direction}
@@ -194,20 +326,6 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
194
326
  slide, below chrome; non-interactive (live only, see ADR 0021) */}
195
327
  <PluginOverlays />
196
328
 
197
- {/* jump-to-number buffer */}
198
- <AnimatePresence>
199
- {jump && (
200
- <motion.div
201
- initial={{ opacity: 0, scale: 0.9 }}
202
- animate={{ opacity: 1, scale: 1 }}
203
- exit={{ opacity: 0 }}
204
- className="absolute left-1/2 top-8 -translate-x-1/2 rounded-xl border border-border bg-surface/80 px-5 py-2 font-mono text-2xl text-text backdrop-blur"
205
- >
206
- → {jump}
207
- </motion.div>
208
- )}
209
- </AnimatePresence>
210
-
211
329
  <HelpOverlay open={help} onClose={() => setHelp(false)} showBrand={brands.length > 1} role={role} ejectable={ejectable} />
212
330
  <PortraitHint />
213
331
  </div>
@@ -239,21 +357,36 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
239
357
  {overview && (
240
358
  <motion.div
241
359
  className="absolute inset-0 z-40 overflow-auto bg-bg/95 p-[4%] backdrop-blur-xl"
242
- initial={{ opacity: 0 }}
360
+ // Mount already opaque (no fade-in): opening from the end screen, the
361
+ // overview must cover the last slide *immediately* while the end card
362
+ // fades out on top, otherwise the slide flashes through the gap.
363
+ initial={{ opacity: 1 }}
243
364
  animate={{ opacity: 1 }}
244
365
  exit={{ opacity: 0 }}
366
+ transition={reduceMotion ? { duration: 0 } : undefined}
245
367
  >
246
- <div className="mb-6 font-mono text-sm uppercase tracking-[0.3em] text-muted">Overview · tap or type a number</div>
368
+ <div className="mb-6 flex items-baseline justify-between gap-4">
369
+ <div className="font-mono text-sm uppercase tracking-[0.3em] text-muted">
370
+ Overview · ←↑↓→ move · Enter open · or type a number
371
+ </div>
372
+ {jump && <div className="font-mono text-sm text-text">→ {jump}</div>}
373
+ </div>
247
374
  <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 lg:gap-5">
248
375
  {norm.map((s, i) => (
249
376
  <button
250
377
  key={i}
378
+ ref={i === sel ? selectedThumbRef : undefined}
251
379
  onClick={() => {
252
380
  ctrl.setIndex(i);
253
- setOverview(false);
381
+ closeOverview();
254
382
  }}
255
- className={`relative aspect-video overflow-hidden rounded-xl border text-left transition ${
256
- i === index ? "border-primary" : "border-border hover:border-text"
383
+ aria-current={i === index ? "true" : undefined}
384
+ className={`relative aspect-video overflow-hidden rounded-xl border text-left outline-none transition ${
385
+ i === sel
386
+ ? "border-primary ring-2 ring-primary"
387
+ : i === index
388
+ ? "border-primary"
389
+ : "border-border hover:border-text"
257
390
  }`}
258
391
  >
259
392
  <DeckThumb Component={s.Component} src={thumbs?.get(i)} alt={`Slide ${i + 1}`} />
@@ -265,6 +398,49 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
265
398
  )}
266
399
  </AnimatePresence>
267
400
 
401
+ {/* terminal end-of-deck card (advancing past the last slide) — a calm,
402
+ deliberate stop, NOT a wrap; covers the slide + ambient motion. Tapping the
403
+ backdrop goes back; the action row offers Back / Overview / Restart. */}
404
+ <AnimatePresence>
405
+ {ended && (
406
+ <motion.div
407
+ className="absolute inset-0 z-50 flex flex-col items-center justify-center gap-6 bg-bg text-center"
408
+ initial={{ opacity: 0 }}
409
+ animate={{ opacity: 1 }}
410
+ exit={{ opacity: 0 }}
411
+ transition={reduceMotion ? { duration: 0 } : { duration: 0.3 }}
412
+ onClick={() => setEnded(false)}
413
+ >
414
+ <div className="space-y-2">
415
+ <div className="font-mono text-sm uppercase tracking-[0.3em] text-muted">End of deck</div>
416
+ <div className="font-mono text-xs text-muted/60">{count} {count === 1 ? "slide" : "slides"}</div>
417
+ </div>
418
+ <div className="flex flex-wrap items-center justify-center gap-3" onClick={(e) => e.stopPropagation()}>
419
+ <button onClick={() => setEnded(false)} className="rounded-lg border border-border px-4 py-2 font-mono text-sm text-text transition hover:border-text">← Back</button>
420
+ <button onClick={openOverview} className="rounded-lg border border-border px-4 py-2 font-mono text-sm text-text transition hover:border-text">Overview</button>
421
+ <button onClick={onRestart} className="rounded-lg border border-border px-4 py-2 font-mono text-sm text-text transition hover:border-text">↺ Restart</button>
422
+ </div>
423
+ <div className="font-mono text-[0.7rem] text-muted/50">← back · O overview · R restart · type a number to jump</div>
424
+ </motion.div>
425
+ )}
426
+ </AnimatePresence>
427
+
428
+ {/* numeric-jump HUD — a single top layer above EVERY overlay (device scale), so
429
+ typing a number reads correctly in slide and end modes. In the overview the
430
+ buffer renders in its header instead (the grid is the jump surface). */}
431
+ <AnimatePresence>
432
+ {jump && !overview && (
433
+ <motion.div
434
+ initial={{ opacity: 0, scale: 0.9 }}
435
+ animate={{ opacity: 1, scale: 1 }}
436
+ exit={{ opacity: 0 }}
437
+ className="pointer-events-none absolute left-1/2 top-8 z-[60] -translate-x-1/2 rounded-xl border border-border bg-surface/80 px-5 py-2 font-mono text-2xl text-text backdrop-blur"
438
+ >
439
+ → {jump}
440
+ </motion.div>
441
+ )}
442
+ </AnimatePresence>
443
+
268
444
  {/* chrome at DEVICE scale, pinned to the real viewport (outside the scaled
269
445
  canvas) so the help + plugin buttons stay tappable on a phone. On touch
270
446
  the help affordance becomes a ⋮ action menu (fullscreen, overview, …). */}
@@ -276,7 +452,7 @@ export function Deck({ slides, persistent = [], brands = ["default"], transition
276
452
  canDrive={canDrive}
277
453
  viewerUrl={liveCtx?.viewerUrl}
278
454
  onHelp={() => setHelp(true)}
279
- onOverview={() => setOverview((v) => !v)}
455
+ onOverview={toggleOverview}
280
456
  onQr={() => setQr((v) => !v)}
281
457
  />
282
458
  </div>
@@ -10,7 +10,7 @@ const EDIT_DOCS_URL = "https://docs.liebstoeckel.app/guides/editing-a-built-deck
10
10
  function Kbd({ children, dim }: { children: string; dim?: boolean }) {
11
11
  return (
12
12
  <kbd
13
- className={`inline-flex min-w-[1.9rem] items-center justify-center rounded-md border px-2 py-1 font-mono text-sm shadow-sm ${
13
+ className={`inline-flex min-w-[1.6rem] items-center justify-center rounded-md border px-1.5 py-0.5 font-mono text-xs shadow-sm ${
14
14
  dim ? "border-border/50 bg-bg/40 text-muted" : "border-border bg-bg text-text"
15
15
  }`}
16
16
  >
@@ -83,10 +83,10 @@ export function HelpOverlay({
83
83
  animate={{ opacity: 1, scale: 1, y: 0 }}
84
84
  exit={{ opacity: 0, scale: 0.97, y: 8 }}
85
85
  transition={{ type: "spring", stiffness: 260, damping: 24 }}
86
- className="relative w-[440px] rounded-2xl border border-border bg-surface/90 p-7 shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)]"
86
+ className="relative max-h-[88%] w-[400px] overflow-auto rounded-2xl border border-border bg-surface/90 p-6 shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)]"
87
87
  >
88
- <div className="mb-5 flex items-baseline justify-between">
89
- <span className="font-heading text-2xl font-semibold text-text">Shortcuts</span>
88
+ <div className="mb-4 flex items-baseline justify-between">
89
+ <span className="font-heading text-xl font-semibold text-text">Shortcuts</span>
90
90
  {role ? (
91
91
  <span className="font-mono text-[11px] uppercase tracking-[0.25em] text-accent">
92
92
  ● live · {role}
@@ -101,7 +101,7 @@ export function HelpOverlay({
101
101
  navigation. You can still interact (e.g. vote).
102
102
  </p>
103
103
  )}
104
- <ul className="space-y-3">
104
+ <ul className="space-y-1.5">
105
105
  {shortcuts.map((s) => {
106
106
  const disabled = isViewer && s.presenterOnly;
107
107
  return (
@@ -110,7 +110,7 @@ export function HelpOverlay({
110
110
  className={`flex items-center justify-between gap-4 ${disabled ? "opacity-40" : ""}`}
111
111
  title={disabled ? "Presenter only" : undefined}
112
112
  >
113
- <span className="font-body text-lg text-text/85">
113
+ <span className="font-body text-sm text-text/85">
114
114
  {s.label}
115
115
  {disabled && <span className="ml-2 font-mono text-[10px] uppercase tracking-wider text-muted">presenter</span>}
116
116
  </span>
@@ -127,8 +127,8 @@ export function HelpOverlay({
127
127
  })}
128
128
  </ul>
129
129
  {ejectable && (
130
- <div className="mt-6 border-t border-border pt-4">
131
- <p className="font-body text-sm text-text/80">
130
+ <div className="mt-4 border-t border-border pt-3">
131
+ <p className="font-body text-[13px] text-text/80">
132
132
  This deck embeds its own source, recover it with{" "}
133
133
  <code className="rounded bg-bg/60 px-1.5 py-0.5 font-mono text-[13px] text-text">
134
134
  liebstoeckel eject
@@ -145,7 +145,7 @@ export function HelpOverlay({
145
145
  </p>
146
146
  </div>
147
147
  )}
148
- <div className="mt-6 border-t border-border pt-4 text-center font-mono text-[11px] uppercase tracking-[0.25em] text-muted">
148
+ <div className="mt-4 border-t border-border pt-3 text-center font-mono text-[11px] uppercase tracking-[0.25em] text-muted">
149
149
  right-click anywhere to toggle
150
150
  </div>
151
151
  </motion.div>
@@ -11,6 +11,7 @@ import {
11
11
  } from "@liebstoeckel/plugin-sdk/manifest";
12
12
  import mdx from "./mdx-plugin";
13
13
  import visxEsmInterop from "./visx-esm-plugin";
14
+ import { brandFontWarning } from "./font-audit";
14
15
  import {
15
16
  createLicenseCollector,
16
17
  renderNotices,
@@ -225,6 +226,11 @@ export async function bundleDeck({
225
226
  console.log(`✓ embedded source package (${files.length} files, ${(zstd.length / 1024).toFixed(1)}KB)`);
226
227
  }
227
228
 
229
+ // Warn (don't fail) if a brand names a `"… Variable"` webfont that no @font-face
230
+ // bundles, it would silently fall back to a system font in the shipped file.
231
+ const fontWarning = brandFontWarning(html);
232
+ if (fontWarning) console.warn(fontWarning);
233
+
228
234
  // Stamp the build's provenance (engine + invoking-tool versions) into the head.
229
235
  html = stampGenerator(html, { engine: await engineVersion(), generator });
230
236
 
@@ -0,0 +1,99 @@
1
+ // Catch the silent brand-font fallback at build time.
2
+ //
3
+ // A brand stores its type as a `font-family` *string* (`--brand-font-heading`,
4
+ // `--brand-font-body`, `--brand-font-mono`), but glyphs only ship if a usable
5
+ // `@font-face` is bundled. The build inlines that face's woff2 into the single
6
+ // file. When the bundled face is wrong (or absent), the browser silently falls
7
+ // back to a system font: no error, and the "what you ship is what you see"
8
+ // promise quietly breaks (the failure ADR 0074 describes; a real session shipped
9
+ // Noto Sans this way before a lucky `pdffonts` check caught it).
10
+ //
11
+ // Two failure modes, both detected from the final inlined CSS:
12
+ //
13
+ // 1. Subsetted faces (`subsetted`). Importing a Fontsource package's `index.css`
14
+ // (or the bare package) pulls ~5 `@font-face` rules split by `unicode-range`.
15
+ // Those subset faces do NOT survive the single-file inlining and never
16
+ // register, the exact reported bug. The fix is one latin face with no
17
+ // `unicode-range`, mirroring @liebstoeckel/theme's fonts.css. The house fonts
18
+ // ship that way, so they never carry `unicode-range` and never trip this.
19
+ //
20
+ // 2. Unbundled brand fonts (`unbundled`). A `--brand-font-*` literal-CSS block
21
+ // names a `"… Variable"` webfont (the self-hosted-font convention) that no
22
+ // `@font-face` bundles at all, a typo or a forgotten import. Scoped to the
23
+ // `"… Variable"` convention so a bare `"Inter"` leaning on a system install
24
+ // isn't flagged. (Typed `brandThemes` generate their `--brand-font-*` at
25
+ // runtime, so this arm covers the hand-written-CSS path; the subset arm and
26
+ // the `pdffonts` verification in the skill cover the rest.)
27
+
28
+ const FACE_RE = /@font-face\s*\{([^}]*)\}/gi;
29
+ const FAMILY_DECL_RE = /font-family\s*:\s*([^;}]+)/i;
30
+ const BRAND_FONT_RE = /--brand-font-(?:heading|body|mono)\s*:\s*([^;}]+)/gi;
31
+
32
+ export interface BrandFontAudit {
33
+ /** Families whose `@font-face` is a `unicode-range` subset that won't survive inlining. */
34
+ subsetted: string[];
35
+ /** `"… Variable"` families named by `--brand-font-*` with no `@font-face` at all. */
36
+ unbundled: string[];
37
+ }
38
+
39
+ /** First family in a `font-family` value, unquoted + trimmed (the rest is the
40
+ * fallback stack). `"Nunito Sans Variable", system-ui` → `Nunito Sans Variable`. */
41
+ function primaryFamily(value: string): string {
42
+ const first = value.split(",")[0] ?? "";
43
+ return first.trim().replace(/^['"]|['"]$/g, "").trim();
44
+ }
45
+
46
+ function dedupe(pairs: Iterable<[string, string]>): string[] {
47
+ const m = new Map<string, string>(); // lowercased key → declared casing
48
+ for (const [key, declared] of pairs) if (!m.has(key)) m.set(key, declared);
49
+ return [...m.values()];
50
+ }
51
+
52
+ /** Inspect a built deck's inlined CSS for brand fonts that won't render. */
53
+ export function auditBrandFonts(css: string): BrandFontAudit {
54
+ const faces = new Set<string>(); // every @font-face family, lowercased
55
+ const subsetted: [string, string][] = [];
56
+ for (const m of css.matchAll(FACE_RE)) {
57
+ const block = m[1] ?? "";
58
+ const decl = FAMILY_DECL_RE.exec(block);
59
+ if (!decl?.[1]) continue;
60
+ const family = primaryFamily(decl[1]);
61
+ faces.add(family.toLowerCase());
62
+ if (/unicode-range\s*:/i.test(block)) subsetted.push([family.toLowerCase(), family]);
63
+ }
64
+
65
+ const unbundled: [string, string][] = [];
66
+ for (const m of css.matchAll(BRAND_FONT_RE)) {
67
+ const family = primaryFamily(m[1] ?? "");
68
+ const key = family.toLowerCase();
69
+ if (!/\bvariable$/i.test(family)) continue; // only the self-hosted-webfont convention
70
+ if (faces.has(key)) continue; // a face bundles it (subset issues handled above)
71
+ unbundled.push([key, family]);
72
+ }
73
+
74
+ return { subsetted: dedupe(subsetted), unbundled: dedupe(unbundled) };
75
+ }
76
+
77
+ /** Human-facing build warning for brand fonts that won't render, or null if clean.
78
+ * Printed by `bundleDeck`; kept pure so it is unit-tested without a real build. */
79
+ export function brandFontWarning(css: string): string | null {
80
+ const { subsetted, unbundled } = auditBrandFonts(css);
81
+ if (subsetted.length === 0 && unbundled.length === 0) return null;
82
+
83
+ const lines = ["⚠ brand font won't render (text will fall back to a system font):"];
84
+ if (subsetted.length) {
85
+ lines.push(
86
+ ` unicode-range subsets that don't survive inlining: ${subsetted.map((f) => `"${f}"`).join(", ")}`,
87
+ ` → don't import a Fontsource package / its index.css; bundle one latin face`,
88
+ ` (…-latin-wght-normal.woff2), mirroring @liebstoeckel/theme's fonts.css.`,
89
+ );
90
+ }
91
+ if (unbundled.length) {
92
+ lines.push(
93
+ ` named by the brand but no @font-face bundles them: ${unbundled.map((f) => `"${f}"`).join(", ")}`,
94
+ ` → add a latin @font-face for the family, or use a bundled house font.`,
95
+ );
96
+ }
97
+ lines.push(` See the skill's brand guide (references/brands.md → Fonts). Verify with pdffonts.`);
98
+ return lines.join("\n");
99
+ }
@@ -0,0 +1,99 @@
1
+ // Pure keyboard routing for the deck. Given the active interaction *layer* and a
2
+ // key, decide the action — so a modal layer (overview, end) captures its keys and
3
+ // deck navigation never leaks through it. No DOM; unit-testable (key × layer → action).
4
+ import type { GridDir } from "./overview";
5
+
6
+ export type NavMode = "slide" | "overview" | "end";
7
+
8
+ export type NavAction =
9
+ | "next"
10
+ | "prev"
11
+ | "first"
12
+ | "last"
13
+ | "select"
14
+ | "exitModal"
15
+ | "restart"
16
+ | "toggleBrand"
17
+ | "presenter"
18
+ | "fullscreen"
19
+ | "blur"
20
+ | "overview"
21
+ | "qr"
22
+ | "help"
23
+ | { grid: GridDir }
24
+ | { digit: string }
25
+ | null;
26
+
27
+ const ARROW_GRID: Record<string, GridDir> = {
28
+ ArrowLeft: "left",
29
+ ArrowRight: "right",
30
+ ArrowUp: "up",
31
+ ArrowDown: "down",
32
+ };
33
+
34
+ const isDigit = (key: string) => /^[0-9]$/.test(key);
35
+
36
+ /** Keys whose browser default (page scroll) we suppress when they map to an action. */
37
+ export function preventsDefault(key: string): boolean {
38
+ return key in ARROW_GRID || key === " " || key === "PageDown" || key === "PageUp";
39
+ }
40
+
41
+ export function routeKey(mode: NavMode, key: string): NavAction {
42
+ // Overview: a modal grid picker. Arrows move the selection, Enter opens it,
43
+ // digits build a jump, Esc/o close. Everything else is swallowed (no deck nav).
44
+ if (mode === "overview") {
45
+ if (key in ARROW_GRID) return { grid: ARROW_GRID[key] };
46
+ if (key === "Enter") return "select";
47
+ if (key === "Escape") return "exitModal";
48
+ if (key === "o") return "overview";
49
+ if (isDigit(key)) return { digit: key };
50
+ if (key === "f") return "fullscreen";
51
+ if (key === "q") return "qr";
52
+ if (key === "?" || key === "h") return "help";
53
+ return null;
54
+ }
55
+ // End screen: a terminal card. ←/Esc/Backspace go back, o opens the overview,
56
+ // r/Home restart, digits/Enter jump. Forward keys are swallowed (no replay).
57
+ if (mode === "end") {
58
+ if (key === "ArrowLeft" || key === "Escape" || key === "Backspace") return "exitModal";
59
+ if (key === "o") return "overview";
60
+ if (key === "r" || key === "Home") return "restart";
61
+ if (isDigit(key) || key === "Enter") return { digit: key };
62
+ if (key === "f") return "fullscreen";
63
+ if (key === "q") return "qr";
64
+ if (key === "?" || key === "h") return "help";
65
+ return null;
66
+ }
67
+ // Slide (default) — the normal presenting controls.
68
+ switch (key) {
69
+ case "ArrowRight":
70
+ case " ":
71
+ case "PageDown":
72
+ return "next";
73
+ case "ArrowLeft":
74
+ case "PageUp":
75
+ return "prev";
76
+ case "Home":
77
+ return "first";
78
+ case "End":
79
+ return "last";
80
+ case "t":
81
+ return "toggleBrand";
82
+ case "p":
83
+ return "presenter";
84
+ case "f":
85
+ return "fullscreen";
86
+ case "b":
87
+ return "blur";
88
+ case "o":
89
+ return "overview";
90
+ case "q":
91
+ return "qr";
92
+ case "?":
93
+ case "h":
94
+ return "help";
95
+ default:
96
+ if (isDigit(key) || key === "Enter" || key === "Escape") return { digit: key };
97
+ return null;
98
+ }
99
+ }
package/src/nav.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { useEffect } from "react";
2
2
  import { resolveTouchGesture, NO_NAV_SELECTOR } from "./mobile";
3
+ import { routeKey, preventsDefault, type NavMode } from "./interaction";
4
+ import type { GridDir } from "./overview";
3
5
 
4
6
  /** Touch navigation: horizontal swipe + edge tap-zones, resolved by the pure
5
7
  * `resolveTouchGesture`. Reuses `onNext`/`onPrev` so step-reveals still run before
@@ -62,11 +64,15 @@ export function isEditableTarget(target: EventTarget | null): boolean {
62
64
  }
63
65
  }
64
66
 
65
- // Keyboard navigation. `onNext`/`onPrev` let the deck intercept for step reveals;
66
- // they fall back to slide nav. Slide index lives in the deck controller (synced).
67
+ // Keyboard navigation, routed by the active interaction layer (`mode`). In a modal
68
+ // layer (overview, end) the layer owns its keys and deck nav never leaks through —
69
+ // the routing is the pure `routeKey` (key × mode → action); this hook just dispatches.
70
+ // `onNext`/`onPrev` let the deck intercept for step reveals; they fall back to slide nav.
67
71
  export function useDeckNav(opts: {
68
72
  count: number;
69
73
  setIndex: (updater: (n: number) => number | number) => void;
74
+ /** Active interaction layer. Defaults to "slide". */
75
+ mode?: NavMode;
70
76
  onNext?: () => void;
71
77
  onPrev?: () => void;
72
78
  onToggleBrand?: () => void;
@@ -77,10 +83,19 @@ export function useDeckNav(opts: {
77
83
  onOverview?: () => void;
78
84
  onQr?: () => void;
79
85
  onDigit?: (key: string) => void;
86
+ /** Overview grid selection move (← → ↑ ↓ while the overview is open). */
87
+ onGridMove?: (dir: GridDir) => void;
88
+ /** Confirm the overview selection (Enter). */
89
+ onSelect?: () => void;
90
+ /** Close the top modal / go back (Esc, or ← on the end screen). */
91
+ onExitModal?: () => void;
92
+ /** Restart to the first slide (R / Home on the end screen). */
93
+ onRestart?: () => void;
80
94
  }) {
81
95
  const {
82
96
  count,
83
97
  setIndex,
98
+ mode = "slide",
84
99
  onNext,
85
100
  onPrev,
86
101
  onToggleBrand,
@@ -91,6 +106,10 @@ export function useDeckNav(opts: {
91
106
  onOverview,
92
107
  onQr,
93
108
  onDigit,
109
+ onGridMove,
110
+ onSelect,
111
+ onExitModal,
112
+ onRestart,
94
113
  } = opts;
95
114
 
96
115
  useEffect(() => {
@@ -99,51 +118,33 @@ export function useDeckNav(opts: {
99
118
  const onKey = (e: KeyboardEvent) => {
100
119
  // don't hijack typing in a plugin's input (Q&A box, etc.)
101
120
  if (isEditableTarget(e.target)) return;
102
- switch (e.key) {
103
- case "ArrowRight":
104
- case " ":
105
- case "PageDown":
106
- e.preventDefault();
107
- next();
108
- break;
109
- case "ArrowLeft":
110
- case "PageUp":
111
- e.preventDefault();
112
- prev();
113
- break;
114
- case "Home":
115
- setIndex(() => 0);
116
- break;
117
- case "End":
118
- setIndex(() => count - 1);
119
- break;
120
- case "t":
121
- onToggleBrand?.();
122
- break;
123
- case "p":
124
- onOpenPresenter?.();
125
- break;
126
- case "f":
127
- onFullscreen?.();
128
- break;
129
- case "b":
130
- onBlur?.();
131
- break;
132
- case "o":
133
- onOverview?.();
134
- break;
135
- case "q":
136
- onQr?.();
137
- break;
138
- case "?":
139
- case "h":
140
- onToggleHelp?.();
141
- break;
142
- default:
143
- if (onDigit && (/^[0-9]$/.test(e.key) || e.key === "Enter" || e.key === "Escape")) onDigit(e.key);
121
+ const action = routeKey(mode, e.key);
122
+ if (action == null) return;
123
+ if (preventsDefault(e.key)) e.preventDefault();
124
+ if (typeof action === "object") {
125
+ if ("grid" in action) onGridMove?.(action.grid);
126
+ else onDigit?.(action.digit);
127
+ return;
128
+ }
129
+ switch (action) {
130
+ case "next": next(); break;
131
+ case "prev": prev(); break;
132
+ case "first": setIndex(() => 0); break;
133
+ case "last": setIndex(() => count - 1); break;
134
+ case "select": onSelect?.(); break;
135
+ case "exitModal": onExitModal?.(); break;
136
+ case "restart": onRestart?.(); break;
137
+ case "toggleBrand": onToggleBrand?.(); break;
138
+ case "presenter": onOpenPresenter?.(); break;
139
+ case "fullscreen": onFullscreen?.(); break;
140
+ case "blur": onBlur?.(); break;
141
+ case "overview": onOverview?.(); break;
142
+ case "qr": onQr?.(); break;
143
+ case "help": onToggleHelp?.(); break;
144
144
  }
145
145
  };
146
146
  window.addEventListener("keydown", onKey);
147
147
  return () => window.removeEventListener("keydown", onKey);
148
- }, [count, setIndex, onNext, onPrev, onToggleBrand, onOpenPresenter, onToggleHelp, onFullscreen, onBlur, onOverview, onQr, onDigit]);
148
+ // eslint-disable-next-line react-hooks/exhaustive-deps
149
+ }, [count, setIndex, mode, onNext, onPrev, onToggleBrand, onOpenPresenter, onToggleHelp, onFullscreen, onBlur, onOverview, onQr, onDigit, onGridMove, onSelect, onExitModal, onRestart]);
149
150
  }
@@ -0,0 +1,32 @@
1
+ // Pure helpers for the overview grid's keyboard selection. No DOM — unit-testable.
2
+
3
+ export type GridDir = "left" | "right" | "up" | "down";
4
+
5
+ /** Columns the overview grid renders at a given viewport width — mirrors the
6
+ * Tailwind breakpoints on the grid (`grid-cols-2` base, `sm:grid-cols-3`,
7
+ * `lg:grid-cols-4`). Used to make ↑/↓ move by a row. */
8
+ export function gridCols(width: number): number {
9
+ if (width >= 1024) return 4;
10
+ if (width >= 640) return 3;
11
+ return 2;
12
+ }
13
+
14
+ /** Move a 0-based selection within a `count`-item, `cols`-wide grid by an arrow
15
+ * direction, clamped to [0, count-1]. ←/→ step linearly (so you can traverse the
16
+ * whole deck, crossing row boundaries); ↑/↓ step by a full row and stop at the
17
+ * edges rather than wrapping. */
18
+ export function moveSelection(sel: number, count: number, cols: number, dir: GridDir): number {
19
+ if (count <= 0) return 0;
20
+ const c = Math.max(1, cols);
21
+ const clamp = (n: number) => Math.min(Math.max(n, 0), count - 1);
22
+ switch (dir) {
23
+ case "left":
24
+ return clamp(sel - 1);
25
+ case "right":
26
+ return clamp(sel + 1);
27
+ case "up":
28
+ return sel - c >= 0 ? sel - c : sel;
29
+ case "down":
30
+ return sel + c <= count - 1 ? sel + c : sel;
31
+ }
32
+ }