@dfosco/storyboard-react 4.2.0-beta.2 → 4.2.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 (85) hide show
  1. package/package.json +9 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  3. package/src/BranchBar/BranchBar.jsx +20 -6
  4. package/src/BranchBar/BranchBar.module.css +13 -4
  5. package/src/BranchBar/useBranches.js +20 -6
  6. package/src/BranchBar/useBranches.test.js +68 -0
  7. package/src/CommandPalette/CommandPalette.jsx +478 -186
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +561 -191
  11. package/src/Viewfinder.module.css +434 -93
  12. package/src/Workspace.jsx +7 -0
  13. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  14. package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
  15. package/src/canvas/CanvasPage.jsx +738 -216
  16. package/src/canvas/CanvasPage.module.css +13 -15
  17. package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
  18. package/src/canvas/ConnectorLayer.jsx +121 -153
  19. package/src/canvas/ConnectorLayer.module.css +69 -0
  20. package/src/canvas/PageSelector.test.jsx +15 -6
  21. package/src/canvas/canvasApi.js +68 -2
  22. package/src/canvas/connectorGeometry.js +132 -0
  23. package/src/canvas/hotPoolDevLogs.js +25 -0
  24. package/src/canvas/useCanvas.js +1 -1
  25. package/src/canvas/useMarqueeSelect.js +30 -4
  26. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  27. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  28. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  29. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  30. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  31. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  32. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  33. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  34. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  35. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  38. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  39. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  40. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  41. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  42. package/src/canvas/widgets/LinkPreview.jsx +112 -4
  43. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  44. package/src/canvas/widgets/MarkdownBlock.jsx +164 -17
  45. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  46. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  47. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  48. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -38
  49. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  50. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  51. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  52. package/src/canvas/widgets/StoryWidget.jsx +72 -15
  53. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  54. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  55. package/src/canvas/widgets/TerminalWidget.jsx +496 -69
  56. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  57. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  58. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  59. package/src/canvas/widgets/WidgetChrome.jsx +73 -153
  60. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  61. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  62. package/src/canvas/widgets/expandUtils.js +557 -0
  63. package/src/canvas/widgets/expandUtils.test.js +155 -0
  64. package/src/canvas/widgets/index.js +9 -0
  65. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  66. package/src/canvas/widgets/tilePool.js +23 -0
  67. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  68. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  70. package/src/canvas/widgets/tiles/leaf.png +0 -0
  71. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  73. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  75. package/src/canvas/widgets/widgetConfig.js +55 -4
  76. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  77. package/src/canvas/widgets/widgetProps.js +1 -0
  78. package/src/context.jsx +47 -19
  79. package/src/hooks/useConfig.js +14 -0
  80. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  81. package/src/index.js +8 -2
  82. package/src/story/ComponentSetPage.jsx +186 -0
  83. package/src/story/ComponentSetPage.module.css +121 -0
  84. package/src/story/StoryPage.jsx +32 -2
  85. package/src/vite/data-plugin.js +324 -30
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { assignToQuadrants, buildSplitLayout } from './expandUtils.js'
3
+
4
+ describe('assignToQuadrants', () => {
5
+ it('assigns 2 items to left/right columns', () => {
6
+ const result = assignToQuadrants([
7
+ { x: 100, y: 200, data: 'A' },
8
+ { x: 500, y: 200, data: 'B' },
9
+ ])
10
+ // centroid.y = 200 = both y values, so both get 'b' row (>= centroid)
11
+ expect(result.bl).toBe('A')
12
+ expect(result.br).toBe('B')
13
+ expect(result.tl).toBeNull()
14
+ expect(result.tr).toBeNull()
15
+ })
16
+
17
+ it('assigns 2 items stacked vertically to top/bottom in same column', () => {
18
+ const result = assignToQuadrants([
19
+ { x: 100, y: 100, data: 'A' },
20
+ { x: 100, y: 500, data: 'B' },
21
+ ])
22
+ // Both same x → degenerate x, but different y
23
+ // centroid = (100, 300). A.y=100 < 300 → top, B.y=500 >= 300 → bottom
24
+ // A.x=100 is NOT < centroid.x=100, so both go to 'r'
25
+ // → tr='A', br='B'
26
+ expect(result.tr).toBe('A')
27
+ expect(result.br).toBe('B')
28
+ })
29
+
30
+ it('assigns 4 items to all quadrants', () => {
31
+ const result = assignToQuadrants([
32
+ { x: 100, y: 100, data: 'TL' },
33
+ { x: 500, y: 100, data: 'TR' },
34
+ { x: 100, y: 500, data: 'BL' },
35
+ { x: 500, y: 500, data: 'BR' },
36
+ ])
37
+ expect(result.tl).toBe('TL')
38
+ expect(result.tr).toBe('TR')
39
+ expect(result.bl).toBe('BL')
40
+ expect(result.br).toBe('BR')
41
+ })
42
+
43
+ it('assigns 3 items: 2 in left column, 1 in right', () => {
44
+ const result = assignToQuadrants([
45
+ { x: 100, y: 100, data: 'TL' },
46
+ { x: 100, y: 500, data: 'BL' },
47
+ { x: 500, y: 300, data: 'R' },
48
+ ])
49
+ // centroid x = (100+100+500)/3 ≈ 233, y = (100+500+300)/3 = 300
50
+ // TL: x=100 < 233 → l, y=100 < 300 → t → tl
51
+ // BL: x=100 < 233 → l, y=500 >= 300 → b → bl
52
+ // R: x=500 >= 233 → r, y=300 >= 300 → b → br
53
+ expect(result.tl).toBe('TL')
54
+ expect(result.bl).toBe('BL')
55
+ expect(result.br).toBe('R')
56
+ })
57
+
58
+ it('cycles TL→TR→BL→BR when all positions are identical', () => {
59
+ const result = assignToQuadrants([
60
+ { x: 0, y: 0, data: 'A' },
61
+ { x: 0, y: 0, data: 'B' },
62
+ { x: 0, y: 0, data: 'C' },
63
+ ])
64
+ expect(result.tl).toBe('A')
65
+ expect(result.tr).toBe('B')
66
+ expect(result.bl).toBe('C')
67
+ expect(result.br).toBeNull()
68
+ })
69
+
70
+ it('handles overflow: 2 items in same quadrant', () => {
71
+ // Two items very close, both land in same quadrant → overflow redistributes
72
+ const result = assignToQuadrants([
73
+ { x: 100, y: 100, data: 'A' },
74
+ { x: 110, y: 110, data: 'B' },
75
+ { x: 500, y: 500, data: 'C' },
76
+ ])
77
+ // centroid = (236, 236). A: x<236 y<236 → tl. B: x<236 y<236 → tl (overflow!)
78
+ // C: x>=236 y>=236 → br
79
+ // A wins tl, B overflows to first empty slot (tr)
80
+ expect(result.tl).toBe('A')
81
+ expect(result.br).toBe('C')
82
+ // B goes to overflow → first empty slot (tr)
83
+ expect(result.tr).toBe('B')
84
+ })
85
+
86
+ it('returns all nulls for empty input', () => {
87
+ const result = assignToQuadrants([])
88
+ expect(result.tl).toBeNull()
89
+ expect(result.tr).toBeNull()
90
+ expect(result.bl).toBeNull()
91
+ expect(result.br).toBeNull()
92
+ })
93
+ })
94
+
95
+ describe('buildSplitLayout', () => {
96
+ function mockPaneFn(widget) {
97
+ return { id: widget.id, label: widget.id, kind: 'react', render: () => null }
98
+ }
99
+
100
+ it('returns single column for 1 widget (no connected)', () => {
101
+ const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 0 }, props: {} }
102
+ const layout = buildSplitLayout(primary, [], mockPaneFn)
103
+ expect(layout.length).toBe(1)
104
+ expect(layout[0].length).toBe(1)
105
+ expect(layout[0][0].id).toBe('a')
106
+ })
107
+
108
+ it('returns 2 columns for 2 horizontally-spaced widgets', () => {
109
+ const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 100 }, props: {} }
110
+ const connected = [
111
+ { id: 'b', type: 'prototype', position: { x: 500, y: 100 }, props: {} },
112
+ ]
113
+ const layout = buildSplitLayout(primary, connected, mockPaneFn)
114
+ expect(layout.length).toBe(2)
115
+ expect(layout[0].length).toBe(1) // left column
116
+ expect(layout[1].length).toBe(1) // right column
117
+ })
118
+
119
+ it('returns 2 columns with row split for 3 widgets', () => {
120
+ const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 0 }, props: {} }
121
+ const connected = [
122
+ { id: 'b', type: 'prototype', position: { x: 500, y: 0 }, props: {} },
123
+ { id: 'c', type: 'markdown', position: { x: 500, y: 500 }, props: {} },
124
+ ]
125
+ const layout = buildSplitLayout(primary, connected, mockPaneFn)
126
+ const totalPanes = layout.flat().length
127
+ expect(totalPanes).toBe(3)
128
+ // One column should have 2 panes (row split)
129
+ const hasRowSplit = layout.some((col) => col.length === 2)
130
+ expect(hasRowSplit).toBe(true)
131
+ })
132
+
133
+ it('returns 2×2 grid for 4 widgets', () => {
134
+ const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 0 }, props: {} }
135
+ const connected = [
136
+ { id: 'b', type: 'prototype', position: { x: 500, y: 0 }, props: {} },
137
+ { id: 'c', type: 'markdown', position: { x: 0, y: 500 }, props: {} },
138
+ { id: 'd', type: 'agent', position: { x: 500, y: 500 }, props: {} },
139
+ ]
140
+ const layout = buildSplitLayout(primary, connected, mockPaneFn)
141
+ expect(layout.length).toBe(2)
142
+ expect(layout[0].length).toBe(2)
143
+ expect(layout[1].length).toBe(2)
144
+ })
145
+
146
+ it('skips widgets where buildPaneFn returns null', () => {
147
+ const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 0 }, props: {} }
148
+ const connected = [
149
+ { id: 'b', type: 'unknown', position: { x: 500, y: 0 }, props: {} },
150
+ ]
151
+ const paneFn = (w) => w.id === 'a' ? mockPaneFn(w) : null
152
+ const layout = buildSplitLayout(primary, connected, paneFn)
153
+ expect(layout.flat().length).toBe(1)
154
+ })
155
+ })
@@ -6,7 +6,11 @@ import ImageWidget from './ImageWidget.jsx'
6
6
  import FigmaEmbed from './FigmaEmbed.jsx'
7
7
  import CodePenEmbed from './CodePenEmbed.jsx'
8
8
  import StoryWidget from './StoryWidget.jsx'
9
+ import ComponentSetWidget from './ComponentSetWidget.jsx'
9
10
  import TerminalWidget from './TerminalWidget.jsx'
11
+ import TerminalReadWidget from './TerminalReadWidget.jsx'
12
+ import PromptWidget from './PromptWidget.jsx'
13
+ import TilesWidget from './TilesWidget.jsx'
10
14
 
11
15
  /**
12
16
  * Maps widget type strings to their React components.
@@ -21,7 +25,12 @@ export const widgetRegistry = {
21
25
  'figma-embed': FigmaEmbed,
22
26
  'codepen-embed': CodePenEmbed,
23
27
  'story': StoryWidget,
28
+ 'component-set': ComponentSetWidget,
24
29
  'terminal': TerminalWidget,
30
+ 'terminal-read': TerminalReadWidget,
31
+ 'agent': TerminalWidget,
32
+ 'prompt': PromptWidget,
33
+ 'tiles': TilesWidget,
25
34
  }
26
35
 
27
36
  /**
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Tests for iframe snapshot display — single snapshot prop.
3
3
  */
4
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
5
- import { render, fireEvent, waitFor, act } from '@testing-library/react'
4
+ import { describe, it, expect, vi, afterEach } from 'vitest'
5
+ import { render } from '@testing-library/react'
6
6
  import PrototypeEmbed from './PrototypeEmbed.jsx'
7
7
  import StoryWidget from './StoryWidget.jsx'
8
8
 
@@ -54,9 +54,9 @@ afterEach(() => {
54
54
  document.querySelectorAll('[data-sb-canvas-theme]').forEach(el => el.remove())
55
55
  })
56
56
 
57
- describe('Snapshot display', () => {
57
+ describe('Snapshot display (snapshots removed — iframes always render)', () => {
58
58
  describe('PrototypeEmbed', () => {
59
- it('shows snapshot image when valid snapshot prop exists', () => {
59
+ it('renders iframe even when snapshot prop is provided', () => {
60
60
  const { wrapper } = renderInCanvas(
61
61
  <PrototypeEmbed
62
62
  id="proto-abc123"
@@ -72,13 +72,11 @@ describe('Snapshot display', () => {
72
72
  />
73
73
  )
74
74
 
75
- const img = wrapper.querySelector('img')
76
- expect(img).toBeInTheDocument()
77
- expect(img.src).toContain('snapshot-proto-abc123.webp')
78
- expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
75
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
76
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
79
77
  })
80
78
 
81
- it('falls back to snapshotLight for backward compat', () => {
79
+ it('renders iframe when snapshotLight prop is provided', () => {
82
80
  const { wrapper } = renderInCanvas(
83
81
  <PrototypeEmbed
84
82
  id="proto-abc123"
@@ -94,12 +92,11 @@ describe('Snapshot display', () => {
94
92
  />
95
93
  )
96
94
 
97
- const img = wrapper.querySelector('img')
98
- expect(img).toBeInTheDocument()
99
- expect(img.src).toContain('snapshot-proto-abc123--light.webp')
95
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
96
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
100
97
  })
101
98
 
102
- it('shows placeholder when no snapshot exists', () => {
99
+ it('renders iframe when no snapshot exists', () => {
103
100
  const { wrapper } = renderInCanvas(
104
101
  <PrototypeEmbed
105
102
  id="proto-xyz"
@@ -110,32 +107,10 @@ describe('Snapshot display', () => {
110
107
  )
111
108
 
112
109
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
113
- expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
114
- })
115
-
116
- it('falls back to placeholder when snapshot image fails to load', () => {
117
- const { wrapper } = renderInCanvas(
118
- <PrototypeEmbed
119
- id="proto-abc123"
120
- props={{
121
- src: '/test',
122
- width: 400,
123
- height: 300,
124
- zoom: 100,
125
- snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
126
- }}
127
- onUpdate={vi.fn()}
128
- resizable={false}
129
- />
130
- )
131
-
132
- const img = wrapper.querySelector('img')
133
- expect(img).toBeInTheDocument()
134
- fireEvent.error(img)
135
- expect(wrapper.querySelector('img')).not.toBeInTheDocument()
110
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
136
111
  })
137
112
 
138
- it('ignores snapshot that does not match widget ID', () => {
113
+ it('ignores snapshot prop that does not match widget ID', () => {
139
114
  const { wrapper } = renderInCanvas(
140
115
  <PrototypeEmbed
141
116
  id="proto-abc123"
@@ -152,9 +127,10 @@ describe('Snapshot display', () => {
152
127
  )
153
128
 
154
129
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
130
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
155
131
  })
156
132
 
157
- it('does not show snapshot for external URLs', () => {
133
+ it('renders iframe for external URLs regardless of snapshot', () => {
158
134
  const { wrapper } = renderInCanvas(
159
135
  <PrototypeEmbed
160
136
  id="proto-ext"
@@ -171,11 +147,12 @@ describe('Snapshot display', () => {
171
147
  )
172
148
 
173
149
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
150
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
174
151
  })
175
152
  })
176
153
 
177
154
  describe('StoryWidget', () => {
178
- it('shows snapshot image when valid snapshot prop exists', () => {
155
+ it('renders iframe even when snapshot prop is provided', () => {
179
156
  const { wrapper } = renderInCanvas(
180
157
  <StoryWidget
181
158
  id="story-abc123"
@@ -191,13 +168,11 @@ describe('Snapshot display', () => {
191
168
  />
192
169
  )
193
170
 
194
- const img = wrapper.querySelector('img')
195
- expect(img).toBeInTheDocument()
196
- expect(img.src).toContain('snapshot-story-abc123.webp')
197
- expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
171
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
172
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
198
173
  })
199
174
 
200
- it('falls back to snapshotDark for backward compat', () => {
175
+ it('renders iframe when snapshotDark prop is provided', () => {
201
176
  const { wrapper } = renderInCanvas(
202
177
  <StoryWidget
203
178
  id="story-abc123"
@@ -210,50 +185,27 @@ describe('Snapshot display', () => {
210
185
  />
211
186
  )
212
187
 
213
- const img = wrapper.querySelector('img')
214
- expect(img).toBeInTheDocument()
215
- expect(img.src).toContain('snapshot-story-abc123--dark.webp')
216
- })
217
-
218
- it('shows placeholder when no snapshot exists', () => {
219
- const { wrapper } = renderInCanvas(
220
- <StoryWidget
221
- id="story-xyz"
222
- props={{
223
- storyId: 'button-patterns',
224
- exportName: 'Primary',
225
- width: 400,
226
- height: 300,
227
- }}
228
- onUpdate={vi.fn()}
229
- resizable={false}
230
- />
231
- )
232
-
233
188
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
234
- expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
189
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
235
190
  })
236
191
 
237
- it('falls back to placeholder when snapshot image fails to load', () => {
192
+ it('renders iframe when no snapshot exists', () => {
238
193
  const { wrapper } = renderInCanvas(
239
194
  <StoryWidget
240
- id="story-abc123"
195
+ id="story-xyz"
241
196
  props={{
242
197
  storyId: 'button-patterns',
243
198
  exportName: 'Primary',
244
199
  width: 400,
245
200
  height: 300,
246
- snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
247
201
  }}
248
202
  onUpdate={vi.fn()}
249
203
  resizable={false}
250
204
  />
251
205
  )
252
206
 
253
- const img = wrapper.querySelector('img')
254
- expect(img).toBeInTheDocument()
255
- fireEvent.error(img)
256
207
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
208
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
257
209
  })
258
210
  })
259
211
  })
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Static tile image pool.
3
+ * Each import resolves to a Vite asset URL at build time.
4
+ */
5
+ import solidA from './tiles/solid-a.png'
6
+ import solidB from './tiles/solid-b.png'
7
+ import quarterTL from './tiles/quarter-tl.png'
8
+ import quarterTR from './tiles/quarter-tr.png'
9
+ import diagonalBR from './tiles/diagonal-br.png'
10
+ import diagonalBL from './tiles/diagonal-bl.png'
11
+ import diagonalTL from './tiles/diagonal-tl.png'
12
+ import leaf from './tiles/leaf.png'
13
+
14
+ export const TILE_POOL = [
15
+ solidA,
16
+ solidB,
17
+ quarterTL,
18
+ quarterTR,
19
+ diagonalBR,
20
+ diagonalBL,
21
+ diagonalTL,
22
+ leaf,
23
+ ]
@@ -50,6 +50,11 @@ function resolveFeature(feature) {
50
50
  const r = {}
51
51
  for (const [k, v] of Object.entries(val)) r[k] = resolveVar(v)
52
52
  resolved[key] = r
53
+ } else if (key === 'toggle' && val && typeof val === 'object') {
54
+ // Pass toggle config through as-is (stateKey, activeIcon, activeLabel)
55
+ resolved[key] = { ...val }
56
+ } else if (key === 'surfaces' && Array.isArray(val)) {
57
+ resolved[key] = val
53
58
  } else {
54
59
  resolved[key] = resolveVar(val)
55
60
  }
@@ -115,16 +120,44 @@ export const widgetTypes = buildWidgetTypes()
115
120
  * In production (or when isLocalDev is false, e.g. ?prodMode simulation),
116
121
  * only features with `prod: true` are returned.
117
122
  * In dev, all features are returned.
123
+ *
124
+ * Features with an explicit `surfaces` array that does NOT include `"toolbar"`
125
+ * are excluded — they only render on their declared surfaces (fullbar/splitbar).
126
+ * Features without a `surfaces` array default to toolbar-only.
127
+ *
118
128
  * @param {string} type — widget type string
119
129
  * @param {{ isLocalDev?: boolean }} [options]
120
130
  * @returns {Array} features array from config (variables resolved), or empty array
121
131
  */
122
132
  export function getFeatures(type, { isLocalDev = true } = {}) {
123
133
  const features = widgetTypes[type]?.features ?? []
134
+ let filtered = features.filter(f => {
135
+ const surfaces = f.surfaces || ['toolbar']
136
+ return surfaces.includes('toolbar')
137
+ })
124
138
  if (import.meta.env?.PROD || !isLocalDev) {
125
- return features.filter(f => f.prod)
139
+ filtered = filtered.filter(f => f.prod)
126
140
  }
127
- return features
141
+ return filtered
142
+ }
143
+
144
+ /**
145
+ * Get features for a specific rendering surface.
146
+ * Features without a `surfaces` array default to `["toolbar"]`.
147
+ * @param {string} type — widget type string
148
+ * @param {'toolbar' | 'fullbar' | 'splitbar'} surface — target surface
149
+ * @param {{ isLocalDev?: boolean }} [options]
150
+ * @returns {Array} filtered features for the given surface
151
+ */
152
+ export function getFeaturesForSurface(type, surface, { isLocalDev = true } = {}) {
153
+ let features = widgetTypes[type]?.features ?? []
154
+ if (import.meta.env?.PROD || !isLocalDev) {
155
+ features = features.filter(f => f.prod)
156
+ }
157
+ return features.filter(f => {
158
+ const surfaces = f.surfaces || ['toolbar']
159
+ return surfaces.includes(surface)
160
+ })
128
161
  }
129
162
 
130
163
  /**
@@ -151,6 +184,24 @@ export function getWidgetMeta(type) {
151
184
  return { label: def.label, icon: def.icon }
152
185
  }
153
186
 
187
+ /**
188
+ * Check if a widget type supports expanding to a full-screen modal.
189
+ * @param {string} type — widget type string
190
+ * @returns {boolean}
191
+ */
192
+ export function isExpandable(type) {
193
+ return widgetTypes[type]?.expandable === true
194
+ }
195
+
196
+ /**
197
+ * Check if a widget type can appear in a split-screen pane.
198
+ * @param {string} type — widget type string
199
+ * @returns {boolean}
200
+ */
201
+ export function isSplitScreenCapable(type) {
202
+ return widgetTypes[type]?.splitScreen === true
203
+ }
204
+
154
205
  /**
155
206
  * Get the interact gate config for a widget type.
156
207
  * @returns {{ enabled: boolean, label: string }}
@@ -170,7 +221,7 @@ export function getInteractGate(type) {
170
221
  */
171
222
  export function getMenuWidgetTypes() {
172
223
  return Object.entries(widgetTypes)
173
- .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story')
224
+ .filter(([type, def]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story' && type !== 'terminal-read' && !def.unlisted)
174
225
  .map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
175
226
  }
176
227
 
@@ -216,7 +267,7 @@ export function getConnectorDefaults() {
216
267
  endpointFill: defaults.endpointFill ?? 'var(--fgColor-accent, #0969da)',
217
268
  endpointStroke: defaults.endpointStroke ?? 'var(--bgColor-default, #ffffff)',
218
269
  endpointStrokeWidth: defaults.endpointStrokeWidth ?? 3,
219
- hitAreaStrokeWidth: defaults.hitAreaStrokeWidth ?? 16,
270
+ hitAreaStrokeWidth: defaults.hitAreaStrokeWidth ?? 24,
220
271
  dragStroke: defaults.dragStroke ?? 'var(--fgColor-accent, #0969da)',
221
272
  dragStrokeWidth: defaults.dragStrokeWidth ?? 2,
222
273
  dragDasharray: defaults.dragDasharray ?? '6 4',