From e16914f10df950c29e89762648e02ef79234bd1f Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:56:38 -0500 Subject: [PATCH 1/7] docs: design minimized GPU suspension --- ...08-09-suspend-gpu-when-minimized-design.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-09-suspend-gpu-when-minimized-design.md diff --git a/docs/superpowers/specs/2026-08-09-suspend-gpu-when-minimized-design.md b/docs/superpowers/specs/2026-08-09-suspend-gpu-when-minimized-design.md new file mode 100644 index 0000000..786eed0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-suspend-gpu-when-minimized-design.md @@ -0,0 +1,121 @@ +# Suspend GPU Rendering While Minimized Design + +**Status:** Approved by the project owner on 2026-08-09 + +## Goal + +Stop Stellr's continuous canvas rendering while the native application window +is minimized, while preserving a very-low-rate data refresh and restoring the +same visual state immediately when the window returns. + +## Behavior Contract + +Minimizing the native Stellr window cancels the star-map renderer's outstanding +animation frame and schedules no replacement frame. Camera position, camera +goal, selection, model data, layout, flare state, and animation state remain in +memory; minimization does not destroy or remount the renderer. + +Restoring the window resets the renderer's frame-time baseline and schedules +exactly one animation loop. The first restored frame paints the current model +without advancing animations by the full minimized duration. Repeated minimize +or restore notifications are idempotent and cannot create duplicate loops. + +Browser-hosted Stellr applies the equivalent behavior when the document becomes +hidden or visible. This gives browser tabs the same power-saving behavior +without changing the browser transport or native-shell contract. + +## Renderer Boundary + +Add explicit `suspend()` and `resume()` lifecycle methods to the imperative +star-map renderer: + +- `suspend()` records the suspended state, cancels a scheduled animation frame, + and clears the frame handle; +- `resume()` does nothing unless suspended, clears the suspended state, resets + the last-frame timestamp, and schedules one frame when a canvas context is + mounted; +- the render callback schedules its successor only while active; +- the animation clock advances by the bounded per-frame delta rather than raw + wall time, so it remains frozen for the entire suspension; +- `destroy()` remains terminal and cleans up a scheduled frame in either state. + +The Svelte wrapper suspends a new renderer before mounting it, so mounting +cannot schedule a frame until initial lifecycle state is known. The observer's +initial notification either keeps it suspended or resumes it. Model, camera, +selection, resize, and input methods remain valid while suspended; their state +is reflected on the first frame after resume. + +## Window Lifecycle Boundary + +Keep native lifecycle detection in the frontend's existing native-shell seam. +When running under Tauri, observe the current window's resize and focus-change +notifications and query `isMinimized()` for the authoritative state. Focus loss +alone does not suspend rendering. Perform an initial query before allowing the +mounted renderer to start, and discard stale asynchronous query results so a +rapid minimize/restore sequence cannot apply lifecycle state out of order. + +When not running under Tauri, observe `document.visibilitychange` and derive the +suspended state from `document.hidden`. The observer exposes one boolean +callback and returns an unsubscribe function. `StarMap.svelte` uses that seam +to call `renderer.suspend()` or `renderer.resume()` and removes the observer on +unmount. + +An observer setup or state-query failure must not leave a visible application +permanently frozen. The failure path retains or returns to active rendering; +ordinary rendering errors remain governed by the existing renderer behavior. + +## Polling Lifecycle + +The existing focus-aware server polling policy remains unchanged: + +- a focused native window polls approximately every 30 seconds; +- a minimized or otherwise unfocused native window polls approximately every + five minutes; +- manual refresh remains immediate in either state; +- browser-hosted `serve` mode retains its established polling behavior. + +Minimization therefore stops GPU-driven canvas frames but does not stop the +application runtime, GitHub provider, control WebSocket, or cached model +updates. Data received while minimized is retained and appears on the first +restored frame. + +## Testing Strategy + +Use test-driven development at the renderer and lifecycle seams. + +Renderer tests prove that: + +- suspension cancels the outstanding frame and no callback schedules another; +- repeated suspension is harmless; +- resume schedules exactly one loop and repeated resume does not duplicate it; +- resume resets elapsed time so animations do not jump by the minimized + duration; +- model, camera, and selection changes made while suspended appear after + resume; +- destroy cleans up correctly from active and suspended states. + +Lifecycle tests prove that: + +- Tauri mode maps initial and subsequent `isMinimized()` results to the boolean + callback and unregisters its native listener; +- browser mode maps `document.hidden` changes and unregisters its document + listener; +- a native state-query failure fails open to active rendering; +- the Svelte wrapper connects lifecycle notifications to renderer suspension + and removes both lifecycle and renderer resources on unmount. + +The completion gate is the targeted frontend tests in red and green states, +the full frontend test suite, Svelte check, frontend production build, Rust +formatting, warnings-denied Clippy, and locked native Windows workspace tests. + +## Release Notes + +Record the power-saving behavior once under `Unreleased` in `CHANGELOG.md`. +Do not edit or repeat the change in an already shipped version section. + +## Scope + +This change does not destroy the webview, hide the window to the tray, pause +WebSocket delivery, change manual refresh, alter the five-minute background +polling interval, suspend merely because another window has focus, reset visual +state, or introduce a user-facing power setting. From 57441a839049e5f103f73cd0db8b4b4798c06553 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:14:26 -0500 Subject: [PATCH 2/7] docs: plan minimized GPU suspension --- .../2026-08-09-suspend-gpu-when-minimized.md | 733 ++++++++++++++++++ 1 file changed, 733 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-09-suspend-gpu-when-minimized.md diff --git a/docs/superpowers/plans/2026-08-09-suspend-gpu-when-minimized.md b/docs/superpowers/plans/2026-08-09-suspend-gpu-when-minimized.md new file mode 100644 index 0000000..8534d13 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-suspend-gpu-when-minimized.md @@ -0,0 +1,733 @@ +# Suspend GPU Rendering While Minimized Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop Stellr's canvas animation loop while its native window is minimized, retain five-minute background polling, and resume the preserved map immediately on restore. + +**Architecture:** The imperative canvas renderer owns an idempotent `suspend()`/`resume()` state machine and a logical animation clock. The existing native-shell frontend seam translates Tauri minimized state or browser document visibility into a boolean lifecycle callback, while the Svelte wrapper connects that callback to the renderer without remounting it. Rust polling remains unchanged because it already switches an unfocused native window from 30-second to five-minute polling. + +**Tech Stack:** TypeScript 6, Svelte 5, Tauri 2.11 window API, Vitest 4 with jsdom, native Windows PowerShell, Vite+, Rust/Cargo workspace validation. + +## Global Constraints + +- Use only native Windows commands and executables; do not use WSL, Linux shells, or `/mnt/*` paths. +- Work in `D:\tmp\stellr-suspend-gpu-when-minimized` on branch `codex/suspend-gpu-when-minimized`; do not modify `D:\dev\stellr` or its current branch. +- Install frontend dependencies with `vp.exe install --frozen-lockfile` from `web` if `web\node_modules` is absent. +- Write each production change only after its focused test fails for the expected missing behavior. +- A minimized or hidden map schedules zero canvas animation frames; restore schedules exactly one loop. +- Minimize/restore preserves the renderer instance, model, layout, camera, selection, flare, ticker, and animation phase. +- Native polling remains 30 seconds while focused and five minutes while minimized or otherwise unfocused; manual refresh remains immediate. +- Browser `serve` mode retains its existing transport and polling behavior and uses `document.hidden` only to suspend rendering. +- Do not add dependencies, a user-facing power setting, tray-hide behavior, WebSocket suspension, or Rust-to-web lifecycle events. +- Add the release note once under `CHANGELOG.md`'s `Unreleased` section; do not edit shipped release sections. + +--- + +## File Map + +- `web/src/lib/starmap/starmap.ts`: owns renderer scheduling, suspension state, and the logical animation/ticker clock. +- `web/src/lib/starmap/render-lifecycle.test.ts`: focused real-renderer tests for frame cancellation, idempotent resume, state preservation, and frozen logical time. +- `web/src/lib/native-shell.ts`: owns the Tauri/browser window-suspension observer beside the existing native-shell adapters. +- `web/src/lib/native-shell.test.ts`: tests native minimized queries, stale async result rejection, fail-open behavior, browser visibility, and cleanup. +- `web/src/lib/StarMap.svelte`: mounts the renderer initially suspended and connects it to the lifecycle observer. +- `web/src/lib/StarMap.test.ts`: proves wrapper wiring and teardown through the real browser visibility seam. +- `CHANGELOG.md`: records the pending power-saving behavior under `Unreleased`. + +--- + +### Task 1: Make the Canvas Renderer Suspendable + +**Files:** +- Create: `web/src/lib/starmap/render-lifecycle.test.ts` +- Modify: `web/src/lib/starmap/starmap.ts:250-326` +- Modify: `web/src/lib/starmap/starmap.ts:517-527` +- Modify: `web/src/lib/starmap/starmap.ts:639-649` +- Modify: `web/src/lib/starmap/starmap.ts:753-776` +- Modify: `web/src/lib/starmap/starmap.ts:908-929` + +**Interfaces:** +- Consumes: the existing `StarMap.mount(host)`, `setModel(...)`, `restoreCamera(...)`, `camera()`, `positions()`, `ticker()`, and `destroy()` renderer seams. +- Produces: `StarMap.suspend(): void` and `StarMap.resume(): void`; both are idempotent and safe before mount. `resume()` schedules only when a live 2D context exists. + +- [ ] **Step 1: Install the locked frontend dependencies if this worktree has none** + +Run from `D:\tmp\stellr-suspend-gpu-when-minimized\web`: + +```powershell +if (!(Test-Path -LiteralPath node_modules)) { vp.exe install --frozen-lockfile } +``` + +Expected: dependencies install without changing `package-lock.json`; if already installed, the command makes no change. + +- [ ] **Step 2: Write focused renderer lifecycle tests** + +Create `src/lib/starmap/render-lifecycle.test.ts` with a real `StarMap`, controlled animation-frame queue, fake `performance.now()`, and a canvas context that accepts the renderer's actual draw calls: + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Ticket } from './model' +import { StarMap } from './starmap' + +const ticket = (status: Ticket['status'] = 'open'): Ticket => ({ + num: 1, + slug: '1', + title: 'Power-saving map', + type: 'task', + status, + blockedBy: [], + parentIssue: null, + frontier: status === 'open', +}) + +function drawingContext(paint: () => void): CanvasRenderingContext2D { + const values: Record = { + createRadialGradient: () => ({ addColorStop: () => undefined }), + fillRect: paint, + measureText: () => ({ width: 40 }), + } + return new Proxy(values, { + get(target, property) { + if (property in target) return target[property] + const method = () => undefined + target[property] = method + return method + }, + set(target, property, value) { + target[property] = value + return true + }, + }) as unknown as CanvasRenderingContext2D +} + +describe('render lifecycle', () => { + let nextFrame: number + let frames: Map + let hostWidth: number + let paint: ReturnType + let resize: ResizeObserverCallback + + beforeEach(() => { + nextFrame = 1 + frames = new Map() + hostWidth = 1000 + paint = vi.fn() + vi.useFakeTimers({ toFake: ['performance'] }) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(drawingContext(paint)) + vi.stubGlobal('ResizeObserver', class { + constructor(callback: ResizeObserverCallback) { + resize = callback + } + observe(): void {} + disconnect(): void {} + }) + vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { + const id = nextFrame++ + frames.set(id, callback) + return id + })) + vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => { + frames.delete(id) + })) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + function mounted(): StarMap { + const host = document.createElement('div') + Object.defineProperty(host, 'clientWidth', { get: () => hostWidth }) + Object.defineProperty(host, 'clientHeight', { value: 700 }) + document.body.appendChild(host) + const renderer = new StarMap() + renderer.mount(host) + return renderer + } + + function step(milliseconds = 16): void { + vi.advanceTimersByTime(milliseconds) + const entry = frames.entries().next().value as [number, FrameRequestCallback] | undefined + if (!entry) throw new Error('expected a scheduled animation frame') + frames.delete(entry[0]) + entry[1](performance.now()) + } + + it('cancels every frame while suspended and resumes exactly one loop', () => { + const renderer = mounted() + expect(frames.size).toBe(1) + + renderer.suspend() + expect(frames.size).toBe(0) + renderer.suspend() + expect(frames.size).toBe(0) + + renderer.resume() + expect(frames.size).toBe(1) + renderer.resume() + expect(frames.size).toBe(1) + + step() + expect(frames.size).toBe(1) + renderer.destroy() + expect(frames.size).toBe(0) + }) + + it('can mount suspended and preserves map state until resume', () => { + const renderer = new StarMap() + renderer.suspend() + const host = document.createElement('div') + Object.defineProperty(host, 'clientWidth', { value: 1000 }) + Object.defineProperty(host, 'clientHeight', { value: 700 }) + document.body.appendChild(host) + renderer.mount(host) + renderer.setModel([ticket()]) + renderer.select(1) + renderer.restoreCamera({ x: 120, y: 180, s: 1.5 }) + + expect(frames.size).toBe(0) + expect(renderer.positions()[1]).toBeDefined() + expect(renderer.camera()).toEqual({ x: 120, y: 180, s: 1.5 }) + + renderer.resume() + expect(frames.size).toBe(1) + expect(renderer.positions()[1]).toBeDefined() + expect(renderer.camera()).toEqual({ x: 120, y: 180, s: 1.5 }) + }) + + it('does not repaint from resize notifications while suspended', () => { + const renderer = mounted() + renderer.suspend() + hostWidth = 800 + + resize([], {} as ResizeObserver) + + expect(paint).not.toHaveBeenCalled() + renderer.resume() + step() + expect(paint).toHaveBeenCalled() + }) + + it('freezes animation and ticker time for the full suspension', () => { + const renderer = mounted() + renderer.setModel([ticket()]) + step() + renderer.suspend() + renderer.setModel([ticket('claimed')]) + const ticker = renderer.ticker() + + vi.advanceTimersByTime(10 * 60 * 1000) + expect(renderer.ticker()).toBe(ticker) + + renderer.resume() + step() + expect(renderer.ticker()).toBe(ticker) + }) +}) +``` + +- [ ] **Step 3: Run the focused test and verify RED** + +Run from `web`: + +```powershell +vp.exe test run src/lib/starmap/render-lifecycle.test.ts +``` + +Expected: FAIL because `StarMap` has no `suspend()` or `resume()` methods. Do not implement until the failure is confirmed to be this missing behavior. + +- [ ] **Step 4: Implement the minimal renderer state machine** + +In `starmap.ts`, add the suspension flag beside `#raf`, prevent a suspended mount from scheduling, expose the two lifecycle methods, use logical time for the ticker, and schedule a successor only after an active frame completes: + +```ts +#clock = 0 +#last = 0 +#raf = 0 +#suspended = false +``` + +```ts +if (this.#ctx && !this.#suspended) { + this.#last = now() + this.#raf = requestAnimationFrame(this.#render) +} +``` + +```ts +suspend(): void { + if (this.#suspended) return + this.#suspended = true + if (this.#raf) cancelAnimationFrame(this.#raf) + this.#raf = 0 +} + +resume(): void { + if (!this.#suspended) return + this.#suspended = false + this.#last = now() + if (this.#ctx && !this.#raf) this.#raf = requestAnimationFrame(this.#render) +} +``` + +Change ticker timestamps from wall time to the renderer's logical clock: + +```ts +#tick(msg: string): void { + this.#tickerText = msg + this.#tickerAt = this.#clock +} + +#tickerAlpha(): number { + if (!this.#tickerText) return 0 + const age = this.#clock - this.#tickerAt + if (age < TICKER_HOLD) return 1 + return clamp(1 - (age - TICKER_HOLD) / TICKER_FADE, 0, 1) +} +``` + +Guard the out-of-band resize repaint while retaining its measurement and camera +updates for the first restored frame: + +```ts +if (!this.#suspended) this.#draw() +``` + +Place that condition where `#onResize()` currently calls `this.#draw()`. + +Replace `#render` with this ordering so a callback racing with cancellation cannot restart the loop: + +```ts +#render = (): void => { + this.#raf = 0 + if (this.#suspended || !this.#ctx) return + const t = now() + let dt = t - this.#last + if (dt < 0 || dt > 0.1) dt = 0.016 + this.#last = t + this.#clock += dt + + for (const n of this.#nodes) { + const ph = n.num * 1.7 + n._x = n.x + Math.sin(this.#clock * 0.7 + ph) * 2.4 + n._y = n.y + Math.cos(this.#clock * 0.55 + ph) * 2.4 + if (n.flare > 0) n.flare = Math.max(0, n.flare - dt / 1.1) + } + this.#easeCamera(dt) + this.#draw() + if (!this.#suspended && this.#ctx) this.#raf = requestAnimationFrame(this.#render) +} +``` + +Keep `destroy()`'s existing frame cancellation and context cleanup unchanged. + +- [ ] **Step 5: Run the focused test and existing renderer tests to verify GREEN** + +```powershell +vp.exe test run src/lib/starmap/render-lifecycle.test.ts src/lib/starmap/starmap.test.ts src/lib/starmap/edge-visual.test.ts +``` + +Expected: all focused and existing renderer tests PASS with no warnings. + +- [ ] **Step 6: Commit the renderer lifecycle** + +```powershell +git add web/src/lib/starmap/starmap.ts web/src/lib/starmap/render-lifecycle.test.ts +git commit -m "feat(web): suspend minimized map rendering" +``` + +--- + +### Task 2: Translate Native and Browser Visibility Into Suspension State + +**Files:** +- Create: `web/src/lib/native-shell.test.ts` +- Modify: `web/src/lib/native-shell.ts:1-34` + +**Interfaces:** +- Consumes: `isTauri()` from `@tauri-apps/api/core`; `getCurrentWindow().isMinimized()`, `onResized(...)`, and `onFocusChanged(...)` from `@tauri-apps/api/window`; `document.hidden` and `visibilitychange` in browser mode. +- Produces: `observeWindowSuspension(notify: (suspended: boolean) => void): Promise`. It emits only changed state, rejects stale native queries, fails open with `false`, and always resolves to cleanup. + +- [ ] **Step 1: Write the lifecycle observer tests** + +Create `web/src/lib/native-shell.test.ts`: + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const native = vi.hoisted(() => ({ + active: false, + minimized: vi.fn<() => Promise>(), + onResized: vi.fn(), + onFocusChanged: vi.fn(), + resize: undefined as (() => void) | undefined, + focus: undefined as (() => void) | undefined, + offResize: vi.fn(), + offFocus: vi.fn(), +})) + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), + isTauri: () => native.active, +})) + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ + isMinimized: native.minimized, + onResized: native.onResized, + onFocusChanged: native.onFocusChanged, + }), +})) + +import { observeWindowSuspension } from './native-shell' + +beforeEach(() => { + native.active = false + native.resize = undefined + native.focus = undefined + native.minimized.mockReset() + native.onResized.mockReset().mockImplementation(async (handler: () => void) => { + native.resize = handler + return native.offResize + }) + native.onFocusChanged.mockReset().mockImplementation(async (handler: () => void) => { + native.focus = handler + return native.offFocus + }) + native.offResize.mockReset() + native.offFocus.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('window rendering suspension', () => { + it('observes browser document visibility and cleans up', async () => { + let hidden = false + vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden) + const states: boolean[] = [] + const stop = await observeWindowSuspension((state) => states.push(state)) + + hidden = true + document.dispatchEvent(new Event('visibilitychange')) + document.dispatchEvent(new Event('visibilitychange')) + expect(states).toEqual([false, true]) + + stop() + hidden = false + document.dispatchEvent(new Event('visibilitychange')) + expect(states).toEqual([false, true]) + }) + + it('queries native minimized state on startup, resize, and focus changes', async () => { + native.active = true + native.minimized.mockResolvedValueOnce(true).mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const states: boolean[] = [] + const stop = await observeWindowSuspension((state) => states.push(state)) + expect(states).toEqual([true]) + + native.resize?.() + await vi.waitFor(() => expect(states).toEqual([true, false])) + native.focus?.() + await vi.waitFor(() => expect(states).toEqual([true, false, true])) + + stop() + expect(native.offResize).toHaveBeenCalledOnce() + expect(native.offFocus).toHaveBeenCalledOnce() + }) + + it('discards an older native query that resolves after a newer query', async () => { + native.active = true + native.minimized.mockResolvedValueOnce(false) + const states: boolean[] = [] + await observeWindowSuspension((state) => states.push(state)) + + let resolveOld!: (value: boolean) => void + let resolveNew!: (value: boolean) => void + native.minimized + .mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve })) + .mockReturnValueOnce(new Promise((resolve) => { resolveNew = resolve })) + native.resize?.() + native.focus?.() + resolveNew(false) + await Promise.resolve() + resolveOld(true) + await Promise.resolve() + + expect(states).toEqual([false]) + }) + + it('fails open when native minimized state cannot be queried', async () => { + native.active = true + native.minimized.mockRejectedValue(new Error('window state unavailable')) + const states: boolean[] = [] + + await observeWindowSuspension((state) => states.push(state)) + + expect(states).toEqual([false]) + }) + + it('cleans up and fails open when native listener setup fails', async () => { + native.active = true + native.onFocusChanged.mockRejectedValueOnce(new Error('focus listener unavailable')) + const states: boolean[] = [] + + const stop = await observeWindowSuspension((state) => states.push(state)) + + expect(states).toEqual([false]) + expect(native.offResize).toHaveBeenCalledOnce() + expect(() => stop()).not.toThrow() + }) +}) +``` + +- [ ] **Step 2: Run the observer test and verify RED** + +Run from `web`: + +```powershell +vp.exe test run src/lib/native-shell.test.ts +``` + +Expected: FAIL because `observeWindowSuspension` is not exported. + +- [ ] **Step 3: Implement the native/browser observer** + +Add imports and the observer to `native-shell.ts` without changing the existing theme, directory, or external-link functions: + +```ts +import type { UnlistenFn } from '@tauri-apps/api/event' +import { getCurrentWindow } from '@tauri-apps/api/window' + +export async function observeWindowSuspension( + notify: (suspended: boolean) => void, +): Promise { + if (!isTauri()) { + let lastState: boolean | undefined + const publish = () => { + if (lastState === document.hidden) return + lastState = document.hidden + notify(lastState) + } + document.addEventListener('visibilitychange', publish) + publish() + return () => document.removeEventListener('visibilitychange', publish) + } + + const window = getCurrentWindow() + const unlisteners: UnlistenFn[] = [] + let disposed = false + let revision = 0 + let lastState: boolean | undefined + const publish = (state: boolean) => { + if (lastState === state) return + lastState = state + notify(state) + } + const refresh = async () => { + const request = ++revision + let minimized = false + try { + minimized = await window.isMinimized() + } catch { + minimized = false + } + if (!disposed && request === revision) publish(minimized) + } + + try { + unlisteners.push(await window.onResized(() => { void refresh() })) + unlisteners.push(await window.onFocusChanged(() => { void refresh() })) + await refresh() + } catch { + disposed = true + revision++ + for (const unlisten of unlisteners) unlisten() + publish(false) + return () => undefined + } + + return () => { + disposed = true + revision++ + for (const unlisten of unlisteners) unlisten() + } +} +``` + +- [ ] **Step 4: Run native-shell and related frontend tests to verify GREEN** + +```powershell +vp.exe test run src/lib/native-shell.test.ts src/lib/native-route.test.ts src/lib/AppearanceMenu.test.ts +``` + +Expected: all tests PASS, including cleanup, stale-result, and fail-open cases. + +- [ ] **Step 5: Commit the lifecycle observer** + +```powershell +git add web/src/lib/native-shell.ts web/src/lib/native-shell.test.ts +git commit -m "feat(web): observe minimized window state" +``` + +--- + +### Task 3: Wire Suspension Into the Svelte Wrapper and Complete Validation + +**Files:** +- Modify: `web/src/lib/StarMap.test.ts:1-184` +- Modify: `web/src/lib/StarMap.svelte:1-57` +- Modify: `CHANGELOG.md:3-6` + +**Interfaces:** +- Consumes: `observeWindowSuspension(...)`, `StarMap.suspend()`, `StarMap.resume()`, and `StarMap.destroy()` from Tasks 1 and 2. +- Produces: a wrapper that never starts a native render loop before initial minimized state is known, applies every lifecycle notification to the same renderer instance, and unregisters both observer and renderer resources on unmount. + +- [ ] **Step 1: Write the failing wrapper lifecycle test** + +Add this asynchronous test inside `describe('StarMap wrapper', ...)` in `StarMap.test.ts`: + +```ts +it('suspends rendering with document visibility and removes the observer on unmount', async () => { + let hidden = false + vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden) + const suspend = vi.spyOn(Renderer.prototype, 'suspend') + const resume = vi.spyOn(Renderer.prototype, 'resume') + const destroy = vi.spyOn(Renderer.prototype, 'destroy') + const target = document.createElement('div') + document.body.appendChild(target) + const component = mount(StarMap, { target, props: { space: space(42) } }) + flushSync() + await Promise.resolve() + + expect(suspend).toHaveBeenCalledOnce() + expect(resume).toHaveBeenCalledOnce() + + hidden = true + document.dispatchEvent(new Event('visibilitychange')) + expect(suspend).toHaveBeenCalledTimes(2) + + hidden = false + document.dispatchEvent(new Event('visibilitychange')) + expect(resume).toHaveBeenCalledTimes(2) + + await unmount(component) + hidden = true + document.dispatchEvent(new Event('visibilitychange')) + expect(suspend).toHaveBeenCalledTimes(2) + expect(destroy).toHaveBeenCalledOnce() +}) +``` + +Do not add this component to the shared `mounted` array because the test unmounts it explicitly before verifying listener cleanup. + +- [ ] **Step 2: Run the wrapper test and verify RED** + +```powershell +vp.exe test run src/lib/StarMap.test.ts -t "suspends rendering with document visibility" +``` + +Expected: FAIL because the wrapper does not call `suspend()` or `resume()` and has no visibility observer. + +- [ ] **Step 3: Connect the wrapper to the lifecycle seam** + +Import `observeWindowSuspension` and replace the current `onMount` body in `StarMap.svelte` with lifecycle-safe setup and cleanup: + +```ts +import { observeWindowSuspension } from './native-shell' +``` + +```ts +onMount(() => { + let disposed = false + let stopObserving: (() => void) | undefined + const activeRenderer = new Renderer() + renderer = activeRenderer + activeRenderer.suspend() + const background = getComputedStyle(host).getPropertyValue('--map-background').trim() + activeRenderer.setBackground(background) + activeRenderer.mount(host) + activeRenderer.onSelect((issueNumber) => { + if (issueNumber !== null && issueNumber !== selectedIssue) select?.(issueNumber) + }) + + void observeWindowSuspension((suspended) => { + if (disposed) return + if (suspended) activeRenderer.suspend() + else activeRenderer.resume() + }).then((unlisten) => { + if (disposed) unlisten() + else stopObserving = unlisten + }) + + return () => { + disposed = true + stopObserving?.() + activeRenderer.destroy() + if (renderer === activeRenderer) renderer = undefined + } +}) +``` + +- [ ] **Step 4: Add the append-only release note** + +Add this newest bullet directly below `## Unreleased` in `CHANGELOG.md`: + +```markdown +- Suspended star-map GPU rendering while the app is minimized or its browser + document is hidden, while retaining five-minute native background polling. +``` + +- [ ] **Step 5: Run the wrapper test and complete frontend verification** + +Run from `web`: + +```powershell +vp.exe test run src/lib/StarMap.test.ts -t "suspends rendering with document visibility" +vp.exe test run +vp.exe run check +vp.exe build +``` + +Expected: the focused test passes; the full frontend suite passes; Svelte check reports zero errors and warnings; the production build succeeds. + +- [ ] **Step 6: Complete native Windows Rust verification** + +Run from the worktree root after `web\dist` exists: + +```powershell +cargo.exe fmt --all -- --check +cargo.exe clippy --workspace --all-targets --locked -- -D warnings +cargo.exe test --workspace --locked -- --test-threads=1 +``` + +Expected: formatting passes, Clippy emits no warnings, and every locked workspace test passes serially. The existing focus-aware polling tests remain green, proving the 30-second/five-minute policy was not changed. + +- [ ] **Step 7: Inspect the final diff and commit the wrapper and release note** + +```powershell +git diff --check +git status --short +git diff -- web/src/lib/StarMap.svelte web/src/lib/StarMap.test.ts CHANGELOG.md +git add web/src/lib/StarMap.svelte web/src/lib/StarMap.test.ts CHANGELOG.md +git commit -m "feat(web): pause rendering while minimized" +``` + +Expected: only the scoped feature files and already committed plan/spec files differ from `main`; the worktree is clean after the commit. + +--- + +## Completion Evidence + +Before claiming completion, record: + +- the RED failure for each of the three focused test files before production changes; +- the focused GREEN commands after each task; +- the final frontend test count and Svelte check/build results; +- native `cargo fmt`, warnings-denied Clippy, and serialized locked workspace-test results; +- `git status --short --branch` and the final commit list on `codex/suspend-gpu-when-minimized`. + +Do not claim measured zero GPU utilization without a real packaged Windows-process measurement. The automated acceptance claim is narrower and exact: no canvas animation frame remains scheduled while minimized/hidden, and one loop resumes afterward. From baa9902b335f9af38c31e814f8f83c14117db372 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:28:47 -0500 Subject: [PATCH 3/7] docs: use project test runner in GPU plan --- .../2026-08-09-suspend-gpu-when-minimized.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-08-09-suspend-gpu-when-minimized.md b/docs/superpowers/plans/2026-08-09-suspend-gpu-when-minimized.md index 8534d13..28dfee9 100644 --- a/docs/superpowers/plans/2026-08-09-suspend-gpu-when-minimized.md +++ b/docs/superpowers/plans/2026-08-09-suspend-gpu-when-minimized.md @@ -233,7 +233,7 @@ describe('render lifecycle', () => { Run from `web`: ```powershell -vp.exe test run src/lib/starmap/render-lifecycle.test.ts +vpr.exe test -- src/lib/starmap/render-lifecycle.test.ts ``` Expected: FAIL because `StarMap` has no `suspend()` or `resume()` methods. Do not implement until the failure is confirmed to be this missing behavior. @@ -326,7 +326,7 @@ Keep `destroy()`'s existing frame cancellation and context cleanup unchanged. - [ ] **Step 5: Run the focused test and existing renderer tests to verify GREEN** ```powershell -vp.exe test run src/lib/starmap/render-lifecycle.test.ts src/lib/starmap/starmap.test.ts src/lib/starmap/edge-visual.test.ts +vpr.exe test -- src/lib/starmap/render-lifecycle.test.ts src/lib/starmap/starmap.test.ts src/lib/starmap/edge-visual.test.ts ``` Expected: all focused and existing renderer tests PASS with no warnings. @@ -489,7 +489,7 @@ describe('window rendering suspension', () => { Run from `web`: ```powershell -vp.exe test run src/lib/native-shell.test.ts +vpr.exe test -- src/lib/native-shell.test.ts ``` Expected: FAIL because `observeWindowSuspension` is not exported. @@ -561,7 +561,7 @@ export async function observeWindowSuspension( - [ ] **Step 4: Run native-shell and related frontend tests to verify GREEN** ```powershell -vp.exe test run src/lib/native-shell.test.ts src/lib/native-route.test.ts src/lib/AppearanceMenu.test.ts +vpr.exe test -- src/lib/native-shell.test.ts src/lib/native-route.test.ts src/lib/AppearanceMenu.test.ts ``` Expected: all tests PASS, including cleanup, stale-result, and fail-open cases. @@ -627,7 +627,7 @@ Do not add this component to the shared `mounted` array because the test unmount - [ ] **Step 2: Run the wrapper test and verify RED** ```powershell -vp.exe test run src/lib/StarMap.test.ts -t "suspends rendering with document visibility" +vpr.exe test -- src/lib/StarMap.test.ts -t "suspends rendering with document visibility" ``` Expected: FAIL because the wrapper does not call `suspend()` or `resume()` and has no visibility observer. @@ -686,10 +686,10 @@ Add this newest bullet directly below `## Unreleased` in `CHANGELOG.md`: Run from `web`: ```powershell -vp.exe test run src/lib/StarMap.test.ts -t "suspends rendering with document visibility" -vp.exe test run -vp.exe run check -vp.exe build +vpr.exe test -- src/lib/StarMap.test.ts -t "suspends rendering with document visibility" +vpr.exe test +vpr.exe check +vpr.exe build ``` Expected: the focused test passes; the full frontend suite passes; Svelte check reports zero errors and warnings; the production build succeeds. From f79df73e74e2009b5d22dc54bd0ba75c90e93cab Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:32:50 -0500 Subject: [PATCH 4/7] feat(web): suspend minimized map rendering --- web/src/lib/starmap/render-lifecycle.test.ts | 170 +++++++++++++++++++ web/src/lib/starmap/starmap.ts | 38 ++++- 2 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 web/src/lib/starmap/render-lifecycle.test.ts diff --git a/web/src/lib/starmap/render-lifecycle.test.ts b/web/src/lib/starmap/render-lifecycle.test.ts new file mode 100644 index 0000000..6c9a513 --- /dev/null +++ b/web/src/lib/starmap/render-lifecycle.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Ticket } from './model' +import { StarMap } from './starmap' + +const ticket = (status: Ticket['status'] = 'open'): Ticket => ({ + num: 1, + slug: '1', + title: 'Power-saving map', + type: 'task', + status, + blockedBy: [], + parentIssue: null, + frontier: status === 'open', +}) + +function drawingContext(paint: () => void): CanvasRenderingContext2D { + const values: Record = { + createRadialGradient: () => ({ addColorStop: () => undefined }), + fillRect: paint, + measureText: () => ({ width: 40 }), + } + return new Proxy(values, { + get(target, property) { + if (property in target) return target[property] + const method = () => undefined + target[property] = method + return method + }, + set(target, property, value) { + target[property] = value + return true + }, + }) as unknown as CanvasRenderingContext2D +} + +describe('render lifecycle', () => { + let nextFrame: number + let frames: Map + let hostWidth: number + let paint: ReturnType + let resize: ResizeObserverCallback + let renderers: StarMap[] + + beforeEach(() => { + nextFrame = 1 + frames = new Map() + hostWidth = 1000 + paint = vi.fn() + renderers = [] + vi.useFakeTimers({ toFake: ['performance'] }) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(drawingContext(paint)) + vi.stubGlobal('ResizeObserver', class { + constructor(callback: ResizeObserverCallback) { + resize = callback + } + observe(): void {} + disconnect(): void {} + }) + vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { + const id = nextFrame++ + frames.set(id, callback) + return id + })) + vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => { + frames.delete(id) + })) + }) + + afterEach(() => { + for (const renderer of renderers) renderer.destroy() + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + document.body.innerHTML = '' + }) + + function track(renderer: StarMap): StarMap { + renderers.push(renderer) + return renderer + } + + function mounted(): StarMap { + const host = document.createElement('div') + Object.defineProperty(host, 'clientWidth', { get: () => hostWidth }) + Object.defineProperty(host, 'clientHeight', { value: 700 }) + document.body.appendChild(host) + const renderer = track(new StarMap()) + renderer.mount(host) + return renderer + } + + function step(milliseconds = 16): void { + vi.advanceTimersByTime(milliseconds) + const entry = frames.entries().next().value as [number, FrameRequestCallback] | undefined + if (!entry) throw new Error('expected a scheduled animation frame') + frames.delete(entry[0]) + entry[1](performance.now()) + } + + it('cancels every frame while suspended and resumes exactly one loop', () => { + const renderer = mounted() + expect(frames.size).toBe(1) + + renderer.suspend() + expect(frames.size).toBe(0) + renderer.suspend() + expect(frames.size).toBe(0) + + renderer.resume() + expect(frames.size).toBe(1) + renderer.resume() + expect(frames.size).toBe(1) + + step() + expect(frames.size).toBe(1) + renderer.destroy() + expect(frames.size).toBe(0) + }) + + it('can mount suspended and preserves map state until resume', () => { + const renderer = track(new StarMap()) + renderer.suspend() + const host = document.createElement('div') + Object.defineProperty(host, 'clientWidth', { value: 1000 }) + Object.defineProperty(host, 'clientHeight', { value: 700 }) + document.body.appendChild(host) + renderer.mount(host) + renderer.setModel([ticket()]) + renderer.select(1) + renderer.restoreCamera({ x: 120, y: 180, s: 1.5 }) + + expect(frames.size).toBe(0) + expect(renderer.positions()[1]).toBeDefined() + expect(renderer.camera()).toEqual({ x: 120, y: 180, s: 1.5 }) + + renderer.resume() + expect(frames.size).toBe(1) + expect(renderer.positions()[1]).toBeDefined() + expect(renderer.camera()).toEqual({ x: 120, y: 180, s: 1.5 }) + }) + + it('does not repaint from resize notifications while suspended', () => { + const renderer = mounted() + renderer.suspend() + hostWidth = 800 + + resize([], {} as ResizeObserver) + + expect(paint).not.toHaveBeenCalled() + renderer.resume() + step() + expect(paint).toHaveBeenCalled() + }) + + it('freezes animation and ticker time for the full suspension', () => { + const renderer = mounted() + renderer.setModel([ticket()]) + step() + renderer.suspend() + renderer.setModel([ticket('claimed')]) + const ticker = renderer.ticker() + + vi.advanceTimersByTime(10 * 60 * 1000) + expect(renderer.ticker()).toBe(ticker) + + renderer.resume() + step() + expect(renderer.ticker()).toBe(ticker) + }) +}) diff --git a/web/src/lib/starmap/starmap.ts b/web/src/lib/starmap/starmap.ts index 30b41fe..5ac1fb3 100644 --- a/web/src/lib/starmap/starmap.ts +++ b/web/src/lib/starmap/starmap.ts @@ -270,8 +270,11 @@ export class StarMap { // actually leaves free, in either docking. #insets = { top: 16, right: 16, bottom: 16, left: 16 } #clock = 0 + #pausedDuration = 0 + #pauseStarted: number | null = null #last = 0 #raf = 0 + #suspended = false // The live WebKit pinch, if one is in flight — it also mutes the wheel path, // so a browser that reports a pinch both ways never zooms twice for one gesture. // Stamped, so a gesture that never reports its end (interrupted, or swallowed @@ -320,7 +323,7 @@ export class StarMap { this.#bindPointer(canvas) this.#refit(true) - if (this.#ctx) { + if (this.#ctx && !this.#suspended) { this.#last = now() this.#raf = requestAnimationFrame(this.#render) } @@ -514,6 +517,26 @@ export class StarMap { this.#bg = color || DEFAULT_BG } + suspend(): void { + if (this.#suspended) return + this.#suspended = true + if (this.#ctx) this.#pauseStarted = now() + if (this.#raf) cancelAnimationFrame(this.#raf) + this.#raf = 0 + } + + resume(): void { + if (!this.#suspended) return + this.#suspended = false + const t = now() + if (this.#pauseStarted !== null) { + this.#pausedDuration += Math.max(0, t - this.#pauseStarted) + this.#pauseStarted = null + } + this.#last = t + if (this.#ctx && !this.#raf) this.#raf = requestAnimationFrame(this.#render) + } + destroy(): void { if (this.#raf) cancelAnimationFrame(this.#raf) this.#raf = 0 @@ -638,12 +661,12 @@ export class StarMap { #tick(msg: string): void { this.#tickerText = msg - this.#tickerAt = now() + this.#tickerAt = this.#clock } #tickerAlpha(): number { if (!this.#tickerText) return 0 - const age = now() - this.#tickerAt + const age = this.#clock - this.#tickerAt if (age < TICKER_HOLD) return 1 return clamp(1 - (age - TICKER_HOLD) / TICKER_FADE, 0, 1) } @@ -772,7 +795,7 @@ export class StarMap { // The label cache is keyed on the camera pose, which the pin above already // moved — so the solve re-runs on its own, and #labelEpoch is left to mean // only what it says it means: the text or the colours moved. - this.#draw() + if (!this.#suspended) this.#draw() } // Fit the whole constellation into the viewport. `snap` sets the camera @@ -910,13 +933,13 @@ export class StarMap { // paint it. The two halves are split because only this one may move the clock — // see #draw. #render = (): void => { - this.#raf = requestAnimationFrame(this.#render) - if (!this.#ctx) return + this.#raf = 0 + if (this.#suspended || !this.#ctx) return const t = now() let dt = t - this.#last if (dt < 0 || dt > 0.1) dt = 0.016 this.#last = t - this.#clock = t + this.#clock = t - this.#pausedDuration for (const n of this.#nodes) { const ph = n.num * 1.7 @@ -926,6 +949,7 @@ export class StarMap { } this.#easeCamera(dt) this.#draw() + if (!this.#suspended && this.#ctx) this.#raf = requestAnimationFrame(this.#render) } // Paint the state as it currently stands, advancing nothing. Holding no time From 9c24f2d7ba8480a8f859c5efbcb9c120a3629342 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:33:13 -0500 Subject: [PATCH 5/7] docs: preserve renderer phase in GPU plan --- .../2026-08-09-suspend-gpu-when-minimized.md | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-08-09-suspend-gpu-when-minimized.md b/docs/superpowers/plans/2026-08-09-suspend-gpu-when-minimized.md index 28dfee9..7867aa0 100644 --- a/docs/superpowers/plans/2026-08-09-suspend-gpu-when-minimized.md +++ b/docs/superpowers/plans/2026-08-09-suspend-gpu-when-minimized.md @@ -233,7 +233,7 @@ describe('render lifecycle', () => { Run from `web`: ```powershell -vpr.exe test -- src/lib/starmap/render-lifecycle.test.ts +vpr.exe test src/lib/starmap/render-lifecycle.test.ts ``` Expected: FAIL because `StarMap` has no `suspend()` or `resume()` methods. Do not implement until the failure is confirmed to be this missing behavior. @@ -244,6 +244,8 @@ In `starmap.ts`, add the suspension flag beside `#raf`, prevent a suspended moun ```ts #clock = 0 +#pausedDuration = 0 +#pauseStarted: number | null = null #last = 0 #raf = 0 #suspended = false @@ -260,6 +262,7 @@ if (this.#ctx && !this.#suspended) { suspend(): void { if (this.#suspended) return this.#suspended = true + if (this.#ctx) this.#pauseStarted = now() if (this.#raf) cancelAnimationFrame(this.#raf) this.#raf = 0 } @@ -267,7 +270,12 @@ suspend(): void { resume(): void { if (!this.#suspended) return this.#suspended = false - this.#last = now() + const t = now() + if (this.#pauseStarted !== null) { + this.#pausedDuration += Math.max(0, t - this.#pauseStarted) + this.#pauseStarted = null + } + this.#last = t if (this.#ctx && !this.#raf) this.#raf = requestAnimationFrame(this.#render) } ``` @@ -307,7 +315,7 @@ Replace `#render` with this ordering so a callback racing with cancellation cann let dt = t - this.#last if (dt < 0 || dt > 0.1) dt = 0.016 this.#last = t - this.#clock += dt + this.#clock = t - this.#pausedDuration for (const n of this.#nodes) { const ph = n.num * 1.7 @@ -326,7 +334,7 @@ Keep `destroy()`'s existing frame cancellation and context cleanup unchanged. - [ ] **Step 5: Run the focused test and existing renderer tests to verify GREEN** ```powershell -vpr.exe test -- src/lib/starmap/render-lifecycle.test.ts src/lib/starmap/starmap.test.ts src/lib/starmap/edge-visual.test.ts +vpr.exe test src/lib/starmap/render-lifecycle.test.ts src/lib/starmap/starmap.test.ts src/lib/starmap/edge-visual.test.ts ``` Expected: all focused and existing renderer tests PASS with no warnings. @@ -489,7 +497,7 @@ describe('window rendering suspension', () => { Run from `web`: ```powershell -vpr.exe test -- src/lib/native-shell.test.ts +vpr.exe test src/lib/native-shell.test.ts ``` Expected: FAIL because `observeWindowSuspension` is not exported. @@ -561,7 +569,7 @@ export async function observeWindowSuspension( - [ ] **Step 4: Run native-shell and related frontend tests to verify GREEN** ```powershell -vpr.exe test -- src/lib/native-shell.test.ts src/lib/native-route.test.ts src/lib/AppearanceMenu.test.ts +vpr.exe test src/lib/native-shell.test.ts src/lib/native-route.test.ts src/lib/AppearanceMenu.test.ts ``` Expected: all tests PASS, including cleanup, stale-result, and fail-open cases. @@ -627,7 +635,7 @@ Do not add this component to the shared `mounted` array because the test unmount - [ ] **Step 2: Run the wrapper test and verify RED** ```powershell -vpr.exe test -- src/lib/StarMap.test.ts -t "suspends rendering with document visibility" +vpr.exe test src/lib/StarMap.test.ts -t "suspends rendering with document visibility" ``` Expected: FAIL because the wrapper does not call `suspend()` or `resume()` and has no visibility observer. @@ -686,7 +694,7 @@ Add this newest bullet directly below `## Unreleased` in `CHANGELOG.md`: Run from `web`: ```powershell -vpr.exe test -- src/lib/StarMap.test.ts -t "suspends rendering with document visibility" +vpr.exe test src/lib/StarMap.test.ts -t "suspends rendering with document visibility" vpr.exe test vpr.exe check vpr.exe build From 241f6bfd52a819dd9ef12b7bf36927f853b19538 Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:34:40 -0500 Subject: [PATCH 6/7] feat(web): observe minimized window state --- web/src/lib/native-shell.test.ts | 129 +++++++++++++++++++++++++++++++ web/src/lib/native-shell.ts | 57 ++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 web/src/lib/native-shell.test.ts diff --git a/web/src/lib/native-shell.test.ts b/web/src/lib/native-shell.test.ts new file mode 100644 index 0000000..4a1fe4c --- /dev/null +++ b/web/src/lib/native-shell.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const native = vi.hoisted(() => ({ + active: false, + minimized: vi.fn<() => Promise>(), + onResized: vi.fn(), + onFocusChanged: vi.fn(), + resize: undefined as (() => void) | undefined, + focus: undefined as (() => void) | undefined, + offResize: vi.fn(), + offFocus: vi.fn(), +})) + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), + isTauri: () => native.active, +})) + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ + isMinimized: native.minimized, + onResized: native.onResized, + onFocusChanged: native.onFocusChanged, + }), +})) + +import { observeWindowSuspension } from './native-shell' + +beforeEach(() => { + native.active = false + native.resize = undefined + native.focus = undefined + native.minimized.mockReset() + native.onResized.mockReset().mockImplementation(async (handler: () => void) => { + native.resize = handler + return native.offResize + }) + native.onFocusChanged.mockReset().mockImplementation(async (handler: () => void) => { + native.focus = handler + return native.offFocus + }) + native.offResize.mockReset() + native.offFocus.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('window rendering suspension', () => { + it('observes browser document visibility and cleans up', async () => { + let hidden = false + vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden) + const states: boolean[] = [] + const stop = await observeWindowSuspension((state) => states.push(state)) + + hidden = true + document.dispatchEvent(new Event('visibilitychange')) + document.dispatchEvent(new Event('visibilitychange')) + expect(states).toEqual([false, true]) + + stop() + hidden = false + document.dispatchEvent(new Event('visibilitychange')) + expect(states).toEqual([false, true]) + }) + + it('queries native minimized state on startup, resize, and focus changes', async () => { + native.active = true + native.minimized.mockResolvedValueOnce(true).mockResolvedValueOnce(false).mockResolvedValueOnce(true) + const states: boolean[] = [] + const stop = await observeWindowSuspension((state) => states.push(state)) + expect(states).toEqual([true]) + + native.resize?.() + await vi.waitFor(() => expect(states).toEqual([true, false])) + native.focus?.() + await vi.waitFor(() => expect(states).toEqual([true, false, true])) + + stop() + expect(native.offResize).toHaveBeenCalledOnce() + expect(native.offFocus).toHaveBeenCalledOnce() + }) + + it('discards an older native query that resolves after a newer query', async () => { + native.active = true + native.minimized.mockResolvedValueOnce(false) + const states: boolean[] = [] + const stop = await observeWindowSuspension((state) => states.push(state)) + + let resolveOld!: (value: boolean) => void + let resolveNew!: (value: boolean) => void + native.minimized + .mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve })) + .mockReturnValueOnce(new Promise((resolve) => { resolveNew = resolve })) + native.resize?.() + native.focus?.() + resolveNew(false) + await Promise.resolve() + resolveOld(true) + await Promise.resolve() + + expect(states).toEqual([false]) + stop() + }) + + it('fails open when native minimized state cannot be queried', async () => { + native.active = true + native.minimized.mockRejectedValue(new Error('window state unavailable')) + const states: boolean[] = [] + + const stop = await observeWindowSuspension((state) => states.push(state)) + + expect(states).toEqual([false]) + stop() + }) + + it('cleans up and fails open when native listener setup fails', async () => { + native.active = true + native.onFocusChanged.mockRejectedValueOnce(new Error('focus listener unavailable')) + const states: boolean[] = [] + + const stop = await observeWindowSuspension((state) => states.push(state)) + + expect(states).toEqual([false]) + expect(native.offResize).toHaveBeenCalledOnce() + expect(() => stop()).not.toThrow() + }) +}) diff --git a/web/src/lib/native-shell.ts b/web/src/lib/native-shell.ts index ca9be78..7ecfb71 100644 --- a/web/src/lib/native-shell.ts +++ b/web/src/lib/native-shell.ts @@ -1,4 +1,6 @@ import { invoke, isTauri } from '@tauri-apps/api/core' +import type { UnlistenFn } from '@tauri-apps/api/event' +import { getCurrentWindow } from '@tauri-apps/api/window' export type ThemePreference = 'system' | 'light' | 'dark' @@ -12,6 +14,61 @@ export function hasNativeShell(): boolean { return isTauri() } +export async function observeWindowSuspension( + notify: (suspended: boolean) => void, +): Promise { + if (!isTauri()) { + let lastState: boolean | undefined + const publish = () => { + if (lastState === document.hidden) return + lastState = document.hidden + notify(lastState) + } + document.addEventListener('visibilitychange', publish) + publish() + return () => document.removeEventListener('visibilitychange', publish) + } + + const window = getCurrentWindow() + const unlisteners: UnlistenFn[] = [] + let disposed = false + let revision = 0 + let lastState: boolean | undefined + const publish = (state: boolean) => { + if (lastState === state) return + lastState = state + notify(state) + } + const refresh = async () => { + const request = ++revision + let minimized = false + try { + minimized = await window.isMinimized() + } catch { + minimized = false + } + if (!disposed && request === revision) publish(minimized) + } + + try { + unlisteners.push(await window.onResized(() => { void refresh() })) + unlisteners.push(await window.onFocusChanged(() => { void refresh() })) + await refresh() + } catch { + disposed = true + revision++ + for (const unlisten of unlisteners) unlisten() + publish(false) + return () => undefined + } + + return () => { + disposed = true + revision++ + for (const unlisten of unlisteners) unlisten() + } +} + export function getThemePreference(): Promise { if (isTauri()) return invoke('get_theme_preference') return Promise.resolve(validTheme(window.localStorage.getItem(browserThemeKey))) From e33afc3cb2096e91323d4f8d001a6e9796d8dffd Mon Sep 17 00:00:00 2001 From: Teloverge <57448196+teloverge@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:45:57 -0500 Subject: [PATCH 7/7] feat(web): pause rendering while minimized --- CHANGELOG.md | 2 ++ web/src/lib/StarMap.svelte | 28 ++++++++++++++---- web/src/lib/StarMap.test.ts | 30 ++++++++++++++++++++ web/src/lib/native-shell.test.ts | 26 +++++++++++++---- web/src/lib/native-shell.ts | 8 +++++- web/src/lib/starmap/render-lifecycle.test.ts | 4 +-- 6 files changed, 84 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6b580..3b3559f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Suspended star-map GPU rendering while the app is minimized or its browser + document is hidden, while retaining five-minute native background polling. - Kept ready subissue labels visible and clear of their emphasis rings while the star-map camera eases. - Declared npm 12.0.2 as the web workspace's development package manager and diff --git a/web/src/lib/StarMap.svelte b/web/src/lib/StarMap.svelte index 474dfa1..022ef81 100644 --- a/web/src/lib/StarMap.svelte +++ b/web/src/lib/StarMap.svelte @@ -1,6 +1,7 @@ diff --git a/web/src/lib/StarMap.test.ts b/web/src/lib/StarMap.test.ts index 8b17a27..261401b 100644 --- a/web/src/lib/StarMap.test.ts +++ b/web/src/lib/StarMap.test.ts @@ -181,4 +181,34 @@ describe('StarMap wrapper', () => { expect(selected).toEqual([42]) }) + + it('suspends rendering with document visibility and removes the observer on unmount', async () => { + let hidden = false + vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden) + const suspend = vi.spyOn(Renderer.prototype, 'suspend') + const resume = vi.spyOn(Renderer.prototype, 'resume') + const destroy = vi.spyOn(Renderer.prototype, 'destroy') + const target = document.createElement('div') + document.body.appendChild(target) + const component = mount(StarMap, { target, props: { space: space(42) } }) + flushSync() + await Promise.resolve() + + expect(suspend).toHaveBeenCalledOnce() + expect(resume).toHaveBeenCalledOnce() + + hidden = true + document.dispatchEvent(new Event('visibilitychange')) + expect(suspend).toHaveBeenCalledTimes(2) + + hidden = false + document.dispatchEvent(new Event('visibilitychange')) + expect(resume).toHaveBeenCalledTimes(2) + + await unmount(component) + hidden = true + document.dispatchEvent(new Event('visibilitychange')) + expect(suspend).toHaveBeenCalledTimes(2) + expect(destroy).toHaveBeenCalledOnce() + }) }) diff --git a/web/src/lib/native-shell.test.ts b/web/src/lib/native-shell.test.ts index 4a1fe4c..ab0b749 100644 --- a/web/src/lib/native-shell.test.ts +++ b/web/src/lib/native-shell.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const native = vi.hoisted(() => ({ active: false, + windowError: undefined as Error | undefined, minimized: vi.fn<() => Promise>(), onResized: vi.fn(), onFocusChanged: vi.fn(), @@ -17,17 +18,21 @@ vi.mock('@tauri-apps/api/core', () => ({ })) vi.mock('@tauri-apps/api/window', () => ({ - getCurrentWindow: () => ({ - isMinimized: native.minimized, - onResized: native.onResized, - onFocusChanged: native.onFocusChanged, - }), + getCurrentWindow: () => { + if (native.windowError) throw native.windowError + return { + isMinimized: native.minimized, + onResized: native.onResized, + onFocusChanged: native.onFocusChanged, + } + }, })) import { observeWindowSuspension } from './native-shell' beforeEach(() => { native.active = false + native.windowError = undefined native.resize = undefined native.focus = undefined native.minimized.mockReset() @@ -126,4 +131,15 @@ describe('window rendering suspension', () => { expect(native.offResize).toHaveBeenCalledOnce() expect(() => stop()).not.toThrow() }) + + it('fails open when the current native window is unavailable', async () => { + native.active = true + native.windowError = new Error('window metadata unavailable') + const states: boolean[] = [] + + const stop = await observeWindowSuspension((state) => states.push(state)) + + expect(states).toEqual([false]) + expect(() => stop()).not.toThrow() + }) }) diff --git a/web/src/lib/native-shell.ts b/web/src/lib/native-shell.ts index 7ecfb71..f4b90b8 100644 --- a/web/src/lib/native-shell.ts +++ b/web/src/lib/native-shell.ts @@ -29,7 +29,13 @@ export async function observeWindowSuspension( return () => document.removeEventListener('visibilitychange', publish) } - const window = getCurrentWindow() + let window: ReturnType + try { + window = getCurrentWindow() + } catch { + notify(false) + return () => undefined + } const unlisteners: UnlistenFn[] = [] let disposed = false let revision = 0 diff --git a/web/src/lib/starmap/render-lifecycle.test.ts b/web/src/lib/starmap/render-lifecycle.test.ts index 6c9a513..971cdc9 100644 --- a/web/src/lib/starmap/render-lifecycle.test.ts +++ b/web/src/lib/starmap/render-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import type { Ticket } from './model' import { StarMap } from './starmap' @@ -37,7 +37,7 @@ describe('render lifecycle', () => { let nextFrame: number let frames: Map let hostWidth: number - let paint: ReturnType + let paint: Mock<() => void> let resize: ResizeObserverCallback let renderers: StarMap[]