@cat-factory/app 0.30.2 → 0.30.4
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/app/components/board/AddTaskModal.vue +3 -1
- package/app/components/board/nodes/BlockNode.vue +2 -0
- package/app/components/board/nodes/DraggableTask.vue +7 -2
- package/app/components/board/nodes/TaskCard.vue +23 -13
- package/app/components/layout/NotificationsInbox.vue +11 -1
- package/app/components/panels/AgentStepDetail.vue +2 -0
- package/app/composables/useBlockQueries.ts +1 -1
- package/app/composables/useTaskExpansion.ts +48 -19
- package/app/stores/board.spec.ts +1 -1
- package/app/stores/taskExpansion.ts +3 -2
- package/app/utils/taskExpansionRanking.spec.ts +51 -0
- package/app/utils/taskExpansionRanking.ts +28 -0
- package/package.json +1 -1
|
@@ -372,7 +372,7 @@ async function add() {
|
|
|
372
372
|
<template>
|
|
373
373
|
<UModal v-model:open="open" title="Add a task">
|
|
374
374
|
<template #body>
|
|
375
|
-
<div class="space-y-4">
|
|
375
|
+
<div class="space-y-4" data-testid="add-task-modal">
|
|
376
376
|
<p v-if="container" class="text-xs text-slate-400">
|
|
377
377
|
New task in <span class="font-medium text-slate-200">{{ container.title }}</span>
|
|
378
378
|
</p>
|
|
@@ -411,6 +411,7 @@ async function add() {
|
|
|
411
411
|
<UFormField label="Title" required>
|
|
412
412
|
<UInput
|
|
413
413
|
v-model="title"
|
|
414
|
+
data-testid="add-task-title"
|
|
414
415
|
placeholder="What needs to be done?"
|
|
415
416
|
autofocus
|
|
416
417
|
class="w-full"
|
|
@@ -746,6 +747,7 @@ async function add() {
|
|
|
746
747
|
<UButton color="neutral" variant="ghost" @click="ui.closeAddTask()">Cancel</UButton>
|
|
747
748
|
<UButton
|
|
748
749
|
color="primary"
|
|
750
|
+
data-testid="add-task-submit"
|
|
749
751
|
:icon="isRecurring ? 'i-lucide-arrow-right' : 'i-lucide-plus'"
|
|
750
752
|
:loading="saving"
|
|
751
753
|
:disabled="!canAdd"
|
|
@@ -368,6 +368,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
368
368
|
}}</UBadge>
|
|
369
369
|
<UButton
|
|
370
370
|
class="nodrag"
|
|
371
|
+
data-testid="frame-add-task"
|
|
371
372
|
size="xs"
|
|
372
373
|
variant="ghost"
|
|
373
374
|
color="neutral"
|
|
@@ -425,6 +426,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
425
426
|
<button
|
|
426
427
|
v-if="!hasTasks"
|
|
427
428
|
type="button"
|
|
429
|
+
data-testid="frame-add-task"
|
|
428
430
|
class="absolute inset-4 flex items-center justify-center gap-1 rounded-lg border border-dashed border-slate-700 text-[11px] text-slate-500 hover:border-slate-500 hover:text-slate-300"
|
|
429
431
|
@click.stop="addTask"
|
|
430
432
|
>
|
|
@@ -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-[
|
|
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
|
-
<!--
|
|
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
|
|
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 -->
|
|
@@ -115,7 +115,13 @@ function revealDecision(n: Notification) {
|
|
|
115
115
|
|
|
116
116
|
<template>
|
|
117
117
|
<UPopover v-if="notifications.count" :content="{ align: 'end' }">
|
|
118
|
-
<UButton
|
|
118
|
+
<UButton
|
|
119
|
+
data-testid="notifications-bell"
|
|
120
|
+
:color="hasUrgent ? 'error' : 'warning'"
|
|
121
|
+
variant="soft"
|
|
122
|
+
size="sm"
|
|
123
|
+
icon="i-lucide-bell"
|
|
124
|
+
>
|
|
119
125
|
{{ notifications.count }}
|
|
120
126
|
</UButton>
|
|
121
127
|
|
|
@@ -127,6 +133,8 @@ function revealDecision(n: Notification) {
|
|
|
127
133
|
<div
|
|
128
134
|
v-for="n in notifications.open"
|
|
129
135
|
:key="n.id"
|
|
136
|
+
data-testid="notification-item"
|
|
137
|
+
:data-notification-type="n.type"
|
|
130
138
|
class="rounded-lg border p-2.5 mt-1.5"
|
|
131
139
|
:class="
|
|
132
140
|
isUrgent(n)
|
|
@@ -167,6 +175,7 @@ function revealDecision(n: Notification) {
|
|
|
167
175
|
</a>
|
|
168
176
|
<div class="mt-2 flex items-center gap-1.5">
|
|
169
177
|
<UButton
|
|
178
|
+
data-testid="notification-act"
|
|
170
179
|
:color="accent(n)"
|
|
171
180
|
variant="soft"
|
|
172
181
|
size="xs"
|
|
@@ -176,6 +185,7 @@ function revealDecision(n: Notification) {
|
|
|
176
185
|
{{ META[n.type].action }}
|
|
177
186
|
</UButton>
|
|
178
187
|
<UButton
|
|
188
|
+
data-testid="notification-dismiss"
|
|
179
189
|
color="neutral"
|
|
180
190
|
variant="ghost"
|
|
181
191
|
size="xs"
|
|
@@ -175,6 +175,7 @@ async function copyOutput() {
|
|
|
175
175
|
<Transition name="reader-fade">
|
|
176
176
|
<div
|
|
177
177
|
v-if="open && step && agent"
|
|
178
|
+
data-testid="step-detail"
|
|
178
179
|
class="fixed inset-0 z-50 flex bg-slate-950/96 backdrop-blur-sm"
|
|
179
180
|
role="dialog"
|
|
180
181
|
aria-modal="true"
|
|
@@ -552,6 +553,7 @@ async function copyOutput() {
|
|
|
552
553
|
<div v-else class="space-y-2 border-t border-slate-800 px-4 py-3">
|
|
553
554
|
<UButton
|
|
554
555
|
color="primary"
|
|
556
|
+
data-testid="step-approve"
|
|
555
557
|
size="sm"
|
|
556
558
|
icon="i-lucide-check"
|
|
557
559
|
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 =
|
|
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
|
|
@@ -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
|
|
25
|
-
* expands only if its footprint doesn't collide with one already granted, so the
|
|
26
|
-
*
|
|
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
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
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
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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
|
}
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -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 +
|
|
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,
|
|
11
|
-
*
|
|
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
|
+
"version": "0.30.4",
|
|
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",
|