feat(editor): add transient app state foundation

This commit is contained in:
syntaxbullet
2026-07-03 09:24:08 +02:00
parent 02df52c978
commit 697d12725d
6 changed files with 116 additions and 4 deletions

51
AGENTS.md Normal file
View File

@@ -0,0 +1,51 @@
# Architecture Rules
Follow these rules for the whole repository. More specific `AGENTS.md` files override/add rules for their folders.
## Boundaries
- `core/`: pure domain models only. See `core/AGENTS.md`.
- `commands/`: the only write path for application state.
- `editor/`: transient editor/app state types, e.g. viewport/camera and selection.
- `renderer/`: renders the current `ImageDocument` using the graphics backend, e.g. WebGL.
- `view/`: React UI shell and controls only.
## State Ownership
- All persistent document state is represented by `ImageDocument` and related `core/` models.
- Transient editor state is application state too: viewport/camera, selection, tools, active artboard/layer, drag state, etc.
- Commands are the only code allowed to create, replace, or mutate persistent or transient state.
- React must not own, manage, derive authoritative, or alter document/editor state.
- React may hold only local ephemeral UI details with no app meaning, e.g. open popover, hovered button, uncontrolled input draft before command submit.
- Do not update state directly from React event handlers, renderer callbacks, effects, stores, services, or keybind handlers. Dispatch a command instead.
## Commands
- Every state change must be modeled as a command with an explicit id, payload, and context.
- Commands must be deterministic and testable; avoid DOM, React, WebGL, timers, network, and filesystem access inside command execution.
- Commands return/apply the next state; they should preserve domain invariants and validate payloads before changing state.
- UI actions, menus, toolbar buttons, keybinds, and renderer interactions all request changes by dispatching commands.
## React / View
- React displays current state and exposes user intent.
- React components receive state snapshots/selectors and command dispatch functions; they do not contain business rules.
- Do not put rendering engine logic, document mutation logic, or editor workflow ownership in React components.
- Effects are for UI integration/subscription setup only, not for deriving or correcting application state.
## Renderer
- The renderer draws the current `ImageDocument` plus read-only editor state overlays.
- Renderer code must not mutate document/editor state directly.
- Renderer interactions may emit intents/events that are translated into commands.
- Keep rendering backend details isolated behind renderer APIs; do not leak WebGL objects into `core/`, `commands/`, or React state.
## Keybinds
- Keybind handling is ordered and explicit.
- First check the global keybind consumer/map.
- If the global consumer handles the keybind, stop.
- If not consumed globally, the keybind may be consumed by dispatching a command.
- Keybind handlers must not mutate state directly.
- Avoid ad-hoc component-local shortcuts unless they are purely local UI behavior and cannot affect app/editor/document state.
## Imports
- `core/` imports nothing from app layers.
- `commands/` may import `core/` and `editor/`; avoid importing React or renderer backend APIs.
- `editor/` may import `core/` types; it must not import React, commands, renderer, storage, or backend APIs.
- `renderer/` may import `core/` and `editor/` types; it must not import React components.
- `view/` may import UI components and command dispatch interfaces; avoid importing renderer internals except through stable view-facing adapters.

View File

@@ -1,11 +1,11 @@
import type { ImageDocument } from "@core/document"; import type { AppState } from "@editor/state";
export type CommandContext = { export type CommandContext = {
document: ImageDocument; state: AppState;
}; };
export type Command<TPayload = void> = { export type Command<TPayload = void> = {
id: string; id: string;
name: string; name: string;
execute(context: CommandContext, payload: TPayload): ImageDocument; execute(context: CommandContext, payload: TPayload): AppState;
}; };

34
core/AGENTS.md Normal file
View File

@@ -0,0 +1,34 @@
# Core Domain Model Rules
Follow these rules for all changes in `core/`.
## Purpose
- `core/` contains pure domain types for the image editor: documents, artboards, layers, assets, ids, and geometry.
- Keep it framework-free, runtime-light, and reusable by renderer, commands, persistence, and tests.
## Hard Rules
- Do not import React, DOM APIs, UI components, storage, networking, filesystem, command handlers, or renderer code.
- Do not add side effects, global state, caches, singletons, or environment-dependent behavior.
- Prefer exported TypeScript `type`s. Add runtime code only when it is pure, deterministic, and domain-generic.
- Keep domain files small and focused. One concept per file; re-export public types from `core/index.ts`.
- Use `import type` / `export type` for type-only dependencies.
- Preserve discriminated unions. Every `Layer` variant must have a stable `type` string.
- Do not weaken domain types with `any`, broad `string | number` unions, optional fields, or nullable values unless the domain truly allows absence.
- IDs are opaque aliases from `id.ts`; do not inline plain `string` ID fields in models.
- Do not duplicate geometry shapes outside `geometry.ts`. Use `Vec2D`, `Size`, `Rect`, `Bounds`, `Transform`, `Mat2D`, and `CoordinateSpace`.
- Avoid app/workflow concerns in names and fields. Domain models describe image-editing state, not UI state.
## Model Invariants
- `ImageDocument` owns `artboards` and shared `assets`.
- `Artboard` owns top-level `layers` and has document-space `bounds`.
- `LayerGroup.children` owns nested layers; only groups have children.
- `ImageLayer.assetId` must reference an `Asset.id` in the same document.
- `BaseLayer.opacity` is normalized `0..1`; `visible` and `locked` are explicit booleans.
- `Transform` stores position, scale, and rotation only; derived matrices/bounds should not be persisted on models.
- `clippingMask.maskLayerId` references another layer by `LayerId`; do not embed mask layer objects.
## Changing Models
- Before adding a field, decide whether it is core persisted state or derived/UI state. Derived/UI state does not belong here.
- When adding a new domain type, create a focused file and export it from `index.ts`.
- When adding a new layer kind, update the union in `layer.ts`, add a discriminant, and document its required relationships.
- Keep names stable and serialization-friendly; assume these types may be saved, loaded, diffed, and migrated.

1
editor/index.ts Normal file
View File

@@ -0,0 +1 @@
export type { AppState, EditorState, SelectionState, ViewportState } from "./state";

25
editor/state.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { ImageDocument } from "@core/document";
import type { Angle, Size, Vec2D } from "@core/geometry";
import type { ArtboardId, LayerId } from "@core/id";
export type ViewportState = {
center: Vec2D;
zoom: number;
rotation: Angle;
size: Size;
};
export type SelectionState = {
artboardId?: ArtboardId;
layerIds: LayerId[];
};
export type EditorState = {
viewport: ViewportState;
selection: SelectionState;
};
export type AppState = {
document: ImageDocument;
editor: EditorState;
};

View File

@@ -27,7 +27,8 @@
"@view/*": ["./view/*"], "@view/*": ["./view/*"],
"@core/*": ["./core/*"], "@core/*": ["./core/*"],
"@renderer/*": ["./renderer/*"], "@renderer/*": ["./renderer/*"],
"@commands/*": ["./commands/*"] "@commands/*": ["./commands/*"],
"@editor/*": ["./editor/*"]
}, },
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)