@cat-factory/app 0.96.4 → 0.96.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.
- package/app/components/board/BoardCanvas.vue +28 -7
- package/app/components/layout/BoardToolbar.vue +29 -9
- package/app/composables/useBoardFlow.ts +9 -2
- package/app/utils/dnd.ts +3 -2
- package/i18n/locales/en.json +5 -1
- package/i18n/locales/es.json +5 -1
- package/i18n/locales/fr.json +5 -1
- package/i18n/locales/he.json +5 -1
- package/i18n/locales/ja.json +5 -1
- package/i18n/locales/pl.json +5 -1
- package/i18n/locales/tr.json +5 -1
- package/i18n/locales/uk.json +5 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ import EpicNode from './nodes/EpicNode.vue'
|
|
|
6
6
|
import TaskDependencyEdges from './TaskDependencyEdges.vue'
|
|
7
7
|
import DependencyConnectOverlay from './DependencyConnectOverlay.vue'
|
|
8
8
|
import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
|
|
9
|
-
import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
|
|
9
|
+
import { BOARD_FLOW_ID, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
|
|
10
10
|
import { useTaskExpansion } from '~/composables/useTaskExpansion'
|
|
11
11
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
12
12
|
import { useFrameStacking } from '~/composables/useFrameStacking'
|
|
@@ -102,9 +102,20 @@ function onNodeClick({ node }: NodeMouseEvent) {
|
|
|
102
102
|
ui.select(node.id)
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function onNodeDoubleClick({ node }: NodeMouseEvent) {
|
|
106
|
-
//
|
|
107
|
-
|
|
105
|
+
function onNodeDoubleClick({ event, node }: NodeMouseEvent) {
|
|
106
|
+
// Task cards live *inside* their frame node, so Vue Flow reports the frame here even
|
|
107
|
+
// when the double-click landed on a task. Resolve the real target from the DOM: a
|
|
108
|
+
// double-click on a task opens its focus view (the same "open this task" gesture as the
|
|
109
|
+
// card's review action), while a double-click on frame chrome centres the camera on the
|
|
110
|
+
// frame and zooms it in. Epics aren't containers, so their double-click stays a no-op.
|
|
111
|
+
const targetId = blockIdFromEvent(event)
|
|
112
|
+
const target = targetId ? board.getBlock(targetId) : undefined
|
|
113
|
+
if (target?.level === 'task') {
|
|
114
|
+
ui.select(target.id)
|
|
115
|
+
ui.focus(target.id)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
if (node.type === 'block') void focusFrame(node.id)
|
|
108
119
|
}
|
|
109
120
|
|
|
110
121
|
function onPaneClick() {
|
|
@@ -147,7 +158,17 @@ async function onDrop(event: DragEvent) {
|
|
|
147
158
|
const blockId = blockIdFromEvent(event)
|
|
148
159
|
const target = blockId ? board.getBlock(blockId) : undefined
|
|
149
160
|
const pipeline = pipelines.getPipeline(payload.pipelineId)
|
|
150
|
-
|
|
161
|
+
// Unknown pipeline id is an internal glitch (nothing the user can act on); a drop
|
|
162
|
+
// onto blank canvas / a non-block, though, needs the same "aim at a task" nudge the
|
|
163
|
+
// wrong-level path gives — otherwise the drop just vanishes (UX-07).
|
|
164
|
+
if (!pipeline) return
|
|
165
|
+
if (!target) {
|
|
166
|
+
toast.add({
|
|
167
|
+
title: t('board.canvas.dropOntoTaskTitle'),
|
|
168
|
+
description: t('board.canvas.dropOntoTaskBody'),
|
|
169
|
+
})
|
|
170
|
+
return
|
|
171
|
+
}
|
|
151
172
|
if (target.level !== 'task') {
|
|
152
173
|
toast.add({
|
|
153
174
|
title: t('board.canvas.dropOntoTaskTitle'),
|
|
@@ -179,8 +200,8 @@ async function onDrop(event: DragEvent) {
|
|
|
179
200
|
<VueFlow
|
|
180
201
|
:id="BOARD_FLOW_ID"
|
|
181
202
|
:nodes="nodes"
|
|
182
|
-
:min-zoom="
|
|
183
|
-
:max-zoom="
|
|
203
|
+
:min-zoom="BOARD_MIN_ZOOM"
|
|
204
|
+
:max-zoom="BOARD_MAX_ZOOM"
|
|
184
205
|
:default-viewport="{ x: 40, y: 20, zoom: 0.85 }"
|
|
185
206
|
:pan-on-drag="panOnDrag"
|
|
186
207
|
:elevate-nodes-on-select="false"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { useBoardFlow } from '~/composables/useBoardFlow'
|
|
2
|
+
import { useBoardFlow, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
|
|
3
3
|
import NotificationsInbox from '~/components/layout/NotificationsInbox.vue'
|
|
4
|
+
import IconButton from '~/components/common/IconButton.vue'
|
|
4
5
|
|
|
5
6
|
const ui = useUiStore()
|
|
6
7
|
const board = useBoardStore()
|
|
@@ -10,7 +11,7 @@ const workspaceSettings = useWorkspaceSettingsStore()
|
|
|
10
11
|
const services = useServicesStore()
|
|
11
12
|
const toast = useToast()
|
|
12
13
|
const { t, n } = useI18n()
|
|
13
|
-
const { fitView, zoomIn, zoomOut } = useBoardFlow()
|
|
14
|
+
const { fitView, zoomIn, zoomOut, resetZoom } = useBoardFlow()
|
|
14
15
|
|
|
15
16
|
async function mountService(serviceId: string, title: string) {
|
|
16
17
|
try {
|
|
@@ -44,6 +45,10 @@ const mountableItems = computed(() =>
|
|
|
44
45
|
)
|
|
45
46
|
|
|
46
47
|
const zoomPct = computed(() => Math.round(ui.zoom * 100))
|
|
48
|
+
// Disable the zoom buttons once the camera hits a clamp (Vue Flow pins the zoom exactly
|
|
49
|
+
// at the limit, so a small epsilon guards against float drift). UX-16.
|
|
50
|
+
const atMinZoom = computed(() => ui.zoom <= BOARD_MIN_ZOOM + 0.001)
|
|
51
|
+
const atMaxZoom = computed(() => ui.zoom >= BOARD_MAX_ZOOM - 0.001)
|
|
47
52
|
// Exhaustive (tier-2) map from level-of-detail → its label key, so adding an LOD
|
|
48
53
|
// without a label fails the typecheck rather than leaking a raw key.
|
|
49
54
|
const LOD_LABEL_KEYS = {
|
|
@@ -99,11 +104,13 @@ const decisionItems = computed(() =>
|
|
|
99
104
|
class="absolute left-1/2 top-3 z-20 flex max-w-[calc(100vw-1rem)] -translate-x-1/2 items-center gap-1 overflow-x-auto rounded-full border border-slate-700 bg-slate-900/90 px-2 py-1.5 shadow-xl backdrop-blur"
|
|
100
105
|
>
|
|
101
106
|
<!-- zoom controls -->
|
|
102
|
-
<
|
|
107
|
+
<IconButton
|
|
108
|
+
:label="t('board.toolbar.zoomOut')"
|
|
103
109
|
icon="i-lucide-zoom-out"
|
|
104
110
|
color="neutral"
|
|
105
111
|
variant="ghost"
|
|
106
112
|
size="sm"
|
|
113
|
+
:disabled="atMinZoom"
|
|
107
114
|
data-testid="board-zoom-out"
|
|
108
115
|
@click="
|
|
109
116
|
() => {
|
|
@@ -111,16 +118,28 @@ const decisionItems = computed(() =>
|
|
|
111
118
|
}
|
|
112
119
|
"
|
|
113
120
|
/>
|
|
114
|
-
<!--
|
|
115
|
-
|
|
121
|
+
<!-- Click the readout to snap back to 100%. Always visible (only the LOD sub-label
|
|
122
|
+
drops on narrow viewports) so the zoom level is never a mystery. -->
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
class="w-16 rounded text-center text-xs tabular-nums text-slate-300 hover:bg-slate-800 focus-visible:ring-2 focus-visible:ring-slate-400/60 sm:w-20"
|
|
126
|
+
:title="t('board.toolbar.resetZoom')"
|
|
127
|
+
:aria-label="t('board.toolbar.resetZoom')"
|
|
128
|
+
data-testid="board-zoom-reset"
|
|
129
|
+
@click="resetZoom()"
|
|
130
|
+
>
|
|
116
131
|
{{ zoomPct }}%
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
<span class="hidden text-[9px] uppercase tracking-wide text-slate-500 sm:block">{{
|
|
133
|
+
lodLabel
|
|
134
|
+
}}</span>
|
|
135
|
+
</button>
|
|
136
|
+
<IconButton
|
|
137
|
+
:label="t('board.toolbar.zoomIn')"
|
|
120
138
|
icon="i-lucide-zoom-in"
|
|
121
139
|
color="neutral"
|
|
122
140
|
variant="ghost"
|
|
123
141
|
size="sm"
|
|
142
|
+
:disabled="atMaxZoom"
|
|
124
143
|
data-testid="board-zoom-in"
|
|
125
144
|
@click="
|
|
126
145
|
() => {
|
|
@@ -128,7 +147,8 @@ const decisionItems = computed(() =>
|
|
|
128
147
|
}
|
|
129
148
|
"
|
|
130
149
|
/>
|
|
131
|
-
<
|
|
150
|
+
<IconButton
|
|
151
|
+
:label="t('board.toolbar.fitView')"
|
|
132
152
|
icon="i-lucide-maximize"
|
|
133
153
|
color="neutral"
|
|
134
154
|
variant="ghost"
|
|
@@ -4,8 +4,15 @@ import { useVueFlow } from '@vue-flow/core'
|
|
|
4
4
|
* useVueFlow() from anywhere (e.g. the toolbar) accesses the same instance. */
|
|
5
5
|
export const BOARD_FLOW_ID = 'board'
|
|
6
6
|
|
|
7
|
+
/** Camera zoom clamps — the single source of truth shared by the canvas (which passes
|
|
8
|
+
* them to <VueFlow>) and the toolbar (which disables its zoom buttons at the limits). */
|
|
9
|
+
export const BOARD_MIN_ZOOM = 0.2
|
|
10
|
+
export const BOARD_MAX_ZOOM = 3
|
|
11
|
+
|
|
7
12
|
/** Camera controls for the main board, usable outside the canvas component. */
|
|
8
13
|
export function useBoardFlow() {
|
|
9
|
-
const { fitView, zoomIn, zoomOut, viewport } = useVueFlow(BOARD_FLOW_ID)
|
|
10
|
-
|
|
14
|
+
const { fitView, zoomIn, zoomOut, zoomTo, viewport } = useVueFlow(BOARD_FLOW_ID)
|
|
15
|
+
/** Snap the camera back to 100% zoom (keeps the current centre). */
|
|
16
|
+
const resetZoom = () => zoomTo(1, { duration: 250 })
|
|
17
|
+
return { fitView, zoomIn, zoomOut, zoomTo, resetZoom, viewport }
|
|
11
18
|
}
|
package/app/utils/dnd.ts
CHANGED
|
@@ -22,8 +22,9 @@ export function readDndPayload(event: DragEvent): DndPayload | null {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/** Walk up from
|
|
26
|
-
|
|
25
|
+
/** Walk up from an event's target to find the block it landed on, if any. Works for any
|
|
26
|
+
* DOM event (drop, double-click, …) — only `event.target` is read. */
|
|
27
|
+
export function blockIdFromEvent(event: Event): string | null {
|
|
27
28
|
const el = (event.target as HTMLElement | null)?.closest('[data-block-id]')
|
|
28
29
|
return el?.getAttribute('data-block-id') ?? null
|
|
29
30
|
}
|
package/i18n/locales/en.json
CHANGED
|
@@ -126,7 +126,11 @@
|
|
|
126
126
|
"spendTitle": "Token spend this month",
|
|
127
127
|
"spendLimitReached": "Spend limit reached — runs paused",
|
|
128
128
|
"serviceAdded": "Added {title}",
|
|
129
|
-
"serviceAddFailed": "Could not add service"
|
|
129
|
+
"serviceAddFailed": "Could not add service",
|
|
130
|
+
"zoomOut": "Zoom out",
|
|
131
|
+
"zoomIn": "Zoom in",
|
|
132
|
+
"fitView": "Fit board to content",
|
|
133
|
+
"resetZoom": "Reset zoom to 100%"
|
|
130
134
|
},
|
|
131
135
|
"canvas": {
|
|
132
136
|
"emptyTitle": "Your board is empty",
|
package/i18n/locales/es.json
CHANGED
|
@@ -108,7 +108,11 @@
|
|
|
108
108
|
"spendTitle": "Gasto de tokens este mes",
|
|
109
109
|
"spendLimitReached": "Límite de gasto alcanzado: ejecuciones en pausa",
|
|
110
110
|
"serviceAdded": "Se añadió {title}",
|
|
111
|
-
"serviceAddFailed": "No se pudo añadir el servicio"
|
|
111
|
+
"serviceAddFailed": "No se pudo añadir el servicio",
|
|
112
|
+
"zoomOut": "Alejar",
|
|
113
|
+
"zoomIn": "Acercar",
|
|
114
|
+
"fitView": "Ajustar al contenido",
|
|
115
|
+
"resetZoom": "Restablecer zoom al 100%"
|
|
112
116
|
},
|
|
113
117
|
"canvas": {
|
|
114
118
|
"emptyTitle": "Tu tablero está vacío",
|
package/i18n/locales/fr.json
CHANGED
|
@@ -108,7 +108,11 @@
|
|
|
108
108
|
"spendTitle": "Dépense de tokens ce mois-ci",
|
|
109
109
|
"spendLimitReached": "Limite de dépense atteinte : exécutions en pause",
|
|
110
110
|
"serviceAdded": "{title} ajouté",
|
|
111
|
-
"serviceAddFailed": "Impossible d’ajouter le service"
|
|
111
|
+
"serviceAddFailed": "Impossible d’ajouter le service",
|
|
112
|
+
"zoomOut": "Dézoomer",
|
|
113
|
+
"zoomIn": "Zoomer",
|
|
114
|
+
"fitView": "Ajuster au contenu",
|
|
115
|
+
"resetZoom": "Réinitialiser le zoom à 100 %"
|
|
112
116
|
},
|
|
113
117
|
"canvas": {
|
|
114
118
|
"emptyTitle": "Votre tableau est vide",
|
package/i18n/locales/he.json
CHANGED
|
@@ -108,7 +108,11 @@
|
|
|
108
108
|
"spendTitle": "צריכת טוקנים החודש",
|
|
109
109
|
"spendLimitReached": "הוגעה למגבלת הצריכה — הריצות הושהו",
|
|
110
110
|
"serviceAdded": "{title} נוסף",
|
|
111
|
-
"serviceAddFailed": "לא ניתן היה להוסיף את השירות"
|
|
111
|
+
"serviceAddFailed": "לא ניתן היה להוסיף את השירות",
|
|
112
|
+
"zoomOut": "הקטנה",
|
|
113
|
+
"zoomIn": "הגדלה",
|
|
114
|
+
"fitView": "התאמה לתוכן",
|
|
115
|
+
"resetZoom": "איפוס התקריב ל-100%"
|
|
112
116
|
},
|
|
113
117
|
"canvas": {
|
|
114
118
|
"emptyTitle": "הלוח שלך ריק",
|
package/i18n/locales/ja.json
CHANGED
|
@@ -108,7 +108,11 @@
|
|
|
108
108
|
"spendTitle": "今月のトークン消費量",
|
|
109
109
|
"spendLimitReached": "消費上限に達しました。実行を一時停止しています",
|
|
110
110
|
"serviceAdded": "{title} を追加しました",
|
|
111
|
-
"serviceAddFailed": "サービスを追加できませんでした"
|
|
111
|
+
"serviceAddFailed": "サービスを追加できませんでした",
|
|
112
|
+
"zoomOut": "縮小",
|
|
113
|
+
"zoomIn": "拡大",
|
|
114
|
+
"fitView": "内容に合わせる",
|
|
115
|
+
"resetZoom": "ズームを100%にリセット"
|
|
112
116
|
},
|
|
113
117
|
"canvas": {
|
|
114
118
|
"emptyTitle": "ボードが空です",
|
package/i18n/locales/pl.json
CHANGED
|
@@ -108,7 +108,11 @@
|
|
|
108
108
|
"spendTitle": "Wydatki na tokeny w tym miesiącu",
|
|
109
109
|
"spendLimitReached": "Osiągnięto limit wydatków: uruchomienia wstrzymane",
|
|
110
110
|
"serviceAdded": "Dodano {title}",
|
|
111
|
-
"serviceAddFailed": "Nie udało się dodać usługi"
|
|
111
|
+
"serviceAddFailed": "Nie udało się dodać usługi",
|
|
112
|
+
"zoomOut": "Pomniejsz",
|
|
113
|
+
"zoomIn": "Powiększ",
|
|
114
|
+
"fitView": "Dopasuj do zawartości",
|
|
115
|
+
"resetZoom": "Zresetuj powiększenie do 100%"
|
|
112
116
|
},
|
|
113
117
|
"canvas": {
|
|
114
118
|
"emptyTitle": "Twoja tablica jest pusta",
|
package/i18n/locales/tr.json
CHANGED
|
@@ -108,7 +108,11 @@
|
|
|
108
108
|
"spendTitle": "Bu ayki token harcaması",
|
|
109
109
|
"spendLimitReached": "Harcama limitine ulaşıldı, çalıştırmalar duraklatıldı",
|
|
110
110
|
"serviceAdded": "{title} eklendi",
|
|
111
|
-
"serviceAddFailed": "Servis eklenemedi"
|
|
111
|
+
"serviceAddFailed": "Servis eklenemedi",
|
|
112
|
+
"zoomOut": "Uzaklaştır",
|
|
113
|
+
"zoomIn": "Yakınlaştır",
|
|
114
|
+
"fitView": "İçeriğe sığdır",
|
|
115
|
+
"resetZoom": "Yakınlaştırmayı %100'e sıfırla"
|
|
112
116
|
},
|
|
113
117
|
"canvas": {
|
|
114
118
|
"emptyTitle": "Panonuz boş",
|
package/i18n/locales/uk.json
CHANGED
|
@@ -108,7 +108,11 @@
|
|
|
108
108
|
"spendTitle": "Витрати на токени цього місяця",
|
|
109
109
|
"spendLimitReached": "Досягнуто ліміт витрат: запуски призупинено",
|
|
110
110
|
"serviceAdded": "Додано {title}",
|
|
111
|
-
"serviceAddFailed": "Не вдалося додати сервіс"
|
|
111
|
+
"serviceAddFailed": "Не вдалося додати сервіс",
|
|
112
|
+
"zoomOut": "Зменшити",
|
|
113
|
+
"zoomIn": "Збільшити",
|
|
114
|
+
"fitView": "Вписати в область",
|
|
115
|
+
"resetZoom": "Скинути масштаб до 100%"
|
|
112
116
|
},
|
|
113
117
|
"canvas": {
|
|
114
118
|
"emptyTitle": "Ваша дошка порожня",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.96.
|
|
3
|
+
"version": "0.96.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",
|