@cat-factory/app 0.287.0 → 0.288.1

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/README.md CHANGED
@@ -342,6 +342,47 @@ projects through kernel's `applyMountLayout`. The resize path is where people hi
342
342
  because a `size`-only edit is the one frame patch with no other visible effect: the SPA upserts the
343
343
  authoritative block the mutation returned and the frame jumps to coordinates no board shows it at.
344
344
 
345
+ **No two top-level board nodes may overlap**, and that is a standing invariant rather than a rule
346
+ each write remembers. `useFrameOverlapGuard` (mounted by `BoardCanvas`) watches the rendered
347
+ geometry of every frame and epic and, whenever two come to overlap, bounces them apart through
348
+ `framePlacement.resolveFrameOverlaps`. Placement alone was not enough: `findFreeFramePosition`
349
+ refuses to CREATE an overlap, but three later events make one anyway, and only one of them is a
350
+ drag. A border drag grows a frame into its neighbour, and a frame GROWS ON ITS OWN when its first
351
+ task arrives, because an empty service renders the "add the first task" panel and reserves a much
352
+ smaller footprint than one rendering lanes. Watching the geometry covers all three, and covers the
353
+ next write nobody has thought of yet.
354
+
355
+ Three things about it are load-bearing:
356
+
357
+ - **It runs in the SPA because only the SPA can measure a frame.** The footprint is derived from
358
+ the lane geometry the browser renders at (`containerSize`); the backend stores a position and at
359
+ most a size override, so it cannot tell whether two frames overlap.
360
+ - **Correcting the VIEW and WRITING the correction are separate, and only a local gesture
361
+ authorises the write.** Every client always draws the board clear, which needs no coordination
362
+ because `resolveFrameOverlaps` is pure: two browsers holding one board draw it identically
363
+ whatever order their events arrived in, and a read-only viewer gets the corrected view for free.
364
+ Persisting is the narrower act, and a drag or border resize is the only cause with an
365
+ unambiguous single author, so that client settles the board when the gesture ends and writes what
366
+ it displaced. A frame that grew ON ITS OWN has no author, so its correction is drawn everywhere
367
+ and written by nobody: better than every open session racing to persist the same value, and
368
+ better behaved besides, since a projected neighbour comes back off the server's own geometry when
369
+ the frame shrinks again while a persisted one would stay pushed.
370
+ - **The settlement ORDER is the whole policy** (`bySettlementOrder`), and it takes at most ONE
371
+ anchor: the node the local user is placing, held still while its neighbours move aside. A LIST of
372
+ anchors would carry an order of its own, and every order available to build one from is
373
+ per-client (the sequence a client's live events arrived in, or a history only the client that
374
+ watched the change has), so two clients would resolve one overlap to different positions and
375
+ write over each other. Everything else settles in READING ORDER, which is POSITIONAL: the node
376
+ nearest the top-left keeps its place. Note that this is not "the newcomer yields to the frames
377
+ already there": a block carries no shared creation stamp, so arrival is knowable only from a
378
+ client's own session, which is the per-client input this rules out.
379
+
380
+ The guard stands down for the whole of a gesture. A drag previews a position on every pointer move,
381
+ and bouncing neighbours off those in-flight positions displaces frames the user is merely passing
382
+ OVER: each pass reads the neighbour where the previous pass pushed it, so the displacement
383
+ accumulates instead of springing back. A frame drawn over its neighbours while the pointer holds it
384
+ is what direct manipulation looks like; the board settles on release.
385
+
345
386
  **Dragging a card now only reparents** (`positioned: false` in `useBlockDrag`): between services,
346
387
  and into or out of a module via a module group header's drop zone. Which LANE a card is in is not
347
388
  something a drop can decide — the lane is derived from state, so dropping a not-started card on
@@ -12,6 +12,7 @@ import { useTaskExpansion } from '~/composables/useTaskExpansion'
12
12
  import { useBlockDrag } from '~/composables/useBlockDrag'
13
13
  import { useFrameStacking } from '~/composables/useFrameStacking'
14
14
  import { useFramePlacement } from '~/composables/useFramePlacement'
15
+ import { useFrameOverlapGuard } from '~/composables/useFrameOverlapGuard'
15
16
  import { useViewport } from '~/composables/useViewport'
16
17
  import { boardPanMode } from '~/utils/boardPanMode'
17
18
  import { createBoardNodeProjection } from './BoardCanvas.logic'
@@ -32,6 +33,11 @@ const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(
32
33
  const { draggingId } = useBlockDrag()
33
34
  const { hoveredFrameId } = useFrameStacking()
34
35
  const { freeFramePosition, focusFrame } = useFramePlacement()
36
+ // The board's "no two top-level nodes overlap" invariant: whenever a frame comes to overlap a
37
+ // neighbour (dragged onto it, grown into it by a border drag, or grown by its first task), the two
38
+ // are bounced apart. Every client draws that correction; only the one whose own gesture caused the
39
+ // overlap writes it back. See useFrameOverlapGuard.
40
+ useFrameOverlapGuard()
35
41
  // Touch drives the canvas gestures: a touch-capable surface needs one-finger pan.
36
42
  // We gate on `hasTouch` (any-pointer: coarse), not `isTouch` (the *primary* pointer),
37
43
  // so a touchscreen laptop / 2-in-1 — whose primary pointer is the trackpad — still
@@ -62,10 +68,11 @@ useTaskExpansion(boardEl, boardActivity)
62
68
  // into a dead zone. We therefore make every frame non-draggable (the pane pans straight
63
69
  // through it) and move it via its header handle instead.
64
70
  //
65
- // Frames are rendered exactly at their stored position and may overlap freely
66
- // moving one never shifts another. The frame being dragged is lifted to the top,
67
- // then the hovered frame (the un-obscured one under the pointer), so overlapping
68
- // services can always be reached and reordered. See useFrameStacking.
71
+ // Frames are rendered at the position the overlap guard keeps clear of every other top-level
72
+ // node, so a frame is never hidden behind a neighbour once it has settled. Stacking still decides
73
+ // what is on top of what while a drag is in flight (the guard stands down for the whole gesture,
74
+ // so a dragged frame does cross its neighbours) and for the frame chrome that extends past the
75
+ // box: the dragged frame is lifted to the top, then the hovered one. See useFrameStacking.
69
76
  //
70
77
  // `elevate-nodes-on-select` is turned OFF on <VueFlow> for this to work: Vue Flow's
71
78
  // default adds +1000 to a selected node's z-index, so a frame stayed pinned on top
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { effectScope, nextTick, ref, type EffectScope } from 'vue'
3
+ import type { Block } from '~/types/domain'
4
+ import { useBoardStore } from '~/stores/board'
5
+ import { useBlockDrag } from '~/composables/useBlockDrag'
6
+ import { useFrameOverlapGuard } from '~/composables/useFrameOverlapGuard'
7
+ import { EMPTY_FRAME_SIZE, FRAME_GAP, framesCollide } from '~/utils/framePlacement'
8
+
9
+ /**
10
+ * The guard is the wiring around `resolveFrameOverlaps` (whose geometry is pinned in
11
+ * `framePlacement.spec.ts`). What is worth testing here is not the bouncing but the two policies
12
+ * layered over it, because both are invisible in the geometry and both were wrong once:
13
+ *
14
+ * - WHEN it corrects: never against the geometry of a gesture still under the pointer, because
15
+ * those corrections displace frames the user is only dragging past.
16
+ * - WHO writes the correction back: only the client whose own gesture caused it, and only through
17
+ * `moveBlock`, whose rollback needs to find the position the server actually holds.
18
+ */
19
+ const canWriteBoard = ref(true)
20
+
21
+ function frame(id: string, x: number, y: number): Block {
22
+ return {
23
+ id,
24
+ title: id,
25
+ type: 'service',
26
+ description: '',
27
+ position: { x, y },
28
+ status: 'planned',
29
+ progress: 0,
30
+ dependsOn: [],
31
+ executionId: null,
32
+ level: 'frame',
33
+ parentId: null,
34
+ }
35
+ }
36
+
37
+ interface Write {
38
+ id: string
39
+ to: { x: number; y: number }
40
+ /** What the STORE still held when the write was asked for: the value a rollback restores. */
41
+ storedAtCall: { x: number; y: number }
42
+ }
43
+
44
+ describe('useFrameOverlapGuard', () => {
45
+ let scope: EffectScope
46
+ let board: ReturnType<typeof useBoardStore>
47
+ let writes: Write[]
48
+
49
+ beforeEach(() => {
50
+ canWriteBoard.value = true
51
+ vi.stubGlobal('useUiStore', () => ({ zoom: 1 }))
52
+ vi.stubGlobal('useWorkspaceAccess', () => ({ canWriteBoard }))
53
+ board = useBoardStore()
54
+ writes = []
55
+ // Stands in for the real `moveBlock`, which records nothing but does apply its position
56
+ // optimistically before the round-trip, which is the behaviour the guard leans on to correct the view
57
+ // and write it in one act. The snapshot is taken BEFORE that, so the rollback assertion below
58
+ // reads the position the real one would restore to.
59
+ board.moveBlock = ((id: string, to: { x: number; y: number }) => {
60
+ writes.push({ id, to, storedAtCall: { ...board.getBlock(id)!.position } })
61
+ board.previewMove(id, to)
62
+ return Promise.resolve()
63
+ }) as unknown as typeof board.moveBlock
64
+ // The composables under test reach the store through Nuxt's auto-import, which plain Vitest
65
+ // does not provide; hand them the same instance the assertions read.
66
+ vi.stubGlobal('useBoardStore', () => board)
67
+ scope = effectScope()
68
+ })
69
+
70
+ afterEach(() => scope.stop())
71
+
72
+ /** Start the guard over the frames the board currently holds and let its first pass run. */
73
+ async function guard() {
74
+ scope.run(() => useFrameOverlapGuard())
75
+ await nextTick()
76
+ }
77
+
78
+ const rectOf = (id: string) => ({ ...board.getBlock(id)!.position, ...EMPTY_FRAME_SIZE })
79
+ const written = () => writes.map((w) => w.id)
80
+
81
+ it('draws an overlapping board clear without writing anything back', async () => {
82
+ board.hydrate([frame('a', 0, 0), frame('b', 100, 0)])
83
+ await guard()
84
+
85
+ expect(framesCollide(rectOf('a'), rectOf('b'), FRAME_GAP)).toBe(false)
86
+ // Nobody authored this overlap locally, so nobody writes it: every client draws the same
87
+ // correction from the same rects, and one write per open session would be pure amplification.
88
+ expect(written()).toEqual([])
89
+ })
90
+
91
+ it('leaves a board whose frames already clear each other alone', async () => {
92
+ board.hydrate([frame('a', 0, 0), frame('b', EMPTY_FRAME_SIZE.w + FRAME_GAP, 0)])
93
+ await guard()
94
+
95
+ expect(board.getBlock('b')!.position).toEqual({ x: EMPTY_FRAME_SIZE.w + FRAME_GAP, y: 0 })
96
+ expect(written()).toEqual([])
97
+ })
98
+
99
+ it('separates a frame that grew into its neighbour, keeping the grown frame in place', async () => {
100
+ // Two empty services a gap apart, then the first one gains a task: it stops rendering the
101
+ // "add the first task" panel and grows to the lane footprint, over its neighbour.
102
+ board.hydrate([frame('a', 0, 0), frame('b', EMPTY_FRAME_SIZE.w + FRAME_GAP, 0)])
103
+ await guard()
104
+
105
+ board.upsert({ ...frame('t1', 0, 0), level: 'task', parentId: 'a' })
106
+ await nextTick()
107
+
108
+ expect(board.getBlock('a')!.position).toEqual({ x: 0, y: 0 })
109
+ expect(
110
+ framesCollide(
111
+ { ...board.getBlock('a')!.position, ...board.containerSize('a') },
112
+ rectOf('b'),
113
+ FRAME_GAP,
114
+ ),
115
+ ).toBe(false)
116
+ expect(written()).toEqual([])
117
+ })
118
+
119
+ it('leaves the frames a drag passes over alone, and settles once the pointer is released', async () => {
120
+ board.hydrate([frame('a', 0, 0), frame('b', 600, 0), frame('c', 1200, 0)])
121
+ await guard()
122
+
123
+ // Stand in for a header-handle drag: the dragged frame's position is previewed on every
124
+ // pointer move, and `draggingId` marks it as the one the guard must not move.
125
+ const { draggingId } = useBlockDrag()
126
+ draggingId.value = 'a'
127
+ // Dragged ACROSS `b` on the way to its resting place beside `c`. Bouncing `b` here would
128
+ // persist a rearrangement of a service the user never touched.
129
+ board.previewMove('a', { x: 600, y: 0 })
130
+ await nextTick()
131
+ expect(board.getBlock('b')!.position).toEqual({ x: 600, y: 0 })
132
+ expect(written()).toEqual([])
133
+
134
+ board.previewMove('a', { x: 1180, y: 0 })
135
+ await nextTick()
136
+ expect(board.getBlock('b')!.position).toEqual({ x: 600, y: 0 })
137
+
138
+ // Release: the drop commits `a` itself, and the guard settles the board around it.
139
+ draggingId.value = null
140
+ await nextTick()
141
+
142
+ expect(board.getBlock('a')!.position).toEqual({ x: 1180, y: 0 })
143
+ expect(written()).toEqual(['c'])
144
+ expect(framesCollide(rectOf('a'), rectOf('c'), FRAME_GAP)).toBe(false)
145
+ })
146
+
147
+ it('writes through moveBlock alone, so a refused correction can still roll back', async () => {
148
+ board.hydrate([frame('a', 0, 0), frame('b', 600, 0)])
149
+ await guard()
150
+
151
+ const { draggingId } = useBlockDrag()
152
+ draggingId.value = 'a'
153
+ board.previewMove('a', { x: 580, y: 0 })
154
+ await nextTick()
155
+ draggingId.value = null
156
+ await nextTick()
157
+
158
+ // `moveBlock` snapshots the position it finds and restores it if the write is refused. A
159
+ // `previewMove` ahead of it would hand it the CORRECTION as the value to roll back to,
160
+ // leaving the board showing a position the server never stored.
161
+ expect(writes).toHaveLength(1)
162
+ expect(writes[0]!.storedAtCall).toEqual({ x: 600, y: 0 })
163
+ expect(writes[0]!.to).not.toEqual(writes[0]!.storedAtCall)
164
+ })
165
+
166
+ it('writes nothing for a drag that was cancelled rather than dropped', async () => {
167
+ board.hydrate([frame('a', 0, 0), frame('b', 600, 0)])
168
+ await guard()
169
+
170
+ const { draggingId } = useBlockDrag()
171
+ draggingId.value = 'a'
172
+ board.previewMove('a', { x: 580, y: 0 })
173
+ await nextTick()
174
+ // A `pointercancel` (or the dragged component unmounting) puts the frame back and commits
175
+ // nothing. The board it leaves behind is already clear, so settling finds no work: the
176
+ // neighbour displacement a cancelled gesture caused must not outlive it.
177
+ board.previewMove('a', { x: 0, y: 0 })
178
+ draggingId.value = null
179
+ await nextTick()
180
+
181
+ expect(board.getBlock('b')!.position).toEqual({ x: 600, y: 0 })
182
+ expect(written()).toEqual([])
183
+ })
184
+
185
+ it('corrects the view for a read-only viewer but writes nothing', async () => {
186
+ canWriteBoard.value = false
187
+ board.hydrate([frame('a', 0, 0), frame('b', 100, 0)])
188
+ await guard()
189
+
190
+ expect(framesCollide(rectOf('a'), rectOf('b'), FRAME_GAP)).toBe(false)
191
+ expect(written()).toEqual([])
192
+ })
193
+ })
@@ -0,0 +1,145 @@
1
+ import { computed, watch } from 'vue'
2
+ import { useBlockDrag } from '~/composables/useBlockDrag'
3
+ import { useFrameResize } from '~/composables/useFrameResize'
4
+ import { EPIC_NODE_SIZE, resolveFrameOverlaps, type PlacedRect } from '~/utils/framePlacement'
5
+
6
+ /**
7
+ * The board's standing "no two top-level nodes overlap" invariant.
8
+ *
9
+ * Frames used to be rendered exactly where they were stored and allowed to overlap freely, with
10
+ * hover-driven stacking as the way to reach the one underneath. That only ever worked for the
11
+ * frame you were pointing at: everything under a neighbour was invisible, and nothing on the
12
+ * board said it was there. Placement already refused to CREATE an overlap
13
+ * (`findFreeFramePosition`), but three later events could still make one (dragging a frame onto
14
+ * a neighbour, dragging a border out into one, and a frame growing when its first task arrives,
15
+ * since an empty service reserves a much smaller footprint than one rendering lanes), so the rule
16
+ * was true of a board nobody had touched and of no other.
17
+ *
18
+ * This closes it as an invariant rather than as a check on each of those writes: it watches the
19
+ * rendered geometry of every top-level board node and, whenever two come to overlap, bounces them
20
+ * apart (`resolveFrameOverlaps`). Being cause-agnostic is the point: a future write that moves or
21
+ * grows a frame is covered without knowing this exists.
22
+ *
23
+ * ## Correcting the view and writing the correction are two different jobs
24
+ *
25
+ * They have opposite requirements, and the guard keeps them apart, because conflating them is
26
+ * what makes a layout fixer fight itself across clients:
27
+ *
28
+ * - **Every client corrects what it DRAWS, always.** `resolveFrameOverlaps` is a pure function of
29
+ * the rects, so this needs no coordination: two browsers holding one board draw it the same way
30
+ * whatever order their events arrived in. A read-only viewer gets the corrected view for free,
31
+ * which is the point (the invariant is about what is VISIBLE).
32
+ * - **Exactly one client may WRITE a correction, and only a local GESTURE elects it.** A drag or
33
+ * a border resize has an unambiguous single author: the browser the pointer is in. That client
34
+ * settles the board when the gesture ends and persists the neighbours it displaced.
35
+ *
36
+ * So a correction is persisted only where the local user caused it. That is a deliberate limit,
37
+ * not an oversight, and the third cause above is what it costs: a frame that grows on its own has
38
+ * NO author (the task may have arrived from a pipeline, with no browser involved), so its
39
+ * correction is DRAWN everywhere and WRITTEN by nobody. The stored position stays the one someone
40
+ * chose, and the board derives a clear presentation from it, this session and the next.
41
+ *
42
+ * The alternative is worse in both directions. Having every writer-capable client persist what it
43
+ * resolved costs a write and a board-wide event per open session for one overlap, and the first of
44
+ * those writes moves the geometry the others are still resolving. And a persisted correction is
45
+ * not even the better answer where it lands: a frame that grew for its first task shrinks again
46
+ * when that task is deleted, and a neighbour bounced by a WRITE stays where it was pushed, while a
47
+ * neighbour bounced by a projection is recomputed off the server's own geometry on the next
48
+ * hydrate and simply comes back.
49
+ *
50
+ * ## Why it runs in the SPA
51
+ *
52
+ * A frame's footprint is derived from the lane geometry the browser renders it at
53
+ * (`containerSize`), which the server cannot compute: it stores a position and, at most, a size
54
+ * override. So the only layer that can tell whether two frames overlap is the one drawing them.
55
+ *
56
+ * Epics take part as well as frames. They are top-level nodes on the same canvas (placement
57
+ * already reserves space around them), so an epic card parked over a frame hides exactly as much
58
+ * of it as another frame would.
59
+ */
60
+ export function useFrameOverlapGuard() {
61
+ const board = useBoardStore()
62
+ const access = useWorkspaceAccess()
63
+ const { draggingId } = useBlockDrag()
64
+ const { resizingId } = useFrameResize()
65
+
66
+ /** Every top-level node's rendered rect, in the flow-space coordinates blocks are stored in. */
67
+ const nodeRects = computed<PlacedRect[]>(() => [
68
+ ...board.frames.map((f) => {
69
+ const size = board.containerSize(f.id)
70
+ return { id: f.id, x: f.position.x, y: f.position.y, w: size.w, h: size.h }
71
+ }),
72
+ ...board.epics.map((e) => ({
73
+ id: e.id,
74
+ x: e.position.x,
75
+ y: e.position.y,
76
+ ...EPIC_NODE_SIZE,
77
+ })),
78
+ ])
79
+
80
+ /** The node the local pointer is placing right now, if any. */
81
+ const gestureId = computed(() => draggingId.value ?? resizingId.value)
82
+
83
+ /**
84
+ * Bounce the overlapping nodes apart, holding `anchorId` still.
85
+ *
86
+ * `persist` picks the channel, and the two are mutually exclusive on purpose.
87
+ * {@link useBoardStore.moveBlock} ALREADY applies its position optimistically and restores the
88
+ * previous one if the write is refused, so it is both the local correction and the write. A
89
+ * `previewMove` ahead of it would break exactly that: `moveBlock` snapshots the position it
90
+ * finds, so a rollback would restore the correction rather than undo it, leaving the SPA
91
+ * showing a position the server never stored.
92
+ */
93
+ function bounce(anchorId: string | null, persist: boolean) {
94
+ for (const [id, position] of resolveFrameOverlaps(nodeRects.value, { anchorId })) {
95
+ if (persist) void board.moveBlock(id, position)
96
+ else board.previewMove(id, position)
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Draw the board clear, on every client, writing nothing.
102
+ *
103
+ * Held for the duration of a gesture. A drag previews a new position on every pointer move, and
104
+ * bouncing neighbours off those in-flight positions displaces frames the user is only passing
105
+ * OVER: each pass reads the neighbour at the spot the previous pass pushed it to, so the
106
+ * displacement accumulates instead of springing back, and a drag across a populated board
107
+ * rearranges services nobody touched. A frame drawn on top of its neighbours while the pointer
108
+ * is holding it is what direct manipulation looks like; the board settles when it is let go.
109
+ *
110
+ * Anchorless, because a change with no local gesture behind it has no frame the user is
111
+ * placing: it is a growth, or another client's write arriving. Reading order decides, and it is
112
+ * the same reading order everywhere.
113
+ */
114
+ function project() {
115
+ if (gestureId.value) return
116
+ bounce(null, false)
117
+ }
118
+
119
+ // Default `pre` flush: the correction is applied before the board re-renders, so an overlap
120
+ // never reaches the screen even for one frame. Re-entrant by construction (correcting a
121
+ // position invalidates `nodeRects`), but `resolveFrameOverlaps` is idempotent, so the second
122
+ // pass finds a settled board and stops there.
123
+ watch(nodeRects, project, { immediate: true })
124
+
125
+ /**
126
+ * Settle the board around the node the local user just placed, and persist what moved.
127
+ *
128
+ * `post` flush so the gesture's own commit has landed first: a drop calls `moveBlock` (and a
129
+ * released border `resizeBlock`) before clearing the id, and both apply their geometry
130
+ * optimistically, so by the time this runs `nodeRects` already holds the placed node's final
131
+ * footprint. The resolution is therefore computed from live geometry rather than replayed from
132
+ * anything the gesture accumulated, which is what makes every way a gesture can end correct
133
+ * without a case for each: a `pointercancel` (or the dragged component unmounting) restores the
134
+ * pre-drag position, so this finds a clear board and writes nothing at all.
135
+ */
136
+ watch(
137
+ gestureId,
138
+ (now, before) => {
139
+ if (now || !before) return
140
+ // Access can be revoked mid-session; a viewer still gets the corrected view.
141
+ bounce(before, access.canWriteBoard.value)
142
+ },
143
+ { flush: 'post' },
144
+ )
145
+ }
@@ -22,6 +22,16 @@ const HANDLES = {
22
22
 
23
23
  export type ResizeEdge = keyof typeof HANDLES
24
24
 
25
+ /**
26
+ * Id of the container currently being resized, for cursor/grip styling and for the board's
27
+ * overlap guard, which stands down while a border is still under the pointer and settles the
28
+ * board around this container once it is released.
29
+ *
30
+ * Module-level for the same reason as `useBlockDrag`'s `draggingId`: only one container is ever
31
+ * resized at a time, and the grips that start the drag are not the only reader.
32
+ */
33
+ const resizingId = ref<string | null>(null)
34
+
25
35
  /** The grips in render order, so a component can `v-for` them instead of listing eight blocks. */
26
36
  export const RESIZE_EDGES = Object.keys(HANDLES) as ResizeEdge[]
27
37
 
@@ -51,8 +61,6 @@ export function useFrameResize() {
51
61
  const board = useBoardStore()
52
62
  const ui = useUiStore()
53
63
  const access = useWorkspaceAccess()
54
- /** Id of the container currently being resized, for cursor/grip styling. */
55
- const resizingId = ref<string | null>(null)
56
64
 
57
65
  /**
58
66
  * How far the origin may travel INWARD before the nearest child would land at a negative
@@ -136,10 +144,15 @@ export function useFrameResize() {
136
144
  window.removeEventListener('pointercancel', onUp)
137
145
  body.style.cursor = priorCursor
138
146
  body.style.userSelect = priorUserSelect
139
- resizingId.value = null
140
147
  // A press with no movement is not a resize: committing it would emit a coarse board signal
141
148
  // (every other client re-hydrates) to store the geometry it already had.
149
+ //
150
+ // Committed BEFORE the id is released, matching `useBlockDrag`'s drop: `resizeBlock` applies
151
+ // the final bounds optimistically, and the overlap guard settles the board on the release of
152
+ // this id, so clearing it first would have the guard read the geometry of a resize that had
153
+ // not landed yet.
142
154
  if (moved) void board.resizeBlock(block.id, bounds, from)
155
+ resizingId.value = null
143
156
  }
144
157
  window.addEventListener('pointermove', onMove)
145
158
  window.addEventListener('pointerup', onUp)
@@ -1,10 +1,12 @@
1
1
  import { ref } from 'vue'
2
2
 
3
- // Service frames can overlap freely on the board. The frame the pointer is over
4
- // is, by definition, the un-obscured one at that point (pointerenter fires on the
5
- // topmost element), so we track it and lift it above every overlapping neighbour.
6
- // Module-level singleton: BlockNode sets it on hover, BoardCanvas reads it to set
7
- // the Vue Flow node's z-index.
3
+ // Service frames are kept clear of one another once settled (see useFrameOverlapGuard), but their
4
+ // boxes are not all there is to stack: chrome hangs outside the box, the guard stands down for the
5
+ // whole of a drag so a dragged frame crosses its neighbours freely, and Vue Flow needs SOME order
6
+ // regardless. The frame the pointer is over
7
+ // is, by definition, the un-obscured one at that point (pointerenter fires on the topmost
8
+ // element), so we track it and lift it above its neighbours. Module-level singleton: BlockNode
9
+ // sets it on hover, BoardCanvas reads it to set the Vue Flow node's z-index.
8
10
  const hoveredFrameId = ref<string | null>(null)
9
11
 
10
12
  export function useFrameStacking() {
@@ -1,5 +1,12 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { findFreeFramePosition, framesCollide, FRAME_GAP, type FrameRect } from './framePlacement'
2
+ import {
3
+ findFreeFramePosition,
4
+ framesCollide,
5
+ resolveFrameOverlaps,
6
+ FRAME_GAP,
7
+ type FrameRect,
8
+ type PlacedRect,
9
+ } from './framePlacement'
3
10
 
4
11
  const size = { w: 360, h: 220 }
5
12
 
@@ -71,3 +78,133 @@ describe('findFreeFramePosition', () => {
71
78
  expect(dist).toBeGreaterThan(0)
72
79
  })
73
80
  })
81
+
82
+ /** Every pair on the resolved board clears the gap: the invariant the guard exists to hold. */
83
+ function assertNoOverlap(rects: PlacedRect[], moved: Map<string, { x: number; y: number }>) {
84
+ const settled = rects.map((r) => ({ ...r, ...moved.get(r.id) }))
85
+ for (const [i, a] of settled.entries()) {
86
+ for (const b of settled.slice(i + 1)) {
87
+ expect({ pair: [a.id, b.id], collides: framesCollide(a, b, FRAME_GAP) }).toEqual({
88
+ pair: [a.id, b.id],
89
+ collides: false,
90
+ })
91
+ }
92
+ }
93
+ }
94
+
95
+ describe('resolveFrameOverlaps', () => {
96
+ it('leaves a board whose frames already clear each other untouched', () => {
97
+ const rects: PlacedRect[] = [
98
+ { id: 'a', x: 0, y: 0, ...size },
99
+ { id: 'b', x: size.w + FRAME_GAP, y: 0, ...size },
100
+ ]
101
+ expect(resolveFrameOverlaps(rects)).toEqual(new Map())
102
+ })
103
+
104
+ it('bounces the neighbour off an anchored frame dropped on top of it, keeping the drop', () => {
105
+ const rects: PlacedRect[] = [
106
+ { id: 'dropped', x: 40, y: 0, ...size },
107
+ { id: 'sitting', x: 0, y: 0, ...size },
108
+ ]
109
+ const moved = resolveFrameOverlaps(rects, { anchorId: 'dropped' })
110
+ // The frame the user placed keeps the exact spot they aimed at.
111
+ expect(moved.has('dropped')).toBe(false)
112
+ expect(moved.has('sitting')).toBe(true)
113
+ assertNoOverlap(rects, moved)
114
+ })
115
+
116
+ it('takes the shortest way out: a small overlap moves by a little, not a whole frame', () => {
117
+ const rects: PlacedRect[] = [
118
+ { id: 'anchor', x: 0, y: 0, ...size },
119
+ { id: 'nudged', x: size.w - 10, y: 0, ...size },
120
+ ]
121
+ const moved = resolveFrameOverlaps(rects, { anchorId: 'anchor' })
122
+ // Clearing rightwards costs 10px of penetration plus the gap; going around would cost a
123
+ // whole frame width.
124
+ expect(moved.get('nudged')).toEqual({ x: size.w + FRAME_GAP, y: 0 })
125
+ })
126
+
127
+ it('separates a frame that grew into its neighbour, keeping the grown frame in place', () => {
128
+ // An empty service (360x220) with a neighbour placed a gap away, then its first task
129
+ // arrives and it grows to the lane footprint.
130
+ const grown = { w: 694, h: 486 }
131
+ const rects: PlacedRect[] = [
132
+ { id: 'grown', x: 0, y: 0, ...grown },
133
+ { id: 'neighbour', x: 360 + FRAME_GAP, y: 0, ...size },
134
+ ]
135
+ const moved = resolveFrameOverlaps(rects, { anchorId: 'grown' })
136
+ expect(moved.has('grown')).toBe(false)
137
+ assertNoOverlap(rects, moved)
138
+ })
139
+
140
+ it('untangles a pile of frames stacked on one spot', () => {
141
+ const rects: PlacedRect[] = ['a', 'b', 'c', 'd', 'e'].map((id) => ({ id, x: 0, y: 0, ...size }))
142
+ assertNoOverlap(rects, resolveFrameOverlaps(rects))
143
+ })
144
+
145
+ it('answers the same way whatever order the rects arrive in', () => {
146
+ const rects: PlacedRect[] = [
147
+ { id: 'a', x: 0, y: 0, ...size },
148
+ { id: 'b', x: 30, y: 20, ...size },
149
+ { id: 'c', x: 60, y: 500, ...size },
150
+ { id: 'd', x: 10, y: 480, ...size },
151
+ ]
152
+ // Two clients hold the same board in whatever order their events arrived; a resolution that
153
+ // depended on that order would have them write different positions and fight.
154
+ const first = resolveFrameOverlaps(rects)
155
+ const second = resolveFrameOverlaps([...rects].reverse())
156
+ expect([...second].sort()).toEqual([...first].sort())
157
+ })
158
+
159
+ it('is idempotent: re-running over the resolved board moves nothing', () => {
160
+ const rects: PlacedRect[] = [
161
+ { id: 'a', x: 0, y: 0, ...size },
162
+ { id: 'b', x: 12, y: 8, ...size },
163
+ { id: 'c', x: 24, y: 16, ...size },
164
+ ]
165
+ const moved = resolveFrameOverlaps(rects)
166
+ const settled = rects.map((r) => ({ ...r, ...moved.get(r.id) }))
167
+ expect(resolveFrameOverlaps(settled)).toEqual(new Map())
168
+ })
169
+
170
+ it('clears fractional positions outright, so a pass cannot leave a sub-pixel to shave again', () => {
171
+ // A drag divides the pointer delta by the board zoom, so the stored positions really are
172
+ // fractional; rounding a correction the wrong way would leave a sliver of overlap behind.
173
+ const rects: PlacedRect[] = [
174
+ { id: 'a', x: 0.5, y: 0.25, ...size },
175
+ { id: 'b', x: 10.75, y: 0.5, ...size },
176
+ ]
177
+ const moved = resolveFrameOverlaps(rects, { anchorId: 'a' })
178
+ assertNoOverlap(rects, moved)
179
+ const settled = rects.map((r) => ({ ...r, ...moved.get(r.id) }))
180
+ expect(resolveFrameOverlaps(settled, { anchorId: 'a' })).toEqual(new Map())
181
+ })
182
+
183
+ it('settles the unanchored board in reading order: the top-left node keeps its place', () => {
184
+ // The policy with no anchor is POSITIONAL, not temporal. Whichever node is nearer the top of
185
+ // the board holds its spot and the one below it yields, regardless of which of the two arrived
186
+ // first: a block carries no shared creation stamp, so "the newcomer yields" is only knowable
187
+ // from a client's own history and two clients would answer it differently.
188
+ const rects: PlacedRect[] = [
189
+ { id: 'upper', x: 0, y: 480, ...size },
190
+ { id: 'lower', x: 0, y: 500, ...size },
191
+ ]
192
+ const moved = resolveFrameOverlaps(rects)
193
+ expect(moved.has('upper')).toBe(false)
194
+ expect(moved.has('lower')).toBe(true)
195
+ assertNoOverlap(rects, moved)
196
+ })
197
+
198
+ it('falls back to reading order when the anchor is not on the board', () => {
199
+ // The guard anchors on the id of whatever gesture just ended, which is routinely a task card
200
+ // rather than a top-level node. An anchor nothing matches must resolve exactly as none at all,
201
+ // or a task drag would settle the board differently from the growth it caused.
202
+ const rects: PlacedRect[] = [
203
+ { id: 'a', x: 0, y: 0, ...size },
204
+ { id: 'b', x: 40, y: 0, ...size },
205
+ ]
206
+ expect(resolveFrameOverlaps(rects, { anchorId: 'task-not-a-frame' })).toEqual(
207
+ resolveFrameOverlaps(rects),
208
+ )
209
+ })
210
+ })
@@ -106,3 +106,111 @@ export function findFreeFramePosition(
106
106
  const rightmost = existing.reduce((m, r) => Math.max(m, r.x + r.w), desired.x)
107
107
  return { x: rightmost + gap, y: desired.y }
108
108
  }
109
+
110
+ /** A rect on the board that knows which block it belongs to. */
111
+ export interface PlacedRect extends FrameRect {
112
+ readonly id: string
113
+ }
114
+
115
+ /**
116
+ * How many times one rect may be pushed off a neighbour before we stop nudging and fall back to
117
+ * the ring search. Each push clears the rect it hit but can walk it into a third one, so a dense
118
+ * cluster needs several; a cluster that needs more than this is one the incremental nudge is not
119
+ * going to untangle, and {@link findFreeFramePosition} always answers.
120
+ */
121
+ const MAX_SEPARATION_PUSHES = 8
122
+
123
+ /**
124
+ * The nearest place `moving` can sit that clears `settled` by `gap`: the minimum translation
125
+ * along whichever axis it is cheapest to leave by, which is what makes a frame nudged onto a
126
+ * neighbour bounce off the nearest border rather than teleport around it.
127
+ *
128
+ * Each candidate edge is rounded OUTWARD, away from the rect being cleared. A drag divides the
129
+ * pointer delta by the board zoom, so the positions coming in here are routinely fractional, and
130
+ * rounding the other way would leave a sub-pixel of the overlap behind for the next pass to find
131
+ * and shave again.
132
+ *
133
+ * Ties are broken in a fixed order (right before left, horizontal before vertical) rather than by
134
+ * anything read off the board, because every client resolves the same overlap independently and
135
+ * two of them answering differently would have the frames trade places on every refresh. A tie
136
+ * only arises when the two rects are exactly concentric on that axis.
137
+ */
138
+ function separatedPosition(moving: FrameRect, settled: FrameRect, gap: number): Point {
139
+ const right = Math.ceil(settled.x + settled.w + gap)
140
+ const left = Math.floor(settled.x - gap - moving.w)
141
+ const down = Math.ceil(settled.y + settled.h + gap)
142
+ const up = Math.floor(settled.y - gap - moving.h)
143
+ const x = Math.abs(right - moving.x) <= Math.abs(left - moving.x) ? right : left
144
+ const y = Math.abs(down - moving.y) <= Math.abs(up - moving.y) ? down : up
145
+ return Math.abs(x - moving.x) <= Math.abs(y - moving.y) ? { x, y: moving.y } : { x: moving.x, y }
146
+ }
147
+
148
+ /**
149
+ * Order the rects for settlement: `anchorId` first if it is on the board, then everything else in
150
+ * reading order (top row first, then left to right), with the id as the final tie-break.
151
+ *
152
+ * The order IS the policy. A rect settles into the space the ones before it have already taken, so
153
+ * whatever comes first keeps its exact position and later ones bounce off it. Naming the frame the
154
+ * local user is placing as the anchor is what makes a deliberate drop land where it was aimed
155
+ * while its neighbours move aside, instead of the other way round.
156
+ *
157
+ * There is at most ONE anchor, and that is deliberate rather than a simplification. A list would
158
+ * carry an order of its own, and the only orders available to build one from are per-client (the
159
+ * order a client's live events happened to arrive in, or a history only the client that watched
160
+ * the change has). Two clients ordering two anchors differently resolve the same overlap to
161
+ * different positions and then write over each other, which is the one failure mode a pure
162
+ * resolution exists to rule out. A single anchor has no order to disagree about.
163
+ *
164
+ * Everything else settles in READING ORDER, which is a POSITIONAL policy, not a temporal one: the
165
+ * node nearest the top-left of the board keeps its place and those below and to the right yield.
166
+ * It is worth being exact about this, because "a frame arriving on the board yields to the frames
167
+ * already there" is the rule one would reach for first and it is not implementable here. A block
168
+ * carries no shared creation or update stamp (see `blockSchema`), so arrival is only knowable from
169
+ * a client's own session history, which is precisely the per-client input ruled out above. Reading
170
+ * order is the strongest rule every client can agree on from the board alone.
171
+ */
172
+ function bySettlementOrder(anchorId: string | null | undefined) {
173
+ const priority = (r: PlacedRect) => (r.id === anchorId ? 0 : 1)
174
+ return (a: PlacedRect, b: PlacedRect): number =>
175
+ priority(a) - priority(b) || a.y - b.y || a.x - b.x || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
176
+ }
177
+
178
+ /**
179
+ * Push apart every board node that overlaps another, returning the new top-left of each one that
180
+ * had to move (and nothing for the ones that did not, so an already-clear board answers empty).
181
+ *
182
+ * This is the board's standing layout invariant, not a placement decision: a frame can come to
183
+ * overlap a neighbour long after it was placed, by being dragged onto it, by a border drag, or by
184
+ * growing when its first task arrives (an empty service reserves a much smaller footprint than
185
+ * one rendering lanes). All three land here, so the rule is stated once rather than at each of
186
+ * the writes that can break it.
187
+ *
188
+ * The result is a pure function of the rects and the single `anchorId`, with tie-breaks fixed in
189
+ * code rather than read off the board, so every client holding the same board computes the same
190
+ * answer whatever order its own events arrived in. That is what lets each of them correct what it
191
+ * DRAWS without any of them having to agree first; who may WRITE a correction back is a separate
192
+ * question, settled in {@link useFrameOverlapGuard}.
193
+ */
194
+ export function resolveFrameOverlaps(
195
+ rects: readonly PlacedRect[],
196
+ opts?: { anchorId?: string | null; gap?: number },
197
+ ): Map<string, Point> {
198
+ const gap = opts?.gap ?? FRAME_GAP
199
+ const settled: FrameRect[] = []
200
+ const moved = new Map<string, Point>()
201
+ for (const rect of [...rects].sort(bySettlementOrder(opts?.anchorId))) {
202
+ let at: FrameRect = { ...rect }
203
+ for (let push = 0; push < MAX_SEPARATION_PUSHES; push++) {
204
+ const blocker = settled.find((s) => framesCollide(at, s, gap))
205
+ if (!blocker) break
206
+ at = { ...at, ...separatedPosition(at, blocker, gap) }
207
+ }
208
+ if (!fits(at, settled, gap)) {
209
+ const free = findFreeFramePosition(settled, rect, { x: rect.x, y: rect.y }, gap)
210
+ at = { ...rect, x: free.x, y: free.y }
211
+ }
212
+ settled.push(at)
213
+ if (at.x !== rect.x || at.y !== rect.y) moved.set(rect.id, { x: at.x, y: at.y })
214
+ }
215
+ return moved
216
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.287.0",
3
+ "version": "0.288.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",
47
- "happy-dom": "^20.11.12",
47
+ "happy-dom": "^20.11.15",
48
48
  "msw": "^2.15.0",
49
49
  "nuxt": "^4.5.2",
50
50
  "typescript": "^6.0.3",