nimbus-frontend
Guidelines for frontend development in the NimbusImage application including Vue 2, Vuetify, Vuex patterns, theming, and component structure.
Files3
View on GitHubSKILL.md.claude/skills/nimbus-frontend/SKILL.md
GitHubNimbus Frontend Development
Dependency bumps and Vitest 4
- Compare the actual resolved graph, not the Dependabot title. An override can make a proposed bump a no-op. Keep legacy consumers on the API major they require; a direct upgrade must not force every transitive consumer across it.
- A pnpm package has both a
packagesentry and asnapshotsdependency entry. Updating only its version and integrity can omit a newly required dependency. Re-resolve with pnpm and inspect the diff; a frozen install checks importer consistency but does not prove every transitive snapshot is complete. - Vitest 4 mocks used with
newmust use a regular function or class, not an arrow. Sweep sibling constructor mocks, including rarely exercised GIF/ZIP paths. Factory methods such as vtk'snewInstance()remain ordinary calls. vi.restoreAllMocks()no longer clears standalone mock call history. The suite usesclearMocks: truefor call isolation; tests must still reset any implementations or state they change. Do not relax call-count assertions.- jsdom 24 forwards stylesheet errors through a host console outside Vitest 4's
test console. Filter only
Could not parse CSS stylesheeton its existing virtual-console handlers, preserving every other error. A console.error wrapper in test setup misses these events and can produce hundreds of MB of log output. Verify the actual log after a runner upgrade.
Test mocks must model the real store's REPLACEMENT semantics
A mock that mutates state in place where the real store replaces it makes
tests silently vacuous. AnalysisPanel.test.ts's setPlots did
plots.length = 0; plots.push(...) while the real applyAnalysisPlots
builds a new array. Vue short-circuits a computed whose value is unchanged
by identity, so analysisPlots never invalidated and every watcher
downstream of it silently never re-ran — a new test for plot-removal
behavior passed against code that did nothing.
It hid because an existing test appeared to cover removal: its watcher
happened to read analysisPopulation, which returns a fresh array each
evaluation, so that one re-fired for an unrelated reason.
Rules:
- Mock setters replace (
mocks.plots = [...next]), matching the store. - Booleans and other scalars are the same trap in reverse: an intermediate
computed returning an unchanged
truestops propagation, so a test that changes only downstream data may never re-run the watcher. - Before trusting a new watcher test, make it FAIL once (revert the fix, or assert the opposite) — this one passed for the wrong reason first.
Two related mock traps, both of which make a test assert against something the component never touched:
- A
vi.mockfactory captures the spy it closes over. Reassigningmocks.someAction = vi.fn()inbeforeEachleaves the component calling the original spy while the test asserts on the new one — "expected spy to be called, number of calls: 0" with obviously working code. Usemocks.someAction.mockClear()instead. - A plain-object store mock is not reactive, so a component watching
() => store.somethingnever fires when a test assigns to it. If the behavior under test is a watcher on store state, wrap the mock's default export inreactive()(vi.mock("@/store", async () => { const { reactive } = await import("vue"); return { default: reactive({ … }) }; })).
Component Patterns
Script Setup (Composition API)
All 121 components use <script setup lang="ts">:
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import store from "@/store";
const props = defineProps<{
value: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
const localState = ref("");
const computedValue = computed(() => props.value.toUpperCase());
watch(() => props.value, (newVal) => {
localState.value = newVal;
});
onMounted(() => {
// lifecycle hook
});
</script>
Store Access
import store from "@/store";
import annotationStore from "@/store/annotation";
// Direct usage in <script setup> — no `this` needed
store.someAction();
annotationStore.filteredAnnotations;
Store modules still use vuex-module-decorators with @Module, @Mutation, and @Action decorators.
For advanced store patterns (routeMapper, form change detection, caching with batch loading): read references/store-module-patterns.md
Driving the AI panel (agent) from the console
To test the Nimbus AI panel (src/store/aiPanel.ts, AiPanel.vue) end-to-end without clicking, dispatch its actions on the live store. Two traps:
- The actions register UNNAMESPACED.
vuex-module-decoratorsputs them in the global action map assendUserMessage,handleAuthenticatedUserChange, etc. — NOTaiPanel/sendUserMessage. A namespaced dispatch is silently dropped (Vuex warns, resolves a no-op promise, nothing runs). Confirm withstore._actions['sendUserMessage']. sendUserMessageruns the whole agent loop and only resolves when the turn ends — don't await it if you want to poll progress; fire it and readstore.state.aiPanel.items/.runningon a timer.
const store = document.querySelector('#app').__vue_app__.config.globalProperties.$store;
store.commit('setAutoApprove', true); // skip gated-action approval clicks
await store.dispatch('clearConversationAndStorage'); // full reset (memory + IndexedDB)
store.dispatch('sendUserMessage', 'Find the nuclei in this image.'); // fire, don't await
Send exactly once, from a clean/hydrated state. Two sendUserMessages in quick succession start two overlapping runs that both push to the module-level wireMessages, nesting the tool-result blocks (content: [[tool_result,…]]). The next request then fails with Anthropic 400 … messages.N.content.0: Input should be an object. This is not a create/run bug — it's conversation corruption from concurrent turns. (The UI's send() and the sendUserMessage guard both check running, but a stale in-flight run or leftover persisted conversation can still bite; a hard reload + clearConversationAndStorage gives a truly clean slate.) Related: hydrating (module var) blocks sends until a reloaded conversation finishes restoring — a dispatch right after reload can no-op; wait a beat. clearConversation() (no force) no-ops while running; use clearConversationAndStorage.
Agent tool executors live in src/agent/executors.ts (executeAgentTool(name, input, ctx)), importable in the Vite dev page for isolated testing: await import('/src/agent/executors.ts?t=' + Date.now()) (the query-bust avoids a stale module cache). Worker tools save parameters under tool.values.workerInterfaceValues; channelCheckboxes values are {channelIndex: true} maps (a true value selects — key-presence alone does not).
Whole-population reads must handle stub-only mode
Above stubThreshold (100K) annotationStore.annotations is empty. The
population is in annotationStubs, and propertyStore.propertyValues holds
only the rendered annotations' values for the displayed columns. Code that
reads either one as "the dataset" returns zero there, or worse, a viewport-sized
subset presented as the whole. Nothing errors, and every small-fixture test
passes. This shipped in the AI panel: list/select/color/tag, every analysis
tool and fit saw nothing on exactly the datasets where the server summary
reported 700K (AI_PANEL_SPEC.md §4.1).
- Count with
annotationCount. Get ids withallAnnotationIds. Look up one id withgetAnnotationOrStubFromId(O(1); capture the getter once before a loop). To iterate, useannotationStubs.values()in stub-only mode, orannotationsForIteration, which copies the map into a new array on every access. Stubs have tags/shape/channel/location/color/centroid, but nonameorcoordinates; narrow withisHydratedAnnotation. - Property values for an arbitrary population: below the threshold, read the
store. Above it, use the server: aggregates via
fetchAnnotationSummaryor the analysis endpoints, or values viagetPropertyValuesForIds(ds, ids, paths)with a non-emptypaths(an empty list means every value). - Test it by putting the store mock in stub-only mode (
stubOnlyMode: true,annotations: [], stubs populated), and confirm the test fails with the fix reverted.executors.test.tsdescribe("stub-only mode")is the pattern.
Never assign a big per-annotation map to state without markRaw
annotationStubs, hydratedAnnotations, and annotationCentroids hold one
entry per annotation — up to ~700K. Every existing assignment wraps them in
markRaw(...); a new mutation that forgets it hands Vue a raw Map to walk and
proxy entry by entry, and that cost dwarfs whatever the mutation was doing. A
whole-dataset recolor measured 16.9s with the markRaw missing against
~5.5s with it — and the mutation itself was only ~0.5s of that.
Nothing static catches this: tsc and lint are happy, and any test with a
handful of fixture annotations is far too small to feel it. The tell is a
measured time that doesn't add up from its parts.
// BAD: Vue proxies ~700K entries on assignment
this.annotationStubs = newStubs;
// GOOD: matches the nine other assignments to this map
this.annotationStubs = markRaw(newStubs);
src/store/__tests__/rawStateMaps.test.ts asserts isReactive(...) === false
after every mutation that replaces one of these maps — extend it when you add
another, rather than hand-checking. Verify a new row can fail by deleting only
the markRaw call (not the whole mutation — stashing the file reverts it
entirely and the test then fails for the wrong reason).
Note the array convention differs: annotations is a plain reactive array of
markRawed items (setAnnotations does annotations.map(markRaw)), so
markRaw goes on the items there, not the array.
Store Edits Break HMR — Hard-Reload
Editing any src/store/*.ts while pnpm run dev runs corrupts the store: vuex-module-decorators registers getters at import time with no HMR accept handler, so a hot re-import double-registers → [vuex] duplicate getter key cascade and broken state (e.g. annotations stuck at 0). Hard-reload the page after every store-module edit before trusting any in-browser behavior. Component .vue edits HMR fine — prefer putting temporary instrumentation in .vue files.
Actions That Throw Need @Action({ rawError: true })
vuex-module-decorators wraps any error thrown from a bare @Action in a generic Error("ERR_ACTION_ACCESS_UNDEFINED: Are you trying to access this.someMutation()..."), discarding the original message — unless the action is declared @Action({ rawError: true }). This is a library-wide behavior, not specific to one module.
Most actions in this codebase never throw (they log and return null/false on failure), so this rarely bites. It matters the moment an action is designed to throw so a caller can show the real failure reason (e.g. addMultiSourceMetadata throwing a storage-quota message for MultiSourceConfiguration.vue to display). Forgetting rawError: true silently replaces that message with the cryptic wrapper text — tsc/lint/tests all stay green because the action still rejects, just with the wrong message.
// BAD: caller's catch block sees "ERR_ACTION_ACCESS_UNDEFINED: ..." instead
// of the real message
@Action
async doThing() {
throw new Error("Helpful, specific reason");
}
// GOOD
@Action({ rawError: true })
async doThing() {
throw new Error("Helpful, specific reason");
}
When writing a test for an action's thrown-error message, expect(...).rejects.toThrow("substring") is not a reliable regression check here: the wrapped error's message embeds the original error's .stack (which starts with "Error: <original message>"), so a substring match can pass even when rawError is missing. Assert the exact .message instead. See src/store/index.test.ts for the pattern (dispatches the real action instead of mocking @/store).
Two traps that let this ship a real bug even after the rule above was documented:
- An action needs the flag if it merely propagates, not only if it contains
throw. Awaiting an API call or another action re-throws through your own decorator.createPropertyhas nothrowand still emitted the blob — so a "grep action bodies forthrow" audit misses exactly these. - Errors get re-wrapped at every
@Actionboundary they cross, across modules.createProperty → setProperties → updateConfigurationProperties → syncConfigurationis four boundaries; one bare@Actionanywhere on the path mangles the message. Audit everysrc/store/*.ts, not justindex.ts.
See references/store-module-patterns.md for the audit commands, how to tell which callers actually display the message, and the vitest setup details (accessor getters are non-configurable — set store.state.main.* directly).
One Logical Change → One Config Write
syncConfiguration(key) PUTs the whole key. So a caller that changes three fields by calling a single-field action three times issues three writes of the same key, and a rejection part-way through leaves the shared collection partially updated while reporting failure — the same false-reporting rawError exists to prevent, one level up. Two instances shipped before this was caught (set_scale writing scales up to 3×, update_layer writing layers 2× via changeLayer + saveContrastInConfiguration).
Validate everything first, then write once:
// BAD: validates and persists per field. An invalid tStep leaves pixelSize
// already written — a partial update with no backend failure involved.
if (input.pixelSize) await apply("pixelSize", input.pixelSize);
if (input.tStep) await apply("tStep", input.tStep); // throws on a bad unit
// GOOD: validate all → assign all → one sync
const scales = {};
if (input.pixelSize) scales.pixelSize = validate("pixelSize", input.pixelSize);
if (input.tStep) scales.tStep = validate("tStep", input.tStep);
await main.saveScalesInConfiguration({ scales, throwOnError: true });
Interleaved validation is the easier half to miss: it fails with no backend involvement at all, so it can't be caught by testing backend rejections. When adding a batch action, keep the singular one — the interactive UI edits one field at a time and legitimately wants it (ScaleSettings.vue).
Existing in-codebase idioms for writing once:
changeLayer({ ..., sync: false })per item, then a singlesyncConfiguration({ key: "layers", throwOnError: true })— seeset_layer_visibility.- A plural action that assigns all entries then syncs once —
saveScalesInConfiguration,setViewContrastOverrides. - An optional
deltamerged into an existing action's single write —saveContrastInConfiguration({ layerId, contrast, delta }).
Writes to genuinely different resources can't be merged (the configuration vs the dataset view are separate endpoints); say so at the call site rather than leaving it looking like an oversight.
Watching Getters That Rebuild Their Return Object
watch(() => someGetter, cb, { deep: true }) on a getter that returns a new object on every read fires on every dependency touch — including dependencies the getter reads but that don't change the output (deep: true skips the value comparison entirely). This shipped a real bug: a deep watch on currentFilters cleared the selection on every Z-scrub because the getter read z unconditionally. tsc/lint/reasoning all passed; only the live app caught it.
// BAD: fires on every dependency touch
watch(() => annotationListServer.currentFilters, cb, { deep: true });
// GOOD: fires only when content genuinely changes; stringify's traversal
// still registers the nested reactive deps
watch(() => JSON.stringify(annotationListServer.currentFilters), cb);
Watch out for stringify cost on large objects.
This bug recurs even after being fixed once nearby — grep for it. A second, separate watch([...9 getters...], cb, { deep: true }) in the same file (AnnotationList.vue's "server-mode reactive refetch" block, a few lines below the currentFilters watch above) had the identical bug, confirmed via live instrumentation firing every 30-80ms with zero of the 9 tracked values actually changing. Each spurious firing called setOptions({ page: 1 }), silently resetting the server-paginated annotation list's page after every click-to-row navigation — while the rows stayed correct (the accompanying debounced refetch never settled long enough to fire), so only the page number/footer/Index column were wrong. This looked exactly like "clicking an annotation goes to the wrong spot in the list," and a plausible-looking VDataTableServer update:options stale-echo race was chased first as the cause (it even reproduced once) before instrumenting the watcher itself proved it was actually firing with no real change. When you find and fix one instance of this pattern, grep -n "deep:\s*true" src for siblings in the same or related files before considering it fixed — a documented fix comment next to one watcher does not protect a copy-pasted watcher elsewhere.
Not every { deep: true } is this bug — it only applies when the watched source is a getter function that rebuilds a fresh object/array on each call (a Vuex/Pinia getter, a computed, or a plain function reading store state). A ref()/reactive() passed directly as the watch source (not wrapped in a function) is the correct, safe use of deep: true — Vue tracks its stable identity and only fires on genuine in-place mutations. Don't blanket-remove deep: true without checking which case you're in.
An "unknown" value needs ONE spelling and ONE predicate
An uncalibrated dataset's pixel size had three stored spellings — 0 (the intended marker), null (JSON's rendering of a NaN from (undefined + undefined) / 2) and {1, "m"} (the configuration default) — and every consumer checked a different subset: the scale bar and blob-metrics worker tested 0, the line scan tested 1 m, nothing tested null. Each gap was a real bug: a 1.5 km scale bar, a worker crash on None * 1e-3, the spatial plugin refusing to adopt a size because "1 m" looked set.
When a value can be "unknown", give it one representation (unknownPixelSize() in src/utils/scales.ts), one predicate every consumer calls (isKnownPixelSize), and normalize legacy spellings at the read boundary (normalizeScales in the scales/configurationScales getters), so workers that receive the value see only real-or-marker. Sweep with a concept search ("where is this value tested for unset?"), not a grep for one spelling. Backend ports of frontend defaults (server/helpers/default_configuration.py) are the twin — change both.
Don't re-key a dataset/configuration watcher on ids
Viewer.vue's watch([dataset, configuration], fetchAnnotationData) looks like it should key on ids ("a reload of the same object shouldn't refetch"), but refreshDataset() depends on the object swap: it re-runs setSelectedDataset with the same id, which resets annotation and property state, and only this watcher repopulates it. Keying on ids left the viewer empty after unroll toggles and addLargeImage (caught in review of issue #1379). Before narrowing any watcher on store.dataset/store.configuration, find what resets state on a same-id reload and who repopulates it.
Switching dataset views loads the dataset and configuration concurrently, so this watcher sees a mismatched pair (new configuration, old dataset) first. Gate per-dataset fetches on both matching store.datasetView, and guard the store action with a sequence token so a superseded fetch can't commit (fetchAnnotations, src/store/__tests__/fetchAnnotationsStale.test.ts).
this.dataset is the OUTGOING dataset while a view loads
setDatasetViewId sets the location (from ?xy=&z=&time= or the view's
lastLocation) before the new dataset arrives, and this.dataset still holds
the previous one until setSelectedDataset commits. Anything that validates
against this.dataset in that window checks the wrong dataset. A setter-side
clamp would cut a deep link that is valid for the incoming dataset, and a save
would write one view's location into another (issue #1388). The location
setters therefore read locationDataset (null while sync.datasetLoading), and
the location is clamped where the dataset lands (clampLocationToDatasetImpl
in setSelectedDataset and at the end of setDatasetViewId). Per-feature
guards in consumers are not needed. Each clamp site must also schedule
updateLastLocationInDatasetView, or the view reopens at the old saved
location. The first cut missed this in setSelectedDataset, which
large-image refreshes reach without going through a view load. Do not clamp an
UNROLLED axis: parseTiles collapses it to one entry, so the clamp sends a
grid-cell click (which sets the frame, then clears the flag) to frame 0. The
roll-up reload clamps it against the real dimensions. And "unrolled" must mean
the shape this.dataset was FETCHED with (datasetUnroll), not the live
unrollZ flag: a refresh started before a toggle can land after it.
The pending save is the reverse problem: it is only accepted while
this.dataset matches datasetView.datasetId, so whatever replaces EITHER one
must flush it first. setDatasetViewId flushes before replacing the view, and
setSelectedDataset before replacing the dataset (Dataset.vue switches
datasets with the old view still loaded). Out-of-range indices also travel in
URLs: the breadcrumb view switcher keeps the current query when it moves to
another dataset. See codebaseDocumentation/DATASET_LOCATION.md and its
regression checklist.
Per-resource component state under a reused route: key it by id
Vue Router reuses a route's component when only a param changes (/project/A
→ /project/B), so any local state in it, or in a child, stays alive across
the switch. That state includes error messages, progress, job subscriptions
and open dialogs, and late responses for A land in it too. On PR #1414,
ZenodoPublish's refusal message for project A showed on project B. Don't
reset the refs one by one in a watch(() => props.project.id): that list is
hand-maintained and misses the next ref someone adds. Key the component by
the resource id in the parent (<zenodo-publish :key="project.id" ...>), so
each resource gets a fresh instance. A late emit from the old instance is
dropped (Vue's emit returns early once isUnmounted). Test it by switching
the resource under a mounted parent and asserting a new child vm
(ProjectInfo.test.ts "gives each project its own ZenodoPublish
instance").
A related trap: work that "resumes" a server-side operation, such as
re-subscribing to a running job, must not run only in onMounted. Run it
whenever the resource reports the state (watch(status, …, { immediate: true })), guard it to once per job, and treat "reports running but no job
exists" as a stale copy to re-read. The store does not refetch cached
resources on navigation.
Every throttle/debounce needs a cancel() in onBeforeUnmount
A trailing call that fires after teardown runs against a dead view — in
AnnotationViewer.vue that means layer.annotations() / layer.draw() on a
torn-down GeoJS map, or a store write from a component that no longer exists.
The teardown block already cancels them; the failure mode is forgetting to add
the new one, which nothing catches because the component unmounts fine and the
trailing call usually lands harmlessly.
Guard it with a test that records the throttles at construction — the
version that listed them by name stayed green while two uncancelled ones
shipped, and a version that scanned wrapper.vm only moved the hand-maintained
list to defineExpose (an unexposed throttle stays invisible there):
// top of the test file — delegates to real lodash, so timing is unchanged
const createdThrottles = vi.hoisted(() => [] as any[]);
vi.mock("lodash", async (importOriginal) => {
const actual = await importOriginal<typeof import("lodash")>();
const record = (w: any) => { createdThrottles.push(w); return w; };
return { ...actual,
throttle: (...a: any[]) => record((actual.throttle as any)(...a)),
debounce: (...a: any[]) => record((actual.debounce as any)(...a)) };
});
// in the test
createdThrottles.length = 0;
wrapper = mountComponent();
expect(createdThrottles.length).toBeGreaterThanOrEqual(7); // recording can break too
const named = createdThrottles.map((fn, i) => [
Object.keys(vm).find((k) => vm[k] === fn) ?? `unexposed#${i}`,
vi.spyOn(fn, "cancel"),
] as const);
wrapper.unmount();
expect(named.filter(([, s]) => !s.mock.calls.length).map(([n]) => n)).toEqual([]);
<script setup> bodies run per instance, so setup-scope throttles are created
during mount and land in the recording. One residual gap: a wrapper built lazily
inside a handler isn't recorded until that handler runs.
Vuetify 4 Patterns
CSS Cascade Layers
Vuetify 4 wraps all styles in CSS @layer declarations. Custom styles (outside layers) automatically win over Vuetify's defaults — no specificity wars.
Key implications:
- Most
!importantoverrides for Vuetify are unnecessary — remove them :deep()selectors targeting Vuetify internals "just work" without specificity tricks- Exception:
@girder/componentsbundles Vuetify 3 CSS (un-layered), so!importantIS still needed when overriding Girder component styles
Light/Dark Mode Theming
// In <script setup>
import { useTheme } from "vuetify";
const theme = useTheme();
const isDark = computed(() => theme.current.value.dark);
<!-- In templates -->
<div :class="{
'v-theme--light': !$vuetify.theme.current.dark,
'v-theme--dark': $vuetify.theme.current.dark
}">
Theme config in src/plugins/vuetify.ts:
defaultTheme: Persister.get("theme", "dark") === "dark" ? "dark" : "light",
Vuetify 4 changed the default theme from "light" to "system". Our config sets it explicitly.
Theme-Aware Styling
Option 1: Vuetify Components (preferred) — auto-inherit theme.
Option 2: Theme classes in SCSS
.v-theme--dark & {
background: rgba(255, 255, 255, 0.05);
}
.v-theme--light & {
background: rgba(0, 0, 0, 0.05);
}
Option 3: CSS Variables
.my-element {
color: rgb(var(--v-theme-primary));
background: rgb(var(--v-theme-surface));
}
The viewer's app bar is already full — measure before widening a control
On the dataset view the app bar holds about 20 controls and already overflows by ~50px at 1024px wide. Anything wider than an icon pushes the right-hand buttons (help, account, Nimbus AI) off screen with no scrollbar and no error; a 264px search-field trigger hid all three anywhere from 960px to ~1300px. Before widening an app-bar control, measure on the viewer route (not the home page, which has half the controls) at 1024 and 1280px:
const ai = document.querySelector('[aria-label="Nimbus AI panel"]');
[innerWidth, Math.round(ai.getBoundingClientRect().right)] // right must be <= innerWidth
Collapse to an icon below lg (1280px) rather than md.
Palette commands registered in a right-hand palette are global unless gated
useCommand registers for the component's lifetime, and the right-hand
FloatingPalettes (Settings, Object Browser, Snapshots) keep their content
mounted on every route via v-show. So a command registered in, say,
ViewerSettings.vue shows on the dataset list too. Gate it with
useInViewer() from src/commands/context.ts, and set each command's
enabled to match its control's :disabled. The left palettes (Navigator,
Layers, Tools) and ImageViewer.vue mount only in the viewer.
A state-flipping palette command names only what it will do
A command whose title flips with state ("Hide minimap" / "Show minimap") must never match a query asking for the state it already has, or Enter does the opposite of what was typed. Seven Codex rounds on PR #1429 each found one more way in:
- synonyms that equate opposites (show = hide);
- loose matching: substring ("lock" in "Unlock", "roll" in "Unroll") or
subsequence ("open" in "Close…"); the scorer now matches the words in
src/commands/stateWords.tsonly as whole title words or keywords; - keywords naming both directions (
["dark", "light"],["add", "remove"]);oppositeKeywords.test.tsscans every array literal for these; - a description stating the current state ("Visible" made "visible dapi" hide the layer);
- a turn-off title that shares a word with the state it ends ("Draw every object, ignoring filters" matched "filter objects").
The rule: title, keywords, aliases and description describe only the
result of running it, and a turn-off title shares no word with the on state
except the neutral subject ("minimap", "scale bar"). Use toggleCommands
with turnOnKeywords / turnOffKeywords, and test both states with the
query a user would type for each.
Select/Combobox Slot Items (No .raw Wrapper)
Vuetify 4 removed the .raw wrapper from select slot items. Items are passed directly. This applies to ALL slot types: #item, #chip, and #selection.
Object items — access properties directly:
<!-- Vuetify 4: access properties directly on object items -->
<v-select :items="items" item-title="displayName">
<template v-slot:item="{ item, props: itemProps }">
<v-list-item v-bind="itemProps">
<template #title>{{ item.displayName }}</template>
<template #subtitle>{{ item.description }}</template>
</v-list-item>
</template>
</v-select>
String items — item IS the string, not a wrapped object. Do NOT use item.title:
<!-- BAD: item.title is undefined on a string — renders empty chips -->
<v-combobox :items="tagList" chips multiple>
<template v-slot:chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ item.title }}</v-chip> <!-- WRONG -->
</template>
</v-combobox>
<!-- GOOD: use item directly for string items -->
<v-combobox :items="tagList" chips multiple>
<template v-slot:chip="{ item, props: chipProps }">
<v-chip v-bind="chipProps">{{ item }}</v-chip> <!-- CORRECT -->
</template>
</v-combobox>
The #item slot name did NOT change (contrary to some sources claiming rename to #internalItem).
v-select shows [object Object] — set item-title to match the item key
Vuetify's VSelect defaults to item-title="title" and item-value="value". If your items are objects keyed differently, the selected display renders the raw object as [object Object] (selection still works because item-value happens to match).
This bit the tool-creation form: every select interface element in public/config/templates.json uses { text, value } items, but the generic VSelect in ToolConfigurationItem.vue set no item-title, so every non-submenu select in the Add-tool dialog rendered [object Object]. Fix: pass item-title="text" (the app's convention) for select elements.
<!-- BAD: items are { text, value } but VSelect looks for `.title` -->
<v-select :items="[{ text: 'Point prompts', value: 'point' }]" /> <!-- [object Object] -->
<!-- GOOD -->
<v-select :items="items" item-title="text" item-value="value" />
When you add a non-submenu select to a tool template, or render options in a v-select, always confirm item-title matches the item objects' label key.
Don't v-model a computed that reads a non-reactive pipeline node
ComputeNode.output / ManualInputNode.output (in src/pipelines/computePipeline.ts) is a plain field, not a Vue ref (pipeline nodes are markRaw'd for perf). A computed whose getter reads node.output registers no reactive dependency, so it never re-evaluates when the node's value changes. Bind a control's v-model to such a computed and the control snaps back to its stale value on the next render — e.g. a dropdown that "looks selected" but always displays the old option, or a slider that jumps back.
// BAD: getter reads node.output (non-reactive) → v-model display reverts
const promptMode = computed({
get: () => promptModeNode.value?.output ?? "point",
set: (v) => promptModeNode.value?.setValue(v),
});
// GOOD: a reactive ref is the UI source of truth; push into the node on change,
// and seed the ref from config/state on mount + when the tool state changes.
const promptMode = ref<TPromptMode>("point");
watch(promptMode, (v) => segState.value?.nodes.input.promptMode.setValue(v));
Reactive state fields (from reactive(...) in the tool-state factory) are fine to read in computeds — only raw markRaw'd node .output reads are the trap.
VRow Density — and dense everywhere else
On <v-row>, the boolean dense prop is deprecated. Use density="comfortable":
<v-row density="comfortable" align="center">
The substitution is visually identical — VRow maps
density === 'comfortable' || dense to the same v-row--density-comfortable
class, so dense still works and the only symptom is
[Vuetify UPGRADE] 'dense' is deprecated in the console. Don't trust
Vuetify's own JSDoc here: makeVRowProps says @deprecated use density="compact" while the runtime warning and the class mapping both say
comfortable. comfortable is the behavior-preserving one.
The warning is one-shot per mounted row, not per render. deprecate() is
called from VRow.setup(), so re-rendering or updating an existing row never
repeats it — which is exactly why you cannot "re-trigger" it by toggling the UI
that contains it, and why it is usually already gone by the time you attach a
console listener. See the in-browser-testing skill for the capture order this
forces.
VRow is the only component that warns. deprecate('dense', …) is called
in exactly one place in Vuetify 4 (VRow.setup()). So a dense on anything
else is silent — and dead: VCard, VListSubheader, and our own
tag-picker / docker-image-select / property-worker-menu declare no dense
prop, so Vue passes it through to the root element as a stray DOM attribute that
styles nothing.
Delete those; don't convert them. VListSubheader has no density prop
either, so a swap is just a different dead attribute. VCard does have one,
so a swap there newly tightens title/subtitle/text padding — an unrequested
visual change. This is the trap in a scripted sweep: a regex that rewrites
dense → density="comfortable" on every tag it matches is wrong on most of
them, and a regex restricted to <v-[a-z-]+ misses the custom-component
instances entirely (they need the opposite treatment, so they can't just be
ignored).
src/vuetifyDeprecations.test.ts scans every .vue template for a boolean
dense on any tag and fails the build, so this can't silently come back.
Extend that test rather than hand-grepping when auditing a new Vuetify
deprecation.
Icon names: check @mdi/font 5.9.55, not the MDI website
@mdi/font here is pinned at 5.9.55, several major versions behind what
mdi.dev documents. A name that doesn't exist in the installed font fails
silently and invisibly: Vuetify sets the class, no ::before rule matches,
and the icon renders as blank space. tsc, lint and every component test stay
green — the only symptom is a gap a human notices in a screenshot, which is
exactly how mdi-gradient-horizontal shipped on a menu item. The sweep that
followed found two more (mdi-sitemap-outline, added after 5.x, in two places;
mdi-save, renamed to mdi-content-save in 5.x).
Never write an icon name from memory or from current MDI docs — grep the installed font:
grep -c '^\.mdi-sitemap-outline::before' node_modules/@mdi/font/css/materialdesignicons.css # 0 → blank icon
src/__tests__/mdiIconNames.test.ts enforces this across all of src/, so a
bad name now fails the suite instead of shipping. Its one exclusion is itself
(it quotes non-existent names in its own prose). In the browser, the direct
evidence is getComputedStyle(el, "::before").content — a codepoint means the
glyph resolved, none/normal means the class matched no rule.
Common 5.9.55 gotchas: no -outline variant for many icons; save →
content-save; gradient has no -horizontal/-vertical suffix.
v-menu / v-dialog Initial State
Vuetify 4's v-menu respects the initial v-model value immediately on mount. Vuetify 3 deferred it. If you set v-model to true before mount, the menu WILL open. Guard with conditions:
// Only auto-open when appropriate
menuOpen.value = route.name === "root" && !store.isLoggedIn;
Global Defaults
Configured in src/plugins/vuetify.ts. Vuetify 4's default density is tighter than V3. We set density: "comfortable" for list/checkbox components to maintain V3 spacing (needed for @girder/components compatibility):
defaults: {
VList: { density: "comfortable" },
VListItem: { density: "comfortable" },
VCheckbox: { color: "primary", density: "comfortable" },
VCheckboxBtn: { density: "comfortable" },
}
@girder/components Compatibility
@girder/components@4.0.0 depends on vuetify: ^3.10.1 — no Vuetify 4-compatible version exists yet. Key issues:
- Girder bundles ~6.9MB of Vuetify 3 CSS (un-layered), which competes with Vuetify 4 layered CSS
!importantis still needed when overriding Girder component stylesCustomFileManager.vuehas targeted CSS overrides for the file manager table layout- See
codebaseDocumentation/VUETIFY4_MIGRATION.mdfor full details
GirderFileManager Prop Names
GirderFileManager (from @girder/components) uses Vuetify 3 prop naming, not Vuetify 4. Key props:
itemsPerPage(kebab:items-per-page) — sets default page size. NOTinitialItemsPerPage.itemsPerPageOptions(kebab:items-per-page-options) — array of page size choices.
These props are defined in node_modules/@girder/components/src/components/FileManager.vue. If you use a wrong prop name, it silently falls through as an unrecognized attribute and the component uses its internal default (10).
Overriding Girder DataTable Row Styles
Girder's DataTable.vue renders a v-data-table-server with <tr> > <td> rows. The DOM structure is:
<tr class="v-data-table__tr">
<td class="...">checkbox</td>
<td>icon + #row slot content</td>
<td class="text-right">file size</td>
</tr>
To override row styles from a parent component:
- Use unscoped
<style>blocks (scoped styles can't reach into Girder internals) - Target
table trandtable tr td— these cover both raw elements and Vuetify class selectors (.v-data-table__tr,.v-data-table__td) since they're the same DOM nodes. No need to duplicate selectors for both. !importantis required because Girder's bundled Vuetify 3 CSS is un-layered- Scope overrides with a parent wrapper class (e.g.,
.browse-expanded .custom-file-manager-wrapper) to avoid leaking globally
Persisting User Preferences with Persister
For UI preferences that should survive page reloads (expand/collapse states, view modes, etc.), use Persister from @/store/Persister:
import Persister from "@/store/Persister";
// Read with default
const expanded = ref(Persister.get("myPreferenceKey", false));
// Write on change
function toggle() {
expanded.value = !expanded.value;
Persister.set("myPreferenceKey", expanded.value);
}
Persister wraps localStorage with JSON serialization. It's already used for theme, tour status, and browse mode preferences.
Dialogs
<v-dialog v-model="dialogOpen" max-width="600px">
<v-card>
<v-card-title>Title</v-card-title>
<v-card-text>Content</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="dialogOpen = false">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
Wide dialogs: class="wide-dialog" when using percentage or vw widths
Vuetify ships .v-dialog { width: 50% } on the outer overlay wrapper. The width / max-width props on <v-dialog> only size the inner .v-overlay__content — so width="60%" actually renders at 60% of that 50% box (= 30% of viewport), and width="70vw" is silently clamped to 50vw.
Whenever a dialog needs a percentage or vw width, opt in with class="wide-dialog". The shared rule lives in src/style.scss (look for .wide-dialog.v-dialog { width: auto }) and lets the prop size against the viewport directly.
<!-- Bad: width prop silently shrinks to 30% of viewport -->
<v-dialog v-model="open" width="60%">…</v-dialog>
<!-- Good: class lets the 60% prop apply to the viewport -->
<v-dialog v-model="open" width="60%" class="wide-dialog">…</v-dialog>
When not to add the class:
max-widthin pixels (e.g.max-width="500px") — works correctly without the class on any reasonable screen.max-width="33vw"and similar —vwmax-widths smaller than 50vw fit inside the default wrapper, so the class is unnecessary.- Dialogs with no width prop — they rely on the implicit 50% wrapper as a sane default; adding the class would let them shrink to content width, which is usually not what's wanted for confirmation-style dialogs.
If you see a dialog with a width="N%" or width="Nvw" prop and no wide-dialog class, it's almost certainly rendering narrower than intended — add the class.
Click-outside close runs after the outside click — and closes on drags
Vuetify's v-click-outside (used by every non-persistent v-dialog / v-menu) runs its close inside a setTimeout, i.e. after the outside click has bubbled through its own handlers. When the dialog's @update:model-value(false) handler clears shared state, it can undo what that same click just did: in the worker-tool dialog (Toolset.vue), clicking another worker tool selected it, and then the deferred close ran setSelectedToolId(null) and deselected it again. It also fires for a drag whose mousedown and mouseup both land outside, so on a scrim-less dialog over the image, panning the map closes the dialog.
Neither case can be told apart from inside update:model-value. click:outside is emitted synchronously just before it, with the click event, so veto there. Snapshot {clientX, clientY, relevant state} in a capture-phase pointerdown listener (registered only while the dialog is open, removed in the watcher's onCleanup), then in @click:outside set a one-shot veto when the state changed or the pointer moved more than a few px. Consume the flag in update:model-value. Escape emits no click:outside, so it still closes. See onWorkerDialogClickOutside in Toolset.vue and its tests in Toolset.test.ts.
A nested overlay (a v-select menu inside the dialog) is the top of the overlay stack, so the first outside click closes only that menu and the second closes the dialog. That is expected, not a bug. Tooltips (_disableGlobalStack) don't take part in the stack.
Controlling a dialog from outside: two traps
Both shipped in the command palette's first cut, when the Data I/O dialogs
moved out of their v-menu so the palette could open them:
- A dialog created already open never fires its open watcher. The CSV
preview and the index-conversion labels are built in non-immediate
watch(dialog, ...)callbacks. Mounting the dialog (v-if) and setting itsv-model:openin the same tick creates it withdialog === true, so the watcher never sees false → true and the first open shows blank state. Mount it closed,await nextTick(), then open it (DataIOMenu.vueopenDataDialog). - An empty slot renders the slot's fallback. Passing
<template #activator />to silence a component's default activator button does nothing: Vue treats slot content that renders only comments as absent and shows the fallback, so the dialog's own "Export CSV" button appeared in the app bar. A dialog that is always opened from outside should have no activator at all; delete it rather than overriding it with nothing.
Reused dialogs that write to the server: two lifetimes
A dialog instance is reused across close/reopen and across a changing
dataset prop (and several instances can exist — one per list row). Its
async handlers mix two things with different lifetimes, and every race
found in ShareDataset.vue (PR #1403 took ~10 Codex rounds) was one treated
as the other:
- What the dialog shows — loaded lists, notices/errors, typed inputs,
pending confirmations. Scoped to one session (open period × dataset).
Capture a session claim before each
awaitand write back only while it holds; bump the session on every reset (close and dataset switch — the dataset id alone recurs, A → B → A). Keep every such field in one reset function used by both paths. Loads are latest-wins counters. - Server operations (create/change/remove/publish/revoke) — they keep
running after the dialog closes or unmounts. Their in-progress guards
must live in a module-level registry keyed by what they change
(
utils/pendingMutations.ts,utils/pendingPublishes.ts), shared by all instances, with UI flags computed from it — never component refs a reset clears. When one settles, every open dialog showing that resource refreshes from the server (quietly: keep selection and notices), or a dialog that loaded mid-change keeps stale rows.
Test it with a never-resolving-until-told promise, resetState()/setProps
in between, then resolve. Module-level registries leak between tests via
still-mounted wrappers: use enableAutoUnmount(afterEach), or a "refreshes"
assertion can pass because a previous test's open dialog did the refresh.
API Calls
Use the API classes from store — never put girderRest.get(...) in components:
import store from "@/store";
const result = await store.api.someMethod();
Your own rights come from the server's effective level, never an ACL row
Don't decide what the current user may do by finding store.girderUser._id in an access list's users. That list holds only direct entries. Someone with access through a Girder group, or a site admin, has no row there, so the UI hides controls the server would allow. Codex caught this on the Share dialog (PR #1406). The access endpoints return currentUserLevel (Girder getAccessLevel) for this. For a Girder document already in hand, its _accessLevel is the same value. Two follow-ons:
- It's derived state. After changing the caller's own entry, re-read it from the server: a group may still grant access. Removing your last access turns the access list itself into a 403, so fail closed (
-1) instead of keeping the old level. - Re-read narrowly. Reloading the whole dialog after a self-change reset the user's collection selection.
Replacing girderRest must reach the API classes too
Every API class (api, annotationsAPI, spatialAPI, …) talks to Girder through
girderRestProxy, not through state.main.girderRest directly. Two credentials are
therefore in play, and a change to one is not a change to the other. The proxy must
resolve the live module state (liveGirderRest): the object a class-field
initializer sees as this is the construction-time instance, which a mutation never
updates. Before the fix, openShareLink committed the link's own client to state while
every API call kept the boot client — so a share view ran as the owner's stored login
(full write access) or, for a recipient with no login, anonymously (private dataset
fails). Its tests asserted main.girderRest.token and passed; the regression test now
also asserts main.api.client.token (index.test.ts, "commits only the latest
validated link"). When you swap or re-token the client, assert on an API class's
client, and check live with store.state.main.api.client.get('user/me') — not with
girderRest.
An optional plugin's missing route is a network error, not a 404
A Girder route that was never registered (its plugin is not installed) gets
cherrypy's bare 404 without CORS headers, and its preflight gets 405. From the
dev server or any cross-origin frontend the browser blocks it, so axios reports
Network Error with no error.response. Code that maps 404 to "nothing here" therefore
reads "plugin missing" as "could not ask". This put a Transcripts error button on every
dataset when the server lacked upenncontrast_spatial. Don't probe an optional
plugin's routes to discover it. Ask GET system/loaded_plugins (public, cached per
apiRoot; see SpatialAPI.isPluginLoaded) and skip the calls when it is absent. To
test, run a second Girder from the same image with the plugin pip uninstalled on another
port, and point a dev server at it with VITE_GIRDER_URL plus the persisted girderUrl
localStorage key, which overrides the env var.
Palette content stays mounted: gate server work on visibility
FloatingPalette hides a closed palette with display: none; its content is
mounted at app boot and stays mounted. So onMounted runs for every user, and
watchers keep firing while nobody can see the panel. Anything that costs
server work must take a visibility prop and defer: the Object Browser's
server-backed list used to refetch on every filter or gate change with its
palette closed — with an analysis gate that is a whole-dataset gate
resolution (8 s on 709K objects) competing with the gate the user just drew.
Pattern: App.vue passes the palette's open state (:visible="annotationPanel"),
the container forwards visible && activeTab === '…', and the component
records a deferred request while hidden and runs it once when shown
(AnnotationList.fetchServerPageWhenShown; AnalysisPanel's required
visible prop is the same idea). Test both halves: nothing while hidden, one
catch-up fetch when shown.
An omitted optional boolean prop is false, not undefined
Vue casts an absent Boolean prop to false. So defineProps<{ visible?: boolean }>() plus a check like props.visible !== false ("undefined means
visible") reads every caller that omits the prop as hidden. A :visible
gate threaded down to PropertyCategoryFilter silently stopped all value
loading this way until tests caught it. When the omitted case should mean
true, use withDefaults(defineProps<...>(), { visible: true }).
Opening a palette from a component that has no palette registry
App.vue owns palette (right/left panel) visibility in local refs, so a
component mounted under the route tree — anything inside ImageViewer /
AnnotationViewer — cannot open one by emitting an event. Ask through the
main store instead: store.requestPaletteOpen(["analysisPanel", "filtersPanel"]) sets paletteOpenRequests; App.vue watches it, opens each
in order, and clears the list. Order matters — open the primary palette
first, then its companion (Filters hosts alongside Analysis and the Object
Browser); the other order closes the palette just opened.
TRequestablePalette in model.ts is an alias of PanelId from
src/utils/panelRegistry.ts (the single list of palettes and their
primary/companion rules), so a renamed palette is a compile error rather than
a click that does nothing. Same shape as the older
isAnnotationPanelOpen hatch used by the Timelapse panel.
A count computed after filtering must say it was filtered
Every count the UI prints from filteredAnnotations, viewportAnnotationCount,
or any id set that survived filters/gates is a filtered number. Printed
without a cue, it reads as data loss the moment a filter is restored from a
saved configuration — the reported case was a HUD reading "Showing 826 of 826
in view" in a viewport visibly holding thousands, because a saved lasso gate
cut 708,983 to 72,925.
- The cue belongs next to the number, not on a palette badge across the window. A badge that was visible the whole time did not prevent the report.
- Count constraints through
src/utils/activeConstraints.ts—collectActiveConstraints/countActiveConstraints— never with a fresh ad-hoc sum. The Filters badge, the Analysis badge and the HUD suffix all read that one list; a new narrowing filter that skips it is invisible on all three. SeecodebaseDocumentation/ACTIVE_CONSTRAINT_CUES.md.
Logging
Never use console.log, console.warn, or console.error — eslint will reject them.
import { logWarning, logError } from "@/utils/log";
logWarning("Something unexpected happened");
logError("An error occurred", error);
Error Reporting (Sentry)
@sentry/vue is wired in src/main.ts, gated on VITE_SENTRY_DSN at build time. When the DSN is unset, no Sentry code is loaded — local installs and OSS users pay zero runtime cost. Uncaught Vue errors and async exceptions (window.onerror/unhandledrejection) are reported automatically via the Vue integration installed at init; you don't need to wrap component code in try/catch just to report errors.
To capture an error or message manually from a component, dynamic-import the package so the no-DSN path stays free of any Sentry reference:
if (import.meta.env.VITE_SENTRY_DSN) {
const Sentry = await import("@sentry/vue");
Sentry.captureException(err, { tags: { feature: "my-feature" } });
}
In practice almost no component should need this — let the global handler do its job. Local testing: see CLAUDE.md § "Error Reporting (Sentry)" for .env.local setup and the setTimeout test recipe.
Buttons — five-role taxonomy
Every <v-btn> should declare an explicit variant and size. Omitting them falls back to Vuetify's elevated default at default size, which looks generic and out of place against the Linear-inspired theme.
| Role | Props | Use |
|---|---|---|
| Primary | variant="flat" color="primary" size="small" |
The one main action of a view or dialog |
| Primary positive | variant="flat" color="success" size="small" |
View / Go / Start CTAs |
| Secondary | variant="outlined" color="primary" size="small" |
Supporting actions |
| Tertiary / text | variant="text" size="small" |
Cancel, low-emphasis, inline |
| Destructive (confirmed) | variant="flat" color="error" size="small" |
The irreversible button in a confirm dialog |
| Destructive (inline) | variant="text" color="error" size="small" |
The trigger that opens a confirm dialog (use mdi-delete, not mdi-close) |
| Informational | variant="text" color="info" size="small" |
View log / inspect detail / open help — actions that read rather than mutate |
| Icon-only | variant="text" icon size="small" |
Toolbar / row actions; wrap in v-tooltip if ambiguous |
Color tokens — never use literals. Use error/success/warning/secondary, not red/green/orange/grey. Semantic tokens are theme-aware.
Dialog action bar pattern:
<v-card-actions class="button-bar">
<v-btn variant="text" size="small" @click="close">Cancel</v-btn>
<v-btn variant="flat" color="primary" size="small" @click="save">Save</v-btn>
</v-card-actions>
Never two filled buttons. For destructive confirms, swap color="primary" → color="error" on the right button.
:to vs @click: a v-btn with :to renders as <a>, one with @click renders as <button>. src/style.scss makes form elements inherit font-family so they match; for groups of buttons that must look identical, use the same action type across all of them so they share the underlying tag.
Full guide: codebaseDocumentation/BUTTON_CONVENTIONS.md
Loading state
<v-btn
variant="flat"
color="primary"
size="small"
:loading="isLoading"
:disabled="isLoading"
@click="doAction"
>
<template v-slot:loader>
<v-progress-circular indeterminate size="18" width="2" class="mr-2" />
Loading...
</template>
<v-icon start>mdi-check</v-icon>
Submit
</v-btn>
Memory Diagnostics
window.__nimbusMem is registered globally for browser-console memory monitoring (zero overhead unless enabled via __nimbusMem.enable()). Useful when investigating memory leaks, comparing memory pressure across changes, or sanity-checking a new cache.
Quick API: __nimbusMem.enable(), snapshot('label'), print(), compare('a','b'), export().
For the full API, recorded fields, the load-order constraint (don't import stores at top level — register from main.ts), instructions for adding new counters or auto-snapshot points, and the cherry-pick procedure for cross-branch comparison: read codebaseDocumentation/MEMORY_DEBUGGING.md.
ONNX / SAM Pipeline (src/pipelines/onnxModels.ts, samPipeline.ts)
The SAM tools run ONNX models (onnxruntime-web, WebGPU) whose WASM loaders and model files are fetched from /onnx-wasm/ and /onnx-models/. Three failure modes here have bitten us in production only — they pass locally because the Vite dev server serves assets instantly and with correct MIME types.
.mjsserved astext/plain(prod nginx). Modernonnxruntime-webdynamicallyimport()s.mjsWASM loaders (e.g.ort-wasm-simd-threaded.asyncify.mjs) fromenv.wasm.wasmPaths. Production static files are served by nginx in the AWSDeploy repo (templates/startup_haproxy.tftpl), which uses stockinclude mime.types;. Stockmime.typesmaps.jsbut not.mjs, so.mjsfalls through totext/plainand the browser refuses the ES-module import ("Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of text/plain")..wasmis unaffected (it is in stockmime.typesasapplication/wasm), which is why pre-.mjsversions never hit this. Fix lives in AWSDeploy (types { text/javascript mjs; }), not in this repo — don't add a frontend workaround. Also:env.wasm.wasmPathsmust be root-absolute (/onnx-wasm/), or the loader specifier resolves against the current SPA route path.Concurrent session creation →
multiple calls to 'initWasm()' detected. onnxruntime-web initializes its shared WASM/WebGPU backend lazily on the firstInferenceSession.create(), and that init is not reentrant. The SAM pipeline creates the encoder (samPipeline.tscreateEncoderSession) and decoder (createDecoderSession) sessions from two independentComputeNodes that fire on the same tick, so both creates race the backend init. The window is sub-millisecond locally (loaders served instantly) but wide in prod (each is a network fetch), so it fails deterministically only when deployed.createOnnxInferenceSessionserializes thecreate()step through a module-level promise chain so the backend initializes exactly once — keep it that way. Model downloads stay parallel; onlycreate()is gated. (Pipeline-node compute errors logthis.fun.name, which is minified in prod —[f2n]/[p2n]are the mangled session-creation functions, not meaningful names.)HTML-shell cache poisoning. A missing model path is answered with the
index.htmlapp shell at HTTP 200 (SPA fallback); caching that permanently breaks the tool ("Failed to load model because protobuf parsing failed", INVALID_PROTOBUF).fetchModelBuffer/warmModelCachedetect it (content-type + first byte0x3c<) and self-heal by dropping the poisoned cache entry.
General promise pattern (Codex P2, PR #1237): when you start an async op eagerly but defer its consumer behind a chain — chain.then(() => started.then(...)) — the started promise can reject before any handler is attached, which the runtime reports as an unhandled rejection (noise in the console and in Sentry) even though a later handler eventually catches it. Settle it into a non-rejecting result the instant it starts, then re-throw inside the chain:
const settled = started.then(
(value) => ({ ok: true as const, value }),
(error) => ({ ok: false as const, error }),
);
const gated = chain.then(() =>
settled.then((r) => {
if (!r.ok) throw r.error;
return use(r.value);
}),
);
Native File / Folder Pickers (src/utils/fileUpload.ts)
Folder upload (picker button + drag-and-drop) is centralized in src/utils/fileUpload.ts: selectFiles(), selectFilesFromFolder(), getFilesFromDrop(event), and filterFilesByAccept(files, accept). Extraction is delegated to the file-selector library (recurses into dropped folders, normalizes <input> change events). Two traps here each shipped as a silent "pick a folder and nothing happens":
Never use a window-
focustimeout to detect dialog cancellation. The detached-<input>trick (document.createElement('input'); input.click()) needs to know when the dialog closes. A tempting fallback is "when the window regains focus, wait Nms; if nochangefired, treat as cancel." This loses a race forwebkitdirectoryfolder picks: Chrome interposes an "Upload N files to this site?" confirmation, andinput.filesisn't populated (nochange) until the user accepts it — often seconds after the window already refocused when the OS dialog closed. The focus-timeout fires in that gap and resolves with an empty selection, discarding the real folder (measured live: focus at +4.5s, timeout resolves[]at +5.0s, realchangeignored at +7.0s). Regular single-file picks have no confirmation dialog, sochangewins the race there — which is why the bug looks like "only folders are broken." No focus heuristic can ever work for folders because focus returns before the files are known. Rely on the standardizedcancelevent (dismissal) +change(selection); both are supported in every browser we target (Chrome 113+, Firefox 91+, Safari 16.4+), so no fallback is needed.A picker promise that never resolves is indistinguishable from "nothing happened."
file-selector'sfromEventcan reject (an unreadable directory entry, agetAsFileSystemHandlefailure). If theawait fromEvent(...)inside the picker's settle handler throws after thesettledguard is set,resolve()is never called and everyawait selectFilesFromFolder()hangs forever; on the drop path it surfaces as an unhandled rejection. Wrap extraction in try/catch →logError(...)+ resolve/return[]so an error degrades to "no selection."acceptfiltering is caller-applied for folder/DnD only. The native<input :accept>is enforced by the browser for click-to-select, but folder selection and drag-and-drop bypass it entirely — that's whyfilterFilesByAcceptexists and is called on those two paths (not on the nativeonChange). Caveat: folder/DnD files frequently have an emptyfile.type(browsers assign no MIME to.tif/.nd2/.czi), so prefer extension tokens (.tif,.nd2) over MIME tokens (image/*) in anyacceptyou pass — a MIME token silently drops empty-typefiles.
Style Guidelines
- Use scoped SCSS:
<style lang="scss" scoped> - Prefer Vuetify components over custom HTML
!importantis rarely needed thanks to CSS Cascade Layers — only use for overriding@girder/componentsor non-Vuetify third-party styles- Keep custom colors as SCSS variables at the top of style blocks
Verification Gates
Before claiming a frontend change done:
pnpm tsc— type checkpnpm lint:ci— zero warningspnpm test— vitest. Known artifact: after a backendtoxrun, ~10 test FILES under.tox/**fail ("Failed to resolve import @playwright/test") — vitest's glob picks up girder's bundled specs. These are spurious; only failures outside.tox/paths are real. CI is unaffected (clean checkout).- In-browser verification for anything user-facing — tsc/lint/vitest green does not mean the UI works (pointer-events, layering, watcher-firing, and store-corruption bugs all passed every static gate). See the in-browser-testing skill; remember to hard-reload after store edits.
Component-level test patterns (AnnotationViewer harness, GeoJS mocks): see the nimbus-geojs skill and codebaseDocumentation/FRONTEND_COMPONENT_TESTING.md.
A mock that cannot represent the bug makes its tests meaningless
Worse than a mock returning the wrong constant is a mock that models none of
the real action's effect. addAnalysisPlot was a bare vi.fn(), so
mockFilters.analysisPlots stayed empty no matter what the code under test
did. Nine tests passed against it — and none of them could observe whether the
executor's plot had actually landed, which is precisely the state the bug
produced (the store refuses at its cap by no-oping, and the executor went on
to configure and report a plot that did not exist).
The rule: a mocked action must reproduce the state change its caller depends on, including its refusal behavior. If the real action appends, the mock appends; if the real one silently no-ops past a cap, the mock does too. When a test needs extra side effects on top, factor the default into a helper and call it, rather than replacing the implementation and silently dropping the effect the code under test is checking for:
function appendAnalysisPlot(id: string) { /* what the real action does */ }
beforeEach(() => { mock.addAnalysisPlot.mockImplementation(appendAnalysisPlot); });
// A test layering extra behaviour composes rather than replaces:
mock.addAnalysisPlot.mockImplementation((id) => { appendAnalysisPlot(id); ...extra... });
Also reset such state in every describe's beforeEach, not just the
first — a test that flips a cap flag or swaps an API stub mid-await leaks it
into every later block.
A mock that returns a fixed value can fail your test for the wrong reason
Shared mocks in this repo return constants chosen for the tests that existed when they were written, and a new test inherits them silently. The failure looks like a bug in the code under test, not in the harness.
geojs.util.distance2dToLineSquaredreturns 100 andpointInPolygonreturns false inAnnotationViewer.test.ts. Any line hit test compares against a squared tolerance (36 for the 6 px connection tolerance), so it can never match until the test setsmockReturnValue(1).mockGeoJSAnnotationdoesn't derivecoordinates()from theverticesoption, so a feature built by the real draw path has correctoptions()and no usable geometry.geojsAnnotationFactorydrops its options argument unless you re-forward it — assertions on a feature's constructedstyleseeundefined.
Unhandled rejections are invisible in tests by default — twice over
vitest.config.js sets dangerouslyIgnoreUnhandledErrors: true, so an abandoned promise that later rejects never fails a run. Listening with process.on("unhandledRejection", spy) (then waiting a setTimeout turn, not a microtask flush) isn't enough on its own: a promise returned by a vi.fn is already "handled", because vitest attaches handlers to record mock.settledResults. A test for "the superseded call's connections request rejects unobserved" passed with and without the fix until the request came from a plain function instead of a vi.fn (fetchAnnotationsStale.test.ts, PR #1380). Any test about an unobserved rejection must produce the promise from a non-spy function.
Before concluding "the code doesn't work", check what the relevant mock actually returns. Equally: when a component test needs a component to do something, prefer asserting the side effect the component owns over re-deriving geometry through the mock.
Unmount components that register global listeners. A wrapper left mounted by an earlier test keeps its window listener attached, so the next test's dispatch fires it too and a spy is called twice. Track the wrapper and unmount it in afterEach. If you see "expected 1 call, got 2", suspect a leaked mount before suspecting the code — and then ask whether the product can also mount that component more than once, because that is the same bug in production.
The same leak bites through reactive shared store mocks: a component that writes into a mocked store from a watcher (e.g. AnnotationList publishing its page to montage.listPageItems) keeps doing so from every wrapper earlier tests left mounted. Flip a reactive flag they all watch and the mock's final value can come from a stale instance. Assert on the instance's own computed (wrapper.vm.x), or trigger the write through state only that instance owns (its own page), rather than reading the shared mock's last value.
Codebase Documentation References
- Vuetify 4 migration details: read
codebaseDocumentation/VUETIFY4_MIGRATION.md - Button taxonomy and patterns: read
codebaseDocumentation/BUTTON_CONVENTIONS.md - When working on batch processing: read
references/batch-processing-patterns.md - When working on projects feature: read
codebaseDocumentation/PROJECTS.md - When working on sharing UI: read
codebaseDocumentation/SHARING.md - When working on annotation combining: read
codebaseDocumentation/COMBINE_ANNOTATIONS.md
Snapshot/export validation must inspect the actual artifact
A successful download click can produce a ZIP of empty or mislabeled images. For image exports, decode the downloaded files and check format, dimensions, coordinate coverage, and distinct pixels where expected. TIFF must bypass a browser canvas scalebar path: canvas cannot decode TIFF and PNG re-encoding would discard the original TIFF data. A scaled style with no frame can default to frame zero on the server; reject missing planes and empty layer selections before building export styles. Reject empty/inverted/nonfinite crops and empty binary responses instead of offering a plausible archive.
Capture export inputs before the first await, including nested layer contrasts, per-crop scalebar geometry/color/text, and format/dimension selections. Disabled controls do not protect against changes from other panels or navigation.
Numeric field tests must cover both emitted strings and numbers, with a nonzero
origin: "100" + 128 becomes "100128". Width/height setters must add numeric
sizes to the origin consistently; do not preserve a test that accidentally
asserts a width is an absolute right coordinate. See Snapshots.test.ts and
utils/screenshot.test.ts for artifact and crop regression coverage.
Check single-file and ZIP paths together: a direct download URL does not carry an Axios/Girder authentication header. Both paths must use the authenticated client before offering a local Blob, especially when disabling a canvas overlay changes which path a format takes.
Snapshot locations and display-layer channels use slider indices, but
dataset.images(z, time, xy, channel) is keyed by metadata coordinate values.
Use getLayerImages for layer validation and styles; for raw exports, map
location indices through the dataset arrays while preserving channel IDs from
the raw-channel selector. Test with the real parseTiles lookup and sparse,
nonzero values on every axis: an index-agnostic image mock hides this mismatch.