@honeydeck/honeydeck 0.5.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.
Files changed (40) hide show
  1. package/AGENTS.md +4 -4
  2. package/DEVELOPMENT.md +2 -3
  3. package/Readme.md +15 -15
  4. package/SPEC.md +2 -1
  5. package/docs/{components-browser-frame.md → browser-frame.md} +4 -0
  6. package/docs/components.md +12 -12
  7. package/docs/configuration.md +2 -0
  8. package/docs/customization.md +2 -0
  9. package/docs/deeper-dive.md +2 -0
  10. package/docs/getting-started.md +2 -0
  11. package/docs/index.json +258 -0
  12. package/docs/{components-keyboard.md → keyboard.md} +4 -0
  13. package/docs/{components-list-style.md → list-style.md} +4 -0
  14. package/docs/local-development.md +3 -1
  15. package/docs/mermaid.md +2 -0
  16. package/docs/mobile.md +3 -1
  17. package/docs/navigation.md +2 -0
  18. package/docs/{components-notes.md → notes.md} +4 -0
  19. package/docs/pdf-export.md +2 -0
  20. package/docs/presenter-mode.md +14 -6
  21. package/docs/{components-reveal-group.md → reveal-group.md} +2 -0
  22. package/docs/{components-reveal-with.md → reveal-with.md} +2 -0
  23. package/docs/{components-reveal.md → reveal.md} +5 -3
  24. package/docs/skills.md +3 -1
  25. package/docs/slides.md +2 -0
  26. package/docs/slidev-migration.md +2 -0
  27. package/docs/steps-and-reveals.md +2 -0
  28. package/docs/{components-timeline-steps.md → timeline-steps.md} +2 -0
  29. package/package.json +3 -2
  30. package/skills/SPEC.md +4 -4
  31. package/skills/honeydeck/SKILL.md +7 -7
  32. package/skills/slidev-migration/SKILL.md +6 -6
  33. package/src/runtime/Deck.tsx +18 -1
  34. package/src/runtime/components/SPEC.md +3 -3
  35. package/src/runtime/presentationApi.ts +112 -6
  36. package/src/runtime/sync.ts +130 -12
  37. package/src/runtime/views/PresenterCastButton.tsx +17 -9
  38. package/src/runtime/views/PresenterView.tsx +247 -30
  39. package/src/runtime/views/SPEC.md +28 -5
  40. package/src/runtime/views/presenterTime.ts +15 -0
@@ -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>