@bendyline/squisq-react 0.1.2

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 (45) hide show
  1. package/dist/index.d.ts +563 -0
  2. package/dist/index.js +3180 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/squisq-player.css +2 -0
  5. package/dist/squisq-player.css.map +1 -0
  6. package/dist/squisq-player.global.js +6 -0
  7. package/dist/squisq-player.global.js.map +1 -0
  8. package/dist/standalone-source.d.ts +2 -0
  9. package/dist/standalone-source.js +2 -0
  10. package/package.json +69 -0
  11. package/src/BlockRenderer.tsx +146 -0
  12. package/src/CaptionOverlay.tsx +86 -0
  13. package/src/DocControlsBottom.tsx +103 -0
  14. package/src/DocControlsOverlay.tsx +178 -0
  15. package/src/DocControlsSidebar.tsx +107 -0
  16. package/src/DocControlsSlideshow.tsx +132 -0
  17. package/src/DocPlayer.tsx +1005 -0
  18. package/src/DocPlayerWithSidebar.tsx +138 -0
  19. package/src/DocProgressBar.tsx +200 -0
  20. package/src/LinearDocView.tsx +313 -0
  21. package/src/MarkdownRenderer.tsx +360 -0
  22. package/src/__tests__/BlockRenderer.test.tsx +105 -0
  23. package/src/__tests__/DocControlsSlideshow.test.tsx +127 -0
  24. package/src/__tests__/LinearDocView.test.tsx +180 -0
  25. package/src/__tests__/MarkdownRenderer.test.tsx +234 -0
  26. package/src/__tests__/exports.test.ts +55 -0
  27. package/src/hooks/AudioProvider.ts +114 -0
  28. package/src/hooks/MediaContext.tsx +81 -0
  29. package/src/hooks/index.ts +6 -0
  30. package/src/hooks/useAudioSync.ts +390 -0
  31. package/src/hooks/useDocPlayback.ts +251 -0
  32. package/src/hooks/useViewportOrientation.ts +117 -0
  33. package/src/index.ts +46 -0
  34. package/src/layers/ImageLayer.tsx +182 -0
  35. package/src/layers/MapLayer.tsx +184 -0
  36. package/src/layers/ShapeLayer.tsx +107 -0
  37. package/src/layers/TextLayer.tsx +197 -0
  38. package/src/layers/VideoLayer.tsx +150 -0
  39. package/src/layers/index.ts +5 -0
  40. package/src/standalone-entry.tsx +228 -0
  41. package/src/styles/doc-animations.css +458 -0
  42. package/src/types.ts +152 -0
  43. package/src/utils/animationUtils.ts +13 -0
  44. package/src/utils/layerUtils.ts +42 -0
  45. package/src/utils/mapTileUtils.ts +375 -0
@@ -0,0 +1,103 @@
1
+ /**
2
+ * DocControlsBottom Component
3
+ *
4
+ * Horizontal control strip that renders below the doc player video.
5
+ * An alternative to the overlay for landscape mode -- controls sit in a
6
+ * separate bar below the video instead of overlapping the content.
7
+ *
8
+ * Contains: restart, play/pause, time display, progress bar, segment
9
+ * indicator, CC toggle. Same controls as the overlay but in an external bar.
10
+ *
11
+ * Related Files:
12
+ * - DocControlsOverlay.tsx -- Overlay variant (default landscape)
13
+ * - DocControlsSidebar.tsx -- Sidebar variant (portrait)
14
+ * - DocProgressBar.tsx -- Shared progress bar component
15
+ * - types.ts -- PlaybackState/PlaybackActions interfaces
16
+ */
17
+
18
+ import { DocProgressBar } from './DocProgressBar';
19
+ import type { PlaybackState, PlaybackActions, BlockMarker } from './types';
20
+ import { formatTime } from './types';
21
+ import type { Block } from '@bendyline/squisq/schemas';
22
+
23
+ interface DocControlsBottomProps {
24
+ state: PlaybackState;
25
+ actions: PlaybackActions;
26
+ blockMarkers: BlockMarker[];
27
+ expandedBlocks: Block[];
28
+ getBlockTitle?: (block: Block) => string;
29
+ }
30
+
31
+ export function DocControlsBottom({
32
+ state,
33
+ actions,
34
+ blockMarkers,
35
+ expandedBlocks,
36
+ getBlockTitle,
37
+ }: DocControlsBottomProps) {
38
+ return (
39
+ <div className="doc-controls-bottom">
40
+ {/* Restart button */}
41
+ <button
42
+ className="bottom-ctrl-btn"
43
+ onClick={actions.restart}
44
+ title="Restart"
45
+ aria-label="Restart from beginning"
46
+ >
47
+ <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
48
+ <path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" />
49
+ </svg>
50
+ </button>
51
+
52
+ {/* Play/Pause button */}
53
+ <button
54
+ className="bottom-ctrl-btn bottom-play-btn"
55
+ onClick={actions.toggle}
56
+ aria-label={state.isPlaying ? 'Pause' : 'Play'}
57
+ >
58
+ {state.isPlaying ? (
59
+ <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
60
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
61
+ </svg>
62
+ ) : (
63
+ <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
64
+ <path d="M8 5v14l11-7z" />
65
+ </svg>
66
+ )}
67
+ </button>
68
+
69
+ {/* Time display */}
70
+ <span className="bottom-time">
71
+ {formatTime(state.currentTime)} / {formatTime(state.totalDuration)}
72
+ </span>
73
+
74
+ {/* Progress bar */}
75
+ <DocProgressBar
76
+ state={state}
77
+ actions={actions}
78
+ blockMarkers={blockMarkers}
79
+ expandedBlocks={expandedBlocks}
80
+ getBlockTitle={getBlockTitle}
81
+ />
82
+
83
+ {/* Segment indicator */}
84
+ <span className="bottom-segment">
85
+ {state.currentBlockIndex + 1}/{state.totalBlocks}
86
+ </span>
87
+
88
+ {/* Caption toggle */}
89
+ {state.hasCaptions && (
90
+ <button
91
+ className={`bottom-ctrl-btn ${state.captionsEnabled ? 'bottom-ctrl-btn--active' : ''}`}
92
+ onClick={() => actions.setCaptionsEnabled(!state.captionsEnabled)}
93
+ title={state.captionsEnabled ? 'Hide captions' : 'Show captions'}
94
+ aria-label={state.captionsEnabled ? 'Hide captions' : 'Show captions'}
95
+ >
96
+ <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
97
+ <path d="M19 4H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 1 0 01-1 1h-3a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1z" />
98
+ </svg>
99
+ </button>
100
+ )}
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * DocControlsOverlay Component
3
+ *
4
+ * The default overlay controls that render at the bottom of the doc player
5
+ * video with a gradient background. Contains all playback controls: restart,
6
+ * play/pause, time display, progress bar with block markers, segment indicator,
7
+ * and caption toggle.
8
+ *
9
+ * This is the original control layout extracted from DocPlayer. It's used
10
+ * for the 'overlay' controls layout mode (Flow mode, landscape default).
11
+ *
12
+ * Related Files:
13
+ * - DocPlayer.tsx -- Parent component
14
+ * - DocProgressBar.tsx -- Progress bar sub-component
15
+ * - types.ts -- Shared type definitions
16
+ */
17
+
18
+ import { DocProgressBar } from './DocProgressBar';
19
+ import type { PlaybackState, PlaybackActions, BlockMarker } from './types';
20
+ import { formatTime } from './types';
21
+ import type { Block } from '@bendyline/squisq/schemas';
22
+
23
+ interface DocControlsOverlayProps {
24
+ state: PlaybackState;
25
+ actions: PlaybackActions;
26
+ blockMarkers: BlockMarker[];
27
+ expandedBlocks: Block[];
28
+ getBlockTitle?: (block: Block) => string;
29
+ }
30
+
31
+ export function DocControlsOverlay({
32
+ state,
33
+ actions,
34
+ blockMarkers,
35
+ expandedBlocks,
36
+ getBlockTitle,
37
+ }: DocControlsOverlayProps) {
38
+ return (
39
+ <div
40
+ className="doc-player__controls"
41
+ style={{
42
+ position: 'absolute',
43
+ bottom: 0,
44
+ left: 0,
45
+ right: 0,
46
+ padding: '12px 16px',
47
+ background: 'linear-gradient(transparent, rgba(0,0,0,0.8))',
48
+ display: 'flex',
49
+ alignItems: 'center',
50
+ gap: '8px',
51
+ zIndex: 100,
52
+ }}
53
+ >
54
+ {/* Restart button */}
55
+ <button
56
+ onClick={actions.restart}
57
+ style={{
58
+ background: 'none',
59
+ border: 'none',
60
+ color: 'rgba(255,255,255,0.7)',
61
+ cursor: 'pointer',
62
+ padding: '8px',
63
+ fontSize: '14px',
64
+ display: 'flex',
65
+ alignItems: 'center',
66
+ }}
67
+ title="Restart"
68
+ aria-label="Restart from beginning"
69
+ >
70
+ <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
71
+ <path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" />
72
+ </svg>
73
+ </button>
74
+
75
+ {/* Play/Pause button */}
76
+ <button
77
+ onClick={actions.toggle}
78
+ style={{
79
+ background: 'rgba(255,255,255,0.2)',
80
+ border: 'none',
81
+ borderRadius: '50%',
82
+ color: 'white',
83
+ cursor: 'pointer',
84
+ padding: '10px',
85
+ fontSize: '16px',
86
+ display: 'flex',
87
+ alignItems: 'center',
88
+ justifyContent: 'center',
89
+ width: '40px',
90
+ height: '40px',
91
+ }}
92
+ aria-label={state.isPlaying ? 'Pause' : 'Play'}
93
+ >
94
+ {state.isPlaying ? (
95
+ <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
96
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
97
+ </svg>
98
+ ) : (
99
+ <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
100
+ <path d="M8 5v14l11-7z" />
101
+ </svg>
102
+ )}
103
+ </button>
104
+
105
+ {/* Time display */}
106
+ <span style={{ color: 'white', fontSize: '12px', minWidth: '80px', fontFamily: 'monospace' }}>
107
+ {formatTime(state.currentTime)} / {formatTime(state.totalDuration)}
108
+ </span>
109
+
110
+ {/* Progress bar */}
111
+ <DocProgressBar
112
+ state={state}
113
+ actions={actions}
114
+ blockMarkers={blockMarkers}
115
+ expandedBlocks={expandedBlocks}
116
+ getBlockTitle={getBlockTitle}
117
+ />
118
+
119
+ {/* Segment indicator */}
120
+ <span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px', whiteSpace: 'nowrap' }}>
121
+ {state.currentBlockIndex + 1}/{state.totalBlocks}
122
+ </span>
123
+
124
+ {/* Caption toggle button */}
125
+ {state.hasCaptions && (
126
+ <button
127
+ onClick={() => actions.setCaptionsEnabled(!state.captionsEnabled)}
128
+ style={{
129
+ background: state.captionsEnabled ? 'rgba(255,255,255,0.2)' : 'none',
130
+ border: 'none',
131
+ color: state.captionsEnabled ? 'white' : 'rgba(255,255,255,0.5)',
132
+ cursor: 'pointer',
133
+ padding: '8px',
134
+ fontSize: '12px',
135
+ display: 'flex',
136
+ alignItems: 'center',
137
+ borderRadius: '4px',
138
+ }}
139
+ title={state.captionsEnabled ? 'Hide captions' : 'Show captions'}
140
+ aria-label={state.captionsEnabled ? 'Hide captions' : 'Show captions'}
141
+ >
142
+ <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
143
+ <path d="M19 4H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 1 0 01-1 1h-3a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1z" />
144
+ </svg>
145
+ </button>
146
+ )}
147
+
148
+ {/* Fullscreen toggle button */}
149
+ {actions.toggleFullscreen && (
150
+ <button
151
+ onClick={actions.toggleFullscreen}
152
+ style={{
153
+ background: state.isFullscreen ? 'rgba(255,255,255,0.2)' : 'none',
154
+ border: 'none',
155
+ color: state.isFullscreen ? 'white' : 'rgba(255,255,255,0.5)',
156
+ cursor: 'pointer',
157
+ padding: '8px',
158
+ display: 'flex',
159
+ alignItems: 'center',
160
+ borderRadius: '4px',
161
+ }}
162
+ title={state.isFullscreen ? 'Exit fullscreen (F)' : 'Fullscreen (F)'}
163
+ aria-label={state.isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
164
+ >
165
+ {state.isFullscreen ? (
166
+ <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
167
+ <path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
168
+ </svg>
169
+ ) : (
170
+ <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
171
+ <path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
172
+ </svg>
173
+ )}
174
+ </button>
175
+ )}
176
+ </div>
177
+ );
178
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * DocControlsSidebar Component
3
+ *
4
+ * Vertical control strip that renders alongside the doc player video.
5
+ * Used in portrait orientation to eliminate black bars -- controls sit in a
6
+ * "tab" to the right of the 9:16 video instead of overlaying it.
7
+ *
8
+ * Contains: restart, play/pause, time display, segment indicator, CC toggle.
9
+ * Does NOT contain the scrubber/progress bar (that stays in the video via
10
+ * DocPlayer's showScrubber prop).
11
+ *
12
+ * The sidebar stretches to match the full height of the video and has rounded
13
+ * corners on the right side to create a connected "tab" appearance.
14
+ *
15
+ * Related Files:
16
+ * - DocPlayerWithSidebar.tsx -- Wrapper that composes player + sidebar
17
+ * - DocPlayer.tsx -- Core player (renders with showScrubber=true)
18
+ * - types.ts -- PlaybackState/PlaybackActions interfaces
19
+ */
20
+
21
+ import type { PlaybackState, PlaybackActions } from './types';
22
+ import { formatTime } from './types';
23
+
24
+ interface DocControlsSidebarProps {
25
+ state: PlaybackState;
26
+ actions: PlaybackActions;
27
+ }
28
+
29
+ export function DocControlsSidebar({ state, actions }: DocControlsSidebarProps) {
30
+ return (
31
+ <div className="doc-controls-sidebar">
32
+ {/* Restart button */}
33
+ <button
34
+ className="sidebar-ctrl-btn"
35
+ onClick={actions.restart}
36
+ title="Restart"
37
+ aria-label="Restart from beginning"
38
+ >
39
+ <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
40
+ <path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" />
41
+ </svg>
42
+ </button>
43
+
44
+ {/* Play/Pause button */}
45
+ <button
46
+ className="sidebar-ctrl-btn sidebar-play-btn"
47
+ onClick={actions.toggle}
48
+ aria-label={state.isPlaying ? 'Pause' : 'Play'}
49
+ >
50
+ {state.isPlaying ? (
51
+ <svg viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
52
+ <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
53
+ </svg>
54
+ ) : (
55
+ <svg viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
56
+ <path d="M8 5v14l11-7z" />
57
+ </svg>
58
+ )}
59
+ </button>
60
+
61
+ {/* Time display -- stacked current / total */}
62
+ <div className="sidebar-time">
63
+ <div>{formatTime(state.currentTime)}</div>
64
+ <div className="sidebar-time-total">{formatTime(state.totalDuration)}</div>
65
+ </div>
66
+
67
+ {/* Segment indicator */}
68
+ <div className="sidebar-segment">
69
+ {state.currentBlockIndex + 1}/{state.totalBlocks}
70
+ </div>
71
+
72
+ {/* Caption toggle */}
73
+ {state.hasCaptions && (
74
+ <button
75
+ className={`sidebar-ctrl-btn ${state.captionsEnabled ? 'sidebar-ctrl-btn--active' : ''}`}
76
+ onClick={() => actions.setCaptionsEnabled(!state.captionsEnabled)}
77
+ title={state.captionsEnabled ? 'Hide captions' : 'Show captions'}
78
+ aria-label={state.captionsEnabled ? 'Hide captions' : 'Show captions'}
79
+ >
80
+ <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
81
+ <path d="M19 4H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2zm-8 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 1 0 01-1 1h-3a1 1 0 01-1-1v-4a1 1 0 011-1h3a1 1 0 011 1v1z" />
82
+ </svg>
83
+ </button>
84
+ )}
85
+
86
+ {/* Fullscreen toggle */}
87
+ {actions.toggleFullscreen && (
88
+ <button
89
+ className={`sidebar-ctrl-btn ${state.isFullscreen ? 'sidebar-ctrl-btn--active' : ''}`}
90
+ onClick={actions.toggleFullscreen}
91
+ title={state.isFullscreen ? 'Exit fullscreen (F)' : 'Fullscreen (F)'}
92
+ aria-label={state.isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
93
+ >
94
+ {state.isFullscreen ? (
95
+ <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
96
+ <path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" />
97
+ </svg>
98
+ ) : (
99
+ <svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
100
+ <path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
101
+ </svg>
102
+ )}
103
+ </button>
104
+ )}
105
+ </div>
106
+ );
107
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * DocControlsSlideshow Component
3
+ *
4
+ * Compact slide navigation controls for the DocPlayer slideshow display mode.
5
+ * Renders in the lower-right corner with previous / slide counter / next buttons,
6
+ * similar to PowerPoint/Keynote presentation navigation.
7
+ *
8
+ * Related Files:
9
+ * - DocPlayer.tsx — Parent component (renders this when displayMode='slideshow')
10
+ * - DocControlsOverlay.tsx — Video-mode controls (overlay layout)
11
+ * - types.ts — Shared type definitions
12
+ */
13
+
14
+ import type { PlaybackState, SlideNavActions } from './types';
15
+
16
+ interface DocControlsSlideshowProps {
17
+ state: PlaybackState;
18
+ slideNav: SlideNavActions;
19
+ }
20
+
21
+ export function DocControlsSlideshow({ state, slideNav }: DocControlsSlideshowProps) {
22
+ const { currentBlockIndex, totalBlocks } = state;
23
+ const isFirst = currentBlockIndex <= 0;
24
+ const isLast = currentBlockIndex >= totalBlocks - 1;
25
+
26
+ return (
27
+ <div
28
+ className="doc-controls-slideshow"
29
+ data-testid="slideshow-controls"
30
+ style={{
31
+ position: 'absolute',
32
+ bottom: '16px',
33
+ right: '16px',
34
+ display: 'flex',
35
+ alignItems: 'center',
36
+ gap: '2px',
37
+ background: 'rgba(0, 0, 0, 0.65)',
38
+ borderRadius: '8px',
39
+ padding: '4px 6px',
40
+ zIndex: 100,
41
+ userSelect: 'none',
42
+ backdropFilter: 'blur(8px)',
43
+ WebkitBackdropFilter: 'blur(8px)',
44
+ }}
45
+ >
46
+ {/* Previous button */}
47
+ <button
48
+ onClick={(e) => {
49
+ e.stopPropagation();
50
+ slideNav.prevSlide();
51
+ }}
52
+ disabled={isFirst}
53
+ data-testid="slide-prev"
54
+ aria-label="Previous slide"
55
+ title="Previous slide"
56
+ style={{
57
+ background: 'none',
58
+ border: 'none',
59
+ color: isFirst ? 'rgba(255,255,255,0.3)' : 'rgba(255,255,255,0.9)',
60
+ cursor: isFirst ? 'default' : 'pointer',
61
+ padding: '6px 8px',
62
+ display: 'flex',
63
+ alignItems: 'center',
64
+ justifyContent: 'center',
65
+ borderRadius: '4px',
66
+ transition: 'background 0.15s',
67
+ }}
68
+ onMouseEnter={(e) => {
69
+ if (!isFirst) e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
70
+ }}
71
+ onMouseLeave={(e) => {
72
+ e.currentTarget.style.background = 'none';
73
+ }}
74
+ >
75
+ <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
76
+ <path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z" />
77
+ </svg>
78
+ </button>
79
+
80
+ {/* Slide counter */}
81
+ <span
82
+ data-testid="slide-counter"
83
+ style={{
84
+ color: 'rgba(255,255,255,0.9)',
85
+ fontSize: '13px',
86
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
87
+ fontVariantNumeric: 'tabular-nums',
88
+ minWidth: '48px',
89
+ textAlign: 'center',
90
+ padding: '0 4px',
91
+ letterSpacing: '0.02em',
92
+ }}
93
+ >
94
+ {totalBlocks > 0 ? `${currentBlockIndex + 1} / ${totalBlocks}` : '—'}
95
+ </span>
96
+
97
+ {/* Next button */}
98
+ <button
99
+ onClick={(e) => {
100
+ e.stopPropagation();
101
+ slideNav.nextSlide();
102
+ }}
103
+ disabled={isLast}
104
+ data-testid="slide-next"
105
+ aria-label="Next slide"
106
+ title="Next slide"
107
+ style={{
108
+ background: 'none',
109
+ border: 'none',
110
+ color: isLast ? 'rgba(255,255,255,0.3)' : 'rgba(255,255,255,0.9)',
111
+ cursor: isLast ? 'default' : 'pointer',
112
+ padding: '6px 8px',
113
+ display: 'flex',
114
+ alignItems: 'center',
115
+ justifyContent: 'center',
116
+ borderRadius: '4px',
117
+ transition: 'background 0.15s',
118
+ }}
119
+ onMouseEnter={(e) => {
120
+ if (!isLast) e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
121
+ }}
122
+ onMouseLeave={(e) => {
123
+ e.currentTarget.style.background = 'none';
124
+ }}
125
+ >
126
+ <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
127
+ <path d="M8.59 16.59L10 18l6-6-6-6-1.41 1.41L13.17 12z" />
128
+ </svg>
129
+ </button>
130
+ </div>
131
+ );
132
+ }