@cat-factory/app 0.46.3 → 0.46.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/README.md +1 -1
- package/app/assets/css/main.css +10 -0
- package/app/components/board/BoardCanvas.vue +18 -9
- package/app/components/board/nodes/BlockNode.vue +4 -4
- package/app/components/board/nodes/DraggableTask.vue +1 -1
- package/app/components/board/nodes/ModuleFrame.vue +4 -4
- package/app/components/board/nodes/TaskCard.vue +1 -1
- package/app/components/layout/BoardToolbar.vue +10 -1
- package/app/composables/useViewport.ts +13 -4
- package/app/utils/boardPanMode.spec.ts +17 -0
- package/app/utils/boardPanMode.ts +20 -0
- package/nuxt.config.ts +0 -1
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ over the WebSocket. How that sync works is written up in
|
|
|
35
35
|
|
|
36
36
|
- **Nuxt 4 / Vue 3** SPA — single route (`pages/index.vue`).
|
|
37
37
|
- **Pinia** (+ `pinia-plugin-persistedstate`) — feature stores.
|
|
38
|
-
- **Vue Flow** (`core`, `background`, `controls`, `
|
|
38
|
+
- **Vue Flow** (`core`, `background`, `controls`, `node-resizer`) — the canvas.
|
|
39
39
|
- **Nuxt UI** + Tailwind — components and styling.
|
|
40
40
|
- **VueUse** — composable utilities.
|
|
41
41
|
- Lint/format via **oxlint** + **oxfmt**; tests via **vitest** + **happy-dom**.
|
package/app/assets/css/main.css
CHANGED
|
@@ -31,6 +31,16 @@ body {
|
|
|
31
31
|
background-color: var(--board-bg);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/* Touch: let the pane own one-finger pan + pinch-zoom rather than the browser
|
|
35
|
+
* treating the drag as a page scroll (which fires `pointercancel` and aborts the
|
|
36
|
+
* gesture mid-flight). The board is full-screen with `body { overflow: hidden }`,
|
|
37
|
+
* so there is nothing to scroll away anyway. The custom drag/resize/connect handles
|
|
38
|
+
* set their own `touch-action: none` (Tailwind `touch-none`) so a handle drag is
|
|
39
|
+
* likewise never stolen by the browser. */
|
|
40
|
+
.vue-flow__pane {
|
|
41
|
+
touch-action: none;
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
/* Status-pulse used by blocked / decision-needed blocks */
|
|
35
45
|
@keyframes board-pulse {
|
|
36
46
|
0%,
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { VueFlow, useVueFlow, type NodeMouseEvent } from '@vue-flow/core'
|
|
3
3
|
import { Background } from '@vue-flow/background'
|
|
4
|
-
import { MiniMap } from '@vue-flow/minimap'
|
|
5
4
|
import BlockNode from './nodes/BlockNode.vue'
|
|
6
5
|
import EpicNode from './nodes/EpicNode.vue'
|
|
7
6
|
import TaskDependencyEdges from './TaskDependencyEdges.vue'
|
|
8
7
|
import DependencyConnectOverlay from './DependencyConnectOverlay.vue'
|
|
9
|
-
import { STATUS_META } from '~/utils/catalog'
|
|
10
8
|
import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
|
|
11
9
|
import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
|
|
12
10
|
import { useTaskExpansion } from '~/composables/useTaskExpansion'
|
|
13
11
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
14
12
|
import { useFrameStacking } from '~/composables/useFrameStacking'
|
|
13
|
+
import { useViewport } from '~/composables/useViewport'
|
|
14
|
+
import { boardPanMode } from '~/utils/boardPanMode'
|
|
15
15
|
|
|
16
16
|
const board = useBoardStore()
|
|
17
17
|
const pipelines = usePipelinesStore()
|
|
@@ -23,6 +23,17 @@ const toast = useToast()
|
|
|
23
23
|
const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(BOARD_FLOW_ID)
|
|
24
24
|
const { draggingId } = useBlockDrag()
|
|
25
25
|
const { hoveredFrameId } = useFrameStacking()
|
|
26
|
+
// Touch drives the canvas gestures: a touch-capable surface needs one-finger pan.
|
|
27
|
+
// We gate on `hasTouch` (any-pointer: coarse), not `isTouch` (the *primary* pointer),
|
|
28
|
+
// so a touchscreen laptop / 2-in-1 — whose primary pointer is the trackpad — still
|
|
29
|
+
// gets finger-panning.
|
|
30
|
+
const { hasTouch } = useViewport()
|
|
31
|
+
|
|
32
|
+
// See `boardPanMode`: the button-array form ([0, 2]) silently blocks one-finger
|
|
33
|
+
// touch panning (a touch `touchstart` has no `event.button`), so we widen it to
|
|
34
|
+
// `true` on any touch-capable surface and keep the precise-pointer restriction on
|
|
35
|
+
// pure-mouse desktops. Extracted as a pure helper so the fix has a unit guard.
|
|
36
|
+
const panOnDrag = computed<boolean | number[]>(() => boardPanMode(hasTouch.value))
|
|
26
37
|
|
|
27
38
|
// Gate which task cards expand their pipeline list on deep zoom: on-screen, and the
|
|
28
39
|
// centre-most of any that would overlap (see useTaskExpansion). Service frames have no
|
|
@@ -97,11 +108,6 @@ function onPaneClick() {
|
|
|
97
108
|
ui.select(null)
|
|
98
109
|
}
|
|
99
110
|
|
|
100
|
-
function minimapColor(node: { id: string }) {
|
|
101
|
-
const b = board.getBlock(node.id)
|
|
102
|
-
return b ? STATUS_META[board.frameStatus(b.id)].color : '#475569'
|
|
103
|
-
}
|
|
104
|
-
|
|
105
111
|
// ---- palette drag & drop onto the canvas ----------------------------------
|
|
106
112
|
function onDragOver(event: DragEvent) {
|
|
107
113
|
event.preventDefault()
|
|
@@ -166,7 +172,7 @@ async function onDrop(event: DragEvent) {
|
|
|
166
172
|
:min-zoom="0.2"
|
|
167
173
|
:max-zoom="3"
|
|
168
174
|
:default-viewport="{ x: 40, y: 20, zoom: 0.85 }"
|
|
169
|
-
:pan-on-drag="
|
|
175
|
+
:pan-on-drag="panOnDrag"
|
|
170
176
|
:elevate-nodes-on-select="false"
|
|
171
177
|
fit-view-on-init
|
|
172
178
|
@node-click="onNodeClick"
|
|
@@ -175,7 +181,10 @@ async function onDrop(event: DragEvent) {
|
|
|
175
181
|
@contextmenu.prevent
|
|
176
182
|
>
|
|
177
183
|
<Background pattern-color="#1e293b" :gap="22" :size="1.4" />
|
|
178
|
-
|
|
184
|
+
<!-- No minimap: it's a precise-pointer affordance (too small to hit on touch,
|
|
185
|
+
eats scarce width on narrow windows) that earned its keep on neither
|
|
186
|
+
desktop nor mobile. The toolbar's zoom-in/out + fit-view controls are the
|
|
187
|
+
camera navigation on every viewport. -->
|
|
179
188
|
|
|
180
189
|
<template #node-block="props">
|
|
181
190
|
<BlockNode :id="props.id" />
|
|
@@ -388,7 +388,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
388
388
|
falls through to the pane (the parent drops `space-y-3` to avoid doubling
|
|
389
389
|
the gap). -->
|
|
390
390
|
<div
|
|
391
|
-
class="nopan cursor-grab space-y-3 pb-3 active:cursor-grabbing"
|
|
391
|
+
class="nopan cursor-grab touch-none space-y-3 pb-3 active:cursor-grabbing"
|
|
392
392
|
title="Drag service"
|
|
393
393
|
@pointerdown="onFrameHandle"
|
|
394
394
|
>
|
|
@@ -485,17 +485,17 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
485
485
|
`nopan` (alongside `nodrag`) so the pane doesn't pan while resizing —
|
|
486
486
|
same reason as the header handle above. -->
|
|
487
487
|
<div
|
|
488
|
-
class="nodrag nopan absolute right-0 top-0 h-full w-2 cursor-ew-resize hover:bg-sky-400/20 pointer-coarse:w-4"
|
|
488
|
+
class="nodrag nopan absolute right-0 top-0 h-full w-2 cursor-ew-resize touch-none hover:bg-sky-400/20 pointer-coarse:w-4"
|
|
489
489
|
title="Drag to resize"
|
|
490
490
|
@pointerdown="onResize($event, 'e')"
|
|
491
491
|
/>
|
|
492
492
|
<div
|
|
493
|
-
class="nodrag nopan absolute bottom-0 left-0 h-2 w-full cursor-ns-resize hover:bg-sky-400/20 pointer-coarse:h-4"
|
|
493
|
+
class="nodrag nopan absolute bottom-0 left-0 h-2 w-full cursor-ns-resize touch-none hover:bg-sky-400/20 pointer-coarse:h-4"
|
|
494
494
|
title="Drag to resize"
|
|
495
495
|
@pointerdown="onResize($event, 's')"
|
|
496
496
|
/>
|
|
497
497
|
<div
|
|
498
|
-
class="nodrag nopan absolute bottom-0 right-0 h-4 w-4 cursor-nwse-resize pointer-coarse:h-11 pointer-coarse:w-11"
|
|
498
|
+
class="nodrag nopan absolute bottom-0 right-0 h-4 w-4 cursor-nwse-resize touch-none pointer-coarse:h-11 pointer-coarse:w-11"
|
|
499
499
|
title="Drag to resize"
|
|
500
500
|
@pointerdown="onResize($event, 'se')"
|
|
501
501
|
>
|
|
@@ -41,7 +41,7 @@ function onHandle(e: PointerEvent) {
|
|
|
41
41
|
>
|
|
42
42
|
<!-- drag handle (`nopan` so the pane doesn't pan on a left-drag from here) -->
|
|
43
43
|
<div
|
|
44
|
-
class="nodrag nopan flex cursor-grab items-center justify-center rounded-t-lg border border-b-0 border-slate-700 bg-slate-800/80 py-px active:cursor-grabbing pointer-coarse:py-2"
|
|
44
|
+
class="nodrag nopan flex cursor-grab touch-none items-center justify-center rounded-t-lg border border-b-0 border-slate-700 bg-slate-800/80 py-px active:cursor-grabbing pointer-coarse:py-2"
|
|
45
45
|
title="Drag task"
|
|
46
46
|
@pointerdown="onHandle"
|
|
47
47
|
>
|
|
@@ -50,7 +50,7 @@ function onResize(e: PointerEvent, edge: 'e' | 's' | 'se') {
|
|
|
50
50
|
>
|
|
51
51
|
<!-- module header / drag handle (`nopan` so a left-drag moves it, not the pane) -->
|
|
52
52
|
<div
|
|
53
|
-
class="nodrag nopan flex h-[30px] cursor-grab items-center gap-1 rounded-t-xl bg-violet-500/15 px-2 active:cursor-grabbing"
|
|
53
|
+
class="nodrag nopan flex h-[30px] cursor-grab touch-none items-center gap-1 rounded-t-xl bg-violet-500/15 px-2 active:cursor-grabbing"
|
|
54
54
|
@pointerdown="onHandle"
|
|
55
55
|
@click.stop="ui.select(moduleId)"
|
|
56
56
|
>
|
|
@@ -76,17 +76,17 @@ function onResize(e: PointerEvent, edge: 'e' | 's' | 'se') {
|
|
|
76
76
|
<!-- resize handles (drag the borders to resize the module, Miro-style).
|
|
77
77
|
`nopan` (with `nodrag`) so resizing doesn't pan the pane. -->
|
|
78
78
|
<div
|
|
79
|
-
class="nodrag nopan absolute right-0 top-0 h-full w-2 cursor-ew-resize hover:bg-violet-400/20 pointer-coarse:w-4"
|
|
79
|
+
class="nodrag nopan absolute right-0 top-0 h-full w-2 cursor-ew-resize touch-none hover:bg-violet-400/20 pointer-coarse:w-4"
|
|
80
80
|
title="Drag to resize"
|
|
81
81
|
@pointerdown="onResize($event, 'e')"
|
|
82
82
|
/>
|
|
83
83
|
<div
|
|
84
|
-
class="nodrag nopan absolute bottom-0 left-0 h-2 w-full cursor-ns-resize hover:bg-violet-400/20 pointer-coarse:h-4"
|
|
84
|
+
class="nodrag nopan absolute bottom-0 left-0 h-2 w-full cursor-ns-resize touch-none hover:bg-violet-400/20 pointer-coarse:h-4"
|
|
85
85
|
title="Drag to resize"
|
|
86
86
|
@pointerdown="onResize($event, 's')"
|
|
87
87
|
/>
|
|
88
88
|
<div
|
|
89
|
-
class="nodrag nopan absolute bottom-0 right-0 h-4 w-4 cursor-nwse-resize pointer-coarse:h-11 pointer-coarse:w-11"
|
|
89
|
+
class="nodrag nopan absolute bottom-0 right-0 h-4 w-4 cursor-nwse-resize touch-none pointer-coarse:h-11 pointer-coarse:w-11"
|
|
90
90
|
title="Drag to resize"
|
|
91
91
|
@pointerdown="onResize($event, 'se')"
|
|
92
92
|
>
|
|
@@ -210,7 +210,7 @@ function selectTask() {
|
|
|
210
210
|
<!-- drag-to-connect handle: drag onto another task to make it depend on this one -->
|
|
211
211
|
<button
|
|
212
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 pointer-coarse:p-2.5"
|
|
213
|
+
class="nodrag shrink-0 cursor-crosshair touch-none rounded-full p-0.5 text-slate-500 hover:bg-slate-800 hover:text-amber-400 pointer-coarse:p-2.5"
|
|
214
214
|
title="Drag onto another task to make it depend on this one"
|
|
215
215
|
@pointerdown.stop="startConnect(task.id, $event)"
|
|
216
216
|
@click.stop
|
|
@@ -89,6 +89,7 @@ const decisionItems = computed(() =>
|
|
|
89
89
|
color="neutral"
|
|
90
90
|
variant="ghost"
|
|
91
91
|
size="sm"
|
|
92
|
+
data-testid="board-zoom-out"
|
|
92
93
|
@click="zoomOut()"
|
|
93
94
|
/>
|
|
94
95
|
<!-- The zoom %/LOD readout is the first thing to drop on narrow viewports. -->
|
|
@@ -96,12 +97,20 @@ const decisionItems = computed(() =>
|
|
|
96
97
|
{{ zoomPct }}%
|
|
97
98
|
<div class="text-[9px] uppercase tracking-wide text-slate-500">{{ lodLabel }}</div>
|
|
98
99
|
</div>
|
|
99
|
-
<UButton
|
|
100
|
+
<UButton
|
|
101
|
+
icon="i-lucide-zoom-in"
|
|
102
|
+
color="neutral"
|
|
103
|
+
variant="ghost"
|
|
104
|
+
size="sm"
|
|
105
|
+
data-testid="board-zoom-in"
|
|
106
|
+
@click="zoomIn()"
|
|
107
|
+
/>
|
|
100
108
|
<UButton
|
|
101
109
|
icon="i-lucide-maximize"
|
|
102
110
|
color="neutral"
|
|
103
111
|
variant="ghost"
|
|
104
112
|
size="sm"
|
|
113
|
+
data-testid="board-fit-view"
|
|
105
114
|
@click="fitView({ padding: 0.2 })"
|
|
106
115
|
/>
|
|
107
116
|
|
|
@@ -7,13 +7,22 @@ import { useBreakpoints, breakpointsTailwind, useMediaQuery } from '@vueuse/core
|
|
|
7
7
|
* (1024px), matching the breakpoint the already-responsive surfaces use
|
|
8
8
|
* (`AgentStepDetail`, the review windows) so the whole shell stays consistent.
|
|
9
9
|
*
|
|
10
|
-
* `isTouch` reports
|
|
11
|
-
* hit targets without affecting precise-pointer (mouse)
|
|
12
|
-
* width, since a small window on a desktop is compact but
|
|
10
|
+
* `isTouch` reports that the *primary* pointer is coarse (phones/tablets) so a
|
|
11
|
+
* component can enlarge hit targets without affecting precise-pointer (mouse)
|
|
12
|
+
* desktops — orthogonal to width, since a small window on a desktop is compact but
|
|
13
|
+
* not touch.
|
|
14
|
+
*
|
|
15
|
+
* `hasTouch` reports that *any* available pointer is coarse. It is the right check
|
|
16
|
+
* for "can this surface be touched at all" — e.g. a touchscreen laptop or a
|
|
17
|
+
* 2-in-1, whose *primary* pointer is the trackpad (`fine`, so `isTouch` is false)
|
|
18
|
+
* but which can still be finger-panned. Use it for behaviour that must work the
|
|
19
|
+
* moment a finger is on the glass (the board's one-finger pan); use `isTouch` for
|
|
20
|
+
* the dominant-modality choices (hit-target sizing).
|
|
13
21
|
*/
|
|
14
22
|
export function useViewport() {
|
|
15
23
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
|
16
24
|
const isCompact = breakpoints.smaller('lg')
|
|
17
25
|
const isTouch = useMediaQuery('(pointer: coarse)')
|
|
18
|
-
|
|
26
|
+
const hasTouch = useMediaQuery('(any-pointer: coarse)')
|
|
27
|
+
return { isCompact, isTouch, hasTouch }
|
|
19
28
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { boardPanMode } from './boardPanMode'
|
|
3
|
+
|
|
4
|
+
describe('boardPanMode', () => {
|
|
5
|
+
it('widens to `true` when the surface can be touched (one-finger pan)', () => {
|
|
6
|
+
// The whole point of the fix: a touch `touchstart` has no `event.button`, so the
|
|
7
|
+
// `[0, 2]` button list rejects it and single-finger panning is dead. `true` is the
|
|
8
|
+
// only form Vue Flow's d3-zoom filter accepts for a button-less touch.
|
|
9
|
+
expect(boardPanMode(true)).toBe(true)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('keeps the precise-pointer button list (left/right-drag, never middle) on mouse', () => {
|
|
13
|
+
expect(boardPanMode(false)).toEqual([0, 2])
|
|
14
|
+
// Middle-drag (button 1) must never pan.
|
|
15
|
+
expect(boardPanMode(false)).not.toContain(1)
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision for Vue Flow's `pan-on-drag` prop on the board canvas.
|
|
3
|
+
*
|
|
4
|
+
* Vue Flow's d3-zoom filter restricts panning to the mouse buttons listed in the
|
|
5
|
+
* array form: a `pointerdown`/`touchstart` whose `event.button` isn't in the list is
|
|
6
|
+
* rejected. A touch `touchstart` carries no `event.button` (it's `undefined`), so the
|
|
7
|
+
* precise-pointer button list `[0, 2]` (left/right-drag, never middle) silently blocks
|
|
8
|
+
* one-finger panning — the touchstart never matches a listed button.
|
|
9
|
+
*
|
|
10
|
+
* So when the surface can be touched at all we widen the prop to `true` (any pointer
|
|
11
|
+
* pans the pane; pinch-zoom stays on by default), and otherwise keep the
|
|
12
|
+
* precise-pointer button restriction for mouse desktops. This is split out as a pure
|
|
13
|
+
* function so the headline touch-pan fix has a non-flaky unit guard — driving real
|
|
14
|
+
* touch gestures through the canvas is explicitly out of scope for the e2e suite.
|
|
15
|
+
*
|
|
16
|
+
* @param canTouch whether any available pointer is coarse (see `useViewport().hasTouch`)
|
|
17
|
+
*/
|
|
18
|
+
export function boardPanMode(canTouch: boolean): true | number[] {
|
|
19
|
+
return canTouch ? true : [0, 2]
|
|
20
|
+
}
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.46.
|
|
3
|
+
"version": "0.46.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",
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
"@toad-contracts/valibot": "0.3.1",
|
|
27
27
|
"@vue-flow/background": "^1.3.2",
|
|
28
28
|
"@vue-flow/core": "^1.48.2",
|
|
29
|
-
"@vue-flow/minimap": "^1.5.4",
|
|
30
29
|
"@vue-flow/node-resizer": "^1.5.1",
|
|
31
30
|
"@vueuse/core": "^14.3.0",
|
|
32
31
|
"markdown-it": "^14.2.0",
|