@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
@@ -130,6 +130,17 @@ export const TUTORIAL_REQUIREMENTS = {
130
130
  labelKey: 'tutorial.requirements.advancedTier',
131
131
  met: (gates) => gates.advancedMode,
132
132
  },
133
+ // The other SPA-narrowing axis, and it is a requirement for exactly the same reason the tier is:
134
+ // a narrowed ROLE (`utils/uiRole.ts`) keeps only the `intake` nav entries, so every tour whose
135
+ // step CLICKS one of the others would be offered to a designer and then hunt for a sidebar entry
136
+ // their screen does not render. Which tours those are is not a judgement call: it is the pairing
137
+ // `tutorial-tours.spec.ts` derives from `navItemVisible`, so a tour that gains a nav-anchored
138
+ // step fails there until this is declared.
139
+ fullSurface: {
140
+ id: 'full-surface',
141
+ labelKey: 'tutorial.requirements.fullSurface',
142
+ met: (gates) => gates.fullSurface,
143
+ },
133
144
  // The platform half's requirements. Each mirrors, exactly, the `gate` of the sidebar entry the
134
145
  // tour clicks (`nav-model-providers` / `nav-integrations`, `nav-fragments`). A requirement
135
146
  // WEAKER than the gate of the control a step points at offers the tour to a user who has no
@@ -204,15 +215,33 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
204
215
  titleKey: 'tutorial.tours.boardBasics.steps.toolbar.title',
205
216
  bodyKey: 'tutorial.tours.boardBasics.steps.toolbar.body',
206
217
  },
218
+ {
219
+ // The role decides which surfaces exist at all, so the orientation tour names it before
220
+ // the tier it caps. No `when`: the switcher is rendered in EVERY role (it is the way back
221
+ // out of the narrowed one), which is exactly why the tour can always point at it.
222
+ id: 'role',
223
+ target: 'ui-role-switcher',
224
+ altTargets: ['ui-role-toggle'],
225
+ placement: 'right',
226
+ titleKey: 'tutorial.tours.boardBasics.steps.role.title',
227
+ bodyKey: 'tutorial.tours.boardBasics.steps.role.body',
228
+ },
207
229
  {
208
230
  // Basic mode is the shipped default, and it HIDES a whole half of the product
209
231
  // (sandbox, Kaizen, bootstrap, the operator surfaces). A user who never finds the
210
232
  // switcher never learns that half exists, so the orientation tour is the one place
211
233
  // that has to name it — the switcher is deliberately visible in both tiers for the
212
234
  // same reason (see `nav-contributions.ts`).
235
+ //
236
+ // `when` rather than a tour-level requirement, and rather than nothing: a narrowed role's
237
+ // tier is CAPPED at basic, so the sidebar drops this switcher, and a step that merely
238
+ // missed its anchor would be reported as an abridged tour, which is the wrong thing to say
239
+ // about a control that is absent because of a choice the user made. Gating the whole tour
240
+ // would be worse still: orientation is the one walkthrough every role should get.
213
241
  id: 'interfaceTier',
214
242
  target: 'ui-mode-switcher',
215
243
  altTargets: ['ui-mode-toggle'],
244
+ when: (gates) => gates.fullSurface,
216
245
  placement: 'right',
217
246
  titleKey: 'tutorial.tours.boardBasics.steps.interfaceTier.title',
218
247
  bodyKey: 'tutorial.tours.boardBasics.steps.interfaceTier.body',
@@ -236,7 +265,11 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
236
265
  // repo is a board write against a connected source, and in basic interface mode
237
266
  // add-from-repo is the ONLY route (bootstrap is advanced), which is what makes this
238
267
  // worth a tour rather than a hint.
239
- requires: [TUTORIAL_REQUIREMENTS.boardWrite, TUTORIAL_REQUIREMENTS.sourceControl],
268
+ requires: [
269
+ TUTORIAL_REQUIREMENTS.boardWrite,
270
+ TUTORIAL_REQUIREMENTS.sourceControl,
271
+ TUTORIAL_REQUIREMENTS.fullSurface,
272
+ ],
240
273
  steps: [
241
274
  {
242
275
  id: 'intro',
@@ -668,7 +701,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
668
701
  // onboarding advisory, which the launch prompt stands down for) — this is for the person who
669
702
  // meets the question later, or who wants to know where the answer lives.
670
703
  offeredAtLaunch: false,
671
- requires: [TUTORIAL_REQUIREMENTS.integrationsManage],
704
+ requires: [TUTORIAL_REQUIREMENTS.integrationsManage, TUTORIAL_REQUIREMENTS.fullSurface],
672
705
  steps: [
673
706
  {
674
707
  id: 'intro',
@@ -712,7 +745,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
712
745
  // change. A user who never finds the builder treats the built-in catalog as the product's
713
746
  // fixed shape and works around it in task descriptions instead.
714
747
  offeredAtLaunch: false,
715
- requires: [TUTORIAL_REQUIREMENTS.boardWrite],
748
+ requires: [TUTORIAL_REQUIREMENTS.boardWrite, TUTORIAL_REQUIREMENTS.fullSurface],
716
749
  steps: [
717
750
  {
718
751
  id: 'intro',
@@ -774,7 +807,11 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
774
807
  // How you steer output without restating your conventions in every task description, which
775
808
  // is what people do instead when they never find this.
776
809
  offeredAtLaunch: false,
777
- requires: [TUTORIAL_REQUIREMENTS.library, TUTORIAL_REQUIREMENTS.settingsManage],
810
+ requires: [
811
+ TUTORIAL_REQUIREMENTS.library,
812
+ TUTORIAL_REQUIREMENTS.settingsManage,
813
+ TUTORIAL_REQUIREMENTS.fullSurface,
814
+ ],
778
815
  steps: [
779
816
  {
780
817
  id: 'intro',
@@ -813,7 +850,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
813
850
  // Each integration changes what a run can SEE or SAY, and none of them announces itself:
814
851
  // a board with no tracker linked simply never mentions that issues could arrive on their own.
815
852
  offeredAtLaunch: false,
816
- requires: [TUTORIAL_REQUIREMENTS.integrationsManage],
853
+ requires: [TUTORIAL_REQUIREMENTS.integrationsManage, TUTORIAL_REQUIREMENTS.fullSurface],
817
854
  steps: [
818
855
  {
819
856
  id: 'intro',
@@ -854,7 +891,7 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
854
891
  // itself, and the cost of not finding it is a run that fails on provisioning with a banner
855
892
  // pointing at a window the user has never opened.
856
893
  offeredAtLaunch: false,
857
- requires: [TUTORIAL_REQUIREMENTS.infrastructure],
894
+ requires: [TUTORIAL_REQUIREMENTS.infrastructure, TUTORIAL_REQUIREMENTS.fullSurface],
858
895
  steps: [
859
896
  {
860
897
  id: 'intro',
@@ -906,7 +943,11 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
906
943
  // workspace that has never made a group renders nothing for the anchored step to find. The
907
944
  // guard only pairs a tour against the visibility of a NAV entry, so a section hiding itself
908
945
  // one level in is exactly the case that has to be declared by hand.
909
- requires: [TUTORIAL_REQUIREMENTS.settingsManage, TUTORIAL_REQUIREMENTS.advancedTier],
946
+ requires: [
947
+ TUTORIAL_REQUIREMENTS.settingsManage,
948
+ TUTORIAL_REQUIREMENTS.advancedTier,
949
+ TUTORIAL_REQUIREMENTS.fullSurface,
950
+ ],
910
951
  steps: [
911
952
  {
912
953
  id: 'intro',
@@ -950,7 +991,11 @@ export const TUTORIAL_TOURS: readonly TutorialTour[] = [
950
991
  // renders the entry this tour clicks. Declared rather than assumed: `navRequirementDrift`
951
992
  // enumerates the whole gate matrix against that entry's own visibility rule and fails
952
993
  // without it.
953
- requires: [TUTORIAL_REQUIREMENTS.settingsManage, TUTORIAL_REQUIREMENTS.advancedTier],
994
+ requires: [
995
+ TUTORIAL_REQUIREMENTS.settingsManage,
996
+ TUTORIAL_REQUIREMENTS.advancedTier,
997
+ TUTORIAL_REQUIREMENTS.fullSurface,
998
+ ],
954
999
  steps: [
955
1000
  {
956
1001
  id: 'intro',
@@ -155,6 +155,9 @@ const TutorialOverlay = defineAsyncComponent(
155
155
  () => import('~/components/tutorial/TutorialOverlay.vue'),
156
156
  )
157
157
  const TutorialNudge = defineAsyncComponent(() => import('~/components/tutorial/TutorialNudge.vue'))
158
+ // The first-run role question (Engineer / Product manager / Designer). Same shape as the tutorial
159
+ // launch prompt: mounted only while its store flag is set, so an answered question costs nothing.
160
+ const RolePrompt = defineAsyncComponent(() => import('~/components/layout/RolePrompt.vue'))
158
161
 
159
162
  const workspace = useWorkspaceStore()
160
163
  const github = useGitHubStore()
@@ -312,11 +315,11 @@ const needsGitHubInstall = computed(() => github.available === true && !github.c
312
315
  const githubProbePending = computed(() => github.available === null)
313
316
 
314
317
  // Offer the tutorial on launch, once the board is up. Yields to every other startup
315
- // surface — the GitHub onboarding gate and the advisory/onboarding modals above so a
316
- // first launch never stacks the tour prompt on top of a dialog that needs answering
317
- // first; when one of those is open, the flip of its flag re-fires this watcher and the
318
- // prompt appears then. The store guards the rest: only a user who never answered is
319
- // asked, at most once per session.
318
+ // surface — the GitHub onboarding gate, the advisory/onboarding modals above, and the role
319
+ // question below — so a first launch never stacks the tour prompt on top of a dialog that
320
+ // needs answering first; when one of those is open, the flip of its flag re-fires this
321
+ // watcher and the prompt appears then. The store guards the rest: only a user who never
322
+ // answered is asked, at most once per session.
320
323
  //
321
324
  // Yielding runs in BOTH directions: an advisory that opens LATER (a health probe that
322
325
  // resolves a beat after the board) would otherwise land on top of an open tour prompt,
@@ -330,6 +333,7 @@ const githubProbePending = computed(() => github.available === null)
330
333
  // state pays nothing for the launch offer: no watcher, no mounted component (the v-ifs
331
334
  // below), no store reads.
332
335
  const tutorial = useTutorialStore()
336
+ const uiRole = useUiRoleStore()
333
337
  const startupAdvisoryOpen = computed(
334
338
  () =>
335
339
  needsGitHubInstall.value ||
@@ -340,6 +344,30 @@ const startupAdvisoryOpen = computed(
340
344
  ui.aiProviderSetupOpen ||
341
345
  ui.aiPresetMismatchOpen,
342
346
  )
347
+
348
+ // The ROLE question comes before the tour offer, and the ordering is the point: the role decides
349
+ // which surfaces exist, so a tour picked ahead of it could be about half a product the next answer
350
+ // removes. It runs the same launch machine as the tutorial offer (yield to anything the user must
351
+ // actually answer, re-arm when that surface goes, at most one offer per session) and stops itself
352
+ // once it can no longer do anything.
353
+ const roleOfferSettled = () => uiRole.chosen || (uiRole.promptAutoOpened && !uiRole.promptOpen)
354
+ if (!roleOfferSettled()) {
355
+ let stopRoleOffer: (() => void) | undefined
356
+ stopRoleOffer = watch(
357
+ () => [workspace.ready, startupAdvisoryOpen.value, uiRole.promptOpen],
358
+ () => {
359
+ if (startupAdvisoryOpen.value) uiRole.deferPrompt()
360
+ else if (workspace.ready) uiRole.maybeOfferOnLaunch()
361
+ if (roleOfferSettled()) stopRoleOffer?.()
362
+ },
363
+ { immediate: true },
364
+ )
365
+ if (roleOfferSettled()) stopRoleOffer()
366
+ }
367
+ // What the TOUR offer yields to: every startup advisory, plus the role question above it. The
368
+ // role prompt is not in `startupAdvisoryOpen` itself, or the role offer would defer to its own
369
+ // standing offer and withdraw it a tick after making it.
370
+ const tutorialYieldsTo = computed(() => startupAdvisoryOpen.value || uiRole.promptOpen)
343
371
  // Settled = the offer can never need to act again: a decision exists, or the prompt was
344
372
  // auto-opened and is still standing (a deferral clears `promptAutoOpened`, which is
345
373
  // exactly what keeps the watcher alive to re-offer).
@@ -350,9 +378,9 @@ if (!tutorialOfferSettled()) {
350
378
  // inside `watch(...)`, before the handle is assigned — the trailing check covers it.
351
379
  let stopTutorialOffer: (() => void) | undefined
352
380
  stopTutorialOffer = watch(
353
- () => [workspace.ready, startupAdvisoryOpen.value, tutorial.promptOpen],
381
+ () => [workspace.ready, tutorialYieldsTo.value, tutorial.promptOpen],
354
382
  () => {
355
- if (startupAdvisoryOpen.value) tutorial.deferPrompt()
383
+ if (tutorialYieldsTo.value) tutorial.deferPrompt()
356
384
  else if (workspace.ready) tutorial.maybeOfferOnLaunch()
357
385
  if (tutorialOfferSettled()) stopTutorialOffer?.()
358
386
  },
@@ -506,6 +534,7 @@ watch(
506
534
  <VendorCredentialsModal v-if="ui.vendorCredentialsOpen" />
507
535
  <AiProviderOnboardingModal v-if="ui.aiProviderSetupOpen" />
508
536
  <AiPresetMismatchDialog v-if="ui.aiPresetMismatchOpen" />
537
+ <RolePrompt v-if="uiRole.promptOpen" />
509
538
  <TutorialPrompt v-if="tutorial.promptOpen" />
510
539
  <TutorialCatalogue v-if="tutorial.catalogueOpen" />
511
540
  <TutorialOverlay v-if="tutorial.touring" />
@@ -1,4 +1,5 @@
1
1
  import type { UpdateBlockInput } from '@cat-factory/contracts'
2
+ import { moduleNameInContainer } from '@cat-factory/contracts'
2
3
  import { useServicesStore } from '~/stores/services'
3
4
  import { useWorkspaceStore } from '~/stores/workspace'
4
5
  import { createBoardDependencies } from './dependencies'
@@ -59,9 +60,14 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
59
60
  // block in the wrong container (a structural lie that survives until re-hydrate).
60
61
  const prevParentId = b.parentId
61
62
  const prevPosition = b.position
63
+ const prevModuleName = b.moduleName
62
64
  const name = b.title
63
65
  b.parentId = newParentId
64
66
  b.position = position
67
+ // The server re-stamps a moved task's declared module from its new container, so predict the
68
+ // same answer here: without it the card lands in the lane group it was dragged OUT of and
69
+ // jumps to the right one when the response arrives.
70
+ if (b.level === 'task') b.moduleName = moduleNameInContainer(parent)
65
71
  try {
66
72
  upsert(
67
73
  await api.reparentBlock(useWorkspaceStore().requireId(), id, {
@@ -90,6 +96,7 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
90
96
  } catch (e) {
91
97
  b.parentId = prevParentId
92
98
  b.position = prevPosition
99
+ b.moduleName = prevModuleName
93
100
  // A cross-home drag can be refused on merge-preset grounds, which is a condition the mover
94
101
  // can act on rather than a fault. The backend sends the machine-readable reason and no
95
102
  // translated prose, so map it here; anything else keeps the raw message as the last resort.
@@ -3,6 +3,8 @@ import { setActivePinia, createPinia } from 'pinia'
3
3
  import type { Block, BlockStatus } from '~/types/domain'
4
4
  import { useBoardStore } from '~/stores/board'
5
5
  import { useWorkspaceStore } from '~/stores/workspace'
6
+ import { EMPTY_FRAME_SIZE } from '~/utils/framePlacement'
7
+ import { frameContentSize, laneBodyHeightIn, LANE_GEOMETRY } from '~/utils/laneGeometry'
6
8
 
7
9
  /** Minimal Block factory — only the fields the read getters care about. */
8
10
  function block(id: string, over: Partial<Block> = {}): Block {
@@ -27,6 +29,8 @@ const moduleBlock = (id: string, parentId: string, over: Partial<Block> = {}) =>
27
29
  block(id, { level: 'module', parentId, ...over })
28
30
  const task = (id: string, parentId: string, over: Partial<Block> = {}) =>
29
31
  block(id, { level: 'task', parentId, ...over })
32
+ const initiativeBlock = (id: string, parentId: string, over: Partial<Block> = {}) =>
33
+ block(id, { level: 'initiative', parentId, ...over })
30
34
 
31
35
  describe('board store read getters', () => {
32
36
  let store: ReturnType<typeof useBoardStore>
@@ -212,29 +216,77 @@ describe('board store read getters', () => {
212
216
  })
213
217
 
214
218
  describe('containerSize', () => {
215
- it('returns base dimensions for an empty service', () => {
219
+ // A frame's size is now a function of the LANE GEOMETRY, not of its contents. That is the
220
+ // point of the swimlanes: each lane scrolls, so a service accumulating work no longer grows
221
+ // a taller and taller frame until it dwarfs its neighbours. These tests pin the INVARIANT
222
+ // (size independent of task count and task position) rather than the pixel arithmetic, which
223
+ // belongs to `LANE_GEOMETRY` and would otherwise be restated here to no purpose.
224
+ it('sizes a service with nothing in it to the panel it actually renders', () => {
225
+ // An empty service shows one "add the first task" panel, not lanes, so it reserves the
226
+ // panel's footprint. Reserving the lanes' would leave the frame more than twice as tall as
227
+ // its own contents — and, since a placement clears frames by their reserved size, would
228
+ // push its neighbours that much further away for a frame holding nothing.
216
229
  store.hydrate([frame('f1')])
217
- expect(store.containerSize('f1')).toEqual({ w: 360, h: 220 })
230
+ expect(store.containerSize('f1')).toEqual(
231
+ frameContentSize({ hasChildren: false, initiatives: 0 }),
232
+ )
218
233
  })
219
234
 
220
- it('grows to fit a task and adds the module header height for modules', () => {
235
+ it('reserves the same footprint for a new frame that a new frame will render at', () => {
236
+ // The drift this caught: `EMPTY_FRAME_SIZE` is what a placement decision reserves BEFORE the
237
+ // block exists, so it cannot measure the frame and has to predict it. A hand-copied pair
238
+ // went stale when the floor changed underneath it, and every new service was then dropped
239
+ // on top of a neighbour it had been placed to clear.
240
+ store.hydrate([frame('f1')])
241
+ expect(store.containerSize('f1')).toEqual(EMPTY_FRAME_SIZE)
242
+ })
243
+
244
+ it('does not grow with task count, however many tasks and wherever they sat', () => {
245
+ store.hydrate([frame('f1'), task('t1', 'f1')])
246
+ const oneTask = store.containerSize('f1')
247
+
221
248
  store.hydrate([
222
249
  frame('f1'),
223
- moduleBlock('m1', 'f1', { position: { x: 0, y: 0 } }),
250
+ moduleBlock('m1', 'f1', { position: { x: 400, y: 300 } }),
224
251
  task('t1', 'm1', { position: { x: 300, y: 200 } }),
252
+ // A position far outside the old content extent: it used to stretch the frame to reach
253
+ // it, and now means nothing at all, because a task no longer renders at coordinates.
254
+ task('t2', 'f1', { position: { x: 4000, y: 9000 } }),
225
255
  ])
226
- // module inner width/height fit the task, plus the 30px module header.
227
- const size = store.containerSize('m1')
228
- expect(size.w).toBe(300 + 210 + 12)
229
- expect(size.h).toBe(200 + 160 + 12 + 30)
256
+ expect(store.containerSize('f1')).toEqual(oneTask)
257
+ })
258
+
259
+ it('makes room for the initiative band above the lanes', () => {
260
+ // Initiatives are the one child still laid out by the frame itself (in a wrapping band),
261
+ // so they are the one thing the frame's height still has to account for.
262
+ store.hydrate([frame('f1'), task('t1', 'f1')])
263
+ const withoutBand = store.containerSize('f1').h
264
+ store.hydrate([frame('f1'), task('t1', 'f1'), initiativeBlock('i1', 'f1')])
265
+ expect(store.containerSize('f1').h).toBe(withoutBand + LANE_GEOMETRY.initiativeHeight)
266
+ })
267
+
268
+ it('sizes a frame holding only an initiative for lanes, since that is what it renders', () => {
269
+ // `BlockNode` gates the lanes on having ANY child — tasks, modules or initiatives — so a
270
+ // frame with an initiative and no tasks renders three (empty) lanes. A size that disagreed
271
+ // with what rendered is the clipping this geometry exists to prevent.
272
+ store.hydrate([frame('f1'), initiativeBlock('i1', 'f1')])
273
+ expect(store.containerSize('f1')).toEqual(
274
+ frameContentSize({ hasChildren: true, initiatives: 1 }),
275
+ )
230
276
  })
231
277
 
232
- it('expands a service to enclose its nested modules', () => {
233
- store.hydrate([frame('f1'), moduleBlock('m1', 'f1', { position: { x: 400, y: 300 } })])
234
- const mod = store.containerSize('m1')
235
- const svc = store.containerSize('f1')
236
- expect(svc.w).toBe(400 + mod.w + 12)
237
- expect(svc.h).toBe(300 + mod.h + 12)
278
+ it('a module reports no canvas of its own, since it is no longer drawn as a box', () => {
279
+ store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'm1')])
280
+ expect(store.containerSize('m1').h).toBe(0)
281
+ })
282
+
283
+ it('keeps an explicitly resized frame at the size the user dragged it to', () => {
284
+ // The geometry is a FLOOR, not a fixed size: dragging the border still gives a reader more
285
+ // room, and a lane grows its scroll viewport into it rather than leaving dead canvas below.
286
+ store.hydrate([frame('f1', { size: { w: 2000, h: 1500 } }), task('t1', 'f1')])
287
+ const size = store.containerSize('f1')
288
+ expect(size).toEqual({ w: 2000, h: 1500 })
289
+ expect(laneBodyHeightIn(size, 0)).toBeGreaterThan(LANE_GEOMETRY.laneBodyHeight)
238
290
  })
239
291
  })
240
292
 
@@ -422,6 +474,59 @@ describe('board store optimistic rollback', () => {
422
474
  // the undo move is itself non-undoable, so no second toast is queued
423
475
  expect(actions).toHaveLength(1)
424
476
  })
477
+
478
+ it('reparentBlock predicts the declared module the server will re-stamp', async () => {
479
+ // The board reads a task's PARENT for its module and falls back to the name it DECLARES (a
480
+ // task can name a module before the engine materialises the block on merge). So a card
481
+ // dragged out of a module and left still declaring it re-groups under the module it was just
482
+ // dragged out of. The server re-stamps the name on every reparent; the optimistic write here
483
+ // has to predict the same answer or the card visibly jumps when the response lands.
484
+ //
485
+ // The request never settles, so what is asserted is strictly what this store put on screen
486
+ // BEFORE hearing back — the window the card would otherwise spend in the wrong group.
487
+ vi.stubGlobal('useApi', () => ({ reparentBlock: () => new Promise(() => {}) }))
488
+ vi.stubGlobal('useToast', () => ({ add: () => {} }))
489
+ setActivePinia(createPinia())
490
+ useWorkspaceStore().workspaceId = 'ws1'
491
+ const store = useBoardStore()
492
+ store.hydrate([
493
+ frame('f1'),
494
+ moduleBlock('m1', 'f1', { title: 'Sessions' }),
495
+ task('t1', 'm1', { moduleName: 'Sessions' }),
496
+ task('t2', 'f1'),
497
+ ])
498
+
499
+ // Out to the service frame: the declared name goes with it, as the empty string the store
500
+ // maps to NULL. Left behind, it is what files the card straight back into "Sessions".
501
+ void store.reparentBlock('t1', 'f1', { x: 0, y: 0 })
502
+ expect(store.getBlock('t1')?.moduleName).toBe('')
503
+
504
+ // And in: the destination module's title, whatever the task declared before.
505
+ void store.reparentBlock('t2', 'm1', { x: 0, y: 0 })
506
+ expect(store.getBlock('t2')?.moduleName).toBe('Sessions')
507
+ })
508
+
509
+ it('reparentBlock restores the declared module when the move is rejected', async () => {
510
+ // The same rollback contract the parent and position already had: a refused move must not
511
+ // leave the card grouped somewhere the server never put it.
512
+ vi.stubGlobal('useApi', () => ({
513
+ reparentBlock: async () => {
514
+ throw new Error('nope')
515
+ },
516
+ }))
517
+ vi.stubGlobal('useToast', () => ({ add: () => {} }))
518
+ setActivePinia(createPinia())
519
+ useWorkspaceStore().workspaceId = 'ws1'
520
+ const store = useBoardStore()
521
+ store.hydrate([
522
+ frame('f1'),
523
+ moduleBlock('m1', 'f1', { title: 'Sessions' }),
524
+ task('t1', 'm1', { moduleName: 'Sessions' }),
525
+ ])
526
+
527
+ await store.reparentBlock('t1', 'f1', { x: 0, y: 0 })
528
+ expect(store.getBlock('t1')).toMatchObject({ parentId: 'm1', moduleName: 'Sessions' })
529
+ })
425
530
  })
426
531
 
427
532
  describe('board store deferred delete + undo', () => {
@@ -0,0 +1,61 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest'
2
+ import { createPinia, setActivePinia } from 'pinia'
3
+ import { useLaneViewStore } from '~/stores/laneView'
4
+
5
+ // The preference is PERSISTED in the reader's browser, which makes the restored value
6
+ // untrusted input: a blob written by an older build can name a sort key this build has
7
+ // retired, and feeding that to the comparator lookup would throw mid-sort and take the whole
8
+ // board down over a stale preference. That narrowing is the thing worth pinning here.
9
+
10
+ describe('laneView store', () => {
11
+ beforeEach(() => {
12
+ setActivePinia(createPinia())
13
+ })
14
+
15
+ it('defaults to the per-lane smart order with no grouping', () => {
16
+ const store = useLaneViewStore()
17
+ expect(store.sortKey).toBe('smart')
18
+ expect(store.groupKey).toBe('none')
19
+ expect(store.hasOverride).toBe(false)
20
+ })
21
+
22
+ it('opens with the Done lane collapsed', () => {
23
+ // A service with a long history would otherwise open as a wall of merged cards.
24
+ expect(useLaneViewStore().doneLaneCollapsed).toBe(true)
25
+ })
26
+
27
+ it('falls back to the default when a restored key is not in this build vocabulary', () => {
28
+ const store = useLaneViewStore()
29
+ // Exactly what a persisted blob from an older build looks like.
30
+ store.storedSortKey = 'sort_by_vibes'
31
+ store.storedGroupKey = 'group_by_astrology'
32
+ expect(store.sortKey).toBe('smart')
33
+ expect(store.groupKey).toBe('none')
34
+ // …and a stale value must not read as an override, or the board would claim a preference
35
+ // it is not actually applying.
36
+ expect(store.hasOverride).toBe(false)
37
+ })
38
+
39
+ it('reports an override so the control survives a switch to basic mode', () => {
40
+ const store = useLaneViewStore()
41
+ store.setSortKey('severity_desc')
42
+ expect(store.hasOverride).toBe(true)
43
+ store.reset()
44
+ expect(store.hasOverride).toBe(false)
45
+ expect(store.sortKey).toBe('smart')
46
+ })
47
+
48
+ it('treats grouping alone as an override', () => {
49
+ const store = useLaneViewStore()
50
+ store.setGroupKey('module')
51
+ expect(store.hasOverride).toBe(true)
52
+ })
53
+
54
+ it('toggles the Done lane', () => {
55
+ const store = useLaneViewStore()
56
+ store.toggleDoneLane()
57
+ expect(store.doneLaneCollapsed).toBe(false)
58
+ store.toggleDoneLane()
59
+ expect(store.doneLaneCollapsed).toBe(true)
60
+ })
61
+ })
@@ -0,0 +1,85 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import {
4
+ isLaneGroupKey,
5
+ isLaneSortKey,
6
+ type LaneGroupKey,
7
+ type LaneSortKey,
8
+ } from '~/utils/laneSort'
9
+
10
+ /**
11
+ * How this reader wants a frame's swimlanes ordered and grouped, plus whether the Done lane
12
+ * is open.
13
+ *
14
+ * Per USER and per BROWSER, persisted like the interface tier and the agent tier. That is a
15
+ * deliberate split from the Done lane's two CAPS, which are per-workspace settings: what the
16
+ * board may show is a shared decision about a service's history, while the order a reader
17
+ * scans it in is personal and changes several times an hour. Making the order shared would
18
+ * mean one person's triage sweep re-arranging everyone else's board.
19
+ *
20
+ * `sortKey` defaults to `smart`, which is per-lane (see `SMART_ORDER_BY_LANE`) rather than one
21
+ * global order — the actionable order genuinely differs by column. The explicit keys are an
22
+ * OVERRIDE of that, which is why the control offering them is an advanced-tier affordance:
23
+ * hiding it in basic mode leaves exactly the default it would have shown.
24
+ */
25
+ export const useLaneViewStore = defineStore(
26
+ 'laneView',
27
+ () => {
28
+ // Stored raw and narrowed on read, the same split `uiMode` uses for `storedMode`. A setup
29
+ // store can only persist what it returns, so these are reachable directly; the narrowing
30
+ // below is what makes that safe rather than a trust assumption.
31
+ const storedSortKey = ref<string>('smart')
32
+ const storedGroupKey = ref<string>('none')
33
+ /**
34
+ * Collapsed by default. The lane's job is to prove finished work exists and give a route
35
+ * to it, and a service with a long history would otherwise open as a wall of merged
36
+ * cards nobody asked to read.
37
+ */
38
+ const doneLaneCollapsed = ref(true)
39
+
40
+ const sortKey = computed<LaneSortKey>(() =>
41
+ isLaneSortKey(storedSortKey.value) ? storedSortKey.value : 'smart',
42
+ )
43
+ const groupKey = computed<LaneGroupKey>(() =>
44
+ isLaneGroupKey(storedGroupKey.value) ? storedGroupKey.value : 'none',
45
+ )
46
+
47
+ /**
48
+ * Whether the reader has overridden the defaults. Drives `showOverrideField`, so a
49
+ * preference set in advanced mode stays visible (and clearable) after a switch to basic
50
+ * — the one case where hiding an override would conceal a setting the board is using.
51
+ */
52
+ const hasOverride = computed(() => sortKey.value !== 'smart' || groupKey.value !== 'none')
53
+
54
+ function setSortKey(next: LaneSortKey) {
55
+ storedSortKey.value = next
56
+ }
57
+
58
+ function setGroupKey(next: LaneGroupKey) {
59
+ storedGroupKey.value = next
60
+ }
61
+
62
+ function reset() {
63
+ storedSortKey.value = 'smart'
64
+ storedGroupKey.value = 'none'
65
+ }
66
+
67
+ function toggleDoneLane() {
68
+ doneLaneCollapsed.value = !doneLaneCollapsed.value
69
+ }
70
+
71
+ return {
72
+ storedSortKey,
73
+ storedGroupKey,
74
+ doneLaneCollapsed,
75
+ sortKey,
76
+ groupKey,
77
+ hasOverride,
78
+ setSortKey,
79
+ setGroupKey,
80
+ reset,
81
+ toggleDoneLane,
82
+ }
83
+ },
84
+ { persist: { pick: ['storedSortKey', 'storedGroupKey', 'doneLaneCollapsed'] } },
85
+ )
@@ -0,0 +1,63 @@
1
+ import { ref } from 'vue'
2
+
3
+ /**
4
+ * The state machine behind a ONCE-PER-SESSION launch offer: the tutorial's "take a tour?"
5
+ * (`stores/tutorial.ts`) and the role question (`stores/uiRole.ts`) are the same shape, so
6
+ * they share this rather than each re-deriving it.
7
+ *
8
+ * It is a small machine with four distinguishable exits and no other job, which is what makes
9
+ * it a seam worth having rather than four refs among twenty: closing without answering, an
10
+ * explicit decline (the caller's own, since only it knows what a decision IS), a DEFERRAL
11
+ * (something the user must actually answer opened on top), and the once-per-session auto-open
12
+ * are four different things, and only some of them write anything down. The subtlety they share
13
+ * is the ONE-OFFER-PER-SESSION guard, which is why they belong together: `promptAutoOpened`
14
+ * must be spent by an offer the user saw and NOT by one that was withdrawn.
15
+ *
16
+ * `hasDecision` is a bound getter over the caller's persisted record rather than the record
17
+ * itself, so this module never learns what a decision IS — only whether one exists, which is
18
+ * the whole of what the offer needs.
19
+ */
20
+ export function createLaunchPrompt(deps: { hasDecision: () => boolean }) {
21
+ const promptOpen = ref(false)
22
+ /** Once-per-session guard for the launch auto-open; later opens are user-driven. */
23
+ const promptAutoOpened = ref(false)
24
+
25
+ /**
26
+ * Auto-open the launch prompt, at most once per session and only while the user has never
27
+ * answered it. Callers gate on the rest of the launch context (board ready, no other startup
28
+ * advisory open) — see `pages/index.vue`.
29
+ */
30
+ function maybeOfferOnLaunch() {
31
+ if (deps.hasDecision() || promptAutoOpened.value) return
32
+ promptAutoOpened.value = true
33
+ promptOpen.value = true
34
+ }
35
+
36
+ /** User-driven open (command palette), regardless of any saved decision. */
37
+ function openPrompt() {
38
+ promptOpen.value = true
39
+ }
40
+
41
+ /**
42
+ * Withdraw an offer this store made, because something the user actually has to answer (a
43
+ * startup advisory, the GitHub onboarding gate) opened on top of it — and re-arm, so the offer
44
+ * returns once that surface is gone. Distinct from {@link closePrompt}: no decision is written
45
+ * EITHER way, but a deferral was not the user's doing, so it must not consume this session's
46
+ * one offer.
47
+ *
48
+ * Only ever withdraws the AUTO-opened prompt; a prompt the user opened themselves from the
49
+ * palette is theirs to close.
50
+ */
51
+ function deferPrompt() {
52
+ if (!promptAutoOpened.value) return
53
+ promptOpen.value = false
54
+ promptAutoOpened.value = false
55
+ }
56
+
57
+ /** Close without answering: no decision is written, so the next launch asks again. */
58
+ function closePrompt() {
59
+ promptOpen.value = false
60
+ }
61
+
62
+ return { promptOpen, promptAutoOpened, maybeOfferOnLaunch, openPrompt, deferPrompt, closePrompt }
63
+ }
@@ -4,7 +4,7 @@ import { useUiStore } from '~/stores/ui'
4
4
 
5
5
  /**
6
6
  * The expansion gate combines two independent grants (hover at any zoom, the deep zoom
7
- * bands otherwise). Both `TaskPipelineMini` (what renders) and `DraggableTask` (what
7
+ * bands otherwise). Both `TaskPipelineMini` (what renders) and `LaneTask` (what
8
8
  * stacks on top) read `isExpanded`, so these cases pin the rule they share.
9
9
  *
10
10
  * Zoom is set through the ui store's raw `zoom`, the same value the board canvas writes;
@@ -8,7 +8,7 @@ import { useUiStore } from '~/stores/ui'
8
8
  *
9
9
  * Two independent grants, both written every frame by the board driver
10
10
  * (`useTaskExpansion`) and combined HERE so the render (`TaskPipelineMini`) and the
11
- * stacking (`DraggableTask`) can never disagree about which cards are expanded:
11
+ * stacking (`LaneTask`) can never disagree about which cards are expanded:
12
12
  *
13
13
  * - HOVER — the card under the pointer expands at ANY zoom level. Pointing at a task
14
14
  * is asking what it is doing right now, and that answer used to be reachable only