@kywk/open-slide-core 2.3.2 → 2.3.3

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.
@@ -1,4 +1,4 @@
1
- import { t as Locale } from "./types-DFK02XVv.js";
1
+ import { t as Locale } from "./types-tdaXSyah.js";
2
2
  //#region src/config.d.ts
3
3
  type OpenSlideBuildConfig = {
4
4
  showSlideBrowser?: boolean;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { n as Plural, t as Locale } from "./types-DFK02XVv.js";
2
- import { n as OpenSlideConfig, t as OpenSlideAgentConfig } from "./config-CDrYgaEj.js";
1
+ import { n as Plural, t as Locale } from "./types-tdaXSyah.js";
2
+ import { n as OpenSlideConfig, t as OpenSlideAgentConfig } from "./config-DJ8ZOe7C.js";
3
3
  import { CSSProperties, ComponentType, HTMLAttributes, PropsWithChildren, ReactNode } from "react";
4
4
  //#region src/app/components/image-placeholder.d.ts
5
5
  type ImagePlaceholderProps = {
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as en, i as es, n as zhCN, r as ja, t as zhTW } from "./zh-tw-B1pkKQsc.js";
1
+ import { a as en, i as es, n as zhCN, r as ja, t as zhTW } from "./zh-tw-DXcqQTrA.js";
2
2
  import { n as defaultDesign, r as designToCssVars, t as cssVarsToString } from "./design-CoZ0cWDN.js";
3
3
  import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { toast } from "sonner";
@@ -1,4 +1,4 @@
1
- import { n as Plural, t as Locale } from "../types-DFK02XVv.js";
1
+ import { n as Plural, t as Locale } from "../types-tdaXSyah.js";
2
2
  //#region src/locale/en.d.ts
3
3
  declare const en: Locale;
4
4
  //#endregion
@@ -1,4 +1,4 @@
1
- import { a as en, i as es, n as zhCN, r as ja, t as zhTW } from "../zh-tw-B1pkKQsc.js";
1
+ import { a as en, i as es, n as zhCN, r as ja, t as zhTW } from "../zh-tw-DXcqQTrA.js";
2
2
  //#region src/locale/format.ts
3
3
  function format(template, vars) {
4
4
  return template.replace(/\{(\w+)\}/g, (m, key) => {
@@ -181,6 +181,7 @@ type Locale = {
181
181
  helpAria: string;
182
182
  exitAria: string;
183
183
  elapsedTime: string;
184
+ lastSlideHint: string;
184
185
  helpEyebrow: string;
185
186
  helpTitle: string;
186
187
  shortcutNext: string;
@@ -1,4 +1,4 @@
1
- import { n as OpenSlideConfig } from "../config-CDrYgaEj.js";
1
+ import { n as OpenSlideConfig } from "../config-DJ8ZOe7C.js";
2
2
  import { InlineConfig } from "vite";
3
3
  //#region src/vite/config.d.ts
4
4
  type CreateViteConfigOptions = {
@@ -168,6 +168,7 @@ const en = {
168
168
  helpAria: "Keyboard shortcuts (?)",
169
169
  exitAria: "Exit (Esc)",
170
170
  elapsedTime: "Elapsed time",
171
+ lastSlideHint: "Last slide · Press Esc to exit",
171
172
  helpEyebrow: "Present mode",
172
173
  helpTitle: "Keyboard shortcuts",
173
174
  shortcutNext: "Next slide",
@@ -796,6 +797,7 @@ const es = {
796
797
  helpAria: "Atajos de teclado (?)",
797
798
  exitAria: "Salir (Esc)",
798
799
  elapsedTime: "Tiempo transcurrido",
800
+ lastSlideHint: "Última diapositiva · Pulsa Esc para salir",
799
801
  helpEyebrow: "Modo presentación",
800
802
  helpTitle: "Atajos de teclado",
801
803
  shortcutNext: "Diapositiva siguiente",
@@ -1424,6 +1426,7 @@ const ja = {
1424
1426
  helpAria: "キーボードショートカット (?)",
1425
1427
  exitAria: "終了 (Esc)",
1426
1428
  elapsedTime: "経過時間",
1429
+ lastSlideHint: "最後のスライドです · Esc で終了",
1427
1430
  helpEyebrow: "発表モード",
1428
1431
  helpTitle: "キーボードショートカット",
1429
1432
  shortcutNext: "次のスライド",
@@ -2052,6 +2055,7 @@ const zhCN = {
2052
2055
  helpAria: "键盘快捷键 (?)",
2053
2056
  exitAria: "退出 (Esc)",
2054
2057
  elapsedTime: "已用时",
2058
+ lastSlideHint: "已是最后一页 · 按 Esc 退出放映",
2055
2059
  helpEyebrow: "演示模式",
2056
2060
  helpTitle: "键盘快捷键",
2057
2061
  shortcutNext: "下一张",
@@ -2680,6 +2684,7 @@ const zhTW = {
2680
2684
  helpAria: "鍵盤快速鍵 (?)",
2681
2685
  exitAria: "離開 (Esc)",
2682
2686
  elapsedTime: "已耗時",
2687
+ lastSlideHint: "已是最後一頁 · 按 Esc 結束播放",
2683
2688
  helpEyebrow: "簡報模式",
2684
2689
  helpTitle: "鍵盤快速鍵",
2685
2690
  shortcutNext: "下一張",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kywk/open-slide-core",
3
- "version": "2.3.2",
3
+ "version": "2.3.3",
4
4
  "description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -106,7 +106,7 @@
106
106
  },
107
107
  "devDependencies": {
108
108
  "@playwright/test": "~1.63.0",
109
- "@types/node": "^26.6.2",
109
+ "@types/node": "^26.6.6",
110
110
  "@types/react": "^19.2.18",
111
111
  "@types/react-dom": "^19.2.5",
112
112
  "tsdown": "^0.22.14",
@@ -12,9 +12,16 @@ import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion';
12
12
  import { OverviewGrid } from './overview-grid';
13
13
  import { PresentBlackoutOverlay } from './present/blackout-overlay';
14
14
  import { PresentControlBar } from './present/control-bar';
15
+ import {
16
+ afterPaint,
17
+ CURTAIN_FADE_MS,
18
+ PresentCurtain,
19
+ whenFullscreenSettled,
20
+ } from './present/curtain';
15
21
  import { PresentHelpOverlay } from './present/help-overlay';
16
22
  import { PresentJumpInput } from './present/jump-input';
17
23
  import { PresentLaserPointer } from './present/laser-pointer';
24
+ import { PresentLastSlideHint } from './present/last-slide-hint';
18
25
  import { PresentProgressBar } from './present/progress-bar';
19
26
  import { useIdle } from './present/use-idle';
20
27
  import { usePointerNearBottom } from './present/use-pointer-near-bottom';
@@ -48,6 +55,8 @@ type Props = {
48
55
  * without entering fullscreen. Defaults to true for back-compat.
49
56
  */
50
57
  fullscreen?: boolean;
58
+ /** Fade in from black on mount and fade to black before calling `onExit`. */
59
+ curtain?: boolean;
51
60
  };
52
61
 
53
62
  export function Player({
@@ -62,9 +71,13 @@ export function Player({
62
71
  slideId,
63
72
  onSwitchSlide,
64
73
  fullscreen = true,
74
+ curtain = false,
65
75
  }: Props) {
66
76
  const isMobile = useIsMobile();
67
77
  const prefersReducedMotion = usePrefersReducedMotion();
78
+ const fade = curtain && !prefersReducedMotion;
79
+ const [curtainOpaque, setCurtainOpaque] = useState(fade);
80
+ const exitingRef = useRef(false);
68
81
  const rootRef = useRef<HTMLDivElement | null>(null);
69
82
  // Mirrored as state so descendants portaling *into* the player subtree
70
83
  // (tooltips, popovers — the body is outside the fullscreen tree) re-render
@@ -93,6 +106,28 @@ export function Player({
93
106
  const canPrev = index > 0;
94
107
  const canNext = index < pages.length - 1;
95
108
 
109
+ // Stepping past either end nudges the slide like a rubber band so the
110
+ // press visibly registered; past the last slide a hint says how to leave.
111
+ const nudgeRef = useRef<HTMLDivElement | null>(null);
112
+ const [lastSlideHintAt, setLastSlideHintAt] = useState(0);
113
+ const bumpEdge = useCallback(
114
+ (direction: 'start' | 'end') => {
115
+ if (!controls) return;
116
+ if (direction === 'end') setLastSlideHintAt(Date.now());
117
+ if (prefersReducedMotion) return;
118
+ const offset = direction === 'end' ? -14 : 14;
119
+ nudgeRef.current?.animate(
120
+ [
121
+ { transform: 'translateX(0)' },
122
+ { transform: `translateX(${offset}px)`, offset: 0.3 },
123
+ { transform: 'translateX(0)' },
124
+ ],
125
+ { duration: 360, easing: 'cubic-bezier(0.2, 0.8, 0.3, 1)' },
126
+ );
127
+ },
128
+ [controls, prefersReducedMotion],
129
+ );
130
+
96
131
  const stepControllerRef = useRef<StepController | null>(null);
97
132
  const [entryDirection, setEntryDirection] = useState<EntryDirection>('jump');
98
133
  const [stepAggregate, setStepAggregate] = useState<StepAggregate>({
@@ -116,14 +151,34 @@ export function Player({
116
151
  [index, onIndexChange],
117
152
  );
118
153
 
154
+ const exitTimerRef = useRef<number | null>(null);
155
+ useEffect(
156
+ () => () => {
157
+ if (exitTimerRef.current !== null) window.clearTimeout(exitTimerRef.current);
158
+ },
159
+ [],
160
+ );
161
+ const requestExit = useCallback(() => {
162
+ if (!fade) {
163
+ onExit();
164
+ return;
165
+ }
166
+ if (exitingRef.current) return;
167
+ exitingRef.current = true;
168
+ setCurtainOpaque(true);
169
+ exitTimerRef.current = window.setTimeout(onExit, CURTAIN_FADE_MS);
170
+ }, [fade, onExit]);
171
+
119
172
  const goPrev = useCallback(() => {
120
173
  if (stepControllerRef.current?.retreat()) return;
121
174
  if (index > 0) handleIndexChange(index - 1);
122
- }, [index, handleIndexChange]);
175
+ else bumpEdge('start');
176
+ }, [index, handleIndexChange, bumpEdge]);
123
177
  const goNext = useCallback(() => {
124
178
  if (stepControllerRef.current?.advance()) return;
125
179
  if (index < pages.length - 1) handleIndexChange(index + 1);
126
- }, [index, pages.length, handleIndexChange]);
180
+ else bumpEdge('end');
181
+ }, [index, pages.length, handleIndexChange, bumpEdge]);
127
182
 
128
183
  const overlayActive = controls && (overviewOpen || helpOpen);
129
184
  const overlayActiveRef = useRef(overlayActive);
@@ -178,8 +233,8 @@ export function Player({
178
233
  useClickPageNavigation({
179
234
  ref: rootRef,
180
235
  enabled: !overlayActive,
181
- canPrev,
182
- canNext,
236
+ canPrev: canPrev || controls,
237
+ canNext: canNext || controls,
183
238
  onPrev: goPrev,
184
239
  onNext: goNext,
185
240
  onViewportClick: controls && isMobile ? handleMobileViewportClick : undefined,
@@ -216,11 +271,33 @@ export function Player({
216
271
  useEffect(() => {
217
272
  if (!allowExit) return;
218
273
  const onFsChange = () => {
219
- if (!document.fullscreenElement && !windowedRef.current) onExit();
274
+ if (!document.fullscreenElement && !windowedRef.current) requestExit();
220
275
  };
221
276
  document.addEventListener('fullscreenchange', onFsChange);
222
277
  return () => document.removeEventListener('fullscreenchange', onFsChange);
223
- }, [onExit, allowExit]);
278
+ }, [requestExit, allowExit]);
279
+
280
+ // Wait out the browser's own fullscreen animation before revealing the
281
+ // first slide, otherwise the fade plays while the screen is still resizing.
282
+ // biome-ignore lint/correctness/useExhaustiveDependencies: runs once on mount
283
+ useEffect(() => {
284
+ if (!fade) return;
285
+ let cancelPaint = () => {};
286
+ const reveal = () => {
287
+ cancelPaint = afterPaint(() => {
288
+ if (!exitingRef.current) setCurtainOpaque(false);
289
+ });
290
+ };
291
+ if (!fullscreen || document.fullscreenElement) {
292
+ reveal();
293
+ return () => cancelPaint();
294
+ }
295
+ const cancelWait = whenFullscreenSettled(reveal);
296
+ return () => {
297
+ cancelWait();
298
+ cancelPaint();
299
+ };
300
+ }, []);
224
301
 
225
302
  const toggleFullscreen = useCallback(() => {
226
303
  setWindowed((w) => !w);
@@ -296,7 +373,7 @@ export function Player({
296
373
  setBlackout(null);
297
374
  return;
298
375
  }
299
- if (allowExit) onExit();
376
+ if (allowExit) requestExit();
300
377
  return;
301
378
  }
302
379
 
@@ -364,7 +441,7 @@ export function Player({
364
441
  helpOpen,
365
442
  blackout,
366
443
  allowExit,
367
- onExit,
444
+ requestExit,
368
445
  goNext,
369
446
  goPrev,
370
447
  handleIndexChange,
@@ -403,26 +480,29 @@ export function Player({
403
480
  controls && (hideCursor ? 'cursor-none' : 'cursor-default'),
404
481
  )}
405
482
  >
406
- <SlideCanvas flat design={design}>
407
- {/* Keyed per deck so a presenter-driven deck switch cuts instead of
483
+ <div ref={nudgeRef} className="h-full w-full">
484
+ <SlideCanvas flat design={design}>
485
+ {/* Keyed per deck so a presenter-driven deck switch cuts instead of
408
486
  animating a transition between two unrelated decks. */}
409
- <SlideTransitionLayer
410
- key={slideId}
411
- pages={pages}
412
- index={index}
413
- total={pages.length}
414
- moduleTransition={transition}
415
- disabled={prefersReducedMotion}
416
- stepControllerRef={stepControllerRef}
417
- entryDirection={entryDirection}
418
- onStepAggregateChange={handleAggregateChange}
419
- />
420
- </SlideCanvas>
487
+ <SlideTransitionLayer
488
+ key={slideId}
489
+ pages={pages}
490
+ index={index}
491
+ total={pages.length}
492
+ moduleTransition={transition}
493
+ disabled={prefersReducedMotion}
494
+ stepControllerRef={stepControllerRef}
495
+ entryDirection={entryDirection}
496
+ onStepAggregateChange={handleAggregateChange}
497
+ />
498
+ </SlideCanvas>
499
+ </div>
421
500
 
422
501
  {controls && (
423
502
  <div data-osd-chrome style={{ display: 'contents' }}>
424
503
  <PresentProgressBar index={index} total={pages.length} visible={chromeVisible} />
425
504
  <PresentBlackoutOverlay mode={blackout} />
505
+ <PresentLastSlideHint shownAt={lastSlideHintAt} />
426
506
  <PresentJumpInput pageCount={pages.length} onJump={handleIndexChange} />
427
507
  <PresentLaserPointer enabled={laser} />
428
508
  <PresentControlBar
@@ -444,7 +524,7 @@ export function Player({
444
524
  onPresenter={() => slideId && openPresenterWindow(slideId)}
445
525
  onToggleFullscreen={toggleFullscreen}
446
526
  onHelp={() => setHelpOpen(true)}
447
- onExit={onExit}
527
+ onExit={requestExit}
448
528
  />
449
529
  <OverviewGrid
450
530
  pages={pages}
@@ -460,6 +540,8 @@ export function Player({
460
540
  <PresentHelpOverlay open={helpOpen} onOpenChange={setHelpOpen} container={rootEl} />
461
541
  </div>
462
542
  )}
543
+
544
+ {fade && <PresentCurtain opaque={curtainOpaque} className="absolute z-[100]" />}
463
545
  </div>
464
546
  );
465
547
  }
@@ -0,0 +1,52 @@
1
+ import { cn } from '@/lib/utils';
2
+
3
+ export const CURTAIN_FADE_MS = 400;
4
+
5
+ type Props = {
6
+ opaque: boolean;
7
+ className?: string;
8
+ };
9
+
10
+ export function PresentCurtain({ opaque, className }: Props) {
11
+ return (
12
+ <div
13
+ aria-hidden
14
+ className={cn(
15
+ 'pointer-events-none inset-0 bg-black transition-opacity ease-in-out',
16
+ opaque ? 'opacity-100' : 'opacity-0',
17
+ className,
18
+ )}
19
+ style={{ transitionDuration: `${CURTAIN_FADE_MS}ms` }}
20
+ />
21
+ );
22
+ }
23
+
24
+ // Two frames so the opaque state is painted before the fade starts;
25
+ // a single rAF can land in the same style recalc and skip the transition.
26
+ export function afterPaint(cb: () => void): () => void {
27
+ let inner = 0;
28
+ const outer = requestAnimationFrame(() => {
29
+ inner = requestAnimationFrame(cb);
30
+ });
31
+ return () => {
32
+ cancelAnimationFrame(outer);
33
+ cancelAnimationFrame(inner);
34
+ };
35
+ }
36
+
37
+ export function whenFullscreenSettled(cb: () => void, maxWaitMs = 800): () => void {
38
+ let fired = false;
39
+ const timer = window.setTimeout(done, maxWaitMs);
40
+ document.addEventListener('fullscreenchange', done);
41
+ function done() {
42
+ if (fired) return;
43
+ fired = true;
44
+ cleanup();
45
+ cb();
46
+ }
47
+ function cleanup() {
48
+ window.clearTimeout(timer);
49
+ document.removeEventListener('fullscreenchange', done);
50
+ }
51
+ return cleanup;
52
+ }
@@ -0,0 +1,36 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { usePanelMount } from '@/components/panel/panel-shell';
3
+ import { useLocale } from '@/lib/use-locale';
4
+ import { cn } from '@/lib/utils';
5
+
6
+ const VISIBLE_MS = 1800;
7
+
8
+ type Props = {
9
+ shownAt: number;
10
+ };
11
+
12
+ export function PresentLastSlideHint({ shownAt }: Props) {
13
+ const t = useLocale();
14
+ const [open, setOpen] = useState(false);
15
+ useEffect(() => {
16
+ if (!shownAt) return;
17
+ setOpen(true);
18
+ const id = window.setTimeout(() => setOpen(false), VISIBLE_MS);
19
+ return () => window.clearTimeout(id);
20
+ }, [shownAt]);
21
+ const { mounted, animVisible } = usePanelMount(open);
22
+ if (!mounted) return null;
23
+ return (
24
+ <div
25
+ role="status"
26
+ className={cn(
27
+ 'pointer-events-none absolute bottom-10 left-1/2 z-30 -translate-x-1/2 rounded-full bg-black/70 px-3.5 py-1.5 text-[12px] text-white/85 shadow-lg backdrop-blur-md motion-safe:transition-[opacity,translate]',
28
+ animVisible
29
+ ? 'opacity-100 motion-safe:duration-200'
30
+ : 'translate-y-1 opacity-0 motion-safe:duration-200',
31
+ )}
32
+ >
33
+ {t.present.lastSlideHint}
34
+ </div>
35
+ );
36
+ }
@@ -51,7 +51,7 @@ export function SlideCanvas({
51
51
  <div ref={containerRef} className={cn('relative h-full w-full', flat && 'overflow-hidden')}>
52
52
  <div
53
53
  className={cn(
54
- 'overflow-hidden bg-white text-black',
54
+ 'overflow-hidden bg-white text-start text-black',
55
55
  !flat && 'rounded-[6px] shadow-floating',
56
56
  )}
57
57
  style={
@@ -568,7 +568,7 @@ function HorizontalVirtualThumbList({
568
568
  aria-label={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
569
569
  aria-current={active ? 'page' : undefined}
570
570
  className={cn(
571
- 'group/thumb relative flex shrink-0 flex-col items-center gap-1.5 rounded-[6px] outline-none motion-safe:transition-[scale] motion-safe:duration-100 active:scale-[0.985]',
571
+ 'group/thumb relative flex shrink-0 flex-col items-center gap-1.5 rounded-[6px] text-left outline-none motion-safe:transition-[scale] motion-safe:duration-100 active:scale-[0.985]',
572
572
  'focus-visible:ring-1 focus-visible:ring-brand focus-visible:ring-offset-1 focus-visible:ring-offset-sidebar',
573
573
  )}
574
574
  >
@@ -323,10 +323,12 @@ function SortControl({ value, onChange }: { value: SortKey; onChange: (next: Sor
323
323
  );
324
324
  }
325
325
 
326
+ // Offered even before an agent is connected: the new deck opens the agent
327
+ // panel, which walks first-time users through installing one.
326
328
  function useCreateDeckWithAgent() {
327
- const { provider } = useAgentStatus();
329
+ const { enabled, provider } = useAgentStatus();
328
330
  const start = useCreateDeck();
329
- if (!provider) return null;
331
+ if (!enabled) return null;
330
332
  return { provider, start };
331
333
  }
332
334
 
@@ -339,7 +341,11 @@ function NewDeckButton() {
339
341
  size="sm"
340
342
  className="h-8 shrink-0"
341
343
  onClick={agent.start}
342
- title={format(t.agent.createDeckWith, { provider: agent.provider.name })}
344
+ title={
345
+ agent.provider
346
+ ? format(t.agent.createDeckWith, { provider: agent.provider.name })
347
+ : t.agent.createDeck
348
+ }
343
349
  >
344
350
  <Plus />
345
351
  {t.agent.createDeck}
@@ -404,8 +410,10 @@ function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: st
404
410
  </p>
405
411
  {agent ? (
406
412
  <Button size="sm" className="mt-4" onClick={agent.start}>
407
- <ProviderLogo provider={agent.provider.id} />
408
- {format(t.agent.createDeckWith, { provider: agent.provider.name })}
413
+ {agent.provider ? <ProviderLogo provider={agent.provider.id} /> : <Plus />}
414
+ {agent.provider
415
+ ? format(t.agent.createDeckWith, { provider: agent.provider.name })
416
+ : t.agent.createDeck}
409
417
  </Button>
410
418
  ) : (
411
419
  <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
@@ -87,6 +87,12 @@ import { PdfProgressToast, PptxProgressToast } from '../components/export-progre
87
87
  import { NotesDrawer } from '../components/notes-drawer';
88
88
  import { OverviewGrid } from '../components/overview-grid';
89
89
  import { openPresenterWindow, Player } from '../components/player';
90
+ import {
91
+ afterPaint,
92
+ CURTAIN_FADE_MS,
93
+ PresentCurtain,
94
+ whenFullscreenSettled,
95
+ } from '../components/present/curtain';
90
96
  import { SlideCanvas } from '../components/slide-canvas';
91
97
  import { isDeckWarmed, markDeckWarmed, SlidePreloadLayer } from '../components/slide-preload-layer';
92
98
  import { SlideTransitionLayer } from '../components/slide-transition-layer';
@@ -166,6 +172,55 @@ export function Slide() {
166
172
  const narrowToolbar = useMediaQuery('(max-width: 639.98px)');
167
173
  const prefersReducedMotion = usePrefersReducedMotion();
168
174
 
175
+ // 'entering' darkens the editor before the Player mounts; 'leaving' keeps
176
+ // the editor dark after the Player unmounts, then fades it back in.
177
+ const [curtain, setCurtain] = useState<'entering' | 'leaving' | null>(null);
178
+ const curtainTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
179
+ useEffect(
180
+ () => () => {
181
+ if (curtainTimerRef.current) clearTimeout(curtainTimerRef.current);
182
+ },
183
+ [],
184
+ );
185
+ const present = useCallback(
186
+ (mode: 'window' | 'fullscreen') => {
187
+ if (prefersReducedMotion) {
188
+ setPlayMode(mode);
189
+ return;
190
+ }
191
+ if (curtainTimerRef.current) return;
192
+ setCurtain('entering');
193
+ // Fullscreen is requested when the Player mounts; the delay stays well
194
+ // inside the browser's transient user-activation window.
195
+ curtainTimerRef.current = setTimeout(() => {
196
+ curtainTimerRef.current = null;
197
+ setCurtain(null);
198
+ setPlayMode(mode);
199
+ }, CURTAIN_FADE_MS);
200
+ },
201
+ [prefersReducedMotion],
202
+ );
203
+ const exitPresent = useCallback(() => {
204
+ setPlayMode(null);
205
+ if (!prefersReducedMotion) setCurtain('leaving');
206
+ }, [prefersReducedMotion]);
207
+ useEffect(() => {
208
+ if (curtain !== 'leaving' || playMode) return;
209
+ let cancelPaint = () => {};
210
+ const reveal = () => {
211
+ cancelPaint = afterPaint(() => setCurtain(null));
212
+ };
213
+ if (!document.fullscreenElement) {
214
+ reveal();
215
+ return () => cancelPaint();
216
+ }
217
+ const cancelWait = whenFullscreenSettled(reveal);
218
+ return () => {
219
+ cancelWait();
220
+ cancelPaint();
221
+ };
222
+ }, [curtain, playMode]);
223
+
169
224
  const modulePages = useMemo(() => slide?.default ?? [], [slide]);
170
225
  const [pages, setPages] = useState<typeof modulePages>(modulePages);
171
226
  useEffect(() => {
@@ -335,7 +390,7 @@ export function Slide() {
335
390
  // When showSlideUi is false the read-only <Player> is rendered and owns
336
391
  // keyboard navigation (including step-aware advance/retreat). Attaching this
337
392
  // page-nav handler too would race it and skip <Steps> reveals, so bail out.
338
- if (playMode || !showSlideUi) return;
393
+ if (playMode || curtain || !showSlideUi) return;
339
394
  const onKey = (e: KeyboardEvent) => {
340
395
  if (
341
396
  isTypingTarget(e.target) ||
@@ -368,12 +423,12 @@ export function Slide() {
368
423
  return;
369
424
  }
370
425
  if (e.key === 'f' || e.key === 'F') {
371
- setPlayMode('fullscreen');
426
+ present('fullscreen');
372
427
  } else if (e.key === 'Enter') {
373
- setPlayMode('window');
428
+ present('window');
374
429
  } else if (e.key === 'p' || e.key === 'P') {
375
430
  if (slideId) openPresenterWindow(slideId);
376
- setPlayMode('window');
431
+ present('window');
377
432
  } else if (import.meta.env.DEV && (e.key === 'd' || e.key === 'D')) {
378
433
  setDesignOpen((v) => !v);
379
434
  } else if (import.meta.env.DEV && (e.key === 'a' || e.key === 'A')) {
@@ -382,7 +437,7 @@ export function Slide() {
382
437
  };
383
438
  window.addEventListener('keydown', onKey);
384
439
  return () => window.removeEventListener('keydown', onKey);
385
- }, [index, goTo, playMode, slideId, overviewOpen, toggleAgent]);
440
+ }, [index, goTo, playMode, curtain, slideId, overviewOpen, toggleAgent, present]);
386
441
 
387
442
  if (error) {
388
443
  return (
@@ -423,8 +478,9 @@ export function Slide() {
423
478
  transition={presented.slide.transition}
424
479
  index={presented.index}
425
480
  onIndexChange={presentReady ? goTo : noop}
426
- onExit={() => setPlayMode(null)}
481
+ onExit={exitPresent}
427
482
  controls
483
+ curtain={!prefersReducedMotion}
428
484
  slideId={presented.slideId}
429
485
  onSwitchSlide={switchPresentedSlide}
430
486
  fullscreen={playMode === 'fullscreen'}
@@ -801,7 +857,7 @@ export function Slide() {
801
857
  size="sm"
802
858
  variant="brand"
803
859
  aria-label={t.slide.present}
804
- onClick={() => setPlayMode(isMobile ? 'window' : 'fullscreen')}
860
+ onClick={() => present(isMobile ? 'window' : 'fullscreen')}
805
861
  className="rounded-r-none px-2.5 lg:px-3"
806
862
  >
807
863
  <Play className="size-3.5 fill-current" />
@@ -821,12 +877,12 @@ export function Slide() {
821
877
  </DropdownMenuTrigger>
822
878
  <DropdownMenuContent align="end" className="min-w-[200px]">
823
879
  <DropdownMenuGroup>
824
- <DropdownMenuItem onClick={() => setPlayMode('window')}>
880
+ <DropdownMenuItem onClick={() => present('window')}>
825
881
  <Play />
826
882
  {t.slide.presentInWindow}
827
883
  <DropdownMenuShortcut>↵</DropdownMenuShortcut>
828
884
  </DropdownMenuItem>
829
- <DropdownMenuItem onClick={() => setPlayMode('fullscreen')}>
885
+ <DropdownMenuItem onClick={() => present('fullscreen')}>
830
886
  <Maximize />
831
887
  {t.slide.presentFullscreen}
832
888
  <DropdownMenuShortcut>F</DropdownMenuShortcut>
@@ -834,7 +890,7 @@ export function Slide() {
834
890
  <DropdownMenuItem
835
891
  onClick={() => {
836
892
  if (slideId) openPresenterWindow(slideId);
837
- setPlayMode('window');
893
+ present('window');
838
894
  }}
839
895
  >
840
896
  <MonitorSpeaker />
@@ -957,11 +1013,11 @@ export function Slide() {
957
1013
  currentIndex={index}
958
1014
  exporting={exporting}
959
1015
  handlers={{
960
- onPresentWindow: () => setPlayMode('window'),
961
- onPresentFullscreen: () => setPlayMode('fullscreen'),
1016
+ onPresentWindow: () => present('window'),
1017
+ onPresentFullscreen: () => present('fullscreen'),
962
1018
  onPresenterView: () => {
963
1019
  if (slideId) openPresenterWindow(slideId);
964
- setPlayMode('window');
1020
+ present('window');
965
1021
  },
966
1022
  onCopyLink: copyLink,
967
1023
  onOverview: () => setOverviewOpen(true),
@@ -976,6 +1032,7 @@ export function Slide() {
976
1032
  />
977
1033
  )}
978
1034
  </div>
1035
+ <PresentCurtain opaque={curtain !== null} className="fixed z-[1000]" />
979
1036
  </InspectorProvider>
980
1037
  </HistoryProvider>
981
1038
  );
package/src/locale/en.ts CHANGED
@@ -178,6 +178,7 @@ export const en: Locale = {
178
178
  helpAria: 'Keyboard shortcuts (?)',
179
179
  exitAria: 'Exit (Esc)',
180
180
  elapsedTime: 'Elapsed time',
181
+ lastSlideHint: 'Last slide · Press Esc to exit',
181
182
  helpEyebrow: 'Present mode',
182
183
  helpTitle: 'Keyboard shortcuts',
183
184
  shortcutNext: 'Next slide',
package/src/locale/es.ts CHANGED
@@ -180,6 +180,7 @@ export const es: Locale = {
180
180
  helpAria: 'Atajos de teclado (?)',
181
181
  exitAria: 'Salir (Esc)',
182
182
  elapsedTime: 'Tiempo transcurrido',
183
+ lastSlideHint: 'Última diapositiva · Pulsa Esc para salir',
183
184
  helpEyebrow: 'Modo presentación',
184
185
  helpTitle: 'Atajos de teclado',
185
186
  shortcutNext: 'Diapositiva siguiente',
package/src/locale/ja.ts CHANGED
@@ -178,6 +178,7 @@ export const ja: Locale = {
178
178
  helpAria: 'キーボードショートカット (?)',
179
179
  exitAria: '終了 (Esc)',
180
180
  elapsedTime: '経過時間',
181
+ lastSlideHint: '最後のスライドです · Esc で終了',
181
182
  helpEyebrow: '発表モード',
182
183
  helpTitle: 'キーボードショートカット',
183
184
  shortcutNext: '次のスライド',
@@ -182,6 +182,7 @@ export type Locale = {
182
182
  helpAria: string;
183
183
  exitAria: string;
184
184
  elapsedTime: string;
185
+ lastSlideHint: string;
185
186
  helpEyebrow: string;
186
187
  helpTitle: string;
187
188
  shortcutNext: string;
@@ -177,6 +177,7 @@ export const zhCN: Locale = {
177
177
  helpAria: '键盘快捷键 (?)',
178
178
  exitAria: '退出 (Esc)',
179
179
  elapsedTime: '已用时',
180
+ lastSlideHint: '已是最后一页 · 按 Esc 退出放映',
180
181
  helpEyebrow: '演示模式',
181
182
  helpTitle: '键盘快捷键',
182
183
  shortcutNext: '下一张',
@@ -177,6 +177,7 @@ export const zhTW: Locale = {
177
177
  helpAria: '鍵盤快速鍵 (?)',
178
178
  exitAria: '離開 (Esc)',
179
179
  elapsedTime: '已耗時',
180
+ lastSlideHint: '已是最後一頁 · 按 Esc 結束播放',
180
181
  helpEyebrow: '簡報模式',
181
182
  helpTitle: '鍵盤快速鍵',
182
183
  shortcutNext: '下一張',