@dfosco/storyboard-core 1.13.0 → 1.15.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,7 +24,7 @@ const SCENE_PREFIX = 'sb-scene--'
24
24
  function sanitize(str) {
25
25
  return String(str)
26
26
  .toLowerCase()
27
- .replace(/[\.\s]+/g, '-')
27
+ .replace(/[.\s]+/g, '-')
28
28
  .replace(/[^a-z0-9-]/g, '')
29
29
  .replace(/-+/g, '-')
30
30
  .replace(/^-|-$/g, '')
@@ -35,7 +35,8 @@ describe('commentMode', () => {
35
35
 
36
36
  it('toggleCommentMode returns false when not authenticated', () => {
37
37
  initCommentsConfig({
38
- comments: { repo: { owner: 'o', name: 'r' } },
38
+ comments: { discussions: { category: 'Test' } },
39
+ repository: { owner: 'o', name: 'r' },
39
40
  })
40
41
  const result = toggleCommentMode()
41
42
  expect(result).toBe(false)
@@ -44,7 +45,8 @@ describe('commentMode', () => {
44
45
 
45
46
  it('toggleCommentMode activates when enabled and authenticated', () => {
46
47
  initCommentsConfig({
47
- comments: { repo: { owner: 'o', name: 'r' } },
48
+ comments: { discussions: { category: 'Test' } },
49
+ repository: { owner: 'o', name: 'r' },
48
50
  })
49
51
  setToken('ghp_test')
50
52
  const result = toggleCommentMode()
@@ -54,7 +56,8 @@ describe('commentMode', () => {
54
56
 
55
57
  it('toggleCommentMode toggles off when active', () => {
56
58
  initCommentsConfig({
57
- comments: { repo: { owner: 'o', name: 'r' } },
59
+ comments: { discussions: { category: 'Test' } },
60
+ repository: { owner: 'o', name: 'r' },
58
61
  })
59
62
  setToken('ghp_test')
60
63
  toggleCommentMode() // on
@@ -8,6 +8,10 @@ import { setToken, validateToken, clearToken, getCachedUser } from '../auth.js'
8
8
 
9
9
  const MODAL_ID = 'sb-auth-modal'
10
10
 
11
+ // Mutable ref updated on each openAuthModal() call so the Alpine factory
12
+ // (registered once) always reaches the *current* modal's resolve/backdrop.
13
+ const _ref = { resolve: null, onKeyDown: null, backdrop: null }
14
+
11
15
  /**
12
16
  * Open the auth modal. Returns a promise that resolves with the user info
13
17
  * on successful sign-in, or null if cancelled.
@@ -65,27 +69,34 @@ export function openAuthModal() {
65
69
 
66
70
  document.body.appendChild(backdrop)
67
71
 
68
- // Close on backdrop click
69
- backdrop.addEventListener('click', (e) => {
70
- if (e.target === backdrop) {
71
- backdrop.remove()
72
- resolve(null)
73
- }
74
- })
75
-
76
72
  // Escape key
77
73
  function onKeyDown(e) {
78
74
  if (e.key === 'Escape') {
79
75
  e.preventDefault()
80
76
  e.stopPropagation()
81
- window.removeEventListener('keydown', onKeyDown, true)
82
- backdrop.remove()
83
- resolve(null)
77
+ window.removeEventListener('keydown', _ref.onKeyDown, true)
78
+ _ref.backdrop.remove()
79
+ _ref.resolve(null)
84
80
  }
85
81
  }
82
+
83
+ // Update shared ref so Alpine callbacks always target the current modal
84
+ _ref.resolve = resolve
85
+ _ref.onKeyDown = onKeyDown
86
+ _ref.backdrop = backdrop
87
+
88
+ // Close on backdrop click
89
+ backdrop.addEventListener('click', (e) => {
90
+ if (e.target === backdrop) {
91
+ window.removeEventListener('keydown', _ref.onKeyDown, true)
92
+ _ref.backdrop.remove()
93
+ _ref.resolve(null)
94
+ }
95
+ })
96
+
86
97
  window.addEventListener('keydown', onKeyDown, true)
87
98
 
88
- // Register Alpine component
99
+ // Register Alpine component once — reads _ref for current modal context
89
100
  if (!window.Alpine._sbAuthRegistered) {
90
101
  window.Alpine.data('sbAuthModal', () => ({
91
102
  token: '',
@@ -112,16 +123,16 @@ export function openAuthModal() {
112
123
  },
113
124
 
114
125
  done() {
115
- window.removeEventListener('keydown', onKeyDown, true)
126
+ window.removeEventListener('keydown', _ref.onKeyDown, true)
116
127
  const user = this.user
117
- backdrop.remove()
118
- resolve(user)
128
+ _ref.backdrop.remove()
129
+ _ref.resolve(user)
119
130
  },
120
131
 
121
132
  close() {
122
- window.removeEventListener('keydown', onKeyDown, true)
123
- backdrop.remove()
124
- resolve(null)
133
+ window.removeEventListener('keydown', _ref.onKeyDown, true)
134
+ _ref.backdrop.remove()
135
+ _ref.resolve(null)
125
136
  },
126
137
  }))
127
138
  window.Alpine._sbAuthRegistered = true
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Tests for authModal.js — PAT entry modal lifecycle.
3
+ *
4
+ * Alpine.js is stubbed: initTree triggers the Alpine component methods
5
+ * directly so we can test the modal's resolve/reject/close behavior
6
+ * without a real Alpine runtime.
7
+ */
8
+
9
+ import { vi } from 'vitest'
10
+ import { clearToken } from '../auth.js' // eslint-disable-line no-unused-vars -- kept for future test coverage
11
+
12
+ // Store the Alpine component factory so tests can call done()/close()/submit()
13
+ let alpineFactory = null
14
+ let alpineInitTree = null
15
+
16
+ vi.mock('alpinejs', () => ({
17
+ default: {
18
+ start: vi.fn(),
19
+ data: vi.fn((name, factory) => { alpineFactory = factory }),
20
+ initTree: vi.fn((el) => { alpineInitTree?.(el) }),
21
+ },
22
+ }))
23
+
24
+ describe('authModal.js', () => {
25
+ let openAuthModal, signOut
26
+
27
+ beforeEach(async () => {
28
+ document.body.innerHTML = ''
29
+ localStorage.clear()
30
+ alpineFactory = null
31
+ alpineInitTree = null
32
+
33
+ vi.resetModules()
34
+
35
+ const mockAlpine = {
36
+ _sbAuthRegistered: false,
37
+ start: vi.fn(),
38
+ data: vi.fn((name, factory) => { alpineFactory = factory }),
39
+ initTree: vi.fn((el) => { alpineInitTree?.(el) }),
40
+ }
41
+
42
+ vi.doMock('alpinejs', () => ({ default: mockAlpine }))
43
+
44
+ // authModal.js reads window.Alpine directly (set by mount.js at runtime)
45
+ window.Alpine = mockAlpine
46
+
47
+ const mod = await import('./authModal.js')
48
+ openAuthModal = mod.openAuthModal
49
+ signOut = mod.signOut
50
+ })
51
+
52
+ afterEach(() => {
53
+ vi.restoreAllMocks()
54
+ })
55
+
56
+ it('creates a backdrop element in the DOM', () => {
57
+ openAuthModal()
58
+
59
+ const backdrop = document.getElementById('sb-auth-modal')
60
+ expect(backdrop).not.toBeNull()
61
+ expect(backdrop.classList.contains('sb-auth-backdrop')).toBe(true)
62
+ })
63
+
64
+ it('removes old modal before creating a new one', () => {
65
+ openAuthModal()
66
+ openAuthModal()
67
+
68
+ const modals = document.querySelectorAll('#sb-auth-modal')
69
+ expect(modals.length).toBe(1)
70
+ })
71
+
72
+ it('resolves null when Escape is pressed', async () => {
73
+ const promise = openAuthModal()
74
+
75
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
76
+
77
+ const result = await promise
78
+ expect(result).toBeNull()
79
+ expect(document.getElementById('sb-auth-modal')).toBeNull()
80
+ })
81
+
82
+ it('resolves null when backdrop is clicked', async () => {
83
+ const promise = openAuthModal()
84
+
85
+ const backdrop = document.getElementById('sb-auth-modal')
86
+ backdrop.dispatchEvent(new MouseEvent('click', { bubbles: true }))
87
+
88
+ const result = await promise
89
+ expect(result).toBeNull()
90
+ expect(document.getElementById('sb-auth-modal')).toBeNull()
91
+ })
92
+
93
+ it('does not close when inner modal content is clicked', () => {
94
+ openAuthModal()
95
+
96
+ const inner = document.querySelector('.sb-auth-modal')
97
+ inner.dispatchEvent(new MouseEvent('click', { bubbles: true }))
98
+
99
+ // Modal should still be present
100
+ expect(document.getElementById('sb-auth-modal')).not.toBeNull()
101
+ })
102
+
103
+ it('registers Alpine component with sbAuthModal name', () => {
104
+ openAuthModal()
105
+ expect(alpineFactory).toBeTypeOf('function')
106
+ })
107
+
108
+ describe('Alpine component methods via _ref', () => {
109
+ it('done() resolves promise with user and removes modal', async () => {
110
+ const promise = openAuthModal()
111
+
112
+ // Simulate what Alpine does: create instance from factory, set user, call done()
113
+ const instance = alpineFactory()
114
+ instance.user = { login: 'testuser', avatarUrl: 'https://example.com/avatar.png' }
115
+ instance.done()
116
+
117
+ const result = await promise
118
+ expect(result).toEqual({ login: 'testuser', avatarUrl: 'https://example.com/avatar.png' })
119
+ expect(document.getElementById('sb-auth-modal')).toBeNull()
120
+ })
121
+
122
+ it('close() resolves promise with null and removes modal', async () => {
123
+ const promise = openAuthModal()
124
+
125
+ const instance = alpineFactory()
126
+ instance.close()
127
+
128
+ const result = await promise
129
+ expect(result).toBeNull()
130
+ expect(document.getElementById('sb-auth-modal')).toBeNull()
131
+ })
132
+
133
+ it('done() on second modal resolves the second promise, not the first', async () => {
134
+ // Open first modal, then close via Escape
135
+ const promise1 = openAuthModal()
136
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
137
+ const result1 = await promise1
138
+ expect(result1).toBeNull()
139
+
140
+ // Reset Alpine registration so factory is recaptured on next open
141
+ window.Alpine._sbAuthRegistered = false
142
+
143
+ // Open second modal
144
+ const promise2 = openAuthModal()
145
+ const instance2 = alpineFactory()
146
+ instance2.user = { login: 'user2', avatarUrl: 'https://example.com/u2.png' }
147
+ instance2.done()
148
+
149
+ const result2 = await promise2
150
+ expect(result2).toEqual({ login: 'user2', avatarUrl: 'https://example.com/u2.png' })
151
+ expect(document.getElementById('sb-auth-modal')).toBeNull()
152
+ })
153
+ })
154
+
155
+ describe('signOut', () => {
156
+ it('clears the stored token', () => {
157
+ localStorage.setItem('sb-comments-token', 'ghp_test')
158
+ localStorage.setItem('sb-comments-user', JSON.stringify({ login: 'test' }))
159
+
160
+ signOut()
161
+
162
+ expect(localStorage.getItem('sb-comments-token')).toBeNull()
163
+ expect(localStorage.getItem('sb-comments-user')).toBeNull()
164
+ })
165
+ })
166
+ })
@@ -16,7 +16,7 @@ function timeAgo(dateStr) {
16
16
  return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
17
17
  }
18
18
 
19
- function esc(str) {
19
+ function esc(str) { // eslint-disable-line no-unused-vars
20
20
  const d = document.createElement('div')
21
21
  d.textContent = str ?? ''
22
22
  return d.innerHTML
@@ -28,14 +28,12 @@ function esc(str) {
28
28
  }
29
29
 
30
30
  function getContentContainer() {
31
- return document.querySelector('main') || document.body
31
+ return document.body
32
32
  }
33
33
 
34
34
  function ensureOverlay() {
35
35
  if (overlay) return overlay
36
36
  const container = getContentContainer()
37
- const pos = getComputedStyle(container).position
38
- if (pos === 'static') container.style.position = 'relative'
39
37
 
40
38
  overlay = document.createElement('div')
41
39
  overlay.className = 'sb-comment-overlay absolute top-0 right-0 bottom-0 left-0 pe-none'
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Tests for mount.js — comment overlay, banner, and body comment-mode logic.
3
+ *
4
+ * Alpine.js and heavy UI deps are mocked; we test DOM-level behavior of the
5
+ * exported mountComments() plus the internal helpers it exercises.
6
+ */
7
+
8
+ import { vi } from 'vitest'
9
+
10
+ // ---- Mocks (must be before importing mount.js) ----
11
+
12
+ vi.mock('alpinejs', () => ({
13
+ default: {
14
+ start: vi.fn(),
15
+ data: vi.fn(),
16
+ initTree: vi.fn(),
17
+ },
18
+ }))
19
+
20
+ vi.mock('../api.js', () => ({
21
+ fetchRouteCommentsSummary: vi.fn(),
22
+ fetchCommentDetail: vi.fn(),
23
+ moveComment: vi.fn(),
24
+ }))
25
+
26
+ vi.mock('../commentCache.js', () => ({
27
+ getCachedComments: vi.fn(() => null),
28
+ setCachedComments: vi.fn(),
29
+ clearCachedComments: vi.fn(),
30
+ }))
31
+
32
+ vi.mock('./composer.js', () => ({
33
+ showComposer: vi.fn(),
34
+ }))
35
+
36
+ vi.mock('./authModal.js', () => ({
37
+ openAuthModal: vi.fn(),
38
+ }))
39
+
40
+ vi.mock('./commentWindow.js', () => ({
41
+ showCommentWindow: vi.fn(),
42
+ closeCommentWindow: vi.fn(),
43
+ }))
44
+
45
+ describe('mount.js', () => {
46
+ // mountComments() is idempotent via a module-level _mounted flag, so we
47
+ // must re-import the module fresh for each test to reset that flag.
48
+ // All sibling modules must also be re-imported so they share the same instances.
49
+ let mountComments
50
+ let setCommentMode
51
+ let isCommentModeActive // eslint-disable-line no-unused-vars
52
+ let initCommentsConfig
53
+ let setToken
54
+ let clearToken
55
+
56
+ beforeEach(async () => {
57
+ // Reset DOM
58
+ document.body.innerHTML = ''
59
+ document.body.className = ''
60
+ document.body.style.cssText = ''
61
+
62
+ // Fresh import to reset _mounted flag and all module-level state
63
+ vi.resetModules()
64
+
65
+ // Re-mock after resetModules
66
+ vi.doMock('alpinejs', () => ({
67
+ default: {
68
+ start: vi.fn(),
69
+ data: vi.fn(),
70
+ initTree: vi.fn(),
71
+ },
72
+ }))
73
+ vi.doMock('../api.js', () => ({
74
+ fetchRouteCommentsSummary: vi.fn(),
75
+ fetchCommentDetail: vi.fn(),
76
+ moveComment: vi.fn(),
77
+ }))
78
+ vi.doMock('../commentCache.js', () => ({
79
+ getCachedComments: vi.fn(() => null),
80
+ setCachedComments: vi.fn(),
81
+ clearCachedComments: vi.fn(),
82
+ }))
83
+ vi.doMock('./composer.js', () => ({ showComposer: vi.fn() }))
84
+ vi.doMock('./authModal.js', () => ({ openAuthModal: vi.fn() }))
85
+ vi.doMock('./commentWindow.js', () => ({
86
+ showCommentWindow: vi.fn(),
87
+ closeCommentWindow: vi.fn(),
88
+ }))
89
+
90
+ // Import everything fresh so mount.js and its deps share the same instances
91
+ const mountMod = await import('./mount.js')
92
+ const commentModeMod = await import('../commentMode.js')
93
+ const configMod = await import('../config.js')
94
+ const authMod = await import('../auth.js')
95
+
96
+ mountComments = mountMod.mountComments
97
+ setCommentMode = commentModeMod.setCommentMode
98
+ isCommentModeActive = commentModeMod.isCommentModeActive
99
+ initCommentsConfig = configMod.initCommentsConfig
100
+ setToken = authMod.setToken
101
+ clearToken = authMod.clearToken
102
+
103
+ // Reset storyboard state
104
+ setCommentMode(false)
105
+ clearToken()
106
+ initCommentsConfig(null)
107
+ })
108
+
109
+ afterEach(() => {
110
+ vi.restoreAllMocks()
111
+ })
112
+
113
+ describe('ensureOverlay (via setBodyCommentMode)', () => {
114
+ it('does not set position:relative on document.body', () => {
115
+ initCommentsConfig({ comments: { repo: { owner: 'o', name: 'r' } } })
116
+ setToken('ghp_test')
117
+ mountComments()
118
+
119
+ // Activate comment mode — triggers ensureOverlay internally
120
+ setCommentMode(true)
121
+
122
+ // body should NOT get position:relative forced on it
123
+ expect(document.body.style.position).not.toBe('relative')
124
+ })
125
+
126
+ it('appends overlay to document.body when comment mode activates', () => {
127
+ initCommentsConfig({ comments: { repo: { owner: 'o', name: 'r' } } })
128
+ setToken('ghp_test')
129
+ mountComments()
130
+
131
+ setCommentMode(true)
132
+
133
+ const overlay = document.body.querySelector('.sb-comment-overlay')
134
+ expect(overlay).not.toBeNull()
135
+ expect(overlay.parentElement).toBe(document.body)
136
+ })
137
+
138
+ it('removes overlay when comment mode deactivates', () => {
139
+ initCommentsConfig({ comments: { repo: { owner: 'o', name: 'r' } } })
140
+ setToken('ghp_test')
141
+ mountComments()
142
+
143
+ setCommentMode(true)
144
+ expect(document.body.querySelector('.sb-comment-overlay')).not.toBeNull()
145
+
146
+ setCommentMode(false)
147
+ expect(document.body.querySelector('.sb-comment-overlay')).toBeNull()
148
+ })
149
+ })
150
+
151
+ describe('banner', () => {
152
+ it('shows banner when comment mode activates', () => {
153
+ initCommentsConfig({ comments: { repo: { owner: 'o', name: 'r' } } })
154
+ setToken('ghp_test')
155
+ mountComments()
156
+
157
+ setCommentMode(true)
158
+
159
+ const banner = document.body.querySelector('.sb-banner')
160
+ expect(banner).not.toBeNull()
161
+ expect(banner.textContent).toContain('Comment mode')
162
+ })
163
+
164
+ it('removes banner when comment mode deactivates', () => {
165
+ initCommentsConfig({ comments: { repo: { owner: 'o', name: 'r' } } })
166
+ setToken('ghp_test')
167
+ mountComments()
168
+
169
+ setCommentMode(true)
170
+ setCommentMode(false)
171
+
172
+ expect(document.body.querySelector('.sb-banner')).toBeNull()
173
+ })
174
+ })
175
+
176
+ describe('body class', () => {
177
+ it('adds sb-comment-mode class when comment mode activates', () => {
178
+ initCommentsConfig({ comments: { repo: { owner: 'o', name: 'r' } } })
179
+ setToken('ghp_test')
180
+ mountComments()
181
+
182
+ setCommentMode(true)
183
+ expect(document.body.classList.contains('sb-comment-mode')).toBe(true)
184
+ })
185
+
186
+ it('removes sb-comment-mode class when comment mode deactivates', () => {
187
+ initCommentsConfig({ comments: { repo: { owner: 'o', name: 'r' } } })
188
+ setToken('ghp_test')
189
+ mountComments()
190
+
191
+ setCommentMode(true)
192
+ setCommentMode(false)
193
+ expect(document.body.classList.contains('sb-comment-mode')).toBe(false)
194
+ })
195
+ })
196
+
197
+ describe('mountComments idempotency', () => {
198
+ it('is safe to call multiple times', () => {
199
+ mountComments()
200
+ mountComments()
201
+ mountComments()
202
+ // No error thrown — _mounted guard prevents double init
203
+ })
204
+ })
205
+ })
package/src/devtools.js CHANGED
@@ -187,7 +187,7 @@ export function mountDevTools(options = {}) {
187
187
 
188
188
  let visible = true
189
189
  let menuOpen = false
190
- let panelOpen = false
190
+ let panelOpen = false // eslint-disable-line no-unused-vars
191
191
 
192
192
  // Build DOM
193
193
  const wrapper = document.createElement('div')
@@ -7,9 +7,6 @@ import {
7
7
  redo,
8
8
  getOverrideHistory,
9
9
  getCurrentIndex,
10
- getNextIndex,
11
- getCurrentSnapshot,
12
- getCurrentRoute,
13
10
  canUndo,
14
11
  canRedo,
15
12
  getShadow,
@@ -17,7 +14,6 @@ import {
17
14
  removeShadow,
18
15
  getAllShadows,
19
16
  syncHashToHistory,
20
- installHistorySync,
21
17
  } from './hideMode.js'
22
18
 
23
19
  // ── Hide Mode Toggle ──