@cat-factory/app 0.275.0 → 0.277.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 (55) hide show
  1. package/README.md +89 -2
  2. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +13 -1
  3. package/app/components/binaryOutput/BinaryOutputReport.vue +15 -1
  4. package/app/components/binaryOutput/StoredAssetView.vue +134 -0
  5. package/app/components/board/AddTaskModal.vue +9 -4
  6. package/app/components/board/LaneViewControl.vue +87 -0
  7. package/app/components/board/nodes/BlockNode.vue +24 -10
  8. package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
  9. package/app/components/board/nodes/InitiativeCard.vue +9 -28
  10. package/app/components/board/nodes/LaneGroup.vue +93 -0
  11. package/app/components/board/nodes/LaneTask.vue +66 -0
  12. package/app/components/board/nodes/TaskCard.vue +16 -2
  13. package/app/components/board/nodes/TaskLane.vue +82 -0
  14. package/app/components/layout/BoardToolbar.vue +4 -0
  15. package/app/components/palettes/PipelinePurposeSelect.vue +1 -0
  16. package/app/components/panels/InspectorPanel.vue +15 -2
  17. package/app/components/panels/inspector/TaskStructure.vue +70 -3
  18. package/app/components/settings/WorkspaceSettingsPanel.vue +61 -1
  19. package/app/composables/api/visualConfirm.ts +11 -2
  20. package/app/composables/useArtifactBlobs.spec.ts +76 -0
  21. package/app/composables/useArtifactBlobs.ts +25 -4
  22. package/app/composables/useBlockDrag.ts +47 -17
  23. package/app/composables/useBlockQueries.ts +27 -24
  24. package/app/composables/useFrameLanes.ts +177 -0
  25. package/app/composables/useTaskExpansion.ts +1 -1
  26. package/app/stores/board/placement.ts +7 -0
  27. package/app/stores/board.spec.ts +119 -14
  28. package/app/stores/laneView.spec.ts +61 -0
  29. package/app/stores/laneView.ts +85 -0
  30. package/app/stores/taskExpansion.spec.ts +1 -1
  31. package/app/stores/taskExpansion.ts +1 -1
  32. package/app/stores/workspaceSettings.ts +4 -0
  33. package/app/utils/binaryCandidates.ts +19 -1
  34. package/app/utils/binaryOutput.ts +13 -0
  35. package/app/utils/catalog.ts +6 -0
  36. package/app/utils/framePlacement.ts +9 -4
  37. package/app/utils/laneGeometry.spec.ts +69 -0
  38. package/app/utils/laneGeometry.ts +104 -0
  39. package/app/utils/laneSort.spec.ts +236 -0
  40. package/app/utils/laneSort.ts +306 -0
  41. package/app/utils/swimlanes.spec.ts +259 -0
  42. package/app/utils/swimlanes.ts +355 -0
  43. package/i18n/locales/de.json +91 -5
  44. package/i18n/locales/en.json +91 -5
  45. package/i18n/locales/es.json +91 -5
  46. package/i18n/locales/fr.json +91 -5
  47. package/i18n/locales/he.json +91 -5
  48. package/i18n/locales/it.json +91 -5
  49. package/i18n/locales/ja.json +91 -5
  50. package/i18n/locales/pl.json +91 -5
  51. package/i18n/locales/tr.json +91 -5
  52. package/i18n/locales/uk.json +91 -5
  53. package/package.json +2 -2
  54. package/app/components/board/nodes/DraggableTask.vue +0 -58
  55. package/app/components/board/nodes/ModuleFrame.vue +0 -73
@@ -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
+ )
@@ -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
@@ -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',
@@ -1,3 +1,4 @@
1
+ import { platformAssetIdOf } from '@cat-factory/contracts'
1
2
  import type { BinaryCandidate, BinaryCandidateStepState, PipelineStep } from '~/types/execution'
2
3
 
3
4
  // ---------------------------------------------------------------------------
@@ -23,6 +24,17 @@ export interface BinaryCandidateRow extends BinaryCandidate {
23
24
  kept: boolean
24
25
  /** The id it is to be stored under, when the person who kept it assigned one. */
25
26
  storeAs?: string
27
+ /**
28
+ * The platform's own artifact id, when this candidate was staged through the platform's asset
29
+ * storage rather than an org's own service.
30
+ *
31
+ * It is the OTHER way a candidate gets a preview, and the one that works on a private estate:
32
+ * `previewUrl` needs the storage service to have issued a public link, which ours never does
33
+ * (its bytes are behind the workspace's own authenticated blob route). Without this a
34
+ * deployment using the shipped storage would compare candidates it could not see, which is the
35
+ * one thing this window exists to make possible.
36
+ */
37
+ assetId: string | null
26
38
  }
27
39
 
28
40
  /** The candidates for one subject, which is the unit a person compares. */
@@ -81,6 +93,7 @@ export function binaryCandidateView(
81
93
  const choice = kept.get(candidate.id)
82
94
  group.rows.push({
83
95
  ...candidate,
96
+ assetId: platformAssetIdOf(candidate),
84
97
  kept: choice !== undefined,
85
98
  ...(choice?.storeAs ? { storeAs: choice.storeAs } : {}),
86
99
  })
@@ -91,7 +104,12 @@ export function binaryCandidateView(
91
104
  awaiting: state.status === 'awaiting_choice',
92
105
  multiSelect: state.multiSelect === true,
93
106
  automatic: state.choice?.automatic === true,
94
- withoutPreview: state.candidates.filter((candidate) => !candidate.previewUrl).length,
107
+ // Counts a candidate with NEITHER kind of preview: a service-issued link, or bytes the
108
+ // platform holds itself. Reading only `previewUrl` here would report every candidate of every
109
+ // run on the shipped storage as unviewable while the window was rendering all of them.
110
+ withoutPreview: state.candidates.filter(
111
+ (candidate) => !candidate.previewUrl && !platformAssetIdOf(candidate),
112
+ ).length,
95
113
  }
96
114
  }
97
115
 
@@ -9,6 +9,7 @@ import {
9
9
  isHarnessTransport,
10
10
  modalityCarriesPixelDimensions,
11
11
  normalizeMediaType,
12
+ platformAssetIdOf,
12
13
  requiredBinaryCapabilities,
13
14
  } from '@cat-factory/contracts'
14
15
  import type {
@@ -103,6 +104,17 @@ export interface BinaryOutputRow extends BinaryOutputArtifact {
103
104
  * instead of letting an absent measurement read as a passing one.
104
105
  */
105
106
  missized: boolean
107
+ /**
108
+ * The platform's own artifact id, when THIS deployment is what holds the bytes: the row's
109
+ * service is the platform asset store and its `location` is a well-formed artifact id.
110
+ *
111
+ * It is what turns a location string into a picture the reader can look at and a file they can
112
+ * save, which is the whole difference between an asset in our storage and one in an org's
113
+ * private bucket. Null covers BOTH "stored elsewhere" and "stored here, but the location is not
114
+ * an id". A model's location is prose, and a paraphrased one costs the row its preview and
115
+ * never its record.
116
+ */
117
+ assetId: string | null
106
118
  }
107
119
 
108
120
  /**
@@ -301,6 +313,7 @@ export function binaryOutputView(step: PipelineStep | null | undefined): BinaryO
301
313
  unknown: unknown.has(artifact.service),
302
314
  // An UNATTRIBUTED row (no `generator` claimed) is not unknown — see the field's own note.
303
315
  generatorUnknown: artifact.generator !== undefined && unknownGenerators.has(artifact.generator),
316
+ assetId: platformAssetIdOf(artifact),
304
317
  // An UNMEASURED row is not missized either: absent dimensions are counted by
305
318
  // `sizeUnreported`, never folded in here.
306
319
  missized:
@@ -959,6 +959,12 @@ export const TASK_TYPE_META: Record<string, TaskTypeMeta> = {
959
959
  color: '#a78bfa',
960
960
  labelKey: 'board.addTask.types.ralph',
961
961
  },
962
+ media: {
963
+ taskType: 'media',
964
+ icon: 'i-lucide-image-plus',
965
+ color: '#f472b6',
966
+ labelKey: 'board.addTask.types.media',
967
+ },
962
968
  recurring: {
963
969
  taskType: 'recurring',
964
970
  icon: 'i-lucide-repeat',
@@ -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. Mirrors the
27
- * empty-frame floor in {@link useBlockQueries.contentSize} (w 360, inner h 220) so a
28
- * placement decision made BEFORE the block exists matches how it will actually render.
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 = { w: 360, h: 220 }
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
+ }