@cat-factory/app 0.26.1 → 0.26.3

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.
@@ -11,7 +11,7 @@ import { STATUS_META } from '~/utils/catalog'
11
11
  import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
12
12
  import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
13
13
  import { useTaskExpansion } from '~/composables/useTaskExpansion'
14
- import { useFrameExpansion } from '~/composables/useFrameExpansion'
14
+ import { computeDisplacement } from '~/utils/boardDisplacement'
15
15
 
16
16
  const board = useBoardStore()
17
17
  const pipelines = usePipelinesStore()
@@ -22,48 +22,97 @@ const toast = useToast()
22
22
 
23
23
  const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(BOARD_FLOW_ID)
24
24
 
25
- // Gate which task cards expand their pipeline list on deep zoom: only on-screen
26
- // cards, and only the centre-most of any that would overlap (see useTaskExpansion).
27
- // The frame-level gate is the same idea one level up: which service frames may
28
- // auto-expand to their task canvas once zoomed in (see useFrameExpansion).
25
+ // Gate which task cards expand their pipeline list on deep zoom: on-screen, and the
26
+ // centre-most of any that would overlap (see useTaskExpansion). Service frames have no
27
+ // such gate they are always expanded to their task canvas (see frameOffsets below).
29
28
  const boardEl = ref<HTMLElement | null>(null)
30
29
  useTaskExpansion(boardEl)
31
- useFrameExpansion(boardEl)
32
30
 
33
31
  // Only frames are board nodes. Dependencies live on tasks (rendered inside the
34
32
  // frames), so there are no frame-to-frame edges on the canvas.
35
33
  //
36
34
  // Vue Flow tags every *draggable* node with the `nopan` class, which makes the
37
- // pane refuse to pan while the pointer is over it. An expanded frame fills much
38
- // of the viewport, so leaving it draggable turns the whole canvas into a dead
39
- // zone once tasks appear. We therefore make expanded frames non-draggable (the
40
- // pane pans straight through them) and move them via their header handle
41
- // instead — collapsed chips stay node-draggable since they're small.
42
- function frameExpanded(id: string) {
43
- return ui.isFrameExpanded(id) && ui.lod !== 'far'
35
+ // pane refuse to pan while the pointer is over it. Service frames are always expanded
36
+ // and fill much of the viewport, so leaving them draggable would turn the whole canvas
37
+ // into a dead zone. We therefore make every frame non-draggable (the pane pans straight
38
+ // through it) and move it via its header handle instead.
39
+
40
+ // Services are always expanded to their full task canvas. An expanded card grows
41
+ // rightward / downward from its stored (chip-sized) top-left and would overlap its
42
+ // neighbours, so compressed space pushes the neighbours away by that growth: the
43
+ // footprint never overlaps a neighbour it wasn't already overlapping. Because the
44
+ // expanded set never changes, the layout is fixed — panning never shifts it and there
45
+ // is no expand/collapse transition to snap on. Render-only; stored positions untouched.
46
+ const FRAME_COLLAPSED_W = 224 // the stored chip footprint (`w-56`) the layout reserves
47
+ const FRAME_COLLAPSED_H = 150
48
+ const FRAME_CHROME_W = 40 // border + padding around the inner task canvas
49
+ const FRAME_CHROME_H = 120 // top bar + header row + paddings above the canvas
50
+
51
+ const frameOffsets = computed(() => {
52
+ const boxes = [
53
+ ...board.frames.map((b) => {
54
+ const c = board.containerSize(b.id)
55
+ return {
56
+ id: b.id,
57
+ x: b.position.x,
58
+ y: b.position.y,
59
+ w: FRAME_COLLAPSED_W,
60
+ h: FRAME_COLLAPSED_H,
61
+ growX: Math.max(0, c.w + FRAME_CHROME_W - FRAME_COLLAPSED_W),
62
+ growY: Math.max(0, c.h + FRAME_CHROME_H - FRAME_COLLAPSED_H),
63
+ }
64
+ }),
65
+ // Epics never expand, but they're pushed aside like any other box so an expanded
66
+ // frame doesn't end up rendered on top of one.
67
+ ...board.epics.map((b) => ({
68
+ id: b.id,
69
+ x: b.position.x,
70
+ y: b.position.y,
71
+ w: FRAME_COLLAPSED_W,
72
+ h: FRAME_COLLAPSED_H,
73
+ growX: 0,
74
+ growY: 0,
75
+ })),
76
+ ]
77
+ return computeDisplacement(boxes)
78
+ })
79
+
80
+ function offsetOf(id: string) {
81
+ return frameOffsets.value.get(id) ?? { dx: 0, dy: 0 }
44
82
  }
45
83
 
46
84
  const nodes = computed(() => [
47
- ...board.frames.map((b) => ({
48
- id: b.id,
49
- type: 'block',
50
- position: b.position,
51
- draggable: !frameExpanded(b.id),
52
- data: {},
53
- })),
85
+ ...board.frames.map((b) => {
86
+ const o = offsetOf(b.id)
87
+ return {
88
+ id: b.id,
89
+ type: 'block',
90
+ position: { x: b.position.x + o.dx, y: b.position.y + o.dy },
91
+ // Always-expanded frames fill the viewport; keep them non-draggable so the pane
92
+ // pans through them (they move via their header handle, see BlockNode).
93
+ draggable: false,
94
+ data: {},
95
+ }
96
+ }),
54
97
  // Epics are top-level grouping nodes (non-structural), drawn alongside frames and
55
98
  // linked to their member tasks by the dependency-edge overlay.
56
- ...board.epics.map((b) => ({
57
- id: b.id,
58
- type: 'epic',
59
- position: b.position,
60
- draggable: true,
61
- data: {},
62
- })),
99
+ ...board.epics.map((b) => {
100
+ const o = offsetOf(b.id)
101
+ return {
102
+ id: b.id,
103
+ type: 'epic',
104
+ position: { x: b.position.x + o.dx, y: b.position.y + o.dy },
105
+ draggable: true,
106
+ data: {},
107
+ }
108
+ }),
63
109
  ])
64
110
 
65
111
  onNodeDragStop(({ node }) => {
66
- board.moveBlock(node.id, { x: node.position.x, y: node.position.y })
112
+ // node.position carries the render offset (compressed space can have pushed this node
113
+ // aside); subtract it so we persist the un-displaced position.
114
+ const o = offsetOf(node.id)
115
+ board.moveBlock(node.id, { x: node.position.x - o.dx, y: node.position.y - o.dy })
67
116
  })
68
117
 
69
118
  onViewportChange((vp) => {
@@ -53,9 +53,11 @@ const FRAME_LABEL: Record<BlockStatus, string> = {
53
53
  const statusLabel = computed(() => FRAME_LABEL[frameStatus.value])
54
54
 
55
55
  const selected = computed(() => ui.selectedBlockId === props.id)
56
- const expanded = computed(() => ui.isFrameExpanded(props.id))
57
- // At far zoom we only ever show the chip; otherwise an expanded frame shows tasks.
58
- const showExpanded = computed(() => expanded.value && lod.value !== 'far')
56
+ // Services are always expanded to their task canvas, at every zoom level: there is no
57
+ // chip/compact collapse, so panning is a fixed layout and zooming has no expand/collapse
58
+ // transition to snap on. The far-chip and compact-summary branches in the template are
59
+ // kept (gated off) so the prior behaviour is one edit away if we want chips back.
60
+ const showExpanded = computed(() => true)
59
61
 
60
62
  // Surface a pending decision from this frame OR any of its tasks.
61
63
  const blockDecisions = computed(() =>
@@ -179,8 +181,10 @@ const ITEM_ICON: Record<string, string> = {
179
181
  </div>
180
182
 
181
183
  <!-- ===================== FAR: glanceable chip ===================== -->
184
+ <!-- Inert while services are always expanded (showExpanded is always true); the
185
+ compact branch below is reached via v-else-if and is likewise inert. -->
182
186
  <div
183
- v-if="lod === 'far'"
187
+ v-if="!showExpanded && lod === 'far'"
184
188
  class="flex w-44 items-center gap-2 rounded-xl border-2 px-3 py-3 shadow-lg backdrop-blur"
185
189
  :class="[selected ? 'border-white' : '', pulseClass]"
186
190
  :style="{ borderColor: accent, backgroundColor: accent + '26' }"
@@ -30,6 +30,7 @@ const RECOMMENDED_SLUGS = [
30
30
  'openai/gpt-5.5',
31
31
  'google/gemini-3-pro',
32
32
  'deepseek/deepseek-chat',
33
+ 'moonshotai/kimi-k2.7-code',
33
34
  ]
34
35
 
35
36
  // Whether the workspace/user has an OpenRouter key connected at any reachable scope.
@@ -105,18 +106,35 @@ async function connectKey() {
105
106
  if (!keyValue.value.trim() || !workspace.workspaceId) return
106
107
  connectingKey.value = true
107
108
  try {
109
+ const scope = keyScope.value
108
110
  const input = {
109
111
  provider: 'openrouter' as const,
110
112
  label: keyLabel.value.trim() || 'openrouter key',
111
113
  key: keyValue.value.trim(),
112
114
  }
113
- if (keyScope.value === 'workspace') await apiKeys.addWorkspaceKey(input)
114
- else await apiKeys.addUserKey(input)
115
+ // The save endpoint stores the key WITHOUT validating it, so a wrong/expired key
116
+ // would otherwise be reported as "connected". Probe OpenRouter with the freshly
117
+ // stored key and only announce success when it's actually reachable; on rejection
118
+ // roll the key back so `keyConnected` stays false and the form remains for a retry.
119
+ const created =
120
+ scope === 'workspace' ? await apiKeys.addWorkspaceKey(input) : await apiKeys.addUserKey(input)
121
+ const result = await store.refresh(workspace.workspaceId)
122
+ if (!result.reachable) {
123
+ if (created) {
124
+ if (scope === 'workspace') await apiKeys.removeWorkspaceKey(created.id).catch(() => {})
125
+ else await apiKeys.removeUserKey(created.id).catch(() => {})
126
+ }
127
+ toast.add({
128
+ title: 'Could not connect key',
129
+ description: store.refreshError ?? 'OpenRouter rejected the key.',
130
+ icon: 'i-lucide-triangle-alert',
131
+ color: 'error',
132
+ })
133
+ return
134
+ }
115
135
  keyValue.value = ''
116
136
  keyLabel.value = ''
117
137
  toast.add({ title: 'OpenRouter key connected', icon: 'i-lucide-check', color: 'success' })
118
- // Now that a key exists, load the live catalog automatically.
119
- await refresh()
120
138
  } catch (e) {
121
139
  toast.add({
122
140
  title: 'Could not connect key',
@@ -82,11 +82,16 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
82
82
  top: rect.top,
83
83
  bottom: rect.top + height,
84
84
  }
85
- // Stable anchor: the card's top-centre. It doesn't move as the card grows
86
- // downward, so the ordering can't oscillate as cards expand / collapse.
87
- const ax = rect.left + rect.width / 2
88
- const ay = rect.top
89
- const dist = (ax - cx) ** 2 + (ay - cy) ** 2
85
+ // Rank by the screen centre's distance to the card's projected footprint 0
86
+ // whenever the centre sits over the card. So a tall card the viewport is parked
87
+ // inside wins over a shorter neighbour whose top edge merely happens to be nearer
88
+ // the centre line (the old top-centre anchor penalised a card you'd scrolled into
89
+ // once its top scrolled above the viewport). The footprint is the stable expanded
90
+ // extent — its top doesn't move and its bottom uses the cached expanded height —
91
+ // so the ranking can't oscillate as cards expand / collapse.
92
+ const ddx = Math.max(footprint.left - cx, 0, cx - footprint.right)
93
+ const ddy = Math.max(footprint.top - cy, 0, cy - footprint.bottom)
94
+ const dist = ddx * ddx + ddy * ddy
90
95
  candidates.push({ id: t.id, rect: footprint, dist })
91
96
  }
92
97
  // Drop cached heights for cards that are gone, so the map can't grow unbounded.
@@ -39,6 +39,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
39
39
  if (!workspaceId.value) return
40
40
  const created = await api.addWorkspaceApiKey(workspaceId.value, input)
41
41
  workspaceKeys.value = [...workspaceKeys.value, created]
42
+ return created
42
43
  }
43
44
 
44
45
  async function removeWorkspaceKey(id: string) {
@@ -50,6 +51,7 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
50
51
  async function addUserKey(input: AddApiKeyInput) {
51
52
  const created = await api.addMyApiKey(input)
52
53
  userKeys.value = [...userKeys.value, created]
54
+ return created
53
55
  }
54
56
 
55
57
  async function removeUserKey(id: string) {
package/app/stores/ui.ts CHANGED
@@ -2,9 +2,8 @@ import { defineStore } from 'pinia'
2
2
  import { ref, computed } from 'vue'
3
3
  import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domain'
4
4
  import type { PendingContext } from '~/composables/useContextLinking'
5
- import { zoomToLod, lodAtLeast } from '~/composables/useSemanticZoom'
5
+ import { zoomToLod } from '~/composables/useSemanticZoom'
6
6
  import { useExecutionStore } from '~/stores/execution'
7
- import { useFrameExpansionStore } from '~/stores/frameExpansion'
8
7
  import { agentKindMeta } from '~/utils/catalog'
9
8
 
10
9
  /** Values used to seed the add-task form when it is opened from another surface. */
@@ -163,15 +162,12 @@ export const useUiStore = defineStore('ui', () => {
163
162
  expandedFrames.value = new Set(expandedFrames.value).add(id)
164
163
  }
165
164
 
166
- /** A frame shows its tasks when manually expanded OR once zoomed in to `close`
167
- * or any deeper band (`steps`/`subtasks` drill further into those tasks). The
168
- * zoom-driven branch is gated by the board's frame-expansion driver so only
169
- * on-screen, centre-most frames open — a large off-centre or off-screen service
170
- * no longer snaps out over the one the user is focused on. The gate degrades to
171
- * "allowed" when no board driver is mounted (focus view / tests). */
172
- function isFrameExpanded(id: string) {
173
- if (expandedFrames.value.has(id)) return true
174
- return lodAtLeast(lod.value, 'close') && useFrameExpansionStore().canExpand(id)
165
+ /** Services are always expanded to their task canvas, at every zoom level, so the
166
+ * board layout is fixed: panning never changes it and zooming has no expand/collapse
167
+ * transition to snap on. (`expandedFrames`/`toggleFrame` are retained for callers but
168
+ * no longer gate rendering.) */
169
+ function isFrameExpanded(_id: string) {
170
+ return true
175
171
  }
176
172
 
177
173
  function select(id: string | null) {
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { computeDisplacement, type DisplacementBox } from '~/utils/boardDisplacement'
3
+
4
+ function box(p: Partial<DisplacementBox> & { id: string }): DisplacementBox {
5
+ return { x: 0, y: 0, w: 10, h: 10, growX: 0, growY: 0, ...p }
6
+ }
7
+
8
+ describe('computeDisplacement', () => {
9
+ it('pushes a right-hand neighbour by the expanded box growth, preserving the gap', () => {
10
+ // A at x=0 w=10 grows by 90; B sits 20 to the right (x=30) in the same row.
11
+ const offsets = computeDisplacement([
12
+ box({ id: 'A', x: 0, w: 10, growX: 90 }),
13
+ box({ id: 'B', x: 30 }),
14
+ ])
15
+ expect(offsets.get('A')).toEqual({ dx: 0, dy: 0 })
16
+ // B moves right by 90, so the 20px gap (A's new right edge 100 → B's new left 120) is kept.
17
+ expect(offsets.get('B')).toEqual({ dx: 90, dy: 0 })
18
+ })
19
+
20
+ it('does not push a neighbour that was already overlapping the box horizontally', () => {
21
+ // B starts inside A's collapsed extent (x=5 < A.right=10): already overlapping, left as-is.
22
+ const offsets = computeDisplacement([
23
+ box({ id: 'A', x: 0, w: 10, growX: 90 }),
24
+ box({ id: 'B', x: 5 }),
25
+ ])
26
+ expect(offsets.get('B')).toEqual({ dx: 0, dy: 0 })
27
+ })
28
+
29
+ it('accumulates the growth of every expanded box to the left (chains)', () => {
30
+ const offsets = computeDisplacement([
31
+ box({ id: 'A', x: 0, w: 10, growX: 90 }),
32
+ box({ id: 'B', x: 30, w: 10, growX: 50 }),
33
+ box({ id: 'C', x: 60 }),
34
+ ])
35
+ // C is right of both A and B → pushed by 90 + 50.
36
+ expect(offsets.get('C')!.dx).toBe(140)
37
+ // B is right of A only → pushed by 90.
38
+ expect(offsets.get('B')!.dx).toBe(90)
39
+ })
40
+
41
+ it('does not cross-push boxes that are disjoint on the other axis', () => {
42
+ // A grows rightward but B is far below it (no shared rows) → no horizontal push.
43
+ const offsets = computeDisplacement([
44
+ box({ id: 'A', x: 0, y: 0, w: 10, h: 10, growX: 90 }),
45
+ box({ id: 'B', x: 30, y: 500, w: 10, h: 10 }),
46
+ ])
47
+ expect(offsets.get('B')).toEqual({ dx: 0, dy: 0 })
48
+ })
49
+
50
+ it('pushes a box below an expanded box downward', () => {
51
+ const offsets = computeDisplacement([
52
+ box({ id: 'A', x: 0, y: 0, w: 10, h: 10, growY: 100 }),
53
+ box({ id: 'B', x: 0, y: 30, w: 10, h: 10 }),
54
+ ])
55
+ expect(offsets.get('B')).toEqual({ dx: 0, dy: 100 })
56
+ })
57
+
58
+ it('returns a zero offset for every box when nothing is expanded', () => {
59
+ const offsets = computeDisplacement([box({ id: 'A', x: 0 }), box({ id: 'B', x: 30 })])
60
+ expect(offsets.get('A')).toEqual({ dx: 0, dy: 0 })
61
+ expect(offsets.get('B')).toEqual({ dx: 0, dy: 0 })
62
+ })
63
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Compressed-space layout for the board. When a box (a service frame, or a task
3
+ * card inside a frame) expands, it grows rightward / downward from its stored
4
+ * top-left. Rather than letting the expanded footprint overlap its neighbours —
5
+ * and then collapsing one of them to resolve the clash, which is what made a
6
+ * zoomed-in service "snap out" as you scrolled across it — we PUSH the neighbours
7
+ * away by the growth, so an expanded box never overlaps a neighbour it wasn't
8
+ * already overlapping. The box stays expanded; you just scroll a bit further to
9
+ * reach the next one.
10
+ *
11
+ * The result is a render-only offset per box; stored positions are never mutated.
12
+ */
13
+ export type DisplacementBox = {
14
+ id: string
15
+ /** Stored top-left, in flow units. */
16
+ x: number
17
+ y: number
18
+ /** Collapsed size, in flow units. */
19
+ w: number
20
+ h: number
21
+ /** Extra size when expanded (0 for a collapsed box). */
22
+ growX: number
23
+ growY: number
24
+ }
25
+
26
+ export type Offset = { dx: number; dy: number }
27
+
28
+ /** Do the two boxes' collapsed extents overlap on the y-axis? */
29
+ function overlapsY(a: DisplacementBox, b: DisplacementBox) {
30
+ return a.y < b.y + b.h && b.y < a.y + a.h
31
+ }
32
+
33
+ /** Do the two boxes' collapsed extents overlap on the x-axis? */
34
+ function overlapsX(a: DisplacementBox, b: DisplacementBox) {
35
+ return a.x < b.x + b.w && b.x < a.x + a.w
36
+ }
37
+
38
+ /**
39
+ * The render offset for every box. A box B is pushed right by the horizontal
40
+ * growth of each expanded box E that sits to B's left (B starts past E's collapsed
41
+ * right edge, so it isn't already overlapping E on x) and shares B's rows (their
42
+ * collapsed y-extents overlap, so E growing rightward would actually reach B).
43
+ * Symmetric for the downward push. Offsets only ever grow, so the function can
44
+ * never create a new overlap, preserves left-to-right / top-to-bottom order and
45
+ * the gaps between boxes, and chained expansions accumulate (the sum is taken from
46
+ * the stable stored positions, so it doesn't oscillate). O(N x expanded).
47
+ */
48
+ export function computeDisplacement(boxes: DisplacementBox[]): Map<string, Offset> {
49
+ const expanded = boxes.filter((e) => e.growX > 0 || e.growY > 0)
50
+ const out = new Map<string, Offset>()
51
+ for (const b of boxes) {
52
+ let dx = 0
53
+ let dy = 0
54
+ for (const e of expanded) {
55
+ if (e.id === b.id) continue
56
+ if (e.growX > 0 && b.x >= e.x + e.w && overlapsY(e, b)) dx += e.growX
57
+ if (e.growY > 0 && b.y >= e.y + e.h && overlapsX(e, b)) dy += e.growY
58
+ }
59
+ out.set(b.id, { dx, dy })
60
+ }
61
+ return out
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.26.1",
3
+ "version": "0.26.3",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,118 +0,0 @@
1
- import type { Ref } from 'vue'
2
- import { onMounted, onBeforeUnmount } from 'vue'
3
- import { useRafFn } from '@vueuse/core'
4
- import { lodAtLeast } from '~/composables/useSemanticZoom'
5
-
6
- type Rect = { left: number; right: number; top: number; bottom: number }
7
-
8
- function intersects(a: Rect, b: Rect) {
9
- return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
10
- }
11
-
12
- function sameSet(a: Set<string>, b: Set<string>) {
13
- if (a.size !== b.size) return false
14
- for (const id of a) if (!b.has(id)) return false
15
- return true
16
- }
17
-
18
- /**
19
- * Board-level driver deciding which service frames may auto-expand to their task
20
- * canvas once zoomed past the `close` band. The frame analogue of
21
- * `useTaskExpansion`: two gates, recomputed every frame against live DOM rects so
22
- * they follow pan / zoom / drag / resize:
23
- *
24
- * - visibility: a frame expands only while its card overlaps the board viewport,
25
- * so a service that isn't on screen at all never expands when you zoom in.
26
- * - overlap: walking the visible frames nearest-to-screen-centre first, a frame
27
- * expands only if its (expanded) footprint doesn't collide with one already
28
- * granted — so the small service the user centred on wins, and a larger
29
- * neighbour can't "snap out" over it.
30
- *
31
- * Writes the permitted id set into the `frameExpansion` store; `ui.isFrameExpanded`
32
- * reads it. Manually-expanded frames bypass this gate entirely (see the store).
33
- */
34
- export function useFrameExpansion(container: Ref<HTMLElement | null>) {
35
- const board = useBoardStore()
36
- const ui = useUiStore()
37
- const store = useFrameExpansionStore()
38
-
39
- // Last-known expanded size per frame. A frame's card balloons from a chip to its
40
- // full task canvas only while it's granted, so its live rect collapses the moment
41
- // it's denied. Testing overlap with the collapsed chip is what would cause
42
- // flashing: a denied frame no longer overlaps its neighbour, gets re-granted,
43
- // expands, overlaps again, gets denied — every frame. We cache the expanded
44
- // extent while a frame is granted and project the footprint with it, so a denied
45
- // frame is still tested at its expanded size and stays denied. Stable.
46
- const expandedSize = new Map<string, { w: number; h: number }>()
47
-
48
- function rectOf(id: string): DOMRect | null {
49
- const el = document.querySelector(`[data-block-id="${id}"]`) as HTMLElement | null
50
- return el ? el.getBoundingClientRect() : null
51
- }
52
-
53
- function recompute() {
54
- // Frames only auto-expand at the `close` band and deeper; clear otherwise.
55
- if (!lodAtLeast(ui.lod, 'close')) {
56
- if (store.allowed.size) store.setAllowed(new Set())
57
- return
58
- }
59
- const view = container.value?.getBoundingClientRect()
60
- if (!view) return
61
- const cx = view.left + view.width / 2
62
- const cy = view.top + view.height / 2
63
-
64
- const candidates: { id: string; rect: Rect; dist: number }[] = []
65
- const liveIds = new Set<string>()
66
- for (const f of board.frames) {
67
- const rect = rectOf(f.id)
68
- if (!rect) continue
69
- liveIds.add(f.id)
70
- // While granted the frame is rendered expanded, so its live rect is its
71
- // expanded footprint — cache it. A denied frame keeps its last cached value.
72
- if (store.allowed.has(f.id)) expandedSize.set(f.id, { w: rect.width, h: rect.height })
73
- // Visibility: the card must intersect the board viewport (live rect).
74
- if (!intersects(rect, view)) continue
75
- // Project to the cached expanded extent so the overlap test is independent of
76
- // the card's current (possibly collapsed-chip) state. A frame grows rightward
77
- // and downward from its top-left, which stays put as it expands.
78
- const cached = expandedSize.get(f.id)
79
- const width = Math.max(rect.width, cached?.w ?? 0)
80
- const height = Math.max(rect.height, cached?.h ?? 0)
81
- const footprint: Rect = {
82
- left: rect.left,
83
- right: rect.left + width,
84
- top: rect.top,
85
- bottom: rect.top + height,
86
- }
87
- // Stable anchor: the card's top-left. It doesn't move as the frame grows, so
88
- // the ordering can't oscillate as frames expand / collapse.
89
- const dist = (rect.left - cx) ** 2 + (rect.top - cy) ** 2
90
- candidates.push({ id: f.id, rect: footprint, dist })
91
- }
92
- // Drop cached sizes for frames that are gone, so the map can't grow unbounded.
93
- for (const id of expandedSize.keys()) if (!liveIds.has(id)) expandedSize.delete(id)
94
- candidates.sort((a, b) => a.dist - b.dist)
95
-
96
- // Greedy by distance to centre: a frame is granted only if its projected
97
- // footprint clears every footprint already granted, so the centre-most frame
98
- // wins any overlap.
99
- const claimed: Rect[] = []
100
- const next = new Set<string>()
101
- for (const c of candidates) {
102
- if (claimed.some((r) => intersects(c.rect, r))) continue
103
- next.add(c.id)
104
- claimed.push(c.rect)
105
- }
106
- if (!sameSet(next, store.allowed)) store.setAllowed(next)
107
- }
108
-
109
- const { pause, resume } = useRafFn(recompute, { immediate: false })
110
- onMounted(() => {
111
- store.setDriverActive(true)
112
- resume()
113
- })
114
- onBeforeUnmount(() => {
115
- pause()
116
- store.setDriverActive(false)
117
- })
118
- }
@@ -1,38 +0,0 @@
1
- import { defineStore } from 'pinia'
2
- import { ref } from 'vue'
3
-
4
- /**
5
- * Which service frames may auto-expand to reveal their tasks once zoomed in.
6
- *
7
- * Past the `close` band a frame opens from a chip to its full task canvas, which
8
- * can balloon across the viewport. Expanding *every* frame at once (the old
9
- * behaviour) made a large off-centre service "snap out" over the small one the
10
- * user was actually centred on, and expanded services that weren't even on screen.
11
- * The board driver (`useFrameExpansion`) recomputes a permitted set every frame —
12
- * only on-screen frames, and only the one closest to the screen centre when two
13
- * expanded footprints would overlap — and writes it here. `ui.isFrameExpanded`
14
- * reads `canExpand` to decide whether the zoom band may open a frame.
15
- *
16
- * `driverActive` lets the gate degrade gracefully: with no board driver mounted
17
- * (e.g. the focus view, or a frame rendered in isolation / tests) `canExpand`
18
- * falls back to "allowed", so the plain zoom behaviour is unchanged.
19
- */
20
- export const useFrameExpansionStore = defineStore('frameExpansion', () => {
21
- const allowed = ref<Set<string>>(new Set())
22
- const driverActive = ref(false)
23
-
24
- function setAllowed(ids: Set<string>) {
25
- allowed.value = ids
26
- }
27
-
28
- function setDriverActive(active: boolean) {
29
- driverActive.value = active
30
- if (!active) allowed.value = new Set()
31
- }
32
-
33
- function canExpand(id: string) {
34
- return driverActive.value ? allowed.value.has(id) : true
35
- }
36
-
37
- return { allowed, driverActive, setAllowed, setDriverActive, canExpand }
38
- })