freeze fix
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
# [DEF:ADR-0006:ADR]
|
||||
# @STATUS ACTIVE
|
||||
# @PURPOSE Define the frontend architecture for ss-tools: Svelte 5 runes reactivity model, SvelteKit routing, component composition rules, state management patterns, API client conventions, and UX contract expectations for C4/C5 components.
|
||||
# @PURPOSE Define the frontend architecture for ss-tools: Svelte 5 runes reactivity model, SvelteKit routing, component composition rules, state management patterns (incl. Screen Models), design token system, UI atom reuse, API client conventions, and UX contract expectations for C4/C5 components.
|
||||
# @RELATION DEPENDS_ON -> [ADR-0001:ADR]
|
||||
# @RELATION DEPENDS_ON -> [ADR-0002:ADR]
|
||||
# @RELATION DEPENDS_ON -> [ADR-0010:ADR]
|
||||
# @RATIONALE Svelte 5 introduces a fundamentally different reactivity model (runes: `$state`, `$derived`, `$effect`, `$props`) compared to Svelte 4's `$:` reactive statements. This ADR locks in the Svelte 5 runes approach and prevents backsliding into legacy patterns when new developers or AI agents contribute code.
|
||||
# @RATIONALE SvelteKit with static adapter (`@sveltejs/adapter-static`) was chosen over SvelteKit SSR because: (a) ss-tools is a Docker‑deployed SPA behind nginx — server‑side rendering provides no latency benefit, (b) static SPA simplifies Docker deployment (no Node.js server needed, just nginx serving static files), (c) all data is fetched via REST API, not server‑side load functions.
|
||||
# @RATIONALE Svelte 5 runes (`$state`, `$derived`, `$effect`) are mandated over Svelte 4 `$:` reactivity and `writable` stores because: (a) runes are the forward path — Svelte 4 patterns are deprecated, (b) runes provide fine‑grained reactivity without subscription boilerplate, (c) `$state` can be used outside `.svelte` files in `.svelte.js` modules, enabling reusable reactive logic.
|
||||
# @RATIONALE Design tokens (semantic colors in `tailwind.config.js`) were introduced in 2026‑06‑02 (PR 0‑1) to eliminate raw Tailwind classes (`bg‑blue‑600`, `text‑gray‑900`) from page‑level code. This prevents visual drift and gives AI agents a single source of truth for color choices. See §"Design Token System" below.
|
||||
# @RATIONALE Screen Model pattern (`[TYPE Model]` in `.svelte.ts` files) was formalised in 2026‑06‑02 (PR 3‑4) to extract cross‑widget state from page files. Models reduce page files from 2700+ lines to <1400 lines and enable unit‑testing of invariants without DOM render (~10ms). See §"State Management Pattern" and ADR‑0010.
|
||||
# @REJECTED React/Next.js — rejected by ADR-0003 (technology stack independence from Superset). Svelte 5 was chosen for its compile‑time approach, smaller bundle size, and superior DX for this project's scale.
|
||||
# @REJECTED Svelte 4 legacy patterns (`$:`, `writable`/`readable` stores) — rejected because Svelte 5 runes are the actively maintained reactivity model. Mixing two models creates confusion and potential reactivity bugs.
|
||||
# @REJECTED Server‑Side Rendering (SSR) with SvelteKit — rejected because the data is entirely API‑driven (no server‑side page data loading), and SSR adds deployment complexity (Node.js process) without latency benefit for authenticated SPA users.
|
||||
# @REJECTED Raw Tailwind color classes (`bg‑blue‑600`, `text‑gray‑900`, `border‑indigo‑300`, etc.) in page and component code — rejected because they create visual drift across 70+ files (3892 violations catalogued 2026‑06‑02). Replaced by semantic token system (see §"Design Token System").
|
||||
# @REJECTED Inline `$state` + handler functions in complex pages (>5 `$state` atoms) — rejected because they produce monolithic, untestable page files (DashboardHub was 2765 lines before extraction). Replaced by Screen Model pattern (see §"State Management Pattern").
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -19,7 +24,7 @@
|
||||
| Framework | SvelteKit | 2.x |
|
||||
| UI Library | Svelte | 5.x (runes mode) |
|
||||
| Build | Vite | 7.x |
|
||||
| Styling | Tailwind CSS | 3.x |
|
||||
| Styling | Tailwind CSS 3.x + semantic design tokens | 3.x |
|
||||
| Adapter | @sveltejs/adapter-static | 3.x (SPA mode) |
|
||||
| Testing | vitest + @testing-library/svelte | 4.x / 5.x |
|
||||
|
||||
@@ -32,24 +37,142 @@ let count = 0; let count = $state(0);
|
||||
$: doubled = count * 2; let doubled = $derived(count * 2);
|
||||
$: { /* side effect */ } $effect(() => { /* side effect */ });
|
||||
export let name; let { name } = $props();
|
||||
import { writable } from ... ❌ stores are .svelte.js modules with $state
|
||||
import { writable } from ... ❌ new stores MUST use .svelte.ts with $state
|
||||
⚠️ 8 existing writable stores are legacy — migrate, don't extend
|
||||
```
|
||||
|
||||
**Note on legacy stores:** 8 stores (`sidebar.ts`, `taskDrawer.ts`, `environmentContext.ts`, `datasetReviewSession.ts`, `assistantChat.ts`, `health.ts`, `activity.ts`, `translationRun.ts`) still use `writable()` from `svelte/store`. These predate Svelte 5 adoption. New reactive state MUST use `.svelte.ts` files with `$state`. Existing stores are read via `$store` syntax in `.svelte` files or `get()` in `.svelte.ts` modules. ADR‑0007 documents the `fromStore` + `$derived` rejection.
|
||||
|
||||
### State Management Pattern
|
||||
|
||||
Three tiers of state, strictly separated:
|
||||
**Four tiers of state**, strictly separated:
|
||||
|
||||
1. **Component‑local state**: `$state()` inside `.svelte` files. Never exported.
|
||||
2. **Shared reactive state**: `.svelte.js` modules exporting `$state` objects. Imported by components.
|
||||
```js
|
||||
// frontend/src/lib/stores/dashboard.svelte.js
|
||||
export const dashboardState = $state({
|
||||
dashboards: [],
|
||||
selectedId: null,
|
||||
isLoading: false
|
||||
});
|
||||
```
|
||||
3. **Server state**: Fetched via API client, held in `$state` variables locally. No global cache — each route fetches what it needs.
|
||||
| Tier | Pattern | File | Use case | Example |
|
||||
|------|---------|------|----------|---------|
|
||||
| 1. Component‑local | `$state()` in `.svelte` | `<Component>.svelte` | Accordion open, tooltip visible, input focus | `let isOpen = $state(false)` |
|
||||
| 2. Screen Model | `class` with `$state` + methods | `.svelte.ts` in `lib/models/` | **Cross‑widget state with invariants** (filters, pagination, search, multi‑step) | `DashboardHubModel`, `MigrationModel` |
|
||||
| 3. Global store | `$state` in `.svelte.ts` module | `.svelte.ts` in `lib/stores/` | Cross‑route state (auth, notifications, sidebar) | `maintenance.svelte.ts` |
|
||||
| 4. Server state | Fetched via API, held in Model or component | N/A | HTTP responses from backend | `api.getDashboards()` → model atom |
|
||||
|
||||
**Screen Model (tier 2) — the model‑first pattern:**
|
||||
|
||||
For any page with **>5 `$state` atoms** or **cross‑widget invariants** (filter resets pagination, selection survives search), create a `[TYPE Model]` in `lib/models/` BEFORE implementing components. The Model declares `@STATE`, `@ACTION`, `@INVARIANT` tags and is testable without DOM render.
|
||||
|
||||
```typescript
|
||||
// frontend/src/lib/models/DashboardHubModel.svelte.ts
|
||||
export class DashboardHubModel {
|
||||
// Atoms
|
||||
dashboards: DashboardRow[] = $state([]);
|
||||
page: number = $state(1);
|
||||
searchQuery: string = $state("");
|
||||
// ... < 40 atoms
|
||||
|
||||
// Actions
|
||||
async loadDashboards(): Promise<void> { ... }
|
||||
setPage(n: number): void { ... }
|
||||
// ... < 40 methods
|
||||
}
|
||||
```
|
||||
|
||||
Page file becomes a thin render layer:
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { DashboardHubModel } from "$lib/models/DashboardHubModel.svelte.ts";
|
||||
const m = new DashboardHubModel();
|
||||
</script>
|
||||
<!-- template uses m.dashboards, m.page, onClick={() => m.setPage(2)} -->
|
||||
```
|
||||
|
||||
**Decomposition gate** (ADR‑0010): If a Model exceeds **400 lines** or **40 public methods**, split into submodels by responsibility domain (e.g. `DashboardFiltersModel`, `DashboardSelectionModel`, `DashboardGitModel`).
|
||||
|
||||
### Design Token System
|
||||
|
||||
**Raw Tailwind color classes are DEPRECATED in page and component code.** All colors MUST use semantic tokens defined in `frontend/tailwind.config.js`. This is the single source of truth for the visual system.
|
||||
|
||||
**Token families** (from `tailwind.config.js`):
|
||||
|
||||
| Purpose | Token | Maps to |
|
||||
|---------|-------|---------|
|
||||
| Primary action | `bg-primary text-white` | blue‑600 |
|
||||
| Page background | `bg-surface-page` | slate‑50 |
|
||||
| Card surface | `bg-surface-card` | white |
|
||||
| Muted surface | `bg-surface-muted` | slate‑100 |
|
||||
| Default border | `border-border` | slate‑200 |
|
||||
| Strong border (inputs) | `border-border-strong` | slate‑300 |
|
||||
| Primary text | `text-text` | slate‑900 |
|
||||
| Muted text | `text-text-muted` | slate‑500 |
|
||||
| Subtle text (placeholders) | `text-text-subtle` | slate‑400 |
|
||||
| Destructive / error | `bg-destructive-light text-destructive border-destructive-ring` | red family |
|
||||
| Success | `bg-success-light text-success` | green family |
|
||||
| Warning | `bg-warning-light text-warning` | amber family |
|
||||
| Info | `bg-info-light text-info` | sky family |
|
||||
|
||||
**Correct vs. wrong:**
|
||||
|
||||
```svelte
|
||||
// ✅ CORRECT — semantic tokens
|
||||
<div class="bg-surface-card border border-border text-text">
|
||||
<Button variant="primary">
|
||||
|
||||
// ❌ WRONG — raw Tailwind (forbidden in page/component code)
|
||||
<div class="bg-white border border-gray-200 text-gray-900">
|
||||
<button class="bg-blue-600 text-white">
|
||||
```
|
||||
|
||||
**Enforcement:** `scripts/audit-frontend-style.mjs` scans `src/routes/**` and `src/lib/components/**` for raw color violations. CI rejects PRs with raw colors. Canonical token reference and copy‑paste examples live in `semantics‑svelte` §VII.
|
||||
|
||||
### UI Atom Reuse
|
||||
|
||||
**Page‑level UI MUST use `$lib/ui` atoms.** Raw `<button>` elements and manual `<div class="bg-white rounded...">` cards in page files are a violation.
|
||||
|
||||
| Atom | Import | Variants |
|
||||
|------|--------|----------|
|
||||
| `Button` | `from "$lib/ui"` | `primary` (default), `secondary`, `destructive` (канонический), `ghost`. `danger` — deprecated alias |
|
||||
| `Card` | `from "$lib/ui"` | `padding="md"` (default), `none`, `sm`, `lg`; optional `title` |
|
||||
| `Input` | `from "$lib/ui"` | `label`, `error`, `disabled`, `type`; optional `id` |
|
||||
| `Select` | `from "$lib/ui"` | `label`, `options`, `disabled`; optional `id` |
|
||||
| `PageHeader` | `from "$lib/ui"` | `title`, optional `subtitle` slot, `actions` slot |
|
||||
| `EmptyState` | `from "$lib/ui"` | `title`, `description`, optional `actionLabel` + `onAction` |
|
||||
| `Icon` | `from "$lib/ui"` | `name`, `size`, `class`, `strokeWidth` |
|
||||
| `HelpTooltip` | `from "$lib/ui"` | text tooltip |
|
||||
| `LanguageSwitcher` | `from "$lib/ui"` | lang toggle |
|
||||
|
||||
Atoms are the ONLY place where raw Tailwind is allowed — they encapsulate design tokens so pages don't need to.
|
||||
|
||||
### Component File Structure
|
||||
|
||||
Three directories, different rules:
|
||||
|
||||
```
|
||||
frontend/src/lib/ui/ # UI atoms (9 files) — MANDATORY for pages
|
||||
├── Button.svelte, Card.svelte, Input.svelte, Select.svelte
|
||||
├── PageHeader.svelte, EmptyState.svelte, Icon.svelte
|
||||
├── HelpTooltip.svelte, LanguageSwitcher.svelte
|
||||
└── index.ts # Barrel export
|
||||
|
||||
frontend/src/lib/components/ # Domain components (30+ files) — NEW components go HERE
|
||||
├── layout/ (Sidebar, TopNavbar, TaskDrawer, Breadcrumbs)
|
||||
├── translate/ (TranslationPreview, BulkReplaceModal, ...)
|
||||
├── reports/ (ReportsList, ReportCard, ReportDetailPanel)
|
||||
├── dataset-review/ (ValidationFindingsPanel, SourceIntakePanel, ...)
|
||||
├── health/ (HealthMatrix, ScheduleAtAGlance, PolicyForm)
|
||||
├── llm/ (ValidationTaskForm, UrlParser, ValidationTaskReport)
|
||||
├── ui/ (SearchableMultiSelect, MultiSelect, DatasetSearchCombobox)
|
||||
├── assistant/ (AssistantChatPanel, AssistantClarificationCard)
|
||||
├── settings/ (ApiKeysTab)
|
||||
└── (DashboardMaintenanceBadge, MaintenanceEventsTable, ...)
|
||||
|
||||
frontend/src/components/ # LEGACY FROZEN (40 files) — do NOT add, migrate out
|
||||
├── auth/ git/ tasks/ tools/ backups/ storage/ llm/
|
||||
├── EnvSelector, DashboardGrid, MappingTable, TaskRunner, TaskHistory
|
||||
├── TaskLogViewer, Toast, Navbar, Footer, PasswordPrompt
|
||||
└── StartupEnvironmentWizard, DynamicForm, MissingMappingModal
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- New domain components → `lib/components/<domain>/`
|
||||
- `src/components/` — заморожено. Новые файлы запрещены. Существующие — мигрировать в `lib/components/` при рефакторинге.
|
||||
- `svelte.config.js` alias `$components → src/components` помечен `@DEPRECATED`.
|
||||
|
||||
### API Client Convention
|
||||
|
||||
@@ -57,23 +180,92 @@ API client modules live under `frontend/src/lib/api/`. Each module wraps `fetch`
|
||||
|
||||
### Component Complexity & UX Contracts
|
||||
|
||||
Svelte components with side effects (API calls, WebSocket subscriptions, file operations) MUST carry UX contract tags as defined by the `semantics-frontend` skill (`.opencode/skills/semantics-frontend/SKILL.md`). The skill is the canonical source for tag inventory, syntax, and per‑complexity requirements.
|
||||
|
||||
### Component File Structure
|
||||
|
||||
```
|
||||
frontend/src/lib/components/
|
||||
├── DashboardGrid.svelte # C4: complex data grid with WebSocket updates
|
||||
├── MappingTable.svelte # C3: migration mapping table
|
||||
├── PluginExecutionCard.svelte # C4: plugin runner with progress feedback
|
||||
├── RoleBadge.svelte # C1: simple role display
|
||||
└── ...
|
||||
```
|
||||
Svelte components with side effects (API calls, WebSocket subscriptions, file operations) MUST carry UX contract tags as defined by the `semantics‑svelte` skill (`.opencode/skills/semantics‑svelte/SKILL.md`). The skill is the canonical source for tag inventory (`@UX_STATE`, `@UX_FEEDBACK`, `@UX_RECOVERY`, `@UX_REACTIVITY`, `@UX_TEST`), syntax, and per‑complexity requirements.
|
||||
|
||||
### Routing
|
||||
|
||||
- SvelteKit file‑based routing under `frontend/src/routes/`
|
||||
- Protected routes check auth in `+layout.svelte` (redirect to `/login` if no token)
|
||||
- Route structure: `/` (dashboard list), `/envs/[id]` (environment detail), `/migration` (migration wizard), `/plugins` (plugin management), `/admin` (user/role management)
|
||||
SvelteKit file‑based routing under `frontend/src/routes/`. Protected routes check auth in `+layout.svelte` (redirect to `/login` if no token).
|
||||
|
||||
**Actual route structure** (verified 2026‑06‑02):
|
||||
|
||||
| Route | Page | Complexity |
|
||||
|-------|------|------------|
|
||||
| `/login` | Login page | C1 |
|
||||
| `/` (redirect → `/dashboards`) | Dashboard hub | C4 → C4+Model |
|
||||
| `/dashboards` | Dashboard hub with git status, validation badges, bulk actions | C4 |
|
||||
| `/dashboards/[id]` | Dashboard detail: metadata, charts, datasets, git, task history | C4 |
|
||||
| `/dashboards/[id]/validation` | Validation status and run history | C3 |
|
||||
| `/dashboards/health` | Dashboard health matrix | C3 |
|
||||
| `/datasets` | Dataset hub with mapping progress, split view | C4 |
|
||||
| `/datasets/[id]` | Dataset detail | C3 |
|
||||
| `/datasets/review` | Dataset review session list | C3 |
|
||||
| `/datasets/review/[id]` | Review workspace: semantic layer, SQL, execution mapping | C5 |
|
||||
| `/migration` | Migration wizard (env selection → dashboards → review → execute) | C4 |
|
||||
| `/migration/mappings` | Migration database mappings | C3 |
|
||||
| `/reports` | Unified reports with task‑type and status filtering | C3 |
|
||||
| `/reports/llm/[taskId]` | LLM validation report | C3 |
|
||||
| `/validation‑tasks` | Validation tasks list with status filter, pagination | C3 |
|
||||
| `/validation‑tasks/new` | Create validation task | C3 |
|
||||
| `/validation‑tasks/[policyId]` | Task detail + runs | C3 |
|
||||
| `/validation‑tasks/[policyId]/edit` | Edit validation task | C3 |
|
||||
| `/validation‑tasks/[policyId]/runs/[runId]` | Run detail with logs | C4 |
|
||||
| `/validation‑tasks/history` | Task run history | C3 |
|
||||
| `/settings` | Consolidated settings (environments, logging, LLM, migration, storage, features, system) | C4 |
|
||||
| `/settings/git` | Git configuration | C3 |
|
||||
| `/settings/notifications` | Notification preferences | C2 |
|
||||
| `/settings/automation` | Automation rules | C3 |
|
||||
| `/profile` | User profile and preferences | C2 |
|
||||
| `/translate` | Translation hub: dictionaries + configs | C4 |
|
||||
| `/translate/[id]` | Translation config detail | C4 |
|
||||
| `/translate/dictionaries` | Dictionary list | C3 |
|
||||
| `/translate/dictionaries/[id]` | Dictionary detail with entries | C4 |
|
||||
| `/translate/history` | Translation run history | C3 |
|
||||
| `/admin/users` | User management (RBAC) | C3 |
|
||||
| `/admin/roles` | Role management | C3 |
|
||||
| `/admin/settings` | Admin‑level settings | C3 |
|
||||
| `/admin/settings/llm` | LLM provider configuration | C3 |
|
||||
| `/git` | Git operations | C3 |
|
||||
| `/storage` | Storage management | C3 |
|
||||
| `/storage/repos` | Repository list | C3 |
|
||||
| `/storage/backups` | Backup management | C3 |
|
||||
| `/tools/mapper` | Data mapper tool | C3 |
|
||||
| `/tools/debug` | Debug tool | C3 |
|
||||
| `/tools/storage` | Storage tool | C3 |
|
||||
| `/tools/backups` | Backup tool | C3 |
|
||||
| `/maintenance` | Maintenance events and settings | C3 |
|
||||
|
||||
### Guardrail Enforcement
|
||||
|
||||
`scripts/audit-frontend-style.mjs` — CI‑ready script that checks:
|
||||
|
||||
1. **Raw color violations** — `bg‑blue‑*`, `text‑gray‑*`, `border‑indigo‑*`, etc. in page and component files
|
||||
2. **Oversized pages** — pages > 400 lines (INV_7)
|
||||
3. **Model‑first violations** — pages with >5 `$state` atoms and no Model import
|
||||
4. **Manual `<button>` warnings** — pages with `<button>` but no `<Button>` import
|
||||
5. **Legacy directory** — files remaining in frozen `src/components/`
|
||||
|
||||
Exit code 1 on violations → CI blocks merge.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Design tokens eliminate visual drift — AI agents get a single color palette.
|
||||
- Screen Models make complex pages testable without DOM (~10ms unit tests).
|
||||
- Guardrail catches violations before merge.
|
||||
- UI atoms ensure consistent interaction patterns (loading states, error borders, focus rings).
|
||||
|
||||
### Negative
|
||||
- Legacy writable stores require `$store` syntax in `.svelte` files and `get()` in `.svelte.ts` — a bridge between Svelte 4 and 5 that must be maintained until full migration.
|
||||
- `src/components/` still exists as a frozen directory — 40 files to migrate.
|
||||
- Screen Model `this`‑context requires arrow wrappers (`onclick={() => m.method()}`) — a pitfall documented in ai‑native‑design‑audit.md §10.
|
||||
|
||||
## References
|
||||
|
||||
- ADR‑0001 — Module Layout
|
||||
- ADR‑0002 — Semantic Protocol
|
||||
- ADR‑0007 — `fromStore` + `$derived` rejection
|
||||
- ADR‑0010 — Model Decomposition Gate
|
||||
- `semantics‑svelte` skill — UI implementation rules, canonical template
|
||||
- `ai‑native‑design‑audit.md` — design audit findings and execution log
|
||||
|
||||
# [/DEF:ADR-0006:ADR]
|
||||
|
||||
119
docs/adr/ADR-0010-model-decomposition-gate.md
Normal file
119
docs/adr/ADR-0010-model-decomposition-gate.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# [DEF:ADR-0010:ADR]
|
||||
# @STATUS ACTIVE
|
||||
# @PURPOSE Define the model decomposition gate for Svelte 5 Screen Models: maximum size thresholds, split strategy, and enforcement mechanism to prevent god‑object anti‑pattern in model‑first architecture.
|
||||
# @RELATION DEPENDS_ON -> [ADR-0006:ADR]
|
||||
# @RELATION REFINES -> [Std.Semantics.Svelte]
|
||||
# @RELATION CALLS -> [DashboardHubModel]
|
||||
# @RATIONALE Screen Models (`[TYPE Model]` in `.svelte.ts` files) aggregate all screen‑level state and actions. Without a size gate, models accumulate methods indefinitely as features grow — reproducing the same monolith problem that model‑first architecture was designed to solve. The DashboardHubModel (2026‑06‑02, PR 4) is the first model to approach the threshold at 350 lines and 35 public methods. This ADR locks in the decomposition rule before the model crosses the boundary.
|
||||
# @RATIONALE 400‑line threshold mirrors INV_7 (module <400 lines) from `semantics‑core` §I, creating a consistent rule across both page files and model files.
|
||||
# @RATIONALE 40‑method threshold is based on practical cognitive load: beyond ~40 public methods, an AI agent or human developer cannot hold the full model API in working memory when reasoning about invariants.
|
||||
# @REJECTED No size gate — rejected because DashboardHubModel already demonstrates the accumulation pattern (35 atoms, 35 methods from one refactoring pass). Without a gate, future features (batch‑delete, export, saved‑filters) would push it past 600 lines.
|
||||
# @REJECTED Arbitrary split (e.g. file‑size only) — rejected because splitting by line count alone ignores method cohesion. The split MUST follow responsibility boundaries (filters, selection, git) to keep invariants within each submodel locally verifiable.
|
||||
|
||||
# ADR-0010: Model Decomposition Gate
|
||||
|
||||
## Status
|
||||
ACTIVE — enforced for all C4+ Screen Models as of 2026‑06‑02.
|
||||
|
||||
## Context
|
||||
|
||||
Screen Models (defined in `semantics‑svelte` §IIIa) are the single source of truth for screen‑level state. They use Svelte 5 runes (`$state`, `$derived`) in `.svelte.ts` files and declare `@STATE`, `@ACTION`, and `@INVARIANT` annotations.
|
||||
|
||||
The first wave of model extraction (PR 4, June 2026) produced `DashboardHubModel`: 350 lines, 35 `$state` atoms, and 35 public methods. While this is a 51% reduction from the original 2765‑line page, the model already bundles four distinct responsibility domains:
|
||||
|
||||
1. **Core data** — pagination, loading/error state, environment context
|
||||
2. **Filters & sort** — column filter dropdowns, search, sort direction
|
||||
3. **Selection & bulk actions** — checkboxes, select‑all, migrate/backup modals
|
||||
4. **Git operations** — init, sync, commit, pull, push, status batch
|
||||
|
||||
Adding future features (batch‑delete, saved‑filters, export) to the same model would push it past 600 lines — reproducing the monolith anti‑pattern at the model layer.
|
||||
|
||||
## Decision
|
||||
|
||||
### Thresholds
|
||||
|
||||
| Criterion | Threshold | Action |
|
||||
|-----------|-----------|--------|
|
||||
| Model file > **400 lines** | Mandatory | MUST decompose before adding new methods |
|
||||
| Model > **40 public methods** (excluding private `_` helpers) | Mandatory | MUST split into submodels by responsibility |
|
||||
| Either threshold crossed | Hard gate | New PRs adding methods to the model MUST include the split |
|
||||
|
||||
### Split Strategy
|
||||
|
||||
When a model crosses either threshold, split into submodels by **responsibility domain**. Each submodel:
|
||||
|
||||
- Lives in its own `.svelte.ts` file: `<Domain>Model.svelte.ts`
|
||||
- Declares `@RELATION COMPOSED_BY -> [ParentModel]` on the parent model
|
||||
- Exports a class with `$state` atoms and public methods for its domain only
|
||||
- Is instantiated as a property of the parent model
|
||||
|
||||
**Concrete split plan for `DashboardHubModel` (when threshold is crossed):**
|
||||
|
||||
```
|
||||
lib/models/
|
||||
├── DashboardHubModel.svelte.ts # Core: pagination, load, environment, composes submodels
|
||||
├── DashboardFiltersModel.svelte.ts # Search, column filters, sort
|
||||
├── DashboardSelectionModel.svelte.ts # Checkbox, select all/visible, bulk modals
|
||||
├── DashboardGitModel.svelte.ts # Git init, sync, commit, pull, push, status batch
|
||||
```
|
||||
|
||||
**Parent model composition pattern:**
|
||||
|
||||
```typescript
|
||||
// #region DashboardHubModel [C:4] [TYPE Model] [SEMANTICS dashboard,hub,core]
|
||||
// @RELATION COMPOSED_BY -> [DashboardFiltersModel]
|
||||
// @RELATION COMPOSED_BY -> [DashboardSelectionModel]
|
||||
// @RELATION COMPOSED_BY -> [DashboardGitModel]
|
||||
export class DashboardHubModel {
|
||||
readonly filters = new DashboardFiltersModel();
|
||||
readonly selection = new DashboardSelectionModel();
|
||||
readonly git = new DashboardGitModel();
|
||||
|
||||
// Core atoms only:
|
||||
selectedEnv = $state<string | null>(null);
|
||||
allDashboards = $state<DashboardRow[]>([]);
|
||||
isLoading = $state(true);
|
||||
// ... < 10 atoms
|
||||
}
|
||||
```
|
||||
|
||||
### Enforcement
|
||||
|
||||
1. **Documentation** — every C4+ model MUST declare `@INVARIANT DECOMPOSITION GATE: 400 lines, 40 methods` with a comment referencing this ADR.
|
||||
2. **Guardrail** — `scripts/audit-frontend-style.mjs` already checks page size; extend with model size check (`>400 lines OR >40 public methods`).
|
||||
3. **Code review** — new PRs adding methods to a model near 300+ lines MUST include either a split plan in the PR description or a `@RATIONALE` explaining why split is deferred.
|
||||
|
||||
### Relationship to Page Size Gate
|
||||
|
||||
| Level | Threshold | Documented in |
|
||||
|-------|-----------|---------------|
|
||||
| Page file (`+page.svelte`) | 400 lines (INV_7) | `semantics‑core` §I |
|
||||
| Screen Model (`.svelte.ts`) | 400 lines OR 40 methods | This ADR |
|
||||
|
||||
A page that exceeds 400 lines is decomposed into Model + Components. A Model that exceeds 400 lines is decomposed into submodels. The invariant cascades down.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Models stay reviewable and testable (unit tests for each submodel in isolation).
|
||||
- AI agents receive a clear instruction via `@INVARIANT DECOMPOSITION GATE` in the model header.
|
||||
- Future features map directly to submodels — no ambiguity about where to add code.
|
||||
|
||||
### Negative
|
||||
- Cross‑submodel invariants become harder to declare (e.g., "changing filter resets selection" spans two submodels). Mitigation: the parent model composes submodels and declares cross‑cutting invariants.
|
||||
- Slightly more indirection (`m.filters.searchQuery` vs `m.searchQuery`). Mitigation: TypeScript autocomplete resolves this in IDEs.
|
||||
|
||||
## Migration
|
||||
|
||||
1. **Done** — `DashboardHubModel` created (350 lines, below threshold). `@INVARIANT DECOMPOSITION GATE` added with split plan.
|
||||
2. **Next** — when model crosses 400 lines or 40 methods, execute the split plan documented in the model header.
|
||||
3. **Future models** — all new C4+ Screen Models created after this ADR MUST include the `@INVARIANT DECOMPOSITION GATE` annotation.
|
||||
|
||||
## References
|
||||
|
||||
- `semantics‑core` §I INV_7 — module < 400 lines
|
||||
- `semantics‑svelte` §IIIa — Screen Model contract template
|
||||
- `semantics‑svelte` §IIIa.1 — Model Decomposition Gate (added 2026‑06‑02)
|
||||
- `ai‑native‑design‑audit.md` §10 — Lessons learned from PR 4
|
||||
|
||||
# [/DEF:ADR-0010:ADR]
|
||||
Reference in New Issue
Block a user