chore: add rule 58 — hierarchical state tree architecture (mandatory)

This commit is contained in:
Hibryda 2026-03-24 14:42:07 +01:00
parent aaeee808c3
commit ae4c07c160

View file

@ -0,0 +1,104 @@
# State Tree Architecture (MANDATORY)
All application state lives in a single hierarchical state tree. Components are pure renderers — they read from the tree via getter functions and dispatch actions. No component-local `$state` except DOM refs (`bind:this`).
## Tree Structure
```
AppState (root) → app-state.svelte.ts
├── theme, locale, appReady
├── ui: settings, palette, search, wizard, notifications
├── workspace
│ ├── groups[], activeGroupId
│ └── projects[] (per project ↓)
│ ├── agent: session, messages, status, cost
│ ├── terminals: tabs[], collapsed, activeTabId
│ ├── files: tree, selectedPath, content, dirty
│ ├── comms: channels[], messages[], mode
│ ├── tasks: items[], dragTarget
│ └── tab: activeTab, activatedTabs
└── health: per-project trackers, attention queue
```
## Rules
### 1. State ownership follows the tree
Each node owns its children. A node NEVER reads siblings or ancestors directly — it receives what it needs via getter functions scoped to its level.
```typescript
// RIGHT — scoped access through parent
function getProjectAgent(projectId: string) {
return getProjectState(projectId).agent;
}
// WRONG — cross-branch reach
function getAgentForComms() {
return agentStore.getSession(commsStore.activeAgentId);
}
```
### 2. Actions bubble up, state flows down
Components dispatch actions to the store that owns the state. State changes propagate downward via Svelte's reactive reads.
```typescript
// Component dispatches action
onclick={() => projectActions.setActiveTab(projectId, 'files')}
// Store mutates owned state
function setActiveTab(projectId: string, tab: ProjectTab) {
getProjectState(projectId).tab.activeTab = tab;
}
```
### 3. One store file per tree level
| Level | File | Owns |
|-------|------|------|
| Root | `app-state.svelte.ts` | theme, locale, appReady |
| UI | `ui-store.svelte.ts` | drawers, overlays, modals |
| Workspace | `workspace-store.svelte.ts` | groups, projects list |
| Project | `project-state.svelte.ts` | per-project sub-states |
| Health | `health-store.svelte.ts` | trackers, attention |
### 4. Per-project state is a typed object, not scattered stores
```typescript
interface ProjectState {
agent: AgentState;
terminals: TerminalState;
files: FileState;
comms: CommsState;
tasks: TaskState;
tab: TabState;
}
// Accessed via:
function getProjectState(id: string): ProjectState
```
### 5. No `$state` in components
Components use ONLY:
- `$props()` for parent-passed values
- Getter functions from stores (e.g., `getProjectAgent(id)`)
- Action functions from stores (e.g., `projectActions.sendMessage(id, text)`)
- `bind:this` for DOM element references (the sole exception)
- Ephemeral interaction state (hover, drag coordinates) via plain `let` — NOT `$state`
### 6. No `$derived` with new objects (Rule 57)
All computed values are plain functions. See Rule 57 for details.
### 7. Initialization in `onMount`, never `$effect`
All async data loading, timer setup, and event listener registration goes in `onMount`. See Rule 57.
## Adding New State
1. Identify which tree node owns it
2. Add the field to that node's interface
3. Add getter + action functions in the store file
4. Components read via getter, mutate via action
5. NEVER add `$state` to a `.svelte` component file