@dfosco/storyboard-core 1.17.2 → 1.18.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.17.2",
3
+ "version": "1.18.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,3 +53,57 @@ export function clearCachedComments(route) {
53
53
  // ignore
54
54
  }
55
55
  }
56
+
57
+ // --- Pending (failed) comments ---
58
+
59
+ const PENDING_PREFIX = 'sb-pending-comments:'
60
+
61
+ /**
62
+ * Save a pending comment that failed to submit.
63
+ * @param {string} route
64
+ * @param {{ id: string, x: number, y: number, text: string, author: object }} comment
65
+ */
66
+ export function savePendingComment(route, comment) {
67
+ try {
68
+ const pending = getPendingComments(route)
69
+ // Replace if same id already exists, else append
70
+ const idx = pending.findIndex(c => c.id === comment.id)
71
+ if (idx >= 0) pending[idx] = comment
72
+ else pending.push(comment)
73
+ localStorage.setItem(PENDING_PREFIX + route, JSON.stringify(pending))
74
+ } catch {
75
+ // ignore
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Get all pending (failed) comments for a route.
81
+ * @param {string} route
82
+ * @returns {Array<{ id: string, x: number, y: number, text: string, author: object }>}
83
+ */
84
+ export function getPendingComments(route) {
85
+ try {
86
+ const raw = localStorage.getItem(PENDING_PREFIX + route)
87
+ return raw ? JSON.parse(raw) : []
88
+ } catch {
89
+ return []
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Remove a pending comment (after successful retry or dismissal).
95
+ * @param {string} route
96
+ * @param {string} pendingId
97
+ */
98
+ export function removePendingComment(route, pendingId) {
99
+ try {
100
+ const pending = getPendingComments(route).filter(c => c.id !== pendingId)
101
+ if (pending.length > 0) {
102
+ localStorage.setItem(PENDING_PREFIX + route, JSON.stringify(pending))
103
+ } else {
104
+ localStorage.removeItem(PENDING_PREFIX + route)
105
+ }
106
+ } catch {
107
+ // ignore
108
+ }
109
+ }
@@ -33,7 +33,7 @@ export {
33
33
  } from './api.js'
34
34
 
35
35
  // Cache
36
- export { getCachedComments, setCachedComments, clearCachedComments } from './commentCache.js'
36
+ export { getCachedComments, setCachedComments, clearCachedComments, savePendingComment, getPendingComments, removePendingComment } from './commentCache.js'
37
37
 
38
38
  // GraphQL client (for advanced use)
39
39
  export { graphql } from './graphql.js'
@@ -34,7 +34,7 @@ export function openAuthModal() {
34
34
  </div>
35
35
  <div class="pa4">
36
36
  <p class="ma0 mb3 lh-copy sb-fg-muted sb-f-sm">
37
- Create a <a class="sb-fg-accent no-underline" href="https://github.com/settings/tokens/new?scopes=repo&description=Storyboard+Comments" target="_blank" rel="noopener">GitHub Personal Access Token</a> with access to <b>github/storyboard</b> repository and <b>Discussions</b> read/write scope. Then enter the token below to sign in and enable commenting features.
37
+ Create a <a class="sb-fg-accent no-underline" href="https://github.com/settings/personal-access-tokens/new" target="_blank" rel="noopener">GitHub Fine-Grained Personal Access Token</a> with access to <b>github/storyboard</b> repository and <b>Discussions</b> read/write scope. Then enter the token below to sign in and enable commenting features.
38
38
  </p>
39
39
  <label class="db mb1 fw5 sb-fg sb-f-sm" for="sb-auth-token-input">Personal Access Token</label>
40
40
  <input class="sb-input w-100 ph3 pv2 br2 f6 code db" id="sb-auth-token-input" type="password"
@@ -139,7 +139,18 @@
139
139
  .sb-comment-window * { scrollbar-width: none; }
140
140
  .sb-comment-window *::-webkit-scrollbar { display: none; }
141
141
  .sb-composer { width: 280px; z-index: 100001; }
142
- .sb-comment-overlay { z-index: 99998; pointer-events: none; }
142
+ .sb-comment-overlay {
143
+ position: absolute;
144
+ top: 0; left: 0; right: 0; bottom: 0;
145
+ z-index: 99998;
146
+ pointer-events: none;
147
+ }
148
+ .sb-comment-mode {
149
+ position: relative;
150
+ }
151
+ .sb-comment-mode .sb-comment-overlay {
152
+ pointer-events: auto;
153
+ }
143
154
  .sb-auth-backdrop { z-index: 100000; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); }
144
155
  .sb-comments-drawer-backdrop { z-index: 99997; background: rgba(0,0,0,0.4); }
145
156
  .sb-comments-drawer { z-index: 99998; width: 420px; max-width: 90vw; }
@@ -187,6 +198,25 @@
187
198
  border-color: var(--sb-fg-muted);
188
199
  opacity: 0.5;
189
200
  }
201
+ .sb-comment-pin-pending {
202
+ border-color: var(--sb-fg-muted) !important;
203
+ opacity: 0.6;
204
+ animation: sb-pin-pulse 1.2s ease-in-out infinite;
205
+ }
206
+ .sb-comment-pin-failed {
207
+ border-color: var(--sb-fg-danger) !important;
208
+ cursor: pointer;
209
+ animation: sb-pin-shake 0.4s ease-in-out;
210
+ }
211
+ @keyframes sb-pin-pulse {
212
+ 0%, 100% { opacity: 0.6; }
213
+ 50% { opacity: 1; }
214
+ }
215
+ @keyframes sb-pin-shake {
216
+ 0%, 100% { transform: translateX(0); }
217
+ 25% { transform: translateX(-3px); }
218
+ 75% { transform: translateX(3px); }
219
+ }
190
220
 
191
221
  /* Error alert */
192
222
  .sb-error-alert {
@@ -5,7 +5,6 @@
5
5
  * Styled with Tachyons + sb-* custom classes for light/dark mode support.
6
6
  */
7
7
 
8
- import { createComment } from '../api.js'
9
8
  import { getCachedUser } from '../auth.js'
10
9
 
11
10
  /**
@@ -16,7 +15,7 @@ import { getCachedUser } from '../auth.js'
16
15
  * @param {string} route - Current route path
17
16
  * @param {object} [callbacks] - Optional callbacks
18
17
  * @param {() => void} [callbacks.onCancel] - Called when composer is dismissed
19
- * @param {(comment: object) => void} [callbacks.onSubmit] - Called after successful submit
18
+ * @param {(text: string) => void} [callbacks.onSubmitOptimistic] - Called with text for optimistic submission
20
19
  * @returns {{ el: HTMLElement, destroy: () => void }}
21
20
  */
22
21
  export function showComposer(container, xPct, yPct, route, callbacks = {}) {
@@ -47,8 +46,8 @@ export function showComposer(container, xPct, yPct, route, callbacks = {}) {
47
46
  </template>
48
47
  <div class="flex items-center justify-end pa3">
49
48
  <button class="sb-btn-cancel ph3 pv2 br2 f7 fw5 pointer mr1" @click="cancel()">Cancel</button>
50
- <button class="sb-btn-success ph3 pv2 br2 f7 fw5 pointer bn" :disabled="submitting"
51
- @click="submit()" x-text="submitting ? 'Posting…' : 'Comment'">Comment</button>
49
+ <button class="sb-btn-success ph3 pv2 br2 f7 fw5 pointer bn"
50
+ @click="submit()">Comment</button>
52
51
  </div>
53
52
  </div>
54
53
  `
@@ -81,22 +80,13 @@ export function showComposer(container, xPct, yPct, route, callbacks = {}) {
81
80
  submitting: false,
82
81
  error: null,
83
82
 
84
- async submit() {
83
+ submit() {
85
84
  const val = this.text.trim()
86
85
  if (!val) return
87
86
 
88
- this.submitting = true
89
- this.error = null
90
-
91
- try {
92
- const comment = await createComment(route, xPct, yPct, val)
93
- destroy()
94
- callbacks.onSubmit?.(comment)
95
- } catch (err) {
96
- this.error = err.message
97
- this.submitting = false
98
- console.error('[storyboard] Failed to post comment:', err)
99
- }
87
+ // Close composer immediately and hand off to optimistic handler
88
+ destroy()
89
+ callbacks.onSubmitOptimistic?.(val)
100
90
  },
101
91
 
102
92
  cancel() {
@@ -7,10 +7,10 @@
7
7
 
8
8
  import Alpine from 'alpinejs'
9
9
  import { isCommentsEnabled } from '../config.js'
10
- import { isAuthenticated } from '../auth.js'
10
+ import { isAuthenticated, getCachedUser } from '../auth.js'
11
11
  import { toggleCommentMode, setCommentMode, isCommentModeActive, subscribeToCommentMode } from '../commentMode.js'
12
- import { fetchRouteCommentsSummary, fetchCommentDetail, moveComment } from '../api.js'
13
- import { getCachedComments, setCachedComments, clearCachedComments } from '../commentCache.js'
12
+ import { fetchRouteCommentsSummary, fetchCommentDetail, moveComment, createComment } from '../api.js'
13
+ import { getCachedComments, setCachedComments, clearCachedComments, savePendingComment, getPendingComments, removePendingComment } from '../commentCache.js'
14
14
  import { showComposer } from './composer.js'
15
15
  import { openAuthModal } from './authModal.js'
16
16
  import { showCommentWindow, closeCommentWindow } from './commentWindow.js'
@@ -27,17 +27,19 @@ function esc(str) {
27
27
  return d.innerHTML
28
28
  }
29
29
 
30
- function getContentContainer() {
31
- return document.body
32
- }
33
-
34
30
  function ensureOverlay() {
35
31
  if (overlay) return overlay
36
- const container = getContentContainer()
37
32
 
38
33
  overlay = document.createElement('div')
39
- overlay.className = 'sb-comment-overlay absolute top-0 right-0 bottom-0 left-0 pe-none'
40
- container.appendChild(overlay)
34
+ overlay.className = 'sb-comment-overlay'
35
+ document.body.appendChild(overlay)
36
+
37
+ // Click handler for placing comments lives on the overlay itself
38
+ overlay.addEventListener('click', (e) => {
39
+ if (!isCommentModeActive()) return
40
+ if (e.target.closest('.sb-composer') || e.target.closest('.sb-comment-pin') || e.target.closest('.sb-comment-window')) return
41
+ handleOverlayClick(e)
42
+ })
41
43
 
42
44
  return overlay
43
45
  }
@@ -74,6 +76,99 @@ function reloadComments() {
74
76
  loadAndRenderComments()
75
77
  }
76
78
 
79
+ /**
80
+ * Render an optimistic pin immediately after the user submits a comment.
81
+ * Returns callbacks to mark it as succeeded or failed.
82
+ */
83
+ function renderOptimisticPin(ov, xPct, yPct, text, user) {
84
+ const pendingId = `pending-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
85
+ const pin = document.createElement('div')
86
+ pin.className = 'sb-comment-pin sb-comment-pin-pending absolute br-100 sb-bg pointer sb-shadow pe-auto overflow-hidden'
87
+ pin.style.left = `${xPct}%`
88
+ pin.style.top = `${yPct}%`
89
+ pin.title = `${user?.login ?? 'you'}: ${text.slice(0, 80)}`
90
+
91
+ pin.innerHTML = user?.avatarUrl
92
+ ? `<img class="br-100 db sb-pin-img" src="${esc(user.avatarUrl)}" alt="${esc(user.login)}" draggable="false" />`
93
+ : ''
94
+
95
+ ov.appendChild(pin)
96
+ renderedPins.push(pin)
97
+
98
+ return {
99
+ pendingId,
100
+ succeed: () => {
101
+ pin.classList.remove('sb-comment-pin-pending')
102
+ },
103
+ fail: () => {
104
+ pin.classList.remove('sb-comment-pin-pending')
105
+ pin.classList.add('sb-comment-pin-failed')
106
+ pin.title = `⚠ Failed to post — click to retry: ${text.slice(0, 60)}`
107
+
108
+ // Save to localStorage for persistence
109
+ const route = getCurrentRoute()
110
+ savePendingComment(route, { id: pendingId, x: xPct, y: yPct, text, author: user })
111
+
112
+ // Click to retry
113
+ pin.addEventListener('click', async (e) => {
114
+ e.stopPropagation()
115
+ pin.classList.remove('sb-comment-pin-failed')
116
+ pin.classList.add('sb-comment-pin-pending')
117
+ pin.title = 'Retrying…'
118
+ try {
119
+ await createComment(route, xPct, yPct, text)
120
+ removePendingComment(route, pendingId)
121
+ pin.classList.remove('sb-comment-pin-pending')
122
+ reloadComments()
123
+ } catch {
124
+ pin.classList.remove('sb-comment-pin-pending')
125
+ pin.classList.add('sb-comment-pin-failed')
126
+ pin.title = `⚠ Failed to post — click to retry: ${text.slice(0, 60)}`
127
+ }
128
+ })
129
+ },
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Render pins for pending (failed) comments from localStorage.
135
+ */
136
+ function renderPendingPins(ov) {
137
+ const route = getCurrentRoute()
138
+ const pending = getPendingComments(route)
139
+ for (const p of pending) {
140
+ const pin = document.createElement('div')
141
+ pin.className = 'sb-comment-pin sb-comment-pin-failed absolute br-100 sb-bg pointer sb-shadow pe-auto overflow-hidden'
142
+ pin.style.left = `${p.x}%`
143
+ pin.style.top = `${p.y}%`
144
+ pin.title = `⚠ Failed to post — click to retry: ${p.text?.slice(0, 60) ?? ''}`
145
+
146
+ pin.innerHTML = p.author?.avatarUrl
147
+ ? `<img class="br-100 db sb-pin-img" src="${esc(p.author.avatarUrl)}" alt="${esc(p.author.login)}" draggable="false" />`
148
+ : ''
149
+
150
+ pin.addEventListener('click', async (e) => {
151
+ e.stopPropagation()
152
+ pin.classList.remove('sb-comment-pin-failed')
153
+ pin.classList.add('sb-comment-pin-pending')
154
+ pin.title = 'Retrying…'
155
+ try {
156
+ await createComment(route, p.x, p.y, p.text)
157
+ removePendingComment(route, p.id)
158
+ pin.remove()
159
+ reloadComments()
160
+ } catch {
161
+ pin.classList.remove('sb-comment-pin-pending')
162
+ pin.classList.add('sb-comment-pin-failed')
163
+ pin.title = `⚠ Failed to post — click to retry: ${p.text?.slice(0, 60) ?? ''}`
164
+ }
165
+ })
166
+
167
+ ov.appendChild(pin)
168
+ renderedPins.push(pin)
169
+ }
170
+ }
171
+
77
172
  function renderPin(ov, comment, index) {
78
173
  const hue = Math.round((index * 137.5) % 360)
79
174
  const pin = document.createElement('div')
@@ -97,21 +192,19 @@ function renderPin(ov, comment, index) {
97
192
  pin.addEventListener('mousedown', (e) => {
98
193
  if (e.button !== 0) return
99
194
  dragged = false
100
- const container = getContentContainer()
101
- const containerRect = container.getBoundingClientRect()
102
195
  const startX = e.clientX
103
196
  const startY = e.clientY
104
- const startLeft = (parseFloat(pin.style.left) / 100) * containerRect.width
105
- const startTop = (parseFloat(pin.style.top) / 100) * containerRect.height
197
+ const startLeftPct = parseFloat(pin.style.left)
198
+ const startTopPct = parseFloat(pin.style.top)
106
199
 
107
200
  const onMove = (ev) => {
108
201
  const dx = ev.clientX - startX
109
202
  const dy = ev.clientY - startY
110
203
  if (!dragged && Math.abs(dx) < 4 && Math.abs(dy) < 4) return
111
204
  dragged = true
112
- const cr = container.getBoundingClientRect()
113
- const xPct = Math.round(((startLeft + dx) / cr.width) * 1000) / 10
114
- const yPct = Math.round(((startTop + dy) / cr.height) * 1000) / 10
205
+ const xPct = Math.round((startLeftPct + (dx / window.innerWidth) * 100) * 10) / 10
206
+ const docHeight = document.documentElement.scrollHeight
207
+ const yPct = Math.round((startTopPct + (dy / docHeight) * 100) * 10) / 10
115
208
  pin.style.left = `${xPct}%`
116
209
  pin.style.top = `${yPct}%`
117
210
  }
@@ -121,11 +214,11 @@ function renderPin(ov, comment, index) {
121
214
  document.removeEventListener('mouseup', onUp)
122
215
  if (!dragged) return
123
216
 
124
- const cr = container.getBoundingClientRect()
125
217
  const dx = ev.clientX - startX
126
218
  const dy = ev.clientY - startY
127
- const xPct = Math.round(((startLeft + dx) / cr.width) * 1000) / 10
128
- const yPct = Math.round(((startTop + dy) / cr.height) * 1000) / 10
219
+ const xPct = Math.round((startLeftPct + (dx / window.innerWidth) * 100) * 10) / 10
220
+ const docHeight = document.documentElement.scrollHeight
221
+ const yPct = Math.round((startTopPct + (dy / docHeight) * 100) * 10) / 10
129
222
  comment.meta = { ...comment.meta, x: xPct, y: yPct }
130
223
 
131
224
  try {
@@ -183,6 +276,7 @@ function renderCachedPins() {
183
276
  renderPin(ov, comment, i)
184
277
  }
185
278
  })
279
+ renderPendingPins(ov)
186
280
  }
187
281
 
188
282
  async function loadAndRenderComments() {
@@ -206,13 +300,17 @@ async function loadAndRenderComments() {
206
300
  setCachedComments(route, discussion)
207
301
  }
208
302
  clearPins()
209
- if (!discussion?.comments?.length) return
303
+ if (!discussion?.comments?.length) {
304
+ renderPendingPins(ov)
305
+ return
306
+ }
210
307
 
211
308
  discussion.comments.forEach((comment, i) => {
212
309
  if (comment.meta?.x != null && comment.meta?.y != null) {
213
310
  renderPin(ov, comment, i)
214
311
  }
215
312
  })
313
+ renderPendingPins(ov)
216
314
 
217
315
  autoOpenCommentFromUrl(ov, discussion)
218
316
  } catch (err) {
@@ -228,9 +326,9 @@ async function autoOpenCommentFromUrl(ov, discussion) {
228
326
  if (!comment) return
229
327
 
230
328
  if (comment.meta?.y != null) {
231
- const container = getContentContainer()
232
- const yPx = (comment.meta.y / 100) * container.scrollHeight
233
- const viewTop = container.scrollTop || window.scrollY
329
+ const docHeight = document.documentElement.scrollHeight
330
+ const yPx = (comment.meta.y / 100) * docHeight
331
+ const viewTop = window.scrollY
234
332
  const viewBottom = viewTop + window.innerHeight
235
333
  if (yPx < viewTop || yPx > viewBottom) {
236
334
  const scrollTarget = Math.max(0, yPx - window.innerHeight / 3)
@@ -272,17 +370,29 @@ function handleOverlayClick(e) {
272
370
  activeComposer = null
273
371
  }
274
372
 
275
- const container = getContentContainer()
276
- const rect = container.getBoundingClientRect()
277
- const xPct = Math.round(((e.clientX - rect.left) / rect.width) * 1000) / 10
278
- const yPct = Math.round(((e.clientY - rect.top + container.scrollTop) / container.scrollHeight) * 1000) / 10
373
+ // x as percentage of viewport width, y as percentage of full document height
374
+ const xPct = Math.round((e.clientX / window.innerWidth) * 1000) / 10
375
+ const docHeight = document.documentElement.scrollHeight
376
+ const yPct = Math.round(((e.clientY + window.scrollY) / docHeight) * 1000) / 10
279
377
 
280
378
  const ov = ensureOverlay()
281
- activeComposer = showComposer(ov, xPct, yPct, getCurrentRoute(), {
379
+ const route = getCurrentRoute()
380
+ activeComposer = showComposer(ov, xPct, yPct, route, {
282
381
  onCancel: () => { activeComposer = null },
283
- onSubmit: () => {
382
+ onSubmitOptimistic: (text) => {
284
383
  activeComposer = null
285
- reloadComments()
384
+ const user = getCachedUser()
385
+ const opt = renderOptimisticPin(ov, xPct, yPct, text, user)
386
+ // Fire API call in background
387
+ createComment(route, xPct, yPct, text)
388
+ .then(() => {
389
+ opt.succeed()
390
+ reloadComments()
391
+ })
392
+ .catch((err) => {
393
+ console.error('[storyboard] Failed to post comment:', err)
394
+ opt.fail()
395
+ })
286
396
  },
287
397
  })
288
398
  }
@@ -327,17 +437,6 @@ export function mountComments() {
327
437
 
328
438
  subscribeToCommentMode(setBodyCommentMode)
329
439
 
330
- // Click handler for placing comments — uses document so devtools/modals can be excluded
331
- document.addEventListener('click', (e) => {
332
- if (!isCommentModeActive()) return
333
- // Let devtools, modals, drawers, and existing comment UI handle their own clicks
334
- if (e.target.closest('.sb-devtools-wrapper') || e.target.closest('.sb-auth-backdrop') ||
335
- e.target.closest('.sb-comments-drawer') || e.target.closest('.sb-comments-drawer-backdrop') ||
336
- e.target.closest('.sb-composer') || e.target.closest('.sb-comment-pin') ||
337
- e.target.closest('.sb-comment-window')) return
338
- handleOverlayClick(e)
339
- })
340
-
341
440
  window.addEventListener('keydown', (e) => {
342
441
  const tag = e.target.tagName
343
442
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target.isContentEditable) {
@@ -21,12 +21,16 @@ vi.mock('../api.js', () => ({
21
21
  fetchRouteCommentsSummary: vi.fn(),
22
22
  fetchCommentDetail: vi.fn(),
23
23
  moveComment: vi.fn(),
24
+ createComment: vi.fn(),
24
25
  }))
25
26
 
26
27
  vi.mock('../commentCache.js', () => ({
27
28
  getCachedComments: vi.fn(() => null),
28
29
  setCachedComments: vi.fn(),
29
30
  clearCachedComments: vi.fn(),
31
+ savePendingComment: vi.fn(),
32
+ getPendingComments: vi.fn(() => []),
33
+ removePendingComment: vi.fn(),
30
34
  }))
31
35
 
32
36
  vi.mock('./composer.js', () => ({
@@ -74,11 +78,15 @@ describe('mount.js', () => {
74
78
  fetchRouteCommentsSummary: vi.fn(),
75
79
  fetchCommentDetail: vi.fn(),
76
80
  moveComment: vi.fn(),
81
+ createComment: vi.fn(),
77
82
  }))
78
83
  vi.doMock('../commentCache.js', () => ({
79
84
  getCachedComments: vi.fn(() => null),
80
85
  setCachedComments: vi.fn(),
81
86
  clearCachedComments: vi.fn(),
87
+ savePendingComment: vi.fn(),
88
+ getPendingComments: vi.fn(() => []),
89
+ removePendingComment: vi.fn(),
82
90
  }))
83
91
  vi.doMock('./composer.js', () => ({ showComposer: vi.fn() }))
84
92
  vi.doMock('./authModal.js', () => ({ openAuthModal: vi.fn() }))
package/src/devtools.js CHANGED
@@ -16,6 +16,7 @@
16
16
  import { loadScene } from './loader.js'
17
17
  import { isCommentsEnabled } from './comments/config.js'
18
18
  import { isHideMode, activateHideMode, deactivateHideMode } from './hideMode.js'
19
+ import { getAllFlags, toggleFlag, getFlagKeys } from './featureFlags.js'
19
20
 
20
21
  const STYLES = `
21
22
  .sb-devtools-wrapper {
@@ -67,7 +68,7 @@ const STYLES = `
67
68
  border: none;
68
69
  color: #c9d1d9;
69
70
  font-size: 14px;
70
- font-family: inherit;
71
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
71
72
  cursor: pointer;
72
73
  text-align: left;
73
74
  }
@@ -101,6 +102,7 @@ const STYLES = `
101
102
  width: 100%;
102
103
  max-width: 640px;
103
104
  max-height: 60vh;
105
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
104
106
  background-color: #0d1117;
105
107
  border: 1px solid #30363d;
106
108
  border-radius: 12px;
@@ -151,6 +153,19 @@ const STYLES = `
151
153
  word-break: break-word;
152
154
  }
153
155
  .sb-devtools-error { color: #f85149; }
156
+ .sb-devtools-separator {
157
+ height: 1px;
158
+ background-color: #21262d;
159
+ margin: 4px 0;
160
+ }
161
+ .sb-devtools-group-header {
162
+ padding: 6px 16px 2px;
163
+ font-size: 12px;
164
+ font-weight: 600;
165
+ color: #8b949e;
166
+ text-transform: uppercase;
167
+ letter-spacing: 0.5px;
168
+ }
154
169
  `
155
170
 
156
171
  // SVG icons (inline to avoid external deps)
@@ -161,6 +176,8 @@ const VIEWFINDER_ICON = '<svg viewBox="0 0 16 16"><path d="M8.5 1.75a.75.75 0 0
161
176
  const X_ICON = '<svg viewBox="0 0 16 16"><path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/></svg>'
162
177
  const EYE_ICON = '<svg viewBox="0 0 16 16"><path d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14s-3.671-.992-4.933-2.078C1.797 10.831.88 9.577.43 8.899a1.62 1.62 0 0 1 0-1.798c.45-.678 1.367-1.932 2.637-3.023C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5s2.823-.742 3.955-1.715c1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5s-2.824.742-3.955 1.715c-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"/></svg>'
163
178
  const EYE_CLOSED_ICON = '<svg viewBox="0 0 16 16"><path d="M.143 2.31a.75.75 0 0 1 1.047-.167l14.5 10.5a.75.75 0 1 1-.88 1.214l-2.248-1.628C11.346 13.19 9.792 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.831.88 9.577.43 8.899a1.62 1.62 0 0 1 0-1.798c.35-.527 1.06-1.476 2.019-2.398L.31 3.357A.75.75 0 0 1 .143 2.31Zm3.386 3.378a14.21 14.21 0 0 0-1.85 2.244.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.195 0 2.31-.488 3.29-1.191L9.063 9.695A2 2 0 0 1 6.058 7.39L3.529 5.688ZM8 3.5c-.516 0-1.017.09-1.499.251a.75.75 0 1 1-.473-1.423A6.23 6.23 0 0 1 8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.11.166-.248.365-.41.587a.75.75 0 1 1-1.21-.887c.14-.191.26-.367.36-.524a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5Z"/></svg>'
179
+ const CHECK_ICON = '<svg viewBox="0 0 16 16"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>'
180
+ const ZAP_ICON = '<svg viewBox="0 0 16 16"><path d="M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004Z"/></svg>'
164
181
 
165
182
  function getSceneName() {
166
183
  return new URLSearchParams(window.location.search).get('scene') || 'default'
@@ -227,10 +244,11 @@ export function mountDevTools(options = {}) {
227
244
  hint.className = 'sb-devtools-hint'
228
245
  hint.innerHTML = 'Press <code>⌘ + .</code> to hide'
229
246
 
230
- menu.appendChild(viewfinderBtn)
231
- menu.appendChild(showInfoBtn)
232
- menu.appendChild(resetBtn)
233
- menu.appendChild(hideModeBtn)
247
+ // Feature flags entry (opens a dedicated panel)
248
+ const featureFlagsBtn = document.createElement('button')
249
+ featureFlagsBtn.className = 'sb-devtools-menu-item'
250
+ featureFlagsBtn.innerHTML = `${ZAP_ICON} Feature Flags`
251
+ featureFlagsBtn.addEventListener('click', openFlagsPanel)
234
252
 
235
253
  // Comments menu items (injected dynamically if comments are enabled)
236
254
  function refreshCommentMenuItems() {
@@ -258,19 +276,104 @@ export function mountDevTools(options = {}) {
258
276
  })
259
277
  }
260
278
 
279
+ function renderMainMenu() {
280
+ while (menu.firstChild) menu.removeChild(menu.firstChild)
281
+ menu.appendChild(viewfinderBtn)
282
+ menu.appendChild(showInfoBtn)
283
+ menu.appendChild(resetBtn)
284
+ menu.appendChild(hideModeBtn)
285
+ if (getFlagKeys().length > 0) {
286
+ const sep = document.createElement('div')
287
+ sep.className = 'sb-devtools-separator'
288
+ menu.appendChild(sep)
289
+ menu.appendChild(featureFlagsBtn)
290
+ }
291
+ refreshCommentMenuItems()
292
+ menu.appendChild(hint)
293
+ }
294
+
261
295
  // Refresh dynamic items when menu opens
262
296
  trigger.addEventListener('click', () => {
263
- refreshCommentMenuItems()
297
+ renderMainMenu()
264
298
  updateHideModeBtn()
265
299
  })
266
300
 
267
- menu.appendChild(hint)
301
+ // Build initial (closed) menu content so tests and static DOM inspection work.
302
+ renderMainMenu()
268
303
  wrapper.appendChild(menu)
269
304
  wrapper.appendChild(trigger)
270
305
  container.appendChild(wrapper)
271
306
 
272
- // Overlay (created lazily)
307
+ // Overlays (created lazily)
273
308
  let overlay = null
309
+ let flagsOverlay = null
310
+
311
+ function closeFlagsPanel() {
312
+ if (flagsOverlay) {
313
+ flagsOverlay.remove()
314
+ flagsOverlay = null
315
+ }
316
+ }
317
+
318
+ function openFlagsPanel() {
319
+ menuOpen = false
320
+ menu.classList.remove('open')
321
+ closeFlagsPanel()
322
+
323
+ flagsOverlay = document.createElement('div')
324
+ flagsOverlay.className = 'sb-devtools-overlay'
325
+
326
+ const backdrop = document.createElement('div')
327
+ backdrop.className = 'sb-devtools-backdrop'
328
+ backdrop.addEventListener('click', closeFlagsPanel)
329
+
330
+ const panel = document.createElement('div')
331
+ panel.className = 'sb-devtools-panel'
332
+
333
+ const header = document.createElement('div')
334
+ header.className = 'sb-devtools-panel-header'
335
+ header.innerHTML = '<span class="sb-devtools-panel-title">Feature Flags</span>'
336
+
337
+ const closeBtn = document.createElement('button')
338
+ closeBtn.className = 'sb-devtools-panel-close'
339
+ closeBtn.setAttribute('aria-label', 'Close feature flags panel')
340
+ closeBtn.innerHTML = X_ICON
341
+ closeBtn.addEventListener('click', closeFlagsPanel)
342
+ header.appendChild(closeBtn)
343
+
344
+ const body = document.createElement('div')
345
+ body.className = 'sb-devtools-panel-body'
346
+
347
+ function renderFlagItems() {
348
+ body.innerHTML = ''
349
+ const keys = getFlagKeys()
350
+ if (keys.length === 0) {
351
+ body.innerHTML = '<span class="sb-devtools-hint">No feature flags are configured.</span>'
352
+ return
353
+ }
354
+ const flags = getAllFlags()
355
+ for (const key of keys) {
356
+ const btn = document.createElement('button')
357
+ btn.className = 'sb-devtools-menu-item'
358
+ const icon = flags[key].current
359
+ ? `<span style="width:16px;height:16px;display:flex;align-items:center;justify-content:center;">${CHECK_ICON}</span>`
360
+ : '<span style="width:16px;height:16px;"></span>'
361
+ btn.innerHTML = `${icon} ${key}`
362
+ btn.addEventListener('click', () => {
363
+ toggleFlag(key)
364
+ renderFlagItems()
365
+ })
366
+ body.appendChild(btn)
367
+ }
368
+ }
369
+ renderFlagItems()
370
+
371
+ panel.appendChild(header)
372
+ panel.appendChild(body)
373
+ flagsOverlay.appendChild(backdrop)
374
+ flagsOverlay.appendChild(panel)
375
+ container.appendChild(flagsOverlay)
376
+ }
274
377
 
275
378
  function openPanel() {
276
379
  menuOpen = false
@@ -367,7 +470,7 @@ export function mountDevTools(options = {}) {
367
470
  menu.classList.remove('open')
368
471
  })
369
472
 
370
- // Close menu when clicking outside
473
+ // Close menu when clicking outside — reset to main view
371
474
  document.addEventListener('click', (e) => {
372
475
  if (menuOpen && !wrapper.contains(e.target)) {
373
476
  menuOpen = false
@@ -385,6 +488,7 @@ export function mountDevTools(options = {}) {
385
488
  menuOpen = false
386
489
  menu.classList.remove('open')
387
490
  closePanel()
491
+ closeFlagsPanel()
388
492
  }
389
493
  }
390
494
  })
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Feature flag system for Storyboard.
3
+ *
4
+ * Flags are defined in storyboard.config.json under "featureFlags" and
5
+ * initialized at app startup via the Vite data plugin.
6
+ *
7
+ * Read priority: URL hash → localStorage → config defaults
8
+ * Write target: URL hash (shareable)
9
+ *
10
+ * All flag keys in hash/localStorage are prefixed with "flag." to avoid
11
+ * collisions with scene overrides.
12
+ */
13
+
14
+ import { getParam, setParam, removeParam, getAllParams } from './session.js'
15
+ import { getLocal, setLocal, removeLocal, getAllLocal } from './localStorage.js'
16
+
17
+ const FLAG_PREFIX = 'flag.'
18
+
19
+ /** Module-level storage for config defaults */
20
+ let _defaults = {}
21
+
22
+ /**
23
+ * Initialize the feature flag system with config defaults.
24
+ * Seeds localStorage with defaults (doesn't overwrite existing values).
25
+ * @param {Record<string, boolean>} defaults - Flag key → default value
26
+ */
27
+ export function initFeatureFlags(defaults = {}) {
28
+ _defaults = { ...defaults }
29
+ // Seed localStorage with defaults (don't overwrite existing)
30
+ for (const [key, value] of Object.entries(_defaults)) {
31
+ if (getLocal(FLAG_PREFIX + key) === null) {
32
+ setLocal(FLAG_PREFIX + key, String(value))
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Read a flag value. Priority: hash → localStorage → config default.
39
+ * @param {string} key - Flag key (without prefix)
40
+ * @returns {boolean}
41
+ */
42
+ export function getFlag(key) {
43
+ // 1. URL hash (highest priority)
44
+ const hashVal = getParam(FLAG_PREFIX + key)
45
+ if (hashVal !== null) return hashVal === 'true'
46
+
47
+ // 2. localStorage
48
+ const localVal = getLocal(FLAG_PREFIX + key)
49
+ if (localVal !== null) return localVal === 'true'
50
+
51
+ // 3. Config default
52
+ return _defaults[key] ?? false
53
+ }
54
+
55
+ /**
56
+ * Set a flag value. Writes to URL hash for shareability.
57
+ * @param {string} key - Flag key (without prefix)
58
+ * @param {boolean} value
59
+ */
60
+ export function setFlag(key, value) {
61
+ setParam(FLAG_PREFIX + key, String(value))
62
+ }
63
+
64
+ /**
65
+ * Toggle a flag. Reads current value, writes opposite to hash.
66
+ * @param {string} key - Flag key (without prefix)
67
+ */
68
+ export function toggleFlag(key) {
69
+ setFlag(key, !getFlag(key))
70
+ }
71
+
72
+ /**
73
+ * Get all flags with their default and current (resolved) values.
74
+ * @returns {Record<string, { default: boolean, current: boolean }>}
75
+ */
76
+ export function getAllFlags() {
77
+ const result = {}
78
+ for (const key of Object.keys(_defaults)) {
79
+ result[key] = {
80
+ default: _defaults[key] ?? false,
81
+ current: getFlag(key),
82
+ }
83
+ }
84
+ return result
85
+ }
86
+
87
+ /**
88
+ * Reset all flags — removes hash and localStorage overrides.
89
+ * Flags revert to config defaults.
90
+ */
91
+ export function resetFlags() {
92
+ const allParams = getAllParams()
93
+ for (const paramKey of Object.keys(allParams)) {
94
+ if (paramKey.startsWith(FLAG_PREFIX)) {
95
+ removeParam(paramKey)
96
+ }
97
+ }
98
+ const allLocal = getAllLocal()
99
+ for (const localKey of Object.keys(allLocal)) {
100
+ if (localKey.startsWith(FLAG_PREFIX)) {
101
+ removeLocal(localKey)
102
+ }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Get all registered flag keys.
108
+ * @returns {string[]}
109
+ */
110
+ export function getFlagKeys() {
111
+ return Object.keys(_defaults)
112
+ }
package/src/index.js CHANGED
@@ -37,5 +37,8 @@ export { mountSceneDebug } from './sceneDebug.js'
37
37
  // Viewfinder utilities
38
38
  export { hash, resolveSceneRoute, getSceneMeta } from './viewfinder.js'
39
39
 
40
+ // Feature flags
41
+ export { initFeatureFlags, getFlag, setFlag, toggleFlag, getAllFlags, resetFlags, getFlagKeys } from './featureFlags.js'
42
+
40
43
  // Comments system
41
44
  export { initCommentsConfig, getCommentsConfig, isCommentsEnabled } from './comments/config.js'