This commit is contained in:
2026-06-05 15:01:34 +03:00
parent 50180aaa14
commit 4cef6af041
27 changed files with 13547 additions and 892 deletions

View File

@@ -7,9 +7,10 @@ description: Svelte 5 (Runes) protocol for ss-tools: UX State Machines, 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.
@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.
@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.
@@ -78,7 +79,7 @@ The component-first approach forces you to encode system logic in event handlers
**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 topk attention selection. Closing `#endregion ModelName` duplicates the identifier — safe after aggressive context compression.
- **CSA resilience:** `#region Users.ListModel [C:N] [SEMANTICS ...]` on line 1 = maximum density for topk attention selection. Closing `#endregion Users.ListModel` 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
@@ -88,8 +89,8 @@ A Model is a **contract** — `#region ModelName [C:N] [TYPE Model] [SEMANTICS t
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]
// frontend/src/lib/models/UsersListModel.svelte.ts
// #region Users.ListModel [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.
@@ -130,7 +131,7 @@ interface UserListResponse {
meta: { total: number };
}
export class UserListModel {
export class UsersListModel {
// ── Atoms (reactive state, all typed) ──────────────────────────
users: User[] = $state([]);
totalCount: number = $state(0);
@@ -204,44 +205,51 @@ export class UserListModel {
}
}
}
// #endregion UserListModel
// #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).
```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] -->
<!-- #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>
import { UserListModel } from "./UserListModel.js";
const model = new UserListModel();
// Initial load
$effect(() => { model.loadPage(1); });
<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-red-600">{model.error}</div>
<button onclick={() => model.retry()}>Retry</button>
<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-gray-500">No users found.</p>
<p class="text-text-muted">No users found.</p>
{:else}
<ul>
{#each model.users as user (user.id)}
<li>
{user.name}
<button onclick={() => model.deleteUser(user.id)}>Delete</button>
<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 UserListPage -->
<!-- #endregion Users.ListPage -->
```
### Searching for Models
@@ -273,10 +281,10 @@ Models accumulate methods as features grow. To prevent "god object" anti-pattern
| 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 DashboardHubModel:**
- `DashboardFiltersModel` — search, column filters, sort
- `DashboardSelectionModel` — checkbox, select all/visible, bulk actions
- `DashboardGitActionsModel` — git init, sync, commit, pull, push
**Submodel split example for `Dashboards.Hub`:**
- `Dashboards.FiltersModel` — search, column filters, sort
- `Dashboards.SelectionModel` — checkbox, select all/visible, bulk actions
- `Dashboards.GitActionsModel` — git init, sync, commit, pull, push
Before decomposition, the model MUST carry `@INVARIANT DECOMPOSITION GATE` with the split plan and line count.
@@ -293,36 +301,7 @@ Before decomposition, the model MUST carry `@INVARIANT DECOMPOSITION GATE` with
## 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.
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)
@@ -499,84 +478,7 @@ bg-indigo-50 text-indigo-700 bg-gradient-to-br from-slate-50 via-white to-sky-50
**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
```
Full test templates (Model invariant + Component UX) → `semantics-testing` §VI-VII.
## IX. FRONTEND VERIFICATION