@dfosco/storyboard-react-primer 1.17.3 → 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-react-primer",
3
- "version": "1.17.3",
3
+ "version": "1.18.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,8 +1,8 @@
1
1
  import { useState, useEffect, useCallback } from 'react'
2
- import { ActionMenu, ActionList } from '@primer/react'
3
- import { loadScene } from '@dfosco/storyboard-core'
4
- import { BeakerIcon, InfoIcon, SyncIcon, XIcon, ScreenFullIcon } from '@primer/octicons-react'
2
+ import { loadScene, getFlagKeys } from '@dfosco/storyboard-core'
3
+ import { BeakerIcon, InfoIcon, SyncIcon, XIcon, ScreenFullIcon, ZapIcon } from '@primer/octicons-react'
5
4
  import styles from './DevTools.module.css'
5
+ import FeatureFlagsPanel from './FeatureFlagsPanel.jsx'
6
6
 
7
7
  function getSceneName() {
8
8
  return new URLSearchParams(window.location.search).get('scene') || 'default'
@@ -11,18 +11,30 @@ function getSceneName() {
11
11
  /**
12
12
  * Storyboard DevTools — a floating toolbar for development.
13
13
  *
14
- * Features:
15
- * - Floating button (bottom-center) that opens a menu
16
- * - "Show scene info" — translucent overlay panel with resolved scene JSON
17
- * - "Reset all params" — clears all URL hash session params
18
- * - Cmd+. (Mac) / Ctrl+. (other) toggles the toolbar visibility
14
+ * Uses a custom dropdown menu (no Primer ActionMenu) so that
15
+ * view-swapping and flag toggling don't auto-close the panel.
19
16
  */
20
17
  export default function DevTools() {
21
18
  const [visible, setVisible] = useState(true)
19
+ const [menuOpen, setMenuOpen] = useState(false)
22
20
  const [panelOpen, setPanelOpen] = useState(false)
21
+ const [flagsPanelOpen, setFlagsPanelOpen] = useState(false)
23
22
  const [sceneData, setSceneData] = useState(null)
24
23
  const [sceneError, setSceneError] = useState(null)
25
24
 
25
+ // Close menu on outside click — use mousedown so it fires before
26
+ // React re-renders remove the clicked element from the DOM
27
+ useEffect(() => {
28
+ if (!menuOpen) return
29
+ function handleMouseDown(e) {
30
+ if (!e.target.closest(`.${styles.wrapper}`)) {
31
+ setMenuOpen(false)
32
+ }
33
+ }
34
+ document.addEventListener('mousedown', handleMouseDown)
35
+ return () => document.removeEventListener('mousedown', handleMouseDown)
36
+ }, [menuOpen])
37
+
26
38
  // Cmd+. keyboard shortcut to toggle toolbar
27
39
  useEffect(() => {
28
40
  function handleKeyDown(e) {
@@ -31,6 +43,8 @@ export default function DevTools() {
31
43
  setVisible((v) => !v)
32
44
  if (visible) {
33
45
  setPanelOpen(false)
46
+ setFlagsPanelOpen(false)
47
+ setMenuOpen(false)
34
48
  }
35
49
  }
36
50
  }
@@ -38,13 +52,16 @@ export default function DevTools() {
38
52
  return () => window.removeEventListener('keydown', handleKeyDown)
39
53
  }, [visible])
40
54
 
55
+ const openMenu = useCallback(() => {
56
+ setMenuOpen((v) => !v)
57
+ }, [])
58
+
41
59
  const handleShowSceneInfo = useCallback(() => {
42
- const sceneName = getSceneName()
60
+ setMenuOpen(false)
43
61
  setPanelOpen(true)
44
62
  setSceneError(null)
45
-
46
63
  try {
47
- setSceneData(loadScene(sceneName))
64
+ setSceneData(loadScene(getSceneName()))
48
65
  } catch (err) {
49
66
  setSceneError(err.message)
50
67
  }
@@ -52,88 +69,76 @@ export default function DevTools() {
52
69
 
53
70
  const handleResetParams = useCallback(() => {
54
71
  window.location.hash = ''
72
+ setMenuOpen(false)
55
73
  }, [])
56
74
 
57
75
  const handleViewfinder = useCallback(() => {
76
+ setMenuOpen(false)
58
77
  window.location.href = (document.querySelector('base')?.href || '/') + 'viewfinder'
59
78
  }, [])
60
79
 
80
+ const handleOpenFlagsPanel = useCallback(() => {
81
+ setMenuOpen(false)
82
+ setFlagsPanelOpen(true)
83
+ }, [])
84
+
61
85
  if (!visible) return null
62
86
 
87
+ const hasFlags = getFlagKeys().length > 0
88
+
63
89
  return (
64
90
  <>
65
91
  {/* Scene info overlay panel */}
66
92
  {panelOpen && (
67
93
  <div className={styles.overlay}>
68
- <div
69
- className={styles.overlayBackdrop}
70
- onClick={() => setPanelOpen(false)}
71
- />
94
+ <div className={styles.overlayBackdrop} onClick={() => setPanelOpen(false)} />
72
95
  <div className={styles.panel}>
73
96
  <div className={styles.panelHeader}>
74
- <span className={styles.panelTitle}>
75
- Scene: {getSceneName()}
76
- </span>
77
- <button
78
- className={styles.panelClose}
79
- onClick={() => setPanelOpen(false)}
80
- aria-label="Close panel"
81
- >
97
+ <span className={styles.panelTitle}>Scene: {getSceneName()}</span>
98
+ <button className={styles.panelClose} onClick={() => setPanelOpen(false)} aria-label="Close panel">
82
99
  <XIcon size={16} />
83
100
  </button>
84
101
  </div>
85
102
  <div className={styles.panelBody}>
86
- {sceneError && (
87
- <span className={styles.error}>{sceneError}</span>
88
- )}
103
+ {sceneError && <span className={styles.error}>{sceneError}</span>}
89
104
  {!sceneError && sceneData && (
90
- <pre className={styles.codeBlock}>
91
- {JSON.stringify(sceneData, null, 2)}
92
- </pre>
105
+ <pre className={styles.codeBlock}>{JSON.stringify(sceneData, null, 2)}</pre>
93
106
  )}
94
107
  </div>
95
108
  </div>
96
109
  </div>
97
110
  )}
111
+ <FeatureFlagsPanel open={flagsPanelOpen} onClose={() => setFlagsPanelOpen(false)} />
98
112
 
99
113
  {/* Floating toolbar */}
100
114
  <div className={styles.wrapper}>
101
- <ActionMenu>
102
- <ActionMenu.Anchor>
103
- <button
104
- className={styles.trigger}
105
- aria-label="Storyboard DevTools"
106
- >
107
- <BeakerIcon className={styles.triggerIcon} size={16} />
115
+ {menuOpen && (
116
+ <div className={styles.menu}>
117
+ <button className={styles.menuItem} onClick={handleViewfinder}>
118
+ <ScreenFullIcon size={16} /> See viewfinder
119
+ </button>
120
+ <button className={styles.menuItem} onClick={handleShowSceneInfo}>
121
+ <InfoIcon size={16} /> Show scene info
108
122
  </button>
109
- </ActionMenu.Anchor>
110
- <ActionMenu.Overlay align="center" side="outside-top" sideOffset={16}>
111
- <ActionList>
112
- <ActionList.Item onSelect={handleViewfinder}>
113
- <ActionList.LeadingVisual>
114
- <ScreenFullIcon size={16} />
115
- </ActionList.LeadingVisual>
116
- See viewfinder
117
- </ActionList.Item>
118
- <ActionList.Item onSelect={handleShowSceneInfo}>
119
- <ActionList.LeadingVisual>
120
- <InfoIcon size={16} />
121
- </ActionList.LeadingVisual>
122
- Show scene info
123
- </ActionList.Item>
124
- <ActionList.Item onSelect={handleResetParams}>
125
- <ActionList.LeadingVisual>
126
- <SyncIcon size={16} />
127
- </ActionList.LeadingVisual>
128
- Reset all params
129
- </ActionList.Item>
130
- <div className={styles.shortcutHint}>
131
- Press <code>⌘ + .</code> to hide
132
- </div>
133
-
134
- </ActionList>
135
- </ActionMenu.Overlay>
136
- </ActionMenu>
123
+ <button className={styles.menuItem} onClick={handleResetParams}>
124
+ <SyncIcon size={16} /> Reset all params
125
+ </button>
126
+ {hasFlags && (
127
+ <>
128
+ <div className={styles.separator} />
129
+ <button className={styles.menuItem} onClick={handleOpenFlagsPanel}>
130
+ <ZapIcon size={16} /> Feature Flags
131
+ </button>
132
+ </>
133
+ )}
134
+ <div className={styles.shortcutHint}>
135
+ Press <code>⌘ + .</code> to hide
136
+ </div>
137
+ </div>
138
+ )}
139
+ <button className={styles.trigger} aria-label="Storyboard DevTools" onClick={openMenu}>
140
+ <BeakerIcon className={styles.triggerIcon} size={16} />
141
+ </button>
137
142
  </div>
138
143
  </>
139
144
  )
@@ -151,3 +151,96 @@ code {
151
151
  margin-left: auto;
152
152
  font-family: var(--fontStack-monospace);
153
153
  }
154
+
155
+ /* Custom dropdown menu */
156
+ .menu {
157
+ position: absolute;
158
+ bottom: 56px;
159
+ right: 0;
160
+ min-width: 200px;
161
+ background-color: var(--overlay-bgColor);
162
+ border: 1px solid var(--borderColor-default);
163
+ border-radius: var(--borderRadius-large);
164
+ box-shadow: var(--shadow-floating-large);
165
+ overflow: hidden;
166
+ padding: var(--base-size-4) 0;
167
+ }
168
+
169
+ .menuItem {
170
+ display: flex;
171
+ align-items: center;
172
+ gap: var(--base-size-8);
173
+ width: 100%;
174
+ padding: var(--base-size-8) var(--base-size-16);
175
+ background: none;
176
+ border: none;
177
+ color: var(--fgColor-default);
178
+ font-size: var(--text-body-size-medium);
179
+ font-family: var(--fontStack-system);
180
+ cursor: pointer;
181
+ text-align: left;
182
+ }
183
+
184
+ .menuItem:hover {
185
+ background-color: var(--control-transparent-bgColor-hover);
186
+ }
187
+
188
+ .menuItem svg {
189
+ flex-shrink: 0;
190
+ color: var(--fgColor-muted);
191
+ }
192
+
193
+ .separator {
194
+ height: 1px;
195
+ background-color: var(--borderColor-muted);
196
+ margin: var(--base-size-4) 0;
197
+ }
198
+
199
+ .flagIcon {
200
+ display: flex;
201
+ align-items: center;
202
+ justify-content: center;
203
+ width: 16px;
204
+ height: 16px;
205
+ }
206
+
207
+ .flagsList {
208
+ display: flex;
209
+ flex-direction: column;
210
+ gap: var(--base-size-4);
211
+ }
212
+
213
+ .flagPanelItem {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: var(--base-size-8);
217
+ width: 100%;
218
+ padding: var(--base-size-8) var(--base-size-10);
219
+ background: none;
220
+ border: 1px solid transparent;
221
+ border-radius: var(--borderRadius-medium);
222
+ color: var(--fgColor-default);
223
+ font-size: var(--text-body-size-medium);
224
+ font-family: var(--fontStack-system);
225
+ text-align: left;
226
+ cursor: pointer;
227
+ }
228
+
229
+ .flagPanelItem:hover {
230
+ background-color: var(--control-transparent-bgColor-hover);
231
+ border-color: var(--borderColor-muted);
232
+ }
233
+
234
+ .flagPanelCheck {
235
+ display: flex;
236
+ align-items: center;
237
+ justify-content: center;
238
+ width: 16px;
239
+ height: 16px;
240
+ color: var(--fgColor-accent);
241
+ }
242
+
243
+ .noFlags {
244
+ color: var(--fgColor-muted);
245
+ font-size: var(--text-body-size-small);
246
+ }
@@ -0,0 +1,43 @@
1
+ import { useSyncExternalStore } from 'react'
2
+ import { getAllFlags, getFlagKeys, toggleFlag, subscribeToHash, getHashSnapshot } from '@dfosco/storyboard-core'
3
+ import { CheckIcon, XIcon } from '@primer/octicons-react'
4
+ import styles from './DevTools.module.css'
5
+
6
+ export default function FeatureFlagsPanel({ open, onClose }) {
7
+ useSyncExternalStore(subscribeToHash, getHashSnapshot)
8
+
9
+ if (!open) return null
10
+
11
+ const flagKeys = getFlagKeys()
12
+ const flags = getAllFlags()
13
+
14
+ return (
15
+ <div className={styles.overlay}>
16
+ <div className={styles.overlayBackdrop} onClick={onClose} />
17
+ <div className={styles.panel}>
18
+ <div className={styles.panelHeader}>
19
+ <span className={styles.panelTitle}>Feature Flags</span>
20
+ <button className={styles.panelClose} onClick={onClose} aria-label="Close feature flags panel">
21
+ <XIcon size={16} />
22
+ </button>
23
+ </div>
24
+ <div className={styles.panelBody}>
25
+ {flagKeys.length === 0 ? (
26
+ <div className={styles.noFlags}>No feature flags are configured.</div>
27
+ ) : (
28
+ <div className={styles.flagsList}>
29
+ {flagKeys.map((key) => (
30
+ <button key={key} className={styles.flagPanelItem} onClick={() => toggleFlag(key)}>
31
+ <span className={styles.flagPanelCheck}>
32
+ {flags[key]?.current ? <CheckIcon size={16} /> : null}
33
+ </span>
34
+ {key}
35
+ </button>
36
+ ))}
37
+ </div>
38
+ )}
39
+ </div>
40
+ </div>
41
+ </div>
42
+ )
43
+ }