28 KiB
#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]
@RELATION DISPATCHES -> [Std.Semantics.Testing]
@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. Model-first architecture chosen because event-handler spaghetti is the #1 Transformer failure mode in UI code: the agent scatters logic across onclick/onchange in 5 files — KV-cache cannot hold cross-component relationships, creating invisible coupling that breaks silently.
@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 and dominates agent training data, causing silent regression. 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. Component-first architecture for complex screens rejected — the Model-first approach (model.svelte.ts → component) keeps system logic in one file where the agent's attention can find it via grep + search_contracts.
@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.tsextension. API DTOs live infrontend/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.anyis forbidden at external boundaries; useunknownwith 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$statecontainer. 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 — NEVERcreateEventDispatcheroron:eventdirectives. This keeps component interfaces explicit, type-checkable, and free of runtime event bus ambiguity. - $effect SCOPE:
$effectis 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 SvelteKitloadfunctions in+page.ts. Using$effectfor 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 viamodel.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:
- Styling: Tailwind CSS utility classes are MANDATORY. Minimize scoped
<style>. If custom CSS is absolutely necessary, use@applydirectives. - Localization: All user-facing text MUST use the
$tstore fromsrc/lib/i18n. No hardcoded UI strings. - API Layer: You MUST use the internal
fetchApi/requestApiwrappers from$lib/api. Using nativefetch()is a fatal violation. - SvelteKit Routing: Pages live under
src/routes/. Components live undersrc/lib/components/. Stores undersrc/lib/stores/. - Testing: Use Vitest with
@testing-library/sveltefor component tests. - 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 (nativetitle), 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.
- Atoms:
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 drawersidebarStore— Navigation sidebar stateauthStore— Authentication state (user, roles, permissions)notificationStore— Toast/snackbar notificationsdashboardStore— Active dashboard datamigrationStore— 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 Users.ListModel [C:N] [SEMANTICS ...]on line 1 = maximum density for top‑k attention selection. Closing#endregion Users.ListModelduplicates the identifier — safe after aggressive context compression. - Component simplicity: When a component contains only
$state,$derived, andmodel.action()calls, its contract is predictable. No guessing which side effect hides inonchange.
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.
// frontend/src/lib/models/UsersListModel.svelte.ts
// #region Users.ListModel [C:4] [TYPE Model] [SEMANTICS users,list,screen-model]
// @ingroup Users
// @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 UsersListModel {
// ── 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 Users.ListModel
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.
For route-level data loading, use SvelteKit load() in +page.ts — NOT $effect (per §0: $effect is for browser-side side effects only).
<!-- #region Users.ListPage [C:3] [TYPE Component] [SEMANTICS users,list,page] -->
<!-- @BRIEF User list page — renders Users.ListModel state, delegates all logic to the model. -->
<!-- @RELATION BINDS_TO -> [Users.ListModel] -->
<!-- @UX_TEST: Loaded -> {click: "delete", expected: User removed, count decremented}. -->
<script lang="ts">
import { onMount } from "svelte";
import { Button } from "$lib/ui";
import { UsersListModel } from "./UsersListModel.svelte.ts";
const model = new UsersListModel();
onMount(() => {
model.loadPage(1);
});
</script>
<div class="max-w-7xl mx-auto px-4 py-6">
{#if model.screenState === "error"}
<div role="alert" class="text-destructive">{model.error}</div>
<Button variant="primary" size="sm" onclick={() => model.retry()}>Retry</Button>
{:else if model.screenState === "empty"}
<p class="text-text-muted">No users found.</p>
{:else}
<ul>
{#each model.users as user (user.id)}
<li>
{user.name}
<Button variant="ghost" size="sm" onclick={() => model.deleteUser(user.id)}>Delete</Button>
</li>
{/each}
</ul>
<nav>Page {model.page} of {model.totalPages}</nav>
{/if}
</div>
<!-- #endregion Users.ListPage -->
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. |
Model Decomposition Gate
Models accumulate methods as features grow. To prevent "god object" anti-pattern:
| Threshold | Action |
|---|---|
| Model > 400 lines | Decompose — extract domain helpers or split into submodels |
| Model > 40 public methods | Split into submodels by responsibility (e.g. FiltersModel, SelectionModel, GitActionsModel) |
Submodel split example for Dashboards.Hub:
Dashboards.FiltersModel— search, column filters, sortDashboards.SelectionModel— checkbox, select all/visible, bulk actionsDashboards.GitActionsModel— git init, sync, commit, pull, push
Before decomposition, the model MUST carry @INVARIANT DECOMPOSITION GATE with the split plan and line count.
IV. IMPLEMENTATION & ACCESSIBILITY (A11Y)
- Event Handling: Use native attributes (e.g.,
onclick={handler},onchange={handler}). - Transitions: Use Svelte's built-in transitions (
fade,slide,fly) for UI state changes. - Async Logic: Every async task (API calls) MUST be handled within a
try/catchblock 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)infinally
- Sets
- 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 per MolecularCoTLogging protocol. Import: import { log } from "$lib/cot-logger". Full wire-format spec, marker reference, and invariants → molecular-cot-logging skill §I-VII.
VI. CANONICAL SVELTE 5 COMPONENT TEMPLATE (SS-TOOLS)
Region format for HTML/Svelte comments:
<!-- #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.
Full test templates (Model invariant + Component UX) → semantics-testing §VI-VII.
IX. FRONTEND VERIFICATION
# 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