@dfosco/storyboard-react 4.0.0-beta.46 → 4.0.0-beta.48

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,11 +1,11 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.0.0-beta.46",
3
+ "version": "4.0.0-beta.48",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@base-ui/react": "^1.4.0",
7
- "@dfosco/storyboard-core": "4.0.0-beta.46",
8
- "@dfosco/tiny-canvas": "4.0.0-beta.46",
7
+ "@dfosco/storyboard-core": "4.0.0-beta.48",
8
+ "@dfosco/tiny-canvas": "4.0.0-beta.48",
9
9
  "@neodrag/react": "^2.3.1",
10
10
  "glob": "^11.0.0",
11
11
  "jsonc-parser": "^3.3.1",
@@ -5,6 +5,8 @@
5
5
  * - ViewfinderNew sidebar login button via same event
6
6
  */
7
7
  import { useState, useEffect, useCallback } from 'react'
8
+ import { Dialog } from '@base-ui/react/dialog'
9
+ import { Button } from '@base-ui/react/button'
8
10
  import css from './AuthModal.module.css'
9
11
 
10
12
  const COMMENTS_TOKEN_KEY = 'sb-comments-token'
@@ -54,75 +56,79 @@ export default function AuthModal() {
54
56
  if (e.key === 'Enter') handleSignIn()
55
57
  }, [handleSignIn])
56
58
 
57
- if (!open) return null
58
-
59
59
  const repo = getRepoInfo()
60
60
 
61
61
  return (
62
- <div className={css.overlay} onClick={handleClose}>
63
- <div className={css.dialog} onClick={e => e.stopPropagation()}>
64
- <button className={css.closeBtn} onClick={handleClose} aria-label="Close">×</button>
65
-
66
- <div className={css.title}>Sign in for comments</div>
67
- <div className={css.desc}>
68
- Leave comments for other users to see and respond, and react to! Storyboard comments use Discussions as a back-end and require a GitHub PAT to be enabled.
69
- </div>
70
-
71
- <hr className={css.separator} />
72
-
73
- <div className={css.tokenCard}>
74
- <div className={css.tokenCardTitle}>Fine-grained Personal Access Token</div>
75
- <div className={css.tokenCardRow}>
76
- <span className={css.tokenCardLabel}>Owner:</span>
77
- <code className={css.tokenCardCode}>{repo.owner}</code>
78
- </div>
79
- <div className={css.tokenCardRow}>
80
- <span className={css.tokenCardLabel}>Expiration:</span>
81
- <code className={css.tokenCardCode}>366 days</code>
82
- <span className={css.tokenCardHint}>(recommended)</span>
83
- </div>
84
- <div className={css.tokenCardRow}>
85
- <span className={css.tokenCardLabel}>Repository access:</span>
86
- <code className={css.tokenCardCode}>Only select repositories &gt; {repo.owner}/{repo.name}</code>
87
- </div>
88
- <div className={css.tokenCardRow}>
89
- <span className={css.tokenCardLabel}>Permissions:</span>
90
- <code className={css.tokenCardCode}>Repositories &gt; Discussions &gt; Access: Read and Write</code>
91
- </div>
92
- </div>
93
-
94
- <a
95
- className={css.tokenLink}
96
- href="https://github.com/settings/personal-access-tokens/new"
97
- target="_blank"
98
- rel="noopener noreferrer"
99
- >
100
- Create a GitHub Fine-Grained Personal Access Token ↗
101
- </a>
102
-
103
- <hr className={css.separator} />
104
-
105
- <label className={css.label}>Personal Access Token</label>
106
- <input
107
- className={css.input}
108
- placeholder="github_pat_… or ghp_…"
109
- type="password"
110
- autoFocus
111
- value={tokenValue}
112
- onChange={e => setTokenValue(e.target.value)}
113
- onKeyDown={handleKeyDown}
114
- />
115
-
116
- <div className={css.warning}>
117
- <span className={css.warningIcon}>⚠️</span>
118
- <span>Comments are an experimental feature and may be unstable.</span>
119
- </div>
120
-
121
- <div className={css.actions}>
122
- <button className={css.btnSecondary} onClick={handleClose}>Cancel</button>
123
- <button className={css.btnPrimary} onClick={handleSignIn}>Sign in</button>
62
+ <Dialog.Root open={open} onOpenChange={setOpen}>
63
+ <Dialog.Portal>
64
+ <Dialog.Backdrop className={css.backdrop} />
65
+ <div className={css.popupWrap}>
66
+ <Dialog.Popup className={css.popup}>
67
+ <Dialog.Title className={css.title}>Sign in for comments</Dialog.Title>
68
+ <Dialog.Description className={css.desc}>
69
+ Leave comments for other users to see and respond, and react to! Storyboard
70
+ comments use Discussions as a back-end and require a GitHub PAT to be enabled.
71
+ </Dialog.Description>
72
+ <Dialog.Close className={css.closeBtn} aria-label="Close">×</Dialog.Close>
73
+
74
+ <hr className={css.separator} />
75
+
76
+ <div className={css.tokenCard}>
77
+ <p className={css.tokenCardTitle}>Fine-grained Personal Access Token</p>
78
+ <div className={css.tokenCardRow}>
79
+ <span className={css.tokenCardLabel}>Owner:</span>
80
+ <code className={css.tokenCardCode}>{repo.owner}</code>
81
+ </div>
82
+ <div className={css.tokenCardRow}>
83
+ <span className={css.tokenCardLabel}>Expiration:</span>
84
+ <code className={css.tokenCardCode}>366 days</code>
85
+ <span className={css.tokenCardHint}>(recommended)</span>
86
+ </div>
87
+ <div className={css.tokenCardRow}>
88
+ <span className={css.tokenCardLabel}>Repository access:</span>
89
+ <code className={css.tokenCardCode}>Only select repositories &gt; {repo.owner}/{repo.name}</code>
90
+ </div>
91
+ <div className={css.tokenCardRow}>
92
+ <span className={css.tokenCardLabel}>Permissions:</span>
93
+ <code className={css.tokenCardCode}>Repositories &gt; Discussions &gt; Access: Read and Write</code>
94
+ </div>
95
+ </div>
96
+
97
+ <a
98
+ className={css.tokenLink}
99
+ href="https://github.com/settings/personal-access-tokens/new"
100
+ target="_blank"
101
+ rel="noopener noreferrer"
102
+ >
103
+ Create a GitHub Fine-Grained Personal Access Token ↗
104
+ </a>
105
+
106
+ <hr className={css.separator} />
107
+
108
+ <label className={css.label} htmlFor="auth-modal-token">Personal Access Token</label>
109
+ <input
110
+ id="auth-modal-token"
111
+ className={css.input}
112
+ placeholder="github_pat_… or ghp_…"
113
+ type="password"
114
+ autoFocus
115
+ value={tokenValue}
116
+ onChange={e => setTokenValue(e.target.value)}
117
+ onKeyDown={handleKeyDown}
118
+ />
119
+
120
+ <div className={css.warning}>
121
+ <span className={css.warningIcon}>⚠️</span>
122
+ <span>Comments are an experimental feature and may be unstable.</span>
123
+ </div>
124
+
125
+ <div className={css.actions}>
126
+ <Dialog.Close className={css.btnSecondary}>Cancel</Dialog.Close>
127
+ <button className={css.btnPrimary} onClick={handleSignIn}>Sign in</button>
128
+ </div>
129
+ </Dialog.Popup>
124
130
  </div>
125
- </div>
126
- </div>
131
+ </Dialog.Portal>
132
+ </Dialog.Root>
127
133
  )
128
134
  }
@@ -1,22 +1,35 @@
1
- /* AuthModal — PAT entry dialog */
1
+ /* AuthModal — PAT entry dialog (BaseUI Dialog) */
2
2
 
3
- .overlay {
3
+ .backdrop {
4
4
  position: fixed;
5
5
  inset: 0;
6
- background: rgba(0, 0, 0, 0.4);
6
+ background: var(--overlay-backdrop-bgColor, rgba(0, 0, 0, 0.4));
7
+ z-index: 10000;
8
+ }
9
+
10
+ .popupWrap {
11
+ position: fixed;
12
+ inset: 0;
13
+ z-index: 10001;
7
14
  display: flex;
8
15
  align-items: center;
9
16
  justify-content: center;
10
- z-index: 10000;
17
+ pointer-events: none;
11
18
  }
12
19
 
13
- .dialog {
14
- background: #fff;
20
+ .popupWrap > * {
21
+ pointer-events: auto;
22
+ }
23
+
24
+ .popup {
25
+ background: var(--bgColor-default);
15
26
  border-radius: 12px;
16
- box-shadow: 0 16px 48px rgba(0, 0, 0, 0.12);
27
+ box-shadow: var(--shadow-overlay, 0 16px 48px rgba(0, 0, 0, 0.12));
17
28
  padding: 28px;
18
- width: 480px;
19
- max-width: 90vw;
29
+ max-width: 620px;
30
+ max-height: 90vh;
31
+ overflow-y: auto;
32
+ color: var(--fgColor-default);
20
33
  position: relative;
21
34
  }
22
35
 
@@ -28,43 +41,43 @@
28
41
  border: none;
29
42
  font-size: 24px;
30
43
  line-height: 1;
31
- color: #999;
44
+ color: var(--fgColor-muted);
32
45
  cursor: pointer;
33
46
  padding: 4px 8px;
34
47
  border-radius: 4px;
35
48
  }
36
49
 
37
50
  .closeBtn:hover {
38
- background: #f0f0f0;
39
- color: #555;
51
+ background: var(--bgColor-neutral-muted);
52
+ color: var(--fgColor-default);
40
53
  }
41
54
 
42
55
  .title {
43
56
  font-size: 18px;
44
57
  font-weight: 600;
45
58
  margin-bottom: 8px;
46
- color: #1a1a1a;
59
+ color: var(--fgColor-default);
47
60
  padding-right: 32px;
48
61
  }
49
62
 
50
63
  .desc {
51
64
  font-size: 15px;
52
- color: #666;
65
+ color: var(--fgColor-muted);
53
66
  margin-bottom: 16px;
54
67
  line-height: 1.5;
55
68
  }
56
69
 
57
70
  .separator {
58
71
  border: none;
59
- border-top: 1px solid #e5e5e5;
72
+ border-top: 1px solid var(--borderColor-default);
60
73
  margin: 16px 0;
61
74
  }
62
75
 
63
76
  /* Token config card — matches GitHub PAT settings screenshot */
64
77
 
65
78
  .tokenCard {
66
- background: #f6f8fa;
67
- border: 1px solid #e5e5e5;
79
+ background: var(--bgColor-muted);
80
+ border: 1px solid var(--borderColor-default);
68
81
  border-radius: 8px;
69
82
  padding: 14px 16px;
70
83
  margin-bottom: 12px;
@@ -73,7 +86,7 @@
73
86
  .tokenCardTitle {
74
87
  font-size: 15px;
75
88
  font-weight: 600;
76
- color: #1a1a1a;
89
+ color: var(--fgColor-default);
77
90
  margin-bottom: 8px;
78
91
  }
79
92
 
@@ -83,7 +96,7 @@
83
96
  gap: 6px;
84
97
  font-size: 14px;
85
98
  line-height: 1.8;
86
- color: #656d76;
99
+ color: var(--fgColor-muted);
87
100
  }
88
101
 
89
102
  .tokenCardLabel {
@@ -93,20 +106,20 @@
93
106
  .tokenCardCode {
94
107
  font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
95
108
  font-size: 13px;
96
- color: #1a1a1a;
97
- background: #fff;
109
+ color: var(--fgColor-default);
110
+ background: var(--bgColor-default);
98
111
  padding: 1px 6px;
99
112
  border-radius: 4px;
100
113
  }
101
114
 
102
115
  .tokenCardHint {
103
- color: #8b949e;
116
+ color: var(--fgColor-muted);
104
117
  }
105
118
 
106
119
  .tokenLink {
107
120
  display: inline-block;
108
121
  font-size: 15px;
109
- color: #0969da;
122
+ color: var(--fgColor-accent);
110
123
  text-decoration: none;
111
124
  margin-bottom: 4px;
112
125
  }
@@ -121,24 +134,31 @@
121
134
  display: block;
122
135
  font-size: 15px;
123
136
  font-weight: 600;
124
- color: #1a1a1a;
137
+ color: var(--fgColor-default);
125
138
  margin-bottom: 6px;
126
139
  }
127
140
 
128
141
  .input {
129
142
  width: 100%;
130
143
  padding: 10px 12px;
131
- border: 1px solid #e5e5e5;
144
+ border: 1px solid var(--borderColor-default);
132
145
  border-radius: 6px;
133
146
  font-size: 15px;
134
147
  font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
135
148
  margin-bottom: 16px;
136
149
  box-sizing: border-box;
150
+ background: var(--bgColor-default);
151
+ color: var(--fgColor-default);
152
+ }
153
+
154
+ .input::placeholder {
155
+ color: var(--fgColor-muted);
137
156
  }
138
157
 
139
158
  .input:focus {
140
159
  outline: none;
141
- border-color: #999;
160
+ border-color: var(--borderColor-accent-emphasis);
161
+ box-shadow: 0 0 0 3px var(--borderColor-accent-muted);
142
162
  }
143
163
 
144
164
  /* Warning banner */
@@ -148,11 +168,11 @@
148
168
  align-items: center;
149
169
  gap: 8px;
150
170
  padding: 10px 12px;
151
- background: #fff8c5;
152
- border: 1px solid #d4a72c;
171
+ background: var(--bgColor-attention-muted);
172
+ border: 1px solid var(--borderColor-attention-muted);
153
173
  border-radius: 6px;
154
174
  font-size: 14px;
155
- color: #6a5300;
175
+ color: var(--fgColor-attention);
156
176
  margin-bottom: 8px;
157
177
  }
158
178
 
@@ -172,29 +192,30 @@
172
192
 
173
193
  .btnSecondary {
174
194
  padding: 8px 16px;
175
- background: #fff;
176
- border: 1px solid #e5e5e5;
195
+ background: transparent;
196
+ border: none;
177
197
  border-radius: 6px;
178
198
  font-size: 15px;
179
- color: #555;
199
+ color: var(--fgColor-muted);
180
200
  cursor: pointer;
181
201
  }
182
202
 
183
203
  .btnSecondary:hover {
184
- background: #f5f5f5;
204
+ color: var(--fgColor-default);
205
+ background: var(--bgColor-neutral-muted);
185
206
  }
186
207
 
187
208
  .btnPrimary {
188
- padding: 8px 16px;
189
- background: #1a1a1a;
209
+ padding: 8px 20px;
210
+ background: var(--bgColor-accent-emphasis);
190
211
  border: none;
191
212
  border-radius: 6px;
192
213
  font-size: 15px;
193
214
  font-weight: 600;
194
- color: #fff;
215
+ color: var(--fgColor-onEmphasis);
195
216
  cursor: pointer;
196
217
  }
197
218
 
198
219
  .btnPrimary:hover {
199
- background: #333;
220
+ background: var(--bgColor-accent-emphasis-hover, var(--bgColor-accent-emphasis));
200
221
  }
@@ -11,6 +11,12 @@ import css from './BranchBar.module.css'
11
11
  export default function BranchBar({ basePath }) {
12
12
  const [hidden, setHidden] = useState(false)
13
13
 
14
+ const isHiddenByParam = useMemo(() => {
15
+ if (typeof window === 'undefined') return false
16
+ const params = new URLSearchParams(window.location.search)
17
+ return params.has('_sb_hide_branch_bar') || params.has('_sb_embed')
18
+ }, [])
19
+
14
20
  const currentBranch = useMemo(() => {
15
21
  const m = (basePath || '').match(/\/branch--([^/]+)\/?$/)
16
22
  return m ? m[1] : 'main'
@@ -26,7 +32,7 @@ export default function BranchBar({ basePath }) {
26
32
  return () => observer.disconnect()
27
33
  }, [])
28
34
 
29
- if (!isOnBranch || hidden) return null
35
+ if (!isOnBranch || hidden || isHiddenByParam) return null
30
36
 
31
37
  function hideChrome() {
32
38
  window.dispatchEvent(new KeyboardEvent('keydown', {
@@ -17,6 +17,7 @@ import {
17
17
  getCommandPaletteConfig,
18
18
  getToolbarConfig,
19
19
  setTheme,
20
+ getTheme,
20
21
  isExcludedByRoute,
21
22
  } from '@dfosco/storyboard-core'
22
23
  import CreateDialog from './CreateDialog.jsx'
@@ -675,6 +676,14 @@ export default function StoryboardCommandPalette({ basePath }) {
675
676
  const [authorIndex, setAuthorIndex] = useState(new Map())
676
677
  const [activePage, setActivePage] = useState('root')
677
678
  const [createType, setCreateType] = useState(null)
679
+ const [currentTheme, setCurrentTheme] = useState(() => getTheme())
680
+
681
+ // Keep currentTheme in sync when theme changes
682
+ useEffect(() => {
683
+ const handler = (e) => setCurrentTheme(e.detail.theme)
684
+ document.addEventListener('storyboard:theme:changed', handler)
685
+ return () => document.removeEventListener('storyboard:theme:changed', handler)
686
+ }, [])
678
687
 
679
688
  function handleCreateAction(type) {
680
689
  setOpen(false)
@@ -773,7 +782,9 @@ export default function StoryboardCommandPalette({ basePath }) {
773
782
  id: `subpage:${menu.id}`,
774
783
  items: (menu.options || []).map((opt, i) => ({
775
784
  id: `subpage:${menu.id}:${i}`,
776
- children: opt.label,
785
+ children: opt.toolHandler === 'core:theme' && opt.value === currentTheme
786
+ ? <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}><span>{opt.label}</span><span>✓</span></span>
787
+ : opt.label,
777
788
  keywords: [opt.label, menu.label || menu.id],
778
789
  showType: false,
779
790
  onClick: () => {
@@ -789,7 +800,7 @@ export default function StoryboardCommandPalette({ basePath }) {
789
800
  },
790
801
  })),
791
802
  })).filter(g => g.items.length > 0)
792
- }, [toolMenus])
803
+ }, [toolMenus, currentTheme])
793
804
 
794
805
  const filteredItems = useMemo(() => {
795
806
  const base = filterItems(items, search)
@@ -903,7 +914,9 @@ export default function StoryboardCommandPalette({ basePath }) {
903
914
  setActivePage('root')
904
915
  }}
905
916
  >
906
- {opt.label}
917
+ {opt.toolHandler === 'core:theme' && opt.value === currentTheme
918
+ ? <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}><span>{opt.label}</span><span>✓</span></span>
919
+ : opt.label}
907
920
  </CommandPalette.ListItem>
908
921
  ))}
909
922
  </CommandPalette.List>
@@ -7,56 +7,101 @@
7
7
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
8
8
  }
9
9
 
10
+ /*
11
+ * Light mode overrides for react-cmdk.
12
+ * react-cmdk uses @media (prefers-color-scheme: dark) with Tailwind dark:
13
+ * utilities. When the OS is dark but the app theme is light, those media
14
+ * queries fire and the palette renders dark. We override every dark: utility
15
+ * back to light values when data-color-mode="light".
16
+ */
17
+ html[data-color-mode="light"] .command-palette .dark\:bg-gray-900,
18
+ html[data-color-mode="light"] .command-palette .dark\:bg-gray-800 {
19
+ background-color: rgb(255 255 255) !important;
20
+ }
21
+
22
+ html[data-color-mode="light"] .command-palette .dark\:text-white {
23
+ color: inherit !important;
24
+ }
25
+
26
+ html[data-color-mode="light"] .command-palette .dark\:text-gray-600 {
27
+ color: rgb(107 114 128) !important;
28
+ }
29
+
30
+ html[data-color-mode="light"] .command-palette .dark\:hover\:bg-gray-800:hover {
31
+ background-color: rgb(243 244 246) !important;
32
+ }
33
+
34
+ html[data-color-mode="light"] .command-palette .dark\:hover\:text-gray-300:hover {
35
+ color: rgb(107 114 128) !important;
36
+ }
37
+
38
+ html[data-color-mode="light"] .command-palette .dark\:divide-gray-800 > :not([hidden]) ~ :not([hidden]) {
39
+ border-color: rgb(229 231 235) !important;
40
+ }
41
+
10
42
  /*
11
43
  * Dark mode overrides for react-cmdk.
12
44
  * react-cmdk uses @media (prefers-color-scheme: dark) internally,
13
- * but we drive dark mode via data-color-mode on body. Override all
14
- * colors with a single high-specificity block.
45
+ * but we drive dark mode via data-color-mode on <html> (set by themeStore).
46
+ * Primer may also mirror it to <body>, so we target both.
15
47
  */
16
- body[data-color-mode="dark"] .command-palette {
48
+ body[data-color-mode="dark"] .command-palette,
49
+ html[data-color-mode="dark"] .command-palette {
17
50
  color-scheme: dark;
18
51
  }
19
52
 
20
- body[data-color-mode="dark"] .command-palette .command-palette-content {
53
+ body[data-color-mode="dark"] .command-palette .command-palette-content,
54
+ html[data-color-mode="dark"] .command-palette .command-palette-content {
21
55
  color: #e6edf3 !important;
22
56
  }
23
57
 
24
58
  /* Panel background */
25
- body[data-color-mode="dark"] .command-palette .bg-white {
59
+ body[data-color-mode="dark"] .command-palette .bg-white,
60
+ html[data-color-mode="dark"] .command-palette .bg-white {
26
61
  background-color: #2d333b !important;
27
62
  }
28
63
 
29
64
  /* Hover highlight */
30
- body[data-color-mode="dark"] .command-palette .hover\:bg-gray-100:hover {
65
+ body[data-color-mode="dark"] .command-palette .hover\:bg-gray-100:hover,
66
+ html[data-color-mode="dark"] .command-palette .hover\:bg-gray-100:hover {
31
67
  background-color: #373e47 !important;
32
68
  }
33
69
 
34
70
  /* Selected/active item highlight */
35
- body[data-color-mode="dark"] .command-palette .bg-gray-200\/50 {
71
+ body[data-color-mode="dark"] .command-palette .bg-gray-200\/50,
72
+ html[data-color-mode="dark"] .command-palette .bg-gray-200\/50 {
36
73
  background-color: rgba(99, 110, 123, 0.4) !important;
37
74
  }
38
75
 
39
76
  /* Muted text (headings, badges, "Action" labels) */
40
77
  body[data-color-mode="dark"] .command-palette .text-gray-400,
41
- body[data-color-mode="dark"] .command-palette .text-gray-500 {
78
+ body[data-color-mode="dark"] .command-palette .text-gray-500,
79
+ html[data-color-mode="dark"] .command-palette .text-gray-400,
80
+ html[data-color-mode="dark"] .command-palette .text-gray-500 {
42
81
  color: #768390 !important;
43
82
  }
44
83
 
45
84
  /* Input text */
46
- body[data-color-mode="dark"] .command-palette input {
85
+ body[data-color-mode="dark"] .command-palette input,
86
+ html[data-color-mode="dark"] .command-palette input {
47
87
  color: #e6edf3 !important;
48
88
  }
49
- body[data-color-mode="dark"] .command-palette .placeholder-gray-500::placeholder {
89
+ body[data-color-mode="dark"] .command-palette .placeholder-gray-500::placeholder,
90
+ html[data-color-mode="dark"] .command-palette .placeholder-gray-500::placeholder {
50
91
  color: #636e7b !important;
51
92
  }
52
- body[data-color-mode="dark"] .command-palette .placeholder-gray-500::-moz-placeholder {
93
+ body[data-color-mode="dark"] .command-palette .placeholder-gray-500::-moz-placeholder,
94
+ html[data-color-mode="dark"] .command-palette .placeholder-gray-500::-moz-placeholder {
53
95
  color: #636e7b !important;
54
96
  }
55
97
 
56
98
  /* Borders / dividers */
57
99
  body[data-color-mode="dark"] .command-palette .divide-y > :not([hidden]) ~ :not([hidden]),
58
100
  body[data-color-mode="dark"] .command-palette .border-t,
59
- body[data-color-mode="dark"] .command-palette .border-b {
101
+ body[data-color-mode="dark"] .command-palette .border-b,
102
+ html[data-color-mode="dark"] .command-palette .divide-y > :not([hidden]) ~ :not([hidden]),
103
+ html[data-color-mode="dark"] .command-palette .border-t,
104
+ html[data-color-mode="dark"] .command-palette .border-b {
60
105
  border-color: #444c56 !important;
61
106
  }
62
107
 
@@ -283,17 +283,40 @@ describe('CanvasPage canvas bridge', () => {
283
283
  })
284
284
 
285
285
  describe('getCanvasThemeVars', () => {
286
- it('returns a distinct dark-dimmed background token', () => {
286
+ it('returns correct tokens for each theme', () => {
287
287
  expect(getCanvasThemeVars('light')['--sb--canvas-bg']).toBe('#f6f8fa')
288
288
  expect(getCanvasThemeVars('light')['--tc-bg-muted']).toBe('#f6f8fa')
289
- expect(getCanvasThemeVars('dark')['--sb--canvas-bg']).toBe('#161b22')
290
- expect(getCanvasThemeVars('dark')['--bgColor-muted']).toBe('#161b22')
291
- expect(getCanvasThemeVars('dark')['--tc-bg-muted']).toBe('#161b22')
292
- expect(getCanvasThemeVars('dark_dimmed')['--sb--canvas-bg']).toBe('#22272e')
293
- expect(getCanvasThemeVars('dark_dimmed')['--bgColor-muted']).toBe('#22272e')
294
- expect(getCanvasThemeVars('dark_dimmed')['--tc-bg-muted']).toBe('#22272e')
295
- expect(getCanvasThemeVars('dark_dimmed')['--tc-dot-color']).toBe('rgba(205, 217, 229, 0.22)')
296
- expect(getCanvasThemeVars('dark_dimmed')['--overlay-backdrop-bgColor']).toBe('rgba(205, 217, 229, 0.22)')
289
+ expect(getCanvasThemeVars('dark')['--sb--canvas-bg']).toBe('#151b23')
290
+ expect(getCanvasThemeVars('dark')['--bgColor-muted']).toBe('#151b23')
291
+ expect(getCanvasThemeVars('dark')['--tc-bg-muted']).toBe('#151b23')
292
+ expect(getCanvasThemeVars('dark_dimmed')['--sb--canvas-bg']).toBe('#262c36')
293
+ expect(getCanvasThemeVars('dark_dimmed')['--bgColor-muted']).toBe('#262c36')
294
+ expect(getCanvasThemeVars('dark_dimmed')['--tc-bg-muted']).toBe('#262c36')
295
+ expect(getCanvasThemeVars('dark_dimmed')['--tc-dot-color']).toBe('rgba(209, 215, 224, 0.18)')
296
+ expect(getCanvasThemeVars('dark_dimmed')['--overlay-backdrop-bgColor']).toBe('rgba(209, 215, 224, 0.18)')
297
+ })
298
+
299
+ it('returns distinct values for dark_high_contrast', () => {
300
+ const vars = getCanvasThemeVars('dark_high_contrast')
301
+ expect(vars['--bgColor-default']).toBe('#010409')
302
+ expect(vars['--borderColor-default']).toBe('#b7bdc8')
303
+ expect(vars['--fgColor-default']).toBe('#ffffff')
304
+ })
305
+
306
+ it('returns distinct values for dark_colorblind', () => {
307
+ const vars = getCanvasThemeVars('dark_colorblind')
308
+ expect(vars['--bgColor-default']).toBe('#0d1117')
309
+ expect(vars['--fgColor-muted']).toBe('#9198a1')
310
+ })
311
+
312
+ it('returns distinct values for light_colorblind', () => {
313
+ const vars = getCanvasThemeVars('light_colorblind')
314
+ expect(vars['--bgColor-default']).toBe('#ffffff')
315
+ expect(vars['--fgColor-muted']).toBe('#59636e')
316
+ })
317
+
318
+ it('falls back to light for unknown themes', () => {
319
+ expect(getCanvasThemeVars('unknown')).toEqual(getCanvasThemeVars('light'))
297
320
  })
298
321
  })
299
322
 
@@ -304,6 +327,11 @@ describe('getCanvasPrimerAttrs', () => {
304
327
  'data-dark-theme': 'dark',
305
328
  'data-light-theme': 'light',
306
329
  })
330
+ expect(getCanvasPrimerAttrs('light_colorblind')).toEqual({
331
+ 'data-color-mode': 'light',
332
+ 'data-dark-theme': 'dark',
333
+ 'data-light-theme': 'light_colorblind',
334
+ })
307
335
  expect(getCanvasPrimerAttrs('dark')).toEqual({
308
336
  'data-color-mode': 'dark',
309
337
  'data-dark-theme': 'dark',
@@ -314,6 +342,16 @@ describe('getCanvasPrimerAttrs', () => {
314
342
  'data-dark-theme': 'dark_dimmed',
315
343
  'data-light-theme': 'light',
316
344
  })
345
+ expect(getCanvasPrimerAttrs('dark_high_contrast')).toEqual({
346
+ 'data-color-mode': 'dark',
347
+ 'data-dark-theme': 'dark_high_contrast',
348
+ 'data-light-theme': 'light',
349
+ })
350
+ expect(getCanvasPrimerAttrs('dark_colorblind')).toEqual({
351
+ 'data-color-mode': 'dark',
352
+ 'data-dark-theme': 'dark_colorblind',
353
+ 'data-light-theme': 'light',
354
+ })
317
355
  })
318
356
  })
319
357
 
@@ -2035,7 +2035,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2035
2035
  return (
2036
2036
  <>
2037
2037
  <div className={styles.canvasTitle}>
2038
- <a href={(import.meta.env?.BASE_URL || '/')} className={`${styles.canvasLogo} smooth-corners`} aria-label="Go to homepage">
2038
+ <a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
2039
2039
  <Icon name="iconoir/key-command" size={16} color="#fff" />
2040
2040
  </a>
2041
2041
  {siblingPages.length > 1 && (
@@ -1,74 +1,118 @@
1
1
  export function getCanvasPrimerAttrs(theme) {
2
- if (String(theme || 'light') === 'dark_dimmed') {
3
- return {
4
- 'data-color-mode': 'dark',
5
- 'data-dark-theme': 'dark_dimmed',
6
- 'data-light-theme': 'light',
7
- }
8
- }
9
- if (String(theme || 'light').startsWith('dark')) {
2
+ const value = String(theme || 'light')
3
+ if (value.startsWith('dark')) {
10
4
  return {
11
5
  'data-color-mode': 'dark',
12
- 'data-dark-theme': 'dark',
6
+ 'data-dark-theme': value,
13
7
  'data-light-theme': 'light',
14
8
  }
15
9
  }
16
10
  return {
17
11
  'data-color-mode': 'light',
18
12
  'data-dark-theme': 'dark',
19
- 'data-light-theme': 'light',
13
+ 'data-light-theme': value.startsWith('light') ? value : 'light',
20
14
  }
21
15
  }
22
16
 
23
- export function getCanvasThemeVars(theme) {
24
- const value = String(theme || 'light')
25
- if (value === 'dark_dimmed') {
26
- return {
27
- '--sb--canvas-bg': '#22272e',
28
- '--bgColor-default': '#22272e',
29
- '--bgColor-muted': '#22272e',
30
- '--bgColor-neutral-muted': 'rgba(99, 110, 123, 0.3)',
31
- '--bgColor-accent-emphasis': '#316dca',
32
- '--tc-bg-muted': '#22272e',
33
- '--tc-dot-color': 'rgba(205, 217, 229, 0.22)',
34
- '--overlay-backdrop-bgColor': 'rgba(205, 217, 229, 0.22)',
35
- '--fgColor-muted': '#768390',
36
- '--fgColor-default': '#adbac7',
37
- '--fgColor-onEmphasis': '#ffffff',
38
- '--borderColor-default': '#444c56',
39
- '--borderColor-muted': '#545d68',
40
- }
41
- }
42
- if (value.startsWith('dark')) {
43
- return {
44
- '--sb--canvas-bg': '#161b22',
45
- '--bgColor-default': '#161b22',
46
- '--bgColor-muted': '#161b22',
47
- '--bgColor-neutral-muted': 'rgba(110, 118, 129, 0.2)',
48
- '--bgColor-accent-emphasis': '#2f81f7',
49
- '--tc-bg-muted': '#161b22',
50
- '--tc-dot-color': 'rgba(255, 255, 255, 0.1)',
51
- '--overlay-backdrop-bgColor': 'rgba(255, 255, 255, 0.1)',
52
- '--fgColor-muted': '#8b949e',
53
- '--fgColor-default': '#e6edf3',
54
- '--fgColor-onEmphasis': '#ffffff',
55
- '--borderColor-default': '#30363d',
56
- '--borderColor-muted': '#30363d',
57
- }
58
- }
59
- return {
17
+ /**
18
+ * Per-theme canvas CSS custom properties sourced from @primer/primitives.
19
+ * Each theme gets its own entry so high-contrast, colorblind, and dimmed
20
+ * variants all render with the correct background, dot, and text colors.
21
+ */
22
+ const THEME_VARS = {
23
+ light: {
60
24
  '--sb--canvas-bg': '#f6f8fa',
61
25
  '--bgColor-default': '#ffffff',
26
+ '--bgColor-muted': '#f6f8fa',
27
+ '--bgColor-neutral-muted': '#818b981f',
28
+ '--bgColor-accent-emphasis': '#0969da',
62
29
  '--tc-bg-muted': '#f6f8fa',
63
30
  '--tc-dot-color': 'rgba(0, 0, 0, 0.08)',
64
31
  '--overlay-backdrop-bgColor': 'rgba(0, 0, 0, 0.08)',
32
+ '--fgColor-muted': '#59636e',
33
+ '--fgColor-default': '#1f2328',
34
+ '--fgColor-onEmphasis': '#ffffff',
35
+ '--borderColor-default': '#d1d9e0',
36
+ '--borderColor-muted': '#d1d9e0b3',
37
+ },
38
+ light_colorblind: {
39
+ '--sb--canvas-bg': '#f6f8fa',
40
+ '--bgColor-default': '#ffffff',
65
41
  '--bgColor-muted': '#f6f8fa',
66
- '--bgColor-neutral-muted': '#eaeef2',
67
- '--bgColor-accent-emphasis': '#2f81f7',
68
- '--fgColor-muted': '#656d76',
42
+ '--bgColor-neutral-muted': '#818b981f',
43
+ '--bgColor-accent-emphasis': '#0969da',
44
+ '--tc-bg-muted': '#f6f8fa',
45
+ '--tc-dot-color': 'rgba(0, 0, 0, 0.08)',
46
+ '--overlay-backdrop-bgColor': 'rgba(0, 0, 0, 0.08)',
47
+ '--fgColor-muted': '#59636e',
69
48
  '--fgColor-default': '#1f2328',
70
49
  '--fgColor-onEmphasis': '#ffffff',
71
50
  '--borderColor-default': '#d1d9e0',
72
- '--borderColor-muted': '#d8dee4',
73
- }
51
+ '--borderColor-muted': '#d1d9e0b3',
52
+ },
53
+ dark: {
54
+ '--sb--canvas-bg': '#151b23',
55
+ '--bgColor-default': '#0d1117',
56
+ '--bgColor-muted': '#151b23',
57
+ '--bgColor-neutral-muted': '#656c7633',
58
+ '--bgColor-accent-emphasis': '#1f6feb',
59
+ '--tc-bg-muted': '#151b23',
60
+ '--tc-dot-color': 'rgba(255, 255, 255, 0.1)',
61
+ '--overlay-backdrop-bgColor': 'rgba(255, 255, 255, 0.1)',
62
+ '--fgColor-muted': '#9198a1',
63
+ '--fgColor-default': '#f0f6fc',
64
+ '--fgColor-onEmphasis': '#ffffff',
65
+ '--borderColor-default': '#3d444d',
66
+ '--borderColor-muted': '#3d444db3',
67
+ },
68
+ dark_dimmed: {
69
+ '--sb--canvas-bg': '#262c36',
70
+ '--bgColor-default': '#212830',
71
+ '--bgColor-muted': '#262c36',
72
+ '--bgColor-neutral-muted': '#656c7633',
73
+ '--bgColor-accent-emphasis': '#316dca',
74
+ '--tc-bg-muted': '#262c36',
75
+ '--tc-dot-color': 'rgba(209, 215, 224, 0.18)',
76
+ '--overlay-backdrop-bgColor': 'rgba(209, 215, 224, 0.18)',
77
+ '--fgColor-muted': '#9198a1',
78
+ '--fgColor-default': '#d1d7e0',
79
+ '--fgColor-onEmphasis': '#f0f6fc',
80
+ '--borderColor-default': '#3d444d',
81
+ '--borderColor-muted': '#3d444db3',
82
+ },
83
+ dark_colorblind: {
84
+ '--sb--canvas-bg': '#151b23',
85
+ '--bgColor-default': '#0d1117',
86
+ '--bgColor-muted': '#151b23',
87
+ '--bgColor-neutral-muted': '#656c7633',
88
+ '--bgColor-accent-emphasis': '#1f6feb',
89
+ '--tc-bg-muted': '#151b23',
90
+ '--tc-dot-color': 'rgba(255, 255, 255, 0.1)',
91
+ '--overlay-backdrop-bgColor': 'rgba(255, 255, 255, 0.1)',
92
+ '--fgColor-muted': '#9198a1',
93
+ '--fgColor-default': '#f0f6fc',
94
+ '--fgColor-onEmphasis': '#ffffff',
95
+ '--borderColor-default': '#3d444d',
96
+ '--borderColor-muted': '#3d444db3',
97
+ },
98
+ dark_high_contrast: {
99
+ '--sb--canvas-bg': '#151b23',
100
+ '--bgColor-default': '#010409',
101
+ '--bgColor-muted': '#151b23',
102
+ '--bgColor-neutral-muted': '#212830',
103
+ '--bgColor-accent-emphasis': '#194fb1',
104
+ '--tc-bg-muted': '#151b23',
105
+ '--tc-dot-color': 'rgba(183, 189, 200, 0.25)',
106
+ '--overlay-backdrop-bgColor': 'rgba(183, 189, 200, 0.25)',
107
+ '--fgColor-muted': '#b7bdc8',
108
+ '--fgColor-default': '#ffffff',
109
+ '--fgColor-onEmphasis': '#ffffff',
110
+ '--borderColor-default': '#b7bdc8',
111
+ '--borderColor-muted': '#b7bdc8',
112
+ },
113
+ }
114
+
115
+ export function getCanvasThemeVars(theme) {
116
+ const value = String(theme || 'light')
117
+ return THEME_VARS[value] || THEME_VARS.light
74
118
  }
@@ -1,21 +1,19 @@
1
1
  /**
2
- * Resolve the effective canvas theme from localStorage + sync settings.
2
+ * Resolve the effective canvas theme from the core theme store.
3
3
  * Respects the canvas-specific theme sync toggle.
4
4
  */
5
+ import { getTheme, getThemeSyncTargets } from '@dfosco/storyboard-core'
6
+
7
+ function resolveSystem() {
8
+ if (typeof window === 'undefined') return 'light'
9
+ return window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ? 'dark' : 'light'
10
+ }
11
+
5
12
  export function resolveCanvasTheme() {
6
- if (typeof localStorage === 'undefined') return 'light'
7
- let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: true }
8
- try {
9
- const rawSync = localStorage.getItem('sb-theme-sync')
10
- if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
11
- } catch { /* ignore */ }
13
+ const sync = getThemeSyncTargets()
12
14
  if (!sync.canvas) return 'light'
13
- const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
14
- if (attrTheme) return attrTheme
15
- const stored = localStorage.getItem('sb-color-scheme') || 'system'
16
- if (stored !== 'system') return stored
17
- return typeof window.matchMedia === 'function' &&
18
- window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
15
+ const theme = getTheme()
16
+ return theme === 'system' ? resolveSystem() : theme
19
17
  }
20
18
 
21
19
  /**
@@ -54,52 +52,97 @@ export function subscribeCanvasTheme({ anchorRef, onTheme }) {
54
52
  }
55
53
  }
56
54
 
57
- export function getEmbedChromeVars(theme) {
58
- const value = String(theme || 'light')
59
- if (value === 'dark_dimmed') {
60
- return {
61
- '--bgColor-default': '#22272e',
62
- '--bgColor-muted': '#2d333b',
63
- '--bgColor-neutral-muted': 'rgba(99, 110, 123, 0.3)',
64
- '--fgColor-default': '#adbac7',
65
- '--fgColor-muted': '#768390',
66
- '--fgColor-onEmphasis': '#ffffff',
67
- '--borderColor-default': '#444c56',
68
- '--borderColor-muted': '#545d68',
69
- '--bgColor-accent-emphasis': '#316dca',
70
- '--trigger-bg': '#2d333b',
71
- '--trigger-bg-hover': '#373e47',
72
- '--trigger-border': '#444c56',
73
- }
74
- }
75
- if (value.startsWith('dark')) {
76
- return {
77
- '--bgColor-default': '#161b22',
78
- '--bgColor-muted': '#21262d',
79
- '--bgColor-neutral-muted': 'rgba(110, 118, 129, 0.2)',
80
- '--fgColor-default': '#e6edf3',
81
- '--fgColor-muted': '#8b949e',
82
- '--fgColor-onEmphasis': '#ffffff',
83
- '--borderColor-default': '#30363d',
84
- '--borderColor-muted': '#30363d',
85
- '--bgColor-accent-emphasis': '#2f81f7',
86
- '--trigger-bg': '#21262d',
87
- '--trigger-bg-hover': '#30363d',
88
- '--trigger-border': '#30363d',
89
- }
90
- }
91
- return {
55
+ /**
56
+ * Per-theme embed chrome CSS custom properties sourced from @primer/primitives.
57
+ */
58
+ const EMBED_CHROME_VARS = {
59
+ light: {
92
60
  '--bgColor-default': '#ffffff',
93
61
  '--bgColor-muted': '#f6f8fa',
94
- '--bgColor-neutral-muted': '#eaeef2',
62
+ '--bgColor-neutral-muted': '#818b981f',
95
63
  '--fgColor-default': '#1f2328',
96
- '--fgColor-muted': '#656d76',
64
+ '--fgColor-muted': '#59636e',
97
65
  '--fgColor-onEmphasis': '#ffffff',
98
- '--borderColor-default': '#d0d7de',
99
- '--borderColor-muted': '#d8dee4',
100
- '--bgColor-accent-emphasis': '#2f81f7',
66
+ '--borderColor-default': '#d1d9e0',
67
+ '--borderColor-muted': '#d1d9e0b3',
68
+ '--bgColor-accent-emphasis': '#0969da',
101
69
  '--trigger-bg': '#f6f8fa',
102
70
  '--trigger-bg-hover': '#eaeef2',
103
- '--trigger-border': '#d0d7de',
104
- }
71
+ '--trigger-border': '#d1d9e0',
72
+ },
73
+ light_colorblind: {
74
+ '--bgColor-default': '#ffffff',
75
+ '--bgColor-muted': '#f6f8fa',
76
+ '--bgColor-neutral-muted': '#818b981f',
77
+ '--fgColor-default': '#1f2328',
78
+ '--fgColor-muted': '#59636e',
79
+ '--fgColor-onEmphasis': '#ffffff',
80
+ '--borderColor-default': '#d1d9e0',
81
+ '--borderColor-muted': '#d1d9e0b3',
82
+ '--bgColor-accent-emphasis': '#0969da',
83
+ '--trigger-bg': '#f6f8fa',
84
+ '--trigger-bg-hover': '#eaeef2',
85
+ '--trigger-border': '#d1d9e0',
86
+ },
87
+ dark: {
88
+ '--bgColor-default': '#0d1117',
89
+ '--bgColor-muted': '#151b23',
90
+ '--bgColor-neutral-muted': '#656c7633',
91
+ '--fgColor-default': '#f0f6fc',
92
+ '--fgColor-muted': '#9198a1',
93
+ '--fgColor-onEmphasis': '#ffffff',
94
+ '--borderColor-default': '#3d444d',
95
+ '--borderColor-muted': '#3d444db3',
96
+ '--bgColor-accent-emphasis': '#1f6feb',
97
+ '--trigger-bg': '#151b23',
98
+ '--trigger-bg-hover': '#212830',
99
+ '--trigger-border': '#3d444d',
100
+ },
101
+ dark_dimmed: {
102
+ '--bgColor-default': '#212830',
103
+ '--bgColor-muted': '#262c36',
104
+ '--bgColor-neutral-muted': '#656c7633',
105
+ '--fgColor-default': '#d1d7e0',
106
+ '--fgColor-muted': '#9198a1',
107
+ '--fgColor-onEmphasis': '#f0f6fc',
108
+ '--borderColor-default': '#3d444d',
109
+ '--borderColor-muted': '#3d444db3',
110
+ '--bgColor-accent-emphasis': '#316dca',
111
+ '--trigger-bg': '#262c36',
112
+ '--trigger-bg-hover': '#2d333b',
113
+ '--trigger-border': '#3d444d',
114
+ },
115
+ dark_colorblind: {
116
+ '--bgColor-default': '#0d1117',
117
+ '--bgColor-muted': '#151b23',
118
+ '--bgColor-neutral-muted': '#656c7633',
119
+ '--fgColor-default': '#f0f6fc',
120
+ '--fgColor-muted': '#9198a1',
121
+ '--fgColor-onEmphasis': '#ffffff',
122
+ '--borderColor-default': '#3d444d',
123
+ '--borderColor-muted': '#3d444db3',
124
+ '--bgColor-accent-emphasis': '#1f6feb',
125
+ '--trigger-bg': '#151b23',
126
+ '--trigger-bg-hover': '#212830',
127
+ '--trigger-border': '#3d444d',
128
+ },
129
+ dark_high_contrast: {
130
+ '--bgColor-default': '#010409',
131
+ '--bgColor-muted': '#151b23',
132
+ '--bgColor-neutral-muted': '#212830',
133
+ '--fgColor-default': '#ffffff',
134
+ '--fgColor-muted': '#b7bdc8',
135
+ '--fgColor-onEmphasis': '#ffffff',
136
+ '--borderColor-default': '#b7bdc8',
137
+ '--borderColor-muted': '#b7bdc8',
138
+ '--bgColor-accent-emphasis': '#194fb1',
139
+ '--trigger-bg': '#151b23',
140
+ '--trigger-bg-hover': '#212830',
141
+ '--trigger-border': '#b7bdc8',
142
+ },
143
+ }
144
+
145
+ export function getEmbedChromeVars(theme) {
146
+ const value = String(theme || 'light')
147
+ return EMBED_CHROME_VARS[value] || EMBED_CHROME_VARS.light
105
148
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * useThemeState — React hook for the global storyboard theme.
3
+ *
4
+ * Subscribes to the core themeStore via useSyncExternalStore so React
5
+ * components re-render whenever the theme changes (user action, system
6
+ * preference toggle, or sync-target change).
7
+ *
8
+ * Returns { theme, resolved } where `theme` may be "system" and
9
+ * `resolved` is always a concrete Primer theme name.
10
+ *
11
+ * useThemeSyncTargets — React hook for which UI surfaces follow the theme.
12
+ * Returns { prototype, toolbar, codeBoxes, canvas }.
13
+ */
14
+
15
+ import { useSyncExternalStore } from 'react'
16
+ import {
17
+ themeState,
18
+ themeSyncState,
19
+ } from '@dfosco/storyboard-core'
20
+
21
+ // --- useThemeState ----------------------------------------------------------
22
+
23
+ function subscribeTheme(cb) {
24
+ return themeState.subscribe(cb)
25
+ }
26
+
27
+ let _themeSnapshot = null
28
+ themeState.subscribe((s) => { _themeSnapshot = s })
29
+
30
+ function getThemeSnapshot() {
31
+ return _themeSnapshot
32
+ }
33
+
34
+ /**
35
+ * Subscribe to the global storyboard theme.
36
+ * @returns {{ theme: string, resolved: string }}
37
+ */
38
+ export function useThemeState() {
39
+ return useSyncExternalStore(subscribeTheme, getThemeSnapshot, getThemeSnapshot)
40
+ }
41
+
42
+ // --- useThemeSyncTargets ----------------------------------------------------
43
+
44
+ function subscribeSyncTargets(cb) {
45
+ return themeSyncState.subscribe(cb)
46
+ }
47
+
48
+ let _syncSnapshot = null
49
+ themeSyncState.subscribe((s) => { _syncSnapshot = s })
50
+
51
+ function getSyncSnapshot() {
52
+ return _syncSnapshot
53
+ }
54
+
55
+ /**
56
+ * Subscribe to which UI surfaces follow the global theme.
57
+ * @returns {{ prototype: boolean, toolbar: boolean, codeBoxes: boolean, canvas: boolean }}
58
+ */
59
+ export function useThemeSyncTargets() {
60
+ return useSyncExternalStore(subscribeSyncTargets, getSyncSnapshot, getSyncSnapshot)
61
+ }
@@ -0,0 +1,66 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
3
+ import { setTheme, setThemeSyncTarget } from '@dfosco/storyboard-core'
4
+ import { useThemeState, useThemeSyncTargets } from './useThemeState.js'
5
+
6
+ beforeEach(() => {
7
+ localStorage.clear()
8
+ // Reset to defaults
9
+ setTheme('system')
10
+ })
11
+
12
+ describe('useThemeState', () => {
13
+ it('returns { theme, resolved }', () => {
14
+ const { result } = renderHook(() => useThemeState())
15
+ expect(result.current).toHaveProperty('theme')
16
+ expect(result.current).toHaveProperty('resolved')
17
+ })
18
+
19
+ it('defaults to system theme', () => {
20
+ const { result } = renderHook(() => useThemeState())
21
+ expect(result.current.theme).toBe('system')
22
+ // resolved should be 'light' or 'dark' depending on matchMedia mock
23
+ expect(['light', 'dark']).toContain(result.current.resolved)
24
+ })
25
+
26
+ it('updates when setTheme is called', () => {
27
+ const { result } = renderHook(() => useThemeState())
28
+
29
+ act(() => {
30
+ setTheme('dark_dimmed')
31
+ })
32
+
33
+ expect(result.current.theme).toBe('dark_dimmed')
34
+ expect(result.current.resolved).toBe('dark_dimmed')
35
+ })
36
+
37
+ it('reverts to system when set back', () => {
38
+ const { result } = renderHook(() => useThemeState())
39
+
40
+ act(() => { setTheme('dark') })
41
+ expect(result.current.theme).toBe('dark')
42
+
43
+ act(() => { setTheme('system') })
44
+ expect(result.current.theme).toBe('system')
45
+ })
46
+ })
47
+
48
+ describe('useThemeSyncTargets', () => {
49
+ it('returns default sync targets', () => {
50
+ const { result } = renderHook(() => useThemeSyncTargets())
51
+ expect(result.current.prototype).toBe(true)
52
+ expect(result.current.toolbar).toBe(false)
53
+ expect(result.current.codeBoxes).toBe(true)
54
+ expect(result.current.canvas).toBe(true)
55
+ })
56
+
57
+ it('updates when setThemeSyncTarget is called', () => {
58
+ const { result } = renderHook(() => useThemeSyncTargets())
59
+
60
+ act(() => {
61
+ setThemeSyncTarget('toolbar', true)
62
+ })
63
+
64
+ expect(result.current.toolbar).toBe(true)
65
+ })
66
+ })
package/src/index.js CHANGED
@@ -24,6 +24,7 @@ export { useHideMode } from './hooks/useHideMode.js'
24
24
  export { useUndoRedo } from './hooks/useUndoRedo.js'
25
25
  export { useFeatureFlag } from './hooks/useFeatureFlag.js'
26
26
  export { useMode } from './hooks/useMode.js'
27
+ export { useThemeState, useThemeSyncTargets } from './hooks/useThemeState.js'
27
28
 
28
29
  // React Router integration
29
30
  export { installHashPreserver } from './hashPreserver.js'
@@ -731,6 +731,12 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
731
731
  initCalls.push(`initUIConfig(${JSON.stringify(config.ui)})`)
732
732
  }
733
733
 
734
+ // Customer mode config from storyboard.config.json
735
+ if (config?.customerMode) {
736
+ imports.push(`import { initCustomerModeConfig } from '@dfosco/storyboard-core'`)
737
+ initCalls.push(`initCustomerModeConfig(${JSON.stringify(config.customerMode)})`)
738
+ }
739
+
734
740
  // Log info when multiple flows target the same route
735
741
  const routeGroups = {}
736
742
  for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {