@cat-factory/app 0.274.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 (69) hide show
  1. package/README.md +158 -4
  2. package/app/components/board/LaneViewControl.vue +87 -0
  3. package/app/components/board/nodes/BlockNode.vue +31 -11
  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/layout/CommandBar.vue +8 -1
  12. package/app/components/layout/RolePrompt.vue +75 -0
  13. package/app/components/layout/SideBar.vue +22 -13
  14. package/app/components/layout/UiRoleSwitcher.vue +73 -0
  15. package/app/components/panels/InspectorPanel.vue +15 -2
  16. package/app/components/panels/inspector/TaskStructure.vue +70 -3
  17. package/app/components/settings/WorkspaceSettingsPanel.vue +59 -0
  18. package/app/composables/useBlockDrag.ts +47 -17
  19. package/app/composables/useBlockQueries.ts +27 -24
  20. package/app/composables/useFrameLanes.ts +177 -0
  21. package/app/composables/useNavContributions.ts +3 -0
  22. package/app/composables/useTaskExpansion.ts +1 -1
  23. package/app/docs/consumer-extensions.md +20 -6
  24. package/app/modular/external-tools.spec.ts +0 -45
  25. package/app/modular/external-tools.ts +12 -23
  26. package/app/modular/nav-contributions.spec.ts +176 -17
  27. package/app/modular/nav-contributions.ts +106 -23
  28. package/app/modular/nav-gates.ts +11 -2
  29. package/app/modular/registry.spec.ts +1 -0
  30. package/app/modular/tutorial-tours.spec.ts +5 -3
  31. package/app/modular/tutorial-tours.ts +53 -8
  32. package/app/pages/index.vue +36 -7
  33. package/app/stores/board/placement.ts +7 -0
  34. package/app/stores/board.spec.ts +119 -14
  35. package/app/stores/laneView.spec.ts +61 -0
  36. package/app/stores/laneView.ts +85 -0
  37. package/app/stores/launchPrompt.ts +63 -0
  38. package/app/stores/taskExpansion.spec.ts +1 -1
  39. package/app/stores/taskExpansion.ts +1 -1
  40. package/app/stores/tutorial.ts +4 -4
  41. package/app/stores/uiMode.spec.ts +11 -0
  42. package/app/stores/uiMode.ts +14 -2
  43. package/app/stores/uiRole.spec.ts +185 -0
  44. package/app/stores/uiRole.ts +86 -0
  45. package/app/stores/workspaceSettings.ts +4 -0
  46. package/app/utils/framePlacement.ts +9 -4
  47. package/app/utils/laneGeometry.spec.ts +69 -0
  48. package/app/utils/laneGeometry.ts +104 -0
  49. package/app/utils/laneSort.spec.ts +236 -0
  50. package/app/utils/laneSort.ts +306 -0
  51. package/app/utils/swimlanes.spec.ts +259 -0
  52. package/app/utils/swimlanes.ts +355 -0
  53. package/app/utils/uiMode.spec.ts +12 -0
  54. package/app/utils/uiMode.ts +24 -6
  55. package/app/utils/uiRole.ts +123 -0
  56. package/i18n/locales/de.json +104 -3
  57. package/i18n/locales/en.json +110 -3
  58. package/i18n/locales/es.json +104 -3
  59. package/i18n/locales/fr.json +104 -3
  60. package/i18n/locales/he.json +104 -3
  61. package/i18n/locales/it.json +104 -3
  62. package/i18n/locales/ja.json +104 -3
  63. package/i18n/locales/pl.json +104 -3
  64. package/i18n/locales/tr.json +104 -3
  65. package/i18n/locales/uk.json +104 -3
  66. package/package.json +2 -2
  67. package/app/components/board/nodes/DraggableTask.vue +0 -58
  68. package/app/components/board/nodes/ModuleFrame.vue +0 -73
  69. package/app/stores/tutorial.prompt.ts +0 -59
package/README.md CHANGED
@@ -15,6 +15,8 @@ The SPA source lives under `app/` (the Nuxt srcDir).
15
15
  - [What it is](#what-it-is)
16
16
  - [Tech stack](#tech-stack)
17
17
  - [Layout](#layout)
18
+ - [Task swimlanes](#task-swimlanes)
19
+ - [Roles (engineer / product manager / designer)](#roles-engineer--product-manager--designer)
18
20
  - [Interface modes (basic / advanced)](#interface-modes-basic--advanced)
19
21
  - [Agent tiers (basic / intermediate / advanced)](#agent-tiers-basic--intermediate--advanced)
20
22
  - [In-app tutorial tours](#in-app-tutorial-tours)
@@ -239,6 +241,153 @@ surface rather than a toast, and are tracked as G4 in
239
241
 
240
242
  A status → chip map feeding a `<UBadge :color="…">` types its values as `BadgeColor` (`utils/badge.ts`), which is derived from `UBadge`'s own prop type rather than restated as a literal union. Typed `string`, the binding does not compile and the reflex is `as any` at each call site: seven of them had accumulated. That cast also accepts a colour Nuxt UI does not define, which renders as an unstyled badge with nothing failing.
241
243
 
244
+ ## Task swimlanes
245
+
246
+ A service frame lays its tasks out in **status lanes**, not at coordinates. Three lanes a reader
247
+ works in (`not_started`, `in_progress`, `needs_you`) plus a collapsed **Done** strip beneath them.
248
+ The vocabulary, the classification and the Done caps are `app/utils/swimlanes.ts`; the ordering and
249
+ grouping are `app/utils/laneSort.ts`; `composables/useFrameLanes.ts` is the only store-facing half.
250
+
251
+ **A lane is a CLAIM, which is a higher bar than a badge.** A mislabelled badge sits beside the
252
+ truth; a card filed in the wrong lane states something false _and_ hides the card from the column
253
+ its reader was scanning. Three rules follow, and they are what the tests pin:
254
+
255
+ - **The classification is TOTAL.** `BASE_REASON_BY_STATUS` is a `Record<BlockStatus, …>`, so a new
256
+ status fails the build; and a status the TYPE says is impossible while the DATABASE still holds it
257
+ (a retired picklist member on an old row) resolves to `unclassified` → `needs_you`, never to
258
+ `undefined`, which would drop the card out of every lane with nothing left to say it existed.
259
+ - **An imprecise reason beats a wrong lane.** The SPA models only decisions and approvals as global
260
+ per-block selectors; a judge / human-test / visual-confirmation / fork / follow-up / input-gate
261
+ park is reachable only by drilling into a step. But every one of them parks the RUN at
262
+ `status: 'blocked'`, and that coarse marker places the card correctly even when this layer cannot
263
+ name the surface. Such a park reports `parked` — named but imprecise, never demoted to "in flight".
264
+ - **The reason is a separate answer from the lane.** The lane says "look at this"; the reason says
265
+ "what would I do". `failed`, `budget_paused` and `approval` all stop work dead and need three
266
+ unrelated actions, which is why `blocking_reason` is a grouping and why a lane header can name it.
267
+
268
+ **Ordering defaults per lane** (`smart`), because the actionable order genuinely differs by column:
269
+ what can be started now / what has gone quiet / what has waited longest. Two rules bind any new
270
+ comparator: an **unknown timestamp sorts last in BOTH directions** (a run that reported no activity
271
+ is not stale and not fresh, and ranking it as either invents a fact), and **every comparator ends on
272
+ board order**, since these re-run on every live push and an unresolved tie makes the lane shuffle
273
+ itself. `nullsLast` is the single place the first rule lives.
274
+
275
+ **Sizing is decoupled from content** (`utils/laneGeometry.ts`). A lane scrolls; it does not grow. A
276
+ service with 300 open tasks gets the same frame as one with three, which is what the old free layout
277
+ could not do — a busy service grew until it dwarfed its neighbours.
278
+
279
+ Three consumers have to agree about that size, which is why the module exports two FUNCTIONS rather
280
+ than leaving each to do the arithmetic: `frameContentSize` is the frame's floor (read by
281
+ `contentSize`, and by `framePlacement.EMPTY_FRAME_SIZE`, which has to reserve a spot for a frame
282
+ that does not exist yet and so cannot measure it), and `laneBodyHeightIn` is its inverse, handing a
283
+ lane whatever the frame's ACTUAL size leaves it. A dragged border therefore grows the lanes rather
284
+ than leaving dead canvas below them, and a constant restating either answer is exactly what went
285
+ stale before. A frame with no children at all is the one that skips the lanes: it renders one "add
286
+ the first task" panel and is sized for that.
287
+
288
+ **Two preferences, deliberately different scopes.** The sort/group choice is per user, per browser
289
+ (`stores/laneView.ts`, persisted like the interface tier): it is personal and changes several times
290
+ an hour, and making it shared would let one person's triage sweep re-arrange everyone's board. The
291
+ Done lane's two **caps** are per-workspace settings (`doneLaneMaxItems`, `doneLaneRetentionDays`),
292
+ because what the board may show of a service's history is a shared decision. Both caps hide cards
293
+ only, and the lane reports the two drop counts separately: an age drop means "there is older
294
+ history", a cap drop means "there is more from this period".
295
+
296
+ **A task with no `completedAt` is exempt from the age cap**, not treated as ancient. Every block
297
+ merged before that column existed reads that way, and hiding history on the strength of a timestamp
298
+ nobody recorded would be the platform inferring a fact it does not have. The count cap still bounds
299
+ them, so the exemption cannot make the lane unbounded, and `undatedShown` says how many there are.
300
+
301
+ **A FRAME still has coordinates, and they live on its `WorkspaceMount`, not on the block** (one
302
+ shared service sits at a different spot on every board that mounts it), so every frame-returning read
303
+ projects through kernel's `applyMountLayout`. The resize path is where people hit a missed projection,
304
+ because a `size`-only edit is the one frame patch with no other visible effect: the SPA upserts the
305
+ authoritative block the mutation returned and the frame jumps to coordinates no board shows it at.
306
+
307
+ **Dragging a card now only reparents** (`positioned: false` in `useBlockDrag`): between services,
308
+ and into or out of a module via a module group header's drop zone. Which LANE a card is in is not
309
+ something a drop can decide — the lane is derived from state, so dropping a not-started card on
310
+ "In progress" could only lie or silently do nothing. Because that drop target exists only while the
311
+ reader has grouping set to `module`, the inspector carries a **module picker** that does not depend
312
+ on the current grouping; module sub-frames no longer render as boxes, so without it the only route
313
+ into a module would be to change a view preference first.
314
+
315
+ **A move re-stamps the module the task DECLARES**, which is the one thing about that drag that is
316
+ not obvious. A task names its module twice: the block it is parented to, and `moduleName`, which
317
+ exists because the engine only materialises the module block on merge, so a task can name its module
318
+ before anything is its parent. Grouping reads the parent and falls back to the declared name — so a
319
+ card dragged OUT of a module and left still declaring it lands right back in the group it came from.
320
+ `BoardService.reparent` therefore rewrites the name from the destination container, exactly as it
321
+ already rewrote the `type` a task inherits from its frame, and the SPA's optimistic write predicts
322
+ the same answer through the shared `moduleNameInContainer` (`@cat-factory/contracts`) so the card
323
+ does not visibly jump when the response lands. "No module" is the EMPTY STRING on the wire, the way
324
+ every other clearable field spells a clear; `undefined` is dropped by `JSON.stringify` and reaches
325
+ the server as an empty patch.
326
+
327
+ ## Roles (engineer / product manager / designer)
328
+
329
+ The outermost of the three narrowing axes, and the only one the app **asks about**: on a first-ever
330
+ launch it puts up one question, "what do you work on?", offering `engineer`, `product-manager` and
331
+ `designer` with a line each on what picking it gives you. Vocabulary, resolution and the
332
+ presentation table are in `app/utils/uiRole.ts`; the choice and its once-per-session prompt live in
333
+ the `uiRole` store.
334
+
335
+ Two roles, three names. `engineer` and `product-manager` both map to the **`full`** surface, and
336
+ that is the product decision rather than an oversight: the two do the same job in this app (plan
337
+ work on a board, run it, review and merge it), and what makes the question answerable is the copy a
338
+ person recognises themselves in. `designer` maps to **`intake`**: the services already on the board,
339
+ the work in flight on them, and the routes that bring new work IN (a new task, a task from a tracker
340
+ ticket, a task from a design). None of the platform configuration behind that. They are separate
341
+ `UiRole` members precisely so one of them can gain a surface the other does not without a migration.
342
+
343
+ - **The default is the FULL surface, and an unanswered question changes nothing.** Closing the prompt
344
+ writes no choice, so the next launch asks again and the person keeps the whole product in the
345
+ meantime. There is deliberately no "don't ask me again": an unanswered question costs nothing,
346
+ where a wrongly-recorded one costs somebody destinations they need. It is also why the prompt
347
+ yields to every startup advisory (and why the tour offer, in turn, yields to it: the role decides
348
+ which surfaces exist, so a tour picked ahead of it could be about half a product).
349
+ - **It is not authorization.** Workspace RBAC (ADR 0025) decides what a request may do and is
350
+ enforced server-side; this decides what the SPA OFFERS, and every role's surface is still gated by
351
+ the caller's permissions on top. Nothing here can widen what a person may do, and everything it
352
+ hides is something they may well be allowed to open, which is why the **way back is reachable from
353
+ inside the narrowed role**: the switcher at the top of the sidebar (rendered in every role) plus a
354
+ command-palette entry, both `intake`.
355
+ - **There is NO deployment env pin**, unlike the interface tier. Which tier a fleet of kiosk-ish
356
+ deployments shows is a decision an operator can reasonably make; which JOB the person at the
357
+ keyboard does is not something a build can know.
358
+ - **A narrowed role CAPS the interface tier at basic** (`resolveUiMode` takes the surface and answers
359
+ `basic` for `intake`, ahead of the env pin). Resolved rather than merely hidden, so every
360
+ `isAdvanced` reader inside a surface agrees with the narrowed nav without restating the role, and
361
+ the tier switcher is dropped for that role: a control that flipped a tier the resolver fixes would
362
+ be the same lie it refuses to be under an env pin.
363
+
364
+ The seams, and what a new feature should use rather than reading the store ad hoc:
365
+
366
+ - **A nav destination** declares `intake: true` in `app/modular/nav-contributions.ts` to survive a
367
+ narrowed role. It is **opt-in**, so a destination added later defaults to the full-surface roles:
368
+ getting that wrong costs one flag, where the other default is a persona that stopped being simple
369
+ without anyone deciding to un-simplify it. Today's set is three (`tutorial`,
370
+ `keyboard-shortcuts`, `ui-role`), and `nav-contributions.spec.ts` pins it against a table naming
371
+ each one's reason, so adding a fourth forces the claim to be written down. The `fullSurface` gate
372
+ rides the same reactive `NavGates` service as `advancedMode`, so a role switch re-gates all three
373
+ shells with no reload; all three axes (role, tier, `gate`) must pass.
374
+ - **A deployment's own external tool** declares the same flag, and defaults the same way. The three
375
+ axes live on one `NavGatedContribution` that both `NavContribution` and `ExternalToolContribution`
376
+ extend, and `navSlotFilter` runs the one `navItemVisible` over both slots, because a tool is
377
+ projected onto a nav contribution downstream. A tool filtered by any other expression is a
378
+ registered application that outlives the narrowing every destination beside it obeys, which is how
379
+ the role axis first shipped; `nav-contributions.spec.ts` pins the two slots' verdicts in lockstep
380
+ across the axes rather than trusting the two spellings to stay equal.
381
+ - **A surface that narrows inline** reads `useUiRoleStore().fullSurface` (the frame header's bug-hunt
382
+ button, the palette's per-connection integration commands). Same rule as the tier: what remains
383
+ must be exactly what the full surface would have shown, only less of it.
384
+ - **A tutorial tour whose step clicks a non-`intake` nav entry declares
385
+ `TUTORIAL_REQUIREMENTS.fullSurface`.** Which tours those are is not a judgement call:
386
+ `tutorial-tours.spec.ts` derives the pairing from `navItemVisible`, so a tour that gains such a
387
+ step fails until the requirement is declared. A single STEP that the role removes (the orientation
388
+ tour's interface-tier step) declares `when` instead, so it is dropped rather than reported as an
389
+ abridged tour.
390
+
242
391
  ## Interface modes (basic / advanced)
243
392
 
244
393
  The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
@@ -247,6 +396,8 @@ options that only exist to override a workspace-level default are left at that d
247
396
  the nav is trimmed to what that loop needs. `advanced` shows everything. The tier resolves in
248
397
  a fixed order, first match wins:
249
398
 
399
+ 0. **The [role](#roles-engineer--product-manager--designer)'s surface**, as a ceiling: an `intake`
400
+ role renders `basic` whatever the two below say.
250
401
  1. **`NUXT_PUBLIC_UI_MODE`** (`basic` | `advanced`): the deployment pin. Like
251
402
  `NUXT_PUBLIC_API_BASE` it is baked in at **build** time (`ssr: false`), and while it is
252
403
  set the in-app switcher is a read-only indicator, since a preference the resolver ignores
@@ -274,8 +425,8 @@ hoc where it can be avoided:
274
425
 
275
426
  - **A nav destination** declares `advanced: true` in `app/modular/nav-contributions.ts`. The
276
427
  shared `navSlotFilter` drops it in basic mode across all three shells (sidebar, command
277
- palette, toolbar), independently of its RBAC `gate`: both must pass. A consumer module's
278
- own contributions take the same flag. The bar is **whether the everyday delivery loop needs
428
+ palette, toolbar), independently of its RBAC `gate` and of the role's `intake` flag: all
429
+ three must pass. A consumer module's own contributions take the same flag. The bar is **whether the everyday delivery loop needs
279
430
  it**, and marking an item does one of two distinguishable things:
280
431
  - **Reached another way**; a shortcut whose surface a basic destination also opens, so
281
432
  nothing is lost (the Merge / Service-best-practices palette entries into Workspace
@@ -937,8 +1088,11 @@ example ships in [`deploy/frontend`](https://github.com/kibertoad/cat-factory/tr
937
1088
  ## Key UI surfaces
938
1089
 
939
1090
  - **Board canvas** (`components/board`): `BoardCanvas` + `nodes/` (`BlockNode`,
940
- `ModuleFrame`, `TaskCard`), dependency edges, the per-block `AgentFailureCard` /
941
- `AgentStopButton`, and a deep-zoom `focus/BlockFocusView`. A running task card expands
1091
+ `FrameSwimlanes` / `TaskLane` / `LaneGroup` / `LaneTask`, `TaskCard`), dependency edges,
1092
+ the per-block `AgentFailureCard` /
1093
+ `AgentStopButton`, and a deep-zoom `focus/BlockFocusView`. Tasks are laid out in status
1094
+ lanes rather than at coordinates (see [Task swimlanes](#task-swimlanes)); the board-level
1095
+ order/grouping override is `LaneViewControl` in the toolbar. A running task card expands
942
1096
  its build pipeline (`TaskPipelineMini`) on hover at any zoom level, and across every
943
1097
  on-screen card past the `steps` zoom band: the two grants are combined in the
944
1098
  `taskExpansion` store and driven by `useTaskExpansion`.
@@ -0,0 +1,87 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ LANE_GROUP_KEYS,
4
+ LANE_SORT_KEYS,
5
+ type LaneGroupKey,
6
+ type LaneSortKey,
7
+ } from '~/utils/laneSort'
8
+ import { showOverrideField } from '~/utils/uiMode'
9
+
10
+ /**
11
+ * The board-level override for how every frame's swimlanes are ordered and grouped.
12
+ *
13
+ * It is an OVERRIDE, which is what makes it an advanced-tier affordance: hidden, what remains is
14
+ * exactly the `smart` per-lane order and no grouping — the default it would otherwise have shown.
15
+ * The gate is `showOverrideField` rather than `isAdvanced` alone, because the preference persists
16
+ * per browser: someone who picks "by severity" in advanced mode and then switches to basic must
17
+ * still be able to see and clear it, or the board would be ordered by a rule they cannot find.
18
+ */
19
+ const { t } = useI18n()
20
+ const uiMode = useUiModeStore()
21
+ const laneView = useLaneViewStore()
22
+
23
+ // The DEFAULT values are passed as `undefined` rather than as themselves: `showOverrideField`
24
+ // reveals the control as soon as any value it edits is set, and `smart`/`none` being set is
25
+ // exactly the state where there is nothing to reveal.
26
+ const visible = computed(() =>
27
+ showOverrideField(
28
+ uiMode.isAdvanced,
29
+ laneView.sortKey === 'smart' ? undefined : laneView.sortKey,
30
+ laneView.groupKey === 'none' ? undefined : laneView.groupKey,
31
+ ),
32
+ )
33
+
34
+ const sortItems = computed(() =>
35
+ LANE_SORT_KEYS.map((key) => ({
36
+ label: t(`board.lanes.sort.${key}`),
37
+ icon: laneView.sortKey === key ? 'i-lucide-check' : undefined,
38
+ onSelect: () => laneView.setSortKey(key as LaneSortKey),
39
+ })),
40
+ )
41
+
42
+ const groupItems = computed(() =>
43
+ LANE_GROUP_KEYS.map((key) => ({
44
+ label: t(`board.lanes.group.${key}`),
45
+ icon: laneView.groupKey === key ? 'i-lucide-check' : undefined,
46
+ onSelect: () => laneView.setGroupKey(key as LaneGroupKey),
47
+ })),
48
+ )
49
+
50
+ /**
51
+ * One menu, two labelled sections, plus a reset. Grouped rather than two adjacent buttons because
52
+ * the two choices are read together — "the newest bugs, by module" is one intent — and because the
53
+ * toolbar has no room for two more controls at small widths.
54
+ */
55
+ const items = computed(() => [
56
+ [{ label: t('board.lanes.sort.heading'), type: 'label' as const }],
57
+ sortItems.value,
58
+ [{ label: t('board.lanes.group.heading'), type: 'label' as const }],
59
+ groupItems.value,
60
+ ...(laneView.hasOverride
61
+ ? [
62
+ [
63
+ {
64
+ label: t('board.lanes.resetView'),
65
+ icon: 'i-lucide-rotate-ccw',
66
+ onSelect: () => laneView.reset(),
67
+ },
68
+ ],
69
+ ]
70
+ : []),
71
+ ])
72
+ </script>
73
+
74
+ <template>
75
+ <UDropdownMenu v-if="visible" :items="items">
76
+ <UButton
77
+ color="neutral"
78
+ :variant="laneView.hasOverride ? 'soft' : 'ghost'"
79
+ size="sm"
80
+ icon="i-lucide-arrow-down-up"
81
+ :title="t('board.lanes.viewTitle')"
82
+ data-testid="lane-view-control"
83
+ >
84
+ <span class="hidden sm:inline">{{ t('board.lanes.viewTitle') }}</span>
85
+ </UButton>
86
+ </UDropdownMenu>
87
+ </template>
@@ -2,15 +2,15 @@
2
2
  import type { Block, BlockStatus } from '~/types/domain'
3
3
  import { blockTypeMeta, STATUS_META } from '~/utils/catalog'
4
4
  import DecisionBadge from './DecisionBadge.vue'
5
- import DraggableTask from './DraggableTask.vue'
5
+ import FrameSwimlanes from './FrameSwimlanes.vue'
6
6
  import InitiativeCard from './InitiativeCard.vue'
7
- import ModuleFrame from './ModuleFrame.vue'
8
7
  import ResizeGrips from './ResizeGrips.vue'
9
8
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
10
9
  import AgentStopButton from '~/components/board/AgentStopButton.vue'
11
10
  import { useBlockDrag } from '~/composables/useBlockDrag'
12
11
  import { useFrameStacking } from '~/composables/useFrameStacking'
13
12
  import { useViewport } from '~/composables/useViewport'
13
+ import { laneBodyHeightIn } from '~/utils/laneGeometry'
14
14
 
15
15
  // Vue Flow passes the node's `id` and `data` as props to custom node components.
16
16
  // Only frames are rendered as board nodes; their tasks live inside the card.
@@ -26,6 +26,7 @@ const services = useServicesStore()
26
26
  const reviews = useReviewStage()
27
27
  const access = useWorkspaceAccess()
28
28
  const uiMode = useUiModeStore()
29
+ const uiRole = useUiRoleStore()
29
30
  const { t } = useI18n()
30
31
  const { lod } = useSemanticZoom()
31
32
  // Coarse-pointer (touch) bumps the frame-header actions from `xs` to `sm` so
@@ -42,7 +43,9 @@ const isShared = computed(() => services.isSharedFrame(props.id))
42
43
  const typeMeta = computed(() => (block.value ? blockTypeMeta(block.value.type) : null))
43
44
 
44
45
  // ---- this service's children (tasks + modules) -----------------------------
45
- const directTasks = computed(() => board.tasksOf(props.id))
46
+ // No `directTasks` here: the swimlanes take EVERY task under the frame, its modules' included,
47
+ // because a module is a grouping inside a lane now rather than a box of its own. `modules` is
48
+ // still read for the composition line's module count.
46
49
  const modules = computed(() => board.modulesOf(props.id))
47
50
  const initiativeBlocks = computed(() => board.initiativesOf(props.id))
48
51
  const allTasks = computed(() => board.allTasksUnder(props.id))
@@ -53,6 +56,9 @@ const hasTasks = computed(
53
56
  )
54
57
  const prTasks = computed(() => allTasks.value.filter((t) => t.status === 'pr_ready').length)
55
58
  const canvas = computed(() => board.containerSize(props.id))
59
+ // A lane's scroll viewport fills whatever the frame's actual size leaves it, so dragging the
60
+ // frame's border gives the reader more of the lane rather than dead canvas beneath it.
61
+ const laneBodyHeight = computed(() => laneBodyHeightIn(canvas.value, initiativeBlocks.value.length))
56
62
 
57
63
  // Frame status is derived from its tasks — services never reach "done".
58
64
  const frameStatus = computed<BlockStatus>(() => board.frameStatus(props.id))
@@ -272,7 +278,7 @@ const ITEM_ICON: Record<string, string> = {
272
278
  />
273
279
  </div>
274
280
 
275
- <!-- ===================== The service card: 2D canvas of tasks + modules =====================
281
+ <!-- ============= The service card: the initiative band + task swimlanes =============
276
282
  There is no chip or compact variant: a service is always expanded to its task canvas, at
277
283
  every zoom level, so panning is a fixed layout and zooming has no expand/collapse
278
284
  transition to snap on. The two gated-off branches that used to sit here (a far-zoom chip
@@ -460,8 +466,13 @@ const ITEM_ICON: Record<string, string> = {
460
466
  :title="t('board.frame.createInitiativeTitle')"
461
467
  @click.stop="createInitiative"
462
468
  />
469
+ <!-- Hunting a tracker board for a bug worth adopting is TRIAGE, not intake: it
470
+ rates open bugs against each other and picks one to take on, which is the
471
+ engineer/PM judgement call. So it is dropped for a narrowed role, whose three
472
+ routes in (a new task, a task from a named ticket, a task from a design) all
473
+ start from work somebody has already decided to do. -->
463
474
  <UButton
464
- v-if="tasks.anyOffered"
475
+ v-if="tasks.anyOffered && uiRole.fullSurface"
465
476
  class="nodrag"
466
477
  data-testid="frame-hunt-bugs"
467
478
  :size="isTouch ? 'sm' : 'xs'"
@@ -495,15 +506,24 @@ const ITEM_ICON: Record<string, string> = {
495
506
  </div>
496
507
  </div>
497
508
 
498
- <!-- the 2D drop zone: modules and loose tasks live here, draggable -->
509
+ <!-- The frame's canvas. Tasks are laid out in status swimlanes rather than at
510
+ coordinates, so this is a fixed-size viewport (each lane scrolls) instead of the 2D
511
+ free-drag surface it used to be. `data-drop-zone` stays on the outer box so a drop
512
+ in the padding between lanes still resolves to this service rather than falling
513
+ through to nothing; each lane body carries the same zone for drops inside it. -->
499
514
  <div
500
515
  :data-drop-zone="block.id"
501
- class="nodrag relative rounded-xl bg-slate-950/40"
502
- :style="{ width: canvas.w + 'px', height: canvas.h + 'px' }"
516
+ class="nodrag relative rounded-xl bg-slate-950/40 p-2"
517
+ :style="{ width: canvas.w + 'px', minHeight: canvas.h + 'px' }"
503
518
  >
504
- <ModuleFrame v-for="m in modules" :key="m.id" :module-id="m.id" />
505
- <InitiativeCard v-for="i in initiativeBlocks" :key="i.id" :block-id="i.id" />
506
- <DraggableTask v-for="t in directTasks" :key="t.id" :task-id="t.id" />
519
+ <!-- Initiatives sit in a wrapping band above the lanes: they are containers of work,
520
+ not units of it, so they belong in no status lane. -->
521
+ <div v-if="initiativeBlocks.length" class="mb-2 flex flex-wrap gap-2">
522
+ <InitiativeCard v-for="i in initiativeBlocks" :key="i.id" :block-id="i.id" />
523
+ </div>
524
+
525
+ <FrameSwimlanes v-if="hasTasks" :frame-id="block.id" :lane-body-height="laneBodyHeight" />
526
+
507
527
  <button
508
528
  v-if="!hasTasks && access.canWriteBoard.value"
509
529
  type="button"
@@ -0,0 +1,139 @@
1
+ <script setup lang="ts">
2
+ import LaneGroup from './LaneGroup.vue'
3
+ import TaskLane from './TaskLane.vue'
4
+ import { useFrameLanes } from '~/composables/useFrameLanes'
5
+ import { LANE_GEOMETRY } from '~/utils/laneGeometry'
6
+ import { LANE_META } from '~/utils/swimlanes'
7
+
8
+ /**
9
+ * A service frame's tasks, laid out in status lanes.
10
+ *
11
+ * Three live lanes side by side, then the Done lane as a full-width strip beneath them rather
12
+ * than a fourth column. A collapsed column cannot state its own count without turning its label
13
+ * sideways, and giving the archive a quarter of the width permanently would take it from the
14
+ * three lanes a reader actually works in. As a strip it is one row when shut and a wide grid when
15
+ * opened, which is also the better shape for scanning history.
16
+ */
17
+ const props = defineProps<{
18
+ frameId: string
19
+ /** A live lane's scroll viewport, resolved by the frame from its own size. */
20
+ laneBodyHeight: number
21
+ }>()
22
+
23
+ const { t } = useI18n()
24
+ const laneView = useLaneViewStore()
25
+ const { lanes, doneSelection } = useFrameLanes(computed(() => props.frameId))
26
+
27
+ const liveLanes = computed(() => lanes.value.filter((l) => l.lane !== 'done'))
28
+ const doneLane = computed(() => lanes.value.find((l) => l.lane === 'done'))
29
+
30
+ /**
31
+ * What the Done lane is holding back, as one line.
32
+ *
33
+ * The two counts are reported separately because they tell a reader different things: an age
34
+ * drop means "there is older history, look further back", a cap drop means "there is more from
35
+ * this same period". Reporting nothing at all would be the worse failure — a truncated archive
36
+ * would read exactly like a complete one.
37
+ */
38
+ const withheldNote = computed(() => {
39
+ const { hiddenByAge, hiddenByCap, undatedShown } = doneSelection.value
40
+ const parts: string[] = []
41
+ if (hiddenByCap > 0) parts.push(t('board.lanes.done.hiddenByCap', { count: hiddenByCap }))
42
+ if (hiddenByAge > 0) parts.push(t('board.lanes.done.hiddenByAge', { count: hiddenByAge }))
43
+ // Blocks merged before completion dates were recorded have no honest age, so the age cap
44
+ // cannot apply to them. Saying so stops the window looking tighter than it is.
45
+ if (undatedShown > 0 && hiddenByAge > 0) {
46
+ parts.push(t('board.lanes.done.undated', { count: undatedShown }))
47
+ }
48
+ return parts.join(' · ')
49
+ })
50
+ </script>
51
+
52
+ <template>
53
+ <div class="space-y-2">
54
+ <!-- The three lanes a reader works in -->
55
+ <div class="flex items-start" :style="{ gap: LANE_GEOMETRY.laneGap + 'px' }">
56
+ <TaskLane
57
+ v-for="rendered in liveLanes"
58
+ :key="rendered.lane"
59
+ :rendered="rendered"
60
+ :group-key="laneView.groupKey"
61
+ :frame-id="frameId"
62
+ :body-height="laneBodyHeight"
63
+ />
64
+ </div>
65
+
66
+ <!-- The Done strip. Rendered only once the service HAS finished something: an empty archive
67
+ is the one lane whose emptiness says nothing a reader needs (a new service has merged
68
+ nothing, which its three live lanes already show). -->
69
+ <div v-if="doneLane && doneLane.total > 0" class="rounded-lg bg-slate-900/40">
70
+ <button
71
+ type="button"
72
+ class="nodrag flex w-full items-center gap-1.5 rounded-lg px-2 py-1.5 text-left hover:bg-slate-800/40"
73
+ data-testid="done-lane-toggle"
74
+ :aria-expanded="!laneView.doneLaneCollapsed"
75
+ @click.stop="laneView.toggleDoneLane()"
76
+ >
77
+ <UIcon
78
+ :name="laneView.doneLaneCollapsed ? 'i-lucide-chevron-right' : 'i-lucide-chevron-down'"
79
+ class="h-3.5 w-3.5 shrink-0 text-slate-500"
80
+ />
81
+ <UIcon
82
+ :name="LANE_META.done.icon"
83
+ class="h-3.5 w-3.5 shrink-0"
84
+ :style="{ color: LANE_META.done.color }"
85
+ />
86
+ <span class="text-[11px] font-semibold text-slate-200">{{
87
+ t(LANE_META.done.labelKey)
88
+ }}</span>
89
+ <!-- The TOTAL, not what the caps admitted: "this service has finished 312 tasks" is the
90
+ fact the lane carries, and counting only the visible cards would understate it by
91
+ two orders of magnitude. -->
92
+ <span
93
+ class="shrink-0 rounded px-1 text-[10px] font-semibold tabular-nums"
94
+ :style="{
95
+ backgroundColor: LANE_META.done.color + '22',
96
+ color: LANE_META.done.color,
97
+ }"
98
+ data-testid="lane-count-done"
99
+ >{{ doneLane.total }}</span
100
+ >
101
+ <span v-if="withheldNote" class="ms-auto truncate text-[10px] text-slate-500">{{
102
+ withheldNote
103
+ }}</span>
104
+ </button>
105
+
106
+ <!-- The archive opens as a WIDE GRID: each group takes the strip's full width and wraps its
107
+ own cards across it, and the groups stack. Wrapping the GROUPS instead read correctly
108
+ only when there were several of them — under the default `none` grouping a lane is
109
+ exactly one group, so the whole archive rendered as a single card-wide column down the
110
+ left of an otherwise empty strip. -->
111
+ <div
112
+ v-if="!laneView.doneLaneCollapsed"
113
+ :data-drop-zone="frameId"
114
+ data-testid="lane-done"
115
+ data-lane="done"
116
+ class="nodrag space-y-2 overflow-y-auto p-2 pt-0"
117
+ :style="{ maxHeight: laneBodyHeight + 'px' }"
118
+ >
119
+ <!-- A zero cap is a real setting ("count them, show none"), so the strip explains why it
120
+ has nothing in it rather than looking broken. -->
121
+ <p
122
+ v-if="doneSelection.shown.length === 0"
123
+ class="px-1 text-[10px] leading-snug text-slate-600"
124
+ >
125
+ {{ t('board.lanes.done.allWithheld') }}
126
+ </p>
127
+ <LaneGroup
128
+ v-for="(group, i) in doneLane.groups"
129
+ v-else
130
+ :key="group.label ?? `catch-all-${i}`"
131
+ :group="group"
132
+ :group-key="laneView.groupKey"
133
+ :frame-id="frameId"
134
+ layout="grid"
135
+ />
136
+ </div>
137
+ </div>
138
+ </div>
139
+ </template>
@@ -9,10 +9,14 @@
9
9
  // on the plan-approval gate (or on an agent-raised decision) offers the button that
10
10
  // opens the window resolving it, instead of leaving the card on a spinning "Run
11
11
  // planning" whose only route in was the inspector's execution panel.
12
- // The tracker button opens the dedicated window directly. Draggable within its
13
- // frame like a task card.
12
+ // The tracker button opens the dedicated window directly.
13
+ //
14
+ // Laid out in a wrapping band above the frame's task swimlanes, not at coordinates. It used to be
15
+ // free-positioned inside the frame's canvas beside the task cards, and lost that along with them
16
+ // when tasks moved into lanes: with the canvas gone there is nothing left for an initiative's
17
+ // coordinates to be relative to, and its drag handle only ever moved it within a canvas nothing
18
+ // renders now. An initiative is still a first-class board block with its own inspector.
14
19
  import type { InitiativeStatus } from '~/types/domain'
15
- import { useBlockDrag } from '~/composables/useBlockDrag'
16
20
  import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
17
21
  import {
18
22
  INITIATIVE_ATTENTION_ICONS,
@@ -27,7 +31,6 @@ const board = useBoardStore()
27
31
  const initiatives = useInitiativesStore()
28
32
  const ui = useUiStore()
29
33
  const { t } = useI18n()
30
- const { draggingId, startDrag } = useBlockDrag()
31
34
 
32
35
  const block = computed(() => board.getBlock(props.blockId))
33
36
  const initiative = computed(() => initiatives.forBlock(props.blockId))
@@ -56,36 +59,14 @@ const {
56
59
  function select() {
57
60
  ui.select(props.blockId)
58
61
  }
59
- function onHandle(e: PointerEvent) {
60
- if (block.value) startDrag(block.value, e)
61
- }
62
62
  </script>
63
63
 
64
64
  <template>
65
- <div
66
- v-if="block"
67
- class="absolute w-[230px]"
68
- :style="{
69
- left: block.position.x + 'px',
70
- top: block.position.y + 'px',
71
- zIndex: draggingId === blockId ? 60 : 10,
72
- pointerEvents: draggingId === blockId ? 'none' : undefined,
73
- }"
74
- >
75
- <div
76
- class="nodrag nopan flex cursor-grab touch-none items-center justify-center rounded-t-lg border border-b-0 border-indigo-800/60 bg-indigo-950/60 py-px active:cursor-grabbing pointer-coarse:py-2"
77
- :title="t('board.frame.dragTask')"
78
- @pointerdown="onHandle"
79
- >
80
- <UIcon
81
- name="i-lucide-grip-horizontal"
82
- class="h-3 w-3 text-indigo-400/60 pointer-coarse:h-5 pointer-coarse:w-5"
83
- />
84
- </div>
65
+ <div v-if="block" class="w-[230px]">
85
66
  <div
86
67
  data-testid="initiative-card"
87
68
  :data-status="status"
88
- class="cursor-pointer rounded-b-lg border border-indigo-800/60 bg-indigo-950/40 p-3 transition hover:border-indigo-600"
69
+ class="cursor-pointer rounded-lg border border-indigo-800/60 bg-indigo-950/40 p-3 transition hover:border-indigo-600"
89
70
  :class="[
90
71
  selected ? 'ring-2 ring-indigo-400/60' : '',
91
72
  awaitingAnswers || attention ? 'board-pulse' : '',