@honeydeck/honeydeck 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/mobile.md CHANGED
@@ -13,7 +13,7 @@ Honeydeck works on phones and tablets, but mobile presentation mode behaves diff
13
13
  | Text selection | Slide text can be selected normally | Slide text selection is off by default; use the nav bar toggle when you need it |
14
14
  | Zoom | Browser/page zoom or normal browser controls | Honeydeck-controlled slide zoom with pinch, up to `5x` |
15
15
  | Overview | Keyboard selection and mouse clicks | Responsive fixed two-column grid |
16
- | Presenter mode | Current slide, next preview, notes, clock | Current slide, notes, navigation buttons; no next preview |
16
+ | Presenter mode | Current slide, next preview, notes, clock, timer, actions; no navigation buttons | Current slide, notes, navigation buttons; no next preview |
17
17
 
18
18
  ## Tap zones
19
19
 
@@ -20,7 +20,7 @@ Presenter mode shows the presenter everything they need while the audience sees
20
20
  │ - Remember to demo the sparkle button │
21
21
  │ - Mention PDF export │
22
22
  │ │
23
- │ Slide 3/12 · Step 2/4 12:34 [Open]
23
+ │ Slide 3/12 · Step 2/4 12:34 Timer 1:23 [Open]
24
24
  └──────────────────────────────────────────┘
25
25
  ```
26
26
 
@@ -29,10 +29,12 @@ Includes:
29
29
  - Next timeline-state preview (smaller): the next step on the current slide when one exists, otherwise the next slide at step 0
30
30
  - Speaker notes for current slide
31
31
  - Slide number / step counter
32
- - Wall clock
32
+ - Wall clock and an elapsed presentation timer with start, pause, continue, restart, and close/reset controls
33
33
  - Button to open audience view in a new tab/window
34
- - Button to cast the audience view to a secondary display when supported; the same control becomes a stop button while casting and is disabled with a hint when unsupported
35
- - Navigation buttons that move through the timeline while staying in presenter mode
34
+ - Color mode toggle that also updates audience views and cast receivers
35
+ - Blank screen toggle (`b`) that makes audience and cast views black while presenter mode shows a `Screen blanked (b)` indicator
36
+ - Button to cast the audience view to a secondary display when supported; the same control becomes a stop button while casting. Unsupported browsers show a visibly disabled-looking button with hover/accessible text explaining why casting is unavailable.
37
+ - Navigation buttons that move through the timeline while staying in presenter mode on mobile. Desktop presenter mode uses keyboard shortcuts instead.
36
38
 
37
39
  ## Speaker Notes
38
40
 
@@ -65,7 +67,7 @@ Content here.
65
67
  - Navigation controls button in normal presentation.
66
68
  - Direct URL: `/#/presenter/1/0`
67
69
 
68
- Pressing `p` opens presenter mode in the **current tab**.
70
+ Pressing `p` opens presenter mode in the **current tab**. In presenter mode, press `p` or `Escape` to return to the audience slide view at the same slide and step.
69
71
 
70
72
  ## Navigation
71
73
 
@@ -86,12 +88,16 @@ Reveal content from later timeline steps is also shown in the `Next` preview at
86
88
 
87
89
  When no next timeline state exists, the `Next` preview shows an end-of-deck placeholder instead of trying to render a missing slide.
88
90
 
91
+ ## Blank Screen
92
+
93
+ Press `b` or use the blank screen button to make the audience and cast views black. Press `b` again or use the same button to restore the slide. Audience tabs unblank automatically when presenter mode disconnects.
94
+
89
95
  ## Presenter/Audience Sync
90
96
 
91
97
  Presenter mode and audience view synchronize navigation via `BroadcastChannel` and, when supported, the Presentation API.
92
98
 
93
99
  - Presenter mode acts as the controller.
94
- - Audience view (opened from presenter mode) listens for navigation updates.
100
+ - Audience view (opened from presenter mode) listens for navigation, color mode, and blank-screen updates.
95
101
  - The cast audience sends a `sync-request` as soon as the receiver connection appears; the presenter replies with the current slide/step in a `sync-response` so late connections resync immediately.
96
102
  - The cast audience follows presenter navigation through Presentation API receiver messages.
97
103
  - No server, internet, WebSocket, or device pairing required.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@honeydeck/honeydeck",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "MDX and React-based presentation framework for AI-friendly slide decks.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -156,12 +156,22 @@ export function Deck() {
156
156
  }, [route]);
157
157
 
158
158
  // ── Audience sync: BroadcastChannel + Presentation API receiver ──────
159
+ const [blankScreen, setBlankScreen] = useState<"black" | null>(null);
160
+ const handleBlankScreen = useCallback(
161
+ (mode: "black" | "off") =>
162
+ setBlankScreen(mode === "black" ? "black" : null),
163
+ [],
164
+ );
159
165
  useSync({
160
166
  enabled: route.view === "slide" || route.view === "overview",
161
167
  isPresenter: false,
168
+ onSetColorMode: setColorMode,
169
+ onBlankScreen: handleBlankScreen,
162
170
  });
163
171
  usePresentationReceiverSync({
164
172
  enabled: route.view === "slide" || route.view === "overview",
173
+ onSetColorMode: setColorMode,
174
+ onBlankScreen: handleBlankScreen,
165
175
  });
166
176
 
167
177
  const resetZoom = useCallback(() => {
@@ -250,7 +260,9 @@ export function Deck() {
250
260
 
251
261
  // ── Presenter mode: delegate to PresenterView ──────────────────────────
252
262
  if (route.view === "presenter") {
253
- return <PresenterView />;
263
+ return (
264
+ <PresenterView colorMode={colorMode} onSetColorMode={setColorMode} />
265
+ );
254
266
  }
255
267
 
256
268
  // Whether slide transitions are enabled (can be disabled via deck frontmatter)
@@ -394,6 +406,11 @@ export function Deck() {
394
406
  />
395
407
  )}
396
408
 
409
+ {/* ── Black screen overlay (controlled by presenter) ────────── */}
410
+ {blankScreen === "black" && (
411
+ <div className="fixed inset-0 bg-black z-[100]" aria-hidden="true" />
412
+ )}
413
+
397
414
  {/* ── Navigation bar ────────────────────────────────────────────── */}
398
415
  {/* GAP-06: showSlideNumbers wired from config */}
399
416
  {!isOverview && (
@@ -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
  }
@@ -9,6 +9,8 @@
9
9
  *
10
10
  * ### Message types
11
11
  * - `navigate` — presenter changed slide/step
12
+ * - `color-mode` — presenter changed configured color mode
13
+ * - `blank-screen` — presenter toggled audience blank screen
12
14
  * - `sync-request` — audience asks for the current presenter route
13
15
  * - `sync-response` — presenter replies with the current route
14
16
  * - `presenter-connected` — a presenter window opened
@@ -28,7 +30,8 @@
28
30
  * ```
29
31
  */
30
32
 
31
- import { useEffect, useRef, useState } from "react";
33
+ import { useCallback, useEffect, useRef, useState } from "react";
34
+ import type { ColorMode } from "./components/ColorModeCycleButton.tsx";
32
35
  import { navigate, parseHash, type Route } from "./router.ts";
33
36
 
34
37
  // ---------------------------------------------------------------------------
@@ -49,6 +52,17 @@ export type SyncResponseMessage = {
49
52
  type: "sync-response";
50
53
  slide: number;
51
54
  step: number;
55
+ colorMode?: ColorMode;
56
+ };
57
+
58
+ export type SyncColorModeMessage = {
59
+ type: "color-mode";
60
+ colorMode: ColorMode;
61
+ };
62
+
63
+ export type SyncBlankScreenMessage = {
64
+ type: "blank-screen";
65
+ mode: "black" | "off";
52
66
  };
53
67
 
54
68
  export type SyncPresenceMessage =
@@ -59,6 +73,8 @@ export type SyncMessage =
59
73
  | SyncNavigateMessage
60
74
  | SyncRequestMessage
61
75
  | SyncResponseMessage
76
+ | SyncColorModeMessage
77
+ | SyncBlankScreenMessage
62
78
  | SyncPresenceMessage;
63
79
 
64
80
  export type UseSyncOptions = {
@@ -70,6 +86,12 @@ export type UseSyncOptions = {
70
86
  currentSlide?: number;
71
87
  /** Current 0-based step index (required when `isPresenter: true`). */
72
88
  currentStep?: number;
89
+ /** Current configured color mode, broadcast by presenter when supplied. */
90
+ currentColorMode?: ColorMode;
91
+ /** Called by audience windows when presenter color mode changes. */
92
+ onSetColorMode?: (mode: ColorMode) => void;
93
+ /** Called by audience windows when presenter toggles blank screen. */
94
+ onBlankScreen?: (mode: "black" | "off") => void;
73
95
  };
74
96
 
75
97
  type PresenterRoute = {
@@ -89,14 +111,28 @@ export function createSyncRequestMessage(): SyncRequestMessage {
89
111
 
90
112
  export function createSyncResponseMessage(
91
113
  route: PresenterRoute,
114
+ colorMode?: ColorMode,
92
115
  ): SyncResponseMessage {
93
116
  return {
94
117
  type: "sync-response",
95
118
  slide: route.slide,
96
119
  step: route.step,
120
+ ...(colorMode ? { colorMode } : {}),
97
121
  };
98
122
  }
99
123
 
124
+ export function createSyncColorModeMessage(
125
+ colorMode: ColorMode,
126
+ ): SyncColorModeMessage {
127
+ return { type: "color-mode", colorMode };
128
+ }
129
+
130
+ export function createSyncBlankScreenMessage(
131
+ mode: "black" | "off",
132
+ ): SyncBlankScreenMessage {
133
+ return { type: "blank-screen", mode };
134
+ }
135
+
100
136
  export function resolveAudienceRouteFromSyncMessage(
101
137
  currentRoute: Route,
102
138
  message: SyncNavigateMessage | SyncResponseMessage,
@@ -110,6 +146,14 @@ export function resolveAudienceRouteFromSyncMessage(
110
146
  };
111
147
  }
112
148
 
149
+ function isColorModeValue(value: unknown): value is { colorMode: ColorMode } {
150
+ if (typeof value !== "object" || value === null) return false;
151
+ const colorMode = (value as { colorMode?: unknown }).colorMode;
152
+ return (
153
+ colorMode === "system" || colorMode === "light" || colorMode === "dark"
154
+ );
155
+ }
156
+
113
157
  function isSyncMessage(value: unknown): value is SyncMessage {
114
158
  if (typeof value !== "object" || value === null) return false;
115
159
  if (!("type" in value)) return false;
@@ -118,17 +162,32 @@ function isSyncMessage(value: unknown): value is SyncMessage {
118
162
  if (type === "sync-request") return true;
119
163
  if (type === "presenter-connected") return true;
120
164
  if (type === "presenter-disconnected") return true;
165
+ if (type === "color-mode") return isColorModeValue(value);
166
+ if (type === "blank-screen") return isBlankScreenValue(value);
121
167
 
122
168
  if (type !== "navigate" && type !== "sync-response") return false;
123
169
 
124
170
  const slide = (value as { slide?: unknown }).slide;
125
171
  const step = (value as { step?: unknown }).step;
126
- return (
127
- typeof slide === "number" &&
128
- Number.isFinite(slide) &&
129
- typeof step === "number" &&
130
- Number.isFinite(step)
131
- );
172
+ if (
173
+ typeof slide !== "number" ||
174
+ !Number.isFinite(slide) ||
175
+ typeof step !== "number" ||
176
+ !Number.isFinite(step)
177
+ ) {
178
+ return false;
179
+ }
180
+
181
+ const colorMode = (value as { colorMode?: unknown }).colorMode;
182
+ return colorMode === undefined || isColorModeValue({ colorMode });
183
+ }
184
+
185
+ function isBlankScreenValue(
186
+ value: unknown,
187
+ ): value is { mode: "black" | "off" } {
188
+ if (typeof value !== "object" || value === null) return false;
189
+ const mode = (value as { mode?: unknown }).mode;
190
+ return mode === "black" || mode === "off";
132
191
  }
133
192
 
134
193
  function isRouteSyncMessage(
@@ -137,6 +196,18 @@ function isRouteSyncMessage(
137
196
  return message.type === "navigate" || message.type === "sync-response";
138
197
  }
139
198
 
199
+ function isColorModeSyncMessage(
200
+ message: SyncMessage,
201
+ ): message is SyncColorModeMessage {
202
+ return message.type === "color-mode";
203
+ }
204
+
205
+ function isBlankScreenSyncMessage(
206
+ message: SyncMessage,
207
+ ): message is SyncBlankScreenMessage {
208
+ return message.type === "blank-screen";
209
+ }
210
+
140
211
  // ---------------------------------------------------------------------------
141
212
  // Hook
142
213
  // ---------------------------------------------------------------------------
@@ -144,21 +215,31 @@ function isRouteSyncMessage(
144
215
  /**
145
216
  * Bidirectional sync hook.
146
217
  *
147
- * @returns `{ presenterConnected }` — true when an audience window has
148
- * detected that a presenter window is open on the same channel.
218
+ * @returns `{ presenterConnected, broadcastBlankScreen }` — presenterConnected is
219
+ * true when an audience window has detected that a presenter window is open on
220
+ * the same channel. broadcastBlankScreen sends a blank-screen command.
149
221
  */
150
222
  export function useSync({
151
223
  enabled = true,
152
224
  isPresenter,
153
225
  currentSlide,
154
226
  currentStep,
155
- }: UseSyncOptions): { presenterConnected: boolean } {
227
+ currentColorMode,
228
+ onSetColorMode,
229
+ onBlankScreen,
230
+ }: UseSyncOptions): {
231
+ presenterConnected: boolean;
232
+ broadcastBlankScreen: (mode: "black" | "off") => void;
233
+ } {
156
234
  const [presenterConnected, setPresenterConnected] = useState(false);
157
235
  const channelRef = useRef<BroadcastChannel | null>(null);
158
236
  const presenterRouteRef = useRef<PresenterRoute>({
159
237
  slide: currentSlide ?? 1,
160
238
  step: currentStep ?? 0,
161
239
  });
240
+ const presenterColorModeRef = useRef<ColorMode | undefined>(currentColorMode);
241
+ const onBlankScreenRef = useRef(onBlankScreen);
242
+ onBlankScreenRef.current = onBlankScreen;
162
243
 
163
244
  useEffect(() => {
164
245
  if (!isPresenter) return;
@@ -170,6 +251,11 @@ export function useSync({
170
251
  };
171
252
  }, [currentSlide, currentStep, isPresenter]);
172
253
 
254
+ useEffect(() => {
255
+ if (!isPresenter) return;
256
+ presenterColorModeRef.current = currentColorMode;
257
+ }, [currentColorMode, isPresenter]);
258
+
173
259
  // ── Channel lifecycle ───────────────────────────────────────────────────
174
260
 
175
261
  useEffect(() => {
@@ -199,6 +285,7 @@ export function useSync({
199
285
  channel.postMessage(
200
286
  createSyncResponseMessage(
201
287
  presenterRouteRef.current,
288
+ presenterColorModeRef.current,
202
289
  ) satisfies SyncMessage,
203
290
  );
204
291
  }
@@ -207,6 +294,7 @@ export function useSync({
207
294
 
208
295
  if (msg.type === "presenter-disconnected") {
209
296
  setPresenterConnected(false);
297
+ onBlankScreenRef.current?.("off");
210
298
  return;
211
299
  }
212
300
 
@@ -215,8 +303,23 @@ export function useSync({
215
303
  return;
216
304
  }
217
305
 
306
+ if (isColorModeSyncMessage(msg)) {
307
+ setPresenterConnected(true);
308
+ onSetColorMode?.(msg.colorMode);
309
+ return;
310
+ }
311
+
312
+ if (isBlankScreenSyncMessage(msg)) {
313
+ setPresenterConnected(true);
314
+ onBlankScreenRef.current?.(msg.mode);
315
+ return;
316
+ }
317
+
218
318
  if (isRouteSyncMessage(msg)) {
219
319
  setPresenterConnected(true);
320
+ if (msg.type === "sync-response" && msg.colorMode) {
321
+ onSetColorMode?.(msg.colorMode);
322
+ }
220
323
  const currentRoute = parseHash(location.hash);
221
324
  const nextRoute = resolveAudienceRouteFromSyncMessage(
222
325
  currentRoute,
@@ -241,7 +344,7 @@ export function useSync({
241
344
  channel.close();
242
345
  channelRef.current = null;
243
346
  };
244
- }, [enabled, isPresenter]);
347
+ }, [enabled, isPresenter, onSetColorMode]);
245
348
 
246
349
  // ── Presenter: broadcast navigation changes ─────────────────────────────
247
350
 
@@ -263,5 +366,20 @@ export function useSync({
263
366
  } satisfies SyncMessage);
264
367
  }, [enabled, isPresenter, currentSlide, currentStep]);
265
368
 
266
- return { presenterConnected };
369
+ useEffect(() => {
370
+ if (!enabled || !isPresenter || !currentColorMode) return;
371
+
372
+ presenterColorModeRef.current = currentColorMode;
373
+ channelRef.current?.postMessage(
374
+ createSyncColorModeMessage(currentColorMode) satisfies SyncMessage,
375
+ );
376
+ }, [enabled, isPresenter, currentColorMode]);
377
+
378
+ const broadcastBlankScreen = useCallback((mode: "black" | "off") => {
379
+ channelRef.current?.postMessage(
380
+ createSyncBlankScreenMessage(mode) satisfies SyncMessage,
381
+ );
382
+ }, []);
383
+
384
+ return { presenterConnected, broadcastBlankScreen };
267
385
  }
@@ -1,5 +1,8 @@
1
1
  import { CastIcon, StopCircleIcon } from "lucide-react";
2
2
 
3
+ const UNSUPPORTED_HINT =
4
+ "Presentation casting is not supported in this browser";
5
+
3
6
  export function PresenterCastButton({
4
7
  supported,
5
8
  isCasting,
@@ -12,20 +15,25 @@ export function PresenterCastButton({
12
15
  onStopCasting: () => void;
13
16
  }) {
14
17
  const label = isCasting ? "Stop casting" : "Cast audience view";
15
- const unsupportedHint =
16
- "Presentation casting is not supported in this browser";
18
+ const title = supported ? label : UNSUPPORTED_HINT;
19
+
20
+ function handleClick() {
21
+ if (!supported) return;
22
+ if (isCasting) onStopCasting();
23
+ else void onStartCasting();
24
+ }
17
25
 
18
26
  return (
19
27
  <button
20
28
  type="button"
21
- onClick={isCasting ? onStopCasting : onStartCasting}
22
- disabled={!supported}
23
- title={supported ? label : unsupportedHint}
24
- aria-label={supported ? label : unsupportedHint}
25
- className={`px-3 py-1 rounded border border-white/20 text-white/80 text-sm font-[inherit] inline-flex items-center gap-1.5 ${
29
+ onClick={handleClick}
30
+ title={title}
31
+ aria-label={title}
32
+ aria-disabled={!supported}
33
+ className={`px-3 py-1 rounded border text-sm font-[inherit] inline-flex items-center gap-1.5 transition-[background,color,border-color,opacity] ${
26
34
  supported
27
- ? "bg-white/6"
28
- : "bg-white/4 text-white/30 border-white/10 cursor-not-allowed"
35
+ ? "border-white/20 bg-white/6 text-white/80 hover:bg-white/10"
36
+ : "border-white/10 bg-white/3 text-white/25 opacity-60 grayscale cursor-not-allowed"
29
37
  }`}
30
38
  >
31
39
  {label}
@@ -11,7 +11,7 @@
11
11
  * │ │ (large) │ ├──────────┤ │
12
12
  * │ │ │ │ Notes │ │
13
13
  * │ └──────────────────────┘ └──────────┘ │
14
- * │ Slide 3/12 · Step 2/4 12:34 [Open] │
14
+ * │ Slide 3/12 · Step 2/4 12:34 Timer 1:23 [Open] │
15
15
  * └──────────────────────────────────────────┘
16
16
  *
17
17
  * ### Notes collection
@@ -35,6 +35,11 @@ import {
35
35
  ChevronRightIcon,
36
36
  ChevronUpIcon,
37
37
  ExternalLinkIcon,
38
+ MonitorOffIcon,
39
+ PauseIcon,
40
+ PlayIcon,
41
+ RotateCcwIcon,
42
+ XIcon,
38
43
  } from "lucide-react";
39
44
  import {
40
45
  type ReactNode,
@@ -44,8 +49,14 @@ import {
44
49
  useRef,
45
50
  useState,
46
51
  } from "react";
52
+ import {
53
+ type ColorMode,
54
+ ColorModeCycleButton,
55
+ } from "../components/ColorModeCycleButton.tsx";
47
56
  import { NotesContext } from "../components/Notes.tsx";
48
57
  import {
58
+ getSlideRouteFromRoute,
59
+ navigateTo,
49
60
  nextSlide,
50
61
  nextStep,
51
62
  openUrlInNewTab,
@@ -65,6 +76,7 @@ import { useSwipeNav } from "../useSwipeNav.ts";
65
76
  import { PresenterCastButton } from "./PresenterCastButton.tsx";
66
77
  import { PresenterNotesPanel } from "./PresenterNotesPanel.tsx";
67
78
  import { getPresenterNextPreview } from "./presenterPreview.ts";
79
+ import { formatPresenterElapsedTime } from "./presenterTime.ts";
68
80
 
69
81
  // ---------------------------------------------------------------------------
70
82
  // Helpers
@@ -163,14 +175,27 @@ function usePresenterMobile(): boolean {
163
175
  // Component
164
176
  // ---------------------------------------------------------------------------
165
177
 
166
- export function PresenterView() {
178
+ type PresenterViewProps = {
179
+ colorMode: ColorMode;
180
+ onSetColorMode: (mode: ColorMode) => void;
181
+ };
182
+
183
+ export function PresenterView({
184
+ colorMode,
185
+ onSetColorMode,
186
+ }: PresenterViewProps) {
167
187
  const route = useRoute();
168
188
  const totalSlides = slideData.length;
169
189
  const slide = Math.max(1, Math.min(route.slide, totalSlides || 1));
170
190
  const step = Math.max(0, route.step);
171
191
 
172
192
  const [clock, setClock] = useState(() => new Date().toLocaleTimeString());
193
+ const [timerState, setTimerState] = useState<"idle" | "running" | "paused">(
194
+ "idle",
195
+ );
196
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
173
197
  const [notes, setNotes] = useState<ReactNode>(null);
198
+ const [isBlankScreen, setIsBlankScreen] = useState(false);
174
199
  const notesContextValue = useMemo(() => ({ setNotes }), []);
175
200
  const isMobile = usePresenterMobile();
176
201
 
@@ -186,18 +211,27 @@ export function PresenterView() {
186
211
  audienceUrl: getPresentationAudienceUrl({ view: "slide", slide, step }),
187
212
  currentSlide: slide,
188
213
  currentStep: step,
214
+ currentColorMode: colorMode,
189
215
  });
216
+ const sendCastMessage = presentationCast.sendMessage;
217
+ const elapsedTime = formatPresenterElapsedTime(elapsedSeconds * 1000);
190
218
 
191
- // ── Wall clock ─────────────────────────────────────────────────────────
219
+ // ── Wall clock + elapsed timer ─────────────────────────────────────────
192
220
  useEffect(() => {
193
221
  const timer = setInterval(() => {
194
222
  setClock(new Date().toLocaleTimeString());
223
+ if (timerState === "running") setElapsedSeconds((value) => value + 1);
195
224
  }, 1000);
196
225
  return () => clearInterval(timer);
197
- }, []);
226
+ }, [timerState]);
198
227
 
199
228
  // ── BroadcastChannel: this window IS the presenter (controller) ─────────
200
- useSync({ isPresenter: true, currentSlide: slide, currentStep: step });
229
+ const { broadcastBlankScreen } = useSync({
230
+ isPresenter: true,
231
+ currentSlide: slide,
232
+ currentStep: step,
233
+ currentColorMode: colorMode,
234
+ });
201
235
 
202
236
  // ── Keyboard navigation ─────────────────────────────────────────────────
203
237
  const getStepCount = useCallback(
@@ -211,6 +245,60 @@ export function PresenterView() {
211
245
  });
212
246
  useSwipeNav({ enabled: isMobile });
213
247
 
248
+ // ── Presenter exit + blank screen shortcuts ─────────────────────────────
249
+ const closePresenter = useCallback(() => {
250
+ navigateTo(getSlideRouteFromRoute({ view: "presenter", slide, step }));
251
+ }, [slide, step]);
252
+
253
+ const toggleBlankScreen = useCallback(() => {
254
+ const next = !isBlankScreen;
255
+ setIsBlankScreen(next);
256
+ const mode = next ? "black" : "off";
257
+ broadcastBlankScreen(mode);
258
+ sendCastMessage({ type: "blank-screen", mode });
259
+ }, [isBlankScreen, broadcastBlankScreen, sendCastMessage]);
260
+
261
+ useEffect(() => {
262
+ function handlePresenterKeys(event: KeyboardEvent) {
263
+ if (event.key === "p" || event.key === "Escape") {
264
+ event.preventDefault();
265
+ closePresenter();
266
+ return;
267
+ }
268
+
269
+ if (event.key === "b" || event.key === "B") {
270
+ event.preventDefault();
271
+ toggleBlankScreen();
272
+ }
273
+ }
274
+
275
+ window.addEventListener("keydown", handlePresenterKeys);
276
+ return () => window.removeEventListener("keydown", handlePresenterKeys);
277
+ }, [closePresenter, toggleBlankScreen]);
278
+
279
+ // ── Timer controls ──────────────────────────────────────────────────────
280
+ function startTimer() {
281
+ setTimerState("running");
282
+ }
283
+
284
+ function pauseTimer() {
285
+ setTimerState("paused");
286
+ }
287
+
288
+ function continueTimer() {
289
+ setTimerState("running");
290
+ }
291
+
292
+ function restartTimer() {
293
+ setElapsedSeconds(0);
294
+ setTimerState("running");
295
+ }
296
+
297
+ function closeTimer() {
298
+ setElapsedSeconds(0);
299
+ setTimerState("idle");
300
+ }
301
+
214
302
  // ── Open audience window ────────────────────────────────────────────────
215
303
  function openAudienceView() {
216
304
  const url = getPresentationAudienceUrl({ view: "slide", slide, step });
@@ -246,6 +334,13 @@ export function PresenterView() {
246
334
 
247
335
  return (
248
336
  <div className="fixed inset-0 bg-black text-white font-sans grid grid-rows-[1fr_auto_auto] grid-cols-1 overflow-hidden select-none">
337
+ {/* ── Blank screen indicator overlay ────────────────────────────── */}
338
+ {isBlankScreen && (
339
+ <div className="absolute top-4 left-1/2 -translate-x-1/2 z-10 px-4 py-2 rounded-md bg-red-600/90 text-white text-sm font-semibold tracking-wide uppercase">
340
+ Screen blanked (b)
341
+ </div>
342
+ )}
343
+
249
344
  {/* ── Top section: current slide plus desktop next/notes column ─── */}
250
345
  <div
251
346
  className={
@@ -284,33 +379,152 @@ export function PresenterView() {
284
379
  />
285
380
  )}
286
381
 
287
- {/* ── Bottom bar: counter · fixed nav buttons · clock/actions ───── */}
288
- <div className="grid grid-cols-[minmax(0,1fr)_auto] sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3 sm:px-5 py-2.5 border-t border-white/8 bg-black/30 gap-x-3 sm:gap-x-4 gap-y-2">
289
- {/* Counter */}
290
- <div className="min-w-0 text-md text-white/60 tabular-nums truncate">
291
- Slide {slide}/{totalSlides}
292
- {currentStepCount > 0 && ` · Step ${step}/${currentStepCount}`}
382
+ {/* ── Bottom bar: counter · mobile nav buttons · timer/actions ──── */}
383
+ <div
384
+ className={
385
+ isMobile
386
+ ? "grid grid-cols-[minmax(0,1fr)_auto] items-center px-3 py-2.5 border-t border-white/8 bg-black/30 gap-x-3 gap-y-2"
387
+ : "grid grid-cols-[minmax(0,1fr)_auto] items-center px-5 py-2.5 border-t border-white/8 bg-black/30 gap-x-4 gap-y-2"
388
+ }
389
+ >
390
+ {/* Counter + timer */}
391
+ <div className="min-w-0 flex flex-wrap items-center gap-3 text-md text-white/60 tabular-nums">
392
+ <span className="truncate">
393
+ Slide {slide}/{totalSlides}
394
+ {currentStepCount > 0 && ` · Step ${step}/${currentStepCount}`}
395
+ </span>
396
+ {timerState === "idle" && (
397
+ <button
398
+ type="button"
399
+ onClick={startTimer}
400
+ title="Start timer"
401
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded bg-white/6 text-white/50 hover:text-white/80 text-sm"
402
+ >
403
+ <PlayIcon aria-hidden="true" size={14} />
404
+ Start timer
405
+ </button>
406
+ )}
407
+ {timerState === "running" && (
408
+ <>
409
+ <button
410
+ type="button"
411
+ onClick={pauseTimer}
412
+ title="Pause timer"
413
+ className="text-lg font-semibold text-white tabular-nums bg-white/10 px-2.5 py-0.5 rounded"
414
+ >
415
+ ⏱ {elapsedTime}
416
+ </button>
417
+ <button
418
+ type="button"
419
+ onClick={pauseTimer}
420
+ title="Pause timer"
421
+ aria-label="Pause timer"
422
+ className="text-white/50 hover:text-white/80 inline-flex"
423
+ >
424
+ <PauseIcon aria-hidden="true" size={16} />
425
+ </button>
426
+ </>
427
+ )}
428
+ {timerState === "paused" && (
429
+ <>
430
+ <span className="text-lg font-semibold text-white/60 tabular-nums bg-white/6 px-2.5 py-0.5 rounded">
431
+ ⏱ {elapsedTime}
432
+ </span>
433
+ <button
434
+ type="button"
435
+ onClick={continueTimer}
436
+ title="Continue timer"
437
+ aria-label="Continue timer"
438
+ className="text-white/50 hover:text-white/80 inline-flex"
439
+ >
440
+ <PlayIcon aria-hidden="true" size={16} />
441
+ </button>
442
+ <button
443
+ type="button"
444
+ onClick={restartTimer}
445
+ title="Restart timer from zero"
446
+ aria-label="Restart timer from zero"
447
+ className="text-white/30 hover:text-white/60 inline-flex"
448
+ >
449
+ <RotateCcwIcon aria-hidden="true" size={15} />
450
+ </button>
451
+ <button
452
+ type="button"
453
+ onClick={closeTimer}
454
+ title="Close timer"
455
+ aria-label="Close timer"
456
+ className="text-white/30 hover:text-white/60 inline-flex"
457
+ >
458
+ <XIcon aria-hidden="true" size={16} />
459
+ </button>
460
+ </>
461
+ )}
293
462
  </div>
294
463
 
295
- {/* Nav buttons */}
296
- <div className="flex gap-2 items-center justify-self-end sm:justify-self-center">
297
- <NavButton onClick={goPrevSlide} title="Previous slide (↑)">
298
- <ChevronUpIcon aria-hidden="true" size={18} />
299
- </NavButton>
300
- <NavButton onClick={goPrev} title="Previous step (←)">
301
- <ChevronLeftIcon aria-hidden="true" size={18} />
302
- </NavButton>
303
- <NavButton onClick={goNext} title="Next step (→)">
304
- <ChevronRightIcon aria-hidden="true" size={18} />
305
- </NavButton>
306
- <NavButton onClick={goNextSlide} title="Next slide (↓)">
307
- <ChevronDownIcon aria-hidden="true" size={18} />
308
- </NavButton>
309
- </div>
464
+ {/* Nav buttons are mobile-only; desktop uses keyboard shortcuts. */}
465
+ {isMobile && (
466
+ <div className="order-last col-span-2 flex w-full gap-2 items-center justify-center">
467
+ <NavButton
468
+ onClick={goPrev}
469
+ title="Previous step (←)"
470
+ className="h-12 w-16"
471
+ >
472
+ <ChevronLeftIcon aria-hidden="true" className="h-6 w-6" />
473
+ </NavButton>
474
+ <NavButton
475
+ onClick={goPrevSlide}
476
+ title="Previous slide (↑)"
477
+ className="h-12 w-16"
478
+ >
479
+ <ChevronUpIcon aria-hidden="true" className="h-6 w-6" />
480
+ </NavButton>
481
+ <NavButton
482
+ onClick={goNextSlide}
483
+ title="Next slide (↓)"
484
+ className="h-12 w-16"
485
+ >
486
+ <ChevronDownIcon aria-hidden="true" className="h-6 w-6" />
487
+ </NavButton>
488
+ <NavButton
489
+ onClick={goNext}
490
+ title="Next step (→)"
491
+ className="h-12 w-16"
492
+ >
493
+ <ChevronRightIcon aria-hidden="true" className="h-6 w-6" />
494
+ </NavButton>
495
+ </div>
496
+ )}
310
497
 
311
- {/* Clock + open button */}
312
- <div className="col-span-2 sm:col-span-1 sm:col-start-3 flex flex-wrap gap-3 items-center justify-self-start sm:justify-self-end">
313
- <span className="text-md tabular-nums text-white/60">{clock}</span>
498
+ {/* Clock + mode/open/cast buttons */}
499
+ <div
500
+ className={
501
+ isMobile
502
+ ? "col-span-2 flex flex-wrap gap-3 items-center justify-self-start"
503
+ : "flex flex-wrap gap-3 items-center justify-self-end"
504
+ }
505
+ >
506
+ <span
507
+ className="text-md tabular-nums text-white/60"
508
+ title="Wall clock"
509
+ >
510
+ {clock}
511
+ </span>
512
+ <ColorModeCycleButton
513
+ colorMode={colorMode}
514
+ onSetColorMode={onSetColorMode}
515
+ iconSize={14}
516
+ className="w-8 h-8 rounded border border-white/20 bg-white/6 text-white/80 inline-flex items-center justify-center"
517
+ />
518
+ <NavButton
519
+ onClick={toggleBlankScreen}
520
+ title={isBlankScreen ? "Unblank screen (b)" : "Blank screen (b)"}
521
+ >
522
+ <MonitorOffIcon
523
+ aria-hidden="true"
524
+ size={16}
525
+ className={isBlankScreen ? "text-red-400" : ""}
526
+ />
527
+ </NavButton>
314
528
  <button
315
529
  type="button"
316
530
  onClick={openAudienceView}
@@ -338,10 +552,12 @@ export function PresenterView() {
338
552
  function NavButton({
339
553
  onClick,
340
554
  title,
555
+ className,
341
556
  children,
342
557
  }: {
343
558
  onClick: () => void;
344
559
  title?: string;
560
+ className?: string;
345
561
  children: ReactNode;
346
562
  }) {
347
563
  return (
@@ -349,7 +565,8 @@ function NavButton({
349
565
  type="button"
350
566
  onClick={onClick}
351
567
  title={title}
352
- className="w-9 h-9 rounded border border-white/15 bg-white/6 text-white/75 text-md flex items-center justify-center font-[inherit]"
568
+ aria-label={title}
569
+ className={`w-9 h-9 rounded border border-white/15 bg-white/6 text-white/75 text-md flex items-center justify-center font-[inherit] ${className ?? ""}`}
353
570
  >
354
571
  {children}
355
572
  </button>
@@ -10,6 +10,11 @@
10
10
  - Navigation controls button
11
11
  - Direct URL: `/#/presenter/1/0`
12
12
 
13
+ ### Deactivation
14
+
15
+ - Keyboard shortcut `p` exits presenter mode back to audience slide view at the same slide/step.
16
+ - Keyboard shortcut `Escape` exits presenter mode back to audience slide view at the same slide/step.
17
+
13
18
  ### UI Layout
14
19
 
15
20
  ```txt
@@ -25,7 +30,7 @@
25
30
  │ - Remember to demo the sparkle button │
26
31
  │ - Mention PDF export │
27
32
  │ │
28
- │ Slide 3/12 · Step 2/4 12:34 [Open]
33
+ │ Slide 3/12 · Step 2/4 12:34 Timer 1:23 [Open]
29
34
  └──────────────────────────────────────────┘
30
35
  ```
31
36
 
@@ -35,9 +40,12 @@ Includes:
35
40
  - Speaker notes for current slide, with Markdown formatting from `<Notes>` rendered as compact presenter prose
36
41
  - Slide/step counter
37
42
  - Clock (wall clock)
43
+ - Elapsed presentation timer sits next to the slide/step counter on the left side of the bottom bar and has idle/running/paused states. Idle shows a start action. Running shows a prominent elapsed display plus pause action. Paused shows elapsed time plus continue, restart-from-zero, and close/reset actions.
38
44
  - Button to open audience view in new tab, preserving the current slide/step and deck base path
39
- - Button to cast the audience view to a secondary display when the Presentation API is supported; unsupported browsers show a disabled hint and active casting can be stopped from the same control
40
- - Presenter navigation buttons provide previous/next timeline-step navigation and previous/next slide navigation. Timeline keyboard shortcuts (`→`/`←`/`↓`/`↑`, `d`/`a`/`s`/`w`) update the presenter route and keep the window in presenter mode.
45
+ - Color mode cycle button (system light dark system). Presenter color mode changes also sync to BroadcastChannel audience views and Presentation API cast receivers.
46
+ - Blank screen toggle button and `b` keyboard shortcut. While blanked, the presenter sees a `Screen blanked (b)` indicator and audience/cast views see a black screen.
47
+ - Button to cast the audience view to a secondary display when the Presentation API is supported. Unsupported browsers keep a visibly disabled-looking control in the action row; it does not render extra inline feedback, and its hover title/accessible label explains that Presentation API casting is unavailable. Active casting can be stopped from the same control.
48
+ - Presenter navigation buttons provide previous/next timeline-step navigation and previous/next slide navigation on mobile presenter layouts. Desktop presenter layouts do not show navigation buttons. On mobile, the timeline-step buttons sit on the outside edges of the button group (previous step, previous slide, next slide, next step), because step navigation is the primary/default action. Timeline keyboard shortcuts (`→`/`←`/`↓`/`↑`, `d`/`a`/`s`/`w`) update the presenter route and keep the window in presenter mode.
41
49
  - Presenter navigation uses the shared Honeydeck navigation command abstraction so button, keyboard, and touch inputs share the same semantics as audience view.
42
50
  - Presenter notes are scroll-owned regions: wheel, trackpad, touch scroll, and swipe gestures that start in notes scroll notes and never navigate slides, even at scroll boundaries.
43
51
  - On mobile presenter layouts, the Current preview may use tap zones and swipe navigation; speaker notes remain scroll-only. Pinch-to-zoom and pinch-to-overview are not required in presenter mode.
@@ -47,7 +55,22 @@ Includes:
47
55
 
48
56
  ### Presenter Responsiveness
49
57
 
50
- Presenter mode uses a two-column preview area (`Current` larger, `Next` smaller), a notes panel, and a bottom status/action bar on desktop. On narrow/mobile screens it switches to a single-column layout and hides the Next preview.
58
+ Presenter mode uses a two-column preview area (`Current` larger, `Next` smaller), a notes panel, and a bottom status/action bar on desktop. On narrow/mobile screens it switches to a single-column layout and hides the Next preview. The navigation button group is the bottom-most element on mobile and uses larger touch targets than desktop.
59
+
60
+ ### Presentation Timer
61
+
62
+ - Idle state shows a `Start timer` button.
63
+ - Starting changes to running state, displaying elapsed time as `MM:SS` or `H:MM:SS`.
64
+ - In running state, clicking the elapsed time or pause action pauses the timer.
65
+ - In paused state, controls let the presenter continue, restart from zero, or close/reset the timer back to idle.
66
+
67
+ ### Blank Screen
68
+
69
+ - Pressing `b` toggles the audience screen to black; pressing `b` again restores the normal view.
70
+ - A blank screen button in the bottom action bar provides the same toggle.
71
+ - While blanked, the presenter sees a `Screen blanked (b)` indicator overlay.
72
+ - The blank-screen state is broadcast via both `BroadcastChannel` and the Presentation API cast connection as a `blank-screen` sync message with `mode: "black" | "off"`.
73
+ - When the presenter disconnects, audience windows automatically unblank.
51
74
 
52
75
  ### Audience Sync
53
76
 
@@ -55,7 +78,7 @@ Presenter mode and audience view synchronize via `BroadcastChannel` and the Pres
55
78
 
56
79
  - Same browser/profile BroadcastChannel sync remains available when casting is unsupported or unavailable
57
80
  - Presenter mode is the controller
58
- - Audience view listens for navigation updates
81
+ - Audience view listens for navigation updates, presenter color mode changes, and blank-screen commands
59
82
  - Late-opening audience tabs request the current presenter position via a `sync-request` / `sync-response` handshake as soon as a receiver connection is available, so they sync immediately instead of waiting for the next presenter move
60
83
  - Presence messages (`presenter-connected` / `presenter-disconnected`) are broadcast
61
84
  - When the Presentation API is supported, presenter mode can cast the audience view to a secondary display; the receiver asks for the current route when a connection becomes available and presenter replies with a `sync-response` so the cast audience resyncs even if it missed the first `navigate` message
@@ -0,0 +1,15 @@
1
+ export function formatPresenterElapsedTime(elapsedMs: number): string {
2
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
3
+ const seconds = totalSeconds % 60;
4
+ const totalMinutes = Math.floor(totalSeconds / 60);
5
+ const minutes = totalMinutes % 60;
6
+ const hours = Math.floor(totalMinutes / 60);
7
+
8
+ if (hours > 0) {
9
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
10
+ .toString()
11
+ .padStart(2, "0")}`;
12
+ }
13
+
14
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
15
+ }