@dfosco/storyboard-core 3.6.0 → 3.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 (50) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +12274 -11387
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +1 -1
  6. package/src/CanvasZoomControl.svelte +8 -8
  7. package/src/CommentsMenuButton.svelte +7 -21
  8. package/src/CoreUIBar.svelte +19 -3
  9. package/src/CreateMenuButton.svelte +8 -12
  10. package/src/InspectorPanel.svelte +12 -15
  11. package/src/SidePanel.svelte +14 -14
  12. package/src/assets/fonts/IoskeleyMono-Bold.woff2 +0 -0
  13. package/src/assets/fonts/IoskeleyMono-Italic.woff2 +0 -0
  14. package/src/assets/fonts/IoskeleyMono-Medium.woff2 +0 -0
  15. package/src/assets/fonts/IoskeleyMono-Regular.woff2 +0 -0
  16. package/src/assets/fonts/IoskeleyMono-SemiBold.woff2 +0 -0
  17. package/src/comments/ui/AuthModal.svelte +45 -12
  18. package/src/comments/ui/authModal.js +6 -1
  19. package/src/comments/ui/comment-layout.css +15 -15
  20. package/src/comments/ui/commentWindow.js +6 -1
  21. package/src/comments/ui/comments.css +57 -57
  22. package/src/comments/ui/commentsDrawer.js +2 -0
  23. package/src/comments/ui/composer.js +7 -2
  24. package/src/comments/ui/mount.js +252 -33
  25. package/src/comments/ui/mount.test.js +138 -0
  26. package/src/core-ui-colors.css +28 -28
  27. package/src/inspector/mouseMode.js +2 -2
  28. package/src/lib/components/ui/button/button.svelte +9 -9
  29. package/src/lib/components/ui/panel/panel-content.svelte +2 -2
  30. package/src/lib/components/ui/select/select-trigger.svelte +1 -1
  31. package/src/lib/components/ui/toggle/toggle.svelte +1 -1
  32. package/src/lib/components/ui/toggle-group/toggle-group.svelte +2 -2
  33. package/src/lib/components/ui/trigger-button/trigger-button.svelte +13 -13
  34. package/src/modes.css +21 -21
  35. package/src/mountStoryboardCore.js +4 -4
  36. package/src/sidepanel.css +11 -11
  37. package/src/styles/tailwind.css +89 -1
  38. package/src/svelte-plugin-ui/components/ModeSwitch.svelte +3 -3
  39. package/src/svelte-plugin-ui/components/Viewfinder.svelte +31 -11
  40. package/src/svelte-plugin-ui/styles/base.css +41 -41
  41. package/src/workshop/features/createFlow/CreateFlowForm.svelte +187 -25
  42. package/src/workshop/features/createFlow/server.js +437 -40
  43. package/src/workshop/features/createPage/CreatePageForm.svelte +249 -0
  44. package/src/workshop/features/createPage/index.js +11 -0
  45. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +77 -24
  46. package/src/workshop/features/createPrototype/server.js +14 -16
  47. package/src/workshop/features/registry-server.js +1 -0
  48. package/src/workshop/features/registry.js +2 -0
  49. package/src/workshop/features/templateIndex.js +155 -0
  50. package/toolbar.config.json +2 -1
@@ -4,7 +4,7 @@
4
4
  * Call mountComments() once at app startup (after initCommentsConfig).
5
5
  */
6
6
 
7
- import { isCommentsEnabled } from '../config.js'
7
+ import { getCommentsConfig, isCommentsEnabled } from '../config.js'
8
8
  import { isAuthenticated, getCachedUser } from '../auth.js'
9
9
  import { toggleCommentMode, setCommentMode, isCommentModeActive, subscribeToCommentMode } from '../commentMode.js'
10
10
  import { fetchRouteCommentsSummary, fetchCommentDetail, moveComment, createComment } from '../api.js'
@@ -13,11 +13,20 @@ import { showComposer } from './composer.js'
13
13
  import { openAuthModal } from './authModal.js'
14
14
  import { showCommentWindow, closeCommentWindow } from './commentWindow.js'
15
15
 
16
+ const INVALID_PAT_ERROR_MESSAGE = 'GitHub PAT is invalid or expired. Please sign in again.'
17
+ const TOKEN_ACCESS_ERROR_MESSAGE =
18
+ `Token doesn't have access to repository discussions. ` +
19
+ 'Fine-grained tokens need "Discussions: Read and write". ' +
20
+ 'Classic tokens need the "repo" scope.'
21
+
16
22
  let banner = null
17
23
  let overlay = null
18
24
  let activeComposer = null
19
25
  let renderedPins = []
20
26
  let cachedDiscussion = null
27
+ const CANVAS_SCROLL_SELECTOR = '[data-storyboard-canvas-scroll]'
28
+ const CANVAS_ZOOM_SELECTOR = '[data-storyboard-canvas-zoom]'
29
+ const CANVAS_SURFACE_SELECTOR = '.tc-canvas'
21
30
 
22
31
  function esc(str) {
23
32
  const d = document.createElement('div')
@@ -25,12 +34,97 @@ function esc(str) {
25
34
  return d.innerHTML
26
35
  }
27
36
 
37
+ function roundPct(value) {
38
+ return Math.round(value * 10) / 10
39
+ }
40
+
41
+ function parseScale(transform) {
42
+ if (!transform || transform === 'none') return 1
43
+ const scaleMatch = transform.match(/scale\(([^)]+)\)/)
44
+ if (scaleMatch) {
45
+ const parsed = Number.parseFloat(scaleMatch[1])
46
+ if (Number.isFinite(parsed) && parsed > 0) return parsed
47
+ }
48
+ const matrixMatch = transform.match(/matrix\(([^)]+)\)/)
49
+ if (matrixMatch) {
50
+ const first = Number.parseFloat(matrixMatch[1].split(',')[0])
51
+ if (Number.isFinite(first) && first > 0) return first
52
+ }
53
+ return 1
54
+ }
55
+
56
+ function getCanvasContext() {
57
+ const scrollEl = document.querySelector(CANVAS_SCROLL_SELECTOR)
58
+ const zoomEl = document.querySelector(CANVAS_ZOOM_SELECTOR)
59
+ const canvasEl = (zoomEl && zoomEl.querySelector(CANVAS_SURFACE_SELECTOR)) || null
60
+ if (!scrollEl || !zoomEl || !canvasEl) return null
61
+ const scale = parseScale(zoomEl.style.transform || getComputedStyle(zoomEl).transform)
62
+ const width = canvasEl.offsetWidth || canvasEl.clientWidth
63
+ const height = canvasEl.offsetHeight || canvasEl.clientHeight
64
+ if (!width || !height) return null
65
+ return { scrollEl, scale, width, height }
66
+ }
67
+
68
+ function getAnchorPosition(xPct, yPct) {
69
+ const canvas = getCanvasContext()
70
+ if (!canvas) {
71
+ return {
72
+ left: `${xPct}%`,
73
+ top: `${yPct}%`,
74
+ canvas: false,
75
+ }
76
+ }
77
+
78
+ const rect = canvas.scrollEl.getBoundingClientRect()
79
+ const canvasX = (xPct / 100) * canvas.width
80
+ const canvasY = (yPct / 100) * canvas.height
81
+ const left = rect.left + (canvasX * canvas.scale) - canvas.scrollEl.scrollLeft
82
+ const top = rect.top + (canvasY * canvas.scale) - canvas.scrollEl.scrollTop
83
+
84
+ return {
85
+ left: `${left}px`,
86
+ top: `${top}px`,
87
+ canvas: true,
88
+ }
89
+ }
90
+
91
+ function getPercentFromPointer(clientX, clientY) {
92
+ const canvas = getCanvasContext()
93
+ if (canvas) {
94
+ const rect = canvas.scrollEl.getBoundingClientRect()
95
+ const canvasX = (clientX - rect.left + canvas.scrollEl.scrollLeft) / canvas.scale
96
+ const canvasY = (clientY - rect.top + canvas.scrollEl.scrollTop) / canvas.scale
97
+ const xPct = roundPct((canvasX / canvas.width) * 100)
98
+ const yPct = roundPct((canvasY / canvas.height) * 100)
99
+ return { xPct, yPct, canvas: true }
100
+ }
101
+
102
+ const xPct = roundPct((clientX / window.innerWidth) * 100)
103
+ const docHeight = document.documentElement.scrollHeight
104
+ const yPct = roundPct(((clientY + window.scrollY) / docHeight) * 100)
105
+ return { xPct, yPct, canvas: false }
106
+ }
107
+
108
+ function syncOverlayCoordinateSpace() {
109
+ if (!overlay) return
110
+ if (getCanvasContext()) {
111
+ overlay.style.position = 'fixed'
112
+ overlay.style.width = '100vw'
113
+ overlay.style.height = '100vh'
114
+ } else {
115
+ overlay.style.position = 'absolute'
116
+ overlay.style.width = ''
117
+ overlay.style.height = ''
118
+ }
119
+ }
120
+
28
121
  function ensureOverlay() {
29
122
  if (overlay) return overlay
30
123
 
31
124
  overlay = document.createElement('div')
32
125
  overlay.className = 'sb-comment-overlay'
33
126
  document.body.appendChild(overlay)
127
+ syncOverlayCoordinateSpace()
34
128
 
35
129
  // Click handler for placing comments lives on the overlay itself
36
130
  overlay.addEventListener('click', (e) => {
@@ -38,6 +132,26 @@ function ensureOverlay() {
38
132
  if (e.target.closest('.sb-composer') || e.target.closest('.sb-comment-pin') || e.target.closest('.sb-comment-window')) return
39
133
  handleOverlayClick(e)
40
134
  })
135
+ // Keep canvas scroll usable while comment mode is active.
136
+ overlay.addEventListener('wheel', (e) => {
137
+ if (!isCommentModeActive()) return
138
+ const target = e.target
139
+ if (
140
+ target instanceof Element &&
141
+ (target.closest('.sb-composer') || target.closest('.sb-comment-pin') || target.closest('.sb-comment-window'))
142
+ ) {
143
+ return
144
+ }
145
+ const canvas = getCanvasContext()
146
+ if (!canvas) return
147
+ if (typeof canvas.scrollEl.scrollBy === 'function') {
148
+ canvas.scrollEl.scrollBy({ left: e.deltaX, top: e.deltaY, behavior: 'auto' })
149
+ } else {
150
+ canvas.scrollEl.scrollLeft += e.deltaX
151
+ canvas.scrollEl.scrollTop += e.deltaY
152
+ }
153
+ e.preventDefault()
154
+ }, { passive: false })
41
155
 
42
156
  return overlay
43
157
  }
@@ -64,6 +178,48 @@ function getCurrentRoute() {
64
178
  return window.location.pathname
65
179
  }
66
180
 
181
+ function getAuthErrorMessage(err) {
182
+ const message = typeof err === 'string'
183
+ ? err
184
+ : (typeof err?.message === 'string' ? err.message : String(err ?? ''))
185
+
186
+ if (message.includes('invalid or expired')) {
187
+ return INVALID_PAT_ERROR_MESSAGE
188
+ }
189
+
190
+ if (message.includes('Not authenticated — no GitHub PAT found')) {
191
+ return 'Not authenticated — no GitHub PAT found. Please sign in.'
192
+ }
193
+
194
+ if (
195
+ message.includes('Resource not accessible by personal access token') ||
196
+ message.includes('insufficient') ||
197
+ message.includes("doesn't have access")
198
+ ) {
199
+ return TOKEN_ACCESS_ERROR_MESSAGE
200
+ }
201
+
202
+ if (message.includes('Could not resolve to a Repository with the name')) {
203
+ const config = getCommentsConfig()
204
+ const repo = config?.repo?.owner && config?.repo?.name
205
+ ? `${config.repo.owner}/${config.repo.name}`
206
+ : 'the configured repository'
207
+
208
+ return `Token cannot access repository \`${repo}\`. Please set the PAT repository access to \`${repo}\` and include Discussions read/write (classic tokens need repo scope).`
209
+ }
210
+
211
+ return null
212
+ }
213
+
214
+ async function promptReauthForAuthError(err) {
215
+ const errorMessage = getAuthErrorMessage(err)
216
+ if (!errorMessage) return false
217
+
218
+ setCommentMode(false)
219
+ openAuthModal({ initialError: errorMessage })
220
+ return true
221
+ }
222
+
67
223
  function clearPins() {
68
224
  for (const pin of renderedPins) pin.remove()
69
225
  renderedPins = []
@@ -82,8 +238,9 @@ function renderOptimisticPin(ov, xPct, yPct, text, user) {
82
238
  const pendingId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
83
239
  const pin = document.createElement('div')
84
240
  pin.className = 'sb-comment-pin sb-comment-pin-pending absolute br-100 sb-bg pointer sb-shadow pe-auto overflow-hidden'
85
- pin.style.left = `${xPct}%`
86
- pin.style.top = `${yPct}%`
241
+ const anchor = getAnchorPosition(xPct, yPct)
242
+ pin.style.left = anchor.left
243
+ pin.style.top = anchor.top
87
244
  pin.title = `${user?.login ?? 'you'}: ${text.slice(0, 80)}`
88
245
 
89
246
  pin.innerHTML = user?.avatarUrl
@@ -118,7 +275,8 @@ function renderOptimisticPin(ov, xPct, yPct, text, user) {
118
275
  removePendingComment(route, pendingId)
119
276
  pin.classList.remove('sb-comment-pin-pending')
120
277
  reloadComments()
121
- } catch {
278
+ } catch (err) {
279
+ if (await promptReauthForAuthError(err)) return
122
280
  pin.classList.remove('sb-comment-pin-pending')
123
281
  pin.classList.add('sb-comment-pin-failed')
124
282
  pin.title = `⚠ Failed to post — click to retry: ${text.slice(0, 60)}`
@@ -137,8 +295,9 @@ function renderPendingPins(ov) {
137
295
  for (const p of pending) {
138
296
  const pin = document.createElement('div')
139
297
  pin.className = 'sb-comment-pin sb-comment-pin-failed absolute br-100 sb-bg pointer sb-shadow pe-auto overflow-hidden'
140
- pin.style.left = `${p.x}%`
141
- pin.style.top = `${p.y}%`
298
+ const anchor = getAnchorPosition(p.x, p.y)
299
+ pin.style.left = anchor.left
300
+ pin.style.top = anchor.top
142
301
  pin.title = `⚠ Failed to post — click to retry: ${p.text?.slice(0, 60) ?? ''}`
143
302
 
144
303
  pin.innerHTML = p.author?.avatarUrl
@@ -155,7 +314,8 @@ function renderPendingPins(ov) {
155
314
  removePendingComment(route, p.id)
156
315
  pin.remove()
157
316
  reloadComments()
158
- } catch {
317
+ } catch (err) {
318
+ if (await promptReauthForAuthError(err)) return
159
319
  pin.classList.remove('sb-comment-pin-pending')
160
320
  pin.classList.add('sb-comment-pin-failed')
161
321
  pin.title = `⚠ Failed to post — click to retry: ${p.text?.slice(0, 60) ?? ''}`
@@ -171,8 +331,9 @@ function renderPin(ov, comment, index) {
171
331
  const hue = Math.round((index * 137.5) % 360)
172
332
  const pin = document.createElement('div')
173
333
  pin.className = 'sb-comment-pin absolute br-100 sb-bg pointer sb-shadow pe-auto overflow-hidden'
174
- pin.style.left = `${comment.meta?.x ?? 0}%`
175
- pin.style.top = `${comment.meta?.y ?? 0}%`
334
+ const startAnchor = getAnchorPosition(comment.meta?.x ?? 0, comment.meta?.y ?? 0)
335
+ pin.style.left = startAnchor.left
336
+ pin.style.top = startAnchor.top
176
337
  pin.style.setProperty('--pin-hue', String(hue))
177
338
 
178
339
  if (comment.meta?.resolved) pin.setAttribute('data-resolved', 'true')
@@ -192,19 +353,29 @@ function renderPin(ov, comment, index) {
192
353
  dragged = false
193
354
  const startX = e.clientX
194
355
  const startY = e.clientY
195
- const startLeftPct = parseFloat(pin.style.left)
196
- const startTopPct = parseFloat(pin.style.top)
356
+ const startCoords = getPercentFromPointer(e.clientX, e.clientY)
357
+ const startLeftPct = startCoords.xPct
358
+ const startTopPct = startCoords.yPct
359
+ let lastCoords = { xPct: startLeftPct, yPct: startTopPct }
197
360
 
198
361
  const onMove = (ev) => {
199
362
  const dx = ev.clientX - startX
200
363
  const dy = ev.clientY - startY
201
364
  if (!dragged && Math.abs(dx) < 4 && Math.abs(dy) < 4) return
202
365
  dragged = true
203
- const xPct = Math.round((startLeftPct + (dx / window.innerWidth) * 100) * 10) / 10
204
- const docHeight = document.documentElement.scrollHeight
205
- const yPct = Math.round((startTopPct + (dy / docHeight) * 100) * 10) / 10
206
- pin.style.left = `${xPct}%`
207
- pin.style.top = `${yPct}%`
366
+
367
+ if (startCoords.canvas) {
368
+ lastCoords = getPercentFromPointer(ev.clientX, ev.clientY)
369
+ pin.style.left = `${ev.clientX}px`
370
+ pin.style.top = `${ev.clientY}px`
371
+ } else {
372
+ const xPct = roundPct(startLeftPct + (dx / window.innerWidth) * 100)
373
+ const docHeight = document.documentElement.scrollHeight
374
+ const yPct = roundPct(startTopPct + (dy / docHeight) * 100)
375
+ lastCoords = { xPct, yPct }
376
+ pin.style.left = `${xPct}%`
377
+ pin.style.top = `${yPct}%`
378
+ }
208
379
  }
209
380
 
210
381
  const onUp = async (ev) => {
@@ -212,11 +383,15 @@ function renderPin(ov, comment, index) {
212
383
  document.removeEventListener('mouseup', onUp)
213
384
  if (!dragged) return
214
385
 
215
- const dx = ev.clientX - startX
216
- const dy = ev.clientY - startY
217
- const xPct = Math.round((startLeftPct + (dx / window.innerWidth) * 100) * 10) / 10
218
- const docHeight = document.documentElement.scrollHeight
219
- const yPct = Math.round((startTopPct + (dy / docHeight) * 100) * 10) / 10
386
+ let xPct = lastCoords.xPct
387
+ let yPct = lastCoords.yPct
388
+ if (!startCoords.canvas) {
389
+ const dx = ev.clientX - startX
390
+ const dy = ev.clientY - startY
391
+ xPct = roundPct(startLeftPct + (dx / window.innerWidth) * 100)
392
+ const docHeight = document.documentElement.scrollHeight
393
+ yPct = roundPct(startTopPct + (dy / docHeight) * 100)
394
+ }
220
395
  comment.meta = { ...comment.meta, x: xPct, y: yPct }
221
396
 
222
397
  try {
@@ -224,6 +399,7 @@ function renderPin(ov, comment, index) {
224
399
  comment._rawBody = null
225
400
  clearCachedComments(getCurrentRoute())
226
401
  } catch (err) {
402
+ if (await promptReauthForAuthError(err)) return
227
403
  console.error('[storyboard] Failed to move pin:', err)
228
404
  }
229
405
  }
@@ -246,6 +422,7 @@ function renderPin(ov, comment, index) {
246
422
  if (detail) {
247
423
  detail._rawBody = detail.body
248
424
  showCommentWindow(ov, detail, cachedDiscussion, {
425
+ getAnchorPosition,
249
426
  onClose: () => {},
250
427
  onMove: () => reloadComments(),
251
428
  })
@@ -254,6 +431,7 @@ function renderPin(ov, comment, index) {
254
431
  console.warn('[storyboard] Could not load comment detail:', err.message)
255
432
  // Fall back to summary data
256
433
  showCommentWindow(ov, comment, cachedDiscussion, {
434
+ getAnchorPosition,
257
435
  onClose: () => {},
258
436
  onMove: () => reloadComments(),
259
437
  })
@@ -312,6 +490,7 @@ async function loadAndRenderComments() {
312
490
 
313
491
  autoOpenCommentFromUrl(ov, discussion)
314
492
  } catch (err) {
493
+ if (await promptReauthForAuthError(err)) return
315
494
  console.warn('[storyboard] Could not load comments:', err.message)
316
495
  }
317
496
  }
@@ -324,13 +503,25 @@ async function autoOpenCommentFromUrl(ov, discussion) {
324
503
  if (!comment) return
325
504
 
326
505
  if (comment.meta?.y != null) {
327
- const docHeight = document.documentElement.scrollHeight
328
- const yPx = (comment.meta.y / 100) * docHeight
329
- const viewTop = window.scrollY
330
- const viewBottom = viewTop + window.innerHeight
331
- if (yPx < viewTop || yPx > viewBottom) {
332
- const scrollTarget = Math.max(0, yPx - window.innerHeight / 3)
333
- window.scrollTo({ top: scrollTarget, behavior: 'smooth' })
506
+ const canvas = getCanvasContext()
507
+ if (canvas) {
508
+ const canvasY = (comment.meta.y / 100) * canvas.height
509
+ const yPx = canvasY * canvas.scale
510
+ const viewTop = canvas.scrollEl.scrollTop
511
+ const viewBottom = viewTop + canvas.scrollEl.clientHeight
512
+ if (yPx < viewTop || yPx > viewBottom) {
513
+ const scrollTarget = Math.max(0, yPx - canvas.scrollEl.clientHeight / 3)
514
+ canvas.scrollEl.scrollTo({ top: scrollTarget, behavior: 'smooth' })
515
+ }
516
+ } else {
517
+ const docHeight = document.documentElement.scrollHeight
518
+ const yPx = (comment.meta.y / 100) * docHeight
519
+ const viewTop = window.scrollY
520
+ const viewBottom = viewTop + window.innerHeight
521
+ if (yPx < viewTop || yPx > viewBottom) {
522
+ const scrollTarget = Math.max(0, yPx - window.innerHeight / 3)
523
+ window.scrollTo({ top: scrollTarget, behavior: 'smooth' })
524
+ }
334
525
  }
335
526
  }
336
527
 
@@ -340,18 +531,21 @@ async function autoOpenCommentFromUrl(ov, discussion) {
340
531
  if (detail) {
341
532
  detail._rawBody = detail.body
342
533
  showCommentWindow(ov, detail, discussion, {
534
+ getAnchorPosition,
343
535
  onClose: () => {},
344
536
  onMove: () => reloadComments(),
345
537
  })
346
538
  return
347
539
  }
348
540
  } catch (err) {
541
+ if (await promptReauthForAuthError(err)) return
349
542
  console.warn('[storyboard] Could not load comment detail:', err.message)
350
543
  }
351
544
 
352
545
  // Fallback to summary data
353
546
  comment._rawBody = comment.body
354
547
  showCommentWindow(ov, comment, discussion, {
548
+ getAnchorPosition,
355
549
  onClose: () => {},
356
550
  onMove: () => reloadComments(),
357
551
  })
@@ -363,10 +557,7 @@ function handleOverlayClick(e) {
363
557
 
364
558
  closeCommentWindow()
365
559
 
366
- // x as percentage of viewport width, y as percentage of full document height
367
- const xPct = Math.round((e.clientX / window.innerWidth) * 1000) / 10
368
- const docHeight = document.documentElement.scrollHeight
369
- const yPct = Math.round(((e.clientY + window.scrollY) / docHeight) * 1000) / 10
560
+ const { xPct, yPct } = getPercentFromPointer(e.clientX, e.clientY)
370
561
 
371
562
  // Move existing composer instead of destroying and recreating
372
563
  if (activeComposer) {
@@ -377,6 +568,7 @@ function handleOverlayClick(e) {
377
568
  const ov = ensureOverlay()
378
569
  const route = getCurrentRoute()
379
570
  activeComposer = showComposer(ov, xPct, yPct, route, {
571
+ getAnchorPosition,
380
572
  onCancel: () => { activeComposer = null },
381
573
  onSubmitOptimistic: (text, x, y) => {
382
574
  activeComposer = null
@@ -388,8 +580,9 @@ function handleOverlayClick(e) {
388
580
  opt.succeed()
389
581
  reloadComments()
390
582
  })
391
- .catch((err) => {
583
+ .catch(async (err) => {
392
584
  console.error('[storyboard] Failed to post comment:', err)
585
+ if (await promptReauthForAuthError(err)) return
393
586
  opt.fail()
394
587
  })
395
588
  },
@@ -401,6 +594,7 @@ function setBodyCommentMode(active) {
401
594
  document.body.classList.add('sb-comment-mode')
402
595
  showBanner()
403
596
  ensureOverlay()
597
+ syncOverlayCoordinateSpace()
404
598
  renderCachedPins()
405
599
  loadAndRenderComments()
406
600
  } else {
@@ -431,6 +625,31 @@ export function mountComments() {
431
625
  _mounted = true
432
626
 
433
627
  subscribeToCommentMode(setBodyCommentMode)
628
+ window.addEventListener('popstate', () => {
629
+ if (isCommentModeActive()) {
630
+ setCommentMode(false)
631
+ }
632
+ })
633
+
634
+ document.addEventListener('storyboard:canvas:mounted', () => {
635
+ syncOverlayCoordinateSpace()
636
+ if (isCommentModeActive()) renderCachedPins()
637
+ })
638
+ document.addEventListener('storyboard:canvas:unmounted', () => {
639
+ syncOverlayCoordinateSpace()
640
+ if (isCommentModeActive()) renderCachedPins()
641
+ })
642
+ document.addEventListener('storyboard:canvas:zoom-changed', () => {
643
+ syncOverlayCoordinateSpace()
644
+ if (isCommentModeActive()) renderCachedPins()
645
+ })
646
+ document.addEventListener('scroll', (e) => {
647
+ const target = e.target
648
+ if (!(target instanceof Element)) return
649
+ if (!target.matches(CANVAS_SCROLL_SELECTOR)) return
650
+ if (!isCommentModeActive()) return
651
+ renderCachedPins()
652
+ }, true)
434
653
 
435
654
  window.addEventListener('keydown', (e) => {
436
655
  const tag = e.target.tagName
@@ -48,6 +48,9 @@ describe('mount.js', () => {
48
48
  let initCommentsConfig
49
49
  let setToken
50
50
  let clearToken
51
+ let createComment
52
+ let openAuthModal
53
+ let showComposer
51
54
 
52
55
  beforeEach(async () => {
53
56
  // Reset DOM
@@ -85,6 +88,9 @@ describe('mount.js', () => {
85
88
  const commentModeMod = await import('../commentMode.js')
86
89
  const configMod = await import('../config.js')
87
90
  const authMod = await import('../auth.js')
91
+ const apiMod = await import('../api.js')
92
+ const authModalMod = await import('./authModal.js')
93
+ const composerMod = await import('./composer.js')
88
94
 
89
95
  mountComments = mountMod.mountComments
90
96
  setCommentMode = commentModeMod.setCommentMode
@@ -92,6 +98,9 @@ describe('mount.js', () => {
92
98
  initCommentsConfig = configMod.initCommentsConfig
93
99
  setToken = authMod.setToken
94
100
  clearToken = authMod.clearToken
101
+ createComment = apiMod.createComment
102
+ openAuthModal = authModalMod.openAuthModal
103
+ showComposer = composerMod.showComposer
95
104
 
96
105
  // Reset storyboard state
97
106
  setCommentMode(false)
@@ -195,4 +204,133 @@ describe('mount.js', () => {
195
204
  // No error thrown — _mounted guard prevents double init
196
205
  })
197
206
  })
207
+
208
+ describe('navigation and canvas coordinates', () => {
209
+ it('turns comment mode off on popstate navigation', () => {
210
+ initCommentsConfig({ comments: { repo: { owner: 'o', name: 'r' } } })
211
+ setToken('ghp_test')
212
+ mountComments()
213
+
214
+ setCommentMode(true)
215
+ expect(document.body.classList.contains('sb-comment-mode')).toBe(true)
216
+
217
+ window.dispatchEvent(new PopStateEvent('popstate'))
218
+ expect(document.body.classList.contains('sb-comment-mode')).toBe(false)
219
+ })
220
+
221
+ it('computes click coordinates relative to canvas absolute space', () => {
222
+ initCommentsConfig({ comments: { repo: { owner: 'o', name: 'r' } } })
223
+ setToken('ghp_test')
224
+ mountComments()
225
+
226
+ const scroll = document.createElement('div')
227
+ scroll.setAttribute('data-storyboard-canvas-scroll', '')
228
+ Object.defineProperty(scroll, 'scrollLeft', { value: 100, writable: true })
229
+ Object.defineProperty(scroll, 'scrollTop', { value: 50, writable: true })
230
+ Object.defineProperty(scroll, 'clientHeight', { value: 700, writable: true })
231
+ scroll.getBoundingClientRect = () => ({ left: 20, top: 10, right: 1020, bottom: 710, width: 1000, height: 700 })
232
+
233
+ const zoom = document.createElement('div')
234
+ zoom.setAttribute('data-storyboard-canvas-zoom', '')
235
+ zoom.style.transform = 'scale(2)'
236
+
237
+ const surface = document.createElement('main')
238
+ surface.className = 'tc-canvas'
239
+ Object.defineProperty(surface, 'offsetWidth', { value: 10000, writable: true })
240
+ Object.defineProperty(surface, 'offsetHeight', { value: 10000, writable: true })
241
+ zoom.appendChild(surface)
242
+ scroll.appendChild(zoom)
243
+ document.body.appendChild(scroll)
244
+
245
+ let captured = null
246
+ showComposer.mockImplementation((ov, x, y) => {
247
+ captured = { x, y }
248
+ return { destroy: vi.fn(), moveTo: vi.fn() }
249
+ })
250
+
251
+ setCommentMode(true)
252
+ const overlay = document.body.querySelector('.sb-comment-overlay')
253
+ overlay.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 220, clientY: 210 }))
254
+
255
+ expect(captured).toEqual({ x: 1.5, y: 1.3 })
256
+ })
257
+
258
+ it('scrolls canvas on wheel while comment mode is active', () => {
259
+ initCommentsConfig({ comments: { repo: { owner: 'o', name: 'r' } } })
260
+ setToken('ghp_test')
261
+ mountComments()
262
+
263
+ const scroll = document.createElement('div')
264
+ scroll.setAttribute('data-storyboard-canvas-scroll', '')
265
+ Object.defineProperty(scroll, 'scrollLeft', { value: 0, writable: true })
266
+ Object.defineProperty(scroll, 'scrollTop', { value: 0, writable: true })
267
+ scroll.scrollBy = vi.fn(({ left = 0, top = 0 }) => {
268
+ scroll.scrollLeft += left
269
+ scroll.scrollTop += top
270
+ })
271
+ scroll.getBoundingClientRect = () => ({ left: 0, top: 0, right: 1000, bottom: 700, width: 1000, height: 700 })
272
+
273
+ const zoom = document.createElement('div')
274
+ zoom.setAttribute('data-storyboard-canvas-zoom', '')
275
+ zoom.style.transform = 'scale(1)'
276
+
277
+ const surface = document.createElement('main')
278
+ surface.className = 'tc-canvas'
279
+ Object.defineProperty(surface, 'offsetWidth', { value: 10000, writable: true })
280
+ Object.defineProperty(surface, 'offsetHeight', { value: 10000, writable: true })
281
+ zoom.appendChild(surface)
282
+ scroll.appendChild(zoom)
283
+ document.body.appendChild(scroll)
284
+
285
+ setCommentMode(true)
286
+ const overlay = document.body.querySelector('.sb-comment-overlay')
287
+ const wheelEvent = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaX: 8, deltaY: 24 })
288
+ overlay.dispatchEvent(wheelEvent)
289
+
290
+ expect(scroll.scrollBy).toHaveBeenCalledTimes(1)
291
+ expect(scroll.scrollLeft).toBe(8)
292
+ expect(scroll.scrollTop).toBe(24)
293
+ expect(wheelEvent.defaultPrevented).toBe(true)
294
+ })
295
+ })
296
+
297
+ describe('auth error handling', () => {
298
+ it('opens auth modal with repo-specific message when PAT lacks repository access during submit', async () => {
299
+ initCommentsConfig({
300
+ comments: { discussions: { category: 'Comments' } },
301
+ repository: { owner: 'correct', name: 'repository' },
302
+ })
303
+ setToken('ghp_test')
304
+
305
+ createComment.mockRejectedValueOnce(
306
+ new Error("GraphQL error: Could not resolve to a Repository with the name 'github/storyboard'.")
307
+ )
308
+
309
+ showComposer.mockImplementation((ov, x, y, route, opts) => {
310
+ queueMicrotask(() => {
311
+ opts.onSubmitOptimistic('Hello', x, y)
312
+ })
313
+ return {
314
+ destroy: vi.fn(),
315
+ moveTo: vi.fn(),
316
+ }
317
+ })
318
+
319
+ mountComments()
320
+ setCommentMode(true)
321
+
322
+ const overlay = document.body.querySelector('.sb-comment-overlay')
323
+ overlay.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 10, clientY: 10 }))
324
+
325
+ await Promise.resolve()
326
+ await Promise.resolve()
327
+ await new Promise((resolve) => setTimeout(resolve, 0))
328
+
329
+ expect(openAuthModal).toHaveBeenCalledTimes(1)
330
+ const call = openAuthModal.mock.calls[0]?.[0]
331
+ expect(call?.initialError).toContain('`correct/repository`')
332
+ expect(call?.initialError).toContain('PAT repository access')
333
+ expect(document.body.classList.contains('sb-comment-mode')).toBe(false)
334
+ })
335
+ })
198
336
  })