@cat-factory/app 0.46.2 → 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
+ }
@@ -5,10 +5,32 @@
5
5
  // Locale MESSAGES are NOT defined here — they live in `i18n/locales/*.json` so the
6
6
  // module can deep-merge them across the `extends` layer chain. This file carries only
7
7
  // the runtime vue-i18n behaviour (fallback, number/date formats) shared by every locale.
8
+ // Slavic one/few/many plural selector (CLDR rule for Polish & Ukrainian), returning the
9
+ // 0|1|2 index into a 3-form `"one | few | many"` message. vue-i18n's BUILT-IN pluralizer
10
+ // only ever picks index 0 (n===1) or 1/2 by a non-Slavic rule, so without this the pl/uk
11
+ // 3-form catalog entries (e.g. board.toolbar.decisionWord "decyzja | decyzje | decyzji")
12
+ // render the WRONG form for counts like 2-4 and 22-24. `choicesLength` is unused — the
13
+ // three forms are assumed; en/es/fr keep the default 2-form behaviour (not listed here).
14
+ const slavicPluralRule = (choice: number): number => {
15
+ const n = Math.abs(choice)
16
+ const mod10 = n % 10
17
+ const mod100 = n % 100
18
+ if (n === 1) return 0 // one
19
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 1 // few
20
+ return 2 // many (incl. 0, 5-21, …)
21
+ }
22
+
8
23
  export default defineI18nConfig(() => ({
9
24
  legacy: false,
10
25
  fallbackLocale: 'en',
11
26
 
27
+ // Per-locale plural selectors. Only the Slavic locales need overriding; the others use
28
+ // vue-i18n's default (correct for their 2-form catalogs).
29
+ pluralRules: {
30
+ pl: slavicPluralRule,
31
+ uk: slavicPluralRule,
32
+ },
33
+
12
34
  // Locale-aware number/currency formatting. Use `$n(value, 'currency')` etc. at call
13
35
  // sites instead of a raw `Intl.NumberFormat`; `$n`/`$d` are thin `Intl` wrappers so
14
36
  // `en` behaviour is identical. `currency` style needs a `currency` override per call
@@ -13,7 +13,10 @@
13
13
  "cancel": "Cancel",
14
14
  "retry": "Retry",
15
15
  "actionFailed": "Action failed",
16
- "close": "Close"
16
+ "close": "Close",
17
+ "@close": {
18
+ "description": "Verb meaning dismiss / shut (a panel, drawer, dialog), NOT the adjective 'near'. Used as an aria-label on close affordances - translate as the imperative verb."
19
+ }
17
20
  },
18
21
  "nav": {
19
22
  "menu": "Navigation menu",
@@ -27,18 +30,27 @@
27
30
  "bootstrapRepo": "Bootstrap repo",
28
31
  "integrations": "Integrations",
29
32
  "sandbox": "Sandbox",
33
+ "@sandbox": {
34
+ "description": "Named feature area (a screen for trying prompt versions / models against graded fixtures). Fine to localize descriptively per locale - unlike nav.kaizen, this one is NOT kept verbatim."
35
+ },
30
36
  "kaizen": "Kaizen",
37
+ "@kaizen": {
38
+ "description": "Proper-noun product feature name (grading history + verified prompt/agent/model combos). Do NOT translate - keep 'Kaizen' verbatim in every locale."
39
+ },
31
40
  "workspaceContext": "Workspace context",
32
41
  "contextFragments": "Context fragments",
33
42
  "configuration": "Configuration",
34
43
  "workspaceSettings": "Workspace settings",
35
- "modelConfiguration": "Model Configuration",
44
+ "modelConfiguration": "Model configuration",
36
45
  "accountSettings": "Account settings"
37
46
  },
38
47
  "board": {
39
48
  "toolbar": {
40
49
  "addService": "Add service",
41
- "decisionWord": "decision | decisions"
50
+ "decisionWord": "decision | decisions",
51
+ "@decisionWord": {
52
+ "description": "Count-based plural noun rendered AFTER a number, e.g. '3 decisions' (pending human decisions in the run queue). Resolved via t(key, count); count is always 1 or more. Provide ALL plural forms your language needs (English has 2; Polish/Ukrainian need 3 - one/few/many - and rely on the custom pluralRules wired in i18n.config.ts)."
53
+ }
42
54
  }
43
55
  },
44
56
  "errors": {
@@ -53,8 +65,14 @@
53
65
  "dependencies_unmet": "Blocked by dependencies",
54
66
  "task_limit_reached": "Concurrency limit reached",
55
67
  "tester_infra_unsupported": "Test infrastructure not configured",
68
+ "@tester_infra_unsupported": {
69
+ "description": "Conflict-toast title for the Tester step's start gate. UMBRELLA over two cases not visible from the string: (a) the service has no test infra configured, AND (b) this deployment's container runtime can't run local test infra (limited mode). The detailed description carries the specific cause; keep the title broad ('not configured / unavailable')."
70
+ },
56
71
  "agent_backend_unconfigured": "Agent backend not configured",
57
72
  "run_not_retryable": "Run can’t be retried",
73
+ "@run_not_retryable": {
74
+ "description": "'Run' is a NOUN here = a pipeline execution (only a failed run can be retried). Translate it as the execution noun, not the verb 'to run'."
75
+ },
58
76
  "no_pr_to_merge": "No PR to merge",
59
77
  "github_not_connected": "GitHub not connected",
60
78
  "bootstrap_not_retryable": "Bootstrap can’t be retried",
@@ -64,6 +82,9 @@
64
82
  "providersUnconfigured": {
65
83
  "title": "No AI provider for this model",
66
84
  "body": "No provider is configured for {models}. Add a provider key, connect a subscription, or enable Cloudflare AI to run it.",
85
+ "@body": {
86
+ "description": "Keep the named placeholder for the model list intact (a comma-separated list of model ids is injected at runtime)."
87
+ },
67
88
  "action": "Configure AI"
68
89
  }
69
90
  }
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.2",
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",