UX Lessons Learned
Practical lessons from building and debugging Evalium's shared frontend components. Each row records the issue, root cause, fix, and why the fix is preferred.
Lessons Table​
| # | Lesson | Problem | Root Cause | Solution | Why This Is the Best Approach |
|---|---|---|---|---|---|
| 1 | SvelteKit pushState() does NOT update $page.url | Context Drawer never opened when clicking entity links. openDrawer() called pushState() with the new URL, but the reactive chain $page.url → drawer → open never fired. | SvelteKit's pushState() only updates $page.state and the browser URL bar cosmetically via history.pushState(). It deliberately does not call its internal update_url(), so $page.url remains frozen at the last full navigation. clone_page(page) clones with the old URL. | Derive drawer state from $page.state.drawer (primary) with parseDrawerState($page.url) as a fallback for deep links. Pass drawer state into the pushState/replaceState state parameter. Use location.href (not $page.url) when comparing current URLs. | $page.state is the only reactive channel that pushState actually updates. URL params remain for deep-link shareability, but reactivity must flow through state. The fallback covers initial page loads where $page.state is empty but the URL contains drawer params. |
| 2 | Bits UI Dialog.Root requires bind:open, not just {open} | After switching Dialog.Root from bind:open to one-way {open}, the dialog stopped responding to state changes entirely. | Bits UI's Dialog uses $bindable with boxWith (from svelte-toolbelt) internally. The boxWith setter updates the bound prop and fires onOpenChange. With one-way {open}, the internal boxWith getter reads the prop but the setter can't push changes back, breaking the reactive loop that Dialog relies on to track open/close transitions. | Restore bind:open on <Dialog.Root>. The parent (ContextDrawerShell) passes open={!!drawer} one-way to DialogSheet, and DialogSheet uses bind:open internally with Bits UI. | Bits UI components are designed around two-way binding. Fighting this pattern creates subtle state divergence. The architecture keeps the single source of truth in the URL/state layer while letting Bits UI manage its own internal lifecycle (focus trap, scroll lock, portal cleanup) through the binding. |
| 3 | Don't wrap always-mounted dialog components in {#if} blocks | Back button closed the drawer visually, but scroll lock persisted and focus was not restored. Sometimes the dialog left orphaned portal elements in the DOM. | The {#if drawer} conditional around <ContextDrawerShell> destroyed the entire component tree (including the Bits UI Dialog) instantly when drawer state became null. This prevented Bits UI from running its cleanup sequence (removing scroll lock, closing portal, restoring focus). | Remove the {#if drawer} wrapper. Keep the component always mounted and drive visibility with open={!!drawer}. The Dialog handles its own show/hide lifecycle internally. | Bits UI Dialog manages significant side effects (scroll lock, focus trap, portal insertion, ARIA attributes). These require an orderly close transition, not an abrupt unmount. Keeping the component mounted and toggling open lets Bits UI clean up properly on every close path (ESC, overlay click, Back button, programmatic close). |
| 4 | Tab changes inside a drawer must not route through openDrawer() | Switching tabs inside an open drawer caused the focus-return target to change from the original entity link to the tab button just clicked. Closing the drawer then returned focus to a tab button that no longer existed. | onTabChange was calling openDrawer(), which captures document.activeElement as the new focusReturnTarget. When switching tabs, activeElement is the tab button, not the original entity link that opened the drawer. | onTabChange now calls updateUrl() directly (with replace mode) instead of openDrawer(), bypassing the focusReturnTarget capture. | focusReturnTarget should only be set when the drawer is first opened by a user click. Content changes within an already-open drawer (tab switches, entity replacement) should update the URL without touching focus state. This matches WAI-ARIA dialog patterns where focus return is determined at open time, not during internal navigation. |
| 5 | EntityLink must use pushState() not goto() for drawer-enabled entities on the current surface | After closing a drawer, clicking the same entity link (or any entity link) failed to re-open the drawer. The drawer appeared stuck closed. | EntityLink originally used goto() to navigate with drawer params in the URL. goto() updates $page.url. But closeDrawer() uses replaceState(), which does not update $page.url (Lesson #1). After closing, $page.url still contained the stale drawer params from the goto() call. The derived drawer state fell through from $page.state.drawer (now undefined) to the fallback parseDrawerState($page.url) — which still had the old drawer params, making the drawer think it was already open or preventing re-navigation (same URL = no-op). | Changed EntityLink.onClick() to use pushState() instead of goto(), passing drawer state via $page.state.drawer. Now both open and close operate through the same $page.state reactive channel. | goto() and pushState()/replaceState() update different reactive stores. Mixing them creates state divergence where $page.url and $page.state disagree. All drawer operations (open, close, tab switch) must use the same channel — pushState/replaceState with $page.state — to keep the reactive chain consistent. The URL params remain for deep-link shareability but are only the fallback, not the primary source. |
| 6 | Schema-driven inventory specs must sanitize filters on view restore | Switching from Questions to Evaluations and restoring a saved view could inject question-only filter keys (e.g., tagsAny, qtype) into the evaluation query state, causing silent API errors or unexpected filtering behavior. | OpenAPI schemas use additionalProperties: false on filter objects. Each inventory type has its own allowed filter keys. Saved views store raw filter objects that may contain keys from a different spec. | sanitizeFiltersForType() strips disallowed keys before applying a view's filters. Called both when restoring a view and when creating a new one. The allowed-key sets (COMMON_FILTER_KEYS, QUESTION_EXTRA_KEYS, EVALUATION_EXTRA_KEYS) mirror the OpenAPI schema constraints. | Enforcing additionalProperties: false at the UI layer prevents the backend from rejecting requests with unknown fields. Sanitizing at save time ensures persisted views are always clean. Sanitizing at restore time is defense-in-depth against schema evolution (a view saved before a field was removed). |
| 7 | Use snippet props (not slots) for entity-specific drawer content | The shared ContextDrawerShell rendered generic overview/activity panels. Different entity types (Questions, Evaluations) needed completely different tab content — metadata layouts, health metrics, usage lists — but the shell shouldn't know about entity-specific types. | Svelte 5 replaced slots with snippets. The shell component needed a way to delegate tab body rendering to the parent page without coupling to entity types. Hardcoding entity-specific content in the shared component would violate separation of concerns. | Added a tabContent: Snippet<[DrawerTabContentProps]> prop to ContextDrawerShell. When provided, the shell renders \{@render tabContent({tabId, status, data, drawer})} inside each Tabs.Content instead of the default panels. DrawerCoordinator passes it through. The parent page provides the snippet with entity-specific rendering logic. | Snippets are Svelte 5's composition primitive — they're typed, can receive props, and are passed as regular component props. This keeps the shell generic (it manages dialog lifecycle, tabs, loading states) while letting each consumer define exactly what appears in each tab. The fallback to default panels preserves backward compatibility for simpler use cases. |
| 8 | Drawer tabs must be $derived when they depend on entity type | When the inventory spec switched from Questions (3 tabs: Overview, Health, Activity) to Evaluations (3 tabs: Overview, Activity, Usage), the drawer kept showing the old tab set until a full page reload. | The drawerTabs array was initially a plain constant. Switching specs changed activeSpec but the tabs array wasn't reactive — it was computed once at initialization. | Made drawerTabs a $derived value that recomputes based on activeSpec. Questions get [overview, health, activity], Evaluations get [overview, activity, usage]. | Any value that depends on reactive state must itself be reactive. $derived ensures the tab list updates synchronously when the spec changes, before the next render. This is a general Svelte 5 principle: if a value feeds into a component prop and its inputs can change, it must be $derived, not const. |
| 9 | In-memory view persistence needs immutable array updates for Svelte reactivity | Creating or deleting saved views didn't update the sidebar list. The savedViews array was mutated in place (.push(), .splice()), but the UI didn't re-render. | Svelte's reactivity system tracks assignments, not mutations. array.push() modifies the array in place without triggering a reactive update because the reference hasn't changed. | Use immutable patterns: savedViews = [...savedViews, newView] for create, savedViews = savedViews.filter(v => v.id !== id) for delete. Each operation produces a new array reference, triggering Svelte's reactive update. | This is fundamental to Svelte's reactivity model (both Svelte 4 and 5). The $state rune tracks the variable binding, not deep object mutations. Immutable updates are also safer — they prevent accidental aliasing bugs where multiple references point to the same mutated array. |
| 10 | Containerized Cloudflare connectors must not use loopback origins | After moving cloudflared into Docker, app-dev returned 502 and later /questions rendered 500 with backend fetch failures. | In a container, 127.0.0.1 points to that container, not other services. Tunnel ingress still targeted 127.0.0.1:5173/8081. Also, SvelteKit SSR and generated client defaults could still resolve backend to localhost if not explicitly set for container runtime. | Route tunnel ingress to compose service DNS (web:5173, api:8081). Ensure frontend proxy reads runtime BACKEND_BASE_URL and keep OpenAPI client base relative (empty) so requests go through /api proxy instead of localhost. | This aligns every hop with container networking semantics and removes host-loopback coupling. Using service DNS and runtime envs avoids hidden build-time drift and keeps local tunnel behavior consistent with deployment topology. |
| 11 | CSRF origin checks must account for forwarded host and stable trusted-origin config | Saving a view on /questions returned 403 AUTH_CSRF_ORIGIN_INVALID only through https://app-dev.evalium.me, even with a valid session + CSRF token. | Backend origin comparison used internal host context unless proxy headers/trusted origins aligned exactly. In multi-hop proxy flows (Cloudflare -> SvelteKit proxy -> API), host/proto can differ from public origin. We also had compose/env drift where WEB_ORIGINS was not consistently present at runtime. | Update CSRF expected-origin logic to honor X-Forwarded-Host (including first value in comma-separated chains), keep X-Forwarded-Proto, and pin API WEB_ORIGINS explicitly in compose to include https://app-dev.evalium.me. | It preserves strict CSRF checks while making them proxy-aware. This avoids false negatives in real tunnel setups without weakening policy, and explicit trusted-origin config removes environment-dependent behavior between dev machines/sessions. |
| 12 | Use a local Bits primitive layer instead of direct bits-ui imports in feature code | /questions and shared inventory components mixed direct bits-ui imports with local wrappers, so interaction and styling drifted between pages. | Without one import boundary, each page sets its own timing, classes, and defaults. That weakens the shared component contract and makes updates expensive. | Added a local primitive export module (src/lib/components/shared/bits/primitives.ts) and migrated feature/shared components to import through it. | This creates one control point for future primitive updates (styling, behavior wrappers, instrumentation) while keeping feature code consistent. |
| 13 | A shared tooltip wrapper is better than ad-hoc title attributes | Row action triggers and health indicators had inconsistent help text, and some contexts had no help text at all. Native title is hover-first and inconsistent across browsers. | Tooltip behavior was scattered across features, so there was no shared delay, styling, or trigger pattern. | Created a shared TooltipInfo wrapper (src/lib/components/shared/bits/TooltipInfo.svelte) using Bits UI Tooltip with standard delay, portal, and content styling. | A shared tooltip pattern keeps guidance predictable, keyboard-friendly, and easy to reuse in dense tables and drawers. |
| 14 | Disabled row actions should explain why | Archive and Restore could be disabled with no explanation, so users had to guess the rule. | The row action contract supported disabled, but the disabled reason was rarely surfaced in the menu UI. | Updated InventoryTable to render disabledReason for disabled menu items and wired question-specific reasons (for example, already archived). | Clear reasons reduce friction and keep users in flow. They do not need to leave the page to understand what changed. |
| 15 | Bulk preview should show question wording first; IDs should be optional | Bulk preview showed raw UUIDs as the main text, which made quick review harder for authors. | The renderer used backend IDs directly even when question wording was available in local cache. | Bulk results now show question wording first and place UUIDs behind a Show IDs toggle. | This keeps the review flow human-readable while preserving IDs for audit and debugging when needed. |
| 16 | Health payloads should be translated into plain guidance, not raw enums | The health drawer showed backend values like needs_attention, MED, and TOO_EASY, which read like internal diagnostics. | API fields were rendered directly with no copy layer for user-facing tone or guidance. | Added health copy mapping functions that convert status, confidence, and reasons into plain-language summaries and review guidance. | The UI stays accurate and actionable while matching Evalium's tone and status-label contract. |
How to Add New Lessons​
When you resolve a non-trivial frontend or UX issue, add a row to the table above with:
- Lesson — A clear, scannable summary of the takeaway.
- Problem — What the user or developer observed (the symptom).
- Root Cause — The technical reason (go past symptoms).
- Solution — What was changed and where.
- Why This Is the Best Approach — Why this fix is preferred and what trade-off it avoids.