Skip to content

Store Patterns & Conventions

This page documents the client-side store patterns and conventions used across the app. These are modules under $stores/ (source: packages/praxrr-app/src/lib/client/stores/) and the alert system under $lib/client/alerts/. Most are module-level singletons; the search store is a factory. Usage examples follow the repo’s Svelte 5 convention of plain props and onclick/on:click handlers rather than runes.

Singleton form dirty-tracking store using snapshot comparison. It stores an original snapshot and current data; changing a field and then changing it back registers as not dirty, and create mode is always dirty. It also drives an unsaved-changes navigation guard modal.

import {(isDirty,
current,
showModal,
initEdit,
initCreate,
update,
resetFromServer,
clear,
confirmNavigation,
confirmDiscard,
cancelDiscard)} from '$stores/dirty';
Prop Type Default Required Description
isDirty Readable<boolean> — Yes Derived store; true when isNewMode is set, or when currentData deep-differs from originalSnapshot (deep equality is order-sensitive for arrays).
current Writable<Record<string, unknown>> — Yes Re-export of the internal currentData store for reactive read/bind of the live form state.
showModal Writable<boolean> — Yes Re-export of the internal showWarningModal store; bind to a DirtyModal open prop to show the unsaved-changes warning.
initEdit <T extends FormData>(serverData: T) => void — Yes Enter edit mode: clears new-mode, structuredClones serverData into both snapshot and current.
initCreate <T extends FormData>(defaults: T) => void — Yes Enter create mode: sets new-mode true (always dirty), null snapshot, current = clone of defaults.
update <T extends FormData, K extends keyof T>(field: K, value: T[K]) => void — Yes Immutably update a single field on currentData.
resetFromServer <T extends FormData>(newServerData: T) => void — Yes After a save + refetch, re-baseline snapshot and current to fresh server data (clears new-mode) so isDirty becomes false.
clear () => void — Yes Reset all state on unmount/navigation-away; sets both stores to the same empty object so isDirty = false.
confirmNavigation () => Promise<boolean> — Yes If not dirty, resolves true immediately; otherwise opens the warning modal and returns a promise resolved by confirmDiscard/cancelDiscard.
confirmDiscard () => void — Yes User confirms discarding: closes modal, re-baselines snapshot to current (isDirty false), resolves pending navigation promise with true.
cancelDiscard () => void — Yes User stays on page: closes modal and resolves pending navigation promise with false.
import { isDirty, showModal, initEdit, update, confirmNavigation, confirmDiscard, cancelDiscard } from '$stores/dirty';
initEdit(data.profile);
$: dirty = $isDirty;
function onNameInput(e) { update('name', e.currentTarget.value); }
// navigation guard (e.g. in beforeNavigate)
const proceed = await confirmNavigation();
if (!proceed) cancel();
<DirtyModal bind:open={$showModal} on:confirm={confirmDiscard} on:cancel={cancelDiscard} />

Notes:

  • This is a module-level singleton, not a factory, so only one form’s dirty state can be tracked at a time.
  • FormData is Record<string, unknown>, and deepEquals is order-sensitive for arrays.
  • The internal stores originalSnapshot, isNewMode, and the resolveNavigation callback are NOT exported.

Global toast/alert queue. add() pushes a typed message that auto-dismisses after a resolved duration (an explicit argument, else the user’s alert-settings duration). This is the canonical user-feedback mechanism referenced across the app.

import {alertStore} from '$lib/client/alerts/store';
Prop Type Default Required Description
subscribe Writable<Alert[]>['subscribe'] — Yes Subscribe to the current array of active alerts. Alert = { id: string; type: AlertType; message: string; duration?: number }.
add (type: AlertType, message: string, duration?: number) => string — Yes Enqueue an alert and return its generated id (via $shared/utils/uuid). Duration resolves to the explicit arg when a number, else alertSettingsStore.durationMs; a resolved duration > 0 schedules auto-dismiss.
remove (id: string) => void — Yes Remove a specific alert by id.
clear () => void — Yes Remove all alerts.
AlertType type = 'success' | 'error' | 'warning' | 'info' — No Exported union of alert severities.
Alert interface { id: string; type: AlertType; message: string; duration?: number } — No Exported shape of a queued alert.
import {alertStore} from '$lib/client/alerts/store'; alertStore.add('success', 'Profile
saved'); alertStore.add('error', 'Save failed', 8000); // explicit duration ms const
id = alertStore.add('info', 'Working...', 0); // 0 = no auto-dismiss alertStore.remove(id);

Variants and notes:

  • AlertType: success | error | warning | info.
  • Singleton. The default duration comes from alertSettingsStore (5000ms unless the user changed it). A duration of 0 (or negative) disables auto-dismiss.
  • Depends on $shared/utils/uuid and ./settings.

Persisted user preferences for alert position and default auto-dismiss duration. Hydrates from localStorage (alertSettings) with validation and writes back on every change.

import {(alertSettingsStore,
ALERT_POSITIONS,
AlertPosition,
AlertSettings,
DEFAULT_ALERT_SETTINGS)} from '$lib/client/alerts/settings';
Prop Type Default Required Description
subscribe Writable<AlertSettings>['subscribe'] — Yes Subscribe to { position: AlertPosition; durationMs: number }. Initializes from validated localStorage or DEFAULT_ALERT_SETTINGS.
setSettings (next: AlertSettings) => void — Yes Replace settings and persist to localStorage (browser-guarded).
ALERT_POSITIONS readonly ['top-left','top-center','top-right','bottom-left','bottom-center','bottom-right'] — No Exported const tuple of valid positions.
AlertPosition type = (typeof ALERT_POSITIONS)[number] — No Exported union of the six position strings.
AlertSettings interface { position: AlertPosition; durationMs: number } — No Exported settings shape.
DEFAULT_ALERT_SETTINGS AlertSettings — No Exported defaults: { position: ‘top-center’, durationMs: 5000 }.
import {(alertSettingsStore, ALERT_POSITIONS, DEFAULT_ALERT_SETTINGS)} from '$lib/client/alerts/settings';
<select
value={$alertSettingsStore.position}
on:change={(e) =>
alertSettingsStore.setSettings({
...$alertSettingsStore,
position: e.currentTarget.value,
})}
>
{#each ALERT_POSITIONS as pos}<option value={pos}>{pos}</option>{/each}
</select>

Variants and notes:

  • Six positions (top/bottom x left/center/right); default top-center / 5000ms.
  • parseStoredSettings validates position against ALERT_POSITIONS and requires a finite durationMs >= 0 (rounded), falling back to defaults on any parse/validation failure.
  • Persistence is browser-guarded. Consumed by alertStore for its default duration and by the alert renderer for placement.

Singleton light/dark theme store. Initializes from localStorage (theme) or the prefers-color-scheme media query, applies the theme class to <html> (using the View Transitions API when available), and persists on toggle.

import {themeStore} from '$stores/theme';
Prop Type Default Required Description
subscribe Writable<'light' | 'dark'>['subscribe'] — Yes Svelte store subscription for the current theme (‘light’ | ‘dark’). Default initial value is ‘dark’ (used on server / before browser hydration).
toggle () => void — Yes Flip between light and dark; applies the class to document.documentElement (via document.startViewTransition when supported) and writes to localStorage (guarded against storage errors).
import {themeStore} from '$stores/theme';
<button on:click={themeStore.toggle}>
{$themeStore === 'dark' ? 'Light mode' : 'Dark mode'}
</button>

Variants and notes:

  • Theme values: light | dark.
  • Only subscribe and toggle are exposed (no set/update). All DOM/localStorage access is guarded by the $app/environment browser flag. The Theme type is not exported.

Singleton accent-color store. Persists a chosen accent to localStorage (accent) and writes the matching Tailwind-shade palette (50–950) to CSS custom properties (--accent-50 … --accent-950) on <html>.

import {(accentStore, accentColors, AccentColor)} from '$stores/accent';
Prop Type Default Required Description
subscribe Writable<AccentColor>['subscribe'] — Yes Subscription to the current AccentColor (‘blue’ | ‘yellow’ | ‘green’ | ‘orange’ | ‘teal’ | ‘purple’ | ‘rose’). Initial value ‘blue’.
set (accent: AccentColor) => void — Yes setAccent: update the store, apply the palette CSS variables, and persist to localStorage. Replaces the store’s default set with this side-effecting version.
AccentColor type = 'blue' | 'yellow' | 'green' | 'orange' | 'teal' | 'purple' | 'rose' — No Exported union type of supported accent color keys.
accentColors { value: AccentColor; label: string; color: string }[] — No Exported list of accents with display label and a representative swatch hex (the 600 shade), for building pickers.
import { accentStore, accentColors, type AccentColor } from '$stores/accent';
{#each accentColors as opt}
<button style="background:{opt.color}" class:selected={$accentStore === opt.value}
on:click={() => accentStore.set(opt.value)}>{opt.label}</button>
{/each}

Variants and notes:

  • Seven accents: blue, yellow, green, orange, teal, purple, rose. Each maps to an 11-shade palette.
  • colorPalettes (full shade maps) is module-internal and not exported. All browser side effects are guarded by the browser flag.
  • localStorage.setItem in setAccent is NOT wrapped in try/catch (unlike theme.ts).

createSearchStore / getPersistentSearchStore

Section titled “createSearchStore / getPersistentSearchStore”

Factory for a per-instance search + filter store with debounced query, arbitrary key/value filters, an isActive flag, and a generic array filter helper. getPersistentSearchStore returns a keyed singleton whose query is mirrored to localStorage.

import {(createSearchStore, getPersistentSearchStore)} from '$stores/search';
Prop Type Default Required Description
createSearchStore (config?: SearchStoreConfig) => SearchStore — Yes Create a new search store. SearchStoreConfig = { debounceMs?: number (default 300); caseSensitive?: boolean (default false) }.
getPersistentSearchStore (key: string, config?: SearchStoreConfig) => SearchStore — Yes Return a cached store for key (created once), hydrating query from localStorage[key] and persisting query changes back (removes the key when query is empty). In-memory Map cache is shared across the module.
SearchStore.subscribe Readable<SearchState>['subscribe'] — Yes Subscribe to { query: string; filters: Record<string, unknown>; isActive: boolean }.
SearchStore.debouncedQuery { subscribe: Readable<string>['subscribe'] } — Yes Read-only debounced query store, updated debounceMs after the last setQuery.
SearchStore.isActive { subscribe: Readable<boolean>['subscribe'] } — Yes Read-only derived boolean; true when a query or filters are present.
SearchStore.filterCount { subscribe: Readable<number>['subscribe'] } — Yes Read-only derived count of active filter keys.
SearchStore.setQuery (query: string) => void — Yes Set the (immediate) query, mark active when non-empty, and schedule the debounced update.
SearchStore.setFilter (key: string, value: unknown) => void — Yes Add/replace a filter key and mark the store active.
SearchStore.removeFilter (key: string) => void — Yes Remove a filter key; isActive recomputed from remaining query/filters.
SearchStore.clearFilters () => void — Yes Drop all filters; isActive stays true only if a query remains.
SearchStore.clear () => void — Yes Reset query, filters, isActive, debounced query, and cancel any pending debounce timer.
SearchStore.filterItems <T extends object>(items: T[], searchFields: (keyof T)[], additionalFilter?: (item: T, filters: Record<string, unknown>) => boolean) => T[] — Yes Filter an array by the current debounced query across searchFields (respecting caseSensitive) plus an optional custom filter predicate; returns items unchanged when the store is inactive.
import { createSearchStore, getPersistentSearchStore } from '$stores/search';
const search = createSearchStore({ debounceMs: 250 });
const debouncedQuery = search.debouncedQuery;
$: visible = search.filterItems(rows, ['name', 'category']);
// consumed by SearchAction in the component showcase:
<SearchAction searchStore={search} placeholder="Search components..." responsive />
// persistent variant keyed to localStorage
const pageSearch = getPersistentSearchStore('customFormats.search');

Notes:

  • SearchState, SearchStoreConfig, and SearchStore (ReturnType of createSearchStore) types are exported.
  • The showcase (routes/dev/components/+page.svelte) passes the whole store to SearchAction via searchStore={...} and also reads search.debouncedQuery directly.
  • debouncedQuery / isActive / filterCount are exposed as { subscribe } wrappers, not full stores.

userComplexityTiersStore / getUserComplexityTierSectionStore

Section titled “userComplexityTiersStore / getUserComplexityTierSectionStore”

Server-synced store for per-section UI complexity tiers (beginner/intermediate/etc.). It wraps a shared debounced section-sync engine that hydrates from and PATCHes to /api/v1/complexity-tiers, tracking interaction counts and tier-suggestion dismissals with optimistic-update auth handling.

import {(userComplexityTiersStore, getUserComplexityTierSectionStore)} from '$stores/userComplexityTiers';
Prop Type Default Required Description
userComplexityTiersStore UserComplexityTiersStore — Yes Top-level singleton: { section(sectionKey, defaultTier?) => UserComplexityTierSectionStore; authRequired: Readable; clearAuthRequired(): void; clearOnAuthChange(): void }.
getUserComplexityTierSectionStore (sectionKey: SectionKey, defaultTier?: ComplexityTier = 'beginner') => UserComplexityTierSectionStore — Yes Convenience accessor equivalent to userComplexityTiersStore.section(…). Returns a ref-counted per-section store (created + hydrated on first use).
UserComplexityTierSectionStore.tier Writable<ComplexityTier> — Yes Current tier; set/update route through the debounced sync (setPendingValue), not a raw store write.
UserComplexityTierSectionStore (read-only stores) interactionCount / advancedToggleCount: Readable<number>; lastSuggestedTier: Readable<ComplexityTier|null>; suggestionDismissedAt: Readable<string|null>; persisted / isSyncing: Readable<boolean>; updatedAt: Readable<string|null> — Yes Reactive read-only projections of the synced section state.
UserComplexityTierSectionStore.refresh () => Promise<void> — Yes Re-hydrate this section from the server (no-op when not in browser).
UserComplexityTierSectionStore.recordActivity (activity: { interaction?: number; advancedToggle?: number }) => Promise<void> — Yes Immediately persist an interaction/advanced-toggle delta for the section.
UserComplexityTierSectionStore.dismissSuggestion (suggestedTier: ComplexityTier) => Promise<void> — Yes Persist dismissal of a suggested tier (records last_suggested_tier + suggestion_dismissed_at = now).
UserComplexityTierSectionStore.cleanup () => void — Yes Decrement the section’s ref count and tear down shared state when it reaches zero. Call on component destroy.
import {getUserComplexityTierSectionStore} from '$stores/userComplexityTiers'; import
{onDestroy} from 'svelte'; const section = getUserComplexityTierSectionStore('quality-profiles',
'beginner'); const tier = section.tier; const isSyncing = section.isSyncing; onDestroy(section.cleanup);
<select bind:value={$tier}>...</select>
{#if $isSyncing}<Spinner />{/if}

Variants and notes:

  • ComplexityTier values come from $shared/complexity/tiers.ts (COMPLEXITY_TIERS); the default tier is beginner.
  • Depends on ./sectionDebouncedSync.ts (createDebouncedSectionSync) and $shared/complexity/tiers.ts.
  • Sections are ref-counted and shared by sectionKey, so calls with the same key return the same underlying state.
  • UserComplexityTierRecord, UserComplexityTiersStore, and UserComplexityTierSectionStore interfaces are exported; the API layer uses snake_case (section_key, interaction_count, etc.) mapped to camelCase records.

The stores above integrate with Tailwind CSS v4 through CSS custom properties rather than hard-coded color classes.

  • accentStore writes an 11-shade palette to --accent-50 … --accent-950 on <html>, so utilities and components can reference the live accent via those custom properties.
  • themeStore toggles the theme class on document.documentElement, which is the anchor for Tailwind’s dark-mode variants.
  • Prefer referencing these CSS variables in component styles over duplicating shade values, so theme and accent changes propagate without rebuilds.

Svelte actions and helpers under $lib/client/utils/ (source: packages/praxrr-app/src/lib/client/utils/) that the shared dialogs, dropdowns, and tooltips use for keyboard and screen-reader support.

import { focusTrap, getTabbable } from '$lib/client/utils/focusTrap';
import { focusOutside } from '$lib/client/utils/focusOutside';
import { clickOutside } from '$lib/client/utils/clickOutside';
import { uniqueId } from '$lib/client/utils/uniqueId';
Helper Signature Description
focusTrap use:focusTrap={{ enabled?: boolean; initialFocus?: HTMLElement | string | null; restoreFocus?: boolean }} Action that keeps Tab/Shift+Tab inside the node while enabled (default true). On activation it records the focused element and focuses initialFocus (an element inside the node or a selector), else the first tabbable element not marked data-focus-trap-region, else the node. On deactivation or destroy it restores focus to the recorded element when restoreFocus (default true) and the element is still connected.
getTabbable (node: HTMLElement) => HTMLElement[] Tabbable descendants in DOM order. Skips disabled, aria-disabled="true", tabindex="-1", hidden (hidden, display: none, visibility: hidden), and closed <details> content; collapses each radio group to its checked (or first) radio. Used by focusTrap and ActionButton.
focusOutside use:focusOutside={() => void} Calls the callback when focus moves from inside the node to an element outside it. Does not move focus. A focusout with no relatedTarget (for example a click on a non-focusable area) is ignored.
clickOutside use:clickOutside={() => void} Calls the callback for any click (document, capture phase) whose target is outside the node.
uniqueId (prefix: string) => string Returns `${prefix}-${n}` from a module-level counter. Call it once per component instance in the <script> block to pair id with aria-controls, aria-labelledby, or aria-describedby.

Usage — a custom dialog panel:

<script>
import { focusTrap } from '$lib/client/utils/focusTrap';
import { uniqueId } from '$lib/client/utils/uniqueId';
export let open = false;
const titleId = uniqueId('settings-dialog-title');
function handleKeydown(e) {
if (e.key !== 'Escape' || !open) return;
e.preventDefault();
e.stopPropagation();
open = false;
}
</script>
{#if open}
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabindex="-1"
on:keydown={handleKeydown}
use:focusTrap={{ enabled: open }}
>
<h2 id={titleId}>Settings</h2>
<!-- controls -->
</div>
{/if}

Notes:

  • Use focusTrap for any new dialog. Prefer Modal or InfoModal, which already apply it. For a custom dialog, also set role="dialog", aria-modal="true", and aria-labelledby, and handle Escape on the panel with stopPropagation() so nested dialogs close one at a time. focusTrap does not handle Escape and does not make the page behind the dialog inert.
  • Give dropdown triggers aria-haspopup and aria-expanded. Also set aria-controls to the Dropdown id (from uniqueId). Match aria-haspopup to the panel role (menu, listbox, or dialog). Pass onClose to Dropdown so Escape closes the panel and returns focus to the trigger. When the trigger is activated from the keyboard (event.detail === 0), move focus to the first item after tick(). ActionButton with hasDropdown and DropdownSelect do all of this already.
  • Close popups with both clickOutside (pointer) and focusOutside (Tab away). Neither one covers the other’s case.
  • Make scrollable regions inside dialogs keyboard-reachable with tabindex="0", role="region", and aria-labelledby. Add data-focus-trap-region so focusTrap gives initial focus to a real control instead of the region.
  • The focusTrap keydown listener ignores Tab combined with Alt, Ctrl, or Meta. If the node has no tabbable elements, Tab keeps focus on the node.