@cat-factory/app 0.275.0 → 0.276.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.
Files changed (44) hide show
  1. package/README.md +89 -2
  2. package/app/components/board/LaneViewControl.vue +87 -0
  3. package/app/components/board/nodes/BlockNode.vue +24 -10
  4. package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
  5. package/app/components/board/nodes/InitiativeCard.vue +9 -28
  6. package/app/components/board/nodes/LaneGroup.vue +93 -0
  7. package/app/components/board/nodes/LaneTask.vue +66 -0
  8. package/app/components/board/nodes/TaskCard.vue +16 -2
  9. package/app/components/board/nodes/TaskLane.vue +82 -0
  10. package/app/components/layout/BoardToolbar.vue +4 -0
  11. package/app/components/panels/InspectorPanel.vue +15 -2
  12. package/app/components/panels/inspector/TaskStructure.vue +70 -3
  13. package/app/components/settings/WorkspaceSettingsPanel.vue +59 -0
  14. package/app/composables/useBlockDrag.ts +47 -17
  15. package/app/composables/useBlockQueries.ts +27 -24
  16. package/app/composables/useFrameLanes.ts +177 -0
  17. package/app/composables/useTaskExpansion.ts +1 -1
  18. package/app/stores/board/placement.ts +7 -0
  19. package/app/stores/board.spec.ts +119 -14
  20. package/app/stores/laneView.spec.ts +61 -0
  21. package/app/stores/laneView.ts +85 -0
  22. package/app/stores/taskExpansion.spec.ts +1 -1
  23. package/app/stores/taskExpansion.ts +1 -1
  24. package/app/stores/workspaceSettings.ts +4 -0
  25. package/app/utils/framePlacement.ts +9 -4
  26. package/app/utils/laneGeometry.spec.ts +69 -0
  27. package/app/utils/laneGeometry.ts +104 -0
  28. package/app/utils/laneSort.spec.ts +236 -0
  29. package/app/utils/laneSort.ts +306 -0
  30. package/app/utils/swimlanes.spec.ts +259 -0
  31. package/app/utils/swimlanes.ts +355 -0
  32. package/i18n/locales/de.json +78 -3
  33. package/i18n/locales/en.json +78 -3
  34. package/i18n/locales/es.json +78 -3
  35. package/i18n/locales/fr.json +78 -3
  36. package/i18n/locales/he.json +78 -3
  37. package/i18n/locales/it.json +78 -3
  38. package/i18n/locales/ja.json +78 -3
  39. package/i18n/locales/pl.json +78 -3
  40. package/i18n/locales/tr.json +78 -3
  41. package/i18n/locales/uk.json +78 -3
  42. package/package.json +2 -2
  43. package/app/components/board/nodes/DraggableTask.vue +0 -58
  44. package/app/components/board/nodes/ModuleFrame.vue +0 -73
@@ -119,6 +119,7 @@ const prLabel = computed(() =>
119
119
  * every section says "nothing here" would teach people the surface is empty. A task marked done
120
120
  * by hand, with no pull request and no run, is that task.
121
121
  */
122
+ const laneView = useLaneViewStore()
122
123
  const outcomeReadable = computed(() => {
123
124
  const block = task.value
124
125
  if (!block) return false
@@ -136,6 +137,17 @@ const outcomeReadable = computed(() => {
136
137
  const showPrChip = computed(
137
138
  () => Boolean(pr.value) && (uiMode.isAdvanced || !outcomeReadable.value),
138
139
  )
140
+
141
+ /**
142
+ * The module chip, dropped while the swimlanes are GROUPED by module.
143
+ *
144
+ * Written as the invariant ("the card names its module wherever nothing else does") for the same
145
+ * reason `showPrChip` is: the surface that carries the other half is itself conditional, and two
146
+ * predicates that have to agree by coincidence eventually do not.
147
+ */
148
+ const showModuleChip = computed(
149
+ () => Boolean(task.value?.moduleName) && laneView.groupKey !== 'module',
150
+ )
139
151
  function openOutcome() {
140
152
  ui.openOutcome(props.taskId, task.value?.executionId ?? null)
141
153
  }
@@ -539,9 +551,11 @@ function selectTask() {
539
551
  </template>
540
552
  </div>
541
553
 
542
- <!-- structural metadata: assigned module -->
554
+ <!-- Structural metadata: assigned module. Dropped while the lanes are GROUPED by module,
555
+ where the group header above the card already names it — two chips saying the same thing
556
+ cost a row of card height each and add nothing. -->
543
557
  <div
544
- v-if="task.moduleName"
558
+ v-if="showModuleChip"
545
559
  class="mt-2 flex flex-wrap items-center gap-1 border-t border-slate-800 pt-2"
546
560
  >
547
561
  <span
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ import LaneGroup from './LaneGroup.vue'
3
+ import type { RenderedLane } from '~/composables/useFrameLanes'
4
+ import { LANE_GEOMETRY } from '~/utils/laneGeometry'
5
+ import type { LaneGroupKey } from '~/utils/laneSort'
6
+ import { LANE_META } from '~/utils/swimlanes'
7
+
8
+ /**
9
+ * One status lane inside a service frame.
10
+ *
11
+ * The lane BODY is the frame's drop zone, so a card dropped into any of another frame's lanes
12
+ * moves to that service. Which lane it lands in is not a choice a drop can make: the lane is
13
+ * derived from the task's state, so dropping a not-started card on "In progress" would have to
14
+ * either lie or silently ignore the gesture. Reparenting is the one thing a drag decides.
15
+ *
16
+ * The body SCROLLS rather than growing. A lane is a viewport onto an unbounded list, which is
17
+ * what keeps a busy service's frame the same size as a quiet one's; its height comes from the
18
+ * frame, so dragging the frame's border gives the reader more of the lane.
19
+ *
20
+ * It takes the whole {@link RenderedLane} rather than spreading it across three props, so the
21
+ * lane object stays one thing. Restating its fields here also collided the `LaneGroup` NAME with
22
+ * the component of that name imported above, leaving one identifier meaning the interface in
23
+ * type position and the component in value position.
24
+ */
25
+ const props = defineProps<{
26
+ rendered: RenderedLane
27
+ groupKey: LaneGroupKey
28
+ frameId: string
29
+ /** The scroll viewport's height, resolved by the frame from its own size. */
30
+ bodyHeight: number
31
+ }>()
32
+
33
+ const { t } = useI18n()
34
+ const meta = computed(() => LANE_META[props.rendered.lane])
35
+ const isEmpty = computed(() => props.rendered.groups.every((g) => g.entries.length === 0))
36
+ </script>
37
+
38
+ <template>
39
+ <div
40
+ class="flex min-w-0 flex-col rounded-lg bg-slate-900/40"
41
+ :style="{ width: LANE_GEOMETRY.laneWidth + 'px' }"
42
+ :data-lane="rendered.lane"
43
+ >
44
+ <!-- Lane header -->
45
+ <div
46
+ class="flex items-center gap-1.5 rounded-t-lg border-b px-2 py-1.5"
47
+ :style="{ borderColor: meta.color + '33' }"
48
+ >
49
+ <UIcon :name="meta.icon" class="h-3.5 w-3.5 shrink-0" :style="{ color: meta.color }" />
50
+ <span class="truncate text-[11px] font-semibold text-slate-200">{{ t(meta.labelKey) }}</span>
51
+ <span
52
+ class="ms-auto shrink-0 rounded px-1 text-[10px] font-semibold tabular-nums"
53
+ :style="{ backgroundColor: meta.color + '22', color: meta.color }"
54
+ :data-testid="`lane-count-${rendered.lane}`"
55
+ >{{ rendered.total }}</span
56
+ >
57
+ </div>
58
+
59
+ <!-- Lane body: the frame's drop zone, and the scroll viewport. -->
60
+ <div
61
+ :data-drop-zone="frameId"
62
+ :data-testid="`lane-${rendered.lane}`"
63
+ class="nodrag flex-1 space-y-2 overflow-y-auto overflow-x-hidden p-2"
64
+ :style="{ height: bodyHeight + 'px' }"
65
+ >
66
+ <!-- An empty lane SAYS it is empty. Left blank, "nothing needs you" and "the lane failed
67
+ to render" look identical, and the first is worth stating: it is the answer a reader
68
+ scanning the needs-you column is hoping for. -->
69
+ <p v-if="isEmpty" class="px-1 pt-2 text-[10px] leading-snug text-slate-600">
70
+ {{ t(meta.emptyKey) }}
71
+ </p>
72
+ <LaneGroup
73
+ v-for="(group, i) in rendered.groups"
74
+ v-else
75
+ :key="group.label ?? `catch-all-${i}`"
76
+ :group="group"
77
+ :group-key="groupKey"
78
+ :frame-id="frameId"
79
+ />
80
+ </div>
81
+ </div>
82
+ </template>
@@ -2,6 +2,7 @@
2
2
  import { useBoardFlow, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
3
3
  import NotificationsInbox from '~/components/layout/NotificationsInbox.vue'
4
4
  import IconButton from '~/components/common/IconButton.vue'
5
+ import LaneViewControl from '~/components/board/LaneViewControl.vue'
5
6
 
6
7
  const ui = useUiStore()
7
8
  const board = useBoardStore()
@@ -209,6 +210,9 @@ const decisionItems = computed(() =>
209
210
  </UButton>
210
211
  </UDropdownMenu>
211
212
 
213
+ <!-- how every frame's swimlanes are ordered + grouped (an override; advanced tier) -->
214
+ <LaneViewControl />
215
+
212
216
  <!-- in-org sharing: add an existing org service to this board (mount = board.write) -->
213
217
  <UDropdownMenu
214
218
  v-if="mountableItems.length && access.canWriteBoard.value"
@@ -319,10 +319,23 @@ const showOriginalDescription = ref(false)
319
319
  </script>
320
320
 
321
321
  <template>
322
+ <!-- On lg+ the panel is a rail in the board pane's end corner, and it CLEARS the board's top
323
+ overlay region rather than sitting beside it (`top-16`, below the region's toolbar pill).
324
+ That region has one owner, `BoardTopOverlays`, and this rail is deliberately not a member:
325
+ it is an end-anchored side panel, not centred chrome. Clearing the region is how a
326
+ non-member stays out of the owner's way.
327
+ Overlapping is not a cosmetic problem. The toolbar is centred and grows with its contents,
328
+ so at some width its end reaches this corner, and whichever of the two is on top covers the
329
+ other's controls and EATS THEIR CLICKS: the click lands on the box above and no handler
330
+ runs, which reads as a dead button rather than as two overlapping boxes. Adding the
331
+ swimlane view control was the width that finally did it, to the notifications bell (then
332
+ with the panel on top; the region now paints above at `z-40`, which only swaps which side
333
+ loses). Sitting the panel below fixes it whichever way the stacking goes, and needs no
334
+ left/right arithmetic to stay correct under RTL. -->
322
335
  <div
323
336
  v-if="block && statusMeta && typeMeta"
324
337
  data-testid="inspector-panel"
325
- class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur lg:absolute lg:inset-x-auto lg:bottom-auto lg:end-4 lg:top-4 lg:w-80 lg:rounded-2xl"
338
+ class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur lg:absolute lg:inset-x-auto lg:bottom-auto lg:end-4 lg:top-16 lg:w-80 lg:rounded-2xl"
326
339
  >
327
340
  <div class="h-1.5 w-full" :style="{ backgroundColor: statusMeta.color }" />
328
341
  <!-- A tall task (execution steps + scenarios + docs) can overflow the
@@ -331,7 +344,7 @@ const showOriginalDescription = ref(false)
331
344
  On compact viewports the panel is a bottom sheet capped to the visible
332
345
  height (dvh excludes mobile browser chrome). -->
333
346
  <div
334
- class="max-h-[80dvh] space-y-4 overflow-y-auto overscroll-contain px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] lg:max-h-[calc(100vh-5rem)]"
347
+ class="max-h-[80dvh] space-y-4 overflow-y-auto overscroll-contain px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] lg:max-h-[calc(100vh-7rem)]"
335
348
  >
336
349
  <!-- header -->
337
350
  <div class="flex items-start justify-between gap-2">
@@ -7,8 +7,69 @@ const props = defineProps<{ block: Block }>()
7
7
 
8
8
  const board = useBoardStore()
9
9
  const fragments = useFragmentsStore()
10
+ const toast = useToast()
10
11
  const { t } = useI18n()
11
12
 
13
+ // ---- module assignment -----------------------------------------------------
14
+ // This is the module-assignment route that does NOT depend on how the board is currently
15
+ // grouped. Dragging a card onto a module's group header works only while the reader has
16
+ // grouping set to `module`, and module sub-frames no longer render as boxes to drop onto, so
17
+ // without this the only way to move a task into a module would be to first change a view
18
+ // preference. It writes the same two things a drag does: the declared `moduleName` and, when the
19
+ // module block already exists, the structural parent.
20
+ //
21
+ // The field used to be a free-text `UInput` bound with `v-model="block.moduleName"`, which
22
+ // mutated the cached store object and never called `updateBlock` — so a module typed here was
23
+ // silently discarded on the next board refresh. Now that module grouping is a first-class board
24
+ // affordance, a field that looks like it assigns a module and does not would read as the grouping
25
+ // being broken.
26
+ const service = computed(() => board.serviceOf(props.block))
27
+
28
+ /** The modules the enclosing service has materialised, as picker options. */
29
+ const moduleOptions = computed(() => {
30
+ const frame = service.value
31
+ const existing = frame ? board.modulesOf(frame.id).map((m) => m.title) : []
32
+ // A task can DECLARE a module the engine has not created a block for yet (it materialises one
33
+ // on merge), so the task's own value has to be offerable even when no block carries that name,
34
+ // or opening the picker would silently drop it.
35
+ const own = props.block.moduleName?.trim()
36
+ const names = own && !existing.includes(own) ? [...existing, own] : existing
37
+ return [
38
+ { label: t('inspector.structure.moduleNone'), value: '' },
39
+ ...names.map((n) => ({ label: n, value: n })),
40
+ ]
41
+ })
42
+
43
+ const selectedModule = computed(() => props.block.moduleName?.trim() ?? '')
44
+
45
+ async function setModule(name: string) {
46
+ const previous = selectedModule.value
47
+ if (name === previous) return
48
+ try {
49
+ // The declared module is what the engine reads when it materialises the module block on
50
+ // merge, so it is written first and is authoritative. "No module" sends the EMPTY STRING,
51
+ // which is how `updateBlock` spells a clear: `undefined` is dropped by `JSON.stringify`, so
52
+ // it reached the server as an empty patch and the response then restored the old value.
53
+ await board.updateBlock(props.block.id, { moduleName: name })
54
+
55
+ // When a block for that module already exists, move the task under it now rather than waiting
56
+ // for a merge, so the board's grouping matches what was just chosen.
57
+ const frame = service.value
58
+ const target = frame
59
+ ? name
60
+ ? board.modulesOf(frame.id).find((m) => m.title === name)
61
+ : frame
62
+ : undefined
63
+ if (target && target.id !== props.block.parentId) {
64
+ await board.reparentBlock(props.block.id, target.id, { x: 0, y: 0 })
65
+ }
66
+ } catch {
67
+ // `updateBlock`/`reparentBlock` already roll back and toast their own failures; this catch
68
+ // exists so a rejected reparent cannot leave the await chain unhandled.
69
+ // silent-catch-ok: both mutations report their own failure to the user.
70
+ }
71
+ }
72
+
12
73
  // ---- best-practice prompt fragments ----------------------------------------
13
74
  // The task's OWN selection (seeded from its service at creation, then editable per task). The
14
75
  // shared <FragmentSelector> renders the picker; a change persists via updateBlock.
@@ -25,12 +86,18 @@ function setFragments(ids: string[]) {
25
86
  <div class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
26
87
  {{ t('inspector.structure.module') }}
27
88
  </div>
28
- <UInput
29
- v-model="block.moduleName"
89
+ <USelectMenu
90
+ :model-value="selectedModule"
91
+ :items="moduleOptions"
92
+ value-key="value"
30
93
  size="sm"
31
94
  class="w-full"
32
- :placeholder="t('inspector.structure.modulePlaceholder')"
33
95
  icon="i-lucide-package"
96
+ :create-item="true"
97
+ :placeholder="t('inspector.structure.modulePlaceholder')"
98
+ data-testid="task-module-select"
99
+ @update:model-value="setModule"
100
+ @create="setModule"
34
101
  />
35
102
  <p class="mt-1 text-[11px] leading-snug text-slate-500">
36
103
  {{ t('inspector.structure.moduleHint') }}
@@ -197,6 +197,9 @@ const draft = reactive({
197
197
  storeAgentContext: true,
198
198
  publishPrVerificationReport: true,
199
199
  artifactRetentionDays: 14,
200
+ doneLaneMaxItems: 20,
201
+ doneLaneRetentionEnabled: true,
202
+ doneLaneRetentionDays: 14 as number,
200
203
  kaizenEnabled: true,
201
204
  allowInitiatorPat: true,
202
205
  inputGateMode: 'standard' as InputGateMode,
@@ -218,6 +221,12 @@ function hydrate() {
218
221
  draft.storeAgentContext = s.storeAgentContext
219
222
  draft.publishPrVerificationReport = s.publishPrVerificationReport
220
223
  draft.artifactRetentionDays = s.artifactRetentionDays
224
+ draft.doneLaneMaxItems = s.doneLaneMaxItems
225
+ // Nullable (null ⇒ no age cap), so a checkbox is derived from whether a value is stored
226
+ // and the number input keeps a sensible starting value to switch back on with — the same
227
+ // shape the nullable review-friction triggers below use.
228
+ draft.doneLaneRetentionEnabled = s.doneLaneRetentionDays != null
229
+ draft.doneLaneRetentionDays = s.doneLaneRetentionDays ?? 14
221
230
  draft.kaizenEnabled = s.kaizenEnabled
222
231
  draft.allowInitiatorPat = s.allowInitiatorPat
223
232
  draft.inputGateMode = s.inputGateMode
@@ -276,6 +285,8 @@ async function save() {
276
285
  storeAgentContext: draft.storeAgentContext,
277
286
  publishPrVerificationReport: draft.publishPrVerificationReport,
278
287
  artifactRetentionDays: draft.artifactRetentionDays,
288
+ doneLaneMaxItems: draft.doneLaneMaxItems,
289
+ doneLaneRetentionDays: draft.doneLaneRetentionEnabled ? draft.doneLaneRetentionDays : null,
279
290
  kaizenEnabled: draft.kaizenEnabled,
280
291
  allowInitiatorPat: draft.allowInitiatorPat,
281
292
  inputGateMode: draft.inputGateMode,
@@ -540,6 +551,54 @@ async function save() {
540
551
  </label>
541
552
  </section>
542
553
 
554
+ <!-- What the board's Done swimlane keeps in view -->
555
+ <section class="space-y-2">
556
+ <h3 class="text-sm font-semibold text-slate-200">
557
+ {{ t('settings.workspaceSettings.doneLane.heading') }}
558
+ </h3>
559
+ <p class="text-[11px] text-slate-400">
560
+ {{ t('settings.workspaceSettings.doneLane.body') }}
561
+ </p>
562
+ <label class="block w-48">
563
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
564
+ {{ t('settings.workspaceSettings.doneLane.maxItems') }}
565
+ </span>
566
+ <UInput
567
+ v-model.number="draft.doneLaneMaxItems"
568
+ type="number"
569
+ :min="0"
570
+ :max="500"
571
+ size="sm"
572
+ data-testid="done-lane-max-items"
573
+ />
574
+ </label>
575
+ <p v-if="draft.doneLaneMaxItems === 0" class="text-[11px] text-slate-500">
576
+ {{ t('settings.workspaceSettings.doneLane.zeroHint') }}
577
+ </p>
578
+ <label class="flex items-center gap-2">
579
+ <UCheckbox v-model="draft.doneLaneRetentionEnabled" size="sm" />
580
+ <span class="text-[11px] text-slate-300">{{
581
+ t('settings.workspaceSettings.doneLane.ageToggle')
582
+ }}</span>
583
+ </label>
584
+ <label v-if="draft.doneLaneRetentionEnabled" class="block w-48">
585
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
586
+ {{ t('settings.workspaceSettings.doneLane.days') }}
587
+ </span>
588
+ <UInput
589
+ v-model.number="draft.doneLaneRetentionDays"
590
+ type="number"
591
+ :min="1"
592
+ :max="3650"
593
+ size="sm"
594
+ data-testid="done-lane-retention-days"
595
+ />
596
+ </label>
597
+ <p class="text-[11px] text-slate-500">
598
+ {{ t('settings.workspaceSettings.doneLane.hidesOnlyHint') }}
599
+ </p>
600
+ </section>
601
+
543
602
  <!-- Run credential: the App installation vs. the initiator's own token -->
544
603
  <section class="space-y-2">
545
604
  <h3 class="text-sm font-semibold text-slate-200">
@@ -9,11 +9,18 @@ const draggingId = ref<string | null>(null)
9
9
 
10
10
  /**
11
11
  * Pointer-driven dragging for blocks positioned inside a container's 2D canvas
12
- * (tasks inside services/modules, modules inside services) and for free-floating
13
- * service frames (via their header handle). Movement is divided by the board zoom
14
- * so the block tracks the cursor. When `reparent` is set, the drop point is
15
- * hit-tested against `[data-drop-zone]` ancestors so a task can be dragged from a
16
- * service into a module (or back out).
12
+ * (initiative cards inside services) and for free-floating service frames (via
13
+ * their header handle). Movement is divided by the board zoom so the block tracks
14
+ * the cursor. When `reparent` is set, the drop point is hit-tested against
15
+ * `[data-drop-zone]` ancestors so a block can be dragged from a service into a
16
+ * module (or back out).
17
+ *
18
+ * A TASK is a `positioned: false` drag, because tasks are laid out in swimlanes and
19
+ * carry no coordinates a reader can see. Such a drag previews nothing and commits
20
+ * nothing on a same-container drop: its ONLY effect is a reparent, which is what a
21
+ * task drag is still for (moving work between services, and into or out of a module).
22
+ * A position write there would persist coordinates nothing renders and emit a board
23
+ * event for a change with no visible result.
17
24
  */
18
25
  export function useBlockDrag() {
19
26
  const board = useBoardStore()
@@ -23,7 +30,7 @@ export function useBlockDrag() {
23
30
  function startDrag(
24
31
  block: Block,
25
32
  e: PointerEvent,
26
- opts: { reparent?: boolean; clamp?: boolean } = {},
33
+ opts: { reparent?: boolean; clamp?: boolean; positioned?: boolean } = {},
27
34
  ) {
28
35
  if (e.button !== 0) return
29
36
  // Read-only viewers can pan/inspect but never move or reparent a block — the drag
@@ -35,9 +42,11 @@ export function useBlockDrag() {
35
42
  const startX = e.clientX
36
43
  const startY = e.clientY
37
44
  const orig = { ...block.position }
38
- // Container-local blocks (tasks/modules) are clamped to their parent's origin;
39
- // frames live in free-floating flow space, so they opt out via `clamp: false`.
45
+ // Container-local blocks (initiative cards) are clamped to their parent's origin;
46
+ // frames live in free-floating flow space, so they opt out via `clamp: false`. Inert for
47
+ // a `positioned: false` drag, which never writes a position at all.
40
48
  const clamp = opts.clamp ?? true
49
+ const positioned = opts.positioned ?? true
41
50
  draggingId.value = block.id
42
51
  // Position is only previewed locally while dragging and persisted once on
43
52
  // release. Writing every move raced — a late, out-of-order response could land
@@ -51,7 +60,10 @@ export function useBlockDrag() {
51
60
  const ny = orig.y + (ev.clientY - startY) / z
52
61
  moved = true
53
62
  last = { x: clamp ? Math.max(0, nx) : nx, y: clamp ? Math.max(0, ny) : ny }
54
- board.previewMove(block.id, last)
63
+ // A lane task has nowhere to preview TO: its place in the column is derived from its
64
+ // status and the reader's sort, so following the cursor would be a lie the drop then
65
+ // undoes. The `draggingId` state the card dims itself with is the whole feedback.
66
+ if (positioned) board.previewMove(block.id, last)
55
67
  }
56
68
  const onUp = (ev: PointerEvent) => {
57
69
  window.removeEventListener('pointermove', onMove)
@@ -60,9 +72,9 @@ export function useBlockDrag() {
60
72
  // A successful reparent persists the move itself; otherwise commit the final
61
73
  // position in place. Either way it's a single write, not one per frame. Run
62
74
  // the hit-test BEFORE clearing draggingId so the dragged element is still
63
- // marked non-interactive (see DraggableTask) and the zone beneath resolves.
64
- const reparented = opts.reparent && reparentAt(block, ev.clientX, ev.clientY)
65
- if (!reparented) void board.moveBlock(block.id, last)
75
+ // marked non-interactive (see LaneTask) and the zone beneath resolves.
76
+ const reparented = opts.reparent && reparentAt(block, ev.clientX, ev.clientY, positioned)
77
+ if (!reparented && positioned) void board.moveBlock(block.id, last)
66
78
  }
67
79
  draggingId.value = null
68
80
  }
@@ -71,10 +83,15 @@ export function useBlockDrag() {
71
83
  }
72
84
 
73
85
  /** Returns true when the block was dropped into a *different* container. */
74
- function reparentAt(block: Block, clientX: number, clientY: number): boolean {
86
+ function reparentAt(
87
+ block: Block,
88
+ clientX: number,
89
+ clientY: number,
90
+ positioned: boolean,
91
+ ): boolean {
75
92
  const el = document.querySelector(`[data-block-id="${block.id}"]`) as HTMLElement | null
76
93
  if (!el) return false
77
- // The dragged block is already non-interactive while dragging (DraggableTask
94
+ // The dragged block is already non-interactive while dragging (LaneTask
78
95
  // drops pointer-events on the whole wrapper, handle included); belt-and-braces,
79
96
  // also neutralise this node so elementFromPoint resolves the zone beneath it.
80
97
  const prev = el.style.pointerEvents
@@ -87,14 +104,27 @@ export function useBlockDrag() {
87
104
  const newParent = zoneEl.getAttribute('data-drop-zone')!
88
105
  if (newParent === block.parentId) return false // same container — caller commits position
89
106
 
107
+ void board.reparentBlock(block.id, newParent, positionIn(zoneEl, el, positioned))
108
+ return true
109
+ }
110
+
111
+ /**
112
+ * Where the dropped block lands in its new container.
113
+ *
114
+ * A lane task gets the origin, not the coordinates it happened to be released over.
115
+ * Its place in the new container is derived from its status and the reader's sort, so a
116
+ * captured offset would be a coordinate nothing reads and every later reader would have
117
+ * to wonder whether it meant something.
118
+ */
119
+ function positionIn(zoneEl: HTMLElement, el: HTMLElement, positioned: boolean) {
120
+ if (!positioned) return { x: 0, y: 0 }
90
121
  const z = ui.zoom || 1
91
122
  const zr = zoneEl.getBoundingClientRect()
92
123
  const er = el.getBoundingClientRect()
93
- void board.reparentBlock(block.id, newParent, {
124
+ return {
94
125
  x: Math.max(0, (er.left - zr.left) / z),
95
126
  y: Math.max(0, (er.top - zr.top) / z),
96
- })
97
- return true
127
+ }
98
128
  }
99
129
 
100
130
  return { draggingId, startDrag }
@@ -1,5 +1,6 @@
1
1
  import { computed, type Ref } from 'vue'
2
2
  import type { Block, BlockStatus } from '~/types/domain'
3
+ import { frameContentSize, LANE_GEOMETRY } from '~/utils/laneGeometry'
3
4
 
4
5
  /**
5
6
  * Pure, read-only queries over a board's blocks. Extracted from the board store
@@ -162,33 +163,35 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
162
163
  }
163
164
 
164
165
  /**
165
- * The natural extent of a container's inner 2D canvas — the smallest size that
166
- * still fits all its children. This is the floor a resizable frame can never be
167
- * dragged below (so tasks/modules are never clipped).
166
+ * The natural extent of a frame's inner canvas — the smallest size that fits its swimlanes
167
+ * and its initiative band. This is the floor a resizable frame can never be dragged below.
168
+ *
169
+ * Task positions are deliberately NOT consulted any more. Tasks are laid out in status
170
+ * lanes, so the frame's size is a function of the LANE GEOMETRY, not of where cards happen
171
+ * to sit, and each lane SCROLLS rather than growing without bound. That decoupling is what
172
+ * fixes the old behaviour where a service accumulating work grew a taller and taller frame
173
+ * until it dwarfed its neighbours, and it is also what keeps this function pure over blocks:
174
+ * a lane's population depends on run state, which this layer cannot see and must not need to.
175
+ *
176
+ * The arithmetic itself lives in `frameContentSize` so the placement helper, which sizes a
177
+ * frame that does not exist yet, reserves the same footprint this one will render at.
168
178
  */
169
179
  function contentSize(id: string): { w: number; h: number } {
170
180
  const b = getBlock(id)
171
- const isModule = b?.level === 'module'
172
- const TASK_W = 210
173
- const TASK_H = 160
174
- const headerH = isModule ? 30 : 0
175
- let w = isModule ? 200 : 360
176
- let inner = isModule ? 60 : 220
177
- for (const t of tasksOf(id)) {
178
- w = Math.max(w, t.position.x + TASK_W + 12)
179
- inner = Math.max(inner, t.position.y + TASK_H + 12)
180
- }
181
- for (const m of modulesOf(id)) {
182
- const s = containerSize(m.id)
183
- w = Math.max(w, m.position.x + s.w + 12)
184
- inner = Math.max(inner, m.position.y + s.h + 12)
185
- }
186
- // Initiative cards render inside the frame's drop zone like tasks (230×~170).
187
- for (const i of initiativesOf(id)) {
188
- w = Math.max(w, i.position.x + 230 + 12)
189
- inner = Math.max(inner, i.position.y + 170 + 12)
190
- }
191
- return { w, h: inner + headerH }
181
+ // A module is no longer drawn as a box (its tasks appear in the frame's lanes, grouped by
182
+ // module name), so it has no canvas of its own. A minimal size keeps any incidental caller
183
+ // honest rather than returning zero, which would read as "measured, and empty".
184
+ if (b?.level === 'module') return { w: LANE_GEOMETRY.laneWidth, h: 0 }
185
+
186
+ const initiatives = initiativesOf(id)
187
+ return frameContentSize({
188
+ // The predicate `BlockNode` renders the lanes on: an empty service shows one "add the first
189
+ // task" panel instead, and reserving lane-sized space for it would leave the frame two and
190
+ // a half times taller than its own contents.
191
+ hasChildren:
192
+ allTasksUnder(id).length > 0 || modulesOf(id).length > 0 || initiatives.length > 0,
193
+ initiatives: initiatives.length,
194
+ })
192
195
  }
193
196
 
194
197
  /**