📘 COMPONENT-INTERACTION-MAP.md
This document defines the “reactive plumbing” of Evalium.
Goal: Make contextual navigation (drawers) feel instantaneous and calm while remaining audit-ready:
- Shareable URLs (“URL-as-State”)
- Predictable Back button behaviour
- Permission-aware navigation via the Link Contract
- i18n-first and accessibility-first in the plumbing (no hardcoded strings)
Capability Baseline (Validated 2026-02-25)
Treat this map as aligned to current backend capability:
- Canonical full-page assets remain:
question,passage,evaluation,assignment,submission. - Drawer peek can be enabled per-surface/per-entity where a drawer read model exists.
- Production rollout status (2026-03-06): drawer peek is fully wired on
/questions; broader cross-surface rollout is pending. - Pending/feature-flagged entity types:
skill,fact. - Compliance case/timeline is future-facing; use jobs/ledger hubs until case orchestration is first-class.
If a target type is not backed by a drawer read model, do not intercept click for drawer open. Fall back to canonical full-page navigation or render as permission-blocked/non-interactive text.
0. Doctrine (MUST)
- URL-as-State: If it’s worth seeing, it’s worth sharing.
- Shallow Routing: Update URL state without re-running
loador losing background UI state. - i18n-first: No user-visible strings in plumbing without message keys.
- Accessible by default: Drawers are dialogs with focus trap + Esc close + focus restore.
- Param preservation: Drawer state must not destroy other URL state (filters, version pickers, cohorts).
- State-first reactivity: For shallow routing, drawer reactivity must read from
page.state.drawerfirst and only use URL parsing as deep-link fallback.
SvelteKit supports shallow routing via pushState and replaceState.
1. URL Contract (MUST)
1.1 Drawer Params
drawer=<type>:<id>(e.g.drawer=user:4b8b...)drawerTab=<tab>(optional; omit whenoverview)
Only intercept for drawer on types that are explicitly supported.
1.2 Canonical Page URLs
Every entity has a canonical full-page URL (used for Cmd/Ctrl+Click, open-in-new-tab, deep links):
/questions/:id/passages/:id/assignments/:id/submissions/:id/users/:id/groups/:id/evaluations/:id
Base-path rule (MUST): internal URLs must respect the app’s
basepath to avoid breaking deployments under subpaths.
2. Navigation Contract: Shallow Routing (MUST)
2.1 Replacement Policy
- First Open: use
pushStateso Back closes the drawer naturally. - Content Swap: if a drawer is already open and another drawer-link is clicked, use
replaceStateto avoid history spam. - Tab Change:
replaceState. - Close:
replaceState(remove drawer params but preserve all other params).
These APIs are intended for shallow routing (URL/state changes without navigation).
2.2 “Not a Real Navigation” Caveat
Shallow routing does not behave exactly like navigation; some hooks (e.g. onNavigate) and certain view transition expectations may not fire unless you implement them explicitly.
Critical behavior:
pushState/replaceStatedo not update$page.urlreactively.- If drawer state is derived only from
$page.url.searchParams, open/close logic can drift. - Use
page.state.draweras primary andparseDrawerState(page.url)as fallback.
3. The Interaction Flow: Click → Drawer
Trigger (EntityLink):
User clicks user:uuid in a table row.
Intercept (EntityLink):
- Renders a real
<a href="...">for native browser behaviours. - Normal click on drawer-enabled entities (for that surface) is intercepted → read-only drawer open.
- Unsupported or pending drawer entity/surface combinations are not intercepted → canonical full page.
- Cmd/Ctrl+Click is never intercepted → open canonical full page in a new tab.
URL Update (DrawerService):
- Closed →
pushState(nextUrl, state) - Open →
replaceState(nextUrl, state)
Coordination (DrawerCoordinator):
Root-level logic derives drawer state from page.state.drawer first, with URL params as deep-link fallback.
Display (ContextDrawer):
Drawer reads {type,id,tab} and fetches the read model.
Dismiss:
- Back → closes (because open used
pushState) - Close button → removes drawer params
- Esc → close
4. Technical Blueprint (reference Implementation)
4.1 DrawerService (Logic Layer) — Svelte 5 Runes
Responsibilities:
- Parse/serialize drawer params
- Preserve unrelated query params
- Enforce replacement policy
- Optionally store ephemeral state in
page.state(e.g., “focusReturnId”)
// src/lib/services/drawer.svelte.ts
import { page } from '$app/state';
import { pushState, replaceState } from '$app/navigation';
import { base } from '$app/paths';
export type DrawerTab = 'overview' | 'activity' | 'evidence' | 'settings';
export type DrawerParams = {
type: string;
id: string;
tab: DrawerTab;
};
const DEFAULT_TAB: DrawerTab = 'overview';
function parseDrawer(raw: string | null, rawTab: string | null): DrawerParams | null {
if (!raw) return null;
// Robust split: only split on the first colon (UUIDs won’t contain colons, but keep it safe)
const idx = raw.indexOf(':');
if (idx <= 0) return null;
const type = raw.slice(0, idx);
const id = raw.slice(idx + 1);
const tab = (rawTab || DEFAULT_TAB) as DrawerTab;
return { type, id, tab };
}
function withDrawer(url: URL, params: DrawerParams | null): URL {
const next = new URL(url.toString());
if (!params) {
next.searchParams.delete('drawer');
next.searchParams.delete('drawerTab');
return next;
}
next.searchParams.set('drawer', `\${params.type}:\${params.id}`);
// Param hygiene: omit drawerTab when default
if (params.tab && params.tab !== DEFAULT_TAB) next.searchParams.set('drawerTab', params.tab);
else next.searchParams.delete('drawerTab');
return next;
}
function ensureBasePath(url: URL): URL {
// If you ever create relative URLs manually, prefix with base.
// For URLs cloned from page.url, this is typically already correct.
// This function is here as a reminder/guardrail.
return url;
}
export class DrawerService {
params = $derived.by(() => {
const drawerFromState = page.state?.drawer as DrawerParams | undefined;
if (drawerFromState?.type && drawerFromState?.id) {
return {
type: drawerFromState.type,
id: drawerFromState.id,
tab: drawerFromState.tab ?? DEFAULT_TAB
};
}
const raw = page.url.searchParams.get('drawer');
const tab = page.url.searchParams.get('drawerTab');
return parseDrawer(raw, tab);
});
isOpen = $derived(!!this.params);
open(next: DrawerParams, opts?: { focusReturnId?: string }) {
// Use live browser URL because shallow routing does not update page.url.
const currentUrl = new URL(location.href);
const nextUrl = ensureBasePath(withDrawer(currentUrl, next));
// Optional ephemeral state: store focus return target in history state, not URL
const state = {
...(page.state ?? {}),
drawer: next,
focusReturnId: opts?.focusReturnId ?? null
};
if (this.isOpen) replaceState(nextUrl, state);
else pushState(nextUrl, state);
}
setTab(tab: DrawerTab) {
if (!this.params) return;
const currentUrl = new URL(location.href);
const nextParams = { ...this.params, tab };
const nextUrl = ensureBasePath(withDrawer(currentUrl, nextParams));
replaceState(nextUrl, { ...(page.state ?? {}), drawer: nextParams });
}
close() {
const currentUrl = new URL(location.href);
const nextUrl = ensureBasePath(withDrawer(currentUrl, null));
replaceState(nextUrl, { ...(page.state ?? {}), drawer: undefined });
}
}
export const drawerService = new DrawerService();
Notes:
$derivedis synchronous and side-effect free. Fetching belongs in$effect.baseis referenced to keep base-path deployments safe.
5. EntityLink (Navigation Orchestrator)
Rules:
- Always render a real
<a href="...">(native browser behaviours). - If the entity is a “drawer entity”, intercept normal click and call
drawerService.open(...). - If the entity type is not yet drawer-backed, do not intercept; navigate to canonical full page.
- Never intercept Cmd/Ctrl+Click / middle-click / shift-click.
SHOULD:
- Prefetch on hover intent for drawer-enabled entities on that surface (best-effort).
6. ContextDrawer (Fetch & Display)
6.1 Fetching Rule (MUST)
Do not fetch inside $derived. Fetch in $effect with an AbortController to prevent race conditions.
<script lang="ts">
import { drawerService } from '$lib/services/drawer.svelte';
import * as m from '$lib/paraglide/messages';
type Status = 'idle' | 'loading' | 'ready' | 'error' | 'notfound' | 'forbidden';
let status = $state<Status>('idle');
let model = $state<any>(null);
$effect(() => {
const p = drawerService.params;
// Reset when closed
if (!p) {
status = 'idle';
model = null;
return;
}
const controller = new AbortController();
(async () => {
status = 'loading';
model = null;
try {
// Endpoint shown as shape only. Production should route by entity type
// to backend-backed read models/BFF endpoints.
const res = await fetch(`/api/readmodels/\${p.type}/\${p.id}?tab=\${p.tab}`, {
signal: controller.signal
});
if (res.status === 404) { status = 'notfound'; return; }
if (res.status === 403) { status = 'forbidden'; return; }
if (!res.ok) { status = 'error'; return; }
model = await res.json();
status = 'ready';
} catch {
if (!controller.signal.aborted) status = 'error';
}
})();
return () => controller.abort();
});
</script>
{#if drawerService.isOpen}
<aside role="dialog" aria-modal="true" class="context-drawer" aria-label={m.drawer_title()}>
{#if status === 'loading'} <p>{m.loading()}</p>
{:else if status === 'notfound'} <p>{m.not_found()}</p>
{:else if status === 'forbidden'} <p>{m.no_permission()}</p>
{:else if status === 'error'} <p>{m.something_went_wrong()}</p>
{:else if status === 'ready'} <DynamicDrawerContent type={drawerService.params!.type} {model} />
{/if}
</aside>
{/if}
6.2 Accessibility Contract (MUST)
-
Focus trap within the drawer
-
Esc closes the drawer
-
On close, focus returns to the triggering
EntityLink- Prefer storing
focusReturnIdinpage.state(ephemeral) rather than URL
- Prefer storing
7. Data Flow Contract: Fast Read Models
Primary Load
- Hub/List pages fetch UI-ready read models in
load
Drawer Fetch
- Drawer fetches support-context read models by
type/id/tab
Mutations
- Writes go through SvelteKit Actions or direct API calls
- On success: invalidate relevant caches (drawer + any tables showing shared data)
8. Definition of Done (PR Gate)
Before merging any feature that adds links/drawers:
- History integrity: Open drawer uses
pushStateso Back closes it. - No history spam: Swapping drawer content uses
replaceState. - Param preservation: Filters/version params remain intact when drawer opens/closes.
- Race proof: Rapid clicking between links ends with correct final drawer content (AbortController verified).
- i18n-first: Loading/Error/Not Found/No Permission strings come from Paraglide keys (no literals).
- Accessibility: Focus trap + Esc close + focus restore to trigger.
- Native behaviour: Cmd/Ctrl+Click opens canonical full page in a new tab.
- Capability gating: Unsupported/pending entity types are not drawer-intercepted and fall back safely.
- Base-path safe: internal URLs work when app is deployed under a base path.