Skip to content

Release v0.2.5: signal viewer workbench - #272

Merged
neuromechanist merged 15 commits into
mainfrom
staging
Sep 1, 2026
Merged

Release v0.2.5: signal viewer workbench#272
neuromechanist merged 15 commits into
mainfrom
staging

Conversation

@neuromechanist

Copy link
Copy Markdown
Contributor

Promotes staging to production as v0.2.5. test.nemar.org is serving this exact artifact (0.2.5+5c139e98, post-Prepare-release).

Contents

Epic #256 — the signal viewer workbench (PR #270: phases #257 directory-format recording rows, #258 recording navigation, #259 capped background preload, #262 View data button, #263 HED annotation; live-QA rounds #266-#269), plus PR #271 (viewer transfer efficiency: v3-pinned opens with a v2 escape hatch, probe sizing, chunk fetch dedup, cache write-through, Save-Data guard).

Every constituent PR was reviewed (Sonnet panels), all findings fixed pre-merge; #271 additionally went through the full four-agent panel. This release PR gets its own complete panel over the full release diff before merge, per policy.

Verification

  • 1,379 unit tests, lint, typecheck, build green on the staging tip.
  • Live browser verification of the View data flow, navigation, annotation create/edit/save, both themes.
  • Post-promotion checks needed on the real hosts (staging is single-host, website#212): cross-host redirects unaffected (no host.ts/middleware host-dispatch changes in this diff), curl -s https://nemar.org/version.json should report 0.2.5.

Post-merge

release.yml tags v0.2.5, cuts the release, back-merges to staging and reopens -dev0.

neuromechanist and others added 14 commits September 1, 2026 17:11
* Recognize .mefd/.ds dirs as viewer recordings

Directory-keyed recordings (MEF3 .mefd, CTF .ds) render as hybrid rows:
the name is the same eeg preview button a signal file gets, the chevron
still expands member files. Extensionless directory recordings (4D/BTi)
are upgraded at zarr-annotate time from the index's store paths. Dir
recordings get a browse fallback instead of a download link.

Tested: bun run test (1085 passing, new signalDirExt + renderer cases),
typecheck, lint, build all green.

* Address review: sibling toggle, BTi failures, copy

Review findings on PR #257, all addressed:
- Hybrid dir rows no longer nest the viewer button in <summary>
  (inconsistent AT exposure); expand toggle and viewer button are
  sibling <button>s, lazy expansion driven by a data-dir-toggle
  delegate sharing loadDirChildren with the details path.
- Dir-recording fallback no longer links to raw listing JSON; copy
  points at the row's expand arrow.
- Extensionless (BTi) upgrades also fire for index failures so a
  known-unconvertible recording shows its reason.
- upgradeDirRecordingRow warns on structural-assumption breaks.
- og-image.png build artifact dropped from the diff; chunk-reveal
  path covered by a test.

Tested: 1087 vitest, typecheck, lint, build green.
* eeg-viewer: thread AbortSignal, export level readers

readWindow/readLevel0/readViewLevel take an optional AbortSignal forwarded
to zarr.get, and readLevel0/readViewLevel are now exported so a caller can
target a specific pyramid level directly instead of readWindow's pixel-width
heuristic. Both are additive (default undefined signal, new exports) with
no behavior change to existing call sites.

Prep for the website#254 background preloader, which needs to read an
explicit level and abort cleanly on teardown. Also adds windowDataBytes, a
pure byte-accounting helper for WindowData.

Tested: bun run test (1109 passed), bun run typecheck (0 errors).

* viewer: capped background preload of recordings

Adds an optional "Preload full recording" gear toggle (off by default) for
clinicians who want the whole recording resident so paging/scrubbing never
waits on a fetch. src/lib/eeg-viewer/prefetch.ts is a pure module with no
zarr/DOM dependency:

- ByteCappedLRUCache<T>: byte-accounted LRU with two insert modes. put()
  (interactive path) evicts LRU entries to fit; putIfRoom() (background
  scheduler) never evicts, failing once full so the walk stops rather than
  displacing what the viewport is showing.
- outwardOrder/segmentIndexForTime: pure helpers for the segment grid.
- PrefetchController<T>: walks the recording outward from the playhead one
  segment at a time, yielding to interactive reads (depth-counted) and to
  document.hidden, aborting cleanly via AbortController on stop().

viewer.ts wires it up:
- Segments are windowLengthS-wide reads at whatever pyramid level the
  interactive path last actually rendered (lastWinLevel), fetched via the
  newly-exported store.ts readLevel0/readViewLevel rather than re-deriving
  readWindow's pixel-width heuristic for off-screen windows.
- The interactive read path opportunistically serves an exact grid-aligned
  window from the cache (Page back/forward, Home/End land on the grid; an
  arbitrary scrub position falls through to a normal network read, same as
  preload off).
- Both settings (enabled, cache cap: 250/500/1000 MB) persist in
  localStorage under nemar:eeg-preload / nemar:eeg-preload-cap-mb, guarded
  with try/catch.
- The minimap gets a thin buffered-region strip (like a video player's
  buffer bar) shading cached segments.
- Preload pauses on visibilitychange (tab hidden) and stop()s on viewer
  destroy, matching the abort discipline from website#208.

Tested: bun run test (1142 passed, 33 new in prefetch.test.ts covering LRU
eviction/cap enforcement, outward scheduling order, priority yielding,
hidden-pause, abort, and stop-when-full), bun run typecheck (0 errors),
bun run lint (clean), bun run build (clean; the one build failure seen
locally was api.nemar.org intermittently 500ing on an unrelated catalog
page during OG image generation, confirmed by direct curl, not this
change).

* prefetch: batch progress/yield over a warm cache restart

A restart over an already-warm cache (cache-cap change, or switching back
to a previously-visited group/level) hit cache.has(key) for every segment
with no await in between: the while(this.yielding) guard is a no-op when
nothing is contending, so a multi-hour recording's worth of segments could
confirm in one synchronous microtask, firing onProgress (a canvas redraw
downstream) on every single one -- a real main-thread freeze rather than a
fast no-op.

The cache-hit branch now batches: it counts confirmed hits and only flushes
progress + awaits idle() every PROGRESS_BATCH_SIZE (32) segments, plus a
final partial flush at the end of the walk and before every early-return
(halted-full, superseded). The fetch path is unchanged -- a real network
call plus the existing idle() await already yields every iteration, so it
never needed batching.

Tested: bun run test (new case asserts idleCalls > 5 and far fewer
onProgress calls than segments across a 300-segment fully-cached restart,
confirming batched yielding rather than one synchronous burst).

* store/viewer: abort-safe retries, level-checked cache hits

Three fixes from review:

- retryingFetch no longer retries an already-aborted request. A caller's
  AbortController firing (background preloader stop()/destroy()) was
  previously treated as a transient failure and retried through up to ~10s
  of backoff, keeping the controller and up to the full cache cap alive
  well past teardown for a response nothing would use. Both the
  resolved-transient-status branch and the catch block now check
  request.signal?.aborted and propagate immediately instead.

- The interactive opportunistic cache-hit path (website#254) trusted
  lastWinLevel -- the level the *previous* frame rendered -- without
  re-deriving it for the CURRENT plotWidth. A resize with no remount (e.g.
  the Enlarge dialog) could serve a stale-level cached window and then
  reassign lastWinLevel back to it, self-reinforcing. Added
  store.ts#chooseWindowLevel, a pure function mirroring readWindow's own
  level-selection heuristic without fetching, and the cache check now only
  serves a hit when it agrees with lastWinLevel; otherwise it falls through
  to a real read (which corrects lastWinLevel for next time). Confirmed the
  filtered/forceLevel0 path was already structurally isolated from the
  cache (the check lives only inside the `!hasFilters(filters)` branch).

- windowDataBytes had zero test coverage despite being the byte-accounting
  foundation for the whole cache. Added line/band/mixed/empty cases.

Also exported retryingFetch and added coverage for the abort-vs-transient
distinction, and added chooseWindowLevel coverage (natural pick, forceLevel0
override, the LEVEL0_MAX_SAMPLES boundary, and no-pyramid fallback).

Tested: bun run test (1122 passed, all new cases green), bun run typecheck
(0 errors), bun run lint (clean), bun run build (clean).
* Add BIDS recording navigation helpers

Pure module behind website#253: parse sub/ses/task/acq/run/recording
entities out of zarr index store paths (files and .mefd/.ds/BTi
directories alike), order the list three ways for the prev/next control,
enumerate subjects and tasks for the dropdowns, and resolve a
(subject, task) pick to a recording. Paths that parse to nothing keep
file order instead of scrambling. Also holds the persisted iteration
order, read through an injected Storage so it is testable.

Tested: 51 new vitest cases (1127 total), typecheck, lint, build green.

* Add viewer nav-order setting and transfer

The gear popover gains one section, "Next moves through", persisting the
prev/next iteration order and announcing changes on a bubbling event so
the dialog chrome can relabel its controls without polling storage.

`transfer`/`onTransfer` let a caller carry window length, manual gain,
channel zoom, filters and the display toggles into the next recording.
Window position, bad channels and group index deliberately do not
travel; the notch re-defaults from the new recording's
PowerLineFrequency unless the user set it themselves.

Tested: typecheck, lint, build, full vitest suite green.

* Navigate recordings from the viewer dialog

Subject and task dropdowns plus prev/next in the enlarged viewer's
header row swap the mounted recording in place, reusing the eegSeq
supersession the tree-click path uses so rapid navigation cannot strand
a mount or a WebGL context (website#208). Settings carry over; the
window resets to the start of the new recording. Neighbours are warmed
with the same prefetch a hovered tree row gets.

The first navigation collapses the inline row the viewer came from and
the dialog owns the instance from then on -- the target recording often
sits in a directory the tree never rendered, so re-anchoring is not
available. The inline panel gains no controls.

Tested: typecheck, lint, build, full vitest suite green.

* Record ADR 0012 on viewer row detachment

Why navigating inside the dialog collapses the originating inline row
instead of re-anchoring under the new recording, and why the alternatives
lost.

* Keep focus when a step button goes disabled

Stepping onto the first or last recording disabled the button under the
user's focus, dropping focus to the body; hand it to the opposite button.

Tested: typecheck and lint green.

* Simplify the selectRecording relaxation

Same behaviour, expressed as two candidate lookups picked by `prefer`
instead of a key-dispatched find.

Tested: full vitest suite, typecheck and lint green.

* Walk subjects before sessions in subjects order

"Subjects first" promises the subject moves fastest, but sorting ses
before sub walked one subject's sessions before reaching the next
subject, which is what runs order already does. Also pins what happens
to recordings that tie on every tracked entity (BIDS split files): they
keep source order, a dropdown pick lands on the first, prev/next reaches
the rest.

Review findings 3a and 3b on PR #258.

Tested: 8 new vitest cases, full suite green.

* Offer the right fallback for directory recordings

renderUnavailable always linked "Download the file", which for a
.mefd/.ds/BTi recording (website#252) resolves to raw directory listing
JSON. Thread `dirRecording` through ViewerOptions from both mount sites
and reuse the page's own wording. The nav path has no tree row to read
data-dir-recording from, so `isDirRecordingName` decides from the name.

Also stops the gear from showing a filter that never runs: a cutoff at
or above the new recording's Nyquist, whether transferred or taken from
the declared PowerLineFrequency, now reads as off.

Review findings 4 and 5 on PR #258.

Tested: 3 new vitest cases, full suite, typecheck, lint green.

* Explain a failed navigation instead of going blank

A throw outside mountEegViewer's own try/catch (DOM build, WebGL setup,
transfer) left the dialog naming the target while the host held a
half-built viewer and no message: the previous instance is torn down as
the mount's first act, so there is nothing to fall back to. Render the
failure with the same fallback the inline path offers, and put the title
and dropdowns back on the recording the user came from.

Prev/next also now update aria-label alongside title, so a screen reader
hears which recording is next rather than the static markup label.

Review findings 1 and 2 on PR #258.

Tested: typecheck, lint, full suite green.
* Add firstRecording nav helper for the View data button

Picks the first recording in a given iteration order, reusing
orderedRecordings. Backs website#260 (dataset page "View data" button),
which needs to resolve a starting recording without a tree click.

* Add hidden View data button to the dataset action bar

SSR hidden next to Download; the dataset page's hydration script reveals
it once the Zarr index resolves with at least one store (website#260).

* Wire View data button to open the enlarge dialog directly

Clicking opens the first recording in the user's current nav order
straight into the enlarge dialog, detached from birth (ADR 0012): no
originating tree row, so closing the dialog destroys the instance.

Reuses the existing eegLive/eegSeq machinery, mountEegViewer, and the
recording-nav dropdowns/prev-next rather than duplicating them. A
mount failure renders an explicit error in the dialog, mirroring
navigateEegViewer's failure state.

eegLive.btn/slot become nullable to represent a viewer with no
originating row; resetEegRow and the dialog close handler are updated
to treat null as a no-op, matching behavior for every existing
row-opened viewer exactly.

* Fix View data dialog aria-busy, prefetch, and aria-label gaps

Review follow-ups on the View data button (website#260):

- openRecordingInDialog never toggled aria-busy on its host, unlike
  openEegViewerInline and navigateEegViewer. Set it right after the
  host is inserted and clear it in a finally, mirroring
  navigateEegViewer.
- The target recording's own store metadata was never warmed before
  the mount -- only its neighbours were, via prefetchAdjacentRecordings.
  Warm it the same way prefetchZarrButton does for a tree row.
- setEegDialogTitle only ever updated the visible heading; the
  dialog's own aria-label stayed the static "Signal viewer" from
  EegViewerDialog.astro, so a screen reader never heard which
  recording was open. This was a pre-existing gap across all three
  entry points (inline enlarge, in-dialog navigation, and now View
  data); fixed here since all three funnel through this one setter.

* Add firstRecording coverage for the subjects nav order

Only "runs" and "file" were covered for firstRecording; add the
missing "subjects" case.
* Add HED vocabulary extraction script

Parses base HED 8.4.0 and the HED-SCORE 2.1.0 library schema from a local
hed-schemas checkout into a committed JSON bundle (488 tags, 138 KB): the
whole SCORE clinical EEG vocabulary plus curated base-HED subtrees for
artifacts, event categories, temporal markers and vigilance state.

SCORE paths carry the `sc:` namespace prefix so an exported HED string is
usable as-is with `"HEDVersion": ["8.4.0", "sc:score_2.1.0"]`.

Hand-rolled hedxml scanner rather than an XML dependency; the script header
documents how to regenerate. Biome skips the generated bundle.

Tested: script runs clean, resolves all 26 quick picks, rejects duplicates.

* Add HED vocabulary loading and fuzzy search

`loadHedVocab` is the only reference to the JSON bundle, so Vite splits it
into its own chunk and the viewer's initial bundle never carries it; the
fetch happens when annotation mode is first entered.

Search is hand-rolled scoring over the 488-entry list: exact, prefix,
hyphen word-boundary, substring, path, description, shortest-tag tiebreak.
Multi-word queries are a conjunction scored by their weakest term.

Tested: 28 vitest cases against the real bundle, including chunk-size and
prefix invariants.

* Add annotation model with BIDS TSV export

Two disjoint kinds: TimeAnnotation (an events.tsv row, duration 0 for a
point marker) and ChannelAnnotation (a channels.tsv row with status and
status_description). Pure module - construction with clamping, sorting,
upsert/remove, windowing, overlap detection, greedy lane assignment for
drawing overlapping spans, and both serializers.

Output is deterministic: total sort order, 0.1 ms rounding without float
noise, n/a for missing, tabs and newlines stripped from free text at the
point it enters the model.

Tested: 64 vitest cases, including byte-identical output for a set built in
a different order.

* Add guarded IndexedDB annotation storage

Keyed by dataset id, dataset version and recording path (a JSON tuple, so
no separator a BIDS path could contain). Not keyed by user: the issue
requires persistence regardless of sign-in.

Every IndexedDB call is guarded. A synchronous throw on open (Firefox
private windows), an absent factory, or a later quota failure degrades to
an in-memory store for the session with `persistent` false, which the
viewer surfaces as "download these". Records read back are re-validated
through the model's constructors rather than trusted.

Tested: 22 vitest cases; the no-IndexedDB fallback is the real path under
node, not a simulated one.

* Add annotation mode to the signal viewer

A pencil toggle beside the topomap arms the tool. On the trace, a click
drops a zero-duration event marker and a horizontal drag makes a span,
both opening a focus-trapped popover with fuzzy HED search, grouped quick
picks and a free-text note. In the gutter, channel selection stays the
viewer's existing bad-channel click, which the panel then annotates as a
set; the two exports are separate downloads.

The layer is self-contained: an overlay canvas above the chrome canvas, a
popover and a panel, reading geometry the renderer already computed. It
never owns the arrow keys, and pointer handlers are capture-phase on the
scope so gutter clicks still reach the existing handler.

Annotations restore on mount and flush on teardown, so a recording swap or
an enlarge round trip keeps them. Anonymous or unpersisted work arms a
beforeunload confirm and an in-panel notice.

Tested: driven end to end in Chromium against the dev server on on005514,
both themes, inline and enlarged - marker, drag, edit, delete, search,
quick picks, channel marks, both downloads, restore across a remount.

* Add ADR for the annotation data model

Records why annotations are two BIDS kinds rather than one type with
optional fields, why storage is local-only in v1, and what that forecloses
(channel-scoped time ranges, cross-device access, sharing).

* Close the annotation store on an early teardown

A mount can be destroyed while openAnnotationStore is still in flight — a
recording swap, or the enlarge dialog closing. The resolved connection was
then unreachable: destroy() flushes through `store`, which is still null,
so the IDBDatabase stayed open for the life of the page.

Close it at the point the layer learns it is gone.

* Never write a negative zero into an annotation TSV

formatSeconds rounds before printing, and a value just below zero rounds to
-0, which stringifies as the cell "-0" — a value no reader of an onset column
expects. Return "0" for a rounded zero of either sign.

* Name the channel export for merging, not replacing

The channel annotations downloaded as <stem>_channels.tsv, which is the name
of the recording's real channels table while being deliberately not one: it
lists only the annotated channels and omits the type/units columns BIDS
requires. Dropped into a dataset under that name it would delete every
unannotated channel's row.

Rename it <stem>_channels-annotations.tsv.

* Place the annotation popover beside what it annotates

Three changes from live design feedback, plus two fixes in the same code.

Placement. The popover opened at a fixed offset under the toolbar, which put
it on top of the very span the annotator had just dragged out. It is now
placed adjacent to its selection: right of it, else left, else below, else
above, else — only when nothing clears it — at the right-centre of the trace.
Coordinates are relative to the viewer root, so the same arithmetic serves the
inline and the enlarged mount. Bounds are the *visible* box (root box
intersected with the viewport and any clipping ancestor), because inside the
enlarge dialog the viewer root is taller than the dialog that clips it, and
placing against the root put the footer below the dialog's edge. It follows
the selection across a frame (scrubbing, channel zoom) and a window resize.

Pinned footer. The popover no longer scrolls as a whole: its body does, and
Save/Cancel sit in a footer that cannot leave the frame however long the
vocabulary list is. Enter now saves, once the draft carries a tag or a note,
except where a control owns Enter itself (a textarea's newline, a button's
activation) or an IME is mid-composition. Escape still cancels.

Channel entry. With annotate mode on, clicking a channel label opens the
channel popover for that channel directly rather than only toggling the bad
mark — the popover's status field decides that mark on save. Its quick picks
are the artifact vocabulary (base HED's artifact tree plus SCORE's terms for
what the artifact cost the recording), derived from the bundle rather than
curated a second time. Outside annotate mode the marking gesture is unchanged.

Escape and the enlarge dialog. Whether a cancelled keydown suppresses a modal
dialog's own close request is engine-dependent, so one Escape could have
closed the popover and ejected the enlarged viewer behind it. The layer now
guards the surrounding dialog's `cancel` while its popover is open (and
briefly after, since the close request can arrive once it has closed), and
exposes isPopoverOpen for the same question asked from outside.

Overlay caching. The lane assignment and the channel index were rebuilt on
every drawOverlay, which runs on every pointer move of a drag. Both are now
cached on the set's identity, the visible-window filter is shared with the
hit test, and drag repaints are coalesced to one per frame.

* Cover the annotation layer's helpers and its storage

The placement arithmetic decides whether the popover covers the signal being
annotated, which a screenshot review catches unreliably; it and the other pure
helpers are exported precisely so they can be driven without a browser.
26 tests over placement (each candidate, the clamps, bounds that do not start
at the viewer's origin), the time/pixel mapping, Enter's submit rule, the
unsaved-work predicate and the colour blend.

Storage gains a real IndexedDB. fake-indexeddb is a platform shim rather than
a mock of anything here — the actual W3C algorithms under Node, as jsdom is a
real DOM — so nothing in annotation-store.ts is stubbed: the tests drive the
genuine open-and-upgrade, a round trip through a second connection, key
separation, and the degrade-to-memory path through a genuine write failure.
Until now only the no-IndexedDB fallback was covered, because Node has none.
A mouse click on a quick-pick chip or search result left focus on that
button, where Enter re-activates the button (ENTER_OWNING_TAGS defers to
it for keyboard users) instead of saving - so the natural mouse flow
drag -> click tag -> Return never saved. Mouse activations (detail > 0)
now hand focus back to the search input; keyboard activations (detail 0)
keep focus on the chip.

Tested: typecheck, lint, 1347 vitest; reported by Yahya in live QA.
Three live-QA fixes from Yahya:
- 'Annotate 1 marked channel' now opens the existing annotation
  prefilled (tags/status/comment) instead of a blank draft, mirroring
  the gutter click's edit-not-replace behavior; multi-channel sets stay
  a fresh draft describing the set.
- Popover footer pills no longer wrap mid-word into circles
  (white-space nowrap + flex-shrink 0).
- Annotation list scroll cap raised 132px -> 260px for long scoring
  sessions.

Tested: typecheck, lint, 1347 vitest.
Yahya's QA round 4:
- New pure hedShortForm(): leaf tag with the sc: library prefix carried
  over. formatHed() now writes short form to events.tsv/channels TSV
  (HED's annotation default; validators expand via HEDVersion).
- Panel rows and chips show short form even before the lazy vocab loads
  (labelForPath falls back to hedShortForm, not the raw path — how
  long paths leaked into the UI); the long form moves to hover titles
  on rows and chosen chips.
- Annotation list becomes a responsive grid: 2-3 columns of short rows
  on a wide panel, collapsing to one, row-major.

Tested: typecheck, lint, 1350 vitest (serializer expectations updated
to short form; new hedShortForm cases).
Yahya's QA round 5: the search box only reached a curated 488-tag
subset, so Building/Left/Right found nothing and 'sleep' missed tags.
The bundle now carries every non-deprecated tag of both schemas
(1525 tags: 1129 base HED + 396 SCORE; 341 KB raw, 68 KB gzipped,
still popover-lazy). Curation now lives only in the quick-picks.
Active searches hide the quick-pick groups so results are the only
list on screen; clearing the box restores them.

Tested: typecheck, lint, build, 1353 vitest (new whole-schema
coverage cases; size cap raised to 500 KB with rationale).
…orkbench

Epic: signal viewer workbench (phases 1-5)
…e write-through (#271)

* Cut zarr metadata roundtrips, dedup chunk fetches

Measured against zarr.nemar.org (129-ch EEG stores):

- Pin every zarr.open to open.v3: generic open tries v2 first on a fresh
  store, costing two serial 404s (.zattrs, .zgroup) before zarr.json on
  every viewer open (~1.9 s wall measured) and tripling every miss
  (view-level probes, missing events). Open of a 40-min store: 36 -> 15
  requests; 172-s store: 5 -> 3 requests, zero 404s.
- Size the first view-level probe batch from n_samples via new
  predictedViewLevelCount (producer decimates x4, stops under 250
  samples; verified against live stores). Prediction only sizes the
  batch; discovery still verifies every level, so a producer change
  costs a couple of extra probes, never a wrong list.
- dedupingFetch: share one in-flight fetch per URL+Range between the
  interactive path and the background preloader. Level-0 reads are 206
  Range responses the browser cache never stores (~140 KB per chunk
  otherwise re-transferred). Shared request aborts only when every
  subscriber has aborted.

Unit tests for both new functions (fake fetch at the transport boundary
per repo testing policy); vitest/typecheck/lint/build green.

* Write interactive windows through to preload cache

An interactive read that lands on the preloader's segment grid IS the
segment the background walk would fetch for that index. Store it under
the walk's own cache key so cache.has skips it, instead of the walk
re-transferring the identical bytes right after the user viewed them
(~430 KB per 10 s level-0 page on a 129-channel store, starting with
the very first window of every open while preload is on).

The renderer DC-removes on copies (cw.line.slice() / removeBandDc
returns new arrays), so cached windows stay pristine -- same invariant
the existing cache-hit path relies on.

* Default preload off when Save-Data is on

A browser-level reduce-data preference (Save-Data / Chromium Data
Saver) outranks a stored opt-in from a previous session: background
preloading a whole recording (up to the configured cap, default 500 MB)
is exactly what Save-Data asks sites not to do. Progressive
enhancement: navigator.connection is Chromium-only; absence changes
nothing. The gear toggle still enables preload for the current mount.

* Harden reader discovery and dedup error paths

Review-panel fixes on the transfer work (PR #271):

- openNode: v3-first open with a v2 legacy escape hatch. The fast path
  stays pinned to open.v3; a NotFound falls back to open.v2 only until
  the store's first successful v3 read (a store is entirely one format),
  so misses on a working v3 store never pay the v2 404 pair again.
- discoverViewLevels: a non-404 probe failure (retry-exhausted 5xx, 403
  from an expired token) now throws ViewLevelDiscoveryError carrying the
  levels that DID open; openGroup keeps them and sets a new
  viewLevelsDegraded flag on the GroupHandle, so callers can distinguish
  'discovery broke' from a genuinely short pyramid. Fulfilled sibling
  probes in a failed batch are kept instead of discarded.
- discoverViewLevels takes openGroup's already-resolved nSamples (attrs
  with level-0-shape fallback) instead of re-deriving with a NaN
  fallback, removing the 12-probe worst case.
- retryingFetch's aborted-under-transient-status error now states that
  the status may be a real backend error, not an effect of the
  cancellation (dedup's refcount abort can race a real 429/5xx).
- Comment fixes: VIEW_PROBE_FOLLOWUP's job is confirming the real end
  (it runs on exact predictions too); discoverViewLevels docstring moved
  onto the function; isNotFound documented (tagged error preferred,
  regex fallback); dedup doc notes the useSuffixRequest coupling.

New tests: fake v3 store harness driving the real reader pipeline
(exact/undershot prediction probe counts, degraded discovery keeps
siblings + sets the flag, no v2 requests on a v3 store), dedup
fresh-fetch-after-rejection, and dedup(retry) composition sharing one
retry cycle between concurrent callers.

* Key preload write-through by captured geometry

Review-panel CRITICAL fix (PR #271): the write-through key was built
from chanStart/chanCount/windowLengthS re-read AFTER the awaited network
read -- live closure state the channel scrollbar or a window-length
change can mutate mid-flight, which would store the OLD window's data
under the NEW range's key and make a later cache hit render the wrong
traces.

The gate + key derivation now live in a pure exported helper,
writeThroughKey (prefetch.ts, beside prefetchCacheKey which moved there
so the walk's keyFor and the write-through provably share one key
scheme). readFrame captures every geometry input into locals before the
await and passes only those. The put()'s boolean is no longer discarded:
a refusal (single window larger than the whole cache cap) logs a
console.warn instead of silently disabling the optimization.

Tests: writeThroughKey boundary/off-grid/partial-trailing/disabled
cases, key equality with prefetchCacheKey for the same segment, and an
oversized put leaving resident cache entries intact.

* Guard Save-Data probe against throwing accessor

Review-panel HIGH fix (PR #271): hardened/privacy browsers can make the
navigator.connection accessor itself throw (fingerprinting
countermeasures); unguarded, that exception propagated out of
loadPreloadEnabled and took down the whole viewer mount. saveDataRequested
now try/catches the access and treats a throw as 'no signal'.

Both settings readers are exported and covered by new boundary-fake
tests (real-shape navigator/localStorage stand-ins, restored after each
test): Save-Data on beats a stored opt-in, Save-Data off honors it, a
throwing accessor degrades to the stored value, and absent storage
defaults off.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 1, 2026

Copy link
Copy Markdown

Deploying nemar-website with  Cloudflare Pages  Cloudflare Pages

Latest commit: c72b308
Status: ✅  Deploy successful!
Preview URL: https://d254e8c0.nemar-website.pages.dev
Branch Preview URL: https://staging.nemar-website.pages.dev

View logs

* Surface a degraded view pyramid in the viewer

GroupHandle.viewLevelsDegraded was computed but never read, so a
truncated overview pyramid looked identical to a short recording. The
status line now carries a suffix and a standing note sits under the
minimap; both re-check when viewLevelsReady settles after first paint.

* Push annotation persistence failures to the viewer UI

mutate() runs syncAll() synchronously before the debounced flush, so a
write that fails on the last annotation before a tab close never re-armed
the beforeunload guard nor repainted the "not being saved" banner. The
store now notifies subscribers from degrade(); the annotation layer
subscribes at open and re-runs syncBeforeUnload() + renderPanel().

* Stop the preloader retrying into an outage

PrefetchController.loop walked the whole recording re-failing forever with
only a static buffered bar as feedback. Four consecutive segment failures
(each already past store.ts's retry ladder) now stop the walk and fire
onStalled; the viewer shows a note beside the preload toggle, and any
restart -- toggle, cache cap, group switch -- re-arms it. Aborts do not
count as failures.

* Block recording swaps while an annotation draft is open

isPopoverOpen() had zero callers, so prev/next and the subject/task
dropdowns destroyed the mounted instance and its unsaved popover draft
silently. The viewer now hands the page a ViewerAnnotationHandle on the
same seam as onTransfer; navigateEegViewer, stepEegViewer and both select
handlers refuse and flash+focus the popover instead. No window.confirm --
the refusal plus a visible cue says the same thing without a blocking
modal.

* Drop a manual gain across a modality change

applyTransfer carried a manually-set gain onto any new recording. Gain
multiplies the modality's DEFAULT_SCALINGS, so an EEG gain on an MEG
recording is a different physical scale, not the same zoom. The transfer
record now carries the modality it was chosen against; a mismatch falls
back to auto-scale. Same-modality swaps are unchanged.

* Tear down a mid-flight viewer on dialog close

The close handler relied on eegLive.destroy, which navigateEegViewer nulls
for the duration of the new mount. Fall back to the host's own
_eegvCleanup so an instance that published it but has not returned its
disposer yet is released immediately instead of at the post-await
staleness check.

* Surface an annotation export that fails to download

The export is the escape hatch for annotations that live nowhere else, so
a throwing createObjectURL/click must not read as an inert button. Wrap it
and render the failure beside the export buttons; a later success clears
the message.

* Warn when the nav-order preference cannot be stored

writeNavOrder returns false for exactly this (privacy mode, blocked
storage) and the return value was discarded.

* Fix stale comments and docs from the release panel

- hed-vocab.ts / annotation-ui.ts: the bundle is ~341 KB and 1525 entries,
  not ~140 KB / 500, and it is no longer curated (website#269).
- HedPath / HedVocabEntry.path / extract-hed-vocab.mjs: the long form is the
  storage and lookup key; the SHORT form is what an export writes (#268).
- annotation-ui.ts labelForPath: it falls back to the derived short form,
  matching its own inline comment.
- render-dir-listing.ts: the missing half of the CHEVRON_SVG sync comment.
- ADR 0013: dated update note; search now covers both schemas in full,
  curation narrowed to quick picks, decision unchanged.
- AGENTS.md: real test count, and eeg-viewer/ now names what it holds.
- [id].astro: releaseEegViewer has two call sites, and
  openRecordingInDialog can be reached with an inline viewer live.
- extract-hed-vocab.mjs: drop the unused byPath map (regenerating the
  bundle after the change produces a byte-identical file).

* Let a dialog refuse its own corner X

DialogCloseButton calls dialog.close(), which fires only the
non-cancelable "close" event -- a decision already taken. So the X walked
straight past the annotation-draft guard that Escape respects via
"cancel", and the detached-close branch destroyed the draft with no
trace. The button now dispatches a cancelable pre-close event; the
dataset page vetoes it while a detached viewer holds an open popover.
Inert for every dialog that does not listen.

* Derive the preload stall note instead of latching it

The note advertised "turn it off and on again to retry", and that cure
provably failed: renderImpl's read-failure branch returns before
updatePrefetchTarget, and the toggle-on path early-returns while no view
level is known, so the latched flag survived its own recovery and the
note stuck for good. The outage that trips the breaker is the same one
that fails the interactive read, so this was the common case.

The note is now derived from PrefetchController.stalled, which stop()
clears alongside start(), and is re-evaluated on every render exactly as
the degraded-pyramid note already was -- that asymmetry was why one
self-healed and the other did not.

* Make the viewer's destroy() idempotent

Three handles point at the one disposer, and a dialog close during a
navigate mount fires two of them: the close handler takes
host._eegvCleanup, then navigateEegViewer's superseded branch calls the
same returned destroy. Running the cleanups twice double-disposes the GL
context and tears the annotation layer down mid-flush. Guard in the
closure, which is the only place that can see all three call sites.

* Fail open when the annotation draft guard throws

A throwing isPopoverOpen/focusPopover would otherwise wedge every
prev/next and both dropdowns for the rest of the session, with the
selects re-syncing away from the user's pick and no gesture that
recovers. Losing at most one unsaved draft is the smaller failure.

* Extract and test the modality gain-transfer gate

gainCarriesOver was an inline expression in applyTransfer's closure. Its
failure mode is a trace off by orders of magnitude -- a flat line or a
wall of clipping that reads as the recording, not as a bug -- so it does
not belong untested. Pure, exported, and covered for same/different
modality, case, whitespace, and the null-safety convention that an absent
modality counts as a match.

* Cover the load-first annotation persistence degrade

The realistic first contact is mount, subscribe, load -- with no prior
save to have caught an unusable connection. That is the visit where the
annotator would be told their marks are safe when they are not.

* Stop the degraded-overview note naming a cause

ViewLevelDiscoveryError covers a retry-exhausted 5xx, a 403 from an
expired token and a decode error alike, and the flag does not say which,
so "connection problem" was a guess presented as a diagnosis. The action
is the same either way: reload.

* Codify the ADR amendment convention

ADR 0013 grew an "## Update -- YYYY-MM-DD" section for a factual change
that left the Decision standing. Write the rule down: amend by appending,
never by editing Context/Decision/Alternatives, and supersede when the
Decision itself changes.

* Fix a stale caller claim and the test count

releaseEegViewer's inline comment still named openEegViewerInline as its
only caller, contradicting the header six lines above it that now names
both. AGENTS.md's test count follows the suite to 1402.
@neuromechanist
neuromechanist merged commit d0d1e98 into main Sep 1, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant