@cat-factory/app 0.280.0 → 0.280.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.
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { measureBlocks } from './blockRects'
3
+
4
+ function card(id: string): HTMLElement {
5
+ const el = document.createElement('div')
6
+ el.setAttribute('data-block-id', id)
7
+ return el
8
+ }
9
+
10
+ beforeEach(() => {
11
+ document.body.innerHTML = ''
12
+ })
13
+
14
+ describe('measureBlocks', () => {
15
+ it('resolves each rendered card by its block id', () => {
16
+ const a = card('a')
17
+ const b = card('b')
18
+ document.body.append(a, b)
19
+
20
+ const blocks = measureBlocks(document)
21
+ expect(blocks.elementFor('a')).toBe(a)
22
+ expect(blocks.elementFor('b')).toBe(b)
23
+ expect(blocks.elementFor('missing')).toBeNull()
24
+ })
25
+
26
+ it('resolves the first card in document order, as a bare querySelector did', () => {
27
+ const onBoard = card('a')
28
+ const inOverlay = card('a')
29
+ document.body.append(onBoard, inOverlay)
30
+
31
+ expect(measureBlocks(document).elementFor('a')).toBe(onBoard)
32
+ })
33
+
34
+ it('measures an element once per pass however many links read it', () => {
35
+ const a = card('a')
36
+ document.body.append(a)
37
+ const measure = vi.spyOn(a, 'getBoundingClientRect')
38
+
39
+ const blocks = measureBlocks(document)
40
+ const first = blocks.rectFor(a)
41
+ expect(blocks.rectFor(a)).toBe(first)
42
+ expect(measure).toHaveBeenCalledTimes(1)
43
+ })
44
+
45
+ it('is a snapshot: a later pass measures again', () => {
46
+ const a = card('a')
47
+ document.body.append(a)
48
+ const measure = vi.spyOn(a, 'getBoundingClientRect')
49
+
50
+ measureBlocks(document).rectFor(a)
51
+ measureBlocks(document).rectFor(a)
52
+ expect(measure).toHaveBeenCalledTimes(2)
53
+ })
54
+
55
+ it('queries nothing until something is actually looked up', () => {
56
+ // The edge overlay builds a pass every awake frame of a pan and, on a board with no links
57
+ // of any kind, asks it for no card at all. Deferring is what keeps that pass free.
58
+ const root = document.createElement('div')
59
+ root.append(card('a'))
60
+ document.body.append(root)
61
+ const query = vi.spyOn(root, 'querySelectorAll')
62
+
63
+ const blocks = measureBlocks(root)
64
+ expect(query).not.toHaveBeenCalled()
65
+
66
+ blocks.elementFor('a')
67
+ blocks.elementFor('a')
68
+ expect(query).toHaveBeenCalledTimes(1)
69
+ })
70
+
71
+ it('scopes the pass to the root it was given', () => {
72
+ const outside = card('a')
73
+ const root = document.createElement('div')
74
+ const inside = card('b')
75
+ root.append(inside)
76
+ document.body.append(outside, root)
77
+
78
+ const blocks = measureBlocks(root)
79
+ expect(blocks.elementFor('b')).toBe(inside)
80
+ expect(blocks.elementFor('a')).toBeNull()
81
+ })
82
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * One measurement pass's view of the board's rendered block cards.
3
+ *
4
+ * The two DOM-measuring drivers on the canvas (dependency edges, task expansion) resolve cards
5
+ * by `[data-block-id]`, which is what lets an arrow follow pan / zoom / drag for free. Done a
6
+ * card at a time it is also the drivers' whole cost: the edge overlay ran two
7
+ * `document.querySelector` scans plus two `getBoundingClientRect` reads PER LINK, so a task with
8
+ * five dependencies was found and measured five times in the same frame, and the expansion sweep
9
+ * ran one scan per candidate task.
10
+ *
11
+ * A pass builds this once instead: one `querySelectorAll` over the board, then map lookups, with
12
+ * each element measured at most once. First-in-document-order wins per id, matching what
13
+ * `document.querySelector` returned before, so a card also rendered outside the canvas (the focus
14
+ * view, the inspector) resolves to the same element it always did.
15
+ *
16
+ * The query itself is DEFERRED to the first lookup, so a pass that turns out to have nothing to
17
+ * resolve costs nothing. That is the common case rather than a corner: a board with no
18
+ * dependency, epic, frontend or connection link runs the edge overlay's pass on every awake
19
+ * frame of a pan and asks it for not one card, and the sweep it replaced did no DOM work there
20
+ * at all. Deferring keeps that property in the helper, where both drivers inherit it, rather
21
+ * than as a `links.length` guard at each call site that a fifth overlay would silently miss.
22
+ *
23
+ * It is still a SNAPSHOT: geometry read inside one frame must not change halfway through a pass,
24
+ * the query runs at most once whenever it runs, and the next pass builds a fresh one.
25
+ */
26
+ export type BlockMeasurements = {
27
+ /** The rendered card for a block id, or null when nothing on the page renders it. */
28
+ elementFor: (id: string) => HTMLElement | null
29
+ /** The element's viewport rect, measured once per pass. */
30
+ rectFor: (el: Element) => DOMRect
31
+ }
32
+
33
+ export const BLOCK_ID_ATTRIBUTE = 'data-block-id'
34
+
35
+ export function measureBlocks(root: ParentNode = document): BlockMeasurements {
36
+ let elements: Map<string, HTMLElement> | null = null
37
+
38
+ function index(): Map<string, HTMLElement> {
39
+ if (elements) return elements
40
+ const found = new Map<string, HTMLElement>()
41
+ for (const el of root.querySelectorAll<HTMLElement>(`[${BLOCK_ID_ATTRIBUTE}]`)) {
42
+ const id = el.getAttribute(BLOCK_ID_ATTRIBUTE)
43
+ if (id && !found.has(id)) found.set(id, el)
44
+ }
45
+ elements = found
46
+ return found
47
+ }
48
+
49
+ const rects = new WeakMap<Element, DOMRect>()
50
+
51
+ return {
52
+ elementFor: (id) => index().get(id) ?? null,
53
+ rectFor(el) {
54
+ const cached = rects.get(el)
55
+ if (cached) return cached
56
+ const rect = el.getBoundingClientRect()
57
+ rects.set(el, rect)
58
+ return rect
59
+ },
60
+ }
61
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createWakeGate, type WakeGateScheduler } from './boardWakeGate'
3
+
4
+ /** A hand-driven timer: `run()` fires the one scheduled callback, whatever its delay. */
5
+ function fakeScheduler() {
6
+ let nextHandle = 1
7
+ const pending = new Map<number, { run: () => void; delayMs: number }>()
8
+ const scheduler: WakeGateScheduler = {
9
+ schedule(run, delayMs) {
10
+ const handle = nextHandle++
11
+ pending.set(handle, { run, delayMs })
12
+ return handle
13
+ },
14
+ cancel(handle) {
15
+ pending.delete(handle)
16
+ },
17
+ }
18
+ return {
19
+ scheduler,
20
+ pending: () => pending.size,
21
+ delays: () => [...pending.values()].map((p) => p.delayMs),
22
+ /** Fire every scheduled callback; anything they schedule waits for the next elapse. */
23
+ elapse() {
24
+ const due = [...pending.entries()]
25
+ pending.clear()
26
+ for (const [, { run }] of due) run()
27
+ },
28
+ }
29
+ }
30
+
31
+ function gateWith(intervalMs?: number) {
32
+ const clock = fakeScheduler()
33
+ let wakes = 0
34
+ const gate = createWakeGate({
35
+ wake: () => {
36
+ wakes++
37
+ },
38
+ scheduler: clock.scheduler,
39
+ intervalMs,
40
+ })
41
+ return { clock, gate, wakes: () => wakes }
42
+ }
43
+
44
+ describe('createWakeGate', () => {
45
+ it('wakes immediately on the first request', () => {
46
+ const { gate, wakes, clock } = gateWith()
47
+ gate.request()
48
+ expect(wakes()).toBe(1)
49
+ expect(clock.pending()).toBe(1)
50
+ })
51
+
52
+ it('admits at most one wake per interval while requests keep arriving', () => {
53
+ const { gate, wakes, clock } = gateWith()
54
+ gate.request()
55
+ gate.request()
56
+ gate.request()
57
+ expect(wakes()).toBe(1)
58
+
59
+ // The suppressed requests are owed one wake, which lands when the interval ends.
60
+ clock.elapse()
61
+ expect(wakes()).toBe(2)
62
+ // ... and the wake it just admitted opens the next interval, so a continuing stream
63
+ // stays bounded rather than firing per request.
64
+ gate.request()
65
+ expect(wakes()).toBe(2)
66
+ clock.elapse()
67
+ expect(wakes()).toBe(3)
68
+ })
69
+
70
+ it('goes idle after a quiet interval, so an isolated request is never delayed', () => {
71
+ const { gate, wakes, clock } = gateWith()
72
+ gate.request()
73
+ expect(wakes()).toBe(1)
74
+
75
+ clock.elapse()
76
+ // Nothing was owed, so the interval simply closed: no wake, nothing scheduled.
77
+ expect(wakes()).toBe(1)
78
+ expect(clock.pending()).toBe(0)
79
+
80
+ gate.request()
81
+ expect(wakes()).toBe(2)
82
+ })
83
+
84
+ it('drops an owed wake when cancelled, and admits the next request immediately', () => {
85
+ const { gate, wakes, clock } = gateWith()
86
+ gate.request()
87
+ gate.request()
88
+ gate.cancel()
89
+ clock.elapse()
90
+ expect(wakes()).toBe(1)
91
+
92
+ gate.request()
93
+ expect(wakes()).toBe(2)
94
+ })
95
+
96
+ it('schedules the interval the caller configured', () => {
97
+ const { gate, clock } = gateWith(40)
98
+ gate.request()
99
+ expect(clock.delays()).toEqual([40])
100
+ })
101
+ })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Rate limiter for the wakes the board's activity pulse raises from RENDERS.
3
+ *
4
+ * The canvas MutationObserver is deliberately broad (see `useBoardActivity`): it watches the
5
+ * whole subtree for structure plus `style`/`class`, which is what lets it catch a geometry
6
+ * change without enumerating the causes. The cost is that every Vue-driven card re-render
7
+ * wakes the two DOM-measuring loops, and each wake carries a settle tail of several frames,
8
+ * so under a steady execution-event stream a busy board never parks them: exactly the board
9
+ * where the measurement is most expensive pays for it continuously.
10
+ *
11
+ * A render is not a gesture, though. A card whose badge changed may or may not have moved its
12
+ * neighbours, and either way nobody is watching that pixel land within one frame, so these
13
+ * wakes may be COALESCED where a pointer/wheel/camera wake may not. This gate fires the first
14
+ * one straight through (an isolated change still follows within a frame) and then admits at
15
+ * most one per interval for as long as the stream lasts.
16
+ *
17
+ * The scheduler is injected so the behaviour is testable without a timer clock.
18
+ */
19
+ export type WakeGateScheduler = {
20
+ schedule: (run: () => void, delayMs: number) => number
21
+ cancel: (handle: number) => void
22
+ }
23
+
24
+ export type WakeGate = {
25
+ /** Ask for a wake: immediate when the interval is clear, coalesced onto its end otherwise. */
26
+ request: () => void
27
+ /** Drop a coalesced wake that has not fired yet. Idempotent. */
28
+ cancel: () => void
29
+ }
30
+
31
+ /**
32
+ * How long one admitted render wake covers. The settle tail of a woken loop is ~4 frames
33
+ * (~66ms at 60Hz), so this leaves a busy board measuring for a fraction of each interval
34
+ * instead of every frame, while a change that really did move a card is on screen well inside
35
+ * the window a reader would notice.
36
+ */
37
+ export const RENDER_WAKE_INTERVAL_MS = 250
38
+
39
+ export function createWakeGate(options: {
40
+ /** Raise the pulse. */
41
+ wake: () => void
42
+ scheduler: WakeGateScheduler
43
+ intervalMs?: number
44
+ }): WakeGate {
45
+ const { wake, scheduler } = options
46
+ const intervalMs = options.intervalMs ?? RENDER_WAKE_INTERVAL_MS
47
+ /** The open interval's handle, or null when no wake has been admitted recently. */
48
+ let window: number | null = null
49
+ /** Whether a request arrived while the interval was open and still owes a wake. */
50
+ let owed = false
51
+
52
+ function closeWindow() {
53
+ window = null
54
+ // A quiet interval simply ends: the next request is admitted immediately, so an isolated
55
+ // render never waits. Only a stream that kept asking re-opens the interval, which is what
56
+ // bounds it to one wake per interval for as long as it lasts.
57
+ if (!owed) return
58
+ owed = false
59
+ wake()
60
+ window = scheduler.schedule(closeWindow, intervalMs)
61
+ }
62
+
63
+ return {
64
+ request() {
65
+ if (window !== null) {
66
+ owed = true
67
+ return
68
+ }
69
+ wake()
70
+ window = scheduler.schedule(closeWindow, intervalMs)
71
+ },
72
+ cancel() {
73
+ if (window !== null) scheduler.cancel(window)
74
+ window = null
75
+ owed = false
76
+ },
77
+ }
78
+ }
@@ -6041,6 +6041,11 @@
6041
6041
  "body": "Die Bereitstellungsintegration dieses Dienstes hat den Verbindungstest nicht bestanden: {detail}. Überprüfe ihren Endpunkt und ihre Anmeldedaten und teste die Verbindung erneut, um sie auszuführen.",
6042
6042
  "action": "Infrastruktur konfigurieren"
6043
6043
  }
6044
+ },
6045
+ "asyncView": {
6046
+ "title": "Diese Ansicht konnte nicht geladen werden",
6047
+ "body": "Ihr Code konnte nicht geladen werden. Meist wurde die Anwendung aktualisiert, während dieser Tab geöffnet war, sodass die angeforderten Dateien nicht mehr auf dem Server liegen. Laden Sie neu, um die aktuelle Version zu erhalten.",
6048
+ "reload": "Neu laden"
6044
6049
  }
6045
6050
  },
6046
6051
  "slack": {
@@ -847,6 +847,11 @@
847
847
  },
848
848
  "action": "Configure infrastructure"
849
849
  }
850
+ },
851
+ "asyncView": {
852
+ "title": "This view could not be loaded",
853
+ "body": "Its code failed to load. That usually means the app was updated while this tab was open, so the files this page is asking for are no longer on the server. Reload to pick up the current version.",
854
+ "reload": "Reload"
850
855
  }
851
856
  },
852
857
  "personalSubscriptions": {
@@ -757,6 +757,11 @@
757
757
  "body": "La integración de despliegue de este servicio falló la prueba de conexión: {detail}. Revisa su endpoint y credenciales y vuelve a probar la conexión para ejecutarla.",
758
758
  "action": "Configurar infraestructura"
759
759
  }
760
+ },
761
+ "asyncView": {
762
+ "title": "No se pudo cargar esta vista",
763
+ "body": "No se pudo cargar su código. Normalmente significa que la aplicación se actualizó mientras esta pestaña estaba abierta, así que los archivos que pide esta página ya no están en el servidor. Vuelve a cargar para obtener la versión actual.",
764
+ "reload": "Volver a cargar"
760
765
  }
761
766
  },
762
767
  "personalSubscriptions": {
@@ -757,6 +757,11 @@
757
757
  "body": "L’intégration de déploiement de ce service a échoué au test de connexion : {detail}. Vérifiez son point de terminaison et ses identifiants, puis retestez la connexion pour l’exécuter.",
758
758
  "action": "Configurer l’infrastructure"
759
759
  }
760
+ },
761
+ "asyncView": {
762
+ "title": "Cette vue n'a pas pu être chargée",
763
+ "body": "Son code n'a pas pu être chargé. Cela signifie généralement que l'application a été mise à jour pendant que cet onglet était ouvert : les fichiers demandés par cette page ne sont plus sur le serveur. Rechargez pour obtenir la version actuelle.",
764
+ "reload": "Recharger"
760
765
  }
761
766
  },
762
767
  "personalSubscriptions": {
@@ -757,6 +757,11 @@
757
757
  "body": "אינטגרציית הפריסה של שירות זה נכשלה בבדיקת החיבור: {detail}. בדוק את נקודת הקצה והאישורים שלה, ואז בדוק שוב את החיבור כדי להריץ אותו.",
758
758
  "action": "הגדר תשתית"
759
759
  }
760
+ },
761
+ "asyncView": {
762
+ "title": "לא ניתן היה לטעון את התצוגה הזו",
763
+ "body": "טעינת הקוד שלה נכשלה. בדרך כלל המשמעות היא שהאפליקציה עודכנה בזמן שהלשונית הזו הייתה פתוחה, ולכן הקבצים שהדף מבקש כבר אינם בשרת. רעננו כדי לקבל את הגרסה הנוכחית.",
764
+ "reload": "רענון"
760
765
  }
761
766
  },
762
767
  "personalSubscriptions": {
@@ -6041,6 +6041,11 @@
6041
6041
  "body": "L’integrazione di deployment di questo servizio non ha superato il test di connessione: {detail}. Controlla il suo endpoint e le credenziali, poi riprova la connessione per eseguirla.",
6042
6042
  "action": "Configura infrastruttura"
6043
6043
  }
6044
+ },
6045
+ "asyncView": {
6046
+ "title": "Impossibile caricare questa vista",
6047
+ "body": "Il suo codice non è stato caricato. Di solito significa che l'applicazione è stata aggiornata mentre questa scheda era aperta, quindi i file richiesti da questa pagina non sono più sul server. Ricarica per ottenere la versione attuale.",
6048
+ "reload": "Ricarica"
6044
6049
  }
6045
6050
  },
6046
6051
  "slack": {
@@ -757,6 +757,11 @@
757
757
  "body": "このサービスのデプロイ連携が接続テストに失敗しました: {detail}。エンドポイントと認証情報を確認し、接続を再テストしてから実行してください。",
758
758
  "action": "インフラを設定"
759
759
  }
760
+ },
761
+ "asyncView": {
762
+ "title": "このビューを読み込めませんでした",
763
+ "body": "コードの読み込みに失敗しました。多くの場合、このタブを開いたままアプリが更新され、このページが要求しているファイルがサーバー上に存在しなくなったことが原因です。再読み込みして最新版を取得してください。",
764
+ "reload": "再読み込み"
760
765
  }
761
766
  },
762
767
  "personalSubscriptions": {
@@ -757,6 +757,11 @@
757
757
  "body": "Integracja wdrożeniowa tej usługi nie przeszła testu połączenia: {detail}. Sprawdź jej punkt końcowy i poświadczenia, a następnie ponownie przetestuj połączenie, aby go uruchomić.",
758
758
  "action": "Skonfiguruj infrastrukturę"
759
759
  }
760
+ },
761
+ "asyncView": {
762
+ "title": "Nie udało się wczytać tego widoku",
763
+ "body": "Nie udało się wczytać jego kodu. Zwykle oznacza to, że aplikacja została zaktualizowana, gdy ta karta była otwarta, więc plików żądanych przez tę stronę nie ma już na serwerze. Odśwież, aby pobrać aktualną wersję.",
764
+ "reload": "Odśwież"
760
765
  }
761
766
  },
762
767
  "personalSubscriptions": {
@@ -757,6 +757,11 @@
757
757
  "body": "Bu hizmetin dağıtım entegrasyonu bağlantı testinde başarısız oldu: {detail}. Uç noktasını ve kimlik bilgilerini kontrol edin, ardından çalıştırmak için bağlantıyı yeniden test edin.",
758
758
  "action": "Altyapıyı yapılandır"
759
759
  }
760
+ },
761
+ "asyncView": {
762
+ "title": "Bu görünüm yüklenemedi",
763
+ "body": "Kodu yüklenemedi. Bu genellikle bu sekme açıkken uygulamanın güncellendiği anlamına gelir; sayfanın istediği dosyalar artık sunucuda değildir. Geçerli sürümü almak için yeniden yükleyin.",
764
+ "reload": "Yeniden yükle"
760
765
  }
761
766
  },
762
767
  "personalSubscriptions": {
@@ -757,6 +757,11 @@
757
757
  "body": "Інтеграція розгортання цього сервісу не пройшла перевірку з’єднання: {detail}. Перевірте її кінцеву точку та облікові дані, потім повторно перевірте з’єднання, щоб запустити його.",
758
758
  "action": "Налаштувати інфраструктуру"
759
759
  }
760
+ },
761
+ "asyncView": {
762
+ "title": "Не вдалося завантажити цей вигляд",
763
+ "body": "Не вдалося завантажити його код. Зазвичай це означає, що застосунок оновили, поки ця вкладка була відкрита, тож файлів, які запитує ця сторінка, уже немає на сервері. Перезавантажте, щоб отримати поточну версію.",
764
+ "reload": "Перезавантажити"
760
765
  }
761
766
  },
762
767
  "personalSubscriptions": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.280.0",
3
+ "version": "0.280.1",
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",