Skip to content

Frontend Architecture

This page is the source of truth for frontend architecture in frontend-pwa/.

  • We build a single-page PWA with Svelte 5 runes.
  • We use global rune stores in src/lib/*.svelte.ts for app state.
  • We host static game assets locally (for example, piece SVGs in public/pieces/) to keep gameplay views available offline.
  • We do not use legacy Svelte store patterns (writable, derived, custom subscribe stores).
  • Components are view composition + interaction wiring, not data orchestration layers.

src/App.svelte is the shell and route-like state switcher.

  • It imports global stores (authStore, gameListStore, activeGameStore, modeStore, etc.).
  • It performs startup orchestration in onMount():
    • Parse URL query params.
    • Load shared game state when game is present.
    • Load isolated shared puzzle mode when puzzle is present.
    • Fetch authenticated user via authStore.fetchMe().
    • Trigger initial game list fetch when user is authenticated and approved.
  • It uses $effect for shell-level side effects (for example, syncing URL query params with current game/move).

Use the centralized logger utility in src/lib/utils/logger.ts instead of ad-hoc console.log statements.

  • debug(message, payload?) — development-only diagnostics
  • info(message, payload?) — development-only operational events
  • warn(message, payload?) — always logged for recoverable problems or degraded states
  • error(message, payload?) — always logged for failures

debug and info are suppressed when import.meta.env.DEV is false. warn and error stay enabled in all environments.

  • Set isLoading/loading... before request.
  • Reset error/...Error before request.
  • Call API through ApiClient only.
  • On success, update state atomically.
  • On failure, store user-facing error message (plus optional console.error for debugging).
  • Always clear loading flags in finally.

This pattern is visible in authStore.fetchMe(), gameListStore.fetchGames(), and adminStore.fetchUsers().

The logger accepts primitive values, objects, and Error instances. When an Error is passed as payload, it is formatted to preserve name, message, and stack data. All HTTP goes through ApiClient.

ApiClient.request<T>() provides shared behavior:

  • credentials: 'include' for cookie-based auth.
  • JSON headers by default.
  • Uniform error translation to ApiError with status and message. Store actions should use the logger for diagnostics around async work and state changes:
    • Non-JSON body -> text

ApiClient exposes both generic verbs and domain helpers:

  • Generic: get, post, delete, request.
  • Domain-specific wrappers: fetchPendingUsers, approveUser, triggerGameSync, getBookmarks, etc.
  • Domain-specific wrappers: fetchUsers, updateUser, triggerGameSync, getBookmarks, getNextPuzzle, etc.

Stores should call these wrappers instead of duplicating endpoint URLs and request options.

  • Optimistic UI updates are allowed in stores (e.g., gameListStore.toggleFavorite).
  • Any optimistic mutation must have explicit rollback behavior on API failure.
  • Error messages propagated from ApiError should be user-safe and concise.

Testing Strategy (Vitest-First for Store Logic)

Section titled “Testing Strategy (Vitest-First for Store Logic)”

Store logic is the primary test target.

  • Test config: frontend-pwa/vitest.config.ts
  • Environment: jsdom
  • Globals enabled (describe, it, expect, etc.)

We prioritize deterministic unit tests for store behavior over UI/E2E coverage:

  • State transitions for loading/error/success paths.
  • Query/parameter construction for API requests.
  • Pagination and filter state behavior.
  • Derived field behavior (totalPages, pendingUsers, turn boundaries, player names, board orientation).
  • Async flow with timers where needed (e.g., playback in activeGameStore with fake timers).
  • Optimistic update + rollback behavior.

Examples:

  • src/lib/gameListStore.test.ts
  • src/lib/activeGameStore.test.ts
  • src/lib/adminStore.test.ts
  • src/lib/bookmarksStore.test.ts
  • Most business logic is in stores, not components.
  • Store tests run fast and isolate data/state regressions.
  • UI/E2E tests are useful but secondary for this codebase and should focus on critical flows only.

Use the centralized logger utility in src/lib/utils/logger.ts instead of ad-hoc console.log statements.

  • debug(message, payload?) — development-only diagnostics
  • info(message, payload?) — development-only operational events
  • warn(message, payload?) — always logged for recoverable problems or degraded states
  • error(message, payload?) — always logged for failures

debug and info are suppressed when import.meta.env.DEV is false. warn and error stay enabled in all environments.

import { logger } from '$lib/utils/logger';
logger.debug('Loading game...', { gameId: '123' });
logger.info('Game data loaded successfully', gameData);
logger.warn('Slow network detected', { latency: 2500 });
logger.error('Failed to sync player data', error);

The logger accepts primitive values, objects, and Error instances. When an Error is passed as payload, it is formatted to preserve name, message, and stack data.

try {
// ... operation
} catch (error) {
logger.error('Operation failed', error); // Stack trace preserved
}

Store actions should use the logger for diagnostics around async work and state changes:

export class GameListStore {
async fetchGames(): Promise<void> {
logger.debug('Fetching games...', { page: this.currentPage });
try {
const games = await apiClient.getGames();
logger.info('Games loaded', { count: games.length });
this.games = games;
} catch (error) {
logger.error('Failed to fetch games', error);
this.error = 'Unable to load games. Please try again.';
}
}
}

Behavior is covered by src/lib/utils/logger.test.ts, including environment filtering, payload handling, and error formatting.

The interactive chessboard is rendered by ChessgroundBoard.svelte using the svelte5-chessground wrapper (v1.1.1).

The wrapper provides a Svelte 5 Runes-native interface around the vanilla chessground library. It eliminates the need for manual onMount, ResizeObserver, and imperative cgApi.set() calls by accepting reactive Svelte props and handling all $effect-based updates internally.

Board state is driven entirely by $derived values sourced from activeGameStore. Use the flat shorthands for simple cases:

<script lang="ts">
import { Chessground } from 'svelte5-chessground';
import 'svelte5-chessground/style.css';
const fen = $derived(activeGameStore.currentState?.fen);
const orientation = $derived(activeGameStore.boardOrientation);
</script>
<Chessground {fen} {orientation} />

Use fen and orientation props for simple cases. For advanced configuration (custom destinations, drawable shapes, etc.) use the config prop — but do not mix the two for the same property, as shorthand props override their config equivalents.

Dice Chess permits King captures and moving into check. Disable all move validation:

<Chessground
movableFree={true}
movableColor={orientation}
turnColor={orientation}
/>
  • movableFree={true} — disables piece-rule validation; any piece can go anywhere.
  • movableColor and turnColor are kept in sync with orientation so chessground’s internal isMovable() check passes after every state update.
  • Instantiate the vanilla Chessground() function directly — use the Svelte component.
  • Hold a manual cgApi reference for state updates; pass reactive props instead (reserve bind:api only for imperative operations like programmatic move animations).
  • Use Svelte 5 runes state in .svelte.ts stores; do not introduce legacy Svelte store APIs.
  • Keep app data flow unidirectional: component event -> store action -> store state -> UI.
  • Keep API calls centralized in ApiClient.
  • Add or update Vitest store tests whenever store logic changes.
  • Use the centralized logger utility instead of ad-hoc console.log statements.