@dfosco/storyboard-core 4.0.0-beta.7 → 4.0.0-beta.8

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": "4.0.0-beta.7",
3
+ "version": "4.0.0-beta.8",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -48,7 +48,8 @@
48
48
  "./ui/viewfinder": "./src/ui/viewfinder.ts",
49
49
  "./svelte-plugin-ui/styles/base.css": "./dist/tailwind.css",
50
50
  "./styles/tailwind.css": "./dist/tailwind.css",
51
- "./worktree/port": "./src/worktree/port.js"
51
+ "./worktree/port": "./src/worktree/port.js",
52
+ "./canvas/collision": "./src/canvas/collision.js"
52
53
  },
53
54
  "dependencies": {
54
55
  "@clack/prompts": "^1.2.0",
@@ -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
+ })
@@ -15,18 +15,12 @@
15
15
  */
16
16
 
17
17
  import * as p from '@clack/prompts'
18
- import { detectWorktreeName, getPort } from '../worktree/port.js'
18
+ import { getServerUrl } from './serverUrl.js'
19
19
 
20
20
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
21
21
  const bold = (s) => `\x1b[1m${s}\x1b[0m`
22
22
  const cyan = (s) => `\x1b[36m${s}\x1b[0m`
23
23
 
24
- function getServerUrl() {
25
- const name = detectWorktreeName()
26
- const port = getPort(name)
27
- return `http://localhost:${port}`
28
- }
29
-
30
24
  async function checkServer() {
31
25
  try {
32
26
  await fetch(getServerUrl(), { signal: AbortSignal.timeout(2000) })
package/src/cli/create.js CHANGED
@@ -15,9 +15,9 @@
15
15
  */
16
16
 
17
17
  import * as p from '@clack/prompts'
18
- import { detectWorktreeName, getPort } from '../worktree/port.js'
19
18
  import { parseFlags, hasFlags, formatFlagHelp } from './flags.js'
20
19
  import { prototypeSchema, canvasSchema, flowSchema, pageSchema } from './schemas.js'
20
+ import { getServerUrl } from './serverUrl.js'
21
21
 
22
22
  const dim = (s) => `\x1b[2m${s}\x1b[0m`
23
23
  const green = (s) => `\x1b[32m${s}\x1b[0m`
@@ -37,12 +37,6 @@ function showHelp(type, schema) {
37
37
  process.exit(0)
38
38
  }
39
39
 
40
- function getServerUrl() {
41
- const name = detectWorktreeName()
42
- const port = getPort(name)
43
- return `http://localhost:${port}`
44
- }
45
-
46
40
  async function serverGet(path) {
47
41
  const base = getServerUrl()
48
42
  const res = await fetch(`${base}${path}`)
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Resolve the dev server URL for the current worktree.
3
+ *
4
+ * Checks the Caddy admin API first to find the actual port mapped to
5
+ * this branch's route, since ports.json can drift from the running
6
+ * dev server. Falls back to ports.json if Caddy isn't reachable.
7
+ */
8
+
9
+ import { detectWorktreeName, getPort } from '../worktree/port.js'
10
+ import { readDevDomain } from './proxy.js'
11
+ import { execSync } from 'child_process'
12
+
13
+ export function getServerUrl() {
14
+ const name = detectWorktreeName()
15
+
16
+ // Try Caddy admin API for the real port
17
+ try {
18
+ const raw = execSync(
19
+ 'curl -sf http://localhost:2019/config/apps/http/servers/srv0/routes',
20
+ { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }
21
+ )
22
+ const routes = JSON.parse(raw)
23
+ const domain = readDevDomain()
24
+
25
+ for (const route of routes) {
26
+ const hosts = route.match?.[0]?.host || []
27
+ if (!hosts.includes(domain)) continue
28
+ const subroutes = route.handle?.[0]?.routes || []
29
+
30
+ if (name === 'main') {
31
+ // Main is the fallback route (no match path)
32
+ const fallback = subroutes.find(r => !r.match)
33
+ if (fallback) {
34
+ const dial = fallback.handle?.[0]?.upstreams?.[0]?.dial
35
+ if (dial) return `http://${dial}`
36
+ }
37
+ } else {
38
+ // Branch route matches /branch--<name>/*
39
+ const branchRoute = subroutes.find(r => {
40
+ const paths = r.match?.[0]?.path || []
41
+ return paths.some(p => p === `/branch--${name}/*`)
42
+ })
43
+ if (branchRoute) {
44
+ const dial = branchRoute.handle?.[0]?.upstreams?.[0]?.dial
45
+ if (dial) return `http://${dial}`
46
+ }
47
+ }
48
+ }
49
+ } catch {
50
+ // Caddy not running or not reachable — fall through
51
+ }
52
+
53
+ // Fallback to ports.json
54
+ const port = getPort(name)
55
+ return `http://localhost:${port}`
56
+ }