@dfosco/storyboard-react 4.0.0-beta.26 → 4.0.0-beta.28

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 (29) hide show
  1. package/package.json +3 -3
  2. package/src/canvas/CanvasPage.bridge.test.jsx +87 -2
  3. package/src/canvas/CanvasPage.jsx +161 -18
  4. package/src/canvas/CanvasPage.module.css +54 -0
  5. package/src/canvas/CanvasPage.multiselect.test.jsx +2 -0
  6. package/src/canvas/canvasApi.js +8 -0
  7. package/src/canvas/widgets/FigmaEmbed.jsx +42 -7
  8. package/src/canvas/widgets/FigmaEmbed.module.css +21 -0
  9. package/src/canvas/widgets/LinkPreview.jsx +247 -18
  10. package/src/canvas/widgets/LinkPreview.module.css +349 -8
  11. package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
  12. package/src/canvas/widgets/MarkdownBlock.jsx +2 -1
  13. package/src/canvas/widgets/MarkdownBlock.module.css +34 -11
  14. package/src/canvas/widgets/PrototypeEmbed.jsx +101 -44
  15. package/src/canvas/widgets/PrototypeEmbed.module.css +1 -0
  16. package/src/canvas/widgets/StoryWidget.jsx +86 -42
  17. package/src/canvas/widgets/StoryWidget.module.css +1 -0
  18. package/src/canvas/widgets/WidgetChrome.jsx +20 -1
  19. package/src/canvas/widgets/embedInteraction.test.jsx +16 -18
  20. package/src/canvas/widgets/embedTheme.js +37 -1
  21. package/src/canvas/widgets/githubUrl.js +82 -0
  22. package/src/canvas/widgets/githubUrl.test.js +74 -0
  23. package/src/canvas/widgets/refreshQueue.js +108 -0
  24. package/src/canvas/widgets/snapshotDisplay.test.jsx +60 -90
  25. package/src/canvas/widgets/useSnapshotCapture.js +38 -139
  26. package/src/canvas/widgets/useSnapshotCapture.test.jsx +30 -91
  27. package/src/canvas/widgets/widgetConfig.test.js +1 -1
  28. package/src/story/StoryPage.jsx +25 -60
  29. package/src/story/StoryPage.module.css +0 -55
@@ -1,6 +1,6 @@
1
1
  import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from 'react'
2
2
  import { Tooltip } from '@primer/react'
3
- import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage } from '@primer/octicons-react'
3
+ import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage, UnfoldIcon as OcticonUnfold, FoldIcon as OcticonFold } from '@primer/octicons-react'
4
4
  import styles from './WidgetChrome.module.css'
5
5
 
6
6
  const STICKY_NOTE_COLORS = {
@@ -122,6 +122,22 @@ function ExpandIcon() {
122
122
  )
123
123
  }
124
124
 
125
+ function SyncIcon() {
126
+ return (
127
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
128
+ <path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z" />
129
+ </svg>
130
+ )
131
+ }
132
+
133
+ function UnfoldIcon() {
134
+ return <OcticonUnfold size={12} />
135
+ }
136
+
137
+ function FoldIcon() {
138
+ return <OcticonFold size={12} />
139
+ }
140
+
125
141
  /** Icon registry — maps icon name strings from config to React components. */
126
142
  const ICON_REGISTRY = {
127
143
  'trash': DeleteIcon,
@@ -140,6 +156,9 @@ const ICON_REGISTRY = {
140
156
  'chevron-down': ChevronDownIcon,
141
157
  'download': DownloadIcon,
142
158
  'expand': ExpandIcon,
159
+ 'sync': SyncIcon,
160
+ 'unfold': UnfoldIcon,
161
+ 'fold': FoldIcon,
143
162
  }
144
163
 
145
164
  /** Danger-styled actions in the overflow menu. */
@@ -56,10 +56,10 @@ describe('Embed interaction overlay', () => {
56
56
  resizable: false,
57
57
  }
58
58
 
59
- it('renders "Click to interact" hint on hover', () => {
59
+ it('renders "Click to open" hint when no snapshot exists', () => {
60
60
  render(<PrototypeEmbed {...defaultProps} />)
61
61
 
62
- const hint = screen.getByText('Click to interact')
62
+ const hint = screen.getByText('Click to open')
63
63
  expect(hint).toBeInTheDocument()
64
64
  // CSS modules mangle class names, just check the element exists
65
65
  })
@@ -68,62 +68,60 @@ describe('Embed interaction overlay', () => {
68
68
  const { container } = render(<PrototypeEmbed {...defaultProps} />)
69
69
 
70
70
  // Overlay should exist before interaction
71
- const overlay = screen.getByRole('button', { name: /click to interact/i })
71
+ const overlay = screen.getByRole('button', { name: /click to open/i })
72
72
  expect(overlay).toBeInTheDocument()
73
73
  expect(container.querySelector('iframe')).not.toBeInTheDocument()
74
74
  expect(screen.getByText('Design Overview')).toBeInTheDocument()
75
- expect(screen.getByText('Design Overview prototype')).toBeInTheDocument()
76
75
 
77
76
  // Single click should remove the overlay (enter interactive mode)
78
77
  fireEvent.click(overlay)
79
78
 
80
79
  // Overlay should no longer exist
81
- expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
82
- expect(screen.queryByText('Design Overview prototype')).not.toBeInTheDocument()
80
+ expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
83
81
  expect(container.querySelector('iframe')).toBeInTheDocument()
84
82
 
85
83
  fireEvent.pointerDown(document.body)
86
- expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
84
+ expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
87
85
  expect(container.querySelector('iframe')).not.toBeInTheDocument()
88
86
  })
89
87
 
90
88
  it('does not enter interactive mode on shift+click (preserves multi-select)', () => {
91
89
  render(<PrototypeEmbed {...defaultProps} />)
92
90
 
93
- const overlay = screen.getByRole('button', { name: /click to interact/i })
91
+ const overlay = screen.getByRole('button', { name: /click to open/i })
94
92
  fireEvent.click(overlay, { shiftKey: true })
95
93
 
96
94
  // Overlay should still exist (did not enter interactive mode)
97
- expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
95
+ expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
98
96
  })
99
97
 
100
98
  it('does not enter interactive mode on meta+click (preserves multi-select)', () => {
101
99
  render(<PrototypeEmbed {...defaultProps} />)
102
100
 
103
- const overlay = screen.getByRole('button', { name: /click to interact/i })
101
+ const overlay = screen.getByRole('button', { name: /click to open/i })
104
102
  fireEvent.click(overlay, { metaKey: true })
105
103
 
106
- expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
104
+ expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
107
105
  })
108
106
 
109
107
  it('supports keyboard interaction (Enter key) with event prevention', () => {
110
108
  render(<PrototypeEmbed {...defaultProps} />)
111
109
 
112
- const overlay = screen.getByRole('button', { name: /click to interact/i })
110
+ const overlay = screen.getByRole('button', { name: /click to open/i })
113
111
  const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() }
114
112
  fireEvent.keyDown(overlay, event)
115
113
 
116
- expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
114
+ expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
117
115
  })
118
116
 
119
117
  it('supports keyboard interaction (Space key) with event prevention', () => {
120
118
  render(<PrototypeEmbed {...defaultProps} />)
121
119
 
122
- const overlay = screen.getByRole('button', { name: /click to interact/i })
120
+ const overlay = screen.getByRole('button', { name: /click to open/i })
123
121
  const event = { key: ' ', preventDefault: vi.fn(), stopPropagation: vi.fn() }
124
122
  fireEvent.keyDown(overlay, event)
125
123
 
126
- expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
124
+ expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
127
125
  })
128
126
  })
129
127
 
@@ -167,16 +165,16 @@ describe('Embed interaction overlay', () => {
167
165
  it('mounts iframe only after user activation', () => {
168
166
  const { container } = render(<StoryWidget {...defaultProps} />)
169
167
 
170
- const overlay = screen.getByRole('button', { name: /click to interact with story component/i })
168
+ const overlay = screen.getByRole('button', { name: /click to open story component/i })
171
169
  expect(container.querySelector('iframe')).not.toBeInTheDocument()
172
170
 
173
171
  fireEvent.click(overlay)
174
172
 
175
- expect(screen.queryByRole('button', { name: /click to interact with story component/i })).not.toBeInTheDocument()
173
+ expect(screen.queryByRole('button', { name: /click to open story component/i })).not.toBeInTheDocument()
176
174
  expect(container.querySelector('iframe')).toBeInTheDocument()
177
175
 
178
176
  fireEvent.pointerDown(document.body)
179
- expect(screen.getByRole('button', { name: /click to interact with story component/i })).toBeInTheDocument()
177
+ expect(screen.getByRole('button', { name: /click to open story component/i })).toBeInTheDocument()
180
178
  expect(container.querySelector('iframe')).not.toBeInTheDocument()
181
179
  })
182
180
  })
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export function resolveCanvasTheme() {
6
6
  if (typeof localStorage === 'undefined') return 'light'
7
- let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
7
+ let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: true }
8
8
  try {
9
9
  const rawSync = localStorage.getItem('sb-theme-sync')
10
10
  if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
@@ -18,6 +18,42 @@ export function resolveCanvasTheme() {
18
18
  window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
19
19
  }
20
20
 
21
+ /**
22
+ * Subscribe to canvas theme changes for embed widget snapshot switching.
23
+ *
24
+ * Reads `data-sb-canvas-theme` from the nearest ancestor set by CanvasPage.
25
+ * With canvas sync ON (the default), this attribute follows the global theme.
26
+ * Uses MutationObserver for immediate reaction + event backup.
27
+ */
28
+ export function subscribeCanvasTheme({ anchorRef, onTheme }) {
29
+ if (typeof onTheme !== 'function') return () => {}
30
+
31
+ let observed = null
32
+ let observer = null
33
+
34
+ function readAndEmit() {
35
+ const el = anchorRef?.current?.closest?.('[data-sb-canvas-theme]') || null
36
+ if (el !== observed) {
37
+ if (observer) observer.disconnect()
38
+ observer = null
39
+ observed = el
40
+ if (el && typeof MutationObserver !== 'undefined') {
41
+ observer = new MutationObserver(readAndEmit)
42
+ observer.observe(el, { attributes: true, attributeFilter: ['data-sb-canvas-theme'] })
43
+ }
44
+ }
45
+ onTheme(el?.getAttribute('data-sb-canvas-theme') || 'light')
46
+ }
47
+
48
+ readAndEmit()
49
+ document.addEventListener('storyboard:theme:changed', readAndEmit)
50
+
51
+ return () => {
52
+ document.removeEventListener('storyboard:theme:changed', readAndEmit)
53
+ if (observer) observer.disconnect()
54
+ }
55
+ }
56
+
21
57
  export function getEmbedChromeVars(theme) {
22
58
  const value = String(theme || 'light')
23
59
  if (value === 'dark_dimmed') {
@@ -0,0 +1,82 @@
1
+ const GH_HOST_RE = /^(www\.)?github\.com$/i
2
+ const ISSUE_PATH_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/
3
+ const DISCUSSION_PATH_RE = /^\/([^/]+)\/([^/]+)\/discussions\/(\d+)\/?$/
4
+ const PULL_REQUEST_PATH_RE = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/
5
+ const ISSUE_COMMENT_HASH_RE = /^#issuecomment-(\d+)$/i
6
+ const DISCUSSION_COMMENT_HASH_RE = /^#discussioncomment-(\d+)$/i
7
+
8
+ function toNumber(raw) {
9
+ const value = Number.parseInt(raw, 10)
10
+ return Number.isFinite(value) ? value : null
11
+ }
12
+
13
+ /**
14
+ * Parse supported GitHub embed URLs (issues, discussions, comments).
15
+ * @param {string} rawUrl
16
+ * @returns {null | {
17
+ * kind: 'issue' | 'discussion' | 'comment',
18
+ * parentKind: 'issue' | 'discussion',
19
+ * owner: string,
20
+ * repo: string,
21
+ * number: number,
22
+ * commentId?: number
23
+ * }}
24
+ */
25
+ export function parseGitHubUrl(rawUrl) {
26
+ try {
27
+ const parsed = new URL(rawUrl)
28
+ if (!GH_HOST_RE.test(parsed.hostname)) return null
29
+
30
+ const issueMatch = parsed.pathname.match(ISSUE_PATH_RE)
31
+ if (issueMatch) {
32
+ const [, owner, repo, numberRaw] = issueMatch
33
+ const number = toNumber(numberRaw)
34
+ if (!number) return null
35
+
36
+ const commentMatch = parsed.hash.match(ISSUE_COMMENT_HASH_RE)
37
+ if (commentMatch) {
38
+ const commentId = toNumber(commentMatch[1])
39
+ if (!commentId) return null
40
+ return { kind: 'comment', parentKind: 'issue', owner, repo, number, commentId }
41
+ }
42
+
43
+ if (parsed.hash) return null
44
+ return { kind: 'issue', parentKind: 'issue', owner, repo, number }
45
+ }
46
+
47
+ const discussionMatch = parsed.pathname.match(DISCUSSION_PATH_RE)
48
+ if (discussionMatch) {
49
+ const [, owner, repo, numberRaw] = discussionMatch
50
+ const number = toNumber(numberRaw)
51
+ if (!number) return null
52
+
53
+ const commentMatch = parsed.hash.match(DISCUSSION_COMMENT_HASH_RE)
54
+ if (commentMatch) {
55
+ const commentId = toNumber(commentMatch[1])
56
+ if (!commentId) return null
57
+ return { kind: 'comment', parentKind: 'discussion', owner, repo, number, commentId }
58
+ }
59
+
60
+ if (parsed.hash) return null
61
+ return { kind: 'discussion', parentKind: 'discussion', owner, repo, number }
62
+ }
63
+
64
+ const prMatch = parsed.pathname.match(PULL_REQUEST_PATH_RE)
65
+ if (prMatch) {
66
+ const [, owner, repo, numberRaw] = prMatch
67
+ const number = toNumber(numberRaw)
68
+ if (!number) return null
69
+
70
+ if (parsed.hash) return null
71
+ return { kind: 'pull_request', parentKind: 'pull_request', owner, repo, number }
72
+ }
73
+ } catch {
74
+ return null
75
+ }
76
+
77
+ return null
78
+ }
79
+
80
+ export function isGitHubEmbedUrl(rawUrl) {
81
+ return parseGitHubUrl(rawUrl) !== null
82
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { isGitHubEmbedUrl, parseGitHubUrl } from './githubUrl.js'
3
+
4
+ describe('parseGitHubUrl', () => {
5
+ it('classifies issue URLs', () => {
6
+ expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12')).toEqual({
7
+ kind: 'issue',
8
+ parentKind: 'issue',
9
+ owner: 'dfosco',
10
+ repo: 'storyboard',
11
+ number: 12,
12
+ })
13
+ })
14
+
15
+ it('classifies discussion URLs', () => {
16
+ expect(parseGitHubUrl('https://github.com/dfosco/storyboard/discussions/99')).toEqual({
17
+ kind: 'discussion',
18
+ parentKind: 'discussion',
19
+ owner: 'dfosco',
20
+ repo: 'storyboard',
21
+ number: 99,
22
+ })
23
+ })
24
+
25
+ it('classifies issue comment URLs', () => {
26
+ expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12#issuecomment-345')).toEqual({
27
+ kind: 'comment',
28
+ parentKind: 'issue',
29
+ owner: 'dfosco',
30
+ repo: 'storyboard',
31
+ number: 12,
32
+ commentId: 345,
33
+ })
34
+ })
35
+
36
+ it('classifies discussion comment URLs', () => {
37
+ expect(parseGitHubUrl('https://github.com/dfosco/storyboard/discussions/99#discussioncomment-888')).toEqual({
38
+ kind: 'comment',
39
+ parentKind: 'discussion',
40
+ owner: 'dfosco',
41
+ repo: 'storyboard',
42
+ number: 99,
43
+ commentId: 888,
44
+ })
45
+ })
46
+
47
+ it('classifies pull request URLs', () => {
48
+ expect(parseGitHubUrl('https://github.com/dfosco/storyboard/pull/12')).toEqual({
49
+ kind: 'pull_request',
50
+ parentKind: 'pull_request',
51
+ owner: 'dfosco',
52
+ repo: 'storyboard',
53
+ number: 12,
54
+ })
55
+ })
56
+
57
+ it('rejects unsupported paths and hashes', () => {
58
+ expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12#random')).toBeNull()
59
+ expect(parseGitHubUrl('https://example.com/dfosco/storyboard/issues/12')).toBeNull()
60
+ expect(parseGitHubUrl('not a url')).toBeNull()
61
+ })
62
+ })
63
+
64
+ describe('isGitHubEmbedUrl', () => {
65
+ it('returns true for supported GitHub URLs', () => {
66
+ expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/issues/12')).toBe(true)
67
+ expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/discussions/99#discussioncomment-888')).toBe(true)
68
+ expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/pull/1')).toBe(true)
69
+ })
70
+
71
+ it('returns false for unsupported URLs', () => {
72
+ expect(isGitHubEmbedUrl('https://example.com')).toBe(false)
73
+ })
74
+ })
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Concurrent refresh queue for bulk snapshot recapture (e.g. on theme change).
3
+ *
4
+ * Captures run in parallel (up to MAX_CONCURRENT) for speed, but REVEALS are
5
+ * staggered on a fixed timeline — widget 0 reveals at 0ms, widget 1 at
6
+ * REVEAL_INTERVAL ms, widget 2 at 2×REVEAL_INTERVAL ms, etc., all relative to
7
+ * batch start. This creates a clean, predictable wave sweep regardless of how
8
+ * fast each capture completes.
9
+ *
10
+ * After a batch completes, any widgets that failed are re-enqueued for a
11
+ * single retry pass.
12
+ *
13
+ * Sorted spatially (top-to-bottom, left-to-right) before assigning reveal slots.
14
+ * Supports cancellation by widget ID.
15
+ */
16
+ const queue = []
17
+ let running = 0
18
+ let drainScheduled = false
19
+ let batchTotal = 0
20
+ let batchDone = 0
21
+ const batchFailed = []
22
+
23
+ const MAX_CONCURRENT = 4
24
+ export const REVEAL_INTERVAL = 200
25
+
26
+ /**
27
+ * Enqueue a snapshot refresh task for a widget.
28
+ * @param {string} widgetId — unique widget identifier (for cancellation)
29
+ * @param {(meta: { revealOrder: number, batchStart: number }) => Promise<boolean>} fn
30
+ * Must resolve to `true` on success, `false` on failure.
31
+ * @param {{ x: number, y: number }} [pos] — spatial position for wave ordering
32
+ */
33
+ export function enqueueRefresh(widgetId, fn, pos) {
34
+ const existing = queue.findIndex(item => item.widgetId === widgetId)
35
+ if (existing !== -1) queue.splice(existing, 1)
36
+
37
+ queue.push({ widgetId, fn, x: pos?.x ?? 0, y: pos?.y ?? 0 })
38
+ scheduleDrain()
39
+ }
40
+
41
+ /**
42
+ * Cancel a pending refresh for a widget (e.g. user activated it manually).
43
+ */
44
+ export function cancelRefresh(widgetId) {
45
+ const idx = queue.findIndex(item => item.widgetId === widgetId)
46
+ if (idx !== -1) queue.splice(idx, 1)
47
+ }
48
+
49
+ function scheduleDrain() {
50
+ if (drainScheduled) return
51
+ drainScheduled = true
52
+ // Batch all enqueueRefresh calls from the same React commit, then sort
53
+ // spatially and assign reveal slots before starting captures.
54
+ setTimeout(() => {
55
+ drainScheduled = false
56
+ queue.sort((a, b) => a.y - b.y || a.x - b.x)
57
+ const batchStart = Date.now()
58
+ batchTotal = queue.length
59
+ batchDone = 0
60
+ batchFailed.length = 0
61
+ queue.forEach((item, i) => {
62
+ item.revealOrder = i
63
+ item.batchStart = batchStart
64
+ item.isRetry = item.isRetry || false
65
+ })
66
+ drain()
67
+ }, 0)
68
+ }
69
+
70
+ function onTaskDone(success, item) {
71
+ batchDone++
72
+ if (!success && !item.isRetry) {
73
+ batchFailed.push(item)
74
+ }
75
+ // When batch is complete, re-enqueue failures for one retry
76
+ if (batchDone >= batchTotal && batchFailed.length > 0) {
77
+ const retries = batchFailed.splice(0)
78
+ for (const failed of retries) {
79
+ failed.isRetry = true
80
+ queue.push(failed)
81
+ }
82
+ batchTotal = queue.length
83
+ batchDone = 0
84
+ const batchStart = Date.now()
85
+ queue.forEach((item, i) => {
86
+ item.revealOrder = i
87
+ item.batchStart = batchStart
88
+ })
89
+ }
90
+ drain()
91
+ }
92
+
93
+ function drain() {
94
+ if (running >= MAX_CONCURRENT || queue.length === 0) return
95
+
96
+ running++
97
+ const item = queue.shift()
98
+ const { fn, revealOrder, batchStart } = item
99
+ Promise.resolve()
100
+ .then(() => fn({ revealOrder, batchStart }))
101
+ .then((success) => { running--; onTaskDone(success !== false, item) })
102
+ .catch(() => { running--; onTaskDone(false, item) })
103
+
104
+ // Start next capture immediately (no stagger on capture start — only reveals are staggered)
105
+ if (queue.length > 0 && running < MAX_CONCURRENT) {
106
+ drain()
107
+ }
108
+ }