@honeydeck/honeydeck 0.1.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 (144) hide show
  1. package/AGENTS.md +25 -0
  2. package/DEVELOPMENT.md +522 -0
  3. package/LICENSE +21 -0
  4. package/Readme.md +49 -0
  5. package/SPEC.md +88 -0
  6. package/docs/components.md +63 -0
  7. package/docs/configuration.md +91 -0
  8. package/docs/getting-started.md +116 -0
  9. package/docs/kit-authoring.md +207 -0
  10. package/docs/kits.md +387 -0
  11. package/docs/local-development.md +95 -0
  12. package/docs/mermaid.md +198 -0
  13. package/docs/mobile.md +108 -0
  14. package/docs/navigation.md +93 -0
  15. package/docs/next-steps.md +377 -0
  16. package/docs/pdf-export.md +91 -0
  17. package/docs/presenter-mode.md +104 -0
  18. package/docs/slides.md +130 -0
  19. package/docs/slidev-migration.md +42 -0
  20. package/docs/steps-and-reveals.md +171 -0
  21. package/package.json +134 -0
  22. package/skills/SPEC.md +21 -0
  23. package/skills/honeydeck/SKILL.md +65 -0
  24. package/skills/presentation-writing/SKILL.md +75 -0
  25. package/skills/slidev-migration/SKILL.md +153 -0
  26. package/src/SPEC.md +89 -0
  27. package/src/assets.d.ts +30 -0
  28. package/src/cli/SPEC.md +230 -0
  29. package/src/cli/args.ts +3 -0
  30. package/src/cli/banner.ts +9 -0
  31. package/src/cli/bin.js +5 -0
  32. package/src/cli/build.ts +229 -0
  33. package/src/cli/deck-path.ts +32 -0
  34. package/src/cli/dev.ts +263 -0
  35. package/src/cli/index.ts +126 -0
  36. package/src/cli/init.ts +369 -0
  37. package/src/cli/pdf.ts +923 -0
  38. package/src/cli/skill.ts +75 -0
  39. package/src/cli/templates/SPEC.md +70 -0
  40. package/src/cli/templates/deck-mdx.ts +15 -0
  41. package/src/cli/templates/package-json.ts +36 -0
  42. package/src/cli/templates/sparkle-button.ts +15 -0
  43. package/src/cli/templates/starter/components/SparkleButton.tsx +84 -0
  44. package/src/cli/templates/starter/deck.mdx +153 -0
  45. package/src/cli/templates/starter/styles.css +14 -0
  46. package/src/cli/templates/styles-css.ts +14 -0
  47. package/src/defaults.ts +1 -0
  48. package/src/layouts/ColorModeImage.tsx +55 -0
  49. package/src/layouts/SPEC.md +393 -0
  50. package/src/layouts/SlideFrame.tsx +48 -0
  51. package/src/layouts/bee/Blank.tsx +12 -0
  52. package/src/layouts/bee/Cover.tsx +70 -0
  53. package/src/layouts/bee/Default.tsx +42 -0
  54. package/src/layouts/bee/Image/Image.tsx +151 -0
  55. package/src/layouts/bee/Image/placeholder-dark.webp +0 -0
  56. package/src/layouts/bee/Image/placeholder-vertical-dark.webp +0 -0
  57. package/src/layouts/bee/Image/placeholder-vertical.webp +0 -0
  58. package/src/layouts/bee/Image/placeholder.webp +0 -0
  59. package/src/layouts/bee/ImageLeft.tsx +27 -0
  60. package/src/layouts/bee/ImageRight.tsx +27 -0
  61. package/src/layouts/bee/ImageSide.tsx +107 -0
  62. package/src/layouts/bee/Section.tsx +40 -0
  63. package/src/layouts/bee/TwoCol.tsx +108 -0
  64. package/src/layouts/bee/index.ts +40 -0
  65. package/src/layouts/clean/Blank.tsx +12 -0
  66. package/src/layouts/clean/Cover.tsx +58 -0
  67. package/src/layouts/clean/Default.tsx +33 -0
  68. package/src/layouts/clean/Image/Image.tsx +103 -0
  69. package/src/layouts/clean/ImageLeft.tsx +27 -0
  70. package/src/layouts/clean/ImageRight.tsx +27 -0
  71. package/src/layouts/clean/ImageSide.tsx +113 -0
  72. package/src/layouts/clean/Section.tsx +35 -0
  73. package/src/layouts/clean/TwoCol.tsx +63 -0
  74. package/src/layouts/clean/index.ts +40 -0
  75. package/src/layouts/index.ts +60 -0
  76. package/src/layouts/placeholders.ts +9 -0
  77. package/src/layouts/utils.ts +13 -0
  78. package/src/remark/SPEC.md +49 -0
  79. package/src/remark/h1-extract.ts +124 -0
  80. package/src/remark/index.ts +4 -0
  81. package/src/remark/shiki-code-blocks.ts +325 -0
  82. package/src/remark/step-numbering.ts +412 -0
  83. package/src/runtime/Deck.tsx +533 -0
  84. package/src/runtime/SPEC.md +256 -0
  85. package/src/runtime/SlideCanvas.tsx +95 -0
  86. package/src/runtime/TimelineContext.tsx +122 -0
  87. package/src/runtime/app-shell/index.html +31 -0
  88. package/src/runtime/app-shell/main.tsx +42 -0
  89. package/src/runtime/aspectRatio.ts +34 -0
  90. package/src/runtime/colorMode.ts +23 -0
  91. package/src/runtime/components/BrowserFrame.tsx +233 -0
  92. package/src/runtime/components/Button.tsx +57 -0
  93. package/src/runtime/components/CodeBlock.tsx +210 -0
  94. package/src/runtime/components/ColorModeCycleButton.tsx +59 -0
  95. package/src/runtime/components/ErrorBoundary.tsx +125 -0
  96. package/src/runtime/components/Keyboard.tsx +87 -0
  97. package/src/runtime/components/ListStyle.tsx +203 -0
  98. package/src/runtime/components/NavBar.tsx +223 -0
  99. package/src/runtime/components/NavBarButton.tsx +47 -0
  100. package/src/runtime/components/NavBarDivider.tsx +3 -0
  101. package/src/runtime/components/Notes.tsx +171 -0
  102. package/src/runtime/components/Reveal.tsx +82 -0
  103. package/src/runtime/components/RevealGroup.tsx +193 -0
  104. package/src/runtime/components/SPEC.md +263 -0
  105. package/src/runtime/components/SlideNumberBadge.tsx +11 -0
  106. package/src/runtime/components/TimelineSteps.tsx +115 -0
  107. package/src/runtime/components/index.ts +55 -0
  108. package/src/runtime/index.ts +42 -0
  109. package/src/runtime/inputOwnership.ts +68 -0
  110. package/src/runtime/keyboardTarget.ts +7 -0
  111. package/src/runtime/lastSlideRoute.ts +56 -0
  112. package/src/runtime/navigation.ts +211 -0
  113. package/src/runtime/router.ts +157 -0
  114. package/src/runtime/slideData.ts +137 -0
  115. package/src/runtime/sync.ts +267 -0
  116. package/src/runtime/types.ts +182 -0
  117. package/src/runtime/useKeyboardNav.ts +138 -0
  118. package/src/runtime/useSwipeNav.ts +257 -0
  119. package/src/runtime/views/DocsView.tsx +74 -0
  120. package/src/runtime/views/OverviewView.tsx +386 -0
  121. package/src/runtime/views/PresenterNotesPanel.tsx +76 -0
  122. package/src/runtime/views/PresenterView.tsx +340 -0
  123. package/src/runtime/views/SPEC.md +152 -0
  124. package/src/runtime/views/docs/ComponentsTab.tsx +178 -0
  125. package/src/runtime/views/docs/DocsHeader.tsx +101 -0
  126. package/src/runtime/views/docs/Intro.tsx +20 -0
  127. package/src/runtime/views/docs/LayoutsTab.tsx +324 -0
  128. package/src/runtime/views/docs/ThemeTab.tsx +110 -0
  129. package/src/runtime/views/index.ts +7 -0
  130. package/src/runtime/views/overviewGrid.ts +106 -0
  131. package/src/runtime/views/presenterPreview.ts +27 -0
  132. package/src/runtime/virtual-modules.d.ts +98 -0
  133. package/src/theme/SPEC.md +179 -0
  134. package/src/theme/base.css +623 -0
  135. package/src/theme/bee.css +35 -0
  136. package/src/theme/clean.css +38 -0
  137. package/src/vite-plugin/SPEC.md +114 -0
  138. package/src/vite-plugin/component-doc-crawler.ts +350 -0
  139. package/src/vite-plugin/deck-loader.ts +148 -0
  140. package/src/vite-plugin/index.ts +373 -0
  141. package/src/vite-plugin/layout-demo-crawler.ts +802 -0
  142. package/src/vite-plugin/splitter.ts +353 -0
  143. package/src/vite-plugin/token-manifest.ts +163 -0
  144. package/src/vite-plugin/virtual-modules.ts +587 -0
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Deck — root presentation component.
3
+ *
4
+ * Renders ALL slides in the DOM simultaneously and shows the current one
5
+ * determined by the URL hash router. Non-current slides are hidden via
6
+ * `opacity: 0` + `visibility: hidden`; a 200ms CSS crossfade makes slide
7
+ * changes smooth. The `visibility` transition is delayed by 200ms on hide
8
+ * so the outgoing slide remains painted during the opacity fade-out,
9
+ * preventing a black flicker from the container background showing through.
10
+ *
11
+ * ### Phase 5 additions
12
+ * - Routes to `<PresenterView>` when hash is `#/presenter/…`
13
+ * - Overlay with `<OverviewView>` when overview mode is toggled
14
+ * - `<NavBar>` always present (auto-hides on desktop, visible on touch)
15
+ * - `useSwipeNav` for touch devices
16
+ * - `useSync` for BroadcastChannel audience-side sync
17
+ * - Manual color mode override (system / light / dark) via NavBar
18
+ *
19
+ * ### Viewport scaling
20
+ * The base canvas is 1920 × 1080 px; `transform: scale()` shrinks it
21
+ * uniformly to fit any screen size without distorting content.
22
+ *
23
+ * ### Architecture note
24
+ * Slide data (metadata, components, layout resolution) is now imported from
25
+ * `./slideData.ts` which is also used by PresenterView and OverviewView.
26
+ */
27
+
28
+ import { config } from "virtual:honeydeck/config";
29
+ import {
30
+ useCallback,
31
+ useEffect,
32
+ useLayoutEffect,
33
+ useRef,
34
+ useState,
35
+ } from "react";
36
+ import {
37
+ applyHoneydeckColorMode,
38
+ resolveEffectiveColorMode,
39
+ } from "./colorMode.ts";
40
+ import type { ColorMode } from "./components/ColorModeCycleButton.tsx";
41
+ import { ErrorBoundary } from "./components/ErrorBoundary.tsx";
42
+ import { NavBar } from "./components/NavBar.tsx";
43
+ import { SlideNumberBadge } from "./components/SlideNumberBadge.tsx";
44
+ import { rememberSlideRoute } from "./lastSlideRoute.ts";
45
+ import {
46
+ closeOverview,
47
+ toggleOverview as toggleOverviewRoute,
48
+ } from "./navigation.ts";
49
+ import { useRoute } from "./router.ts";
50
+ import {
51
+ BASE_HEIGHT,
52
+ BASE_WIDTH,
53
+ resolveLayout,
54
+ slideData,
55
+ } from "./slideData.ts";
56
+ import { useSync } from "./sync.ts";
57
+ import { TimelineProvider } from "./TimelineContext.tsx";
58
+ import { useKeyboardNav } from "./useKeyboardNav.ts";
59
+ import { useSwipeNav } from "./useSwipeNav.ts";
60
+ import { DocsView } from "./views/DocsView.tsx";
61
+ import { OverviewView } from "./views/OverviewView.tsx";
62
+ import { PresenterView } from "./views/PresenterView.tsx";
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Scale calculation
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Compute the scale from the stage element's actual dimensions.
70
+ * The stage fills the viewport, so its size equals the viewport size.
71
+ */
72
+ function calcScaleFromElement(el: HTMLElement | null): number | null {
73
+ if (!el?.isConnected || el.clientWidth <= 0 || el.clientHeight <= 0) {
74
+ return null;
75
+ }
76
+ return Math.min(el.clientWidth / BASE_WIDTH, el.clientHeight / BASE_HEIGHT);
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Component
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export function Deck() {
84
+ const stageRef = useRef<HTMLDivElement>(null);
85
+ const [scale, setScale] = useState(1);
86
+ const [slideZoom, setSlideZoom] = useState(1);
87
+ const [slidePan, setSlidePan] = useState({ x: 0, y: 0 });
88
+ const [navBarToggleSignal, setNavBarToggleSignal] = useState(0);
89
+ const [slideTextSelectionEnabled, setSlideTextSelectionEnabled] =
90
+ useState(false);
91
+ // GAP-04: initialize colorMode from config.colorMode (spec: deck can pin mode)
92
+ const [colorMode, setColorMode] = useState<ColorMode>(() => {
93
+ const c = config.colorMode;
94
+ if (c === "light" || c === "dark") return c;
95
+ return "system";
96
+ });
97
+
98
+ const route = useRoute();
99
+ const pointerLayout = usePointerLayout();
100
+
101
+ // ── All hooks MUST be called unconditionally (React rules of hooks) ────
102
+ // Previously these were guarded by early returns which violated hook rules.
103
+ // Now all hooks run on every render; reference/presenter modes simply ignore them.
104
+
105
+ // ── Color mode: apply data-honeydeck-color-mode to <html> ──────────────────
106
+ useLayoutEffect(() => {
107
+ function applyMode(darkFromSystem: boolean) {
108
+ applyHoneydeckColorMode(
109
+ resolveEffectiveColorMode(colorMode, darkFromSystem),
110
+ );
111
+ }
112
+
113
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
114
+ applyMode(mq.matches);
115
+
116
+ const onChange = (e: MediaQueryListEvent) => applyMode(e.matches);
117
+ mq.addEventListener("change", onChange);
118
+ return () => mq.removeEventListener("change", onChange);
119
+ }, [colorMode]);
120
+
121
+ // ── Observe stage size → recalculate scale ─────────────────────────────
122
+ useEffect(() => {
123
+ if (route.view !== "slide" && route.view !== "overview") return;
124
+
125
+ const el = stageRef.current;
126
+ if (!el) return;
127
+
128
+ function updateScale() {
129
+ const nextScale = calcScaleFromElement(el);
130
+ if (nextScale !== null) setScale(nextScale);
131
+ }
132
+
133
+ const ro = new ResizeObserver(updateScale);
134
+ ro.observe(el);
135
+ updateScale();
136
+ return () => ro.disconnect();
137
+ }, [route.view]);
138
+
139
+ // ── Remember latest audience slide for returning from reference pages ───
140
+ useEffect(() => {
141
+ if (route.view !== "slide") return;
142
+
143
+ rememberSlideRoute({
144
+ view: "slide",
145
+ slide: Math.max(1, Math.min(route.slide, slideData.length || 1)),
146
+ step: Math.max(0, route.step),
147
+ });
148
+ }, [route]);
149
+
150
+ // ── BroadcastChannel: audience side (listen for presenter navigation) ──
151
+ useSync({
152
+ enabled: route.view === "slide" || route.view === "overview",
153
+ isPresenter: false,
154
+ });
155
+
156
+ const resetZoom = useCallback(() => {
157
+ setSlideZoom(1);
158
+ setSlidePan({ x: 0, y: 0 });
159
+ }, []);
160
+
161
+ function clampPan(nextPan: { x: number; y: number }, zoom = slideZoom) {
162
+ const maxX = (BASE_WIDTH * scale * Math.max(0, zoom - 1)) / 2;
163
+ const maxY = (BASE_HEIGHT * scale * Math.max(0, zoom - 1)) / 2;
164
+ return {
165
+ x: Math.max(-maxX, Math.min(maxX, nextPan.x)),
166
+ y: Math.max(-maxY, Math.min(maxY, nextPan.y)),
167
+ };
168
+ }
169
+
170
+ // ── Touch swipe/tap/pinch navigation ────────────────────────────────────
171
+ useSwipeNav({
172
+ enabled: route.view === "slide" && !slideTextSelectionEnabled,
173
+ zoom: slideZoom,
174
+ boundaryRef: stageRef,
175
+ onToggleNavBar: () => setNavBarToggleSignal((value) => value + 1),
176
+ onZoomChange: (zoom) => {
177
+ setSlideZoom(zoom);
178
+ setSlidePan((pan) => clampPan(pan, zoom));
179
+ },
180
+ onResetZoom: resetZoom,
181
+ onPanBy: ({ dx, dy }) => {
182
+ setSlidePan((pan) => clampPan({ x: pan.x + dx, y: pan.y + dy }));
183
+ },
184
+ });
185
+
186
+ const getStepCount = useCallback(
187
+ (slideIndex: number): number => slideData[slideIndex]?.stepCount ?? 0,
188
+ [],
189
+ );
190
+
191
+ const isOverview = route.view === "overview";
192
+ const toggleOverview = useCallback(
193
+ () =>
194
+ toggleOverviewRoute(route, {
195
+ slideCount: slideData.length,
196
+ getStepCount,
197
+ }),
198
+ [route, getStepCount],
199
+ );
200
+
201
+ useKeyboardNav({
202
+ enabled: route.view === "slide",
203
+ slideCount: slideData.length,
204
+ getStepCount,
205
+ onToggleOverview: toggleOverview,
206
+ isOverview,
207
+ });
208
+
209
+ const routePositionRef = useRef(`${route.slide}/${route.step}`);
210
+ // Slide/step navigation resets Honeydeck-controlled slide zoom.
211
+ useEffect(() => {
212
+ const routePosition = `${route.slide}/${route.step}`;
213
+ if (routePositionRef.current === routePosition) return;
214
+ routePositionRef.current = routePosition;
215
+ resetZoom();
216
+ }, [resetZoom, route.slide, route.step]);
217
+
218
+ useEffect(() => {
219
+ setSlidePan((pan) => {
220
+ const maxX = (BASE_WIDTH * scale * Math.max(0, slideZoom - 1)) / 2;
221
+ const maxY = (BASE_HEIGHT * scale * Math.max(0, slideZoom - 1)) / 2;
222
+ return {
223
+ x: Math.max(-maxX, Math.min(maxX, pan.x)),
224
+ y: Math.max(-maxY, Math.min(maxY, pan.y)),
225
+ };
226
+ });
227
+ }, [scale, slideZoom]);
228
+
229
+ // ── Reference mode: delegate to DocsView ─────────────────────────────
230
+ if (route.view === "kit") {
231
+ return (
232
+ <DocsView
233
+ tab={route.kitTab}
234
+ colorMode={colorMode}
235
+ onSetColorMode={setColorMode}
236
+ />
237
+ );
238
+ }
239
+
240
+ // ── Presenter mode: delegate to PresenterView ──────────────────────────
241
+ if (route.view === "presenter") {
242
+ return <PresenterView />;
243
+ }
244
+
245
+ // Whether slide transitions are enabled (can be disabled via deck frontmatter)
246
+ const enableTransition = config.transition !== false;
247
+
248
+ // ─────────────────────────────────────────────────────────────────────────
249
+ // Guard: no slides
250
+ // ─────────────────────────────────────────────────────────────────────────
251
+
252
+ if (slideData.length === 0) {
253
+ return (
254
+ <div className="fixed inset-0 flex items-center justify-center bg-void text-white font-sans text-3xl">
255
+ ✏️ No slides found — add content to <code>deck.mdx</code>
256
+ </div>
257
+ );
258
+ }
259
+
260
+ const currentSlide = Math.max(1, Math.min(route.slide, slideData.length));
261
+ const currentStep = Math.max(0, route.step);
262
+ const controlRoute =
263
+ route.view === "slide" || route.view === "overview"
264
+ ? { ...route, slide: currentSlide, step: currentStep }
265
+ : route;
266
+ const slideTransform = `translate(${slidePan.x}px, ${slidePan.y}px) scale(${scale * slideZoom})`;
267
+ const showSlideNumbers = config.showSlideNumbers === true;
268
+ const disableSlideTextSelection =
269
+ route.view === "slide" &&
270
+ pointerLayout.isTouchDevice &&
271
+ !slideTextSelectionEnabled;
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Render
275
+ // ---------------------------------------------------------------------------
276
+
277
+ return (
278
+ <div className="fixed inset-0 overflow-hidden bg-black">
279
+ {/* ── Sizing container: fills viewport for scale calc ──────── */}
280
+ <div
281
+ ref={stageRef}
282
+ className={`absolute inset-0 ${disableSlideTextSelection ? "select-none" : ""}`}
283
+ >
284
+ {/* ── Stage backdrop: themed bg at slide size, prevents flicker ──── */}
285
+ <div
286
+ aria-hidden="true"
287
+ className="absolute inset-0 flex items-center justify-center"
288
+ >
289
+ <div
290
+ className="shrink-0 bg-background"
291
+ style={{
292
+ width: BASE_WIDTH,
293
+ height: BASE_HEIGHT,
294
+ transform: `scale(${scale})`,
295
+ transformOrigin: "center center",
296
+ }}
297
+ />
298
+ </div>
299
+
300
+ {/* ── All slides (only current is visible) ──────────────────────── */}
301
+ {slideData.map((data, i) => {
302
+ const slideNumber = i + 1;
303
+ const isCurrent = slideNumber === currentSlide;
304
+ const { Component, stepCount, title, frontmatter, layoutName } = data;
305
+ const LayoutComponent = resolveLayout(layoutName);
306
+
307
+ return (
308
+ <div
309
+ key={data.id}
310
+ aria-hidden={!isCurrent}
311
+ className={`absolute inset-0 flex items-center justify-center ${
312
+ isCurrent
313
+ ? "opacity-100 visible pointer-events-auto z-1"
314
+ : "opacity-0 invisible pointer-events-none z-0"
315
+ }`}
316
+ style={{
317
+ transition: enableTransition
318
+ ? `opacity 200ms ease, visibility 0s ${isCurrent ? "0s" : "200ms"}`
319
+ : "none",
320
+ }}
321
+ >
322
+ <TimelineProvider
323
+ stepIndex={isCurrent ? currentStep : 0}
324
+ stepCount={stepCount}
325
+ >
326
+ <div
327
+ className="honeydeck-slide-canvas shrink-0 relative overflow-hidden box-border"
328
+ style={{
329
+ width: BASE_WIDTH,
330
+ height: BASE_HEIGHT,
331
+ transform: slideTransform,
332
+ transformOrigin: "center center",
333
+ }}
334
+ >
335
+ <ErrorBoundary slideNumber={slideNumber}>
336
+ <LayoutComponent
337
+ title={title || null}
338
+ frontmatter={frontmatter}
339
+ rawChildren={<Component />}
340
+ >
341
+ <Component />
342
+ </LayoutComponent>
343
+ </ErrorBoundary>
344
+ </div>
345
+ </TimelineProvider>
346
+ </div>
347
+ );
348
+ })}
349
+
350
+ {showSlideNumbers && (
351
+ <div className="absolute inset-0 z-10 flex items-center justify-center pointer-events-none">
352
+ <div
353
+ className="honeydeck-slide-number-layer shrink-0 relative"
354
+ style={{
355
+ width: BASE_WIDTH,
356
+ height: BASE_HEIGHT,
357
+ transform: slideTransform,
358
+ transformOrigin: "center center",
359
+ }}
360
+ >
361
+ <SlideNumberBadge slide={currentSlide} />
362
+ </div>
363
+ </div>
364
+ )}
365
+ </div>
366
+
367
+ {/* ── Overview overlay ──────────────────────────────────────────── */}
368
+ {isOverview && (
369
+ <OverviewView
370
+ currentSlide={currentSlide}
371
+ currentStep={currentStep}
372
+ onClose={() =>
373
+ closeOverview(controlRoute, {
374
+ slideCount: slideData.length,
375
+ getStepCount,
376
+ })
377
+ }
378
+ />
379
+ )}
380
+
381
+ {/* ── Navigation bar ────────────────────────────────────────────── */}
382
+ {/* GAP-06: showSlideNumbers wired from config */}
383
+ {!isOverview && (
384
+ <NavBarWithHover
385
+ route={controlRoute}
386
+ isOverview={isOverview}
387
+ colorMode={colorMode}
388
+ onToggleOverview={toggleOverview}
389
+ onSetColorMode={setColorMode}
390
+ isZoomed={slideZoom > 1}
391
+ onResetZoom={resetZoom}
392
+ toggleSignal={navBarToggleSignal}
393
+ showTextSelectionToggle={pointerLayout.isTouchDevice}
394
+ isTextSelectionEnabled={slideTextSelectionEnabled}
395
+ onToggleTextSelection={() =>
396
+ setSlideTextSelectionEnabled((value) => !value)
397
+ }
398
+ />
399
+ )}
400
+ </div>
401
+ );
402
+ }
403
+
404
+ function usePointerLayout() {
405
+ const [state, setState] = useState(() => {
406
+ if (typeof window === "undefined") {
407
+ return { isTouchDevice: false, isPortrait: false };
408
+ }
409
+ return {
410
+ isTouchDevice: window.matchMedia("(pointer: coarse)").matches,
411
+ isPortrait: window.matchMedia("(orientation: portrait)").matches,
412
+ };
413
+ });
414
+
415
+ useEffect(() => {
416
+ const pointerQuery = window.matchMedia("(pointer: coarse)");
417
+ const portraitQuery = window.matchMedia("(orientation: portrait)");
418
+ const update = () => {
419
+ setState({
420
+ isTouchDevice: pointerQuery.matches,
421
+ isPortrait: portraitQuery.matches,
422
+ });
423
+ };
424
+ update();
425
+ pointerQuery.addEventListener("change", update);
426
+ portraitQuery.addEventListener("change", update);
427
+ window.addEventListener("resize", update);
428
+ return () => {
429
+ pointerQuery.removeEventListener("change", update);
430
+ portraitQuery.removeEventListener("change", update);
431
+ window.removeEventListener("resize", update);
432
+ };
433
+ }, []);
434
+
435
+ return state;
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // NavBarWithHover — handles desktop hover / touch visibility
440
+ // ---------------------------------------------------------------------------
441
+
442
+ function NavBarWithHover(props: {
443
+ route: ReturnType<typeof useRoute>;
444
+ isOverview: boolean;
445
+ colorMode: ColorMode;
446
+ onToggleOverview: () => void;
447
+ onSetColorMode: (m: ColorMode) => void;
448
+ isZoomed: boolean;
449
+ onResetZoom: () => void;
450
+ toggleSignal: number;
451
+ showTextSelectionToggle: boolean;
452
+ isTextSelectionEnabled: boolean;
453
+ onToggleTextSelection: () => void;
454
+ }) {
455
+ const [hovered, setHovered] = useState(false);
456
+ const [touchVisible, setTouchVisible] = useState(false);
457
+ const { isTouchDevice, isPortrait } = usePointerLayout();
458
+ const hideTimerRef = useRef<number | null>(null);
459
+ const lastToggleSignalRef = useRef(props.toggleSignal);
460
+
461
+ const visible = isTouchDevice
462
+ ? isPortrait || touchVisible || props.isTextSelectionEnabled
463
+ : hovered;
464
+
465
+ const clearHideTimer = useCallback(() => {
466
+ if (hideTimerRef.current !== null) {
467
+ window.clearTimeout(hideTimerRef.current);
468
+ hideTimerRef.current = null;
469
+ }
470
+ }, []);
471
+
472
+ const scheduleHide = useCallback(() => {
473
+ if (!isTouchDevice || isPortrait) return;
474
+ clearHideTimer();
475
+ hideTimerRef.current = window.setTimeout(
476
+ () => setTouchVisible(false),
477
+ 3000,
478
+ );
479
+ }, [clearHideTimer, isPortrait, isTouchDevice]);
480
+
481
+ useEffect(() => {
482
+ if (lastToggleSignalRef.current === props.toggleSignal) return;
483
+ lastToggleSignalRef.current = props.toggleSignal;
484
+ if (!isTouchDevice || isPortrait) return;
485
+ setTouchVisible((value) => !value);
486
+ }, [props.toggleSignal, isPortrait, isTouchDevice]);
487
+
488
+ useEffect(() => {
489
+ if (touchVisible) scheduleHide();
490
+ return clearHideTimer;
491
+ }, [clearHideTimer, scheduleHide, touchVisible]);
492
+
493
+ return (
494
+ <>
495
+ {/* Transparent hover zone at bottom of screen */}
496
+ {!isTouchDevice && (
497
+ <div
498
+ aria-hidden="true"
499
+ role="presentation"
500
+ className="fixed bottom-0 left-0 right-0 h-20 z-40 pointer-events-auto"
501
+ onMouseEnter={() => setHovered(true)}
502
+ onMouseLeave={() => setHovered(false)}
503
+ />
504
+ )}
505
+
506
+ {/* NavBar with opacity transition */}
507
+ <nav
508
+ aria-label="Deck controls"
509
+ className={`transition-opacity duration-200 ease-out z-[150] ${
510
+ visible
511
+ ? "opacity-100 pointer-events-auto"
512
+ : "opacity-0 pointer-events-none"
513
+ }`}
514
+ onMouseEnter={() => !isTouchDevice && setHovered(true)}
515
+ onMouseLeave={() => !isTouchDevice && setHovered(false)}
516
+ onPointerDown={scheduleHide}
517
+ >
518
+ <NavBar
519
+ route={props.route}
520
+ isOverview={props.isOverview}
521
+ colorMode={props.colorMode}
522
+ onToggleOverview={props.onToggleOverview}
523
+ onSetColorMode={props.onSetColorMode}
524
+ isZoomed={props.isZoomed}
525
+ onResetZoom={props.onResetZoom}
526
+ showTextSelectionToggle={props.showTextSelectionToggle}
527
+ isTextSelectionEnabled={props.isTextSelectionEnabled}
528
+ onToggleTextSelection={props.onToggleTextSelection}
529
+ />
530
+ </nav>
531
+ </>
532
+ );
533
+ }