---
name: semantics-svelte
description: Svelte 5 (Runes) protocol for ss-tools: UX State Machines, Tailwind components, stores, and browser-driven visual validation.
---

#region Std.Semantics.Svelte [C:5] [TYPE Skill] [SEMANTICS frontend,svelte,ui,ux,tailwind]
@BRIEF HOW to build Svelte 5 (Runes) Components for ss-tools with UX State Machines, Tailwind CSS, store topology, and visual-interactive validation.
@RELATION DEPENDS_ON -> [Std.Semantics.Core]
@RELATION DEPENDS_ON -> [MolecularCoTLogging]
@RESTRICTION EXAMPLES ONLY — this file provides language-specific code patterns. All protocol rules (tier definitions, tag catalog, anchor syntax) are defined exclusively in `semantics-core`. UX contract tags are defined here as examples; the tag catalog lives in `semantics-core` §III. This file MUST NOT redefine or contradict any rule from `semantics-core`.
@RATIONALE Svelte 5 runes ($state, $derived, $effect, $props) chosen for reactive precision and native compiler optimisations over Svelte 4 legacy reactivity ($:). Tailwind CSS selected for zero-runtime utility-first styling and rapid visual validation via chrome-devtools MCP. FSM-based UX contracts (@UX_STATE, @UX_FEEDBACK, @UX_RECOVERY) chosen to create verifiable state-transition tests that the browser Judge Agent can execute deterministically. ss-tools internal API wrappers (fetchApi/requestApi) chosen over native fetch to enforce auth, error normalisation, and trace_id propagation.
@REJECTED React (JSX) rejected — Svelte's compiler-first approach yields smaller bundles and native reactivity without virtual DOM overhead. Vue rejected — Svelte 5 runes provide simpler mental model. Legacy Svelte 4 syntax (export let, $:, on:event) rejected — incompatible with Svelte 5 runes mode. CSS Modules / styled-components rejected in favour of Tailwind's utility-first approach, which avoids style leakage and simplifies chrome-devtools visual diffing. Native fetch() rejected — bypasses ss-tools middleware chain (auth, trace_id, error normalisation). Plain-text logging rejected per MolecularCoTLogging §VII — JSON lines are mandatory for agent-parsable traces.
@INVARIANT Frontend components MUST be verifiable by the browser toolset via `chrome-devtools` MCP.
@INVARIANT Use Tailwind CSS exclusively. Raw Tailwind color classes (`blue-600`, `green-500`, `red-600`, `gray-*`, `indigo-*`) are DEPRECATED in page and component code — use semantic tokens from `tailwind.config.js` only (`primary`, `destructive`, `success`, `warning`, `surface-*`, `border-*`, `text-*`).
@INVARIANT Page-level UI MUST use `$lib/ui` atoms: `<Button>`, `<Card>`, `<Input>`, `<Select>`, `<PageHeader>`. Raw `<button>` elements and manual card `<div>` containers in page files are a violation.
@INVARIANT `src/components/` is LEGACY FROZEN. New domain components go in `src/lib/components/<domain>/`. Do not create new files under `src/components/`.
@INVARIANT Native `fetch` is forbidden — use `requestApi`/`fetchApi` wrappers.

## 0. SVELTE 5 PARADIGM & UX PHILOSOPHY (SS-TOOLS)

- **TYPESCRIPT-FIRST:** TypeScript is the default language for ALL frontend code. Components use `<script lang="ts">`. Screen Models use `.svelte.ts` extension. API DTOs live in `frontend/src/types/` with explicit interfaces. Types are the contract between the model and the component — without them, the model-first architecture has no enforcement layer. `any` is forbidden at external boundaries; use `unknown` with runtime narrowing.
- **STRICT RUNES ONLY (PROJECT RULE):** Our project deliberately chooses Svelte 5 runes exclusively — `$state()`, `$derived()`, `$effect()`, `$props()`, `$bindable()`. This is a codebase-wide architectural decision, not a Svelte 5 limitation. Every component follows the same reactive pattern; every model is a predictable `$state` container. Mixed styles create agent confusion and verification gaps.
- **FORBIDDEN SYNTAX (PROJECT RULE):** Do NOT use `export let`, `on:event`, `createEventDispatcher`, or the legacy `$:` reactivity. These are banned for architectural consistency — not because Svelte 5 cannot run them.
- **EVENT ARCHITECTURE:** DOM events use native attributes (`onclick`, `onchange`, `onsubmit`). Component-to-component communication uses typed callback props — NEVER `createEventDispatcher` or `on:event` directives. This keeps component interfaces explicit, type-checkable, and free of runtime event bus ambiguity.
- **$effect SCOPE:** `$effect` is for browser-side side effects only — DOM measurements, WebSocket subscriptions, external library bindings, synchronising state that cannot be expressed as `$derived`. For route-level data loading, use SvelteKit `load` functions in `+page.ts`. Using `$effect` for initial data fetch breaks SSR and creates unpredictable loading order.
- **UX AS A STATE MACHINE:** Every component is a Finite State Machine (FSM). Declare visual states in the contract BEFORE writing implementation.
- **MODEL-FIRST FOR COMPLEX SCREENS:** For screens with cross-widget logic (filters, pagination, search, multi-step forms), create a `[TYPE Model]` FIRST. The model is the source of truth — components only render model state and pass user intentions via `model.action()`. See §IIIa for the full RSM protocol.
- **RESOURCE-CENTRIC:** Navigation and actions revolve around Resources (Dashboards, Datasets, Tasks). Every action MUST be traceable.
- **SS-TOOLS SPECIFIC:** This is an Apache Superset automation dashboard — components deal with migrations, Git operations, task monitoring, dataset mapping, and plugin management.

## I. PROJECT ARCHITECTURAL INVARIANTS (SS-TOOLS)

You are bound by strict repository-level design rules:
1. **Styling:** Tailwind CSS utility classes are MANDATORY. Minimize scoped `<style>`. If custom CSS is absolutely necessary, use `@apply` directives.
2. **Localization:** All user-facing text MUST use the `$t` store from `src/lib/i18n`. No hardcoded UI strings.
3. **API Layer:** You MUST use the internal `fetchApi`/`requestApi` wrappers from `$lib/api`. Using native `fetch()` is a fatal violation.
4. **SvelteKit Routing:** Pages live under `src/routes/`. Components live under `src/lib/components/`. Stores under `src/lib/stores/`.
5. **Testing:** Use Vitest with `@testing-library/svelte` for component tests.
6. **Component Reuse:** Before creating any new component, scan the existing library:
   - **Atoms:** `$lib/ui/Button.svelte`, `$lib/ui/Select.svelte`, `$lib/ui/Input.svelte`, `$lib/ui/Card.svelte`
   - **Widgets:** `$lib/components/ui/SearchableMultiSelect.svelte`, `$lib/components/ui/MultiSelect.svelte`
   - **Infrastructure:** `addToast()` from `$lib/toasts.js` (Toast already mounted in root layout)
   - **Patterns (no component needed):** badges (`rounded-full px-2.5 py-0.5 text-xs font-medium`), tooltips (native `title`), skeletons (`animate-pulse bg-gray-200`), collapsibles (`<details><summary>`), empty states (`border-dashed bg-gray-50`), confirmations (`confirm()`)
   Refer to `.opencode/command/speckit.plan.md` §"Frontend Component Reuse Scan" for the mandatory scan workflow.

## II. UX CONTRACTS (STRICT UI BEHAVIOR)

Every component MUST define its behavioral contract in the header.
- **`@UX_STATE:`** Maps FSM state names to visual behavior. *Example:* `@UX_STATE Loading -> Spinner visible, btn disabled, aria-busy=true`.
- **`@UX_FEEDBACK:`** Defines external system reactions (Toast, Shake, RedBorder, Modal).
- **`@UX_RECOVERY:`** Defines the user's recovery path. *Example:* `@UX_RECOVERY Retry button, Clear filters, Reload page`.
- **`@UX_REACTIVITY:`** Explicitly declares the state source. *Example:* `@UX_REACTIVITY: Props -> $props(), LocalState -> $state(...)`.
- **`@UX_TEST:`** Defines the interaction scenario for the automated Judge Agent. *Example:* `@UX_TEST: Idle -> {click: submit, expected: Loading}`.

## III. STATE MANAGEMENT & STORE TOPOLOGY (SS-TOOLS STORES)

Key stores in ss-tools:
- `taskDrawerStore` — Background task monitoring drawer
- `sidebarStore` — Navigation sidebar state
- `authStore` — Authentication state (user, roles, permissions)
- `notificationStore` — Toast/snackbar notifications
- `dashboardStore` — Active dashboard data
- `migrationStore` — Migration plan and progress

**Store subscription rules:**
- Use the `$` prefix for reactive store access (e.g., `$sidebarStore.collapsed`).
- **Graph Linkage:** Whenever a component reads or writes to a global store, declare it:
  `@RELATION BINDS_TO -> [Store_ID]`

## IIIa. REACTIVE SCREEN MODELS (RSM) — MODEL-FIRST STATE ARCHITECTURE

### Why Models

The component-first approach forces you to encode system logic in event handlers (`onclick`, `onchange`), scattering it across JSX and hooks. For LLM agents, this means holding dozens of cross-component relationships in context — a primary source of errors.

**Model-first approach:** The entire screen is a pure function of state. The Model declares atoms, invariants, and relationships between them. Components only render the current state and pass user intentions back to the Model.

**What this means for you, the agent:**
- **Findability:** grep `@semantics.*users` → all models related to users. The contract is single-source, not scattered across HTML.
- **Testability:** Model invariants (`@INVARIANT changing filter resets pagination`) are verified in vitest without browser render — milliseconds, not seconds.
- **CSA resilience:** `#region ModelName [C:N] [SEMANTICS ...]` on line 1 = maximum density for top‑k attention selection. Closing `#endregion ModelName` duplicates the identifier — safe after aggressive context compression.
- **Component simplicity:** When a component contains only `$state`, `$derived`, and `model.action()` calls, its contract is predictable. No guessing which side effect hides in `onchange`.

### Model Contract Template

A Model is a **contract** — `#region ModelName [C:N] [TYPE Model] [SEMANTICS tags]` with mandatory `@BRIEF` and `@INVARIANT`.

Models use the **`.svelte.ts`** extension (not plain `.ts`) because they rely on Svelte 5 reactive primitives (`$state`, `$derived`). The Svelte compiler processes `.svelte.ts` files and transforms these runes into proper reactive code.

```typescript
// frontend/src/lib/models/UserListModel.svelte.ts
// #region UserListModel [C:4] [TYPE Model] [SEMANTICS users,list,screen-model]
// @BRIEF State model for the user list screen — declares atoms, invariants, and actions.
// @INVARIANT Changing filter (search, role, status) resets pagination to page 1.
// @INVARIANT Deleting a user removes it from the list and decrements total count atomically.
// @INVARIANT The list never contains deleted users (idempotent delete).
// @STATE idle     — Initial state, no data loaded.
// @STATE loading  — API call in progress, list is stale or empty.
// @STATE loaded   — Data fetched successfully, list populated.
// @STATE empty    — Query returned zero results, filtering is active.
// @STATE error    — API call failed, error message available.
// @ACTION search(query)   — Full-text search with debounce, resets pagination.
// @ACTION setFilter(key, value) — Sets a filter atom, resets pagination.
// @ACTION deleteUser(id)  — Deletes user, removes from list, decrements count.
// @ACTION loadPage(n)     — Loads a specific page of results.
// @ACTION retry()         — Re-runs the last failed operation.
// @ACTION reset()         — Clears all filters and search, reloads page 1.
// @RELATION DEPENDS_ON -> [userApi]
// @RELATION CALLS -> [log]

import { requestApi } from "$lib/api";

// ── Type definitions (boundary types) ───────────────────────────

type ScreenState = "idle" | "loading" | "loaded" | "empty" | "error";

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserFilters {
  role: string | null;
  status: string | null;
}

interface UserListResponse {
  data: User[];
  meta: { total: number };
}

export class UserListModel {
  // ── Atoms (reactive state, all typed) ──────────────────────────
  users: User[] = $state([]);
  totalCount: number = $state(0);
  page: number = $state(1);
  perPage: number = $state(20);
  searchQuery: string = $state("");
  filters: UserFilters = $state({ role: null, status: null });
  error: string | null = $state(null);
  screenState: ScreenState = $state("idle");

  // ── Derived ────────────────────────────────────────────────────
  totalPages: number = $derived(Math.ceil(this.totalCount / this.perPage));

  // ── Actions (typed public API for components) ──────────────────
  async search(query: string): Promise<void> {
    this.searchQuery = query;
    this.page = 1; // @INVARIANT: reset pagination
    await this._fetch();
  }

  setFilter<K extends keyof UserFilters>(key: K, value: UserFilters[K]): void {
    this.filters[key] = value;
    this.page = 1; // @INVARIANT: reset pagination
    this._fetch();
  }

  async deleteUser(id: string): Promise<void> {
    try {
      await requestApi(`/api/users/${id}`, { method: "DELETE" });
      // @INVARIANT: atomic removal
      this.users = this.users.filter((u: User) => u.id !== id);
      this.totalCount--;
    } catch (e: unknown) {
      this.error = e instanceof Error ? e.message : "Delete failed";
      this.screenState = "error";
    }
  }

  async loadPage(n: number): Promise<void> {
    this.page = n;
    await this._fetch();
  }

  async retry(): Promise<void> { await this._fetch(); }

  reset(): void {
    this.searchQuery = "";
    this.filters = { role: null, status: null };
    this.page = 1;
    this._fetch();
  }

  // ── Private ────────────────────────────────────────────────────
  private async _fetch(): Promise<void> {
    this.screenState = "loading";
    this.error = null;
    try {
      const params = new URLSearchParams({
        q: this.searchQuery,
        page: String(this.page),
        per_page: String(this.perPage),
        ...this.filters
      } as Record<string, string>);
      const res: UserListResponse = await requestApi(`/api/users?${params}`);
      this.users = res.data;
      this.totalCount = res.meta.total;
      this.screenState = this.users.length === 0 ? "empty" : "loaded";
    } catch (e: unknown) {
      this.error = e instanceof Error ? e.message : "Fetch failed";
      this.screenState = "error";
    }
  }
}
// #endregion UserListModel
```

### Component Binds to Model (RSM pattern)

The component contract declares: `@RELATION BINDS_TO -> [ModelId]`. The component code is minimal — it renders model state and calls `model.action()` on user intent. No side-effect logic lives in event handlers.

```svelte
<!-- #region UserListPage [C:3] [TYPE Component] [SEMANTICS users,list,page] -->
<!-- @BRIEF User list page — renders UserListModel state, delegates all logic to the model. -->
<!-- @RELATION BINDS_TO -> [UserListModel] -->
<!-- @UX_TEST: Loaded -> {click: "delete", expected: User removed, count decremented}. -->
<script>
  import { UserListModel } from "./UserListModel.js";
  const model = new UserListModel();
  // Initial load
  $effect(() => { model.loadPage(1); });
</script>

<div class="max-w-7xl mx-auto px-4 py-6">
  {#if model.screenState === "error"}
    <div role="alert" class="text-red-600">{model.error}</div>
    <button onclick={() => model.retry()}>Retry</button>
  {:else if model.screenState === "empty"}
    <p class="text-gray-500">No users found.</p>
  {:else}
    <ul>
      {#each model.users as user (user.id)}
        <li>
          {user.name}
          <button onclick={() => model.deleteUser(user.id)}>Delete</button>
        </li>
      {/each}
    </ul>
    <nav>Page {model.page} of {model.totalPages}</nav>
  {/if}
</div>
<!-- #endregion UserListPage -->
```

### Searching for Models

```
# Quick grep across all frontend files
grep "@semantics.*users" frontend/src/lib/**/*.{js,ts,svelte}

# Axiom semantic search (structured)
search_contracts query="users" type="Model"

# Both methods return models in one shot — no need to trace scattered event handlers.
```

### When to Use a Model vs. a Store vs. Inline Component State

| Pattern | Use Case |
|---------|----------|
| **Model** (`[TYPE Model]`) | Screen-level state with cross-widget invariants (filters, pagination, search). Multiple components read/write the same atoms. |
| **Store** (`BINDS_TO -> [storeId]`) | Global cross-route state (auth, notifications, task drawer). Persists across navigation. |
| **Inline $state** | Local component UI state (accordion open, tooltip visible, input focus). No cross-component invariants. |

## IV. IMPLEMENTATION & ACCESSIBILITY (A11Y)

1. **Event Handling:** Use native attributes (e.g., `onclick={handler}`, `onchange={handler}`).
2. **Transitions:** Use Svelte's built-in transitions (`fade`, `slide`, `fly`) for UI state changes.
3. **Async Logic:** Every async task (API calls) MUST be handled within a `try/catch` block that:
   - Sets `isLoading = $state(true)` before the call
   - Catches errors and transitions to `Error` `@UX_STATE`
   - Provides `@UX_FEEDBACK` (Toast notification)
   - Sets `isLoading = $state(false)` in `finally`
4. **A11Y:** Proper ARIA roles (`aria-busy`, `aria-invalid`, `aria-describedby`). Semantic HTML (`<nav>`, `<main>`, `<section>`). Keyboard navigation for modals and drawers.

## V. LOGGING (MOLECULAR-COT FOR UI)

Frontend logging uses `log()` from `$lib/cot-logger`, emitting **JSON lines** per MolecularCoTLogging protocol.
Import: `import { log } from "$lib/cot-logger";`

The logger is a TypeScript module at `frontend/src/lib/cot-logger.ts` with full type support:

```typescript
import { log } from "$lib/cot-logger";

// Before an operation:
log("ComponentName", "REASON", "What we are about to do", { param: value });

// After successful verification:
log("ComponentName", "REFLECT", "Operation completed", { result: value });

// On error or fallback:
log("ComponentName", "EXPLORE", "Operation failed", { param: value }, "Error description");
```

### Marker Reference

| Marker | When | Signature |
|--------|------|-----------|
| `REASON` | BEFORE API call or state mutation | `log("ComponentID", "REASON", "intent", payload)` |
| `REFLECT` | AFTER successful operation (verification) | `log("ComponentID", "REFLECT", "outcome", payload)` |
| `EXPLORE` | ON error, fallback, or violated assumption | `log("ComponentID", "EXPLORE", "message", payload, error="...")` |

### Invariants
- Every log line is a **single JSON object** — no plain-text prefixes.
- `trace_id` propagates from HTTP response headers via the ss-tools API wrappers.
- One marker per line. No markerless log lines in C4/C5 components.

## VI. CANONICAL SVELTE 5 COMPONENT TEMPLATE (SS-TOOLS)

Region format for HTML/Svelte comments:

```html
<!-- #region MigrationTaskCard [C:3] [TYPE Component] [SEMANTICS ui,migration,task] -->
<!-- @BRIEF Card displaying a migration task with status, progress, and action buttons. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [StatusBadge] -->
<!-- @RELATION DEPENDS_ON -> [ProgressBar] -->
<!-- @RELATION BINDS_TO -> [taskDrawerStore] -->
<!-- @RELATION BINDS_TO -> [notificationStore] -->
<!-- @UX_STATE Idle -> Default card view with task summary. -->
<!-- @UX_STATE Loading -> Action button disabled, spinner active, progress bar animated. -->
<!-- @UX_STATE Error -> Card border + bg use destructive tokens, retry button visible. -->
<!-- @UX_STATE Success -> Card border + bg use success tokens, checkmark, duration displayed. -->
<!-- @UX_FEEDBACK Toast notification on start/fail/complete. -->
<!-- @UX_FEEDBACK Drawer opens on "View Logs" click. -->
<!-- @UX_RECOVERY Retry button on error. Clear/Cancel on running task. -->
<!-- @UX_REACTIVITY Props -> $props(), LocalState -> $state(isLoading, error). -->
<!-- @UX_TEST: Idle -> {click: "Run Migration", expected: Loading -> Success toast}. -->
<!-- @RATIONALE Uses semantic tokens (destructive-light, success-light, border, surface-card) instead of raw Tailwind (red-*, green-*, gray-*). Uses $lib/ui Button instead of raw <button>. This is the canonical visual reference for all components. -->
<script lang="ts">
  import { fetchApi } from "$lib/api";
  import { log } from "$lib/cot-logger";
  import { t } from "$lib/i18n";
  import { Button } from "$lib/ui";
  import { taskDrawerStore } from "$lib/stores";
  import { notificationStore } from "$lib/stores";
  import StatusBadge from "./StatusBadge.svelte";
  import ProgressBar from "./ProgressBar.svelte";

  let { taskId, dashboardName, sourceEnv, targetEnv } = $props();

  let isLoading = $state(false);
  let error: string | null = $state(null);
  let status: "idle" | "loading" | "success" | "error" = $state("idle");

  async function handleRunMigration() {
    isLoading = true;
    status = "loading";
    error = null;
    log("MigrationTaskCard", "REASON", "Starting migration", {
      taskId, dashboardName, sourceEnv, targetEnv
    });
    try {
      const result = await fetchApi(`/api/tasks/${taskId}/run`, { method: "POST" });
      status = "success";
      log("MigrationTaskCard", "REFLECT", "Migration completed", { taskId, result });
      notificationStore.add({ type: "success", message: $t("migration.completed", { name: dashboardName }) });
    } catch (e) {
      status = "error";
      error = e instanceof Error ? e.message : "Migration failed";
      log("MigrationTaskCard", "EXPLORE", "Migration failed", { taskId }, error);
      notificationStore.add({ type: "error", message: $t("migration.failed", { name: dashboardName }) });
    } finally {
      isLoading = false;
    }
  }

  function handleViewLogs() {
    log("MigrationTaskCard", "REASON", "Opening task drawer", { taskId });
    taskDrawerStore.open(taskId);
  }
</script>

<!-- @RATIONALE Card uses semantic surface/border/text tokens. Dynamic state uses destructive/success token families. Button component from $lib/ui instead of raw <button> tags. -->
<div
  class="rounded-lg p-4 transition-colors
    {status === 'error' ? 'border border-destructive-ring bg-destructive-light' : ''}
    {status === 'success' ? 'border border-success-DEFAULT bg-success-light' : ''}
    {status !== 'error' && status !== 'success' ? 'border border-border bg-surface-card' : ''}"
  role="region"
  aria-label={$t("migration.task_card", { name: dashboardName })}
>
  <div class="flex items-center justify-between mb-2">
    <h3 class="font-semibold text-text">{dashboardName}</h3>
    <StatusBadge status={status} />
  </div>

  <div class="text-sm text-text-muted mb-3">
    {$t("migration.from")}: {sourceEnv} → {$t("migration.to")}: {targetEnv}
  </div>

  {#if status === "loading"}
    <ProgressBar />
  {/if}

  {#if error}
    <p class="text-sm text-destructive mb-2" aria-live="polite">{error}</p>
  {/if}

  <div class="flex gap-2 mt-3">
    <Button
      variant="primary"
      size="sm"
      onclick={handleRunMigration}
      isLoading={isLoading}
    >
      {status === "error" ? $t("actions.retry") : $t("actions.run")}
    </Button>
    <Button variant="secondary" size="sm" onclick={handleViewLogs}>
      {$t("actions.view_logs")}
    </Button>
  </div>
</div>
<!-- #endregion MigrationTaskCard -->
```

## VII. SS-TOOLS DESIGN TOKEN CANON & COMPONENT REUSE

### Design tokens (source of truth: `frontend/tailwind.config.js`)

**Raw Tailwind colors (`blue-600`, `green-500`, `red-600`, `gray-*`, `indigo-*`) are DEPRECATED in page-level and component code.** Use ONLY semantic tokens below.

| Purpose | Token | Maps to |
|---------|-------|---------|
| Primary action | `text-primary bg-primary hover:bg-primary-hover` | blue-600/700 |
| Destructive action / error surface | `text-destructive bg-destructive-light border-destructive-ring` | red family |
| Page background | `bg-surface-page` | near-white |
| Card surface | `bg-surface-card` | white |
| Muted surface (hover/filter bars) | `bg-surface-muted` | gray-100 |
| Default border | `border-border` | gray-200 |
| Strong border (inputs, focus) | `border-border-strong` | gray-300 |
| Primary text | `text-text` | near-black |
| Muted text (secondary, captions) | `text-text-muted` | gray-500 |
| Subtle text (placeholders) | `text-text-subtle` | gray-400 |
| Success | `text-success bg-success-light border-success-*` | green family |
| Warning | `text-warning bg-warning-light border-warning-*` | amber family |
| Info | `text-info bg-info-light border-info-*` | sky family |

### Component reuse rules (MANDATORY for page-level code)

| Rule | Requirement |
|------|------------|
| **$lib/ui mandatory** | All page files (`src/routes/**/+page.svelte`) MUST import from `$lib/ui` for buttons, cards, inputs, selects, page headers. Raw `<button>` and `<div class="bg-white rounded...">` in page files are a violation unless covered by a documented exception. |
| **Component directory** | New domain components go in `src/lib/components/<domain>/`. `src/components/` is **LEGACY FROZEN** — do not add new files, do not extend, migrate out only. |
| **Button variants** | Use `<Button variant="primary">` (default), `<Button variant="secondary">`, `<Button variant="destructive">`, `<Button variant="ghost">`. The string `"danger"` is kept as a deprecated alias for `"destructive"` — prefer `"destructive"`. |
| **Page layout** | `<div class="max-w-7xl mx-auto px-4 py-6">` or `<div class="mx-auto w-full px-4 lg:px-8 space-y-6">`. |
| **Table pattern** | `min-w-full divide-y divide-border` — border via token. |

### Canonical semantic token reference (copy-paste for agents)

```
// ✅ CORRECT — semantic tokens
bg-surface-page               // page background
bg-surface-card               // card container
bg-surface-muted              // filter bar, secondary button hover
border-border                 // card border, table divider
border-border-strong          // input border, select border
text-text                     // heading, body
text-text-muted               // secondary label, caption
text-text-subtle              // placeholder
bg-primary text-white         // primary action button
bg-destructive-light          // error surface
text-destructive              // error text
border-destructive-ring       // error border

// ❌ WRONG — raw Tailwind (deprecated in page/component code)
bg-blue-600 text-blue-700 bg-gray-50 bg-white border-gray-200
text-gray-900 text-gray-500 bg-red-50 border-red-400 bg-green-50
bg-indigo-50 text-indigo-700 bg-gradient-to-br from-slate-50 via-white to-sky-50
```

## VIII. VITEST CONVENTIONS

### Two Testing Layers

| Layer | What | Where | Runs |
|-------|------|-------|------|
| **Model invariants** | `@INVARIANT` rules, `@ACTION` side effects, `@STATE` transitions | vitest unit test — **no render** | ~10ms |
| **UX contracts** | `@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY` visual behavior | vitest with `@testing-library/svelte` + browser validation | ~500ms |

**Rule:** Model invariants MUST be verified without render. UX contracts MAY use render + browser. This eliminates the confusion that slows down the feedback loop — a filter-reset invariant doesn't need a DOM.

### Model Invariant Tests (No Render)

```javascript
// #region UserListModelTests [C:3] [TYPE Module] [SEMANTICS test,model]
// @BRIEF Verify UserListModel @INVARIANT guarantees without DOM rendering.
// @RELATION BINDS_TO -> [UserListModel]
// @TEST_INVARIANT: filter-resets-pagination -> VERIFIED_BY: [test_filter_resets_pagination]
// @TEST_INVARIANT: atomic-delete -> VERIFIED_BY: [test_delete_removes_user_and_decrements]
import { describe, it, expect, vi, beforeEach } from "vitest";
import { UserListModel } from "../UserListModel.js";

describe("UserListModel invariants", () => {
  let model;

  beforeEach(() => {
    vi.mock("$lib/api", () => ({
      requestApi: vi.fn().mockResolvedValue({ data: [], meta: { total: 0 } })
    }));
    model = new UserListModel();
  });

  // @INVARIANT: Changing filter resets pagination to page 1.
  it("resets page to 1 when filter changes", () => {
    model.page = 5;
    model.setFilter("role", "admin");
    expect(model.page).toBe(1);
  });

  it("resets page to 1 on search", () => {
    model.page = 3;
    model.search("john");
    expect(model.page).toBe(1);
  });

  // @INVARIANT: Deleting a user removes it and decrements count atomically.
  it("removes user and decrements count on delete", async () => {
    model.users = [{ id: "1", name: "Alice" }, { id: "2", name: "Bob" }];
    model.totalCount = 2;
    vi.mocked(requestApi).mockResolvedValueOnce({ ok: true });
    await model.deleteUser("1");
    expect(model.users).toEqual([{ id: "2", name: "Bob" }]);
    expect(model.totalCount).toBe(1);
  });

  // Hardcoded fixture — no logic mirror
  it("reports empty state when API returns no results", async () => {
    vi.mocked(requestApi).mockResolvedValueOnce({ data: [], meta: { total: 0 } });
    await model._fetch();
    expect(model.screenState).toBe("empty");
    expect(model.users).toEqual([]);
  });
});
// #endregion UserListModelTests
```

### Component UX Tests (With Render)

```javascript
// #region MigrationTaskCardTests [C:1] [TYPE Module]
import { render, screen, fireEvent } from "@testing-library/svelte";
import { describe, it, expect, vi } from "vitest";
import MigrationTaskCard from "./MigrationTaskCard.svelte";

describe("MigrationTaskCard", () => {
  it("renders dashboard name and environments", () => {
    render(MigrationTaskCard, {
      props: { taskId: "1", dashboardName: "Sales", sourceEnv: "dev", targetEnv: "prod" }
    });
    expect(screen.getByText("Sales")).toBeTruthy();
    expect(screen.getByText(/dev.*prod/)).toBeTruthy();
  });

  it("shows loading state when action clicked", async () => {
    // ... button click → loading assertion
  });
});
// #endregion MigrationTaskCardTests
```

## IX. FRONTEND VERIFICATION

```bash
# From frontend/ directory
npm run test          # Vitest (unit/component tests)
npm run build         # Production build check
npm run dev           # Development server (for browser validation)
```

#endregion Std.Semantics.Svelte
