@cat-factory/app 0.192.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.
@@ -387,13 +387,15 @@ const fragmentPool = computed(() => fragments.forBlockType(frame.value?.type ??
387
387
 
388
388
  // Hide UI-testing pipelines (`tester-ui` / `visual-confirmation`) when the target frame has no
389
389
  // UI to exercise — they'd be refused server-side (see utils/pipeline + the backend gate). Also
390
- // hide `'recurring'`-only pipelines (a one-off task start of one is refused at run start) and,
391
- // for a `document` / `review` task, every pipeline whose purpose doesn't match (a doc task authors
392
- // a doc, a review task reviews a PR only document / review pipelines are relevant, per the
393
- // `purpose` classifier). Re-filters as the chosen task type changes.
390
+ // hide `'recurring'`-only pipelines (a one-off task start of one is refused at run start) and every
391
+ // pipeline whose purpose doesn't match the chosen task type (a doc task authors a doc, a review task
392
+ // reviews a PR, and a `feature`/`bug` task ships codeso it is offered build + research only, not
393
+ // the doc/review/planning presets). `blockLevel: 'task'` is passed literally because this modal only
394
+ // ever creates a task leaf, which also drops the three planning presets the backend would refuse.
395
+ // Re-filters as the chosen task type changes.
394
396
  const selectablePipelines = computed(() =>
395
397
  pipelines.pipelines.filter((p) =>
396
- pipelineAllowedForManualStart(p, frame.value, board.blocks, taskType.value),
398
+ pipelineAllowedForManualStart(p, frame.value, board.blocks, taskType.value, 'task'),
397
399
  ),
398
400
  )
399
401
  // Some task types want their type-default pipeline surfaced in the modal up front, so picking the
@@ -74,9 +74,15 @@ const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
74
74
 
75
75
  // Infer the template from the picked pipeline so the backend seeds the right block
76
76
  // description (and so we know to show the tracker config).
77
+ //
78
+ // Only the pipelines whose SHAPE is specific to one kind of recurring work can be inferred this
79
+ // way. `dep-update` no longer can: its pipeline was retired in the catalog collapse (it was the
80
+ // ordinary build tail under a recurring name), so a dependency-update schedule now runs an ordinary
81
+ // build rung — which is also what every generic schedule runs, so inferring the template from it
82
+ // would mislabel all of them. The template itself survives for an explicit API caller; see
83
+ // `scheduleTemplateSchema`.
77
84
  const template = computed<ScheduleTemplate>(() => {
78
85
  if (pipelineId.value === 'pl_tech_debt') return 'tech-debt'
79
- if (pipelineId.value === 'pl_dep_update') return 'dep-update'
80
86
  if (pipelineId.value === 'pl_bug_triage') return 'bug-triage'
81
87
  return 'custom'
82
88
  })
@@ -99,11 +105,10 @@ watch(open, (isOpen) => {
99
105
  if (!isOpen) return
100
106
  name.value = ''
101
107
  description.value = ''
102
- // Default to the Dependency-updates pipeline if present, else the first.
103
- pipelineId.value =
104
- pipelines.pipelines.find((p) => p.id === 'pl_dep_update')?.id ??
105
- pipelines.pipelines[0]?.id ??
106
- ''
108
+ // Default to the first schedulable pipeline, which is the ladder's own default rung. There is no
109
+ // longer a canned recurring build preset to prefer — the dependency-update pipeline was the
110
+ // ordinary build tail under another name — so the default build is the honest starting point.
111
+ pipelineId.value = selectablePipelines.value[0]?.id ?? pipelines.pipelines[0]?.id ?? ''
107
112
  recurrence.value = defaultRecurrence()
108
113
  onDemand.value = false
109
114
  saving.value = false
@@ -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>
@@ -30,12 +30,20 @@ const deps = computed(() =>
30
30
  )
31
31
 
32
32
  // Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
33
- // pipelines (a manual run of one is refused server-side), and for a `document` task — every
34
- // non-document pipeline (per the `purpose` classifier) — see the backend gate.
33
+ // pipelines (a manual run of one is refused server-side), and every pipeline whose purpose doesn't
34
+ // match this block's task type or LEVEL (per the `purpose` classifier) — see the backend gate. The
35
+ // level is what keeps the planning presets on initiative blocks and off everything else, in both
36
+ // directions, so this menu never offers a run the engine answers with a 409.
35
37
  const runOptions = computed(() => {
36
38
  const frame = block.value ? board.serviceOf(block.value) : undefined
37
39
  return pipelines.pipelines.filter((p) =>
38
- pipelineAllowedForManualStart(p, frame, board.blocks, block.value?.taskType),
40
+ pipelineAllowedForManualStart(
41
+ p,
42
+ frame,
43
+ board.blocks,
44
+ block.value?.taskType,
45
+ block.value?.level,
46
+ ),
39
47
  )
40
48
  })
41
49
 
@@ -178,13 +178,24 @@ const taskBranchUrl = computed(() => {
178
178
  return base ? `${base}/tree/${pr.branch}` : null
179
179
  })
180
180
 
181
- // Hide UI-testing pipelines when this block's frame has no UI to exercise, and `'recurring'`-only
182
- // pipelines (a manual run of one is refused server-side) they'd be refused at run start (see
183
- // utils/pipeline + the backend gate).
181
+ // Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
182
+ // pipelines (a manual run of one is refused server-side), and every pipeline whose purpose doesn't
183
+ // match this block's task type or LEVEL — they'd be refused at run start (see utils/pipeline + the
184
+ // backend gate). The level is what keeps the planning presets on initiative blocks and off
185
+ // everything else, so this menu never offers a run the engine answers with a 409; it applies to
186
+ // frames and modules too, which is why it reads `block.level` rather than assuming a task.
184
187
  const runMenu = computed(() => {
185
188
  const frame = block.value ? board.serviceOf(block.value) : undefined
186
189
  return pipelines.pipelines
187
- .filter((p) => pipelineAllowedForManualStart(p, frame, board.blocks))
190
+ .filter((p) =>
191
+ pipelineAllowedForManualStart(
192
+ p,
193
+ frame,
194
+ board.blocks,
195
+ block.value?.taskType,
196
+ block.value?.level,
197
+ ),
198
+ )
188
199
  .map((p) => ({
189
200
  label: p.name,
190
201
  icon: 'i-lucide-play',
@@ -144,10 +144,14 @@ const selectedPipeline = computed(() =>
144
144
  // pipelines (the task's manual Run control can't start one), and — for a `document` task — every
145
145
  // non-document pipeline (it authors a doc, so a build/test pipeline makes no sense). All would be
146
146
  // refused / wrong at run start (see utils/pipeline + the backend gate + the purpose classifier).
147
+ // `blockLevel: 'task'` is passed literally because this panel only ever edits a task leaf, which
148
+ // drops the planning presets the engine would refuse. The task-type narrowing already excludes them
149
+ // for `feature`/`bug`, but NOT for a `spike` / `ralph` / deployment-custom type — and a planning
150
+ // preset settable as a task's DEFAULT pipeline is a 409 on every later Start.
147
151
  const taskFrame = computed(() => board.serviceOf(props.block))
148
152
  const selectablePipelines = computed(() =>
149
153
  pipelines.pipelines.filter((p) =>
150
- pipelineAllowedForManualStart(p, taskFrame.value, board.blocks, props.block.taskType),
154
+ pipelineAllowedForManualStart(p, taskFrame.value, board.blocks, props.block.taskType, 'task'),
151
155
  ),
152
156
  )
153
157
  function setPipeline(id: string) {
@@ -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 }