@honeydeck/honeydeck 0.6.0 → 0.8.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.
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { config } from "virtual:honeydeck/config";
29
29
  import {
30
+ type CSSProperties,
30
31
  useCallback,
31
32
  useEffect,
32
33
  useLayoutEffect,
@@ -82,6 +83,80 @@ function calcScaleFromElement(el: HTMLElement | null): number | null {
82
83
  return Math.min(el.clientWidth / BASE_WIDTH, el.clientHeight / BASE_HEIGHT);
83
84
  }
84
85
 
86
+ type SlideTransitionState = {
87
+ from: number;
88
+ to: number;
89
+ name: string;
90
+ className: string;
91
+ duration: number;
92
+ easing: string;
93
+ direction: 1 | -1;
94
+ enterFromOpacity: number;
95
+ exitFromOpacity: number;
96
+ };
97
+
98
+ function normalizeTransitionName(value: unknown): string {
99
+ if (value === false) return "none";
100
+ if (value === true || value == null) return "fade";
101
+ if (typeof value !== "string") return "fade";
102
+
103
+ const name = value.trim();
104
+ if (!name) return "fade";
105
+ if (name === "true") return "fade";
106
+ if (name === "false") return "none";
107
+ return name;
108
+ }
109
+
110
+ function normalizeTransitionDuration(value: unknown): number | null {
111
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
112
+ return Math.max(0, Math.round(value));
113
+ }
114
+
115
+ function normalizeTransitionEasing(value: unknown): string | null {
116
+ if (typeof value !== "string") return null;
117
+ const easing = value.trim();
118
+ return easing ? easing : null;
119
+ }
120
+
121
+ function transitionClassName(name: string): string {
122
+ return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-");
123
+ }
124
+
125
+ function readLayerOpacity(
126
+ element: HTMLElement | null | undefined,
127
+ ): number | null {
128
+ if (!element) return null;
129
+ const opacity = Number.parseFloat(window.getComputedStyle(element).opacity);
130
+ return Number.isFinite(opacity) ? opacity : null;
131
+ }
132
+
133
+ function getTransitionOptions(
134
+ slideIndex: number,
135
+ ): Omit<
136
+ SlideTransitionState,
137
+ "from" | "to" | "direction" | "enterFromOpacity" | "exitFromOpacity"
138
+ > {
139
+ const frontmatter = slideData[slideIndex]?.frontmatter ?? {};
140
+ const name = normalizeTransitionName(
141
+ frontmatter.transition ?? config.transition,
142
+ );
143
+ const duration =
144
+ normalizeTransitionDuration(frontmatter.transitionDuration) ??
145
+ normalizeTransitionDuration(config.transitionDuration) ??
146
+ 200;
147
+ const easing =
148
+ normalizeTransitionEasing(frontmatter.transitionEasing) ??
149
+ normalizeTransitionEasing(config.transitionEasing) ??
150
+ "ease";
151
+
152
+ return {
153
+ name,
154
+ className: transitionClassName(name),
155
+ duration,
156
+ easing,
157
+ };
158
+ }
159
+
85
160
  // ---------------------------------------------------------------------------
86
161
  // Component
87
162
  // ---------------------------------------------------------------------------
@@ -102,6 +177,7 @@ export function Deck() {
102
177
  });
103
178
  const [effectiveColorMode, setEffectiveColorMode] =
104
179
  useState<EffectiveColorMode>("light");
180
+ const [reducedMotion, setReducedMotion] = useState(false);
105
181
 
106
182
  const route = useRoute();
107
183
  const pointerLayout = usePointerLayout();
@@ -126,6 +202,17 @@ export function Deck() {
126
202
  return () => mq.removeEventListener("change", onChange);
127
203
  }, [colorMode]);
128
204
 
205
+ // ── Reduced motion: disable slide transition animations ────────────────
206
+ useEffect(() => {
207
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
208
+ setReducedMotion(mq.matches);
209
+
210
+ const onChange = (event: MediaQueryListEvent) =>
211
+ setReducedMotion(event.matches);
212
+ mq.addEventListener("change", onChange);
213
+ return () => mq.removeEventListener("change", onChange);
214
+ }, []);
215
+
129
216
  // ── Observe stage size → recalculate scale ─────────────────────────────
130
217
  useEffect(() => {
131
218
  if (route.view !== "slide" && route.view !== "overview") return;
@@ -156,12 +243,22 @@ export function Deck() {
156
243
  }, [route]);
157
244
 
158
245
  // ── Audience sync: BroadcastChannel + Presentation API receiver ──────
246
+ const [blankScreen, setBlankScreen] = useState<"black" | null>(null);
247
+ const handleBlankScreen = useCallback(
248
+ (mode: "black" | "off") =>
249
+ setBlankScreen(mode === "black" ? "black" : null),
250
+ [],
251
+ );
159
252
  useSync({
160
253
  enabled: route.view === "slide" || route.view === "overview",
161
254
  isPresenter: false,
255
+ onSetColorMode: setColorMode,
256
+ onBlankScreen: handleBlankScreen,
162
257
  });
163
258
  usePresentationReceiverSync({
164
259
  enabled: route.view === "slide" || route.view === "overview",
260
+ onSetColorMode: setColorMode,
261
+ onBlankScreen: handleBlankScreen,
165
262
  });
166
263
 
167
264
  const resetZoom = useCallback(() => {
@@ -237,6 +334,73 @@ export function Deck() {
237
334
  });
238
335
  }, [scale, slideZoom]);
239
336
 
337
+ const currentSlide = Math.max(
338
+ 1,
339
+ Math.min(route.slide, slideData.length || 1),
340
+ );
341
+ const previousSlideRef = useRef<number | null>(null);
342
+ const slideLayerRefs = useRef<Record<number, HTMLDivElement | null>>({});
343
+ const [slideTransition, setSlideTransition] =
344
+ useState<SlideTransitionState | null>(null);
345
+ const slideTransitionRef = useRef<SlideTransitionState | null>(null);
346
+ slideTransitionRef.current = slideTransition;
347
+
348
+ useLayoutEffect(() => {
349
+ if (route.view !== "slide") {
350
+ previousSlideRef.current = currentSlide;
351
+ setSlideTransition(null);
352
+ return;
353
+ }
354
+
355
+ if (reducedMotion) {
356
+ previousSlideRef.current = currentSlide;
357
+ setSlideTransition(null);
358
+ return;
359
+ }
360
+
361
+ const previousSlide = previousSlideRef.current;
362
+ if (previousSlide === null) {
363
+ previousSlideRef.current = currentSlide;
364
+ return;
365
+ }
366
+ if (previousSlide === currentSlide) return;
367
+
368
+ const direction: 1 | -1 = currentSlide > previousSlide ? 1 : -1;
369
+ const options = getTransitionOptions(currentSlide - 1);
370
+ previousSlideRef.current = currentSlide;
371
+
372
+ if (options.name === "none" || options.duration === 0) {
373
+ setSlideTransition(null);
374
+ return;
375
+ }
376
+
377
+ const activeTransition = slideTransitionRef.current;
378
+ const isInterruptingFade = activeTransition?.name === "fade";
379
+ const nextTransition = {
380
+ ...options,
381
+ from: previousSlide,
382
+ to: currentSlide,
383
+ direction,
384
+ enterFromOpacity: isInterruptingFade
385
+ ? (readLayerOpacity(slideLayerRefs.current[currentSlide]) ?? 0)
386
+ : 0,
387
+ exitFromOpacity: isInterruptingFade
388
+ ? (readLayerOpacity(slideLayerRefs.current[previousSlide]) ?? 1)
389
+ : 1,
390
+ };
391
+ setSlideTransition(nextTransition);
392
+
393
+ const timeout = window.setTimeout(() => {
394
+ setSlideTransition((active) =>
395
+ active?.from === nextTransition.from && active.to === nextTransition.to
396
+ ? null
397
+ : active,
398
+ );
399
+ }, options.duration);
400
+
401
+ return () => window.clearTimeout(timeout);
402
+ }, [currentSlide, reducedMotion, route.view]);
403
+
240
404
  // ── Reference mode: delegate to DocsView ─────────────────────────────
241
405
  if (route.view === "kit") {
242
406
  return (
@@ -250,12 +414,11 @@ export function Deck() {
250
414
 
251
415
  // ── Presenter mode: delegate to PresenterView ──────────────────────────
252
416
  if (route.view === "presenter") {
253
- return <PresenterView />;
417
+ return (
418
+ <PresenterView colorMode={colorMode} onSetColorMode={setColorMode} />
419
+ );
254
420
  }
255
421
 
256
- // Whether slide transitions are enabled (can be disabled via deck frontmatter)
257
- const enableTransition = config.transition !== false;
258
-
259
422
  // ─────────────────────────────────────────────────────────────────────────
260
423
  // Guard: no slides
261
424
  // ─────────────────────────────────────────────────────────────────────────
@@ -268,14 +431,15 @@ export function Deck() {
268
431
  );
269
432
  }
270
433
 
271
- const currentSlide = Math.max(1, Math.min(route.slide, slideData.length));
272
434
  const currentStep = Math.max(0, route.step);
273
435
  const controlRoute =
274
436
  route.view === "slide" || route.view === "overview"
275
437
  ? { ...route, slide: currentSlide, step: currentStep }
276
438
  : route;
277
439
  const activeSlideScale = scale * slideZoom;
440
+ const viewportScale = scale || 1;
278
441
  const slideTransform = `translate(${slidePan.x}px, ${slidePan.y}px) scale(${activeSlideScale})`;
442
+ const zoomedSlideTransform = `translate(${slidePan.x / viewportScale}px, ${slidePan.y / viewportScale}px) scale(${slideZoom})`;
279
443
  const showSlideNumbers = config.showSlideNumbers === true;
280
444
  const disableSlideTextSelection =
281
445
  route.view === "slide" &&
@@ -314,6 +478,31 @@ export function Deck() {
314
478
  {slideData.map((data, i) => {
315
479
  const slideNumber = i + 1;
316
480
  const isCurrent = slideNumber === currentSlide;
481
+ const activeTransition = slideTransition;
482
+ const transitionRole =
483
+ activeTransition?.to === slideNumber
484
+ ? "enter"
485
+ : activeTransition?.from === slideNumber
486
+ ? "exit"
487
+ : null;
488
+ const isVisible = isCurrent || transitionRole !== null;
489
+ const transitionLayerClass =
490
+ transitionRole && activeTransition
491
+ ? `honeydeck-transition-${activeTransition.className} honeydeck-transition-${transitionRole}`
492
+ : "";
493
+ const layerStyle =
494
+ transitionRole && activeTransition
495
+ ? ({
496
+ "--honeydeck-transition-duration": `${activeTransition.duration}ms`,
497
+ "--honeydeck-transition-easing": activeTransition.easing,
498
+ "--honeydeck-transition-direction":
499
+ activeTransition.direction,
500
+ "--honeydeck-transition-enter-from-opacity":
501
+ activeTransition.enterFromOpacity,
502
+ "--honeydeck-transition-exit-from-opacity":
503
+ activeTransition.exitFromOpacity,
504
+ } as CSSProperties)
505
+ : undefined;
317
506
  const { Component, stepCount, title, frontmatter, layoutName } =
318
507
  data;
319
508
  const LayoutComponent = resolveLayout(layoutName);
@@ -323,42 +512,67 @@ export function Deck() {
323
512
  key={data.id}
324
513
  aria-hidden={!isCurrent}
325
514
  className={`absolute inset-0 flex items-center justify-center ${
326
- isCurrent
327
- ? "opacity-100 visible pointer-events-auto z-1"
328
- : "opacity-0 invisible pointer-events-none z-0"
515
+ isVisible ? "visible" : "invisible"
516
+ } ${isCurrent ? "pointer-events-auto" : "pointer-events-none"} ${
517
+ transitionRole === "enter"
518
+ ? "z-2"
519
+ : transitionRole === "exit"
520
+ ? "z-1"
521
+ : isCurrent
522
+ ? "z-1"
523
+ : "z-0"
329
524
  }`}
330
- style={{
331
- transition: enableTransition
332
- ? `opacity 200ms ease, visibility 0s ${isCurrent ? "0s" : "200ms"}`
333
- : "none",
334
- }}
335
525
  >
336
- <TimelineProvider
337
- stepIndex={isCurrent ? currentStep : 0}
338
- stepCount={stepCount}
526
+ <div
527
+ className="shrink-0 overflow-hidden"
528
+ style={{
529
+ width: BASE_WIDTH,
530
+ height: BASE_HEIGHT,
531
+ transform: `scale(${scale})`,
532
+ transformOrigin: "center center",
533
+ }}
339
534
  >
340
- <SlideScaleProvider scale={activeSlideScale}>
341
- <div
342
- className="honeydeck-slide-canvas shrink-0 relative overflow-hidden box-border"
343
- style={{
344
- width: BASE_WIDTH,
345
- height: BASE_HEIGHT,
346
- transform: slideTransform,
347
- transformOrigin: "center center",
348
- }}
535
+ <div
536
+ ref={(element) => {
537
+ slideLayerRefs.current[slideNumber] = element;
538
+ }}
539
+ className={`honeydeck-slide-layer relative ${
540
+ isCurrent ? "opacity-100" : "opacity-0"
541
+ } ${transitionLayerClass}`}
542
+ style={{
543
+ width: BASE_WIDTH,
544
+ height: BASE_HEIGHT,
545
+ ...layerStyle,
546
+ }}
547
+ >
548
+ <TimelineProvider
549
+ stepIndex={isCurrent ? currentStep : 0}
550
+ stepCount={stepCount}
349
551
  >
350
- <ErrorBoundary slideNumber={slideNumber}>
351
- <LayoutComponent
352
- title={title || null}
353
- frontmatter={frontmatter}
354
- rawChildren={<Component />}
552
+ <SlideScaleProvider scale={activeSlideScale}>
553
+ <div
554
+ className="honeydeck-slide-canvas shrink-0 relative overflow-hidden box-border"
555
+ style={{
556
+ width: BASE_WIDTH,
557
+ height: BASE_HEIGHT,
558
+ transform: zoomedSlideTransform,
559
+ transformOrigin: "center center",
560
+ }}
355
561
  >
356
- <Component />
357
- </LayoutComponent>
358
- </ErrorBoundary>
359
- </div>
360
- </SlideScaleProvider>
361
- </TimelineProvider>
562
+ <ErrorBoundary slideNumber={slideNumber}>
563
+ <LayoutComponent
564
+ title={title || null}
565
+ frontmatter={frontmatter}
566
+ rawChildren={<Component />}
567
+ >
568
+ <Component />
569
+ </LayoutComponent>
570
+ </ErrorBoundary>
571
+ </div>
572
+ </SlideScaleProvider>
573
+ </TimelineProvider>
574
+ </div>
575
+ </div>
362
576
  </div>
363
577
  );
364
578
  })}
@@ -394,6 +608,11 @@ export function Deck() {
394
608
  />
395
609
  )}
396
610
 
611
+ {/* ── Black screen overlay (controlled by presenter) ────────── */}
612
+ {blankScreen === "black" && (
613
+ <div className="fixed inset-0 bg-black z-[100]" aria-hidden="true" />
614
+ )}
615
+
397
616
  {/* ── Navigation bar ────────────────────────────────────────────── */}
398
617
  {/* GAP-06: showSlideNumbers wired from config */}
399
618
  {!isOverview && (
@@ -223,12 +223,20 @@ Build output is a single-page application. The app preserves client-side slide t
223
223
 
224
224
  ### Slide Transitions
225
225
 
226
- Honeydeck includes a single subtle crossfade (~200ms) between slides. Disabled with:
226
+ Honeydeck uses a named slide transition system. Deck-level frontmatter sets defaults, and slide-level frontmatter overrides the transition **into that slide**:
227
227
 
228
228
  ```yaml
229
- transition: false
229
+ transition: fade
230
+ transitionDuration: 200
231
+ transitionEasing: ease
230
232
  ```
231
233
 
234
+ Built-in transition names are `fade`, `none`, and `slide-left`. Any other string is exposed as a custom CSS hook on the participating slide layers. Legacy `transition: true` maps to `fade`, and `transition: false` maps to `none`.
235
+
236
+ During slide navigation, Honeydeck keeps the outgoing and incoming slide layers mounted inside a scaled slide-sized clipping viewport, applies `honeydeck-slide-layer`, `honeydeck-transition-{name}`, and either `honeydeck-transition-enter` or `honeydeck-transition-exit` only to those two layers, then clears transition state after the configured duration. Transition visuals are clipped to the slide canvas area and must not animate into letterbox or pillarbox bars around the slide. If the next transition is `none` or navigation is interrupted, stale transition state is cleared/replaced so old slides do not remain visible. Outgoing layers are visible during the transition but have pointer events disabled. The built-in `fade` transition uses keyframes and, when a fade is interrupted, starts the next fade from the participating layers' current computed opacity so quick back-and-forth navigation stays close to the old opacity-transition behavior.
237
+
238
+ Participating slide layers receive CSS variables: `--honeydeck-transition-duration`, `--honeydeck-transition-easing`, and `--honeydeck-transition-direction` (`1` forward, `-1` backward). Built-in `slide-left` uses the direction variable so backward navigation reverses direction. Custom transition CSS can use the same variable for opt-in reverse awareness. Reduced-motion preferences disable slide transition animations.
239
+
232
240
  ### Aspect Ratio
233
241
 
234
242
  Slides render at a fixed 1920px logical width. Height is derived from deck-level `aspectRatio` when it is a string ratio matching `N:N` (default `16:9` → `1080`, `4:3` → `1440`, etc.). Invalid or missing ratios fall back to `16:9`.
@@ -1,9 +1,13 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type { ColorMode } from "./components/ColorModeCycleButton.tsx";
2
3
  import { getRouteUrl } from "./navigation.ts";
3
4
  import { navigate, parseHash, type Route } from "./router.ts";
4
5
  import {
6
+ createSyncColorModeMessage,
5
7
  createSyncResponseMessage,
6
8
  resolveAudienceRouteFromSyncMessage,
9
+ type SyncBlankScreenMessage,
10
+ type SyncColorModeMessage,
7
11
  type SyncMessage,
8
12
  type SyncNavigateMessage,
9
13
  type SyncRequestMessage,
@@ -60,6 +64,10 @@ type PresenterRoute = {
60
64
  step: number;
61
65
  };
62
66
 
67
+ type PresentationColorModeRef = {
68
+ current: ColorMode;
69
+ };
70
+
63
71
  type PresentationWindowLike = Window & {
64
72
  PresentationRequest?: PresentationRequestLike;
65
73
  navigator: Navigator & {
@@ -142,13 +150,21 @@ function sendPresentationSyncResponse(
142
150
  connection: PresentationConnectionLike | null,
143
151
  slide: number,
144
152
  step: number,
153
+ colorMode?: ColorMode,
145
154
  ): void {
146
155
  sendPresentationMessage(
147
156
  connection,
148
- createSyncResponseMessage({ slide, step }),
157
+ createSyncResponseMessage({ slide, step }, colorMode),
149
158
  );
150
159
  }
151
160
 
161
+ function sendPresentationColorModeToConnection(
162
+ connection: PresentationConnectionLike | null,
163
+ colorMode: ColorMode,
164
+ ): void {
165
+ sendPresentationMessage(connection, createSyncColorModeMessage(colorMode));
166
+ }
167
+
152
168
  type PresentationRouteRef = {
153
169
  current: PresenterRoute;
154
170
  };
@@ -163,7 +179,9 @@ export async function startPresentationCast({
163
179
  audienceUrl,
164
180
  currentSlide,
165
181
  currentStep,
182
+ currentColorMode,
166
183
  routeRef,
184
+ colorModeRef,
167
185
  requestConstructor,
168
186
  connectionRef,
169
187
  startInFlightRef,
@@ -175,7 +193,9 @@ export async function startPresentationCast({
175
193
  audienceUrl: string | null;
176
194
  currentSlide: number;
177
195
  currentStep: number;
196
+ currentColorMode: ColorMode;
178
197
  routeRef: PresentationRouteRef;
198
+ colorModeRef: PresentationColorModeRef;
179
199
  requestConstructor: PresentationRequestLike | null;
180
200
  connectionRef: { current: PresentationConnectionLike | null };
181
201
  startInFlightRef: { current: boolean };
@@ -212,6 +232,7 @@ export async function startPresentationCast({
212
232
  connection,
213
233
  routeRef.current.slide,
214
234
  routeRef.current.step,
235
+ colorModeRef.current,
215
236
  );
216
237
  };
217
238
 
@@ -235,6 +256,7 @@ export async function startPresentationCast({
235
256
  connection.addEventListener?.("statechange", onStateChange);
236
257
 
237
258
  sendPresentationRouteToConnection(connection, currentSlide, currentStep);
259
+ sendPresentationColorModeToConnection(connection, currentColorMode);
238
260
  } catch {
239
261
  if (castGeneration !== castGenerationRef.current) {
240
262
  return;
@@ -265,8 +287,12 @@ function getConnectionFromEvent(event: {
265
287
 
266
288
  export function usePresentationReceiverSync({
267
289
  enabled = true,
290
+ onSetColorMode,
291
+ onBlankScreen,
268
292
  }: {
269
293
  enabled?: boolean;
294
+ onSetColorMode?: (mode: ColorMode) => void;
295
+ onBlankScreen?: (mode: "black" | "off") => void;
270
296
  }): void {
271
297
  useEffect(() => {
272
298
  if (!enabled) return;
@@ -283,6 +309,20 @@ export function usePresentationReceiverSync({
283
309
  function handleMessage(event: MessageEvent<unknown>) {
284
310
  const message = parsePresentationMessage(event.data);
285
311
  if (cancelled || !isSyncMessage(message)) return;
312
+
313
+ if (isColorModeMessage(message)) {
314
+ onSetColorMode?.(message.colorMode);
315
+ return;
316
+ }
317
+
318
+ if (isBlankScreenMessage(message)) {
319
+ onBlankScreen?.(message.mode);
320
+ return;
321
+ }
322
+
323
+ if (message.type === "sync-response" && message.colorMode) {
324
+ onSetColorMode?.(message.colorMode);
325
+ }
286
326
  const currentRoute = parseHash(location.hash);
287
327
  const nextRoute = resolveAudienceRouteFromSyncMessage(
288
328
  currentRoute,
@@ -342,7 +382,7 @@ export function usePresentationReceiverSync({
342
382
  });
343
383
  connections.clear();
344
384
  };
345
- }, [enabled]);
385
+ }, [enabled, onSetColorMode, onBlankScreen]);
346
386
  }
347
387
 
348
388
  export function usePresentationCast({
@@ -350,16 +390,19 @@ export function usePresentationCast({
350
390
  audienceUrl,
351
391
  currentSlide,
352
392
  currentStep,
393
+ currentColorMode,
353
394
  }: {
354
395
  enabled?: boolean;
355
396
  audienceUrl: string | null;
356
397
  currentSlide: number;
357
398
  currentStep: number;
399
+ currentColorMode: ColorMode;
358
400
  }): {
359
401
  supported: boolean;
360
402
  isCasting: boolean;
361
403
  startCasting: () => Promise<void>;
362
404
  stopCasting: () => void;
405
+ sendMessage: (message: SyncMessage) => void;
363
406
  } {
364
407
  const [isCasting, setIsCasting] = useState(false);
365
408
  const isMountedRef = useRef(true);
@@ -370,6 +413,7 @@ export function usePresentationCast({
370
413
  slide: currentSlide,
371
414
  step: currentStep,
372
415
  });
416
+ const colorModeRef = useRef<ColorMode>(currentColorMode);
373
417
  const supported = isPresentationApiSupported();
374
418
 
375
419
  useEffect(() => {
@@ -379,6 +423,10 @@ export function usePresentationCast({
379
423
  };
380
424
  }, [currentSlide, currentStep]);
381
425
 
426
+ useEffect(() => {
427
+ colorModeRef.current = currentColorMode;
428
+ }, [currentColorMode]);
429
+
382
430
  const setCastingState = useCallback((next: boolean) => {
383
431
  if (isMountedRef.current) setIsCasting(next);
384
432
  }, []);
@@ -394,7 +442,9 @@ export function usePresentationCast({
394
442
  audienceUrl,
395
443
  currentSlide,
396
444
  currentStep,
445
+ currentColorMode,
397
446
  routeRef,
447
+ colorModeRef,
398
448
  requestConstructor: getPresentationRequestConstructor(),
399
449
  connectionRef,
400
450
  startInFlightRef,
@@ -405,6 +455,7 @@ export function usePresentationCast({
405
455
  audienceUrl,
406
456
  currentSlide,
407
457
  currentStep,
458
+ currentColorMode,
408
459
  enabled,
409
460
  supported,
410
461
  setCastingState,
@@ -419,6 +470,14 @@ export function usePresentationCast({
419
470
  );
420
471
  }, [currentSlide, currentStep, isCasting]);
421
472
 
473
+ useEffect(() => {
474
+ if (!isCasting) return;
475
+ sendPresentationColorModeToConnection(
476
+ connectionRef.current,
477
+ currentColorMode,
478
+ );
479
+ }, [currentColorMode, isCasting]);
480
+
422
481
  useEffect(() => {
423
482
  isMountedRef.current = true;
424
483
  return () => {
@@ -427,9 +486,13 @@ export function usePresentationCast({
427
486
  };
428
487
  }, [stopCasting]);
429
488
 
489
+ const sendMessage = useCallback((message: SyncMessage) => {
490
+ sendPresentationMessage(connectionRef.current, message);
491
+ }, []);
492
+
430
493
  return useMemo(
431
- () => ({ supported, isCasting, startCasting, stopCasting }),
432
- [supported, isCasting, startCasting, stopCasting],
494
+ () => ({ supported, isCasting, startCasting, stopCasting, sendMessage }),
495
+ [supported, isCasting, startCasting, stopCasting, sendMessage],
433
496
  );
434
497
  }
435
498
 
@@ -441,9 +504,52 @@ function isSyncRequestMessage(value: unknown): value is SyncRequestMessage {
441
504
 
442
505
  function isSyncMessage(
443
506
  value: unknown,
444
- ): value is SyncNavigateMessage | SyncResponseMessage {
507
+ ): value is
508
+ | SyncNavigateMessage
509
+ | SyncResponseMessage
510
+ | SyncColorModeMessage
511
+ | SyncBlankScreenMessage {
445
512
  if (typeof value !== "object" || value === null) return false;
446
513
  if (!("type" in value)) return false;
447
514
  const type = (value as SyncMessage).type;
448
- return type === "navigate" || type === "sync-response";
515
+ if (type === "color-mode") return isColorModeMessage(value);
516
+ if (type === "blank-screen") return isBlankScreenMessage(value);
517
+ if (type !== "navigate" && type !== "sync-response") return false;
518
+
519
+ const slide = (value as { slide?: unknown }).slide;
520
+ const step = (value as { step?: unknown }).step;
521
+ if (
522
+ typeof slide !== "number" ||
523
+ !Number.isFinite(slide) ||
524
+ typeof step !== "number" ||
525
+ !Number.isFinite(step)
526
+ ) {
527
+ return false;
528
+ }
529
+
530
+ const colorMode = (value as { colorMode?: unknown }).colorMode;
531
+ return colorMode === undefined || isColorMode(colorMode);
532
+ }
533
+
534
+ function isColorModeMessage(value: unknown): value is SyncColorModeMessage {
535
+ if (typeof value !== "object" || value === null) return false;
536
+ if (!("type" in value)) return false;
537
+ return (
538
+ (value as SyncMessage).type === "color-mode" &&
539
+ isColorMode((value as { colorMode?: unknown }).colorMode)
540
+ );
541
+ }
542
+
543
+ function isBlankScreenMessage(value: unknown): value is SyncBlankScreenMessage {
544
+ if (typeof value !== "object" || value === null) return false;
545
+ if (!("type" in value)) return false;
546
+ const mode = (value as { mode?: unknown }).mode;
547
+ return (
548
+ (value as SyncMessage).type === "blank-screen" &&
549
+ (mode === "black" || mode === "off")
550
+ );
551
+ }
552
+
553
+ function isColorMode(value: unknown): value is ColorMode {
554
+ return value === "system" || value === "light" || value === "dark";
449
555
  }