@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.
- package/README.md +158 -4
- package/app/components/board/LaneViewControl.vue +87 -0
- package/app/components/board/nodes/BlockNode.vue +31 -11
- package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
- package/app/components/board/nodes/InitiativeCard.vue +9 -28
- package/app/components/board/nodes/LaneGroup.vue +93 -0
- package/app/components/board/nodes/LaneTask.vue +66 -0
- package/app/components/board/nodes/TaskCard.vue +16 -2
- package/app/components/board/nodes/TaskLane.vue +82 -0
- package/app/components/layout/BoardToolbar.vue +4 -0
- package/app/components/layout/CommandBar.vue +8 -1
- package/app/components/layout/RolePrompt.vue +75 -0
- package/app/components/layout/SideBar.vue +22 -13
- package/app/components/layout/UiRoleSwitcher.vue +73 -0
- package/app/components/panels/InspectorPanel.vue +15 -2
- package/app/components/panels/inspector/TaskStructure.vue +70 -3
- package/app/components/settings/WorkspaceSettingsPanel.vue +59 -0
- package/app/composables/useBlockDrag.ts +47 -17
- package/app/composables/useBlockQueries.ts +27 -24
- package/app/composables/useFrameLanes.ts +177 -0
- package/app/composables/useNavContributions.ts +3 -0
- package/app/composables/useTaskExpansion.ts +1 -1
- package/app/docs/consumer-extensions.md +20 -6
- package/app/modular/external-tools.spec.ts +0 -45
- package/app/modular/external-tools.ts +12 -23
- package/app/modular/nav-contributions.spec.ts +176 -17
- package/app/modular/nav-contributions.ts +106 -23
- package/app/modular/nav-gates.ts +11 -2
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +5 -3
- package/app/modular/tutorial-tours.ts +53 -8
- package/app/pages/index.vue +36 -7
- package/app/stores/board/placement.ts +7 -0
- package/app/stores/board.spec.ts +119 -14
- package/app/stores/laneView.spec.ts +61 -0
- package/app/stores/laneView.ts +85 -0
- package/app/stores/launchPrompt.ts +63 -0
- package/app/stores/taskExpansion.spec.ts +1 -1
- package/app/stores/taskExpansion.ts +1 -1
- package/app/stores/tutorial.ts +4 -4
- package/app/stores/uiMode.spec.ts +11 -0
- package/app/stores/uiMode.ts +14 -2
- package/app/stores/uiRole.spec.ts +185 -0
- package/app/stores/uiRole.ts +86 -0
- package/app/stores/workspaceSettings.ts +4 -0
- package/app/utils/framePlacement.ts +9 -4
- package/app/utils/laneGeometry.spec.ts +69 -0
- package/app/utils/laneGeometry.ts +104 -0
- package/app/utils/laneSort.spec.ts +236 -0
- package/app/utils/laneSort.ts +306 -0
- package/app/utils/swimlanes.spec.ts +259 -0
- package/app/utils/swimlanes.ts +355 -0
- package/app/utils/uiMode.spec.ts +12 -0
- package/app/utils/uiMode.ts +24 -6
- package/app/utils/uiRole.ts +123 -0
- package/i18n/locales/de.json +104 -3
- package/i18n/locales/en.json +110 -3
- package/i18n/locales/es.json +104 -3
- package/i18n/locales/fr.json +104 -3
- package/i18n/locales/he.json +104 -3
- package/i18n/locales/it.json +104 -3
- package/i18n/locales/ja.json +104 -3
- package/i18n/locales/pl.json +104 -3
- package/i18n/locales/tr.json +104 -3
- package/i18n/locales/uk.json +104 -3
- package/package.json +2 -2
- package/app/components/board/nodes/DraggableTask.vue +0 -58
- package/app/components/board/nodes/ModuleFrame.vue +0 -73
- 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: [
|
|
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: [
|
|
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: [
|
|
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: [
|
|
994
|
+
requires: [
|
|
995
|
+
TUTORIAL_REQUIREMENTS.settingsManage,
|
|
996
|
+
TUTORIAL_REQUIREMENTS.advancedTier,
|
|
997
|
+
TUTORIAL_REQUIREMENTS.fullSurface,
|
|
998
|
+
],
|
|
954
999
|
steps: [
|
|
955
1000
|
{
|
|
956
1001
|
id: 'intro',
|
package/app/pages/index.vue
CHANGED
|
@@ -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
|
|
316
|
-
// first launch never stacks the tour prompt on top of a dialog that
|
|
317
|
-
// first; when one of those is open, the flip of its flag re-fires this
|
|
318
|
-
// prompt appears then. The store guards the rest: only a user who never
|
|
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,
|
|
381
|
+
() => [workspace.ready, tutorialYieldsTo.value, tutorial.promptOpen],
|
|
354
382
|
() => {
|
|
355
|
-
if (
|
|
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.
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
230
|
+
expect(store.containerSize('f1')).toEqual(
|
|
231
|
+
frameContentSize({ hasChildren: false, initiatives: 0 }),
|
|
232
|
+
)
|
|
218
233
|
})
|
|
219
234
|
|
|
220
|
-
it('
|
|
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:
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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('
|
|
233
|
-
store.hydrate([frame('f1'), moduleBlock('m1', 'f1',
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 `
|
|
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 (`
|
|
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
|