@dfosco/storyboard-core 4.0.0-beta.2 → 4.0.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 (73) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +11882 -11126
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -3
  6. package/paste.config.json +54 -0
  7. package/scaffold/deploy.yml +101 -0
  8. package/scaffold/githooks/pre-push +114 -0
  9. package/scaffold/manifest.json +11 -0
  10. package/scaffold/storyboard.config.json +4 -1
  11. package/src/ActionMenuButton.svelte +12 -2
  12. package/src/CanvasCreateMenu.svelte +228 -10
  13. package/src/CanvasSnap.svelte +2 -0
  14. package/src/CoreUIBar.svelte +152 -3
  15. package/src/CreateMenuButton.svelte +4 -1
  16. package/src/InspectorPanel.svelte +2 -0
  17. package/src/PwaInstallBanner.svelte +124 -0
  18. package/src/autosync/server.js +99 -111
  19. package/src/autosync/server.test.js +0 -7
  20. package/src/canvas/collision.js +206 -0
  21. package/src/canvas/collision.test.js +271 -0
  22. package/src/canvas/deriveCanvasId.test.js +40 -0
  23. package/src/canvas/identity.js +107 -0
  24. package/src/canvas/identity.test.js +100 -0
  25. package/src/canvas/server.js +285 -31
  26. package/src/canvasConfig.js +56 -0
  27. package/src/canvasConfig.test.js +42 -0
  28. package/src/cli/canvasAdd.js +185 -0
  29. package/src/cli/canvasRead.js +208 -0
  30. package/src/cli/code.js +67 -0
  31. package/src/cli/create.js +339 -72
  32. package/src/cli/dev-helpers.js +53 -0
  33. package/src/cli/dev-helpers.test.js +53 -0
  34. package/src/cli/dev.js +245 -26
  35. package/src/cli/flags.js +174 -0
  36. package/src/cli/flags.test.js +155 -0
  37. package/src/cli/index.js +84 -13
  38. package/src/cli/intro.js +37 -0
  39. package/src/cli/proxy.js +127 -6
  40. package/src/cli/proxy.test.js +63 -0
  41. package/src/cli/schemas.js +200 -0
  42. package/src/cli/serverUrl.js +56 -0
  43. package/src/cli/setup.js +130 -20
  44. package/src/cli/snapshots.js +335 -0
  45. package/src/cli/updateVersion.js +54 -3
  46. package/src/configSchema.js +125 -0
  47. package/src/configSchema.test.js +68 -0
  48. package/src/index.js +5 -0
  49. package/src/inspector/highlighter.js +10 -2
  50. package/src/lib/components/ui/trigger-button/trigger-button.svelte +1 -1
  51. package/src/loader.js +21 -2
  52. package/src/loader.test.js +63 -1
  53. package/src/mobileViewport.js +57 -0
  54. package/src/mobileViewport.test.js +68 -0
  55. package/src/mountStoryboardCore.js +61 -7
  56. package/src/rename-watcher/config.json +23 -0
  57. package/src/rename-watcher/watcher.js +538 -0
  58. package/src/svelte-plugin-ui/components/Viewfinder.svelte +6 -17
  59. package/src/tools/handlers/flows.js +6 -7
  60. package/src/viewfinder.js +21 -9
  61. package/src/viewfinder.test.js +2 -2
  62. package/src/vite/server-plugin.js +150 -7
  63. package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +8 -2
  64. package/src/workshop/features/createFlow/CreateFlowForm.svelte +1 -1
  65. package/src/workshop/features/createPage/CreatePageForm.svelte +1 -1
  66. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +2 -2
  67. package/src/workshop/features/createStory/CreateStoryForm.svelte +160 -0
  68. package/src/workshop/features/createStory/index.js +14 -0
  69. package/src/workshop/features/registry.js +2 -0
  70. package/src/worktree/port.js +57 -1
  71. package/src/worktree/port.test.js +91 -1
  72. package/toolbar.config.json +3 -3
  73. package/widgets.config.json +132 -27
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Canvas Collision Detection — Find collision-free positions for widgets.
3
+ *
4
+ * When placing or moving widgets, this module checks for overlaps with
5
+ * existing widgets and adjusts the position until no collisions remain.
6
+ */
7
+
8
+ /**
9
+ * Default widget sizes by type (from widgets.config.json).
10
+ */
11
+ export const DEFAULT_SIZES = {
12
+ 'sticky-note': { width: 270, height: 170 },
13
+ 'markdown': { width: 530, height: 240 },
14
+ 'prototype': { width: 800, height: 600 },
15
+ 'figma-embed': { width: 800, height: 450 },
16
+ 'image': { width: 400, height: 300 },
17
+ 'link-preview': { width: 400, height: 200 },
18
+ 'component': { width: 300, height: 200 },
19
+ }
20
+
21
+ /**
22
+ * Get the bounding box of a widget.
23
+ * @param {object} widget - Widget with position and props
24
+ * @returns {{ x: number, y: number, width: number, height: number }}
25
+ */
26
+ export function getWidgetBounds(widget) {
27
+ const { position = { x: 0, y: 0 }, props = {}, type } = widget
28
+ const defaults = DEFAULT_SIZES[type] || { width: 270, height: 170 }
29
+ return {
30
+ x: position.x,
31
+ y: position.y,
32
+ width: props.width ?? defaults.width,
33
+ height: props.height ?? defaults.height,
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Check if two rectangles overlap.
39
+ * @param {{ x: number, y: number, width: number, height: number }} a
40
+ * @param {{ x: number, y: number, width: number, height: number }} b
41
+ * @returns {boolean}
42
+ */
43
+ export function rectsOverlap(a, b) {
44
+ return !(
45
+ a.x + a.width <= b.x || // a is to the left of b
46
+ b.x + b.width <= a.x || // b is to the left of a
47
+ a.y + a.height <= b.y || // a is above b
48
+ b.y + b.height <= a.y // b is above a
49
+ )
50
+ }
51
+
52
+ /**
53
+ * Check if a proposed position collides with any existing widget.
54
+ * @param {{ x: number, y: number, width: number, height: number }} rect - Proposed bounds
55
+ * @param {object[]} widgets - Existing widgets array
56
+ * @param {string} [excludeId] - Widget ID to exclude (for move operations)
57
+ * @returns {object|null} - The first colliding widget, or null if no collision
58
+ */
59
+ export function findCollision(rect, widgets, excludeId = null) {
60
+ for (const widget of widgets) {
61
+ if (excludeId && widget.id === excludeId) continue
62
+ const bounds = getWidgetBounds(widget)
63
+ if (rectsOverlap(rect, bounds)) {
64
+ return widget
65
+ }
66
+ }
67
+ return null
68
+ }
69
+
70
+ /**
71
+ * Snap a value to grid.
72
+ * @param {number} value
73
+ * @param {number} gridSize
74
+ * @returns {number}
75
+ */
76
+ export function snapToGrid(value, gridSize) {
77
+ return Math.round(value / gridSize) * gridSize
78
+ }
79
+
80
+ /**
81
+ * Find a collision-free position for a widget.
82
+ *
83
+ * Strategy:
84
+ * 1. Try the initial position
85
+ * 2. If collision, move right by (collider.width + gap)
86
+ * 3. If still colliding after maxIterations, try moving down instead
87
+ * 4. Snap final position to grid
88
+ *
89
+ * @param {object} options
90
+ * @param {number} options.x - Initial X position
91
+ * @param {number} options.y - Initial Y position
92
+ * @param {number} options.width - Widget width
93
+ * @param {number} options.height - Widget height
94
+ * @param {object[]} options.widgets - Existing widgets array
95
+ * @param {string} [options.excludeId] - Widget ID to exclude (for move operations)
96
+ * @param {number} [options.gridSize=24] - Grid size for snapping
97
+ * @param {number} [options.gap] - Gap between widgets (defaults to gridSize)
98
+ * @param {number} [options.maxIterations=50] - Max collision resolution attempts
99
+ * @returns {{ x: number, y: number, adjusted: boolean }}
100
+ */
101
+ export function findFreePosition({
102
+ x,
103
+ y,
104
+ width,
105
+ height,
106
+ widgets,
107
+ excludeId = null,
108
+ gridSize = 24,
109
+ gap = null,
110
+ maxIterations = 50,
111
+ }) {
112
+ const spacing = gap ?? gridSize
113
+ let currentX = x
114
+ let currentY = y
115
+ let adjusted = false
116
+ let iteration = 0
117
+
118
+ // Phase 1: Try moving right
119
+ while (iteration < maxIterations) {
120
+ const rect = { x: currentX, y: currentY, width, height }
121
+ const collider = findCollision(rect, widgets, excludeId)
122
+
123
+ if (!collider) {
124
+ // No collision — snap and return
125
+ return {
126
+ x: snapToGrid(currentX, gridSize),
127
+ y: snapToGrid(currentY, gridSize),
128
+ adjusted,
129
+ }
130
+ }
131
+
132
+ // Move right past the collider
133
+ const colliderBounds = getWidgetBounds(collider)
134
+ currentX = colliderBounds.x + colliderBounds.width + spacing
135
+ adjusted = true
136
+ iteration++
137
+ }
138
+
139
+ // Phase 2: Reset X, try moving down
140
+ currentX = x
141
+ iteration = 0
142
+
143
+ while (iteration < maxIterations) {
144
+ const rect = { x: currentX, y: currentY, width, height }
145
+ const collider = findCollision(rect, widgets, excludeId)
146
+
147
+ if (!collider) {
148
+ return {
149
+ x: snapToGrid(currentX, gridSize),
150
+ y: snapToGrid(currentY, gridSize),
151
+ adjusted,
152
+ }
153
+ }
154
+
155
+ // Move down past the collider
156
+ const colliderBounds = getWidgetBounds(collider)
157
+ currentY = colliderBounds.y + colliderBounds.height + spacing
158
+ adjusted = true
159
+ iteration++
160
+ }
161
+
162
+ // Fallback: return the last attempted position (snapped)
163
+ return {
164
+ x: snapToGrid(currentX, gridSize),
165
+ y: snapToGrid(currentY, gridSize),
166
+ adjusted,
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Resolve collision for a widget being placed or moved.
172
+ * Convenience wrapper that extracts size from widget type/props.
173
+ *
174
+ * @param {object} options
175
+ * @param {number} options.x - Target X position
176
+ * @param {number} options.y - Target Y position
177
+ * @param {string} options.type - Widget type
178
+ * @param {object} [options.props={}] - Widget props (may contain width/height)
179
+ * @param {object[]} options.widgets - Existing widgets array
180
+ * @param {string} [options.excludeId] - Widget ID to exclude
181
+ * @param {number} [options.gridSize=24] - Grid size
182
+ * @returns {{ x: number, y: number, adjusted: boolean }}
183
+ */
184
+ export function resolvePosition({
185
+ x,
186
+ y,
187
+ type,
188
+ props = {},
189
+ widgets,
190
+ excludeId = null,
191
+ gridSize = 24,
192
+ }) {
193
+ const defaults = DEFAULT_SIZES[type] || { width: 270, height: 170 }
194
+ const width = props.width ?? defaults.width
195
+ const height = props.height ?? defaults.height
196
+
197
+ return findFreePosition({
198
+ x,
199
+ y,
200
+ width,
201
+ height,
202
+ widgets,
203
+ excludeId,
204
+ gridSize,
205
+ })
206
+ }
@@ -0,0 +1,271 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ getWidgetBounds,
4
+ rectsOverlap,
5
+ findCollision,
6
+ snapToGrid,
7
+ findFreePosition,
8
+ resolvePosition,
9
+ DEFAULT_SIZES,
10
+ } from './collision.js'
11
+
12
+ describe('getWidgetBounds', () => {
13
+ it('uses position and props dimensions', () => {
14
+ const widget = {
15
+ type: 'sticky-note',
16
+ position: { x: 100, y: 200 },
17
+ props: { width: 300, height: 180 },
18
+ }
19
+ expect(getWidgetBounds(widget)).toEqual({
20
+ x: 100,
21
+ y: 200,
22
+ width: 300,
23
+ height: 180,
24
+ })
25
+ })
26
+
27
+ it('falls back to default sizes when props missing', () => {
28
+ const widget = {
29
+ type: 'sticky-note',
30
+ position: { x: 50, y: 50 },
31
+ props: {},
32
+ }
33
+ expect(getWidgetBounds(widget)).toEqual({
34
+ x: 50,
35
+ y: 50,
36
+ width: DEFAULT_SIZES['sticky-note'].width,
37
+ height: DEFAULT_SIZES['sticky-note'].height,
38
+ })
39
+ })
40
+
41
+ it('handles missing position and props', () => {
42
+ const widget = { type: 'markdown' }
43
+ expect(getWidgetBounds(widget)).toEqual({
44
+ x: 0,
45
+ y: 0,
46
+ width: DEFAULT_SIZES['markdown'].width,
47
+ height: DEFAULT_SIZES['markdown'].height,
48
+ })
49
+ })
50
+
51
+ it('uses generic fallback for unknown widget types', () => {
52
+ const widget = { type: 'unknown-type', position: { x: 10, y: 20 } }
53
+ expect(getWidgetBounds(widget)).toEqual({
54
+ x: 10,
55
+ y: 20,
56
+ width: 270,
57
+ height: 170,
58
+ })
59
+ })
60
+ })
61
+
62
+ describe('rectsOverlap', () => {
63
+ it('returns true for overlapping rects', () => {
64
+ const a = { x: 0, y: 0, width: 100, height: 100 }
65
+ const b = { x: 50, y: 50, width: 100, height: 100 }
66
+ expect(rectsOverlap(a, b)).toBe(true)
67
+ })
68
+
69
+ it('returns false for non-overlapping rects (side by side)', () => {
70
+ const a = { x: 0, y: 0, width: 100, height: 100 }
71
+ const b = { x: 100, y: 0, width: 100, height: 100 }
72
+ expect(rectsOverlap(a, b)).toBe(false)
73
+ })
74
+
75
+ it('returns false for non-overlapping rects (stacked)', () => {
76
+ const a = { x: 0, y: 0, width: 100, height: 100 }
77
+ const b = { x: 0, y: 100, width: 100, height: 100 }
78
+ expect(rectsOverlap(a, b)).toBe(false)
79
+ })
80
+
81
+ it('returns true when one rect contains another', () => {
82
+ const a = { x: 0, y: 0, width: 200, height: 200 }
83
+ const b = { x: 50, y: 50, width: 50, height: 50 }
84
+ expect(rectsOverlap(a, b)).toBe(true)
85
+ })
86
+
87
+ it('returns true for rects that share an edge partially', () => {
88
+ const a = { x: 0, y: 0, width: 100, height: 100 }
89
+ const b = { x: 50, y: 0, width: 100, height: 100 }
90
+ expect(rectsOverlap(a, b)).toBe(true)
91
+ })
92
+ })
93
+
94
+ describe('findCollision', () => {
95
+ const widgets = [
96
+ { id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 270, height: 170 } },
97
+ { id: 'w2', type: 'sticky-note', position: { x: 300, y: 0 }, props: { width: 270, height: 170 } },
98
+ ]
99
+
100
+ it('returns colliding widget', () => {
101
+ const rect = { x: 100, y: 50, width: 270, height: 170 }
102
+ const collision = findCollision(rect, widgets)
103
+ expect(collision?.id).toBe('w1')
104
+ })
105
+
106
+ it('returns null when no collision', () => {
107
+ const rect = { x: 0, y: 200, width: 270, height: 170 }
108
+ const collision = findCollision(rect, widgets)
109
+ expect(collision).toBeNull()
110
+ })
111
+
112
+ it('excludes specified widget ID', () => {
113
+ // This rect only overlaps w1, not w2 — so excluding w1 means no collision
114
+ const rect = { x: 50, y: 50, width: 200, height: 100 }
115
+ const collision = findCollision(rect, widgets, 'w1')
116
+ expect(collision).toBeNull()
117
+ })
118
+ })
119
+
120
+ describe('snapToGrid', () => {
121
+ it('snaps to nearest grid line', () => {
122
+ expect(snapToGrid(25, 24)).toBe(24)
123
+ expect(snapToGrid(36, 24)).toBe(48)
124
+ expect(snapToGrid(12, 24)).toBe(24) // 12 rounds to 24 (0.5 → 1)
125
+ expect(snapToGrid(11, 24)).toBe(0) // 11 rounds to 0
126
+ expect(snapToGrid(48, 24)).toBe(48)
127
+ })
128
+
129
+ it('works with different grid sizes', () => {
130
+ expect(snapToGrid(15, 10)).toBe(20)
131
+ expect(snapToGrid(14, 10)).toBe(10)
132
+ })
133
+ })
134
+
135
+ describe('findFreePosition', () => {
136
+ it('returns original position when no collision', () => {
137
+ const widgets = []
138
+ const result = findFreePosition({
139
+ x: 100,
140
+ y: 100,
141
+ width: 270,
142
+ height: 170,
143
+ widgets,
144
+ gridSize: 24,
145
+ })
146
+ expect(result).toEqual({ x: 96, y: 96, adjusted: false })
147
+ })
148
+
149
+ it('moves right to avoid collision', () => {
150
+ const widgets = [
151
+ { id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 270, height: 170 } },
152
+ ]
153
+ const result = findFreePosition({
154
+ x: 0,
155
+ y: 0,
156
+ width: 270,
157
+ height: 170,
158
+ widgets,
159
+ gridSize: 24,
160
+ })
161
+ // Should move right: 270 (width) + 24 (gap) = 294, snapped to 288 or 312
162
+ expect(result.x).toBeGreaterThanOrEqual(288)
163
+ expect(result.y).toBe(0)
164
+ expect(result.adjusted).toBe(true)
165
+ })
166
+
167
+ it('moves down when rightward movement exhausted', () => {
168
+ // Create a row of widgets that blocks rightward movement
169
+ const widgets = []
170
+ for (let i = 0; i < 10; i++) {
171
+ widgets.push({
172
+ id: `w${i}`,
173
+ type: 'sticky-note',
174
+ position: { x: i * 300, y: 0 },
175
+ props: { width: 270, height: 170 },
176
+ })
177
+ }
178
+ const result = findFreePosition({
179
+ x: 0,
180
+ y: 0,
181
+ width: 270,
182
+ height: 170,
183
+ widgets,
184
+ gridSize: 24,
185
+ maxIterations: 5, // Force early switch to vertical
186
+ })
187
+ // After exhausting horizontal, should move down
188
+ expect(result.adjusted).toBe(true)
189
+ })
190
+
191
+ it('respects excludeId parameter', () => {
192
+ const widgets = [
193
+ { id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 270, height: 170 } },
194
+ ]
195
+ const result = findFreePosition({
196
+ x: 0,
197
+ y: 0,
198
+ width: 270,
199
+ height: 170,
200
+ widgets,
201
+ excludeId: 'w1',
202
+ gridSize: 24,
203
+ })
204
+ // Should stay at original position since w1 is excluded
205
+ expect(result).toEqual({ x: 0, y: 0, adjusted: false })
206
+ })
207
+
208
+ it('handles chain of collisions', () => {
209
+ // Two widgets next to each other
210
+ const widgets = [
211
+ { id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 270, height: 170 } },
212
+ { id: 'w2', type: 'sticky-note', position: { x: 294, y: 0 }, props: { width: 270, height: 170 } },
213
+ ]
214
+ const result = findFreePosition({
215
+ x: 0,
216
+ y: 0,
217
+ width: 270,
218
+ height: 170,
219
+ widgets,
220
+ gridSize: 24,
221
+ })
222
+ // Should move past both widgets
223
+ expect(result.x).toBeGreaterThan(294 + 270)
224
+ expect(result.adjusted).toBe(true)
225
+ })
226
+ })
227
+
228
+ describe('resolvePosition', () => {
229
+ it('uses widget type defaults', () => {
230
+ const widgets = [
231
+ { id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: {} },
232
+ ]
233
+ const result = resolvePosition({
234
+ x: 0,
235
+ y: 0,
236
+ type: 'sticky-note',
237
+ widgets,
238
+ gridSize: 24,
239
+ })
240
+ // Should detect collision and move
241
+ expect(result.adjusted).toBe(true)
242
+ expect(result.x).toBeGreaterThan(0)
243
+ })
244
+
245
+ it('uses custom props dimensions', () => {
246
+ const widgets = [
247
+ { id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 100, height: 100 } },
248
+ ]
249
+ const result = resolvePosition({
250
+ x: 0,
251
+ y: 0,
252
+ type: 'sticky-note',
253
+ props: { width: 50, height: 50 },
254
+ widgets,
255
+ gridSize: 24,
256
+ })
257
+ // Should collide with w1 at (0,0) even though new widget is smaller
258
+ expect(result.adjusted).toBe(true)
259
+ })
260
+
261
+ it('returns snapped position when no collision', () => {
262
+ const result = resolvePosition({
263
+ x: 500,
264
+ y: 500,
265
+ type: 'markdown',
266
+ widgets: [],
267
+ gridSize: 24,
268
+ })
269
+ expect(result).toEqual({ x: 504, y: 504, adjusted: false })
270
+ })
271
+ })
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { toCanvasId } from './identity.js'
3
+
4
+ describe('toCanvasId (path-based canvas identity)', () => {
5
+ it('returns basename for flat canvas in src/canvas/', () => {
6
+ expect(toCanvasId('src/canvas/design-overview.canvas.jsonl')).toBe('design-overview')
7
+ })
8
+
9
+ it('returns path-based ID for canvas inside a .folder/', () => {
10
+ expect(toCanvasId('src/canvas/research.folder/interviews.canvas.jsonl')).toBe('research/interviews')
11
+ })
12
+
13
+ it('strips .folder suffix from path', () => {
14
+ expect(toCanvasId('src/canvas/ux.folder/onboarding.canvas.jsonl')).toBe('ux/onboarding')
15
+ })
16
+
17
+ it('handles nested subdirectories inside a .folder/', () => {
18
+ expect(toCanvasId('src/canvas/team.folder/sub/deep.canvas.jsonl')).toBe('team/sub/deep')
19
+ })
20
+
21
+ it('returns proto:-prefixed ID for prototype-scoped canvas', () => {
22
+ expect(toCanvasId('src/prototypes/Dashboard/overview.canvas.jsonl')).toBe('proto:Dashboard/overview')
23
+ })
24
+
25
+ it('handles prototype inside a .folder/', () => {
26
+ expect(toCanvasId('src/prototypes/main.folder/Dashboard/overview.canvas.jsonl')).toBe('proto:main/Dashboard/overview')
27
+ })
28
+
29
+ it('duplicate basenames in different folders get distinct IDs', () => {
30
+ const id1 = toCanvasId('src/canvas/alpha.folder/overview.canvas.jsonl')
31
+ const id2 = toCanvasId('src/canvas/beta.folder/overview.canvas.jsonl')
32
+ expect(id1).toBe('alpha/overview')
33
+ expect(id2).toBe('beta/overview')
34
+ expect(id1).not.toBe(id2)
35
+ })
36
+
37
+ it('normalizes backslashes to forward slashes', () => {
38
+ expect(toCanvasId('src\\canvas\\research.folder\\interviews.canvas.jsonl')).toBe('research/interviews')
39
+ })
40
+ })
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Canvas Identity — path-based canvas ID utilities.
3
+ *
4
+ * Canonical canvas IDs are normalized relative paths derived from the file
5
+ * location. They uniquely identify a canvas even when two canvases share the
6
+ * same basename in different folders.
7
+ *
8
+ * Format examples:
9
+ * src/canvas/overview.canvas.jsonl → "overview"
10
+ * src/canvas/design.folder/overview.canvas.jsonl → "design/overview"
11
+ * src/canvas/design.folder/sub.folder/a.canvas.jsonl → "design/sub/a"
12
+ * src/prototypes/Main/board.canvas.jsonl → "proto:Main/board"
13
+ *
14
+ * Canvases under src/canvas/ get plain IDs; those under src/prototypes/ get
15
+ * a "proto:" prefix so the two namespaces never collide.
16
+ *
17
+ * Legacy IDs (bare names like "overview") remain supported as aliases when
18
+ * they resolve to exactly one canvas.
19
+ *
20
+ * @module canvas/identity
21
+ */
22
+
23
+ const CANVAS_SUFFIX_RE = /\.canvas\.jsonl$/
24
+ const FOLDER_SUFFIX_RE = /([^/]+)\.folder\//g
25
+
26
+ /**
27
+ * Convert a relative file path to a canonical canvas ID.
28
+ *
29
+ * @param {string} relPath — path relative to project root
30
+ * @returns {string} canonical canvas ID
31
+ */
32
+ export function toCanvasId(relPath) {
33
+ let p = relPath.replace(/\\/g, '/')
34
+
35
+ let prefix = ''
36
+ if (p.startsWith('src/canvas/')) {
37
+ p = p.slice('src/canvas/'.length)
38
+ } else if (p.startsWith('src/prototypes/')) {
39
+ p = p.slice('src/prototypes/'.length)
40
+ prefix = 'proto:'
41
+ }
42
+
43
+ // Strip .canvas.jsonl suffix
44
+ p = p.replace(CANVAS_SUFFIX_RE, '')
45
+
46
+ // Normalize .folder segments: "design.folder/" → "design/"
47
+ p = p.replace(FOLDER_SUFFIX_RE, '$1/')
48
+
49
+ // Clean up double slashes and trailing slash
50
+ p = p.replace(/\/+/g, '/').replace(/\/$/, '')
51
+
52
+ return prefix + (p || 'unknown')
53
+ }
54
+
55
+ /**
56
+ * Parse a canvas ID into its segments.
57
+ *
58
+ * @param {string} id — canonical canvas ID
59
+ * @returns {{ namespace: 'canvas' | 'prototype', segments: string[], name: string }}
60
+ */
61
+ export function parseCanvasId(id) {
62
+ let namespace = 'canvas'
63
+ let raw = id
64
+ if (raw.startsWith('proto:')) {
65
+ namespace = 'prototype'
66
+ raw = raw.slice('proto:'.length)
67
+ }
68
+ const segments = raw.split('/').filter(Boolean)
69
+ const name = segments[segments.length - 1] || raw
70
+ return { namespace, segments, name }
71
+ }
72
+
73
+ /**
74
+ * Extract the basename from a canvas ID (the last segment).
75
+ * Useful for backward-compatible lookups.
76
+ *
77
+ * @param {string} id
78
+ * @returns {string}
79
+ */
80
+ export function canvasIdBasename(id) {
81
+ return parseCanvasId(id).name
82
+ }
83
+
84
+ /**
85
+ * Check whether an ID is a legacy bare name (no path segments, no namespace prefix).
86
+ *
87
+ * @param {string} id
88
+ * @returns {boolean}
89
+ */
90
+ export function isLegacyCanvasId(id) {
91
+ return !id.includes('/') && !id.startsWith('proto:')
92
+ }
93
+
94
+ /**
95
+ * Known consumers of canvas identity that must be updated when migrating
96
+ * from basename-only to path-based IDs. This inventory is used by downstream
97
+ * slices to track migration completeness.
98
+ */
99
+ export const CANVAS_IDENTITY_CONSUMERS = [
100
+ 'packages/react/src/vite/data-plugin.js — index & HMR payloads',
101
+ 'packages/core/src/canvas/server.js — findCanvasPath() & all API routes',
102
+ 'packages/react/src/context.jsx — canvasRouteMap',
103
+ 'packages/react/src/canvas/CanvasPage.jsx — cross-canvas copy/paste (canvasName/widgetId)',
104
+ 'packages/react/src/canvas/CanvasPage.jsx — viewport persistence keys',
105
+ 'packages/core/src/viewfinder.js — canvas listing',
106
+ 'packages/core/src/rename-watcher/watcher.js — canvas rename route inference',
107
+ ]