@cat-factory/app 0.30.3 → 0.30.5

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.
@@ -11,7 +11,8 @@ import { STATUS_META } from '~/utils/catalog'
11
11
  import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
12
12
  import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
13
13
  import { useTaskExpansion } from '~/composables/useTaskExpansion'
14
- import { computeDisplacement } from '~/utils/boardDisplacement'
14
+ import { useBlockDrag } from '~/composables/useBlockDrag'
15
+ import { useFrameStacking } from '~/composables/useFrameStacking'
15
16
 
16
17
  const board = useBoardStore()
17
18
  const pipelines = usePipelinesStore()
@@ -21,10 +22,12 @@ const github = useGitHubStore()
21
22
  const toast = useToast()
22
23
 
23
24
  const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(BOARD_FLOW_ID)
25
+ const { draggingId } = useBlockDrag()
26
+ const { hoveredFrameId } = useFrameStacking()
24
27
 
25
28
  // Gate which task cards expand their pipeline list on deep zoom: on-screen, and the
26
29
  // centre-most of any that would overlap (see useTaskExpansion). Service frames have no
27
- // such gate — they are always expanded to their task canvas (see frameOffsets below).
30
+ // such gate — they are always expanded to their task canvas.
28
31
  const boardEl = ref<HTMLElement | null>(null)
29
32
  useTaskExpansion(boardEl)
30
33
 
@@ -36,83 +39,41 @@ useTaskExpansion(boardEl)
36
39
  // and fill much of the viewport, so leaving them draggable would turn the whole canvas
37
40
  // into a dead zone. We therefore make every frame non-draggable (the pane pans straight
38
41
  // through it) and move it via its header handle instead.
39
-
40
- // Services are always expanded to their full task canvas. An expanded card grows
41
- // rightward / downward from its stored (chip-sized) top-left and would overlap its
42
- // neighbours, so compressed space pushes the neighbours away by that growth: the
43
- // footprint never overlaps a neighbour it wasn't already overlapping. Because the
44
- // expanded set never changes, the layout is fixed — panning never shifts it and there
45
- // is no expand/collapse transition to snap on. Render-only; stored positions untouched.
46
- const FRAME_COLLAPSED_W = 224 // the stored chip footprint (`w-56`) the layout reserves
47
- const FRAME_COLLAPSED_H = 150
48
- const FRAME_CHROME_W = 40 // border + padding around the inner task canvas
49
- const FRAME_CHROME_H = 120 // top bar + header row + paddings above the canvas
50
-
51
- const frameOffsets = computed(() => {
52
- const boxes = [
53
- ...board.frames.map((b) => {
54
- const c = board.containerSize(b.id)
55
- return {
56
- id: b.id,
57
- x: b.position.x,
58
- y: b.position.y,
59
- w: FRAME_COLLAPSED_W,
60
- h: FRAME_COLLAPSED_H,
61
- growX: Math.max(0, c.w + FRAME_CHROME_W - FRAME_COLLAPSED_W),
62
- growY: Math.max(0, c.h + FRAME_CHROME_H - FRAME_COLLAPSED_H),
63
- }
64
- }),
65
- // Epics never expand, but they're pushed aside like any other box so an expanded
66
- // frame doesn't end up rendered on top of one.
67
- ...board.epics.map((b) => ({
68
- id: b.id,
69
- x: b.position.x,
70
- y: b.position.y,
71
- w: FRAME_COLLAPSED_W,
72
- h: FRAME_COLLAPSED_H,
73
- growX: 0,
74
- growY: 0,
75
- })),
76
- ]
77
- return computeDisplacement(boxes)
78
- })
79
-
80
- function offsetOf(id: string) {
81
- return frameOffsets.value.get(id) ?? { dx: 0, dy: 0 }
42
+ //
43
+ // Frames are rendered exactly at their stored position and may overlap freely
44
+ // moving one never shifts another. The frame being dragged is lifted to the top,
45
+ // then the hovered frame (the un-obscured one under the pointer), so overlapping
46
+ // services can always be reached and reordered. See useFrameStacking.
47
+ function frameZIndex(id: string) {
48
+ if (draggingId.value === id) return 1000
49
+ if (hoveredFrameId.value === id) return 100
50
+ return 1
82
51
  }
83
52
 
84
53
  const nodes = computed(() => [
85
- ...board.frames.map((b) => {
86
- const o = offsetOf(b.id)
87
- return {
88
- id: b.id,
89
- type: 'block',
90
- position: { x: b.position.x + o.dx, y: b.position.y + o.dy },
91
- // Always-expanded frames fill the viewport; keep them non-draggable so the pane
92
- // pans through them (they move via their header handle, see BlockNode).
93
- draggable: false,
94
- data: {},
95
- }
96
- }),
54
+ ...board.frames.map((b) => ({
55
+ id: b.id,
56
+ type: 'block',
57
+ position: { x: b.position.x, y: b.position.y },
58
+ // Always-expanded frames fill the viewport; keep them non-draggable so the pane
59
+ // pans through them (they move via their header handle, see BlockNode).
60
+ draggable: false,
61
+ zIndex: frameZIndex(b.id),
62
+ data: {},
63
+ })),
97
64
  // Epics are top-level grouping nodes (non-structural), drawn alongside frames and
98
65
  // linked to their member tasks by the dependency-edge overlay.
99
- ...board.epics.map((b) => {
100
- const o = offsetOf(b.id)
101
- return {
102
- id: b.id,
103
- type: 'epic',
104
- position: { x: b.position.x + o.dx, y: b.position.y + o.dy },
105
- draggable: true,
106
- data: {},
107
- }
108
- }),
66
+ ...board.epics.map((b) => ({
67
+ id: b.id,
68
+ type: 'epic',
69
+ position: { x: b.position.x, y: b.position.y },
70
+ draggable: true,
71
+ data: {},
72
+ })),
109
73
  ])
110
74
 
111
75
  onNodeDragStop(({ node }) => {
112
- // node.position carries the render offset (compressed space can have pushed this node
113
- // aside); subtract it so we persist the un-displaced position.
114
- const o = offsetOf(node.id)
115
- board.moveBlock(node.id, { x: node.position.x - o.dx, y: node.position.y - o.dy })
76
+ board.moveBlock(node.id, node.position)
116
77
  })
117
78
 
118
79
  onViewportChange((vp) => {
@@ -8,6 +8,7 @@ import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
8
8
  import AgentStopButton from '~/components/board/AgentStopButton.vue'
9
9
  import { useBlockDrag } from '~/composables/useBlockDrag'
10
10
  import { useFrameResize } from '~/composables/useFrameResize'
11
+ import { useFrameStacking } from '~/composables/useFrameStacking'
11
12
 
12
13
  // Vue Flow passes the node's `id` and `data` as props to custom node components.
13
14
  // Only frames are rendered as board nodes; their tasks live inside the card.
@@ -91,13 +92,20 @@ function toggleExpand() {
91
92
  }
92
93
 
93
94
  // Expanded frames are not Vue Flow-draggable (so the pane can pan through them),
94
- // so they're repositioned by grabbing the header handle instead. Frames live in
95
- // free-floating flow space, hence `clamp: false`.
95
+ // so they're repositioned by grabbing the header instead. The whole header bar is
96
+ // the grab surface only the action buttons (marked `.nodrag`) opt out, so a press
97
+ // on them clicks rather than starts a drag. Frames live in free-floating flow space,
98
+ // hence `clamp: false`.
96
99
  const { startDrag } = useBlockDrag()
97
100
  function onFrameHandle(e: PointerEvent) {
101
+ if ((e.target as HTMLElement).closest('.nodrag')) return
98
102
  if (block.value) startDrag(block.value, e, { clamp: false })
99
103
  }
100
104
 
105
+ // Lift this frame above any overlapping neighbours while the pointer is over it
106
+ // (see useFrameStacking + BoardCanvas's frameZIndex).
107
+ const { enter: enterFrame, leave: leaveFrame } = useFrameStacking()
108
+
101
109
  // Miro-style frame resizing: drag the right / bottom edges or the corner. Handles
102
110
  // live on the expanded card's drop zone (see template); the composable clamps to
103
111
  // the frame's content extent and persists the size on release.
@@ -158,7 +166,13 @@ const ITEM_ICON: Record<string, string> = {
158
166
  </script>
159
167
 
160
168
  <template>
161
- <div v-if="block" class="relative" :data-block-id="block.id">
169
+ <div
170
+ v-if="block"
171
+ class="relative"
172
+ :data-block-id="block.id"
173
+ @pointerenter="enterFrame(block.id)"
174
+ @pointerleave="leaveFrame(block.id)"
175
+ >
162
176
  <!-- decision / approval indicator floats above the card at all zoom levels -->
163
177
  <div
164
178
  v-if="blockDecisions.length || blockApprovals.length"
@@ -341,16 +355,17 @@ const ITEM_ICON: Record<string, string> = {
341
355
  <AgentFailureCard :run="run" variant="expanded" />
342
356
  </div>
343
357
  <div class="space-y-3 p-4">
344
- <!-- frame header (doubles as the drag handle for the expanded frame) -->
345
- <div class="flex items-start justify-between gap-2">
346
- <!-- `nopan` stops Vue Flow's pane from panning on a left-drag that starts on
347
- this handle (it pans via d3-zoom's mousedown, which our pointerdown
348
- stopPropagation can't intercept), so the grab drives the frame move. -->
349
- <div
350
- class="nopan flex cursor-grab items-center gap-2 active:cursor-grabbing"
351
- title="Drag service"
352
- @pointerdown="onFrameHandle"
353
- >
358
+ <!-- frame header: the whole bar is the drag handle for the expanded frame.
359
+ `nopan` stops Vue Flow's pane from panning on a left-drag that starts here
360
+ (it pans via d3-zoom's mousedown, which our pointerdown stopPropagation
361
+ can't intercept), so the grab drives the frame move. The action buttons
362
+ on the right opt out via `.nodrag` (onFrameHandle ignores them). -->
363
+ <div
364
+ class="nopan flex cursor-grab items-start justify-between gap-2 active:cursor-grabbing"
365
+ title="Drag service"
366
+ @pointerdown="onFrameHandle"
367
+ >
368
+ <div class="flex items-center gap-2">
354
369
  <div
355
370
  class="flex h-8 w-8 items-center justify-center rounded-lg"
356
371
  :style="{ backgroundColor: typeMeta!.accent + '22' }"
@@ -4,9 +4,14 @@ import { useBlockDrag } from '~/composables/useBlockDrag'
4
4
 
5
5
  const props = defineProps<{ taskId: string }>()
6
6
  const board = useBoardStore()
7
+ const expansion = useTaskExpansionStore()
7
8
  const task = computed(() => board.getBlock(props.taskId))
8
9
  const { draggingId, startDrag } = useBlockDrag()
9
10
 
11
+ // An expanded pipeline grows downward over its neighbours, so it must stack above the
12
+ // other (compact) task cards — never let a neighbour render on top of the pipeline.
13
+ const expanded = computed(() => expansion.allowed.has(props.taskId))
14
+
10
15
  // Once a task is merged it stops being a unit of work and becomes part of the
11
16
  // architecture: it no longer renders as a draggable card (arrows fall back to its
12
17
  // container), so we never leave a zero-size anchor behind.
@@ -22,11 +27,11 @@ function onHandle(e: PointerEvent) {
22
27
  <!-- in-flight task → draggable work card (merged tasks render nothing) -->
23
28
  <div
24
29
  v-if="!merged"
25
- class="absolute w-[180px]"
30
+ class="absolute w-[210px]"
26
31
  :style="{
27
32
  left: task.position.x + 'px',
28
33
  top: task.position.y + 'px',
29
- zIndex: draggingId === taskId ? 60 : 10,
34
+ zIndex: draggingId === taskId ? 60 : expanded ? 20 : 10,
30
35
  // While this task is being dragged it must not capture hit-tests, so the
31
36
  // drop-zone (service or module) beneath the cursor can be resolved on
32
37
  // release — including the drag handle, which lives in this wrapper above
@@ -182,7 +182,9 @@ function selectTask() {
182
182
  ]"
183
183
  @click.stop="selectTask"
184
184
  >
185
- <!-- header row -->
185
+ <!-- meta row: status dot, recurring icon, status label + connect handle. The
186
+ status label is a fixed-width-ish stub ("APPROVAL NEEDED" etc.), so it sits
187
+ on its own row rather than stealing horizontal space from the title. -->
186
188
  <div class="flex items-center gap-1.5">
187
189
  <span class="h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: statusMeta.color }" />
188
190
  <UIcon
@@ -191,19 +193,8 @@ function selectTask() {
191
193
  class="h-3 w-3 shrink-0 text-indigo-400"
192
194
  :title="schedule.enabled ? 'Recurring pipeline' : 'Recurring pipeline (paused)'"
193
195
  />
194
- <span class="truncate text-[11px] font-semibold text-slate-100">{{ task.title }}</span>
195
- <!-- drag-to-connect handle: drag onto another task to make it depend on this one -->
196
- <button
197
- type="button"
198
- class="nodrag ml-1 shrink-0 cursor-crosshair rounded-full p-0.5 text-slate-500 hover:bg-slate-800 hover:text-amber-400"
199
- title="Drag onto another task to make it depend on this one"
200
- @pointerdown.stop="startConnect(task.id, $event)"
201
- @click.stop
202
- >
203
- <UIcon name="i-lucide-spline" class="h-3 w-3" />
204
- </button>
205
196
  <span
206
- class="ml-auto shrink-0 text-[9px] uppercase tracking-wide"
197
+ class="ml-auto truncate text-[9px] uppercase tracking-wide"
207
198
  :class="
208
199
  runFailed
209
200
  ? 'text-rose-400'
@@ -216,6 +207,25 @@ function selectTask() {
216
207
  >
217
208
  {{ statusText }}
218
209
  </span>
210
+ <!-- drag-to-connect handle: drag onto another task to make it depend on this one -->
211
+ <button
212
+ type="button"
213
+ class="nodrag shrink-0 cursor-crosshair rounded-full p-0.5 text-slate-500 hover:bg-slate-800 hover:text-amber-400"
214
+ title="Drag onto another task to make it depend on this one"
215
+ @pointerdown.stop="startConnect(task.id, $event)"
216
+ @click.stop
217
+ >
218
+ <UIcon name="i-lucide-spline" class="h-3 w-3" />
219
+ </button>
220
+ </div>
221
+
222
+ <!-- title gets a full-width row so long titles wrap to two lines rather than
223
+ truncating to an unreadable stub; the full text stays available on hover. -->
224
+ <div
225
+ class="mt-1 line-clamp-2 break-words text-[11px] font-semibold leading-snug text-slate-100"
226
+ :title="task.title"
227
+ >
228
+ {{ task.title }}
219
229
  </div>
220
230
 
221
231
  <!-- a failed run: the shared failure banner + retry, never a stuck bar -->
@@ -1,17 +1,23 @@
1
1
  import { ref } from 'vue'
2
2
  import type { Block } from '~/types/domain'
3
3
 
4
+ // Only one block is ever dragged at a time, so the dragged id is a module-level
5
+ // singleton: the component that starts the drag and a sibling that needs to react
6
+ // to it (e.g. BoardCanvas elevating the dragged service frame's z-index) read the
7
+ // same ref instead of separate per-call copies.
8
+ const draggingId = ref<string | null>(null)
9
+
4
10
  /**
5
11
  * Pointer-driven dragging for blocks positioned inside a container's 2D canvas
6
- * (tasks inside services/modules, modules inside services). Movement is divided
7
- * by the board zoom so the block tracks the cursor. When `reparent` is set, the
8
- * drop point is hit-tested against `[data-drop-zone]` ancestors so a task can be
9
- * dragged from a service into a module (or back out).
12
+ * (tasks inside services/modules, modules inside services) and for free-floating
13
+ * service frames (via their header handle). Movement is divided by the board zoom
14
+ * so the block tracks the cursor. When `reparent` is set, the drop point is
15
+ * hit-tested against `[data-drop-zone]` ancestors so a task can be dragged from a
16
+ * service into a module (or back out).
10
17
  */
11
18
  export function useBlockDrag() {
12
19
  const board = useBoardStore()
13
20
  const ui = useUiStore()
14
- const draggingId = ref<string | null>(null)
15
21
 
16
22
  function startDrag(
17
23
  block: Block,
@@ -117,7 +117,7 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
117
117
  function contentSize(id: string): { w: number; h: number } {
118
118
  const b = getBlock(id)
119
119
  const isModule = b?.level === 'module'
120
- const TASK_W = 180
120
+ const TASK_W = 210
121
121
  const TASK_H = 160
122
122
  const headerH = isModule ? 30 : 0
123
123
  let w = isModule ? 200 : 360
@@ -0,0 +1,18 @@
1
+ import { ref } from 'vue'
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.
8
+ const hoveredFrameId = ref<string | null>(null)
9
+
10
+ export function useFrameStacking() {
11
+ function enter(id: string) {
12
+ hoveredFrameId.value = id
13
+ }
14
+ function leave(id: string) {
15
+ if (hoveredFrameId.value === id) hoveredFrameId.value = null
16
+ }
17
+ return { hoveredFrameId, enter, leave }
18
+ }
@@ -2,8 +2,7 @@ import type { Ref } from 'vue'
2
2
  import { onMounted, onBeforeUnmount } from 'vue'
3
3
  import { useRafFn } from '@vueuse/core'
4
4
  import { lodAtLeast } from '~/composables/useSemanticZoom'
5
-
6
- type Rect = { left: number; right: number; top: number; bottom: number }
5
+ import { headerDistanceSq, type Rect } from '~/utils/taskExpansionRanking'
7
6
 
8
7
  function intersects(a: Rect, b: Rect) {
9
8
  return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
@@ -21,9 +20,13 @@ function sameSet(a: Set<string>, b: Set<string>) {
21
20
  * frame against live DOM rects so they follow pan / zoom / drag / resize:
22
21
  *
23
22
  * - visibility: a task expands only while its card overlaps the board viewport.
24
- * - overlap: walking the visible candidates nearest-to-screen-centre first, a task
25
- * expands only if its footprint doesn't collide with one already granted, so the
26
- * centre-most task wins an overlap and the rest stay compact.
23
+ * - overlap: walking the visible candidates nearest-header-to-screen-centre first, a
24
+ * task expands only if its footprint doesn't collide with one already granted, so the
25
+ * card you're looking at wins an overlap and the rest stay compact.
26
+ * - hover: the task directly under the pointer is granted first, so hovering any card
27
+ * expands its pipeline regardless of its position on screen. "Under the pointer" is
28
+ * the TOPMOST card at the cursor (document.elementFromPoint), so hovering a region
29
+ * already covered by another open pipeline keeps that pipeline, not the card beneath.
27
30
  *
28
31
  * Writes the permitted id set into the `taskExpansion` store; `TaskPipelineMini` reads it.
29
32
  * Only tasks with a running pipeline (steps to show) are candidates — a task that
@@ -44,11 +47,30 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
44
47
  // card is still tested at its expanded extent and stays denied. Stable.
45
48
  const expandedHeight = new Map<string, number>()
46
49
 
50
+ // Last pointer position over the board (viewport coords), or null when the pointer has
51
+ // left it. The card under the pointer is expanded on hover (see `hoveredTaskId`).
52
+ let pointer: { x: number; y: number } | null = null
53
+ function onPointerMove(e: PointerEvent) {
54
+ pointer = { x: e.clientX, y: e.clientY }
55
+ }
56
+ function onPointerLeave() {
57
+ pointer = null
58
+ }
59
+
47
60
  function rectOf(id: string): DOMRect | null {
48
61
  const el = document.querySelector(`[data-block-id="${id}"]`) as HTMLElement | null
49
62
  return el ? el.getBoundingClientRect() : null
50
63
  }
51
64
 
65
+ // The task whose card is topmost at the pointer, or null. Using elementFromPoint (not a
66
+ // rect test) means an open pipeline stacked above a neighbour wins the hit, so hovering
67
+ // a region obscured by another pipeline doesn't switch to the card hidden beneath it.
68
+ function hoveredTaskId(): string | null {
69
+ if (!pointer) return null
70
+ const hit = document.elementFromPoint(pointer.x, pointer.y)
71
+ return hit?.closest('[data-block-id]')?.getAttribute('data-block-id') ?? null
72
+ }
73
+
52
74
  function recompute() {
53
75
  // Task cards only expand at the deep zoom bands; clear everything otherwise.
54
76
  if (!lodAtLeast(ui.lod, 'steps')) {
@@ -82,28 +104,29 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
82
104
  top: rect.top,
83
105
  bottom: rect.top + height,
84
106
  }
85
- // Rank by the screen centre's distance to the card's projected footprint 0
86
- // whenever the centre sits over the card. So a tall card the viewport is parked
87
- // inside wins over a shorter neighbour whose top edge merely happens to be nearer
88
- // the centre line (the old top-centre anchor penalised a card you'd scrolled into
89
- // once its top scrolled above the viewport). The footprint is the stable expanded
90
- // extent — its top doesn't move and its bottom uses the cached expanded height —
91
- // so the ranking can't oscillate as cards expand / collapse.
92
- const ddx = Math.max(footprint.left - cx, 0, cx - footprint.right)
93
- const ddy = Math.max(footprint.top - cy, 0, cy - footprint.bottom)
94
- const dist = ddx * ddx + ddy * ddy
95
- candidates.push({ id: t.id, rect: footprint, dist })
107
+ // Rank by the screen centre's distance to the card's stable header (top edge),
108
+ // so the card you're looking at wins and a tall card's expanded body can't claim
109
+ // the centre just by covering it. See utils/taskExpansionRanking.ts.
110
+ candidates.push({ id: t.id, rect: footprint, dist: headerDistanceSq(footprint, cx, cy) })
96
111
  }
97
112
  // Drop cached heights for cards that are gone, so the map can't grow unbounded.
98
113
  for (const id of expandedHeight.keys()) if (!liveIds.has(id)) expandedHeight.delete(id)
99
114
  candidates.sort((a, b) => a.dist - b.dist)
100
115
 
101
- // Greedy by distance to centre: a candidate is granted only if its projected
102
- // footprint clears every footprint already granted, so the centre-most card
103
- // wins any overlap.
116
+ // Greedy by header distance: a candidate is granted only if its projected footprint
117
+ // clears every footprint already granted, so the centre-most card wins any overlap.
118
+ // The hovered card is granted FIRST, so hovering a card expands it regardless of its
119
+ // distance from the centre (and a centre-most neighbour it overlaps yields to it).
120
+ const hovered = hoveredTaskId()
104
121
  const claimed: Rect[] = []
105
122
  const next = new Set<string>()
123
+ const hoveredCard = hovered ? candidates.find((c) => c.id === hovered) : undefined
124
+ if (hoveredCard) {
125
+ next.add(hoveredCard.id)
126
+ claimed.push(hoveredCard.rect)
127
+ }
106
128
  for (const c of candidates) {
129
+ if (c.id === hoveredCard?.id) continue
107
130
  if (claimed.some((r) => intersects(c.rect, r))) continue
108
131
  next.add(c.id)
109
132
  claimed.push(c.rect)
@@ -114,10 +137,16 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
114
137
  const { pause, resume } = useRafFn(recompute, { immediate: false })
115
138
  onMounted(() => {
116
139
  store.setDriverActive(true)
140
+ const el = container.value
141
+ el?.addEventListener('pointermove', onPointerMove)
142
+ el?.addEventListener('pointerleave', onPointerLeave)
117
143
  resume()
118
144
  })
119
145
  onBeforeUnmount(() => {
120
146
  pause()
147
+ const el = container.value
148
+ el?.removeEventListener('pointermove', onPointerMove)
149
+ el?.removeEventListener('pointerleave', onPointerLeave)
121
150
  store.setDriverActive(false)
122
151
  })
123
152
  }
@@ -173,7 +173,7 @@ describe('board store read getters', () => {
173
173
  ])
174
174
  // module inner width/height fit the task, plus the 30px module header.
175
175
  const size = store.containerSize('m1')
176
- expect(size.w).toBe(300 + 180 + 12)
176
+ expect(size.w).toBe(300 + 210 + 12)
177
177
  expect(size.h).toBe(200 + 160 + 12 + 30)
178
178
  })
179
179
 
@@ -7,8 +7,9 @@ import { ref } from 'vue'
7
7
  * Deep-zoom (`steps`/`subtasks`) grows a task card downward, and cards are
8
8
  * absolutely positioned in their frame, so several expanded cards stacked
9
9
  * vertically pile on top of each other. The board driver (`useTaskExpansion`)
10
- * recomputes a permitted set every frame — only on-screen cards, and only the
11
- * one closest to the screen centre when two would overlap and writes it here.
10
+ * recomputes a permitted set every frame — only on-screen cards, the one closest to
11
+ * the screen centre when two would overlap, plus whichever card the pointer is hovering
12
+ * (hover expands a card regardless of position) — and writes it here.
12
13
  * `TaskPipelineMini` reads `canExpand` to decide whether to expand or stay compact.
13
14
  *
14
15
  * `driverActive` lets the gate degrade gracefully: with no board driver mounted
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { headerDistanceSq, type Rect } from './taskExpansionRanking'
3
+
4
+ /** Rank a set of cards against a screen centre, best (would-expand) first. */
5
+ function rank(cards: Record<string, Rect>, cx: number, cy: number): string[] {
6
+ return Object.entries(cards)
7
+ .map(([id, rect]) => ({ id, dist: headerDistanceSq(rect, cx, cy) }))
8
+ .sort((a, b) => a.dist - b.dist)
9
+ .map((c) => c.id)
10
+ }
11
+
12
+ describe('headerDistanceSq', () => {
13
+ it('measures from the centre of the card top edge', () => {
14
+ const r: Rect = { left: 0, right: 100, top: 200, bottom: 600 }
15
+ // top-centre is (50, 200); centre (50, 240) → dy 40 → 40² = 1600
16
+ expect(headerDistanceSq(r, 50, 240)).toBe(1600)
17
+ // 30px to the right of the top-centre, level with the top → 30² = 900
18
+ expect(headerDistanceSq(r, 80, 200)).toBe(900)
19
+ })
20
+
21
+ it('ignores the expanded height — only the top edge counts', () => {
22
+ const short: Rect = { left: 0, right: 100, top: 200, bottom: 260 }
23
+ const tall: Rect = { left: 0, right: 100, top: 200, bottom: 2000 }
24
+ expect(headerDistanceSq(short, 50, 240)).toBe(headerDistanceSq(tall, 50, 240))
25
+ })
26
+ })
27
+
28
+ describe('ranking', () => {
29
+ // The regression from the screenshot: a tall card parked at the top of the screen
30
+ // expands its pipeline down past the centre, so its body covers the centre. A compact
31
+ // card whose header sits right at the centre must still win — the one you're looking at.
32
+ it('prefers the card whose header is at the centre over a tall card bleeding down from the top', () => {
33
+ const top: Rect = { left: 0, right: 200, top: 30, bottom: 700 } // header far up, body covers centre
34
+ const here: Rect = { left: 0, right: 200, top: 320, bottom: 520 } // header at the centre
35
+ expect(rank({ top, here }, 100, 340)).toEqual(['here', 'top'])
36
+ // document order can't flip the winner
37
+ expect(rank({ here, top }, 100, 340)).toEqual(['here', 'top'])
38
+ })
39
+
40
+ it('ranks by the header nearest the centre regardless of expansion state', () => {
41
+ const above: Rect = { left: 0, right: 200, top: 100, bottom: 800 }
42
+ const below: Rect = { left: 0, right: 200, top: 360, bottom: 420 }
43
+ expect(rank({ above, below }, 100, 320)).toEqual(['below', 'above'])
44
+ })
45
+
46
+ it('uses horizontal offset to break a vertical tie', () => {
47
+ const near: Rect = { left: 0, right: 100, top: 100, bottom: 200 }
48
+ const far: Rect = { left: 400, right: 500, top: 100, bottom: 200 }
49
+ expect(rank({ far, near }, 80, 100)).toEqual(['near', 'far'])
50
+ })
51
+ })
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Pure ranking for the task-expansion gate (see `composables/useTaskExpansion.ts`).
3
+ *
4
+ * A task card grows DOWNWARD when it expands its pipeline, so its `top`/`left`/`right`
5
+ * never move — only its height does. We rank candidates by the screen centre's distance
6
+ * to each card's stable HEADER anchor (the top edge), which is what the user reads as
7
+ * "the card I'm looking at". Ranking on the header (not the expanded footprint) is what
8
+ * stops a tall card from winning just because its expanded body happens to cover the
9
+ * centre: a compact card whose header sits right at the centre beats a neighbour whose
10
+ * header is parked at the top of the screen, even when both bodies overlap the centre.
11
+ *
12
+ * Because the anchor uses only top/left/right (all stable as the card expands and
13
+ * collapses), the ordering can't oscillate. A tall card you've scrolled past the top of
14
+ * is no longer kept expanded by the ranking itself — the hover "pin" in the driver keeps
15
+ * the pipeline you're pointing at from collapsing while you scroll.
16
+ */
17
+ export type Rect = { left: number; right: number; top: number; bottom: number }
18
+
19
+ /**
20
+ * Squared distance from the screen centre `(cx, cy)` to a card's stable header anchor
21
+ * (the centre of its top edge). Smaller = nearer the centre = ranks first.
22
+ */
23
+ export function headerDistanceSq(card: Rect, cx: number, cy: number): number {
24
+ const ax = (card.left + card.right) / 2
25
+ const dx = ax - cx
26
+ const dy = card.top - cy
27
+ return dx * dx + dy * dy
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.30.3",
3
+ "version": "0.30.5",
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",
@@ -1,63 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { computeDisplacement, type DisplacementBox } from '~/utils/boardDisplacement'
3
-
4
- function box(p: Partial<DisplacementBox> & { id: string }): DisplacementBox {
5
- return { x: 0, y: 0, w: 10, h: 10, growX: 0, growY: 0, ...p }
6
- }
7
-
8
- describe('computeDisplacement', () => {
9
- it('pushes a right-hand neighbour by the expanded box growth, preserving the gap', () => {
10
- // A at x=0 w=10 grows by 90; B sits 20 to the right (x=30) in the same row.
11
- const offsets = computeDisplacement([
12
- box({ id: 'A', x: 0, w: 10, growX: 90 }),
13
- box({ id: 'B', x: 30 }),
14
- ])
15
- expect(offsets.get('A')).toEqual({ dx: 0, dy: 0 })
16
- // B moves right by 90, so the 20px gap (A's new right edge 100 → B's new left 120) is kept.
17
- expect(offsets.get('B')).toEqual({ dx: 90, dy: 0 })
18
- })
19
-
20
- it('does not push a neighbour that was already overlapping the box horizontally', () => {
21
- // B starts inside A's collapsed extent (x=5 < A.right=10): already overlapping, left as-is.
22
- const offsets = computeDisplacement([
23
- box({ id: 'A', x: 0, w: 10, growX: 90 }),
24
- box({ id: 'B', x: 5 }),
25
- ])
26
- expect(offsets.get('B')).toEqual({ dx: 0, dy: 0 })
27
- })
28
-
29
- it('accumulates the growth of every expanded box to the left (chains)', () => {
30
- const offsets = computeDisplacement([
31
- box({ id: 'A', x: 0, w: 10, growX: 90 }),
32
- box({ id: 'B', x: 30, w: 10, growX: 50 }),
33
- box({ id: 'C', x: 60 }),
34
- ])
35
- // C is right of both A and B → pushed by 90 + 50.
36
- expect(offsets.get('C')!.dx).toBe(140)
37
- // B is right of A only → pushed by 90.
38
- expect(offsets.get('B')!.dx).toBe(90)
39
- })
40
-
41
- it('does not cross-push boxes that are disjoint on the other axis', () => {
42
- // A grows rightward but B is far below it (no shared rows) → no horizontal push.
43
- const offsets = computeDisplacement([
44
- box({ id: 'A', x: 0, y: 0, w: 10, h: 10, growX: 90 }),
45
- box({ id: 'B', x: 30, y: 500, w: 10, h: 10 }),
46
- ])
47
- expect(offsets.get('B')).toEqual({ dx: 0, dy: 0 })
48
- })
49
-
50
- it('pushes a box below an expanded box downward', () => {
51
- const offsets = computeDisplacement([
52
- box({ id: 'A', x: 0, y: 0, w: 10, h: 10, growY: 100 }),
53
- box({ id: 'B', x: 0, y: 30, w: 10, h: 10 }),
54
- ])
55
- expect(offsets.get('B')).toEqual({ dx: 0, dy: 100 })
56
- })
57
-
58
- it('returns a zero offset for every box when nothing is expanded', () => {
59
- const offsets = computeDisplacement([box({ id: 'A', x: 0 }), box({ id: 'B', x: 30 })])
60
- expect(offsets.get('A')).toEqual({ dx: 0, dy: 0 })
61
- expect(offsets.get('B')).toEqual({ dx: 0, dy: 0 })
62
- })
63
- })
@@ -1,62 +0,0 @@
1
- /**
2
- * Compressed-space layout for the board. When a box (a service frame, or a task
3
- * card inside a frame) expands, it grows rightward / downward from its stored
4
- * top-left. Rather than letting the expanded footprint overlap its neighbours —
5
- * and then collapsing one of them to resolve the clash, which is what made a
6
- * zoomed-in service "snap out" as you scrolled across it — we PUSH the neighbours
7
- * away by the growth, so an expanded box never overlaps a neighbour it wasn't
8
- * already overlapping. The box stays expanded; you just scroll a bit further to
9
- * reach the next one.
10
- *
11
- * The result is a render-only offset per box; stored positions are never mutated.
12
- */
13
- export type DisplacementBox = {
14
- id: string
15
- /** Stored top-left, in flow units. */
16
- x: number
17
- y: number
18
- /** Collapsed size, in flow units. */
19
- w: number
20
- h: number
21
- /** Extra size when expanded (0 for a collapsed box). */
22
- growX: number
23
- growY: number
24
- }
25
-
26
- export type Offset = { dx: number; dy: number }
27
-
28
- /** Do the two boxes' collapsed extents overlap on the y-axis? */
29
- function overlapsY(a: DisplacementBox, b: DisplacementBox) {
30
- return a.y < b.y + b.h && b.y < a.y + a.h
31
- }
32
-
33
- /** Do the two boxes' collapsed extents overlap on the x-axis? */
34
- function overlapsX(a: DisplacementBox, b: DisplacementBox) {
35
- return a.x < b.x + b.w && b.x < a.x + a.w
36
- }
37
-
38
- /**
39
- * The render offset for every box. A box B is pushed right by the horizontal
40
- * growth of each expanded box E that sits to B's left (B starts past E's collapsed
41
- * right edge, so it isn't already overlapping E on x) and shares B's rows (their
42
- * collapsed y-extents overlap, so E growing rightward would actually reach B).
43
- * Symmetric for the downward push. Offsets only ever grow, so the function can
44
- * never create a new overlap, preserves left-to-right / top-to-bottom order and
45
- * the gaps between boxes, and chained expansions accumulate (the sum is taken from
46
- * the stable stored positions, so it doesn't oscillate). O(N x expanded).
47
- */
48
- export function computeDisplacement(boxes: DisplacementBox[]): Map<string, Offset> {
49
- const expanded = boxes.filter((e) => e.growX > 0 || e.growY > 0)
50
- const out = new Map<string, Offset>()
51
- for (const b of boxes) {
52
- let dx = 0
53
- let dy = 0
54
- for (const e of expanded) {
55
- if (e.id === b.id) continue
56
- if (e.growX > 0 && b.x >= e.x + e.w && overlapsY(e, b)) dx += e.growX
57
- if (e.growY > 0 && b.y >= e.y + e.h && overlapsX(e, b)) dy += e.growY
58
- }
59
- out.set(b.id, { dx, dy })
60
- }
61
- return out
62
- }