@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 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`, `minimap`, `node-resizer`) — the canvas.
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**.
@@ -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="[0, 2]"
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
- <MiniMap pannable zoomable :node-color="minimapColor" class="!bg-slate-900/80" />
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 icon="i-lucide-zoom-in" color="neutral" variant="ghost" size="sm" @click="zoomIn()" />
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 a coarse pointer (phones/tablets) so a component can enlarge
11
- * hit targets without affecting precise-pointer (mouse) desktops — orthogonal to
12
- * width, since a small window on a desktop is compact but not touch.
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
- return { isCompact, isTouch }
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
@@ -77,7 +77,6 @@ export default defineNuxtConfig({
77
77
  css: [
78
78
  '@vue-flow/core/dist/style.css',
79
79
  '@vue-flow/core/dist/theme-default.css',
80
- '@vue-flow/minimap/dist/style.css',
81
80
  '@vue-flow/node-resizer/dist/style.css',
82
81
  join(layerDir, 'app/assets/css/main.css'),
83
82
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.46.3",
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",