@dfosco/storyboard-core 3.7.0 → 3.8.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": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -25,6 +25,7 @@
25
25
  let { config = {}, tabindex = -1 }: Props = $props()
26
26
 
27
27
  let menuOpen = $state(false)
28
+ let canvasActive = $state(false)
28
29
 
29
30
  function handleSelect(value: ThemeValue) {
30
31
  setTheme(value)
@@ -35,6 +36,28 @@
35
36
  e.preventDefault()
36
37
  setThemeSyncTarget(target, !$themeSyncState[target])
37
38
  }
39
+
40
+ $effect(() => {
41
+ function handleCanvasMounted() {
42
+ canvasActive = true
43
+ }
44
+ function handleCanvasUnmounted() {
45
+ canvasActive = false
46
+ }
47
+ document.addEventListener('storyboard:canvas:mounted', handleCanvasMounted)
48
+ document.addEventListener('storyboard:canvas:unmounted', handleCanvasUnmounted)
49
+
50
+ const state = (window as any).__storyboardCanvasBridgeState
51
+ canvasActive = state?.active === true
52
+ if (!canvasActive) {
53
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:status-request'))
54
+ }
55
+
56
+ return () => {
57
+ document.removeEventListener('storyboard:canvas:mounted', handleCanvasMounted)
58
+ document.removeEventListener('storyboard:canvas:unmounted', handleCanvasUnmounted)
59
+ }
60
+ })
38
61
  </script>
39
62
 
40
63
  <DropdownMenu.Root bind:open={menuOpen}>
@@ -74,12 +97,21 @@
74
97
  <DropdownMenu.SubTrigger>Theme settings</DropdownMenu.SubTrigger>
75
98
  <DropdownMenu.SubContent class="min-w-[180px]">
76
99
  <DropdownMenu.Label>Apply theme to</DropdownMenu.Label>
77
- <DropdownMenu.CheckboxItem
78
- checked={$themeSyncState.prototype}
79
- onSelect={(e) => handleSyncToggle(e, 'prototype')}
80
- >
81
- Prototype
82
- </DropdownMenu.CheckboxItem>
100
+ {#if canvasActive}
101
+ <DropdownMenu.CheckboxItem
102
+ checked={$themeSyncState.canvas}
103
+ onSelect={(e) => handleSyncToggle(e, 'canvas')}
104
+ >
105
+ Canvas
106
+ </DropdownMenu.CheckboxItem>
107
+ {:else}
108
+ <DropdownMenu.CheckboxItem
109
+ checked={$themeSyncState.prototype}
110
+ onSelect={(e) => handleSyncToggle(e, 'prototype')}
111
+ >
112
+ Prototype
113
+ </DropdownMenu.CheckboxItem>
114
+ {/if}
83
115
  <DropdownMenu.CheckboxItem
84
116
  checked={$themeSyncState.toolbar}
85
117
  onSelect={(e) => handleSyncToggle(e, 'toolbar')}
@@ -16,21 +16,70 @@
16
16
  */
17
17
 
18
18
  /**
19
- * Parse a JSONL string into an array of event objects.
20
- * Blank lines and lines that fail to parse are silently skipped.
19
+ * Split a text blob into top-level JSON object snippets.
20
+ * Supports strict JSONL and accidentally concatenated objects.
21
21
  *
22
- * @param {string} text - Raw JSONL file contents
22
+ * @param {string} text
23
+ * @returns {string[]}
24
+ */
25
+ function splitJsonObjects(text) {
26
+ const chunks = []
27
+ let start = -1
28
+ let depth = 0
29
+ let inString = false
30
+ let escaped = false
31
+
32
+ for (let i = 0; i < text.length; i++) {
33
+ const ch = text[i]
34
+
35
+ if (inString) {
36
+ if (escaped) {
37
+ escaped = false
38
+ } else if (ch === '\\') {
39
+ escaped = true
40
+ } else if (ch === '"') {
41
+ inString = false
42
+ }
43
+ continue
44
+ }
45
+
46
+ if (ch === '"') {
47
+ inString = true
48
+ continue
49
+ }
50
+
51
+ if (ch === '{') {
52
+ if (depth === 0) start = i
53
+ depth++
54
+ continue
55
+ }
56
+
57
+ if (ch === '}') {
58
+ if (depth > 0) depth--
59
+ if (depth === 0 && start >= 0) {
60
+ chunks.push(text.slice(start, i + 1))
61
+ start = -1
62
+ }
63
+ }
64
+ }
65
+
66
+ return chunks
67
+ }
68
+
69
+ /**
70
+ * Parse canvas event text into an array of event objects.
71
+ * Blank lines and malformed JSON snippets are skipped.
72
+ *
73
+ * @param {string} text - Raw canvas event file contents
23
74
  * @returns {object[]} Parsed event objects
24
75
  */
25
76
  export function parseCanvasJsonl(text) {
26
77
  const events = []
27
- for (const line of text.split('\n')) {
28
- const trimmed = line.trim()
29
- if (!trimmed) continue
78
+ for (const snippet of splitJsonObjects(text || '')) {
30
79
  try {
31
- events.push(JSON.parse(trimmed))
80
+ events.push(JSON.parse(snippet))
32
81
  } catch {
33
- // Skip malformed lines
82
+ // Skip malformed snippets
34
83
  }
35
84
  }
36
85
  return events
@@ -22,6 +22,22 @@ describe('parseCanvasJsonl', () => {
22
22
  expect(events).toHaveLength(2)
23
23
  })
24
24
 
25
+ it('parses concatenated JSON objects on a single line', () => {
26
+ const text = '{"event":"canvas_created","title":"Test"}{"event":"source_updated","sources":[]}'
27
+ const events = parseCanvasJsonl(text)
28
+ expect(events).toHaveLength(2)
29
+ expect(events[0].event).toBe('canvas_created')
30
+ expect(events[1].event).toBe('source_updated')
31
+ })
32
+
33
+ it('handles braces inside JSON strings', () => {
34
+ const text = '{"event":"canvas_created","title":"A {title}"}{"event":"widget_added","widget":{"id":"w1","props":{"text":"x}"}}}'
35
+ const events = parseCanvasJsonl(text)
36
+ expect(events).toHaveLength(2)
37
+ expect(events[0].title).toBe('A {title}')
38
+ expect(events[1].widget.props.text).toBe('x}')
39
+ })
40
+
25
41
  it('returns empty array for empty input', () => {
26
42
  expect(parseCanvasJsonl('')).toEqual([])
27
43
  expect(parseCanvasJsonl('\n\n')).toEqual([])
@@ -20,11 +20,27 @@
20
20
 
21
21
  /* Default: light tokens */
22
22
  [data-core-ui-bar],
23
+ [data-sidepanel],
23
24
  [data-slot="dropdown-menu-content"],
24
25
  [data-slot="dropdown-menu-sub-content"] {
25
26
  color-scheme: light;
26
27
 
27
28
  /* shadcn / Tailwind semantic tokens — pinned to light values */
29
+ --color-background: hsl(0 0% 100%);
30
+ --color-foreground: hsl(222.2 84% 4.9%);
31
+ --color-popover: hsl(0 0% 100%);
32
+ --color-popover-foreground: hsl(222.2 84% 4.9%);
33
+ --color-primary: hsl(222.2 47.4% 11.2%);
34
+ --color-primary-foreground: hsl(210 40% 98%);
35
+ --color-secondary: hsl(210 40% 96.1%);
36
+ --color-secondary-foreground: hsl(222.2 47.4% 11.2%);
37
+ --color-muted: hsl(210 40% 96.1%);
38
+ --color-muted-foreground: hsl(215.4 16.3% 46.9%);
39
+ --color-accent: hsl(210 40% 96.1%);
40
+ --color-accent-foreground: hsl(222.2 47.4% 11.2%);
41
+ --color-border: hsl(214.3 31.8% 91.4%);
42
+ --color-input: hsl(214.3 31.8% 91.4%);
43
+
28
44
  --sb--color-background: hsl(0 0% 100%);
29
45
  --sb--color-foreground: hsl(222.2 84% 4.9%);
30
46
  --sb--color-popover: hsl(0 0% 100%);
@@ -39,6 +55,13 @@
39
55
  --sb--color-accent-foreground: hsl(222.2 47.4% 11.2%);
40
56
  --sb--color-border: hsl(214.3 31.8% 91.4%);
41
57
  --sb--color-input: hsl(214.3 31.8% 91.4%);
58
+
59
+ /* Primer token aliases used in side panel + inspector chrome */
60
+ --bgColor-default: #ffffff;
61
+ --fgColor-default: #1f2328;
62
+ --fgColor-muted: #656d76;
63
+ --borderColor-default: #d1d9e0;
64
+ --bgColor-neutral-muted: rgba(175, 184, 193, 0.2);
42
65
  }
43
66
 
44
67
  /* Dark tokens — applied when toolbar syncs with a dark theme.
@@ -46,10 +69,26 @@
46
69
  * instead of generic shadcn dark values.
47
70
  */
48
71
  :root[data-sb-toolbar-theme^="dark"] [data-core-ui-bar],
72
+ :root[data-sb-toolbar-theme^="dark"] [data-sidepanel],
49
73
  :root[data-sb-toolbar-theme^="dark"] [data-slot="dropdown-menu-content"],
50
74
  :root[data-sb-toolbar-theme^="dark"] [data-slot="dropdown-menu-sub-content"] {
51
75
  color-scheme: dark;
52
76
 
77
+ --color-background: #161b22;
78
+ --color-foreground: #e6edf3;
79
+ --color-popover: #161b22;
80
+ --color-popover-foreground: #e6edf3;
81
+ --color-primary: #e6edf3;
82
+ --color-primary-foreground: #161b22;
83
+ --color-secondary: #21262d;
84
+ --color-secondary-foreground: #e6edf3;
85
+ --color-muted: #21262d;
86
+ --color-muted-foreground: #8b949e;
87
+ --color-accent: #21262d;
88
+ --color-accent-foreground: #e6edf3;
89
+ --color-border: #30363d;
90
+ --color-input: #30363d;
91
+
53
92
  --sb--color-background: #161b22;
54
93
  --sb--color-foreground: #e6edf3;
55
94
  --sb--color-popover: #161b22;
@@ -64,4 +103,11 @@
64
103
  --sb--color-accent-foreground: #e6edf3;
65
104
  --sb--color-border: #30363d;
66
105
  --sb--color-input: #30363d;
106
+
107
+ /* Primer token aliases used in side panel + inspector chrome */
108
+ --bgColor-default: #161b22;
109
+ --fgColor-default: #e6edf3;
110
+ --fgColor-muted: #8b949e;
111
+ --borderColor-default: #30363d;
112
+ --bgColor-neutral-muted: rgba(110, 118, 129, 0.2);
67
113
  }
@@ -33,8 +33,23 @@ function applyEarlyTheme() {
33
33
  typeof localStorage !== 'undefined'
34
34
  ? localStorage.getItem('sb-color-scheme')
35
35
  : null
36
+ const storedSync =
37
+ typeof localStorage !== 'undefined'
38
+ ? localStorage.getItem('sb-theme-sync')
39
+ : null
40
+ let syncTargets = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
41
+ if (storedSync) {
42
+ try {
43
+ syncTargets = { ...syncTargets, ...JSON.parse(storedSync) }
44
+ } catch {
45
+ // Ignore malformed persisted sync settings and use defaults.
46
+ }
47
+ }
36
48
  const theme = stored || 'system'
37
49
  const el = document.documentElement
50
+ const searchParams =
51
+ typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null
52
+ const forcedTarget = searchParams?.get('_sb_theme_target')
38
53
 
39
54
  // Resolve "system" to an actual theme for data-sb-theme
40
55
  let resolved = theme
@@ -46,19 +61,34 @@ function applyEarlyTheme() {
46
61
  : 'light'
47
62
  }
48
63
 
49
- el.setAttribute('data-sb-theme', resolved)
64
+ const forcePrototype = forcedTarget === 'prototype'
65
+ const forceToolbar = forcedTarget === 'toolbar'
50
66
 
51
- if (theme === 'system') {
67
+ const prototypeTheme = forcePrototype
68
+ ? resolved
69
+ : (syncTargets.prototype ? resolved : 'light')
70
+ const toolbarTheme = forceToolbar
71
+ ? resolved
72
+ : (syncTargets.toolbar ? resolved : 'light')
73
+ const codeTheme = syncTargets.codeBoxes ? resolved : 'light'
74
+ const canvasTheme = syncTargets.canvas ? resolved : 'light'
75
+
76
+ el.setAttribute('data-sb-theme', prototypeTheme)
77
+ el.setAttribute('data-sb-toolbar-theme', toolbarTheme)
78
+ el.setAttribute('data-sb-code-theme', codeTheme)
79
+ el.setAttribute('data-sb-canvas-theme', canvasTheme)
80
+
81
+ if (theme === 'system' && syncTargets.prototype) {
52
82
  el.setAttribute('data-color-mode', 'auto')
53
83
  el.setAttribute('data-light-theme', 'light')
54
84
  el.setAttribute('data-dark-theme', 'dark')
55
- } else if (resolved.startsWith('dark')) {
85
+ } else if (prototypeTheme.startsWith('dark')) {
56
86
  el.setAttribute('data-color-mode', 'dark')
57
- el.setAttribute('data-dark-theme', resolved)
87
+ el.setAttribute('data-dark-theme', prototypeTheme)
58
88
  el.setAttribute('data-light-theme', 'light')
59
89
  } else {
60
90
  el.setAttribute('data-color-mode', 'light')
61
- el.setAttribute('data-light-theme', resolved)
91
+ el.setAttribute('data-light-theme', prototypeTheme)
62
92
  el.setAttribute('data-dark-theme', 'dark')
63
93
  }
64
94
  }
@@ -96,6 +96,7 @@ function _applyToDOM(theme: ThemeValue, resolved: string): void {
96
96
  const prototypeTheme = _syncTargets.prototype ? resolved : 'light'
97
97
  const toolbarTheme = _syncTargets.toolbar ? resolved : 'light'
98
98
  const codeTheme = _syncTargets.codeBoxes ? resolved : 'light'
99
+ const canvasTheme = _syncTargets.canvas ? resolved : 'light'
99
100
 
100
101
  // Internal attributes
101
102
  el.setAttribute('data-sb-theme', prototypeTheme)
@@ -103,6 +104,7 @@ function _applyToDOM(theme: ThemeValue, resolved: string): void {
103
104
 
104
105
  // Toolbar theme — follows global theme when synced, stays light otherwise
105
106
  el.setAttribute('data-sb-toolbar-theme', toolbarTheme)
107
+ el.setAttribute('data-sb-canvas-theme', canvasTheme)
106
108
 
107
109
  // Primer CSS attributes — these drive @primer/react ThemeProvider and
108
110
  // Primer CSS custom-property layers without needing React state updates.
@@ -127,6 +129,7 @@ function _dispatchEvent(theme: ThemeValue, resolved: string): void {
127
129
  const prototypeResolved = _syncTargets.prototype ? resolved : 'light'
128
130
  const toolbarResolved = _syncTargets.toolbar ? resolved : 'light'
129
131
  const codeResolved = _syncTargets.codeBoxes ? resolved : 'light'
132
+ const canvasResolved = _syncTargets.canvas ? resolved : 'light'
130
133
 
131
134
  document.dispatchEvent(
132
135
  new CustomEvent('storyboard:theme:changed', {
@@ -137,6 +140,7 @@ function _dispatchEvent(theme: ThemeValue, resolved: string): void {
137
140
  prototypeResolved,
138
141
  toolbarResolved,
139
142
  codeResolved,
143
+ canvasResolved,
140
144
  },
141
145
  }),
142
146
  )
@@ -193,6 +197,7 @@ export interface ThemeSyncTargets {
193
197
  prototype: boolean
194
198
  toolbar: boolean
195
199
  codeBoxes: boolean
200
+ canvas: boolean
196
201
  }
197
202
 
198
203
  const SYNC_STORAGE_KEY = 'sb-theme-sync'
@@ -201,6 +206,7 @@ const DEFAULT_SYNC: ThemeSyncTargets = {
201
206
  prototype: true,
202
207
  toolbar: false,
203
208
  codeBoxes: true,
209
+ canvas: false,
204
210
  }
205
211
 
206
212
  function readStoredSync(): ThemeSyncTargets {
@@ -28,7 +28,7 @@
28
28
  "render": "menu",
29
29
  "surface": "main-toolbar",
30
30
  "modes": ["*"],
31
- "menuWidth": "260px",
31
+ "menuWidth": "280px",
32
32
  "handler": "core:flows"
33
33
  },
34
34
  "theme": {
@@ -40,7 +40,7 @@
40
40
  "surface": "main-toolbar",
41
41
  "handler": "core:theme",
42
42
  "modes": ["*"],
43
- "menuWidth": "220px"
43
+ "menuWidth": "240px"
44
44
  },
45
45
  "comments": {
46
46
  "ariaLabel": "Comments",