@dfosco/storyboard 0.6.0-beta.2 → 0.6.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/storyboard-ui.js +3112 -3098
  2. package/dist/storyboard-ui.js.map +1 -1
  3. package/mascot/frame-01-peek-left.txt +4 -0
  4. package/mascot/frame-02-eyes-open.txt +4 -0
  5. package/mascot/frame-03-peek-right.txt +4 -0
  6. package/mascot/frame-04-eyes-open.txt +4 -0
  7. package/mascot/frame-05-eyes-closed.txt +4 -0
  8. package/mascot/frame-06-eyes-open.txt +4 -0
  9. package/mascot.config.json +13 -0
  10. package/package.json +5 -2
  11. package/scaffold/AGENTS.md +1 -0
  12. package/scaffold/gitignore +12 -2
  13. package/scaffold/skills/design-system-catalog/SKILL.md +98 -0
  14. package/scaffold/skills/design-system-catalog/extract-components.mjs +441 -0
  15. package/scaffold/skills/design-system-catalog/generate-catalog.sh +255 -0
  16. package/scaffold/skills/migrate/SKILL.md +72 -50
  17. package/scaffold/terminal-agent.agent.md +8 -1
  18. package/src/core/canvas/agent-session.js +103 -17
  19. package/src/core/canvas/agent-session.test.js +29 -1
  20. package/src/core/canvas/collision.js +54 -45
  21. package/src/core/canvas/collision.test.js +39 -0
  22. package/src/core/canvas/configReader.js +110 -0
  23. package/src/core/canvas/hot-pool.js +5 -3
  24. package/src/core/canvas/server.js +32 -13
  25. package/src/core/canvas/terminal-server.js +156 -91
  26. package/src/core/cli/agent.js +86 -33
  27. package/src/core/cli/dev.js +303 -17
  28. package/src/core/cli/server.js +1 -1
  29. package/src/core/cli/setup.js +203 -60
  30. package/src/core/cli/terminal-welcome.js +5 -6
  31. package/src/core/cli/userState.js +63 -0
  32. package/src/core/stores/configSchema.js +1 -0
  33. package/src/core/stores/themeStore.ts +24 -0
  34. package/src/core/tools/handlers/devtools.test.js +1 -1
  35. package/src/core/vite/server-plugin.js +107 -10
  36. package/src/internals/CommandPalette/CommandPalette.jsx +1 -1
  37. package/src/internals/Viewfinder.jsx +10 -2
  38. package/src/internals/canvas/CanvasPage.jsx +30 -9
  39. package/src/internals/canvas/WebGLContextPool.jsx +6 -7
  40. package/src/internals/canvas/componentIsolate.jsx +7 -8
  41. package/src/internals/canvas/componentSetIsolate.jsx +7 -8
  42. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +3 -1
  43. package/src/internals/canvas/widgets/StorySetWidget.jsx +19 -7
  44. package/src/internals/canvas/widgets/StoryWidget.jsx +9 -3
  45. package/src/internals/canvas/widgets/TerminalWidget.jsx +74 -13
  46. package/src/internals/canvas/widgets/expandUtils.js +4 -2
  47. package/src/internals/hooks/usePrototypeReloadGuard.js +9 -5
  48. package/src/internals/vite/data-plugin.js +126 -3
  49. package/terminal.config.json +66 -0
@@ -12,18 +12,25 @@
12
12
  import * as p from '@clack/prompts'
13
13
  import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync, symlinkSync } from 'fs'
14
14
  import path from 'path'
15
- import { execSync } from 'child_process'
15
+ import { execSync, spawn } from 'child_process'
16
16
  import { gettingStartedLines, dim, magenta, bold, yellow, green } from './intro.js'
17
17
  import { parseFlags } from './flags.js'
18
+ import { readUserState, writeUserState, getInstalledStoryboardVersion } from './userState.js'
18
19
 
19
20
  const flagSchema = {
20
21
  'skip-branch': { type: 'boolean', default: false, description: 'Skip the branch prompt at the end' },
21
22
  branch: { type: 'string', description: 'Switch to a branch after setup (non-interactive)' },
23
+ 'no-buddy': { type: 'boolean', default: false, description: 'Omit the storyboard mascot from setup output' },
22
24
  'nuke': { type: 'boolean', default: false }, // undocumented: output uninstall command
23
25
  }
24
26
 
25
27
  const { flags } = parseFlags(process.argv.slice(3), flagSchema)
26
28
 
29
+ // Suppress the mascot when piped into another storyboard command that
30
+ // renders its own mascot (e.g. `storyboard dev` auto-runs setup first).
31
+ // Respects either the --no-buddy flag or the STORYBOARD_NO_BUDDY env var.
32
+ const showMascot = !flags['no-buddy'] && process.env.STORYBOARD_NO_BUDDY !== '1'
33
+
27
34
  // Hidden: output uninstall command for testing fresh setups
28
35
  if (flags.nuke) {
29
36
  const nukeCmd = [
@@ -42,23 +49,53 @@ if (flags.nuke) {
42
49
  }
43
50
 
44
51
  /**
45
- * Run a potentially slow task with a spinner that only appears after 500ms.
46
- * If the task completes quickly, shows the done message immediately.
52
+ * Async command runner does NOT block the event loop, so spinners animate.
53
+ */
54
+ function runAsync(cmd, args = [], opts = {}) {
55
+ return new Promise((resolve, reject) => {
56
+ const child = spawn(cmd, args, { stdio: 'ignore', ...opts })
57
+ child.on('error', reject)
58
+ child.on('exit', (code) => {
59
+ if (code === 0) resolve()
60
+ else reject(new Error(`${cmd} exited with code ${code}`))
61
+ })
62
+ })
63
+ }
64
+
65
+ /**
66
+ * Install a brew package with an animated spinner.
47
67
  */
48
- async function withSpin(label, doneMsg, fn) {
68
+ async function brewInstall(pkg, label) {
49
69
  const spin = p.spinner()
50
- const timer = setTimeout(() => spin.start(label), 500)
70
+ spin.start(`Installing ${label}`)
51
71
  try {
52
- await fn()
53
- clearTimeout(timer)
54
- spin.stop(doneMsg)
55
- } catch (err) {
56
- clearTimeout(timer)
57
- spin.stop(`Failed: ${label}`)
58
- throw err
72
+ await runAsync('brew', ['install', pkg])
73
+ spin.stop(`${label} installed`)
74
+ return true
75
+ } catch {
76
+ spin.stop(`Failed to install ${label}`)
77
+ p.log.warning(`Install manually: brew install ${pkg}`)
78
+ return false
59
79
  }
60
80
  }
61
81
 
82
+ /**
83
+ * Quick network probe — alerts the user if GitHub looks unreachable.
84
+ * We don't block on failure; downstream installers will surface their own errors.
85
+ */
86
+ async function checkNetwork() {
87
+ const http = await import('node:https')
88
+ return new Promise((resolve) => {
89
+ const req = http.request('https://api.github.com', { method: 'HEAD', timeout: 4000 }, (res) => {
90
+ resolve(res.statusCode != null && res.statusCode < 500)
91
+ res.resume()
92
+ })
93
+ req.on('error', () => resolve(false))
94
+ req.on('timeout', () => { req.destroy(); resolve(false) })
95
+ req.end()
96
+ })
97
+ }
98
+
62
99
  function mascot() {
63
100
  const d = dim('·')
64
101
  const f = magenta
@@ -87,13 +124,20 @@ function isInstalled(cmd) {
87
124
 
88
125
  p.intro('storyboard setup')
89
126
 
90
- // 1. Check for node_modules (quick sanity check full install runs at the end)
91
- if (!existsSync('node_modules')) {
92
- p.log.info('node_modules not found will install at end of setup')
93
- } else {
94
- p.log.success('Dependencies present')
127
+ // 0. Network probe alert if GitHub is unreachable; don't block.
128
+ {
129
+ const online = await checkNetwork()
130
+ if (!online) {
131
+ p.log.warning('Network looks offline — installers/downloads may fail')
132
+ p.log.info(dim(' Tried HEAD https://api.github.com; check VPN/proxy/DNS'))
133
+ } else {
134
+ p.log.success('Network reachable')
135
+ }
95
136
  }
96
137
 
138
+ // Node is assumed to be present (you already ran `npx storyboard setup`).
139
+ // node_modules will be installed at the end if missing.
140
+
97
141
  // 2. Homebrew
98
142
  let hasBrew = isInstalled('brew')
99
143
  if (!hasBrew) {
@@ -121,35 +165,43 @@ if (hasBrew) {
121
165
  if (isInstalled('git')) {
122
166
  p.log.success('Git installed')
123
167
  } else {
124
- const gitSpin = p.spinner()
125
- gitSpin.start('Installing Git')
126
- try {
127
- run('brew install git')
128
- gitSpin.stop('Git installed')
129
- } catch {
130
- gitSpin.stop('Failed to install Git')
131
- p.log.warning('Install manually: brew install git')
132
- }
168
+ await brewInstall('git', 'Git')
133
169
  }
134
170
  }
135
171
 
136
172
  // 4. Caddy is no longer used. Worktrees run their own Vite directly on
137
- // `http://localhost:<port>/storyboard/`. The block below intentionally
138
- // skips the previous Caddy install + start steps.
173
+ // `http://localhost:<port>/storyboard/`.
139
174
 
140
175
  if (hasBrew) {
141
- // 5. GitHub CLI
176
+ // 5. GitHub CLI — required for `gh auth`, `gh pr`, `gh issue` in agents.
177
+ let ghNewlyInstalled = false
142
178
  if (isInstalled('gh')) {
143
179
  p.log.success('GitHub CLI installed')
144
180
  } else {
145
- const ghSpin = p.spinner()
146
- ghSpin.start('Installing GitHub CLI')
181
+ ghNewlyInstalled = await brewInstall('gh', 'GitHub CLI')
182
+ }
183
+
184
+ // 5a. tmux (required for headless agent sessions)
185
+ if (isInstalled('tmux')) {
186
+ p.log.success('tmux installed')
187
+ } else {
188
+ await brewInstall('tmux', 'tmux')
189
+ }
190
+
191
+ // 5b. Surface gh auth status. Even if gh was already installed, we should
192
+ // prompt the user to log in — agents that shell out to `gh` will fail
193
+ // silently otherwise.
194
+ if (isInstalled('gh')) {
195
+ let authed = false
147
196
  try {
148
- run('brew install gh')
149
- ghSpin.stop('GitHub CLI installed')
150
- } catch {
151
- ghSpin.stop('Failed to install GitHub CLI')
152
- p.log.warning('Install manually: brew install gh')
197
+ execSync('gh auth status', { stdio: 'ignore' })
198
+ authed = true
199
+ } catch { /* not authed */ }
200
+ if (authed) {
201
+ p.log.success('GitHub CLI authenticated')
202
+ } else {
203
+ p.log.warning(ghNewlyInstalled ? 'GitHub CLI installed but not logged in' : 'GitHub CLI is not logged in')
204
+ p.log.info(` Run ${yellow('gh auth login')} to authenticate`)
153
205
  }
154
206
  }
155
207
  }
@@ -195,25 +247,102 @@ if (isInstalled('code')) {
195
247
  }
196
248
  }
197
249
 
198
- // 6a. Copilot CLI
199
- if (isInstalled('copilot')) {
200
- p.log.success('Copilot CLI installed')
201
- } else {
202
- const copilotSpin = p.spinner()
203
- copilotSpin.start('Installing Copilot CLI')
204
- try {
205
- run('curl -fsSL https://gh.io/copilot-install | bash')
206
- // Add ~/.local/bin to PATH if not already there
250
+ // 6a. Coding agents (Copilot CLI / Claude Code / Codex CLI).
251
+ // On first-time setup, ask the user which agents they want installed.
252
+ // On subsequent runs, only install the previously-opted-in agents that
253
+ // went missing — never re-prompt to avoid being annoying after upgrades.
254
+ {
255
+ const priorState = readUserState()
256
+ const priorAgents = priorState.agents || null
257
+ const firstRun = !priorState.setupVersion
258
+
259
+ const ensureLocalBinOnPath = () => {
207
260
  const localBin = `${process.env.HOME}/.local/bin`
208
261
  if (!process.env.PATH.includes(localBin)) {
209
262
  process.env.PATH = `${localBin}:${process.env.PATH}`
210
263
  }
211
- copilotSpin.stop('Copilot CLI installed')
212
- p.log.info(dim(' Note: You may need to restart your terminal or add ~/.local/bin to PATH'))
213
- } catch {
214
- copilotSpin.stop('Failed to install Copilot CLI')
215
- p.log.warning('Install manually: curl -fsSL https://gh.io/copilot-install | bash')
216
264
  }
265
+
266
+ const installers = {
267
+ copilot: {
268
+ label: 'Copilot CLI',
269
+ bin: 'copilot',
270
+ install: async () => {
271
+ await runAsync('bash', ['-c', 'curl -fsSL https://gh.io/copilot-install | bash'])
272
+ ensureLocalBinOnPath()
273
+ },
274
+ manualHint: 'curl -fsSL https://gh.io/copilot-install | bash',
275
+ authHint: `Auth is separate from gh — run ${yellow('copilot')} then ${yellow('/login')}`,
276
+ },
277
+ claude: {
278
+ label: 'Claude Code',
279
+ bin: 'claude',
280
+ install: async () => {
281
+ await runAsync('bash', ['-c', 'curl -fsSL https://claude.ai/install.sh | bash'])
282
+ ensureLocalBinOnPath()
283
+ },
284
+ manualHint: 'curl -fsSL https://claude.ai/install.sh | bash',
285
+ authHint: `Run ${yellow('claude')} once to authenticate`,
286
+ },
287
+ codex: {
288
+ label: 'Codex CLI',
289
+ bin: 'codex',
290
+ install: async () => {
291
+ await runAsync('npm', ['install', '-g', '@openai/codex'])
292
+ },
293
+ manualHint: 'npm i -g @openai/codex',
294
+ authHint: `Run ${yellow('codex login')} to authenticate`,
295
+ },
296
+ }
297
+
298
+ let chosen
299
+ if (firstRun) {
300
+ const selection = await p.multiselect({
301
+ message: 'Which coding agents do you want installed?',
302
+ options: [
303
+ { value: 'copilot', label: 'Copilot CLI', hint: 'recommended' },
304
+ { value: 'claude', label: 'Claude Code' },
305
+ { value: 'codex', label: 'Codex CLI' },
306
+ ],
307
+ initialValues: ['copilot'],
308
+ required: false,
309
+ })
310
+ if (p.isCancel(selection)) {
311
+ chosen = { copilot: true, claude: false, codex: false }
312
+ } else {
313
+ chosen = Object.fromEntries(Object.keys(installers).map((k) => [k, selection.includes(k)]))
314
+ }
315
+ } else {
316
+ // Returning user — install only what they previously opted into and is
317
+ // currently missing. If we have no record (older setup), default to
318
+ // re-installing copilot only when missing.
319
+ chosen = priorAgents || { copilot: true, claude: false, codex: false }
320
+ }
321
+
322
+ for (const [key, agent] of Object.entries(installers)) {
323
+ if (!chosen[key]) continue
324
+ if (isInstalled(agent.bin)) {
325
+ p.log.success(`${agent.label} installed`)
326
+ p.log.info(` ${agent.authHint}`)
327
+ continue
328
+ }
329
+ const spin = p.spinner()
330
+ spin.start(`Installing ${agent.label}`)
331
+ try {
332
+ await agent.install()
333
+ spin.stop(`${agent.label} installed`)
334
+ if (!isInstalled(agent.bin)) {
335
+ p.log.warning(`${agent.bin} not on PATH — add ${yellow('export PATH="$HOME/.local/bin:$PATH"')} to your shell rc`)
336
+ }
337
+ p.log.info(` ${agent.authHint}`)
338
+ } catch {
339
+ spin.stop(`Failed to install ${agent.label}`)
340
+ p.log.warning(`Install manually: ${agent.manualHint}`)
341
+ }
342
+ }
343
+
344
+ // Persist the choice so future setups know what to keep installed.
345
+ writeUserState({ agents: chosen })
217
346
  }
218
347
 
219
348
  // 8. Git hooks
@@ -283,6 +412,8 @@ if (isInstalled('copilot')) {
283
412
  'src/canvas/~*/',
284
413
  'src/prototypes/~*/',
285
414
  'src/prototypes/**/~*.{flow,object,record,prototype,folder}.json',
415
+ // Per-user local state (setup version marker, agent prefs, future onboarding state)
416
+ '.storyboard/.user.json',
286
417
  ]
287
418
  if (existsSync(gitignorePath)) {
288
419
  try {
@@ -352,18 +483,26 @@ if (isInstalled('copilot')) {
352
483
 
353
484
  // 10. Install / sync dependencies
354
485
  {
486
+ const installSpin = p.spinner()
487
+ installSpin.start('Installing dependencies')
355
488
  try {
356
- await withSpin(
357
- 'Installing dependencies...',
358
- 'Dependencies installed',
359
- () => { run('npm install', { stdio: 'ignore' }) }
360
- )
489
+ await runAsync('npm', ['install'])
490
+ installSpin.stop('Dependencies installed')
361
491
  } catch {
362
- p.log.warning('npm install failed — run it manually to see details')
492
+ installSpin.stop('npm install failed')
493
+ p.log.warning('Run it manually to see details:')
363
494
  p.log.info(` ${dim('npm install')}`)
364
495
  }
365
496
  }
366
497
 
498
+ // 11. Stamp the user-state marker so `npm run dev` knows setup is fresh
499
+ // and won't re-run it until the storyboard package version changes.
500
+ {
501
+ const version = getInstalledStoryboardVersion() || 'unknown'
502
+ writeUserState({ setupVersion: version, setupRanAt: new Date().toISOString() })
503
+ p.log.success(`Setup marker written (.storyboard/.user.json @ ${version})`)
504
+ }
505
+
367
506
  p.note(
368
507
  [
369
508
  ...gettingStartedLines(),
@@ -380,8 +519,10 @@ p.note(
380
519
  const { runBranchGuide } = await import('./branch.js')
381
520
  await runBranchGuide(flags.branch)
382
521
  } else if (flags['skip-branch']) {
383
- console.log()
384
- console.log(mascot())
522
+ if (showMascot) {
523
+ console.log()
524
+ console.log(mascot())
525
+ }
385
526
  p.outro('')
386
527
  } else {
387
528
  // Interactive: ask the user
@@ -403,8 +544,10 @@ p.note(
403
544
  const { runBranchGuide } = await import('./branch.js')
404
545
  await runBranchGuide()
405
546
  } else {
406
- console.log()
407
- console.log(mascot())
547
+ if (showMascot) {
548
+ console.log()
549
+ console.log(mascot())
550
+ }
408
551
  p.log.info(`${dim('Non-interactive:')} ${green('npx sb setup --skip-branch')}`)
409
552
  p.outro('')
410
553
  }
@@ -17,11 +17,12 @@
17
17
 
18
18
  import * as p from '@clack/prompts'
19
19
  import { execSync, spawn } from 'node:child_process'
20
- import { readFileSync, existsSync } from 'node:fs'
21
- import { resolve, join } from 'node:path'
20
+ import { existsSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
22
  import { parseFlags } from './flags.js'
23
23
  import { dim, bold } from './intro.js'
24
24
  import { takePendingMessages } from '../canvas/terminal-config.js'
25
+ import { readAgentsConfig } from '../canvas/configReader.js'
25
26
 
26
27
  const blue = (s) => `\x1b[34m${s}\x1b[0m`
27
28
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`
@@ -76,14 +77,12 @@ function agentEnv() {
76
77
  }
77
78
 
78
79
  /**
79
- * Read agents config from storyboard.config.json.
80
+ * Read agents config (lib defaults + storyboard.config.json + terminal.config.json merged).
80
81
  * Returns an array of { id, label, startupCommand, resumeCommand } entries.
81
82
  */
82
83
  function loadAgents() {
83
84
  try {
84
- const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
85
- const config = JSON.parse(raw)
86
- const agents = config?.canvas?.agents
85
+ const agents = readAgentsConfig()
87
86
  if (!agents || typeof agents !== 'object') return []
88
87
  return Object.entries(agents).map(([id, cfg]) => ({
89
88
  id,
@@ -0,0 +1,63 @@
1
+ /**
2
+ * userState — Per-user local state for the current repo, stored in
3
+ * `.storyboard/.user.json` (gitignored).
4
+ *
5
+ * This is a free-form key/value bag for things that should persist across
6
+ * runs but never be committed:
7
+ * - setupVersion (string) — @dfosco/storyboard version setup was last
8
+ * run against. Compared against the installed version on `npm run dev`
9
+ * to decide whether scaffolding needs to re-run.
10
+ * - setupRanAt (ISO date) — last setup timestamp.
11
+ * - agents (object) — per-agent opt-in flags from the
12
+ * first-run install prompt (copilot/claude/codex booleans).
13
+ * - … future: onboarded, username, lastSeenChangelog, etc.
14
+ */
15
+
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
17
+ import { resolve, dirname } from 'node:path'
18
+
19
+ const FILE = '.storyboard/.user.json'
20
+
21
+ export function userStatePath(cwd = process.cwd()) {
22
+ return resolve(cwd, FILE)
23
+ }
24
+
25
+ export function readUserState(cwd = process.cwd()) {
26
+ const file = userStatePath(cwd)
27
+ if (!existsSync(file)) return {}
28
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return {} }
29
+ }
30
+
31
+ export function writeUserState(patch, cwd = process.cwd()) {
32
+ const file = userStatePath(cwd)
33
+ const dir = dirname(file)
34
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
35
+ const next = { ...readUserState(cwd), ...patch }
36
+ writeFileSync(file, JSON.stringify(next, null, 2) + '\n', 'utf8')
37
+ return next
38
+ }
39
+
40
+ /**
41
+ * Read the installed @dfosco/storyboard package version, or null if not
42
+ * resolvable (e.g. running from a worktree without node_modules).
43
+ */
44
+ export function getInstalledStoryboardVersion(cwd = process.cwd()) {
45
+ try {
46
+ const pkgPath = resolve(cwd, 'node_modules', '@dfosco', 'storyboard', 'package.json')
47
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version || null
48
+ } catch { return null }
49
+ }
50
+
51
+ /**
52
+ * Decide whether `storyboard setup` should run before `dev`.
53
+ * Returns null when up-to-date, or { reason, ... } otherwise.
54
+ */
55
+ export function setupNeeded(cwd = process.cwd()) {
56
+ const state = readUserState(cwd)
57
+ const current = getInstalledStoryboardVersion(cwd)
58
+ if (!state.setupVersion) return { reason: 'first-run', current }
59
+ if (current && state.setupVersion !== current) {
60
+ return { reason: 'version-changed', from: state.setupVersion, to: current }
61
+ }
62
+ return null
63
+ }
@@ -65,6 +65,7 @@
65
65
  * @property {string} [sessionIdEnv] — env var exposed in the agent's SessionStart hook payload that holds its session id (e.g. "COPILOT_AGENT_SESSION_ID"). When set, the server captures the id per-widget so cold restarts can auto-resume.
66
66
  * @property {string} [sessionStateDir] — directory where the agent stores per-session state, used to pre-flight `--resume` (e.g. "~/.copilot/session-state"). Pass `null` to skip the fs check (UUID-only validation).
67
67
  * @property {string} [sessionStateGlob] — alternative to sessionStateDir for agents that store sessions under a per-project subdir, with `{id}` placeholder (e.g. "~/.claude/projects/*‍/{id}.jsonl").
68
+ * @property {boolean} [resumeFallback] — when true (default), the resume command is shell-chained with `|| <startupCommand>` so a runtime resume failure falls through to a fresh session instead of leaving the widget with a dead terminal. Set false to opt out.
68
69
  * @property {boolean} [resizable] — override terminal resizability for this agent
69
70
  * @property {number} [defaultWidth] — override default width
70
71
  * @property {number} [defaultHeight] — override default height
@@ -218,6 +218,30 @@ if (typeof window !== 'undefined') {
218
218
  _applyToDOM('system', state.resolved)
219
219
  _dispatchEvent('system', state.resolved)
220
220
  })
221
+
222
+ // Cross-frame sync: the native `storage` event fires in OTHER windows
223
+ // when localStorage is mutated. Prototype iframes embed the same app on
224
+ // the same origin, so when the parent toolbar flips the theme we must
225
+ // pick that up here and re-apply the DOM attributes / dispatch the
226
+ // theme-changed event. Without this, Tailwind utilities (and Primer
227
+ // CSS vars) inside the iframe stay stuck on the load-time theme.
228
+ window.addEventListener('storage', (e) => {
229
+ if (e.storageArea !== localStorage) return
230
+ if (e.key === STORAGE_KEY || e.key === null) {
231
+ const next = readStoredTheme()
232
+ _current = next
233
+ const state = snapshot(next)
234
+ _store.set(state)
235
+ _applyToDOM(next, state.resolved)
236
+ _dispatchEvent(next, state.resolved)
237
+ } else if (e.key === SYNC_STORAGE_KEY) {
238
+ _syncTargets = readStoredSync()
239
+ _syncStore.set(_syncTargets)
240
+ const state = snapshot(_current)
241
+ _applyToDOM(_current, state.resolved)
242
+ _dispatchEvent(_current, state.resolved)
243
+ }
244
+ })
221
245
  }
222
246
 
223
247
  // ---------------------------------------------------------------------------
@@ -76,7 +76,7 @@ describe('devtools prototype auto-reload toggle', () => {
76
76
  window.history.replaceState({}, '', '/')
77
77
  })
78
78
 
79
- it('shows the toggle as inactive by default (guard ON)', async () => {
79
+ it('shows the toggle as inactive by default (guard ON, auto-reload OFF)', async () => {
80
80
  const devtools = await createDevtoolsHandler({})
81
81
  const item = getPrototypeAutoReloadItem(devtools.getChildren())
82
82