Skip to main content

📘 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)

  1. URL-as-State: If it’s worth seeing, it’s worth sharing.
  2. Shallow Routing: Update URL state without re-running load or losing background UI state.
  3. i18n-first: No user-visible strings in plumbing without message keys.
  4. Accessible by default: Drawers are dialogs with focus trap + Esc close + focus restore.
  5. Param preservation: Drawer state must not destroy other URL state (filters, version pickers, cohorts).
  6. State-first reactivity: For shallow routing, drawer reactivity must read from page.state.drawer first 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 when overview)

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 base path to avoid breaking deployments under subpaths.


2. Navigation Contract: Shallow Routing (MUST)

2.1 Replacement Policy

  • First Open: use pushState so Back closes the drawer naturally.
  • Content Swap: if a drawer is already open and another drawer-link is clicked, use replaceState to 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/replaceState do not update $page.url reactively.
  • If drawer state is derived only from $page.url.searchParams, open/close logic can drift.
  • Use page.state.drawer as primary and parseDrawerState(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:

  • $derived is synchronous and side-effect free. Fetching belongs in $effect.
  • base is referenced to keep base-path deployments safe.

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 focusReturnId in page.state (ephemeral) rather than URL

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 pushState so 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.