@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
package/app/stores/tutorial.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
|
-
import {
|
|
3
|
+
import { createLaunchPrompt } from '~/stores/launchPrompt'
|
|
4
4
|
import { createTutorialRecord } from '~/stores/tutorial.record'
|
|
5
5
|
|
|
6
6
|
// `TutorialDecision` is deliberately NOT re-exported from here even though it used to live here:
|
|
@@ -37,9 +37,9 @@ export const useTutorialStore = defineStore(
|
|
|
37
37
|
'tutorial',
|
|
38
38
|
() => {
|
|
39
39
|
const record = createTutorialRecord()
|
|
40
|
-
// The launch offer's own four-exit state machine (`stores/
|
|
41
|
-
// know only WHETHER an answer exists.
|
|
42
|
-
const prompt =
|
|
40
|
+
// The launch offer's own four-exit state machine (`stores/launchPrompt.ts`, shared with the
|
|
41
|
+
// role question), which needs to know only WHETHER an answer exists.
|
|
42
|
+
const prompt = createLaunchPrompt({ hasDecision: () => record.decision.value !== null })
|
|
43
43
|
const { promptOpen } = prompt
|
|
44
44
|
/**
|
|
45
45
|
* The tutorial catalogue (every tour this deployment ships, startable at any time) is
|
|
@@ -66,6 +66,17 @@ describe('useUiModeStore mode resolution', () => {
|
|
|
66
66
|
expect(ui.mode).toBe('advanced')
|
|
67
67
|
})
|
|
68
68
|
|
|
69
|
+
it('ignores an unrecognised RESTORED value on the same terms as the env one', () => {
|
|
70
|
+
// `storedMode`'s type says what `setMode` writes, not what the persistence plugin
|
|
71
|
+
// rehydrated: an older build's blob (or a hand-edited one) arrives typed as a `UiMode`.
|
|
72
|
+
// Left unparsed it resolves to neither tier, and `isAdvanced` then answers a question
|
|
73
|
+
// about a value that is not a tier at all.
|
|
74
|
+
const ui = useUiModeStore()
|
|
75
|
+
ui.storedMode = 'expert' as UiMode
|
|
76
|
+
expect(ui.mode).toBe('basic')
|
|
77
|
+
expect(ui.isAdvanced).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
69
80
|
it('toggleMode flips between the two tiers', () => {
|
|
70
81
|
const ui = useUiModeStore()
|
|
71
82
|
ui.toggleMode()
|
package/app/stores/uiMode.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
|
+
import { useUiRoleStore } from '~/stores/uiRole'
|
|
3
4
|
import { DEFAULT_RAIL_COLLAPSED, parseUiMode, resolveUiMode, type UiMode } from '~/utils/uiMode'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The interface tier (`basic` / `advanced`) and the side-navbar collapse state.
|
|
7
8
|
*
|
|
8
|
-
* Mode resolution is `env → browser-stored → basic` (see `utils/uiMode.ts`)
|
|
9
|
+
* Mode resolution is `role surface → env → browser-stored → basic` (see `utils/uiMode.ts`); the
|
|
10
|
+
* role is the ceiling, so an `intake` role renders basic whatever the other two say. The env value
|
|
9
11
|
* is `runtimeConfig.public.uiMode`, i.e. `NUXT_PUBLIC_UI_MODE`: the SPA is `ssr: false`, so
|
|
10
12
|
* — exactly like `apiBase` — it is baked in at build time and cannot change while the app
|
|
11
13
|
* is loaded. It is therefore read ONCE here rather than tracked reactively. Only the user's
|
|
@@ -31,7 +33,17 @@ export const useUiModeStore = defineStore(
|
|
|
31
33
|
/** The rail state each tier was last left in, persisted. Seeded from the per-tier defaults. */
|
|
32
34
|
const railCollapsed = ref<Record<UiMode, boolean>>({ ...DEFAULT_RAIL_COLLAPSED })
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
// The role's surface caps the tier (see `resolveUiMode`): an `intake` role renders basic
|
|
37
|
+
// whatever the env pin or the stored choice says, so every `isAdvanced` reader in the app
|
|
38
|
+
// agrees with the narrowed nav without restating the role.
|
|
39
|
+
const uiRole = useUiRoleStore()
|
|
40
|
+
// The restored value goes through `parseUiMode` for the same reason the env string does, and
|
|
41
|
+
// for the reason `railCollapsed` is read defensively just below: `storedMode`'s type says
|
|
42
|
+
// what `setMode` writes, not what the persistence plugin rehydrated. An unrecognised tier
|
|
43
|
+
// would otherwise pass straight through `resolveUiMode` and render as neither tier.
|
|
44
|
+
const mode = computed<UiMode>(() =>
|
|
45
|
+
resolveUiMode(envMode, parseUiMode(storedMode.value), uiRole.surface),
|
|
46
|
+
)
|
|
35
47
|
const isAdvanced = computed(() => mode.value === 'advanced')
|
|
36
48
|
/** Pinned by the deployment: the switcher is read-only, since a write would be ignored. */
|
|
37
49
|
const envPinned = computed(() => envMode !== null)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { createPinia, setActivePinia } from 'pinia'
|
|
3
|
+
import { missingI18nKeys } from '../../test/i18nKeys'
|
|
4
|
+
import { useUiRoleStore } from '~/stores/uiRole'
|
|
5
|
+
import { useUiModeStore } from '~/stores/uiMode'
|
|
6
|
+
import {
|
|
7
|
+
parseUiRole,
|
|
8
|
+
ROLE_PRESENTATION,
|
|
9
|
+
ROLE_SURFACES,
|
|
10
|
+
UI_ROLES,
|
|
11
|
+
type UiRole,
|
|
12
|
+
} from '~/utils/uiRole'
|
|
13
|
+
|
|
14
|
+
describe('useUiRoleStore role resolution', () => {
|
|
15
|
+
it('boots on the FULL surface with nothing stored, and says nobody has chosen', () => {
|
|
16
|
+
// The two halves of the first-run condition, and they must not be conflated: an unanswered
|
|
17
|
+
// question leaves the whole product in place (`fullSurface`) while still being unanswered
|
|
18
|
+
// (`chosen`), which is what lets the prompt ask again without ever having taken anything away.
|
|
19
|
+
const role = useUiRoleStore()
|
|
20
|
+
expect(role.chosen).toBe(false)
|
|
21
|
+
expect(role.role).toBe('engineer')
|
|
22
|
+
expect(role.fullSurface).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('honours a stored choice and narrows only for the intake role', () => {
|
|
26
|
+
const role = useUiRoleStore()
|
|
27
|
+
role.setRole('product-manager')
|
|
28
|
+
expect(role.role).toBe('product-manager')
|
|
29
|
+
expect(role.chosen).toBe(true)
|
|
30
|
+
// Engineer and product-manager resolve to the SAME surface today; that is the product
|
|
31
|
+
// decision, not an accident of the mapping.
|
|
32
|
+
expect(role.fullSurface).toBe(true)
|
|
33
|
+
|
|
34
|
+
role.setRole('designer')
|
|
35
|
+
expect(role.surface).toBe('intake')
|
|
36
|
+
expect(role.fullSurface).toBe(false)
|
|
37
|
+
// Persisted, so a reload restores it (the persist plugin picks `storedRole`).
|
|
38
|
+
expect(role.storedRole).toBe('designer')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('coerces an unknown persisted value on the way IN, not at the call sites', () => {
|
|
42
|
+
// What the persistence plugin restores is a JSON blob an older build wrote or somebody hand
|
|
43
|
+
// edited, so an unknown string arrives typed as a `UiRole` and nothing downstream is
|
|
44
|
+
// forgiving: `ROLE_SURFACES[role]` is `undefined` (the nav narrows to intake without a
|
|
45
|
+
// word) and `ROLE_PRESENTATION[role].labelKey` throws in the switcher. Writing the raw
|
|
46
|
+
// value onto `storedRole` is exactly what hydration does, which is why this drives the
|
|
47
|
+
// STORE rather than calling `parseUiRole` by hand: a guard nothing calls looks identical to
|
|
48
|
+
// a guard that works.
|
|
49
|
+
const role = useUiRoleStore()
|
|
50
|
+
role.storedRole = 'architect' as UiRole
|
|
51
|
+
|
|
52
|
+
expect(role.role).toBe('engineer')
|
|
53
|
+
expect(role.surface).toBe('full')
|
|
54
|
+
expect(role.fullSurface).toBe(true)
|
|
55
|
+
// An unrecognised value is not an ANSWER, so the first-run prompt asks again and the person
|
|
56
|
+
// can replace it. Reading `storedRole !== null` instead pins the browser to the default with
|
|
57
|
+
// the one question that would fix it already marked settled.
|
|
58
|
+
expect(role.chosen).toBe(false)
|
|
59
|
+
// The resolved role indexes both catalogs, which is the property the switcher relies on.
|
|
60
|
+
expect(ROLE_PRESENTATION[role.role].labelKey).toBeTruthy()
|
|
61
|
+
expect(ROLE_SURFACES[role.role]).toBe('full')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('parses a raw value the way the store consumes it', () => {
|
|
65
|
+
expect(parseUiRole('lead-designer')).toBeNull()
|
|
66
|
+
expect(parseUiRole(undefined)).toBeNull()
|
|
67
|
+
expect(parseUiRole(' Designer ')).toBe('designer')
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('useUiRoleStore first-run prompt', () => {
|
|
72
|
+
it('offers itself once per session while unanswered', () => {
|
|
73
|
+
const role = useUiRoleStore()
|
|
74
|
+
role.maybeOfferOnLaunch()
|
|
75
|
+
expect(role.promptOpen).toBe(true)
|
|
76
|
+
|
|
77
|
+
// Closing without answering writes nothing, so the NEXT launch asks again, but this
|
|
78
|
+
// session's one offer is spent.
|
|
79
|
+
role.closePrompt()
|
|
80
|
+
expect(role.storedRole).toBeNull()
|
|
81
|
+
role.maybeOfferOnLaunch()
|
|
82
|
+
expect(role.promptOpen).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('never offers itself again once a role is recorded', () => {
|
|
86
|
+
const role = useUiRoleStore()
|
|
87
|
+
role.setRole('designer')
|
|
88
|
+
role.maybeOfferOnLaunch()
|
|
89
|
+
expect(role.promptOpen).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('re-arms after a deferral, so a startup advisory does not consume the offer', () => {
|
|
93
|
+
// What `pages/index.vue` does when an advisory the user must answer opens on top: the offer
|
|
94
|
+
// is withdrawn and comes back, rather than counting as a question they were asked.
|
|
95
|
+
const role = useUiRoleStore()
|
|
96
|
+
role.maybeOfferOnLaunch()
|
|
97
|
+
role.deferPrompt()
|
|
98
|
+
expect(role.promptOpen).toBe(false)
|
|
99
|
+
role.maybeOfferOnLaunch()
|
|
100
|
+
expect(role.promptOpen).toBe(true)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('leaves a user-opened prompt alone on a deferral', () => {
|
|
104
|
+
// Opened from the command palette: it is the user's, so nothing withdraws it.
|
|
105
|
+
const role = useUiRoleStore()
|
|
106
|
+
role.openPrompt()
|
|
107
|
+
role.deferPrompt()
|
|
108
|
+
expect(role.promptOpen).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('settles the prompt by picking a role', () => {
|
|
112
|
+
const role = useUiRoleStore()
|
|
113
|
+
role.openPrompt()
|
|
114
|
+
role.setRole('engineer')
|
|
115
|
+
expect(role.promptOpen).toBe(false)
|
|
116
|
+
expect(role.chosen).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('the role as a ceiling on the interface tier', () => {
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
setActivePinia(createPinia())
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('caps a narrowed role at the basic tier, whatever the user stored', () => {
|
|
126
|
+
// Resolved rather than merely hidden: every `isAdvanced` reader inside a surface (the
|
|
127
|
+
// override fields, the authoring affordances) has to agree with the narrowed nav, and only
|
|
128
|
+
// the resolved mode reaches all of them.
|
|
129
|
+
const mode = useUiModeStore()
|
|
130
|
+
mode.setMode('advanced')
|
|
131
|
+
expect(mode.isAdvanced).toBe(true)
|
|
132
|
+
|
|
133
|
+
useUiRoleStore().setRole('designer')
|
|
134
|
+
expect(mode.mode).toBe('basic')
|
|
135
|
+
expect(mode.isAdvanced).toBe(false)
|
|
136
|
+
|
|
137
|
+
// Leaving the narrowed role restores the tier the person had picked: the cap withholds the
|
|
138
|
+
// tier, it does not overwrite the preference.
|
|
139
|
+
useUiRoleStore().setRole('engineer')
|
|
140
|
+
expect(mode.isAdvanced).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('UI_ROLES catalog integrity', () => {
|
|
145
|
+
it('maps every role to a surface and presents each one', () => {
|
|
146
|
+
// Derived from the vocabulary rather than re-listed, so a new role fails here (and in the
|
|
147
|
+
// exhaustive Records themselves) until it has picked a surface and gained its copy.
|
|
148
|
+
expect(Object.keys(ROLE_SURFACES).sort()).toEqual([...UI_ROLES].sort())
|
|
149
|
+
expect(Object.keys(ROLE_PRESENTATION).sort()).toEqual([...UI_ROLES].sort())
|
|
150
|
+
// Exactly one narrowed role today, and the full ones are the majority: a mapping that
|
|
151
|
+
// narrowed everything would pass every other assertion in this file.
|
|
152
|
+
const surfaces = UI_ROLES.map((role: UiRole) => ROLE_SURFACES[role])
|
|
153
|
+
expect(surfaces.filter((s) => s === 'full').length).toBeGreaterThan(0)
|
|
154
|
+
expect(surfaces.filter((s) => s === 'intake').length).toBeGreaterThan(0)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('names an i18n key that exists for every role label and hint', () => {
|
|
158
|
+
// A table lookup is invisible to typed message keys and to `i18n:check` (neither sees
|
|
159
|
+
// `t(ROLE_PRESENTATION[role].labelKey)`), so deleting one of these keys would otherwise read
|
|
160
|
+
// as a clean removal and render its own key path in the picker.
|
|
161
|
+
const keys = UI_ROLES.flatMap((role: UiRole) => [
|
|
162
|
+
ROLE_PRESENTATION[role].labelKey,
|
|
163
|
+
ROLE_PRESENTATION[role].hintKey,
|
|
164
|
+
])
|
|
165
|
+
expect(missingI18nKeys(keys)).toEqual([])
|
|
166
|
+
// And the copy the two surfaces around them use, which is written literally there but is
|
|
167
|
+
// just as easy to rename out from under this table.
|
|
168
|
+
expect(
|
|
169
|
+
missingI18nKeys([
|
|
170
|
+
'uiRole.switcher',
|
|
171
|
+
'uiRole.prompt.title',
|
|
172
|
+
'uiRole.prompt.intro',
|
|
173
|
+
'uiRole.prompt.change',
|
|
174
|
+
'uiRole.prompt.later',
|
|
175
|
+
]),
|
|
176
|
+
).toEqual([])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('gives every role a distinct glyph', () => {
|
|
180
|
+
// The rail renders the glyph alone above a truncated name, so two roles sharing one would be
|
|
181
|
+
// indistinguishable exactly where the label has the least room.
|
|
182
|
+
const icons = UI_ROLES.map((role: UiRole) => ROLE_PRESENTATION[role].icon)
|
|
183
|
+
expect(new Set(icons).size).toBe(icons.length)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import { createLaunchPrompt } from '~/stores/launchPrompt'
|
|
4
|
+
import {
|
|
5
|
+
isFullSurfaceRole,
|
|
6
|
+
parseUiRole,
|
|
7
|
+
resolveUiRole,
|
|
8
|
+
roleSurface,
|
|
9
|
+
type UiRole,
|
|
10
|
+
} from '~/utils/uiRole'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The role the person is here to do (`engineer` / `product-manager` / `designer`) and the
|
|
14
|
+
* first-run question that asks for it. Resolution and the surface each role maps to are in
|
|
15
|
+
* `utils/uiRole.ts`.
|
|
16
|
+
*
|
|
17
|
+
* Only the person's own choice exists: there is deliberately NO deployment env pin, unlike the
|
|
18
|
+
* interface tier. The tier is a fleet-shaped decision an operator can reasonably make for a
|
|
19
|
+
* kiosk deployment; which JOB the person at the keyboard does is not something the build can
|
|
20
|
+
* know, and pinning it would leave a designer's laptop configured as an engineer's with no way
|
|
21
|
+
* to say otherwise.
|
|
22
|
+
*
|
|
23
|
+
* `chosen` is the whole of the first-run condition: no recognised answer is recorded, which is
|
|
24
|
+
* what the prompt asks about, and it is what a browser that has never been asked, one whose
|
|
25
|
+
* answer was cleared, and one carrying a value that is no longer a role all have in common. The
|
|
26
|
+
* resolved role stays the default throughout, so an unanswered question never takes a
|
|
27
|
+
* destination away.
|
|
28
|
+
*/
|
|
29
|
+
export const useUiRoleStore = defineStore(
|
|
30
|
+
'uiRole',
|
|
31
|
+
() => {
|
|
32
|
+
/** The person's explicit pick, persisted. `null` until they choose one. */
|
|
33
|
+
const storedRole = ref<UiRole | null>(null)
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The persisted pick, COERCED, and the only thing anything below reads.
|
|
37
|
+
*
|
|
38
|
+
* `storedRole`'s type describes what {@link setRole} WRITES, not what boot restores into it:
|
|
39
|
+
* the persistence plugin rehydrates a JSON blob a previous build wrote or a person hand-
|
|
40
|
+
* edited, so an unknown string arrives typed as a `UiRole` and every reader believes it.
|
|
41
|
+
* There is nothing forgiving downstream to catch it: `ROLE_SURFACES[role]` is `undefined`,
|
|
42
|
+
* which narrows the nav to intake without a word, and `ROLE_PRESENTATION[role].labelKey`
|
|
43
|
+
* throws in the switcher, i.e. white-screens the board rather than degrading. So the raw
|
|
44
|
+
* value is parsed ONCE here, exactly as `agentTier` does with its own restored level.
|
|
45
|
+
*
|
|
46
|
+
* `chosen` reads it too, and that is the half that makes the degradation honest rather than
|
|
47
|
+
* merely safe: an unrecognised value is not an answer, so the first-run prompt asks again
|
|
48
|
+
* and the person can replace it. Reading `storedRole !== null` instead would leave a browser
|
|
49
|
+
* pinned to the default with the question it needs to be asked already marked settled.
|
|
50
|
+
*/
|
|
51
|
+
const pickedRole = computed<UiRole | null>(() => parseUiRole(storedRole.value))
|
|
52
|
+
|
|
53
|
+
const role = computed<UiRole>(() => resolveUiRole(pickedRole.value))
|
|
54
|
+
const surface = computed(() => roleSurface(role.value))
|
|
55
|
+
/**
|
|
56
|
+
* The role sees the whole product. Read by the nav gate of the same name and by the few
|
|
57
|
+
* surfaces that narrow inline; stated positively so no reader has to invert it.
|
|
58
|
+
*/
|
|
59
|
+
const fullSurface = computed(() => isFullSurfaceRole(role.value))
|
|
60
|
+
/** A RECOGNISED answer has been recorded, so the first-run prompt has nothing left to ask. */
|
|
61
|
+
const chosen = computed(() => pickedRole.value !== null)
|
|
62
|
+
|
|
63
|
+
// The same once-per-session launch machine the tutorial offer runs on: the question is
|
|
64
|
+
// answered by PICKING a role, so `hasDecision` is exactly `chosen`. Closing without picking
|
|
65
|
+
// writes nothing and the next launch asks again, which is safe here precisely because the
|
|
66
|
+
// default is the full surface.
|
|
67
|
+
const prompt = createLaunchPrompt({ hasDecision: () => chosen.value })
|
|
68
|
+
|
|
69
|
+
/** Record the person's pick and settle the question. */
|
|
70
|
+
function setRole(next: UiRole) {
|
|
71
|
+
storedRole.value = next
|
|
72
|
+
prompt.promptOpen.value = false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
role,
|
|
77
|
+
surface,
|
|
78
|
+
fullSurface,
|
|
79
|
+
chosen,
|
|
80
|
+
storedRole,
|
|
81
|
+
...prompt,
|
|
82
|
+
setRole,
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
{ persist: { pick: ['storedRole'] } },
|
|
86
|
+
)
|
|
@@ -12,6 +12,10 @@ const DEFAULTS: WorkspaceSettings = {
|
|
|
12
12
|
storeAgentContext: true,
|
|
13
13
|
publishPrVerificationReport: true,
|
|
14
14
|
artifactRetentionDays: 14,
|
|
15
|
+
// The board's Done swimlane keeps two weeks and 20 cards. Both cap what is RENDERED; a
|
|
16
|
+
// task aged out of the lane is still on the board's data and still counted in its total.
|
|
17
|
+
doneLaneMaxItems: 20,
|
|
18
|
+
doneLaneRetentionDays: 14,
|
|
15
19
|
kaizenEnabled: true,
|
|
16
20
|
delegateAgentsToRunnerPool: false,
|
|
17
21
|
inputGateMode: 'standard',
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* nodes in. Sizes are the frame's rendered pixel footprint (see
|
|
9
9
|
* {@link useBlockQueries.containerSize}).
|
|
10
10
|
*/
|
|
11
|
+
import { frameContentSize } from '~/utils/laneGeometry'
|
|
11
12
|
|
|
12
13
|
export interface Point {
|
|
13
14
|
x: number
|
|
@@ -23,11 +24,15 @@ export interface FrameRect extends Point {
|
|
|
23
24
|
export const FRAME_GAP = 48
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
* Footprint of a freshly-added, empty service frame in flow-space.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Footprint of a freshly-added, empty service frame in flow-space.
|
|
28
|
+
*
|
|
29
|
+
* DERIVED from the lane geometry rather than restated, because a placement decision is made
|
|
30
|
+
* BEFORE the block exists and so cannot measure it: the numbers here and the ones the frame
|
|
31
|
+
* renders at have to be the same numbers, or a new service is dropped on top of a neighbour it
|
|
32
|
+
* was placed to clear. A hand-copied pair went stale exactly that way when the frame's floor
|
|
33
|
+
* changed underneath it.
|
|
29
34
|
*/
|
|
30
|
-
export const EMPTY_FRAME_SIZE = {
|
|
35
|
+
export const EMPTY_FRAME_SIZE = frameContentSize({ hasChildren: false, initiatives: 0 })
|
|
31
36
|
|
|
32
37
|
/**
|
|
33
38
|
* Footprint of an epic grouping node in flow-space. Epics are top-level board nodes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { frameContentSize, laneBodyHeightIn, LANE_GEOMETRY } from './laneGeometry'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* These pin the RELATION between the two derivations, not their pixel arithmetic. The numbers are
|
|
6
|
+
* `LANE_GEOMETRY`'s to change; what must never change is that a frame sized by one of them hands
|
|
7
|
+
* the other back exactly what it reserved, because that is the agreement three consumers depend on
|
|
8
|
+
* (the frame's floor, what `FrameSwimlanes` renders into, and the spot placement reserves for a
|
|
9
|
+
* frame that does not exist yet).
|
|
10
|
+
*/
|
|
11
|
+
describe('frameContentSize', () => {
|
|
12
|
+
it('sizes a service with nothing in it to its "add the first task" panel, not to lanes', () => {
|
|
13
|
+
// An empty service renders no lanes at all, so reserving lane-sized space for it would leave
|
|
14
|
+
// the frame more than twice as tall as the one thing inside it — and push its neighbours that
|
|
15
|
+
// much further away, since placement clears frames by their reserved footprint.
|
|
16
|
+
const empty = frameContentSize({ hasChildren: false, initiatives: 0 })
|
|
17
|
+
expect(empty).toEqual({
|
|
18
|
+
w: LANE_GEOMETRY.emptyFrameWidth,
|
|
19
|
+
h: LANE_GEOMETRY.emptyFrameHeight,
|
|
20
|
+
})
|
|
21
|
+
expect(empty.h).toBeLessThan(frameContentSize({ hasChildren: true, initiatives: 0 }).h)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('does not grow with anything except the initiative band', () => {
|
|
25
|
+
// The whole point of the lanes: a lane scrolls rather than growing, so nothing about a
|
|
26
|
+
// service's task count reaches this function. Initiatives are the one child the frame still
|
|
27
|
+
// lays out itself, so they are the one thing its height still answers to.
|
|
28
|
+
const base = frameContentSize({ hasChildren: true, initiatives: 0 })
|
|
29
|
+
expect(frameContentSize({ hasChildren: true, initiatives: 1 })).toEqual({
|
|
30
|
+
w: base.w,
|
|
31
|
+
h: base.h + LANE_GEOMETRY.initiativeHeight,
|
|
32
|
+
})
|
|
33
|
+
// A second row only once the first is full, whatever that width happens to allow.
|
|
34
|
+
const perRow = Math.floor(LANE_GEOMETRY.canvasWidth / LANE_GEOMETRY.initiativeWidth)
|
|
35
|
+
expect(frameContentSize({ hasChildren: true, initiatives: perRow }).h).toBe(
|
|
36
|
+
base.h + LANE_GEOMETRY.initiativeHeight,
|
|
37
|
+
)
|
|
38
|
+
expect(frameContentSize({ hasChildren: true, initiatives: perRow + 1 }).h).toBe(
|
|
39
|
+
base.h + 2 * LANE_GEOMETRY.initiativeHeight,
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('laneBodyHeightIn', () => {
|
|
45
|
+
it('hands a frame at its floor size exactly the lane body that floor was computed from', () => {
|
|
46
|
+
// The round trip that keeps the lanes from being clipped by their own frame. Asserted for a
|
|
47
|
+
// frame with an initiative band too, since the band is the term the two have to agree about.
|
|
48
|
+
for (const initiatives of [0, 1, 5]) {
|
|
49
|
+
const floor = frameContentSize({ hasChildren: true, initiatives })
|
|
50
|
+
expect(laneBodyHeightIn(floor, initiatives)).toBe(LANE_GEOMETRY.laneBodyHeight)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('gives a dragged-taller frame the whole extra height', () => {
|
|
55
|
+
// The contract `LANE_GEOMETRY` states and `containerSize` implements: the geometry is a FLOOR,
|
|
56
|
+
// and a reader who wants more room drags the border. The lanes kept a constant height before
|
|
57
|
+
// this, so the extra space was dead canvas below them and the gesture appeared to do nothing.
|
|
58
|
+
const floor = frameContentSize({ hasChildren: true, initiatives: 0 })
|
|
59
|
+
const dragged = { w: floor.w, h: floor.h + 300 }
|
|
60
|
+
expect(laneBodyHeightIn(dragged, 0)).toBe(LANE_GEOMETRY.laneBodyHeight + 300)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('never returns less than the floor lane body, whatever it is handed', () => {
|
|
64
|
+
// A frame cannot be dragged below its floor, so this is only reachable by a caller measuring
|
|
65
|
+
// one mid-layout; collapsing a lane to nothing (or a negative height) is never the answer.
|
|
66
|
+
expect(laneBodyHeightIn({ w: 100, h: 0 }, 0)).toBe(LANE_GEOMETRY.laneBodyHeight)
|
|
67
|
+
expect(laneBodyHeightIn({ w: 100, h: 40 }, 12)).toBe(LANE_GEOMETRY.laneBodyHeight)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The swimlane layout's fixed pixel geometry, and the two functions that derive a frame's size
|
|
3
|
+
* from it.
|
|
4
|
+
*
|
|
5
|
+
* One module rather than numbers spread across the components, because three consumers have to
|
|
6
|
+
* agree exactly: `useBlockQueries.contentSize` computes the frame's minimum size, `FrameSwimlanes`
|
|
7
|
+
* renders into that space, and `framePlacement` reserves a spot for a frame that does not exist
|
|
8
|
+
* yet. When they disagree the lanes are clipped by their own frame or a new service is dropped on
|
|
9
|
+
* top of its neighbour, both of which look like rendering bugs rather than a stale constant. That
|
|
10
|
+
* is why the two derivations below are FUNCTIONS here rather than arithmetic at each call site:
|
|
11
|
+
* a constant restating the result is exactly the thing that went stale.
|
|
12
|
+
*
|
|
13
|
+
* These are DELIBERATELY fixed rather than derived from content. A lane scrolls; it does not
|
|
14
|
+
* grow. The frame of a service with 300 open tasks is the same size as one with three, which is
|
|
15
|
+
* what keeps a board of many services readable — the old free-layout frames grew with their task
|
|
16
|
+
* count until the busiest service dwarfed everything around it. A reader who wants more room
|
|
17
|
+
* drags the frame's border, and the stored size raises the floor these numbers set.
|
|
18
|
+
*/
|
|
19
|
+
export const LANE_GEOMETRY = {
|
|
20
|
+
/** Card width, matching the task card's own fixed width. */
|
|
21
|
+
cardWidth: 210,
|
|
22
|
+
/** One lane column: a card plus its gutters. */
|
|
23
|
+
laneWidth: 226,
|
|
24
|
+
/** Gap between lane columns. */
|
|
25
|
+
laneGap: 8,
|
|
26
|
+
/** The three live lanes plus the gaps and the canvas's own padding. */
|
|
27
|
+
canvasWidth: 226 * 3 + 8 * 2 + 16,
|
|
28
|
+
/** A lane's scrolling body, at the frame's floor size. */
|
|
29
|
+
laneBodyHeight: 420,
|
|
30
|
+
/** A lane's header (label, count, the withheld-count line on the Done lane). */
|
|
31
|
+
laneHeaderHeight: 34,
|
|
32
|
+
/** The collapsed Done strip's header row. */
|
|
33
|
+
doneStripHeight: 32,
|
|
34
|
+
/** An initiative card in the band above the lanes. */
|
|
35
|
+
initiativeWidth: 240,
|
|
36
|
+
initiativeHeight: 176,
|
|
37
|
+
/**
|
|
38
|
+
* A service with no children at all renders one "add the first task" panel and no lanes, so it
|
|
39
|
+
* reserves the panel's footprint rather than the lanes'. Sizing an empty service as though the
|
|
40
|
+
* lanes were there would leave every new frame two and a half times taller than the thing
|
|
41
|
+
* inside it, and would push its neighbours that much further away for nothing.
|
|
42
|
+
*/
|
|
43
|
+
emptyFrameWidth: 360,
|
|
44
|
+
emptyFrameHeight: 220,
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
/** What the frame lays out itself, which is everything its height cannot derive from a lane. */
|
|
48
|
+
export interface FrameContent {
|
|
49
|
+
/**
|
|
50
|
+
* Whether the frame renders lanes at all: it has tasks, modules or initiatives under it. The
|
|
51
|
+
* same predicate `BlockNode` gates `FrameSwimlanes` on, because a size that disagrees with
|
|
52
|
+
* what rendered is the clipping bug this module exists to prevent.
|
|
53
|
+
*/
|
|
54
|
+
readonly hasChildren: boolean
|
|
55
|
+
/** Initiative cards, which sit in a wrapping band above the lanes. */
|
|
56
|
+
readonly initiatives: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** How many initiative cards fit across a canvas `width` px wide. */
|
|
60
|
+
function initiativeRows(count: number, width: number): number {
|
|
61
|
+
const perRow = Math.max(1, Math.floor(width / LANE_GEOMETRY.initiativeWidth))
|
|
62
|
+
return Math.ceil(count / perRow)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The smallest size that fits a frame's swimlanes and its initiative band: the floor a resizable
|
|
67
|
+
* frame can never be dragged below, and the footprint a placement decision reserves for one that
|
|
68
|
+
* does not exist yet.
|
|
69
|
+
*/
|
|
70
|
+
export function frameContentSize(content: FrameContent): { w: number; h: number } {
|
|
71
|
+
if (!content.hasChildren) {
|
|
72
|
+
return { w: LANE_GEOMETRY.emptyFrameWidth, h: LANE_GEOMETRY.emptyFrameHeight }
|
|
73
|
+
}
|
|
74
|
+
const w = LANE_GEOMETRY.canvasWidth
|
|
75
|
+
return {
|
|
76
|
+
w,
|
|
77
|
+
h:
|
|
78
|
+
initiativeRows(content.initiatives, w) * LANE_GEOMETRY.initiativeHeight +
|
|
79
|
+
LANE_GEOMETRY.laneBodyHeight +
|
|
80
|
+
LANE_GEOMETRY.laneHeaderHeight +
|
|
81
|
+
LANE_GEOMETRY.doneStripHeight,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* How tall a lane's scrolling body is inside a frame of `size`.
|
|
87
|
+
*
|
|
88
|
+
* The inverse of {@link frameContentSize}: the room left once the initiative band, the lane
|
|
89
|
+
* headers and the collapsed Done strip have taken theirs. A frame at its floor size gets exactly
|
|
90
|
+
* `laneBodyHeight` back, and a frame the reader has dragged taller gives the whole difference to
|
|
91
|
+
* the lanes — which is the point of dragging it. Without this the lanes kept their constant
|
|
92
|
+
* height and the extra space was dead canvas below them, so the gesture appeared to do nothing.
|
|
93
|
+
*
|
|
94
|
+
* Never returns less than `laneBodyHeight`: a frame can be dragged no smaller than its floor, and
|
|
95
|
+
* a caller measuring one mid-layout should not be able to collapse a lane to nothing.
|
|
96
|
+
*/
|
|
97
|
+
export function laneBodyHeightIn(size: { w: number; h: number }, initiatives: number): number {
|
|
98
|
+
const room =
|
|
99
|
+
size.h -
|
|
100
|
+
initiativeRows(initiatives, size.w) * LANE_GEOMETRY.initiativeHeight -
|
|
101
|
+
LANE_GEOMETRY.laneHeaderHeight -
|
|
102
|
+
LANE_GEOMETRY.doneStripHeight
|
|
103
|
+
return Math.max(LANE_GEOMETRY.laneBodyHeight, room)
|
|
104
|
+
}
|