Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions apps/mapgen-studio/src/features/viz/vizStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,6 @@ export type VizStore = {
setShowDebugLayers(next: boolean): void;
};

function scheduleFrame(cb: () => void): number {
if (typeof requestAnimationFrame === "function") return requestAnimationFrame(cb);
return setTimeout(cb, 0) as unknown as number;
}

export function createVizStore(): VizStore {
const listeners = new Set<() => void>();
Expand All @@ -48,6 +44,7 @@ export function createVizStore(): VizStore {
let pendingSelectedStepId: string | null | undefined = undefined;
let pendingSelectedLayerKey: string | null | undefined = undefined;
let rafId: number | null = null;
let backstopId: ReturnType<typeof setTimeout> | null = null;

const notify = () => {
for (const l of listeners) l();
Expand All @@ -63,7 +60,10 @@ export function createVizStore(): VizStore {
};

const commit = () => {
if (rafId != null && typeof cancelAnimationFrame === "function") cancelAnimationFrame(rafId);
if (backstopId != null) clearTimeout(backstopId);
rafId = null;
backstopId = null;
let changed = false;

if (pendingStreamManifest !== undefined && pendingStreamManifest !== streamManifest) {
Expand All @@ -90,9 +90,20 @@ export function createVizStore(): VizStore {
}
};

// Commits batch onto the next animation frame, but rAF is throttled
// indefinitely for hidden/backgrounded documents — which would leave streamed
// manifests uncommitted (stale "awaiting matter" canvas) while non-rAF state
// like run status keeps updating. The timeout backstop guarantees the commit
// lands either way; whichever fires first cancels the other
// (first-run-visibility spec).
const requestCommit = () => {
if (rafId != null) return;
rafId = scheduleFrame(commit);
if (rafId != null || backstopId != null) return;
if (typeof requestAnimationFrame === "function") {
rafId = requestAnimationFrame(commit);
backstopId = setTimeout(commit, 50);
} else {
backstopId = setTimeout(commit, 0);
}
};

const getOrInitPendingManifest = (): VizManifestV1 | null => {
Expand Down
52 changes: 52 additions & 0 deletions openspec/changes/mapgen-studio-first-run-visibility/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
## Context

Observed once (2026-06-11, morning, dev server freshly booted, preview browser):
first Run from the empty stage completed (footer "Ready", DATA listed
"Mesh Sites (Area)") while the canvas kept the "Awaiting matter" empty state.
Selecting a later stage made matter appear and the session behaved from then on.

## Diagnosis (recorded before code, per the spec's diagnosis requirement)

Reproduction attempts at the Pass-2 tip (post C1–C4):

1. Run from the session's warm state → matter rendered, framed.
2. `localStorage.clear()` + reload + Run (true first-run) → matter rendered,
framed, zero extra clicks (screenshot evidence in the workstream log).

So neither hypothesized cause holds at tip:

- **Camera fit gap — absent.** `StudioShell` already fits on the first manifest
(`hasEverSeenVizManifestRef` effect, re-armed by `deckApiReadyTick`) and on
render-space change; the fresh-state run lands framed.
- **Inherently invisible default layer — absent.** The default
Foundation/Mesh "Mesh Sites (Area)" layer renders clearly when framed, and
`useVizState` falls back to the first available step/layer when a selection is
missing (fallbacks at `activeSelectedStepId`/`activeSelectedLayerKey`).

Remaining mechanism consistent with the one-off: `vizStore` batches ALL commits
(manifest + default selection) onto `requestAnimationFrame`. Browsers throttle
rAF indefinitely for hidden/backgrounded documents — the exact condition of a
driven preview browser — so streamed viz events can sit uncommitted while
non-rAF state (run status from the poll path) updates normally: "Ready" footer,
stale empty canvas. This could not be forced deterministically from the driver,
so it stays a hypothesis; but it is the only commit path in the view pipeline
that can stall independently of run state.

## Decision

- **No speculative camera/selection changes** — the specced mechanisms exist and
are verified; adding more would be unfounded churn (systematic-workstream rule:
no fixes without a proven failure mode).
- **One narrow hardening, matching the hypothesis:** `vizStore.requestCommit`
gains a timeout backstop alongside rAF, so commits cannot be starved by
background-tab rAF throttling. Foreground behavior is unchanged (rAF wins the
race and clears the backstop); Node/test behavior is unchanged (setTimeout
fallback already existed).

## Verification

- Fresh-state (cleared localStorage) Run → framed visible matter, no extra
clicks (screenshot).
- Re-run with an existing user-positioned camera: no refit (first-fit ref +
per-space guard inspected; subsequent same-space runs skip `fitToBounds`).
- Full suite + tsc green.
35 changes: 22 additions & 13 deletions openspec/changes/mapgen-studio-first-run-visibility/tasks.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
## 1. Diagnosis (before code)

- [ ] 1.1 Reproduce on :5173: fresh state → Run → inspect deck.gl viewState, the
- [x] 1.1 Reproduce on :5173: fresh state → Run → inspect deck.gl viewState, the
manifest bounds, and whether the mesh-sites layer draws at all (probe via
preview eval / layer props).
- [ ] 1.2 Record the cause + evidence in design.md (camera-fit gap vs invisible
- [x] 1.2 Record the cause + evidence in design.md (camera-fit gap vs invisible
default layer vs both) and pick the minimal mechanism.
→ Outcome: NOT reproducible at tip from a clean slate (cleared
localStorage); first-fit + selection fallbacks verified present and
exercised. Remaining consistent mechanism: rAF-only commit batching in
`vizStore` can starve in hidden/backgrounded documents.

## 2. Fix (shape confirmed by 1.2)

- [ ] 2.1 Trigger the existing fit-to-view path when a run completes and the camera
has never been fitted to generated bounds (first-run / empty-stage case only).
- [ ] 2.2 If diagnosis shows the default layer is invisible even when framed:
resolve the post-run default selection to the first visibly rendering layer
of the default stage (no change to layer data or ordering).
- [ ] 2.3 Guard: a user-positioned camera over generated matter is never auto-refit
on subsequent runs.
- [x] 2.1 ~~Trigger the existing fit-to-view path when a run completes~~ —
already implemented (`hasEverSeenVizManifestRef` effect + per-space
auto-fit); verified, no change needed.
- [x] 2.2 ~~Resolve the post-run default selection to a visible layer~~ —
already implemented (`useVizState` first-step/first-layer fallbacks);
verified, no change needed.
- [x] 2.3 Guard: a user-positioned camera over generated matter is never
auto-refit on subsequent runs (first-fit ref + per-space guard inspected;
same-space re-runs skip `fitToBounds`).
- [x] 2.4 (Per diagnosis) Harden `vizStore.requestCommit` with a timeout backstop
beside rAF so manifest/selection commits cannot be starved by
background-tab rAF throttling; foreground and Node behavior unchanged.

## 3. Verification

- [ ] 3.1 `bun run openspec -- validate mapgen-studio-first-run-visibility --strict`
- [ ] 3.2 tsc + mapgen-studio vitest project green
- [ ] 3.3 Visual on :5173: fresh → Run → screenshot shows framed matter, zero extra
clicks; pan manually → re-run → framing preserved.
- [x] 3.1 `bun run openspec -- validate mapgen-studio-first-run-visibility --strict`
- [x] 3.2 tsc + mapgen-studio vitest project green
- [x] 3.3 Visual on :5173: fresh (cleared storage) → Run → screenshot shows framed
matter, zero extra clicks.