@cat-factory/app 0.193.0 → 0.194.0
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/nodes/BlockNode.vue +28 -46
- package/app/components/board/nodes/ModuleFrame.vue +4 -33
- package/app/components/board/nodes/ResizeGrips.vue +130 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +34 -0
- package/app/composables/api/board.ts +7 -1
- package/app/composables/api/context.ts +2 -0
- package/app/composables/useFrameResize.ts +116 -22
- package/app/stores/board/placement.ts +64 -1
- package/app/stores/board.spec.ts +51 -0
- package/app/types/requirements.ts +1 -0
- package/i18n/locales/de.json +7 -1
- package/i18n/locales/en.json +11 -1
- package/i18n/locales/es.json +7 -1
- package/i18n/locales/fr.json +7 -1
- package/i18n/locales/he.json +7 -1
- package/i18n/locales/it.json +7 -1
- package/i18n/locales/ja.json +7 -1
- package/i18n/locales/pl.json +7 -1
- package/i18n/locales/tr.json +7 -1
- package/i18n/locales/uk.json +7 -1
- package/package.json +2 -2
|
@@ -5,10 +5,10 @@ import DecisionBadge from './DecisionBadge.vue'
|
|
|
5
5
|
import DraggableTask from './DraggableTask.vue'
|
|
6
6
|
import InitiativeCard from './InitiativeCard.vue'
|
|
7
7
|
import ModuleFrame from './ModuleFrame.vue'
|
|
8
|
+
import ResizeGrips from './ResizeGrips.vue'
|
|
8
9
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
9
10
|
import AgentStopButton from '~/components/board/AgentStopButton.vue'
|
|
10
11
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
11
|
-
import { useFrameResize } from '~/composables/useFrameResize'
|
|
12
12
|
import { useFrameStacking } from '~/composables/useFrameStacking'
|
|
13
13
|
import { useViewport } from '~/composables/useViewport'
|
|
14
14
|
|
|
@@ -148,14 +148,6 @@ function onFrameHandle(e: PointerEvent) {
|
|
|
148
148
|
// (see useFrameStacking + BoardCanvas's frameZIndex).
|
|
149
149
|
const { enter: enterFrame, leave: leaveFrame } = useFrameStacking()
|
|
150
150
|
|
|
151
|
-
// Miro-style frame resizing: drag the right / bottom edges or the corner. Handles
|
|
152
|
-
// live on the expanded card's drop zone (see template); the composable clamps to
|
|
153
|
-
// the frame's content extent and persists the size on release.
|
|
154
|
-
const { startResize } = useFrameResize()
|
|
155
|
-
function onResize(e: PointerEvent, edge: 'e' | 's' | 'se') {
|
|
156
|
-
if (block.value) startResize(block.value, e, edge)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
151
|
function addTask() {
|
|
160
152
|
ui.expandFrame(props.id)
|
|
161
153
|
ui.openAddTask(props.id)
|
|
@@ -385,7 +377,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
385
377
|
<!-- ===================== EXPANDED: 2D canvas of tasks + modules ===================== -->
|
|
386
378
|
<div
|
|
387
379
|
v-else
|
|
388
|
-
class="overflow-visible rounded-2xl border bg-slate-900/95 shadow-2xl backdrop-blur"
|
|
380
|
+
class="relative overflow-visible rounded-2xl border bg-slate-900/95 shadow-2xl backdrop-blur"
|
|
389
381
|
:class="[selected ? 'border-white' : 'border-slate-700', pulseClass]"
|
|
390
382
|
>
|
|
391
383
|
<div class="h-1.5 w-full rounded-t-2xl" :style="{ backgroundColor: accent }" />
|
|
@@ -563,16 +555,23 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
563
555
|
</div>
|
|
564
556
|
</div>
|
|
565
557
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
558
|
+
<!-- Composition line. It deliberately does NOT carry a done/total tally: the
|
|
559
|
+
per-task status is already on every card in the canvas below, so the
|
|
560
|
+
frame-level "N/M implemented" was a second, coarser answer to a question
|
|
561
|
+
the canvas answers precisely — and the one people misread, since it counts
|
|
562
|
+
every task ever added to the service rather than the work in flight. What
|
|
563
|
+
stays is what the canvas can't show at a glance. -->
|
|
564
|
+
<div
|
|
565
|
+
v-if="modules.length || prTasks"
|
|
566
|
+
class="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500"
|
|
567
|
+
>
|
|
568
|
+
<span v-if="modules.length">{{
|
|
569
|
+
t('board.frame.moduleCount', { count: modules.length }, modules.length)
|
|
570
|
+
}}</span>
|
|
571
|
+
<span v-if="modules.length && prTasks" aria-hidden="true">·</span>
|
|
572
|
+
<span v-if="prTasks" class="text-emerald-400">{{
|
|
573
|
+
t('board.frame.prReadyCount', { count: prTasks })
|
|
569
574
|
}}</span>
|
|
570
|
-
<span v-if="modules.length"
|
|
571
|
-
>· {{ t('board.frame.moduleCount', { count: modules.length }, modules.length) }}</span
|
|
572
|
-
>
|
|
573
|
-
<span v-if="prTasks" class="text-emerald-400"
|
|
574
|
-
>· {{ t('board.frame.prReadyCount', { count: prTasks }) }}</span
|
|
575
|
-
>
|
|
576
575
|
</div>
|
|
577
576
|
</div>
|
|
578
577
|
|
|
@@ -594,35 +593,18 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
594
593
|
>
|
|
595
594
|
<UIcon name="i-lucide-plus" class="h-3.5 w-3.5" /> {{ t('board.frame.addFirstTask') }}
|
|
596
595
|
</button>
|
|
597
|
-
|
|
598
|
-
<!-- resize handles (drag the borders to resize the service, Miro-style).
|
|
599
|
-
`nopan` (alongside `nodrag`) so the pane doesn't pan while resizing —
|
|
600
|
-
same reason as the header handle above. These stay PHYSICAL
|
|
601
|
-
(`right-0`, not `end-0`): the resize math in useFrameResize grows the
|
|
602
|
-
right/bottom edge from an unmirrored clientX/clientY delta, so a
|
|
603
|
-
logical (RTL-flipped) grip would render on the opposite edge from the
|
|
604
|
-
one the drag actually moves. -->
|
|
605
|
-
<div
|
|
606
|
-
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"
|
|
607
|
-
:title="t('board.frame.dragToResize')"
|
|
608
|
-
@pointerdown="onResize($event, 'e')"
|
|
609
|
-
/>
|
|
610
|
-
<div
|
|
611
|
-
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"
|
|
612
|
-
:title="t('board.frame.dragToResize')"
|
|
613
|
-
@pointerdown="onResize($event, 's')"
|
|
614
|
-
/>
|
|
615
|
-
<div
|
|
616
|
-
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"
|
|
617
|
-
:title="t('board.frame.dragToResize')"
|
|
618
|
-
@pointerdown="onResize($event, 'se')"
|
|
619
|
-
>
|
|
620
|
-
<span
|
|
621
|
-
class="absolute bottom-1 right-1 h-2 w-2 rounded-sm border-b-2 border-r-2 border-slate-500"
|
|
622
|
-
/>
|
|
623
|
-
</div>
|
|
624
596
|
</div>
|
|
625
597
|
</div>
|
|
598
|
+
|
|
599
|
+
<!-- Every border and corner of the CARD is a resize grip (see ResizeGrips: the geometry,
|
|
600
|
+
the hit bands, and why a north/west drag translates the contents). They used to sit on
|
|
601
|
+
the inner drop zone's edge, 16px of padding inside the visible border and flush against
|
|
602
|
+
the content, where two thin strips read as scrollbars rather than as the frame's border.
|
|
603
|
+
|
|
604
|
+
The grips are PHYSICAL (`right-0`, not `end-0`): the resize math derives the box from an
|
|
605
|
+
unmirrored clientX/clientY delta, so a logical (RTL-flipped) grip would render on the
|
|
606
|
+
opposite border from the one the drag actually moves. -->
|
|
607
|
+
<ResizeGrips :block="block" tone="frame" />
|
|
626
608
|
</div>
|
|
627
609
|
</div>
|
|
628
610
|
</template>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import DraggableTask from './DraggableTask.vue'
|
|
3
|
+
import ResizeGrips from './ResizeGrips.vue'
|
|
3
4
|
import { MODULE_META } from '~/utils/catalog'
|
|
4
5
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
5
|
-
import { useFrameResize } from '~/composables/useFrameResize'
|
|
6
6
|
|
|
7
7
|
const props = defineProps<{ moduleId: string }>()
|
|
8
8
|
const board = useBoardStore()
|
|
@@ -25,14 +25,6 @@ const { draggingId, startDrag } = useBlockDrag()
|
|
|
25
25
|
function onHandle(e: PointerEvent) {
|
|
26
26
|
if (mod.value) startDrag(mod.value, e)
|
|
27
27
|
}
|
|
28
|
-
|
|
29
|
-
// Miro-style resizing, same as a service frame: drag the right / bottom edges or
|
|
30
|
-
// the corner. The composable clamps to the module's content extent and persists
|
|
31
|
-
// the size on release.
|
|
32
|
-
const { startResize } = useFrameResize()
|
|
33
|
-
function onResize(e: PointerEvent, edge: 'e' | 's' | 'se') {
|
|
34
|
-
if (mod.value) startResize(mod.value, e, edge)
|
|
35
|
-
}
|
|
36
28
|
</script>
|
|
37
29
|
|
|
38
30
|
<template>
|
|
@@ -74,29 +66,8 @@ function onResize(e: PointerEvent, edge: 'e' | 's' | 'se') {
|
|
|
74
66
|
<DraggableTask v-for="t in tasks" :key="t.id" :task-id="t.id" />
|
|
75
67
|
</div>
|
|
76
68
|
|
|
77
|
-
<!--
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
BlockNode: the resize delta is unmirrored, so a logical grip would sit on
|
|
81
|
-
the opposite edge from the one the drag moves. -->
|
|
82
|
-
<div
|
|
83
|
-
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"
|
|
84
|
-
:title="t('board.frame.dragToResize')"
|
|
85
|
-
@pointerdown="onResize($event, 'e')"
|
|
86
|
-
/>
|
|
87
|
-
<div
|
|
88
|
-
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"
|
|
89
|
-
:title="t('board.frame.dragToResize')"
|
|
90
|
-
@pointerdown="onResize($event, 's')"
|
|
91
|
-
/>
|
|
92
|
-
<div
|
|
93
|
-
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"
|
|
94
|
-
:title="t('board.frame.dragToResize')"
|
|
95
|
-
@pointerdown="onResize($event, 'se')"
|
|
96
|
-
>
|
|
97
|
-
<span
|
|
98
|
-
class="absolute bottom-1 right-1 h-2 w-2 rounded-sm border-b-2 border-r-2 border-violet-400/60"
|
|
99
|
-
/>
|
|
100
|
-
</div>
|
|
69
|
+
<!-- Every border and corner of the module box is a resize grip, the same component (and so
|
|
70
|
+
the same hit bands + content-preserving north/west behaviour) the service frame uses. -->
|
|
71
|
+
<ResizeGrips :block="mod" tone="module" />
|
|
101
72
|
</div>
|
|
102
73
|
</template>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Block } from '~/types/domain'
|
|
3
|
+
import { RESIZE_EDGES, type ResizeEdge, useFrameResize } from '~/composables/useFrameResize'
|
|
4
|
+
|
|
5
|
+
// The eight border/corner grips of a resizable container (a service frame or a module), shared so
|
|
6
|
+
// the two node components can't drift in hit area, geometry, or which borders exist.
|
|
7
|
+
//
|
|
8
|
+
// Each grip STRADDLES the border it moves — half outside the box, half inside — giving a 12px hit
|
|
9
|
+
// band on a fine pointer (24px on a coarse one) while the DRAWN affordance stays a 2px bar on the
|
|
10
|
+
// border itself. The grips used to live on the frame's inner drop zone, 16px inside the visible
|
|
11
|
+
// border and flush against the content, where two thin strips read as scrollbars rather than as
|
|
12
|
+
// the box's edge.
|
|
13
|
+
//
|
|
14
|
+
// Corners are rendered LAST so they win the overlap with both of their edges. `RESIZE_EDGES` is
|
|
15
|
+
// ordered accordingly (edges first), and the whole set comes from the composable, so adding a
|
|
16
|
+
// direction is one entry there rather than markup here.
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
/** The container being resized. Its position/size are what the drag writes. */
|
|
19
|
+
block: Block
|
|
20
|
+
/** Which palette to draw in — a frame's grips are sky, a module's violet. */
|
|
21
|
+
tone: 'frame' | 'module'
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const access = useWorkspaceAccess()
|
|
25
|
+
const { resizingId, startResize } = useFrameResize()
|
|
26
|
+
const resizing = computed(() => resizingId.value === props.block.id)
|
|
27
|
+
|
|
28
|
+
// Which grip the pointer rests on, and which one it grabbed. The lit bar is a CHILD of the grip's
|
|
29
|
+
// hit band, so a plain `hover:` utility on the bar is wrong (the pointer is over the band, not the
|
|
30
|
+
// 2px bar); tracking it in state instead of a `group-hover/<name>:` variant means ONE predicate
|
|
31
|
+
// lights the border whether the pointer is resting on it or dragging it. The drag reads `dragEdge`
|
|
32
|
+
// rather than `hoverEdge` on purpose: the pointer routinely leaves the band mid-drag, and the
|
|
33
|
+
// border it is moving must stay lit until the drag ends.
|
|
34
|
+
const hoverEdge = ref<ResizeEdge | null>(null)
|
|
35
|
+
const dragEdge = ref<ResizeEdge | null>(null)
|
|
36
|
+
const lit = (edge: ResizeEdge) =>
|
|
37
|
+
resizing.value ? dragEdge.value === edge : hoverEdge.value === edge
|
|
38
|
+
|
|
39
|
+
function onPointerDown(e: PointerEvent, edge: ResizeEdge) {
|
|
40
|
+
dragEdge.value = edge
|
|
41
|
+
startResize(props.block, e, edge)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Tailwind needs literal class names, so the geometry is a static table rather than interpolated
|
|
45
|
+
// strings. `box` positions the hit band (straddling, widened for a coarse pointer); `mark` draws
|
|
46
|
+
// the lit affordance — a bar along an edge, an L at a corner, each anchored ON the border by
|
|
47
|
+
// matching the box's own negative offset.
|
|
48
|
+
interface GripStyle {
|
|
49
|
+
box: string
|
|
50
|
+
mark: string
|
|
51
|
+
/** Corners keep a faint mark at rest, so the gesture is discoverable without a hover. */
|
|
52
|
+
idle?: boolean
|
|
53
|
+
}
|
|
54
|
+
const GRIPS: Record<ResizeEdge, GripStyle> = {
|
|
55
|
+
n: {
|
|
56
|
+
box: '-top-1.5 left-0 h-3 w-full cursor-ns-resize pointer-coarse:-top-3 pointer-coarse:h-6',
|
|
57
|
+
mark: 'inset-x-3 top-1/2 h-0.5 -translate-y-1/2 rounded-full',
|
|
58
|
+
},
|
|
59
|
+
s: {
|
|
60
|
+
box: '-bottom-1.5 left-0 h-3 w-full cursor-ns-resize pointer-coarse:-bottom-3 pointer-coarse:h-6',
|
|
61
|
+
mark: 'inset-x-3 top-1/2 h-0.5 -translate-y-1/2 rounded-full',
|
|
62
|
+
},
|
|
63
|
+
e: {
|
|
64
|
+
box: '-right-1.5 top-0 h-full w-3 cursor-ew-resize pointer-coarse:-right-3 pointer-coarse:w-6',
|
|
65
|
+
mark: 'inset-y-3 left-1/2 w-0.5 -translate-x-1/2 rounded-full',
|
|
66
|
+
},
|
|
67
|
+
w: {
|
|
68
|
+
box: '-left-1.5 top-0 h-full w-3 cursor-ew-resize pointer-coarse:-left-3 pointer-coarse:w-6',
|
|
69
|
+
mark: 'inset-y-3 left-1/2 w-0.5 -translate-x-1/2 rounded-full',
|
|
70
|
+
},
|
|
71
|
+
ne: {
|
|
72
|
+
box: '-top-1.5 -right-1.5 h-5 w-5 cursor-nesw-resize pointer-coarse:-top-3 pointer-coarse:-right-3 pointer-coarse:h-11 pointer-coarse:w-11',
|
|
73
|
+
mark: 'right-1.5 top-1.5 h-2 w-2 rounded-sm border-r-2 border-t-2 pointer-coarse:right-3 pointer-coarse:top-3',
|
|
74
|
+
},
|
|
75
|
+
nw: {
|
|
76
|
+
box: '-left-1.5 -top-1.5 h-5 w-5 cursor-nwse-resize pointer-coarse:-left-3 pointer-coarse:-top-3 pointer-coarse:h-11 pointer-coarse:w-11',
|
|
77
|
+
mark: 'left-1.5 top-1.5 h-2 w-2 rounded-sm border-l-2 border-t-2 pointer-coarse:left-3 pointer-coarse:top-3',
|
|
78
|
+
},
|
|
79
|
+
se: {
|
|
80
|
+
box: '-bottom-1.5 -right-1.5 h-5 w-5 cursor-nwse-resize pointer-coarse:-bottom-3 pointer-coarse:-right-3 pointer-coarse:h-11 pointer-coarse:w-11',
|
|
81
|
+
mark: 'bottom-1.5 right-1.5 h-2 w-2 rounded-sm border-b-2 border-r-2 pointer-coarse:bottom-3 pointer-coarse:right-3',
|
|
82
|
+
idle: true,
|
|
83
|
+
},
|
|
84
|
+
sw: {
|
|
85
|
+
box: '-bottom-1.5 -left-1.5 h-5 w-5 cursor-nesw-resize pointer-coarse:-bottom-3 pointer-coarse:-left-3 pointer-coarse:h-11 pointer-coarse:w-11',
|
|
86
|
+
mark: 'bottom-1.5 left-1.5 h-2 w-2 rounded-sm border-b-2 border-l-2 pointer-coarse:bottom-3 pointer-coarse:left-3',
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A bar is filled, an L-shaped corner mark is drawn in border colour — so the lit/idle classes
|
|
91
|
+
// differ per shape as well as per tone.
|
|
92
|
+
const TONES = {
|
|
93
|
+
frame: { barLit: 'bg-sky-400', markLit: 'border-sky-400', markIdle: 'border-slate-500' },
|
|
94
|
+
module: {
|
|
95
|
+
barLit: 'bg-violet-300',
|
|
96
|
+
markLit: 'border-violet-300',
|
|
97
|
+
markIdle: 'border-violet-400/60',
|
|
98
|
+
},
|
|
99
|
+
} as const
|
|
100
|
+
|
|
101
|
+
function markClass(edge: ResizeEdge): string {
|
|
102
|
+
const tone = TONES[props.tone]
|
|
103
|
+
const isCorner = edge.length === 2
|
|
104
|
+
if (!isCorner) return lit(edge) ? tone.barLit : 'bg-transparent'
|
|
105
|
+
if (lit(edge)) return tone.markLit
|
|
106
|
+
return GRIPS[edge].idle ? tone.markIdle : 'border-transparent'
|
|
107
|
+
}
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<template>
|
|
111
|
+
<!-- Resizing persists geometry, so it is a `board.write` mutation: a read-only viewer gets no
|
|
112
|
+
grips at all rather than borders that light up and no-op (which is what `startResize`'s own
|
|
113
|
+
permission check then backs up rather than carries alone). `nopan` alongside `nodrag` so the
|
|
114
|
+
pane doesn't pan while resizing, same reason as the node's own drag handle. -->
|
|
115
|
+
<template v-if="access.canWriteBoard.value">
|
|
116
|
+
<div
|
|
117
|
+
v-for="edge in RESIZE_EDGES"
|
|
118
|
+
:key="edge"
|
|
119
|
+
class="nodrag nopan absolute z-10 touch-none"
|
|
120
|
+
:class="GRIPS[edge].box"
|
|
121
|
+
:title="$t('board.frame.dragToResize')"
|
|
122
|
+
:data-testid="`resize-${edge}`"
|
|
123
|
+
@pointerenter="hoverEdge = edge"
|
|
124
|
+
@pointerleave="hoverEdge = null"
|
|
125
|
+
@pointerdown="onPointerDown($event, edge)"
|
|
126
|
+
>
|
|
127
|
+
<span class="absolute transition-colors" :class="[GRIPS[edge].mark, markClass(edge)]" />
|
|
128
|
+
</div>
|
|
129
|
+
</template>
|
|
130
|
+
</template>
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type OrderedFinding,
|
|
18
18
|
} from './RequirementsReviewWindow.logic'
|
|
19
19
|
import type {
|
|
20
|
+
RecommendationSource,
|
|
20
21
|
RequirementRecommendation,
|
|
21
22
|
RequirementReview,
|
|
22
23
|
RequirementReviewItem,
|
|
@@ -150,6 +151,17 @@ const STATUS_COLOR = {
|
|
|
150
151
|
recommend_requested: 'primary',
|
|
151
152
|
} as const satisfies Record<ReviewItemStatus, string>
|
|
152
153
|
|
|
154
|
+
// What a Writer suggestion actually rests on. Shown because a suggestion drawn from the team's own
|
|
155
|
+
// standards and one resting on nothing but the model look identical once they are sitting in the
|
|
156
|
+
// answer box, and they deserve very different scrutiny. A suggestion whose standard resolved already
|
|
157
|
+
// carries the richer "current standard" badge, so this fills in the rest rather than repeating it.
|
|
158
|
+
const GROUNDING_COLOR = {
|
|
159
|
+
standard: 'success',
|
|
160
|
+
'project-spec': 'info',
|
|
161
|
+
web: 'neutral',
|
|
162
|
+
'general-practice': 'warning',
|
|
163
|
+
} as const satisfies Record<RecommendationSource, string>
|
|
164
|
+
|
|
153
165
|
// Exhaustive enum→label maps of literal keys, so the typed-key drift guard sees each key
|
|
154
166
|
// (vs a runtime-built `requirements.severity.${value}`).
|
|
155
167
|
const SEVERITY_LABELS = computed<Record<ReviewItemSeverity, string>>(() => ({
|
|
@@ -157,6 +169,12 @@ const SEVERITY_LABELS = computed<Record<ReviewItemSeverity, string>>(() => ({
|
|
|
157
169
|
medium: t('requirements.severity.medium'),
|
|
158
170
|
low: t('requirements.severity.low'),
|
|
159
171
|
}))
|
|
172
|
+
const GROUNDING_LABELS = computed<Record<RecommendationSource, string>>(() => ({
|
|
173
|
+
standard: t('requirements.grounding.standard'),
|
|
174
|
+
'project-spec': t('requirements.grounding.project-spec'),
|
|
175
|
+
web: t('requirements.grounding.web'),
|
|
176
|
+
'general-practice': t('requirements.grounding.general-practice'),
|
|
177
|
+
}))
|
|
160
178
|
const CATEGORY_LABELS = computed<Record<ReviewItemCategory, string>>(() => ({
|
|
161
179
|
gap: t('requirements.category.gap'),
|
|
162
180
|
clarification: t('requirements.category.clarification'),
|
|
@@ -842,6 +860,14 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
842
860
|
})
|
|
843
861
|
}}
|
|
844
862
|
</UBadge>
|
|
863
|
+
<UBadge
|
|
864
|
+
v-else-if="autoDefaults.get(item.id)!.groundedIn"
|
|
865
|
+
size="xs"
|
|
866
|
+
variant="subtle"
|
|
867
|
+
:color="GROUNDING_COLOR[autoDefaults.get(item.id)!.groundedIn!]"
|
|
868
|
+
>
|
|
869
|
+
{{ GROUNDING_LABELS[autoDefaults.get(item.id)!.groundedIn!] }}
|
|
870
|
+
</UBadge>
|
|
845
871
|
</div>
|
|
846
872
|
<UTextarea
|
|
847
873
|
v-model="drafts[item.id]"
|
|
@@ -891,6 +917,14 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
891
917
|
})
|
|
892
918
|
}}
|
|
893
919
|
</UBadge>
|
|
920
|
+
<UBadge
|
|
921
|
+
v-else-if="rec.groundedIn"
|
|
922
|
+
size="xs"
|
|
923
|
+
variant="subtle"
|
|
924
|
+
:color="GROUNDING_COLOR[rec.groundedIn]"
|
|
925
|
+
>
|
|
926
|
+
{{ GROUNDING_LABELS[rec.groundedIn] }}
|
|
927
|
+
</UBadge>
|
|
894
928
|
<p class="mt-1 whitespace-pre-line text-sm text-slate-300">
|
|
895
929
|
{{ rec.recommendedText }}
|
|
896
930
|
</p>
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
removeBlockContract,
|
|
16
16
|
reparentBlockContract,
|
|
17
17
|
reseedPipelineContract,
|
|
18
|
+
resizeBlockContract,
|
|
18
19
|
restoreBlockContract,
|
|
19
20
|
toggleDependencyContract,
|
|
20
21
|
updateBlockContract,
|
|
@@ -26,7 +27,7 @@ import type {
|
|
|
26
27
|
UpdatePipelineInput,
|
|
27
28
|
} from '@cat-factory/contracts'
|
|
28
29
|
import type { BlockType, CreateTaskType, TaskTypeFields } from '~/types/domain'
|
|
29
|
-
import type { ApiContext, Position } from './context'
|
|
30
|
+
import type { ApiContext, Position, Size } from './context'
|
|
30
31
|
|
|
31
32
|
/** Board structure: block (frame/module/task) mutations + the pipeline library. */
|
|
32
33
|
export function boardApi({ send, ws }: ApiContext) {
|
|
@@ -80,6 +81,11 @@ export function boardApi({ send, ws }: ApiContext) {
|
|
|
80
81
|
moveBlock: (workspaceId: string, blockId: string, body: { position: Position }) =>
|
|
81
82
|
send(moveBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId }, body }),
|
|
82
83
|
|
|
84
|
+
// Border-drag resize. One call for both halves of the geometry: the server derives the
|
|
85
|
+
// origin delta and translates the container's children so its contents stay put.
|
|
86
|
+
resizeBlock: (workspaceId: string, blockId: string, body: { position: Position; size: Size }) =>
|
|
87
|
+
send(resizeBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId }, body }),
|
|
88
|
+
|
|
83
89
|
reparentBlock: (
|
|
84
90
|
workspaceId: string,
|
|
85
91
|
blockId: string,
|
|
@@ -2,56 +2,150 @@ import { ref } from 'vue'
|
|
|
2
2
|
import type { Block } from '~/types/domain'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* The eight border/corner grips, each as the pair of unit factors saying how far the drag moves
|
|
6
|
+
* the container's ORIGIN versus its far edge. `1` on an origin axis means that axis's border is
|
|
7
|
+
* the one being dragged (west/north), so the box grows the OPPOSITE way from the pointer delta.
|
|
8
|
+
*
|
|
9
|
+
* Encoding the geometry as data rather than a `switch` per axis is what keeps the corners honest:
|
|
10
|
+
* `nw` is exactly `n` and `w` applied together, and there is no eighth case to forget.
|
|
11
|
+
*/
|
|
12
|
+
const HANDLES = {
|
|
13
|
+
n: { ox: 0, oy: 1, sx: 0, sy: -1, cursor: 'ns-resize' },
|
|
14
|
+
s: { ox: 0, oy: 0, sx: 0, sy: 1, cursor: 'ns-resize' },
|
|
15
|
+
e: { ox: 0, oy: 0, sx: 1, sy: 0, cursor: 'ew-resize' },
|
|
16
|
+
w: { ox: 1, oy: 0, sx: -1, sy: 0, cursor: 'ew-resize' },
|
|
17
|
+
ne: { ox: 0, oy: 1, sx: 1, sy: -1, cursor: 'nesw-resize' },
|
|
18
|
+
nw: { ox: 1, oy: 1, sx: -1, sy: -1, cursor: 'nwse-resize' },
|
|
19
|
+
se: { ox: 0, oy: 0, sx: 1, sy: 1, cursor: 'nwse-resize' },
|
|
20
|
+
sw: { ox: 1, oy: 0, sx: -1, sy: 1, cursor: 'nesw-resize' },
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
export type ResizeEdge = keyof typeof HANDLES
|
|
24
|
+
|
|
25
|
+
/** The grips in render order, so a component can `v-for` them instead of listing eight blocks. */
|
|
26
|
+
export const RESIZE_EDGES = Object.keys(HANDLES) as ResizeEdge[]
|
|
27
|
+
|
|
28
|
+
/** The `cursor` a given grip shows, and holds on `<body>` while its drag runs. */
|
|
29
|
+
export function resizeCursor(edge: ResizeEdge): string {
|
|
30
|
+
return HANDLES[edge].cursor
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pointer-driven resizing for containers (service frames and modules) by dragging any border or
|
|
35
|
+
* corner, Miro-style. The drag delta is divided by the board zoom so the border tracks the
|
|
36
|
+
* cursor, and the new size is clamped to the container's content extent so dragging inwards never
|
|
37
|
+
* clips the tasks/modules inside.
|
|
38
|
+
*
|
|
39
|
+
* Dragging the north or west border also moves the container's ORIGIN, and a child's position is
|
|
40
|
+
* stored relative to that origin — so the store translates the children by the inverse (see
|
|
41
|
+
* `previewResize`) and the backend does the same on commit, which is what makes the border extend
|
|
42
|
+
* past the contents instead of dragging them along. The origin is derived from the CLAMPED size
|
|
43
|
+
* rather than from the raw pointer delta: once the box has hit its content floor the border must
|
|
44
|
+
* stop dead, and a separately-clamped origin would keep sliding, walking the whole container
|
|
45
|
+
* across the board.
|
|
46
|
+
*
|
|
47
|
+
* The container grows live off the store's optimistic geometry, and the final bounds are
|
|
48
|
+
* persisted ONCE on release rather than on every move.
|
|
11
49
|
*/
|
|
12
50
|
export function useFrameResize() {
|
|
13
51
|
const board = useBoardStore()
|
|
14
52
|
const ui = useUiStore()
|
|
15
53
|
const access = useWorkspaceAccess()
|
|
16
|
-
/** Id of the
|
|
54
|
+
/** Id of the container currently being resized, for cursor/grip styling. */
|
|
17
55
|
const resizingId = ref<string | null>(null)
|
|
18
56
|
|
|
19
57
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
58
|
+
* How far the origin may travel INWARD before the nearest child would land at a negative
|
|
59
|
+
* offset — i.e. outside the box, spilling over the very border being dragged. `contentSize` is
|
|
60
|
+
* no help here: it measures only the FAR edge of the contents, which a north/west shrink moves
|
|
61
|
+
* inward in step with the border, so nothing there ever objects. `Infinity` for an empty
|
|
62
|
+
* container, which is then bounded by `contentSize`'s empty floor alone.
|
|
22
63
|
*/
|
|
23
|
-
function
|
|
64
|
+
function originSlack(id: string): { x: number; y: number } {
|
|
65
|
+
const children = board.childrenOf(id)
|
|
66
|
+
if (!children.length) return { x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY }
|
|
67
|
+
return {
|
|
68
|
+
x: Math.min(...children.map((c) => c.position.x)),
|
|
69
|
+
y: Math.min(...children.map((c) => c.position.y)),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Begin a resize from one of the container's borders or corners. */
|
|
74
|
+
function startResize(block: Block, e: PointerEvent, edge: ResizeEdge) {
|
|
24
75
|
if (e.button !== 0) return
|
|
25
|
-
// Resizing
|
|
26
|
-
//
|
|
76
|
+
// Resizing persists geometry — a `board.write` mutation, so a read-only viewer's resize
|
|
77
|
+
// no-ops (the grips are hidden for them at the component level).
|
|
27
78
|
if (!access.canWriteBoard.value) return
|
|
28
79
|
e.preventDefault()
|
|
29
80
|
e.stopPropagation()
|
|
81
|
+
const handle = HANDLES[edge]
|
|
30
82
|
const startX = e.clientX
|
|
31
83
|
const startY = e.clientY
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
// Seed from the current rendered size so the first move doesn't jump.
|
|
84
|
+
// Seed from the current rendered geometry so the first move doesn't jump. `size` may be
|
|
85
|
+
// absent (an auto-sized container), which is also what a rejected resize must restore.
|
|
35
86
|
const start = board.containerSize(block.id)
|
|
87
|
+
const from = {
|
|
88
|
+
position: { ...block.position },
|
|
89
|
+
size: block.size ? { ...block.size } : undefined,
|
|
90
|
+
}
|
|
91
|
+
// The floors, snapshotted once: on an origin-axis drag the children MOVE, so a floor re-read
|
|
92
|
+
// mid-drag would chase them. `contentSize` bounds the far edge; the near-edge bound applies
|
|
93
|
+
// only where the origin travels, and only inwards.
|
|
94
|
+
const min = board.contentSize(block.id)
|
|
95
|
+
const slack = originSlack(block.id)
|
|
96
|
+
const floor = {
|
|
97
|
+
w: handle.ox ? Math.max(min.w, start.w - slack.x) : min.w,
|
|
98
|
+
h: handle.oy ? Math.max(min.h, start.h - slack.y) : min.h,
|
|
99
|
+
}
|
|
36
100
|
resizingId.value = block.id
|
|
37
101
|
|
|
102
|
+
// Hold the resize cursor on `<body>` (and kill text selection) for the whole drag: the
|
|
103
|
+
// pointer routinely outruns the 12px grip, and without this the cursor flips back to the
|
|
104
|
+
// default mid-drag, which reads as "the grab was dropped" even though the border is still
|
|
105
|
+
// tracking.
|
|
106
|
+
const body = document.body
|
|
107
|
+
const priorCursor = body.style.cursor
|
|
108
|
+
const priorUserSelect = body.style.userSelect
|
|
109
|
+
body.style.cursor = handle.cursor
|
|
110
|
+
body.style.userSelect = 'none'
|
|
111
|
+
|
|
112
|
+
let bounds = { position: from.position, size: start }
|
|
113
|
+
let moved = false
|
|
38
114
|
const onMove = (ev: PointerEvent) => {
|
|
39
115
|
const z = ui.zoom || 1
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
116
|
+
const dx = (ev.clientX - startX) / z
|
|
117
|
+
const dy = (ev.clientY - startY) / z
|
|
118
|
+
const w = Math.round(Math.max(floor.w, start.w + handle.sx * dx))
|
|
119
|
+
const h = Math.round(Math.max(floor.h, start.h + handle.sy * dy))
|
|
120
|
+
// A grown box on an origin axis extends BACKWARDS from where the far edge stays put, so
|
|
121
|
+
// the origin moves by whatever the size actually gained after clamping.
|
|
122
|
+
bounds = {
|
|
123
|
+
position: {
|
|
124
|
+
x: from.position.x - handle.ox * (w - start.w),
|
|
125
|
+
y: from.position.y - handle.oy * (h - start.h),
|
|
126
|
+
},
|
|
127
|
+
size: { w, h },
|
|
128
|
+
}
|
|
129
|
+
moved = true
|
|
130
|
+
// Optimistic, local-only (the store also translates the children): no round-trip per move.
|
|
131
|
+
board.previewResize(block.id, bounds.position, bounds.size)
|
|
45
132
|
}
|
|
46
133
|
const onUp = () => {
|
|
47
134
|
window.removeEventListener('pointermove', onMove)
|
|
48
135
|
window.removeEventListener('pointerup', onUp)
|
|
136
|
+
window.removeEventListener('pointercancel', onUp)
|
|
137
|
+
body.style.cursor = priorCursor
|
|
138
|
+
body.style.userSelect = priorUserSelect
|
|
49
139
|
resizingId.value = null
|
|
50
|
-
//
|
|
51
|
-
|
|
140
|
+
// A press with no movement is not a resize: committing it would emit a coarse board signal
|
|
141
|
+
// (every other client re-hydrates) to store the geometry it already had.
|
|
142
|
+
if (moved) void board.resizeBlock(block.id, bounds, from)
|
|
52
143
|
}
|
|
53
144
|
window.addEventListener('pointermove', onMove)
|
|
54
145
|
window.addEventListener('pointerup', onUp)
|
|
146
|
+
// A cancelled pointer (touch interrupted by a gesture, window losing the pointer) never fires
|
|
147
|
+
// `pointerup`, so without this the body cursor stays stuck on `ew-resize` for the session.
|
|
148
|
+
window.addEventListener('pointercancel', onUp)
|
|
55
149
|
}
|
|
56
150
|
|
|
57
151
|
return { resizingId, startResize }
|
|
@@ -12,7 +12,7 @@ import { UNDO_WINDOW_MS } from './context'
|
|
|
12
12
|
* in-closure functions, and the split is purely to keep every function within the size budget.
|
|
13
13
|
*/
|
|
14
14
|
export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
15
|
-
const { getBlock, upsert, api, toast, tr } = ctx
|
|
15
|
+
const { blocks, getBlock, upsert, api, toast, tr } = ctx
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Move a block into a new container at a new local position. Drag-reparent commits
|
|
@@ -120,6 +120,67 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Translate every DIRECT child of a container — the client half of the compensation the
|
|
125
|
+
* backend's `shiftChildPositions` applies. A child's position is relative to its container's
|
|
126
|
+
* content origin, so moving that origin (a north/west border drag) has to move the children
|
|
127
|
+
* the other way or the contents slide with the border. Grandchildren ride their module.
|
|
128
|
+
*/
|
|
129
|
+
function shiftChildren(parentId: string, dx: number, dy: number) {
|
|
130
|
+
if (!dx && !dy) return
|
|
131
|
+
for (const child of blocks.value) {
|
|
132
|
+
if (child.parentId !== parentId) continue
|
|
133
|
+
child.position = { x: child.position.x + dx, y: child.position.y + dy }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Local-only geometry update during an active border drag — the resize counterpart of
|
|
139
|
+
* {@link previewMove}, and for the same reason: persisting every pointer move would let an
|
|
140
|
+
* out-of-order response land a stale size after the user let go. Takes ABSOLUTE bounds and
|
|
141
|
+
* derives the origin delta itself, so a caller can drive it from a running drag without
|
|
142
|
+
* tracking what it has already applied. {@link resizeBlock} commits the final bounds once.
|
|
143
|
+
*/
|
|
144
|
+
function previewResize(
|
|
145
|
+
id: string,
|
|
146
|
+
position: { x: number; y: number },
|
|
147
|
+
size?: { w: number; h: number },
|
|
148
|
+
) {
|
|
149
|
+
const b = getBlock(id)
|
|
150
|
+
if (!b) return
|
|
151
|
+
shiftChildren(id, b.position.x - position.x, b.position.y - position.y)
|
|
152
|
+
b.position = position
|
|
153
|
+
b.size = size
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Commit a border-drag resize: ONE call carrying both halves of the geometry, because only an
|
|
158
|
+
* operation that sees the origin delta can translate the container's children with it (see
|
|
159
|
+
* `BoardService.resizeBlock`). `from` is the pre-drag geometry — a rejected resize replays it
|
|
160
|
+
* through {@link previewResize}, which undoes the child translation by the same arithmetic that
|
|
161
|
+
* applied it, so a failure can't leave the contents offset from a box the server never stored.
|
|
162
|
+
*/
|
|
163
|
+
async function resizeBlock(
|
|
164
|
+
id: string,
|
|
165
|
+
bounds: { position: { x: number; y: number }; size: { w: number; h: number } },
|
|
166
|
+
from: { position: { x: number; y: number }; size?: { w: number; h: number } },
|
|
167
|
+
) {
|
|
168
|
+
const b = getBlock(id)
|
|
169
|
+
if (!b) return
|
|
170
|
+
previewResize(id, bounds.position, bounds.size)
|
|
171
|
+
try {
|
|
172
|
+
upsert(await api.resizeBlock(useWorkspaceStore().requireId(), id, bounds))
|
|
173
|
+
} catch (e) {
|
|
174
|
+
previewResize(id, from.position, from.size)
|
|
175
|
+
toast.add({
|
|
176
|
+
title: tr('board.toast.resizeFailed'),
|
|
177
|
+
description: e instanceof Error ? e.message : String(e),
|
|
178
|
+
icon: 'i-lucide-triangle-alert',
|
|
179
|
+
color: 'error',
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
123
184
|
/** Patch the user-editable fields of a block (title, features, threshold…). */
|
|
124
185
|
async function updateBlock(id: string, patch: UpdateBlockInput) {
|
|
125
186
|
const b = getBlock(id)
|
|
@@ -196,6 +257,8 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
196
257
|
reparentBlock,
|
|
197
258
|
previewMove,
|
|
198
259
|
moveBlock,
|
|
260
|
+
previewResize,
|
|
261
|
+
resizeBlock,
|
|
199
262
|
updateBlock,
|
|
200
263
|
toggleDependency,
|
|
201
264
|
removeDependency,
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -310,6 +310,57 @@ describe('board store optimistic rollback', () => {
|
|
|
310
310
|
expect(store.getBlock('t1')?.description).toBe('keep')
|
|
311
311
|
})
|
|
312
312
|
|
|
313
|
+
it('previewResize translates the children when the drag moves the content origin', () => {
|
|
314
|
+
// A child's position is relative to its container's content origin, so growing the frame
|
|
315
|
+
// 40px west (origin -40) has to move every direct child +40 or the whole content slides with
|
|
316
|
+
// the border. A grandchild rides its module and must NOT move on its own.
|
|
317
|
+
const store = useBoardStore()
|
|
318
|
+
store.hydrate([
|
|
319
|
+
frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
|
|
320
|
+
moduleBlock('m1', 'f1', { position: { x: 20, y: 30 } }),
|
|
321
|
+
task('t1', 'f1', { position: { x: 10, y: 20 } }),
|
|
322
|
+
task('t2', 'm1', { position: { x: 5, y: 5 } }),
|
|
323
|
+
])
|
|
324
|
+
store.previewResize('f1', { x: 60, y: 100 }, { w: 640, h: 400 })
|
|
325
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 50, y: 20 })
|
|
326
|
+
expect(store.getBlock('m1')?.position).toEqual({ x: 60, y: 30 })
|
|
327
|
+
expect(store.getBlock('t2')?.position).toEqual({ x: 5, y: 5 })
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('previewResize leaves the children alone when only the far border moved', () => {
|
|
331
|
+
const store = useBoardStore()
|
|
332
|
+
store.hydrate([
|
|
333
|
+
frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
|
|
334
|
+
task('t1', 'f1', { position: { x: 10, y: 20 } }),
|
|
335
|
+
])
|
|
336
|
+
store.previewResize('f1', { x: 100, y: 100 }, { w: 700, h: 500 })
|
|
337
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 10, y: 20 })
|
|
338
|
+
expect(store.getBlock('f1')?.size).toEqual({ w: 700, h: 500 })
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('resizeBlock rolls the bounds AND the child translation back when the API rejects', async () => {
|
|
342
|
+
// The rollback has to undo both halves: a restored box with its contents still offset is the
|
|
343
|
+
// one failure mode that looks fine until the next refresh moves everything.
|
|
344
|
+
vi.stubGlobal('useApi', () => ({
|
|
345
|
+
resizeBlock: () => Promise.reject(new Error('conflict')),
|
|
346
|
+
}))
|
|
347
|
+
setActivePinia(createPinia())
|
|
348
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
349
|
+
const store = useBoardStore()
|
|
350
|
+
store.hydrate([
|
|
351
|
+
frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
|
|
352
|
+
task('t1', 'f1', { position: { x: 10, y: 20 } }),
|
|
353
|
+
])
|
|
354
|
+
await store.resizeBlock(
|
|
355
|
+
'f1',
|
|
356
|
+
{ position: { x: 60, y: 70 }, size: { w: 640, h: 430 } },
|
|
357
|
+
{ position: { x: 100, y: 100 }, size: { w: 600, h: 400 } },
|
|
358
|
+
)
|
|
359
|
+
expect(store.getBlock('f1')?.position).toEqual({ x: 100, y: 100 })
|
|
360
|
+
expect(store.getBlock('f1')?.size).toEqual({ w: 600, h: 400 })
|
|
361
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 10, y: 20 })
|
|
362
|
+
})
|
|
363
|
+
|
|
313
364
|
it('reparentBlock offers an undo that moves the block back to its previous home', async () => {
|
|
314
365
|
vi.stubGlobal('useApi', () => ({
|
|
315
366
|
reparentBlock: async (
|
package/i18n/locales/de.json
CHANGED
|
@@ -2313,6 +2313,7 @@
|
|
|
2313
2313
|
"updateFailed": "Änderungen konnten nicht gespeichert werden",
|
|
2314
2314
|
"epicFailed": "Epic konnte nicht geändert werden",
|
|
2315
2315
|
"moveFailed": "Verschieben nicht möglich",
|
|
2316
|
+
"resizeFailed": "Größe konnte nicht geändert werden",
|
|
2316
2317
|
"deleteFailed": "Löschen nicht möglich",
|
|
2317
2318
|
"linkFailed": "Aufgaben konnten nicht verknüpft werden",
|
|
2318
2319
|
"unlinkFailed": "Abhängigkeit konnte nicht entfernt werden",
|
|
@@ -2566,7 +2567,6 @@
|
|
|
2566
2567
|
"mergedOfTotal": "{merged}/{total} gemergt",
|
|
2567
2568
|
"noTasksYet": "Noch keine Aufgaben",
|
|
2568
2569
|
"prCount": "{count} PR",
|
|
2569
|
-
"implemented": "{merged}/{total} implementiert",
|
|
2570
2570
|
"prReadyCount": "{count} PR bereit",
|
|
2571
2571
|
"taskCount": "{count} Aufgabe | {count} Aufgaben",
|
|
2572
2572
|
"moduleCount": "{count} Modul | {count} Module",
|
|
@@ -3864,6 +3864,12 @@
|
|
|
3864
3864
|
"recommendationProgress": "{ready} / {total} bereit",
|
|
3865
3865
|
"generatingSuggestion": "Ein fundierter Vorschlag wird generiert…",
|
|
3866
3866
|
"currentStandard": "Aktueller Standard: {title}",
|
|
3867
|
+
"grounding": {
|
|
3868
|
+
"standard": "Team-Standard",
|
|
3869
|
+
"project-spec": "Projektspezifikation",
|
|
3870
|
+
"web": "Webquelle",
|
|
3871
|
+
"general-practice": "Allgemeine Praxis"
|
|
3872
|
+
},
|
|
3867
3873
|
"accept": "Annehmen",
|
|
3868
3874
|
"reject": "Ablehnen",
|
|
3869
3875
|
"reRequestPlaceholder": "Nach einer anderen Empfehlung fragen…",
|
package/i18n/locales/en.json
CHANGED
|
@@ -152,6 +152,7 @@
|
|
|
152
152
|
"updateFailed": "Could not save changes",
|
|
153
153
|
"epicFailed": "Could not change epic",
|
|
154
154
|
"moveFailed": "Could not move",
|
|
155
|
+
"resizeFailed": "Could not resize",
|
|
155
156
|
"deleteFailed": "Could not delete",
|
|
156
157
|
"linkFailed": "Could not link tasks",
|
|
157
158
|
"unlinkFailed": "Could not remove dependency",
|
|
@@ -417,7 +418,6 @@
|
|
|
417
418
|
"mergedOfTotal": "{merged}/{total} merged",
|
|
418
419
|
"noTasksYet": "No tasks yet",
|
|
419
420
|
"prCount": "{count} PR",
|
|
420
|
-
"implemented": "{merged}/{total} implemented",
|
|
421
421
|
"prReadyCount": "{count} PR ready",
|
|
422
422
|
"taskCount": "{count} task | {count} tasks",
|
|
423
423
|
"@taskCount": {
|
|
@@ -4484,6 +4484,16 @@
|
|
|
4484
4484
|
"recommendationProgress": "{ready} / {total} ready",
|
|
4485
4485
|
"generatingSuggestion": "Generating a grounded suggestion…",
|
|
4486
4486
|
"currentStandard": "Current standard: {title}",
|
|
4487
|
+
"grounding": {
|
|
4488
|
+
"standard": "Team standard",
|
|
4489
|
+
"@standard": "Badge on a suggested answer: it came from the team's own best-practice standard. Noun phrase, short — fits a small badge.",
|
|
4490
|
+
"project-spec": "Project spec",
|
|
4491
|
+
"@project-spec": "Badge on a suggested answer: it came from the project's own committed specification documents. Noun phrase, short.",
|
|
4492
|
+
"web": "Web source",
|
|
4493
|
+
"@web": "Badge on a suggested answer: it came from a web search result. Noun phrase, short.",
|
|
4494
|
+
"general-practice": "General practice",
|
|
4495
|
+
"@general-practice": "Badge on a suggested answer: it rests only on the model's general knowledge, with no team standard, project spec or source behind it — the weakest grounding, so the wording should not sound authoritative. Noun phrase, short."
|
|
4496
|
+
},
|
|
4487
4497
|
"accept": "Accept",
|
|
4488
4498
|
"reject": "Reject",
|
|
4489
4499
|
"reRequestPlaceholder": "Ask for a different recommendation…",
|
package/i18n/locales/es.json
CHANGED
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
"updateFailed": "No se pudieron guardar los cambios",
|
|
132
132
|
"epicFailed": "No se pudo cambiar la épica",
|
|
133
133
|
"moveFailed": "No se pudo mover",
|
|
134
|
+
"resizeFailed": "No se pudo cambiar el tamaño",
|
|
134
135
|
"deleteFailed": "No se pudo eliminar",
|
|
135
136
|
"linkFailed": "No se pudieron vincular las tareas",
|
|
136
137
|
"unlinkFailed": "No se pudo eliminar la dependencia",
|
|
@@ -384,7 +385,6 @@
|
|
|
384
385
|
"mergedOfTotal": "{merged}/{total} fusionadas",
|
|
385
386
|
"noTasksYet": "Aún no hay tareas",
|
|
386
387
|
"prCount": "{count} PR",
|
|
387
|
-
"implemented": "{merged}/{total} implementadas",
|
|
388
388
|
"prReadyCount": "{count} PR listas",
|
|
389
389
|
"taskCount": "{count} tarea | {count} tareas",
|
|
390
390
|
"moduleCount": "{count} módulo | {count} módulos",
|
|
@@ -4297,6 +4297,12 @@
|
|
|
4297
4297
|
"recommendationProgress": "{ready} / {total} listas",
|
|
4298
4298
|
"generatingSuggestion": "Generando una sugerencia fundamentada…",
|
|
4299
4299
|
"currentStandard": "Estándar actual: {title}",
|
|
4300
|
+
"grounding": {
|
|
4301
|
+
"standard": "Estándar del equipo",
|
|
4302
|
+
"project-spec": "Especificación del proyecto",
|
|
4303
|
+
"web": "Fuente web",
|
|
4304
|
+
"general-practice": "Práctica general"
|
|
4305
|
+
},
|
|
4300
4306
|
"accept": "Aceptar",
|
|
4301
4307
|
"reject": "Rechazar",
|
|
4302
4308
|
"reRequestPlaceholder": "Pide una recomendación diferente…",
|
package/i18n/locales/fr.json
CHANGED
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
"updateFailed": "Impossible d'enregistrer les modifications",
|
|
132
132
|
"epicFailed": "Impossible de changer l'épopée",
|
|
133
133
|
"moveFailed": "Impossible de déplacer",
|
|
134
|
+
"resizeFailed": "Redimensionnement impossible",
|
|
134
135
|
"deleteFailed": "Impossible de supprimer",
|
|
135
136
|
"linkFailed": "Impossible de lier les tâches",
|
|
136
137
|
"unlinkFailed": "Impossible de supprimer la dépendance",
|
|
@@ -384,7 +385,6 @@
|
|
|
384
385
|
"mergedOfTotal": "{merged}/{total} fusionnées",
|
|
385
386
|
"noTasksYet": "Pas encore de tâches",
|
|
386
387
|
"prCount": "{count} PR",
|
|
387
|
-
"implemented": "{merged}/{total} implémentées",
|
|
388
388
|
"prReadyCount": "{count} PR prêtes",
|
|
389
389
|
"taskCount": "{count} tâche | {count} tâches",
|
|
390
390
|
"moduleCount": "{count} module | {count} modules",
|
|
@@ -4297,6 +4297,12 @@
|
|
|
4297
4297
|
"recommendationProgress": "{ready} / {total} prêtes",
|
|
4298
4298
|
"generatingSuggestion": "Génération d'une suggestion étayée…",
|
|
4299
4299
|
"currentStandard": "Standard actuel : {title}",
|
|
4300
|
+
"grounding": {
|
|
4301
|
+
"standard": "Standard de l'équipe",
|
|
4302
|
+
"project-spec": "Spécification du projet",
|
|
4303
|
+
"web": "Source web",
|
|
4304
|
+
"general-practice": "Pratique générale"
|
|
4305
|
+
},
|
|
4300
4306
|
"accept": "Accepter",
|
|
4301
4307
|
"reject": "Rejeter",
|
|
4302
4308
|
"reRequestPlaceholder": "Demander une autre recommandation…",
|
package/i18n/locales/he.json
CHANGED
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
"updateFailed": "לא ניתן היה לשמור את השינויים",
|
|
132
132
|
"epicFailed": "לא ניתן היה לשנות את האפיק",
|
|
133
133
|
"moveFailed": "לא ניתן היה להעביר",
|
|
134
|
+
"resizeFailed": "לא ניתן היה לשנות את הגודל",
|
|
134
135
|
"deleteFailed": "לא ניתן היה למחוק",
|
|
135
136
|
"linkFailed": "לא ניתן היה לקשר משימות",
|
|
136
137
|
"unlinkFailed": "לא ניתן להסיר את התלות",
|
|
@@ -384,7 +385,6 @@
|
|
|
384
385
|
"mergedOfTotal": "{merged}/{total} מוזגו",
|
|
385
386
|
"noTasksYet": "אין עדיין משימות",
|
|
386
387
|
"prCount": "{count} PR",
|
|
387
|
-
"implemented": "{merged}/{total} מומשו",
|
|
388
388
|
"prReadyCount": "{count} PR מוכנים",
|
|
389
389
|
"taskCount": "{count} משימה | {count} משימות",
|
|
390
390
|
"moduleCount": "{count} מודול | {count} מודולים",
|
|
@@ -4308,6 +4308,12 @@
|
|
|
4308
4308
|
"recommendationProgress": "{ready} / {total} מוכנות",
|
|
4309
4309
|
"generatingSuggestion": "מייצר הצעה מבוססת…",
|
|
4310
4310
|
"currentStandard": "תקן נוכחי: {title}",
|
|
4311
|
+
"grounding": {
|
|
4312
|
+
"standard": "תקן הצוות",
|
|
4313
|
+
"project-spec": "מפרט הפרויקט",
|
|
4314
|
+
"web": "מקור מהאינטרנט",
|
|
4315
|
+
"general-practice": "נוהג מקובל"
|
|
4316
|
+
},
|
|
4311
4317
|
"accept": "קבל",
|
|
4312
4318
|
"reject": "דחה",
|
|
4313
4319
|
"reRequestPlaceholder": "בקש המלצה אחרת…",
|
package/i18n/locales/it.json
CHANGED
|
@@ -2313,6 +2313,7 @@
|
|
|
2313
2313
|
"updateFailed": "Impossibile salvare le modifiche",
|
|
2314
2314
|
"epicFailed": "Impossibile cambiare epic",
|
|
2315
2315
|
"moveFailed": "Impossibile spostare",
|
|
2316
|
+
"resizeFailed": "Impossibile ridimensionare",
|
|
2316
2317
|
"deleteFailed": "Impossibile eliminare",
|
|
2317
2318
|
"linkFailed": "Impossibile collegare le attività",
|
|
2318
2319
|
"unlinkFailed": "Impossibile rimuovere la dipendenza",
|
|
@@ -2566,7 +2567,6 @@
|
|
|
2566
2567
|
"mergedOfTotal": "{merged}/{total} unite",
|
|
2567
2568
|
"noTasksYet": "Ancora nessuna attività",
|
|
2568
2569
|
"prCount": "{count} PR",
|
|
2569
|
-
"implemented": "{merged}/{total} implementate",
|
|
2570
2570
|
"prReadyCount": "{count} PR pronte",
|
|
2571
2571
|
"taskCount": "{count} attività | {count} attività",
|
|
2572
2572
|
"moduleCount": "{count} modulo | {count} moduli",
|
|
@@ -3864,6 +3864,12 @@
|
|
|
3864
3864
|
"recommendationProgress": "{ready} / {total} pronte",
|
|
3865
3865
|
"generatingSuggestion": "Generazione di un suggerimento fondato…",
|
|
3866
3866
|
"currentStandard": "Standard attuale: {title}",
|
|
3867
|
+
"grounding": {
|
|
3868
|
+
"standard": "Standard del team",
|
|
3869
|
+
"project-spec": "Specifica del progetto",
|
|
3870
|
+
"web": "Fonte web",
|
|
3871
|
+
"general-practice": "Prassi generale"
|
|
3872
|
+
},
|
|
3867
3873
|
"accept": "Accetta",
|
|
3868
3874
|
"reject": "Rifiuta",
|
|
3869
3875
|
"reRequestPlaceholder": "Chiedi una raccomandazione diversa…",
|
package/i18n/locales/ja.json
CHANGED
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
"updateFailed": "変更を保存できませんでした",
|
|
132
132
|
"epicFailed": "エピックを変更できませんでした",
|
|
133
133
|
"moveFailed": "移動できませんでした",
|
|
134
|
+
"resizeFailed": "サイズを変更できませんでした",
|
|
134
135
|
"deleteFailed": "削除できませんでした",
|
|
135
136
|
"linkFailed": "タスクをリンクできませんでした",
|
|
136
137
|
"unlinkFailed": "依存関係を削除できませんでした",
|
|
@@ -384,7 +385,6 @@
|
|
|
384
385
|
"mergedOfTotal": "{merged}/{total} マージ済み",
|
|
385
386
|
"noTasksYet": "タスクはまだありません",
|
|
386
387
|
"prCount": "{count} 件の PR",
|
|
387
|
-
"implemented": "{merged}/{total} 実装済み",
|
|
388
388
|
"prReadyCount": "{count} 件の PR が準備完了",
|
|
389
389
|
"taskCount": "{count} 件のタスク | {count} 件のタスク",
|
|
390
390
|
"moduleCount": "{count} 件のモジュール | {count} 件のモジュール",
|
|
@@ -4309,6 +4309,12 @@
|
|
|
4309
4309
|
"recommendationProgress": "{ready} / {total} 件準備完了",
|
|
4310
4310
|
"generatingSuggestion": "根拠に基づいた提案を生成中…",
|
|
4311
4311
|
"currentStandard": "現在の標準: {title}",
|
|
4312
|
+
"grounding": {
|
|
4313
|
+
"standard": "チーム標準",
|
|
4314
|
+
"project-spec": "プロジェクト仕様",
|
|
4315
|
+
"web": "Web の情報源",
|
|
4316
|
+
"general-practice": "一般的な慣行"
|
|
4317
|
+
},
|
|
4312
4318
|
"accept": "承認",
|
|
4313
4319
|
"reject": "却下",
|
|
4314
4320
|
"reRequestPlaceholder": "別の推奨をリクエスト…",
|
package/i18n/locales/pl.json
CHANGED
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
"updateFailed": "Nie udało się zapisać zmian",
|
|
132
132
|
"epicFailed": "Nie udało się zmienić epiku",
|
|
133
133
|
"moveFailed": "Nie udało się przenieść",
|
|
134
|
+
"resizeFailed": "Nie udało się zmienić rozmiaru",
|
|
134
135
|
"deleteFailed": "Nie udało się usunąć",
|
|
135
136
|
"linkFailed": "Nie udało się powiązać zadań",
|
|
136
137
|
"unlinkFailed": "Nie udało się usunąć zależności",
|
|
@@ -384,7 +385,6 @@
|
|
|
384
385
|
"mergedOfTotal": "{merged}/{total} scalonych",
|
|
385
386
|
"noTasksYet": "Brak zadań",
|
|
386
387
|
"prCount": "{count} PR",
|
|
387
|
-
"implemented": "{merged}/{total} wdrożonych",
|
|
388
388
|
"prReadyCount": "{count} PR gotowych",
|
|
389
389
|
"taskCount": "{count} zadanie | {count} zadania | {count} zadań",
|
|
390
390
|
"moduleCount": "{count} moduł | {count} moduły | {count} modułów",
|
|
@@ -4297,6 +4297,12 @@
|
|
|
4297
4297
|
"recommendationProgress": "{ready} / {total} gotowych",
|
|
4298
4298
|
"generatingSuggestion": "Generowanie ugruntowanej sugestii…",
|
|
4299
4299
|
"currentStandard": "Aktualny standard: {title}",
|
|
4300
|
+
"grounding": {
|
|
4301
|
+
"standard": "Standard zespołu",
|
|
4302
|
+
"project-spec": "Specyfikacja projektu",
|
|
4303
|
+
"web": "Źródło z sieci",
|
|
4304
|
+
"general-practice": "Ogólna praktyka"
|
|
4305
|
+
},
|
|
4300
4306
|
"accept": "Akceptuj",
|
|
4301
4307
|
"reject": "Odrzuć",
|
|
4302
4308
|
"reRequestPlaceholder": "Poproś o inną rekomendację…",
|
package/i18n/locales/tr.json
CHANGED
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
"updateFailed": "Değişiklikler kaydedilemedi",
|
|
132
132
|
"epicFailed": "Epik değiştirilemedi",
|
|
133
133
|
"moveFailed": "Taşınamadı",
|
|
134
|
+
"resizeFailed": "Yeniden boyutlandırılamadı",
|
|
134
135
|
"deleteFailed": "Silinemedi",
|
|
135
136
|
"linkFailed": "Görevler bağlanamadı",
|
|
136
137
|
"unlinkFailed": "Bağımlılık kaldırılamadı",
|
|
@@ -384,7 +385,6 @@
|
|
|
384
385
|
"mergedOfTotal": "{merged}/{total} birleştirildi",
|
|
385
386
|
"noTasksYet": "Henüz görev yok",
|
|
386
387
|
"prCount": "{count} PR",
|
|
387
|
-
"implemented": "{merged}/{total} uygulandı",
|
|
388
388
|
"prReadyCount": "{count} PR hazır",
|
|
389
389
|
"taskCount": "{count} görev | {count} görev",
|
|
390
390
|
"moduleCount": "{count} modül | {count} modül",
|
|
@@ -4309,6 +4309,12 @@
|
|
|
4309
4309
|
"recommendationProgress": "{ready} / {total} hazır",
|
|
4310
4310
|
"generatingSuggestion": "Temellendirilmiş bir öneri oluşturuluyor…",
|
|
4311
4311
|
"currentStandard": "Mevcut standart: {title}",
|
|
4312
|
+
"grounding": {
|
|
4313
|
+
"standard": "Ekip standardı",
|
|
4314
|
+
"project-spec": "Proje şartnamesi",
|
|
4315
|
+
"web": "Web kaynağı",
|
|
4316
|
+
"general-practice": "Genel uygulama"
|
|
4317
|
+
},
|
|
4312
4318
|
"accept": "Kabul et",
|
|
4313
4319
|
"reject": "Reddet",
|
|
4314
4320
|
"reRequestPlaceholder": "Farklı bir öneri isteyin…",
|
package/i18n/locales/uk.json
CHANGED
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
"updateFailed": "Не вдалося зберегти зміни",
|
|
132
132
|
"epicFailed": "Не вдалося змінити епік",
|
|
133
133
|
"moveFailed": "Не вдалося перемістити",
|
|
134
|
+
"resizeFailed": "Не вдалося змінити розмір",
|
|
134
135
|
"deleteFailed": "Не вдалося видалити",
|
|
135
136
|
"linkFailed": "Не вдалося звʼязати завдання",
|
|
136
137
|
"unlinkFailed": "Не вдалося видалити залежність",
|
|
@@ -384,7 +385,6 @@
|
|
|
384
385
|
"mergedOfTotal": "{merged}/{total} злито",
|
|
385
386
|
"noTasksYet": "Поки немає завдань",
|
|
386
387
|
"prCount": "{count} PR",
|
|
387
|
-
"implemented": "{merged}/{total} реалізовано",
|
|
388
388
|
"prReadyCount": "{count} PR готово",
|
|
389
389
|
"taskCount": "{count} завдання | {count} завдання | {count} завдань",
|
|
390
390
|
"moduleCount": "{count} модуль | {count} модулі | {count} модулів",
|
|
@@ -4297,6 +4297,12 @@
|
|
|
4297
4297
|
"recommendationProgress": "{ready} / {total} готово",
|
|
4298
4298
|
"generatingSuggestion": "Генерування обґрунтованої пропозиції…",
|
|
4299
4299
|
"currentStandard": "Поточний стандарт: {title}",
|
|
4300
|
+
"grounding": {
|
|
4301
|
+
"standard": "Стандарт команди",
|
|
4302
|
+
"project-spec": "Специфікація проєкту",
|
|
4303
|
+
"web": "Джерело з інтернету",
|
|
4304
|
+
"general-practice": "Загальна практика"
|
|
4305
|
+
},
|
|
4300
4306
|
"accept": "Прийняти",
|
|
4301
4307
|
"reject": "Відхилити",
|
|
4302
4308
|
"reRequestPlaceholder": "Попросити іншу рекомендацію…",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.194.0",
|
|
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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"valibot": "^1.4.2",
|
|
41
41
|
"vue": "3.5.40",
|
|
42
42
|
"wretch": "^3.0.9",
|
|
43
|
-
"@cat-factory/contracts": "0.
|
|
43
|
+
"@cat-factory/contracts": "0.201.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@toad-contracts/testing": "0.3.2",
|