@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.
@@ -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
- <div class="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
567
- <span>{{
568
- t('board.frame.implemented', { merged: mergedTasks, total: taskCount })
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
- <!-- resize handles (drag the borders to resize the module, Miro-style).
78
- `nopan` (with `nodrag`) so resizing doesn't pan the pane. Kept PHYSICAL
79
- (`right-0`, not `end-0`) for the same reason as the service-frame grips in
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,
@@ -44,3 +44,5 @@ export interface ApiContext {
44
44
  }
45
45
 
46
46
  export type Position = { x: number; y: number }
47
+ /** An explicit pixel size for a resizable container (a service frame or a module). */
48
+ export type Size = { w: number; h: number }
@@ -2,56 +2,150 @@ import { ref } from 'vue'
2
2
  import type { Block } from '~/types/domain'
3
3
 
4
4
  /**
5
- * Pointer-driven resizing for service frames (Miro-style border drag). The drag
6
- * delta is divided by the board zoom so the edge tracks the cursor, and the new
7
- * size is clamped to the frame's content extent so dragging in never clips the
8
- * tasks/modules inside. The frame grows live (the store block is mutated in place,
9
- * which `containerSize` reads back), and the final size is persisted once on
10
- * release rather than on every move.
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 frame currently being resized, for cursor/handle styling. */
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
- * Begin a resize from one of the frame's edges/corner. `edge` selects which
21
- * dimensions move: `'e'` width only, `'s'` height only, `'se'` both.
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 startResize(block: Block, e: PointerEvent, edge: 'e' | 's' | 'se') {
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 a frame persists its size — a `board.write` mutation, so a read-only
26
- // viewer's resize no-ops (the grips are hidden for them at the component level).
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
- // The content extent is the floor never shrink a frame below its tasks.
33
- const min = board.contentSize(block.id)
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 w = edge === 's' ? start.w : Math.max(min.w, start.w + (ev.clientX - startX) / z)
41
- const h = edge === 'e' ? start.h : Math.max(min.h, start.h + (ev.clientY - startY) / z)
42
- // Optimistic, local-only: mutate the cached block so the frame grows live
43
- // without a round-trip on every pointer move.
44
- block.size = { w: Math.round(w), h: Math.round(h) }
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
- // Persist the final size once (also re-applies it as the authoritative block).
51
- if (block.size) void board.updateBlock(block.id, { size: block.size })
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,
@@ -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 (
@@ -15,6 +15,7 @@ export type {
15
15
  RequirementReviewItem,
16
16
  RequirementReviewStatus,
17
17
  ResolveRequirementsExceededChoice,
18
+ RecommendationSource,
18
19
  RecommendationStatus,
19
20
  RequirementRecommendation,
20
21
  RequirementReview,
@@ -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…",
@@ -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…",
@@ -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…",
@@ -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…",
@@ -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": "בקש המלצה אחרת…",
@@ -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…",
@@ -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": "別の推奨をリクエスト…",
@@ -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ę…",
@@ -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…",
@@ -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.193.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.200.0"
43
+ "@cat-factory/contracts": "0.201.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",