@cat-factory/app 0.259.3 → 0.260.1

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 CHANGED
@@ -68,6 +68,40 @@ Order within the column is by what the user loses by not reading it now; the too
68
68
 
69
69
  `app/components/layout/BoardTopOverlays.spec.ts` enforces the no-self-placement half, reading the member list from the component's own imports.
70
70
 
71
+ ### A board driver that MEASURES the DOM runs off the activity pulse, never a bare RAF
72
+
73
+ Two board features cannot be derived from the stores alone: the dependency-edge overlay needs
74
+ each card's on-screen rectangle, and the task-expansion driver needs the topmost card under the
75
+ pointer. Both used `useRafFn`, so an open board paid O(edges) `querySelector` plus forced layout
76
+ reads sixty times a second with nothing moving, and the edge overlay reassigned an
77
+ equal-but-new segment array every frame on top of that.
78
+
79
+ **A new driver of that kind pairs `useSettlingRaf(compute)` with the canvas pulse
80
+ (`useBoardActivity`), and `compute` reports honestly whether it changed anything.** The pulse
81
+ answers "something may have started moving" (DOM mutations under the canvas, its resize, the
82
+ Vue Flow camera, pointer/wheel/scroll gestures) and the settling loop carries that wake through
83
+ the animation that follows, parking once the output has held still for a few frames. Neither
84
+ half works alone: a signal fires one frame BEFORE the transition it starts has any geometry, and
85
+ a bare frame loop never stops.
86
+
87
+ Two things this cost, both worth knowing before adding a third driver. `compute` returning
88
+ `true` unconditionally silently restores the old behaviour, which is why the loop's contract is
89
+ stated in terms of what the user can see rather than what the function did. And the pulse
90
+ watches `style`/`class` attributes but not the geometry attributes the overlay itself writes,
91
+ because a driver whose own output pulsed it awake would never settle.
92
+
93
+ A `compute` that THROWS parks the loop and lets the error reach the frame callback, so the next
94
+ pulse of any kind is what restarts it. Retrying the frame instead would turn one bad measurement
95
+ into a 60Hz error storm, and staying awake with no frame scheduled would make every later poke a
96
+ no-op and freeze the board for the session.
97
+
98
+ What the pulse cannot see is a reflow with no mutation and no gesture, a late-loading image or
99
+ font resizing a card. That leaves an arrow stale until the next pulse of any kind, which is the
100
+ deliberate trade: firing too often costs a handful of frames, and the alternative is the loop
101
+ that never sleeps.
102
+
103
+ `app/utils/settlingLoop.spec.ts` pins the loop against a hand-driven frame clock.
104
+
71
105
  ### A store must be instantiable outside a component `setup`
72
106
 
73
107
  A Pinia setup store runs its body on the FIRST `useStore()` anywhere in the app, and that
@@ -7,6 +7,7 @@ import TaskDependencyEdges from './TaskDependencyEdges.vue'
7
7
  import DependencyConnectOverlay from './DependencyConnectOverlay.vue'
8
8
  import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
9
9
  import { BOARD_FLOW_ID, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
10
+ import { provideBoardActivity } from '~/composables/useBoardActivity'
10
11
  import { useTaskExpansion } from '~/composables/useTaskExpansion'
11
12
  import { useBlockDrag } from '~/composables/useBlockDrag'
12
13
  import { useFrameStacking } from '~/composables/useFrameStacking'
@@ -46,7 +47,10 @@ const panOnDrag = computed<boolean | number[]>(() => boardPanMode(hasTouch.value
46
47
  // centre-most of any that would overlap (see useTaskExpansion). Service frames have no
47
48
  // such gate — they are always expanded to their task canvas.
48
49
  const boardEl = ref<HTMLElement | null>(null)
49
- useTaskExpansion(boardEl)
50
+ // The canvas owns the "something may have moved" pulse both DOM-measuring drivers run off:
51
+ // this one directly, the dependency-edge overlay by injection. See useBoardActivity.
52
+ const boardActivity = provideBoardActivity(boardEl)
53
+ useTaskExpansion(boardEl, boardActivity)
50
54
 
51
55
  // Only frames are board nodes. Dependencies live on tasks (rendered inside the
52
56
  // frames), so there are no frame-to-frame edges on the canvas.
@@ -100,6 +104,10 @@ onNodeDragStop(({ node }) => {
100
104
 
101
105
  onViewportChange((vp) => {
102
106
  ui.zoom = vp.zoom
107
+ // Pan and zoom move every card on screen. Vue Flow does that by restyling its transform
108
+ // pane, which the pulse's observer would also catch, but the camera is too load-bearing for
109
+ // the overlays to depend on which DOM strategy Vue Flow uses to apply it.
110
+ boardActivity.pulse()
103
111
  })
104
112
 
105
113
  function onNodeClick({ node }: NodeMouseEvent) {
@@ -1,29 +1,31 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
3
- import { useRafFn } from '@vueuse/core'
2
+ import { ref, shallowRef, computed, watch } from 'vue'
3
+ import { useBoardActivity } from '~/composables/useBoardActivity'
4
+ import { useSettlingRaf } from '~/composables/useSettlingRaf'
5
+ import { commitSegments, type EdgeSegment } from '~/utils/edgeSegments'
4
6
 
5
7
  /**
6
8
  * Draws dependency arrows between task cards as an SVG overlay on top of the
7
9
  * board. Tasks are plain DOM nodes (inside frame cards), so we resolve their
8
- * on-screen rectangles by `[data-block-id]` every frame — this makes arrows
9
- * follow pan / zoom / drag / expand for free. When a task's frame is collapsed
10
- * (its card isn't rendered), the arrow anchors to the frame card instead.
10
+ * on-screen rectangles by `[data-block-id]` — this makes arrows follow pan /
11
+ * zoom / drag / expand for free. When a task's frame is collapsed (its card
12
+ * isn't rendered), the arrow anchors to the frame card instead.
13
+ *
14
+ * Measuring is O(edges) `querySelector` + forced layout reads, so it runs only
15
+ * while something is actually moving: the board's activity pulse wakes it and
16
+ * `useSettlingRaf` parks it again once the resolved segments hold still.
11
17
  */
12
18
  const board = useBoardStore()
13
19
 
14
20
  const svg = ref<SVGSVGElement | null>(null)
15
21
 
16
- type Seg = { id: string; x1: number; y1: number; x2: number; y2: number; done: boolean }
17
- const segments = ref<Seg[]>([])
22
+ const segments = shallowRef<EdgeSegment[]>([])
18
23
  // Epic→member membership links (distinct style from dependency edges).
19
- type MemberSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
20
- const memberSegments = ref<MemberSeg[]>([])
24
+ const memberSegments = shallowRef<EdgeSegment[]>([])
21
25
  // Frontend frame → bound service frame links (from a frontend's backend bindings).
22
- type FrontendSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
23
- const frontendSegments = ref<FrontendSeg[]>([])
26
+ const frontendSegments = shallowRef<EdgeSegment[]>([])
24
27
  // Service frame → connected provider service frame links (from serviceConnections).
25
- type ConnectionSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
26
- const connectionSegments = ref<ConnectionSeg[]>([])
28
+ const connectionSegments = shallowRef<EdgeSegment[]>([])
27
29
 
28
30
  // task → its dependencies, both ends being tasks
29
31
  const taskDeps = computed(() => {
@@ -129,8 +131,8 @@ function segmentBetween(sourceId: string, targetId: string, origin: DOMRect) {
129
131
  function linkSegments(
130
132
  links: { id: string; source: string; target: string }[],
131
133
  origin: DOMRect,
132
- ): { id: string; x1: number; y1: number; x2: number; y2: number }[] {
133
- const out: { id: string; x1: number; y1: number; x2: number; y2: number }[] = []
134
+ ): EdgeSegment[] {
135
+ const out: EdgeSegment[] = []
134
136
  for (const link of links) {
135
137
  const seg = segmentBetween(link.source, link.target, origin)
136
138
  if (seg) out.push({ id: link.id, ...seg })
@@ -138,27 +140,34 @@ function linkSegments(
138
140
  return out
139
141
  }
140
142
 
141
- function recompute() {
143
+ /** Re-measure every overlay link; reports whether any of them moved. */
144
+ function recompute(): boolean {
142
145
  const el = svg.value
143
- if (!el) return
146
+ if (!el) return false
144
147
  const origin = el.getBoundingClientRect()
145
148
 
146
- const next: Seg[] = []
149
+ const deps: EdgeSegment[] = []
147
150
  for (const d of taskDeps.value) {
148
151
  const seg = segmentBetween(d.source, d.target, origin)
149
152
  if (!seg) continue
150
- next.push({ id: d.id, ...seg, done: board.getBlock(d.source)?.status === 'done' })
153
+ deps.push({ id: d.id, ...seg, done: board.getBlock(d.source)?.status === 'done' })
151
154
  }
152
- segments.value = next
153
155
 
154
- memberSegments.value = linkSegments(epicLinks.value, origin)
155
- frontendSegments.value = linkSegments(frontendLinks.value, origin)
156
- connectionSegments.value = linkSegments(connectionLinks.value, origin)
156
+ // An array literal, so every list is committed before the result is reduced: a `||` chain
157
+ // would short-circuit and leave the later overlays drawn at stale coordinates.
158
+ return [
159
+ commitSegments(segments, deps),
160
+ commitSegments(memberSegments, linkSegments(epicLinks.value, origin)),
161
+ commitSegments(frontendSegments, linkSegments(frontendLinks.value, origin)),
162
+ commitSegments(connectionSegments, linkSegments(connectionLinks.value, origin)),
163
+ ].some(Boolean)
157
164
  }
158
165
 
159
- const { pause, resume } = useRafFn(recompute, { immediate: false })
160
- onMounted(resume)
161
- onBeforeUnmount(pause)
166
+ const { poke } = useSettlingRaf(recompute)
167
+ useBoardActivity(poke)
168
+ // A link set can change with no visible change to any card (toggling a dependency between two
169
+ // tasks draws an arrow and nothing else), which the DOM-level pulse would never see.
170
+ watch([taskDeps, epicLinks, frontendLinks, connectionLinks], poke)
162
171
  </script>
163
172
 
164
173
  <template>
@@ -12,7 +12,15 @@
12
12
  // browses its tree and multi-selects the service directories to add — from ANY
13
13
  // parent folder, in one pass — then adds them all at once. Directories that
14
14
  // already back a service on this board are shown but not selectable.
15
+ //
16
+ // One of those directories may be marked the FRONTEND for the rest: it is created as a
17
+ // `frontend` frame instead of a service, pinned to its subdirectory, and bound to every
18
+ // backend added beside it (`frontendConfig.backendBindings`, the frontend→service board
19
+ // link). Every frontend frame the import creates also gets its subdirectory recorded on
20
+ // `frontendConfig`, marked or not. The rules live in `~/utils/monorepoImport`, which also
21
+ // explains why the bindings carry no env-var names.
15
22
  import type { FrameRepoType, GitHubAvailableRepo } from '~/types/domain'
23
+ import type { CreatedMonorepoFrame } from '~/utils/monorepoImport'
16
24
  import RepoSearchEmpty from '~/components/github/RepoSearchEmpty.vue'
17
25
  import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
18
26
  import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
@@ -170,9 +178,53 @@ const addedDirectories = computed<string[]>(() => {
170
178
  })
171
179
  const addedDirSet = computed(() => new Set(addedDirectories.value))
172
180
 
181
+ // What the next "Add N services" will actually create: the cart minus anything already backing a
182
+ // service. THE population every frontend-mark decision reads, and the one `addServices` iterates.
183
+ // The two can differ: a partial failure leaves the cart intact while its earlier creates stand, so
184
+ // judging the mark by the raw cart would offer it (and count it) for frames that already exist.
185
+ const pendingDirectories = computed(() =>
186
+ selectedDirectories.value.filter((d) => !addedDirSet.value.has(normalizeRepoPath(d))),
187
+ )
188
+
189
+ // The one picked directory marked as the frontend for the others, or undefined when the
190
+ // selection is all backends. Empty string is the select's "none" option.
191
+ const frontendDirectory = ref<string | undefined>(undefined)
192
+
193
+ // Whether the mark is on offer at all (role must be `service`, at least two directories to
194
+ // create): see `canDesignateFrontend`. The picker is hidden otherwise, so drop a mark that a role
195
+ // change has made unofferable rather than leaving it to act unseen.
196
+ const frontendOffered = computed(() =>
197
+ canDesignateFrontend(selectedType.value, pendingDirectories.value.length),
198
+ )
199
+ watch(frontendOffered, (offered) => {
200
+ if (!offered) frontendDirectory.value = undefined
201
+ })
202
+ // The pending set is the option list, so a directory that leaves it (removed from the cart, or
203
+ // created by an earlier add) can no longer be the mark. The computed re-runs on the cart's
204
+ // in-place mutations (`push`/`splice`), so no deep watch is needed on top of it.
205
+ watch(pendingDirectories, (dirs) => {
206
+ if (frontendDirectory.value && !dirs.includes(frontendDirectory.value)) {
207
+ frontendDirectory.value = undefined
208
+ }
209
+ })
210
+
211
+ const frontendItems = computed(() => [
212
+ { label: t('github.addService.frontendNone'), value: '' },
213
+ ...pendingDirectories.value.map((d) => ({ label: d, value: d })),
214
+ ])
215
+
216
+ // USelect needs a present value for its "none" row; the mark itself stays absent-or-a-path.
217
+ const frontendSelection = computed({
218
+ get: () => frontendDirectory.value ?? '',
219
+ set: (value: string) => {
220
+ frontendDirectory.value = value || undefined
221
+ },
222
+ })
223
+
173
224
  function toggleMonorepo(value: boolean) {
174
225
  isMonorepo.value = value
175
226
  selectedDirectories.value = []
227
+ frontendDirectory.value = undefined
176
228
  }
177
229
 
178
230
  // Add/remove a directory from the cart. Guards against an already-added directory (the
@@ -209,12 +261,14 @@ watch(selectedRepoId, (id) => {
209
261
  }
210
262
  isMonorepo.value = selectedRepo.value?.isMonorepo === true
211
263
  selectedDirectories.value = []
264
+ frontendDirectory.value = undefined
212
265
  configuredBlockId.value = undefined
213
266
  })
214
267
 
215
268
  function resetSelection() {
216
269
  selectedRepoId.value = undefined
217
270
  selectedDirectories.value = []
271
+ frontendDirectory.value = undefined
218
272
  isMonorepo.value = false
219
273
  configuredBlockId.value = undefined
220
274
  resetRepoSearch()
@@ -265,18 +319,13 @@ const canAddServices = computed(
265
319
  !needsConnection.value &&
266
320
  selectedRepoId.value !== undefined &&
267
321
  isMonorepo.value &&
268
- selectedDirectories.value.length > 0,
322
+ pendingDirectories.value.length > 0,
269
323
  )
270
324
 
271
325
  // Directories the user has picked but NOT yet committed via "Add N services". Closing the
272
326
  // modal ("Done") would silently discard them — almost never what the user wants — so the
273
- // footer's Done is disabled while any remain (see the template). Filtered against the
274
- // already-added set for parity with `addServices`, so a stale cart entry can't block Done.
275
- const hasPendingSelection = computed(
276
- () =>
277
- isMonorepo.value &&
278
- selectedDirectories.value.some((d) => !addedDirSet.value.has(normalizeRepoPath(d))),
279
- )
327
+ // footer's Done is disabled while any remain (see the template).
328
+ const hasPendingSelection = computed(() => isMonorepo.value && pendingDirectories.value.length > 0)
280
329
 
281
330
  async function add() {
282
331
  if (!canAdd.value || selectedRepoId.value === undefined) return
@@ -315,38 +364,71 @@ async function add() {
315
364
  }
316
365
  }
317
366
 
318
- // Add every directory in the cart as its own service, in one action. Each add lays the
319
- // frame out in free space (seeing the ones added earlier in the loop, so they don't
320
- // overlap); the projection is refreshed and the camera centres on the last one. The
321
- // just-added directories then move to `addedDirectories`, so the cart is cleared and the
322
- // tree marks them "added" ready to pick more (from any folder) or close.
367
+ // What the success toast says about the frontend wiring, which is the half of the add that can
368
+ // fail on its own. A landed mark names the directory and points at the inspector for the env-var
369
+ // names the import deliberately leaves empty; a patch that did not persist says SO, because the
370
+ // frames are on the board either way and a silent omission reads exactly like a clean import. The
371
+ // failure note covers an undesignated frontend frame too: it lost its subdirectory, so the harness
372
+ // would build the repo root.
373
+ function frontendNote(designatedDirectory: string | undefined, wiringLanded: boolean): string {
374
+ if (!wiringLanded) return t('github.addService.toast.frontendWiringFailedNote')
375
+ if (!designatedDirectory) return ''
376
+ return t('github.addService.toast.frontendLinkedNote', { directory: designatedDirectory })
377
+ }
378
+
379
+ // Add every pending directory as its own frame, in one action. Each add lays the frame out in free
380
+ // space (seeing the ones added earlier in the loop, so they don't overlap); the projection is
381
+ // refreshed and the camera centres on the last one. The just-added directories then move to
382
+ // `addedDirectories`, so the cart is cleared and the tree marks them "added", ready to pick more
383
+ // (from any folder) or close. That is why the pending set is SNAPSHOTTED before the first await:
384
+ // each create refreshes the projection, so the live computed shrinks under the loop.
385
+ //
386
+ // A created `frontend` frame is then patched with its `frontendConfig`: its subdirectory always,
387
+ // plus a binding per sibling frame when it is the marked one. Those patches can only run after the
388
+ // loop, because the bindings name block ids the creates mint. The frame being wired is the one the
389
+ // PLAN designated, never whichever entry happens to carry `type: 'frontend'` (see
390
+ // `MonorepoImportEntry.designatedFrontend`). A patch that does not land leaves its frames standing
391
+ // and is REPORTED: `updateBlock` toasts its own failure and answers whether it persisted, so the
392
+ // success toast claims only the links that were actually written.
323
393
  async function addServices() {
324
394
  if (!canAddServices.value || selectedRepoId.value === undefined) return
325
- const dirs = selectedDirectories.value.filter((d) => !addedDirSet.value.has(normalizeRepoPath(d)))
395
+ const dirs = [...pendingDirectories.value]
326
396
  if (dirs.length === 0) return
397
+ // The mark is handed over raw: `planMonorepoImport` applies `canDesignateFrontend` itself over
398
+ // the very directories it is creating, so there is no second copy of that condition to drift.
399
+ const plan = planMonorepoImport(dirs, selectedType.value, frontendDirectory.value)
400
+ const designatedDirectory = plan.find((entry) => entry.designatedFrontend)?.directory
327
401
  adding.value = true
328
402
  try {
329
- let lastBlock: Awaited<ReturnType<typeof board.addServiceFromRepo>> | undefined
330
- for (const directory of dirs) {
331
- lastBlock = await board.addServiceFromRepo(selectedRepoId.value, {
332
- directory,
403
+ const created: CreatedMonorepoFrame[] = []
404
+ for (const entry of plan) {
405
+ const block = await board.addServiceFromRepo(selectedRepoId.value, {
406
+ directory: entry.directory,
333
407
  isMonorepo: true,
334
- type: selectedType.value,
408
+ type: entry.type,
335
409
  position: freeFramePosition(),
336
410
  })
411
+ created.push({ blockId: block.id, entry })
412
+ }
413
+ let wiringLanded = true
414
+ for (const patch of planFrontendConfigPatches(created)) {
415
+ const persisted = await board.updateBlock(patch.blockId, { frontendConfig: patch.config })
416
+ if (!persisted) wiringLanded = false
337
417
  }
338
418
  await github.load()
339
- if (lastBlock) await focusFrame(lastBlock.id)
419
+ const lastBlockId = created.at(-1)?.blockId
420
+ if (lastBlockId) await focusFrame(lastBlockId)
340
421
  selectedDirectories.value = []
341
422
  toast.add({
342
423
  title: t('github.addService.toast.servicesAddedTitle'),
343
- description: t(
344
- 'github.addService.toast.servicesAddedDescription',
345
- { count: dirs.length },
346
- dirs.length,
347
- ),
348
- icon: 'i-lucide-check',
349
- color: 'success',
424
+ description: [
425
+ t('github.addService.toast.servicesAddedDescription', { count: dirs.length }, dirs.length),
426
+ frontendNote(designatedDirectory, wiringLanded),
427
+ ]
428
+ .filter(Boolean)
429
+ .join(' '),
430
+ icon: wiringLanded ? 'i-lucide-check' : 'i-lucide-triangle-alert',
431
+ color: wiringLanded ? 'success' : 'warning',
350
432
  })
351
433
  } catch (e) {
352
434
  toast.add({
@@ -498,6 +580,25 @@ function done() {
498
580
  <p v-else class="text-xs text-slate-500">
499
581
  {{ t('github.addService.noServicesSelected') }}
500
582
  </p>
583
+
584
+ <!-- Mark one pick as the frontend for the others: it is created as a frontend
585
+ app and bound to every backend added beside it. Only offered while the
586
+ mark would wire something (see `canDesignateFrontend`). -->
587
+ <UFormField
588
+ v-if="frontendOffered"
589
+ :label="t('github.addService.frontendLabel')"
590
+ :description="t('github.addService.frontendHint')"
591
+ >
592
+ <USelect
593
+ v-model="frontendSelection"
594
+ :items="frontendItems"
595
+ value-key="value"
596
+ size="sm"
597
+ class="w-full"
598
+ data-testid="add-service-frontend-select"
599
+ />
600
+ </UFormField>
601
+
501
602
  <div class="flex justify-end">
502
603
  <UButton
503
604
  color="primary"
@@ -507,11 +608,13 @@ function done() {
507
608
  :disabled="!canAddServices"
508
609
  @click="addServices"
509
610
  >
611
+ <!-- Counts what the click will CREATE, not the raw cart: an entry whose frame
612
+ already exists (a retry after a partial failure) is not added again. -->
510
613
  {{
511
614
  t(
512
615
  'github.addService.addServices',
513
- { count: selectedDirectories.length },
514
- selectedDirectories.length,
616
+ { count: pendingDirectories.length },
617
+ pendingDirectories.length,
515
618
  )
516
619
  }}
517
620
  </UButton>
@@ -0,0 +1,111 @@
1
+ import { inject, onBeforeUnmount, onMounted, provide, type InjectionKey, type Ref } from 'vue'
2
+
3
+ /**
4
+ * The board's shared "something may have moved" pulse.
5
+ *
6
+ * The two DOM-measuring drivers on the canvas (dependency edges, task expansion) cannot ask
7
+ * the DOM "did anything change since last frame" without doing the measurement that IS the
8
+ * cost, so each used to measure unconditionally every frame. This publishes the signals that
9
+ * can START a visible change instead; the drivers pair it with `useSettlingRaf`, which
10
+ * carries each wake through the animation that follows and parks once the output holds still.
11
+ *
12
+ * The signals are deliberately coarse. A pulse that fires when nothing moved costs a handful
13
+ * of frames; one that fails to fire leaves a stale arrow on screen, so this errs toward
14
+ * firing:
15
+ *
16
+ * - a `MutationObserver` over the canvas subtree, watching structure plus `style` / `class`.
17
+ * That is every Vue-driven render change on the board, Vue Flow's own pan/zoom transform
18
+ * included. Attribute changes the drivers themselves write (`x1`/`y1` on the edge overlay)
19
+ * are outside the filter, so a driver cannot pulse itself awake forever.
20
+ * - a `ResizeObserver` on the canvas, plus window `resize`: layout changes with no mutation.
21
+ * - pointer, wheel and scroll gestures on the canvas: the user moving something.
22
+ *
23
+ * What it does NOT catch is a reflow with no mutation and no gesture, such as a late-loading
24
+ * image or font resizing a card. Those settle on the next pulse of any kind.
25
+ */
26
+ export type BoardActivity = {
27
+ /** Subscribe to the pulse. Returns the unsubscribe. */
28
+ subscribe: (onPulse: () => void) => () => void
29
+ /** Fire the pulse from a signal the observers above cannot see. */
30
+ pulse: () => void
31
+ }
32
+
33
+ const boardActivityKey: InjectionKey<BoardActivity> = Symbol('boardActivity')
34
+
35
+ /**
36
+ * Installs the signal sources on the board canvas element and provides the pulse to the
37
+ * canvas's descendants. Returns it too, because a component cannot inject what it provides.
38
+ */
39
+ export function provideBoardActivity(container: Ref<HTMLElement | null>): BoardActivity {
40
+ const subscribers = new Set<() => void>()
41
+ const pulse = () => {
42
+ for (const onPulse of subscribers) onPulse()
43
+ }
44
+
45
+ const activity: BoardActivity = {
46
+ subscribe(onPulse) {
47
+ subscribers.add(onPulse)
48
+ return () => subscribers.delete(onPulse)
49
+ },
50
+ pulse,
51
+ }
52
+ provide(boardActivityKey, activity)
53
+
54
+ const mutations = new MutationObserver(pulse)
55
+ const resizes = new ResizeObserver(pulse)
56
+ // `scroll` does not bubble, so it is caught in the capture phase; the gestures are
57
+ // passive listeners because the pulse never wants to cancel one.
58
+ const gestures = [
59
+ 'pointerdown',
60
+ 'pointermove',
61
+ 'pointerup',
62
+ 'pointerleave',
63
+ 'wheel',
64
+ 'scroll',
65
+ ] as const
66
+ const gestureOptions = { capture: true, passive: true }
67
+
68
+ onMounted(() => {
69
+ // The canvas binds this ref to its own root, so by mount it is always set; the narrowing is
70
+ // for the nullable template-ref type rather than a case that happens.
71
+ const el = container.value
72
+ if (!el) return
73
+ mutations.observe(el, {
74
+ childList: true,
75
+ subtree: true,
76
+ attributes: true,
77
+ attributeFilter: ['style', 'class'],
78
+ })
79
+ resizes.observe(el)
80
+ for (const type of gestures) el.addEventListener(type, pulse, gestureOptions)
81
+ window.addEventListener('resize', pulse)
82
+ })
83
+
84
+ onBeforeUnmount(() => {
85
+ mutations.disconnect()
86
+ resizes.disconnect()
87
+ const el = container.value
88
+ for (const type of gestures) el?.removeEventListener(type, pulse, gestureOptions)
89
+ window.removeEventListener('resize', pulse)
90
+ subscribers.clear()
91
+ })
92
+
93
+ return activity
94
+ }
95
+
96
+ /** Keeps `onPulse` subscribed to a pulse the caller already holds, for the component's lifetime. */
97
+ export function onBoardActivity(activity: BoardActivity, onPulse: () => void): void {
98
+ onBeforeUnmount(activity.subscribe(onPulse))
99
+ }
100
+
101
+ /**
102
+ * The same, for a descendant of the canvas that reads the pulse by injection. Throws when used
103
+ * outside the board canvas: a driver that silently subscribed to nothing would measure once and
104
+ * then freeze, which reads as a layout bug rather than the wiring one it is. The canvas itself
105
+ * cannot inject what it provides, so it passes the returned pulse to `onBoardActivity` instead.
106
+ */
107
+ export function useBoardActivity(onPulse: () => void): void {
108
+ const activity = inject(boardActivityKey, null)
109
+ if (!activity) throw new Error('useBoardActivity() requires a board canvas ancestor')
110
+ onBoardActivity(activity, onPulse)
111
+ }
@@ -0,0 +1,32 @@
1
+ import { onBeforeUnmount, onMounted } from 'vue'
2
+ import { createSettlingLoop, type SettlingLoop } from '~/utils/settlingLoop'
3
+
4
+ /**
5
+ * Vue lifecycle wrapper around {@link createSettlingLoop}: an animation-frame loop that runs
6
+ * while `compute()` keeps changing something and parks once it settles. The caller wakes it
7
+ * with the returned `poke`, wired to whatever signals can start a change (see
8
+ * `useBoardActivity` for the board's shared set).
9
+ *
10
+ * `compute` MUST report honestly whether it changed anything: returning `true` unconditionally
11
+ * turns this back into the unconditional 60fps loop it replaced.
12
+ */
13
+ export function useSettlingRaf(
14
+ compute: () => boolean,
15
+ options: { settleFrames?: number } = {},
16
+ ): Pick<SettlingLoop, 'poke'> {
17
+ const loop = createSettlingLoop({
18
+ compute,
19
+ settleFrames: options.settleFrames,
20
+ scheduler: {
21
+ schedule: (run) => requestAnimationFrame(run),
22
+ cancel: (handle) => cancelAnimationFrame(handle),
23
+ },
24
+ })
25
+
26
+ // The first frame runs on mount: the board arrives with blocks already laid out, and
27
+ // nothing would poke a loop that had never measured anything.
28
+ onMounted(loop.poke)
29
+ onBeforeUnmount(loop.stop)
30
+
31
+ return { poke: loop.poke }
32
+ }
@@ -1,7 +1,8 @@
1
1
  import type { Ref } from 'vue'
2
2
  import { onMounted, onBeforeUnmount } from 'vue'
3
- import { useRafFn } from '@vueuse/core'
4
3
  import { lodAtLeast } from '~/composables/useSemanticZoom'
4
+ import { onBoardActivity, type BoardActivity } from '~/composables/useBoardActivity'
5
+ import { useSettlingRaf } from '~/composables/useSettlingRaf'
5
6
  import { headerDistanceSq, type Rect } from '~/utils/taskExpansionRanking'
6
7
 
7
8
  function intersects(a: Rect, b: Rect) {
@@ -33,8 +34,12 @@ function sameSet(a: Set<string>, b: Set<string>) {
33
34
  *
34
35
  * Only tasks with a running pipeline (steps to show) are candidates for either grant — a
35
36
  * task that wouldn't expand never blocks a neighbour and never lifts an empty card.
37
+ *
38
+ * Deciding costs a rect per candidate plus an `elementFromPoint`, so it runs only while the
39
+ * board is moving: the canvas activity pulse wakes it and `useSettlingRaf` parks it again once
40
+ * the two grants stop changing.
36
41
  */
37
- export function useTaskExpansion(container: Ref<HTMLElement | null>) {
42
+ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: BoardActivity) {
38
43
  const board = useBoardStore()
39
44
  const execution = useExecutionStore()
40
45
  const ui = useUiStore()
@@ -79,21 +84,29 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
79
84
  return id
80
85
  }
81
86
 
82
- function recompute() {
87
+ /** Re-decide both grants; reports whether either of them changed. */
88
+ function recompute(): boolean {
83
89
  // Hover expands a card at ANY zoom band, so the pointer hit is resolved BEFORE the
84
90
  // zoom gate below — resolving it after would collapse the hovered card the moment the
85
91
  // user zoomed back out past the `steps` band.
86
92
  const hovered = hoveredTaskId()
87
- if (store.hoveredId !== hovered) store.setHovered(hovered)
93
+ let changed = false
94
+ if (store.hoveredId !== hovered) {
95
+ store.setHovered(hovered)
96
+ changed = true
97
+ }
88
98
 
89
99
  // The zoom-driven expansion (every on-screen card, overlap-resolved) is deep-band
90
100
  // only; clear its grants otherwise. The hover grant above stands on its own.
91
101
  if (!lodAtLeast(ui.lod, 'steps')) {
92
- if (store.allowed.size) store.setAllowed(new Set())
93
- return
102
+ if (store.allowed.size) {
103
+ store.setAllowed(new Set())
104
+ changed = true
105
+ }
106
+ return changed
94
107
  }
95
108
  const view = container.value?.getBoundingClientRect()
96
- if (!view) return
109
+ if (!view) return changed
97
110
  const cx = view.left + view.width / 2
98
111
  const cy = view.top + view.height / 2
99
112
 
@@ -145,19 +158,24 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
145
158
  next.add(c.id)
146
159
  claimed.push(c.rect)
147
160
  }
148
- if (!sameSet(next, store.allowed)) store.setAllowed(next)
161
+ if (!sameSet(next, store.allowed)) {
162
+ store.setAllowed(next)
163
+ changed = true
164
+ }
165
+ return changed
149
166
  }
150
167
 
151
- const { pause, resume } = useRafFn(recompute, { immediate: false })
168
+ const { poke } = useSettlingRaf(recompute)
169
+ // The pointer listeners below only record where the pointer IS; the pulse (which watches the
170
+ // same gestures) is what schedules the frame that acts on it.
171
+ onBoardActivity(activity, poke)
152
172
  onMounted(() => {
153
173
  store.setDriverActive(true)
154
174
  const el = container.value
155
175
  el?.addEventListener('pointermove', onPointerMove)
156
176
  el?.addEventListener('pointerleave', onPointerLeave)
157
- resume()
158
177
  })
159
178
  onBeforeUnmount(() => {
160
- pause()
161
179
  const el = container.value
162
180
  el?.removeEventListener('pointermove', onPointerMove)
163
181
  el?.removeEventListener('pointerleave', onPointerLeave)