diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 976b2b0c1ac..903a2fb42a5 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,11 +1,11 @@ # CI and contributions - Read affected package guidance before changing checks. -- Preserve PR, main-push, merge-group, manual-dispatch, and reusable-workflow events. `github.base_ref` is not always populated outside PRs. -- Change detection must include workflows and relevant shared actions/configuration; test-only and tooling-only changes must trigger owning checks. -- Preserve tool versions, lockfiles, reproducible installs, hardware/platform matrices, and pinned action revisions. -- Prefer package completion commands over duplicating them in YAML. For webv2's `pnpm check:release`, install Chromium and required Linux dependencies first. -- Preserve failure diagnostics with `always()` where appropriate; keep generated review/performance artifacts out of Git. -- Match hooks to CI's Ruff version/configuration; validation must not rewrite formatting. -- Follow [the PR template](pull_request_template.md); keep summaries short and omit subagent counts, tiers, and process narration. -- Workflow changes do not configure hosted branch protection or install local hooks; verify before claiming either. +- Keep PR, main-push, merge-group, manual-dispatch, reusable-workflow events. `github.base_ref` is not always set outside PRs. +- Change detection must include workflows and relevant shared actions/config. Test-only and tooling-only changes must trigger owning checks. +- Keep tool versions, lockfiles, reproducible installs, hardware/platform matrices, pinned action revisions. +- Prefer package completion commands over duplicating them in YAML. Webv2 `pnpm check:release`: install Chromium and required Linux dependencies first. +- Keep failure diagnostics with `always()` where fitting. Generated review/performance artifacts stay out of Git. +- Hooks must match CI's Ruff version and config. Validation never rewrites formatting. +- Follow [the PR template](pull_request_template.md). Short summaries. Omit subagent counts, tiers, process narration. +- Workflow changes neither configure hosted branch protection nor install local hooks. Verify before claiming either. diff --git a/AGENTS.md b/AGENTS.md index ce309621bdc..578f726f3f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,86 +2,99 @@ ## Communication -- Give plain, concise updates led by outcomes, blockers, and evidence. Distinguish verified results, assumptions, and unavailable checks. -- Ask only when a decision cannot be inferred safely from the repository or request; continue authorized work without repeated permission requests. +- Updates: plain, short, outcome first. Name blockers and evidence. Separate verified results, assumptions, unavailable checks. +- Ask only when repo or request cannot settle a decision safely. Continue authorized work without re-asking. ## Find the right owner -Before editing, read `AGENTS.md` along each target path; scoped rules supplement this file. Keep policy in `AGENTS.md`. +Before editing, read every `AGENTS.md` along each target path. Scoped rules add to this file. Policy lives in `AGENTS.md`. | Area | Guidance and source of truth | | --- | --- | -| Active frontend | [webv2/AGENTS.md](invokeai/frontend/webv2/AGENTS.md), its `ARCHITECTURE.md` and executable architecture policy | +| Active frontend | [webv2/AGENTS.md](invokeai/frontend/webv2/AGENTS.md), its `ARCHITECTURE.md`, executable architecture policy | | API, services, invocations | [app/AGENTS.md](invokeai/app/AGENTS.md) | -| Inference and model management | [backend/AGENTS.md](invokeai/backend/AGENTS.md) | -| Python tests | [tests/AGENTS.md](tests/AGENTS.md); also read guidance for the production code under test | +| Inference, model management | [backend/AGENTS.md](invokeai/backend/AGENTS.md) | +| Python tests | [tests/AGENTS.md](tests/AGENTS.md), plus guidance for the code under test | | Legacy frontend | [webv1/AGENTS.md](invokeai/frontend/webv1/AGENTS.md) | | Shared generated API contracts | [api/AGENTS.md](invokeai/frontend/api/AGENTS.md) | -| CI and contribution tooling | [.github/AGENTS.md](.github/AGENTS.md) | -| Documentation | `docs/README.md` and `docs/package.json`; keep documentation about the implemented product current | +| CI, contribution tooling | [.github/AGENTS.md](.github/AGENTS.md) | +| Documentation | `docs/README.md`, `docs/package.json`. Keep product docs current. | -Ordinary frontend work targets **webv2**, served by default; `--webv2` remains a compatibility alias and `--web-legacy` selects legacy web. Verify build/launch targets for UI investigations. The shared `frontend/api` package owns generated OpenAPI/type artifacts. +Frontend work targets **webv2**, served by default. `--webv2` is a compatibility alias; `--web-legacy` selects legacy web. UI investigations: verify build and launch target first. `frontend/api` owns generated OpenAPI/type artifacts. ## Engineering standard -- Inspect implementations, callers, contracts, and tests before designing changes. Define observable outcomes and validation; reproduce bugs where feasible, fix root causes, and reconsider designs that accumulate patches. -- Keep ownership and interfaces clear. Account for edge cases, failures, data lifecycles, and operational limits. -- Every function, dependency, fallback, and test must earn its place. Add abstractions to translate contracts, isolate dependencies, own lifecycles, prevent drift, or simplify callers; avoid pass-through wrappers, speculative extensibility, redundant validation/configuration, and abstractions justified only by similar-looking code. -- Remove obsolete code, tests, and scaffolding; preserve unrelated work. Comment only non-obvious intent, constraints, or trade-offs. -- Test meaningful behavior through its owning interface with independent expectations that catch plausible regressions. Avoid trivial constant/type/re-export assertions, incidental snapshots, duplicate coverage, and mock choreography. Prefer real lightweight dependencies and isolated fixtures; use doubles for expensive/external systems. Optimize test value, not count. -- Keep dependency/lockfile changes intentional. Never weaken assertions, gates, architecture policy, or performance budgets to pass; explain existing failures and verification limits. -- Keep architecture/reference docs aligned with implementation. Skills are optional helpers: adapt them to this stack without importing personal paths or generic ceremony. +- Before designing: read implementations, callers, contracts, tests. Define observable outcome and validation. Reproduce bugs where feasible. Fix root causes. Rethink designs that pile up patches. +- Keep ownership and interfaces clear. Cover edge cases, failures, data lifecycles, operational limits. +- Every function, dependency, fallback, test earns its place. Abstractions must translate contracts, isolate dependencies, own lifecycles, prevent drift, or simplify callers. No pass-through wrappers, speculative extensibility, redundant validation/config, or abstraction justified only by similar-looking code. +- Remove obsolete code, tests, scaffolding. Preserve unrelated work. Comment only non-obvious intent, constraints, trade-offs. +- Test meaningful behavior through its owning interface. Expected values independent; tests catch plausible regressions. No trivial constant/type/re-export assertions, incidental snapshots, duplicate coverage, mock choreography. Prefer real lightweight dependencies and isolated fixtures. Use doubles for expensive or external systems. Optimize test value, not count. +- Dependency and lockfile changes only on purpose. Never weaken assertions, gates, architecture policy, or performance budgets to pass. Explain existing failures and verification limits. +- Keep architecture and reference docs matching the code. State current invariants, not migration history. Write paths from a stated root. Give the reason for a non-obvious rule. +- Repo guidance beats external skill, plugin, workflow defaults: test boundaries follow existing owner interfaces and tests; reviews use the tiers below over the full task-owned diff, uncommitted files included; notes go in `.scratch/agents//`; commands use package-owned scripts and pnpm. No personal paths, no generic ceremony. ## Performance and efficiency -- Inspect affected hot paths and immediate callers for repeated work, renders/subscriptions, waterfalls, queries, serialization/copies, unbounded collections, and retained resources. -- Implement clear, low-risk improvements in scope; larger optimizations must serve the task. Briefly note unrelated opportunities without expanding scope. -- Measure nontrivial optimizations and performance claims with existing budgets and representative fixtures, query counts, profiling, or focused benchmarks. Report material before/after results and limits. -- Preserve correctness and clarity. Require understood invalidation, lifetimes, ordering, and cost for caching, memoization, concurrency, or batching. Do not invent optimizations or benchmarks for a checklist. +- Check affected hot paths and immediate callers for repeated work, renders/subscriptions, waterfalls, queries, serialization/copies, unbounded collections, retained resources. +- Do clear, low-risk improvements in scope. Larger optimizations must serve the task. Note unrelated opportunities briefly; do not expand scope. +- Measure nontrivial optimizations and performance claims: existing budgets, representative fixtures, query counts, profiling, or focused benchmarks. Report material before/after and limits. +- Correctness and clarity first. Caching, memoization, concurrency, batching need understood invalidation, lifetimes, ordering, cost. No invented optimizations or benchmarks for a checklist. ## Product quality -Maintain a polished desktop experience: deliberate layouts, responsive interactions, cohesive accessible controls, and complete loading/empty/error/recovery states. Verify changed interactions in the browser; automated checks alone do not establish visual quality. +Polished desktop experience: deliberate layouts, responsive interactions, cohesive accessible controls, complete loading/empty/error/recovery states. Verify changed interactions in the browser. Automated checks alone do not prove visual quality. ## Milestones and code review rules -A milestone is one coherent, commit-ready unit. Run relevant checks and self-review every change. Choose review depth by the complete task's behavior, blast radius, and failure consequences—not file/line counts or directory alone. Do not split risky work to avoid review. +Milestone = one coherent, commit-ready unit. Run relevant checks and self-review every change. Pick review depth by the whole task's behavior, blast radius, failure consequences. Not by file/line count or directory alone. Never split risky work to dodge review. -| Review depth | When to use it | +| Review depth | When | | --- | --- | -| **Self-review; no subagents** | Local, low-risk edits with straightforward verification: copy, docs, styling, or an input minimum without shared validation, persistence, or API contract changes. | -| **One independent reviewer** | Substantive features, fixes, or refactors with bounded impact and no high-risk trigger. | -| **Three independent reviewers** | Behavior changes affecting authentication/authorization, account isolation, persisted-data compatibility/migrations, destructive operations, concurrency/resource lifecycles, shared API compatibility, inference numerical/device/memory behavior, or substantial architecture across owners. | +| **Self-review; no independent reviewer** | Local, low-risk, easy to verify: copy, docs, styling, input minimum without shared validation, persistence, or API contract changes | +| **One independent reviewer** | Substantive feature, fix, refactor; bounded impact; no high-risk trigger | +| **Three independent reviewers** | Behavior changes touching authentication/authorization, account isolation, persisted-data compatibility/migrations, destructive operations, concurrency/resource lifecycles, shared API compatibility, inference numerics/device/memory, or substantial cross-owner architecture | -Review focuses: correctness/spec conformance; architecture/operational safety/performance/unnecessary complexity; test value/gaps/product quality, including UI accessibility. One reviewer covers all; three divide them. User instructions override defaults. Select the tier without asking permission; briefly explain spawning. Required checks and browser verification apply at every tier. +Review focuses: (1) correctness, spec conformance; (2) architecture, operational safety, performance, needless complexity; (3) test value and gaps, product quality, UI accessibility. One reviewer covers all three; three reviewers split them. User instructions override defaults. Pick the tier without asking; say briefly why you spawn reviewers. Required checks and browser verification apply at every tier. -1. Review once per coherent candidate after checks. Supply the same fixed base, acceptance criteria, and complete task-owned diff (staged, unstaged, new files); identify unrelated changes. Use focused briefs and entry points, not full conversation inheritance. -2. Reviewers inspect independently, including necessary callers/contracts. They are read-only: no edits, commits, or recursive delegation. Findings need locations, impact, and failure scenarios or engineering costs; style preferences alone are not blockers. -3. Resolve all material findings, add useful regression coverage, and rerun affected checks. A clean review is final for an unchanged candidate. For subsequent edits, seek blocker-only follow-up from affected reviewers on changes and consequences. Broaden review and reassess the tier only when scope/risk materially changes. -4. Do not declare readiness with material findings or unexplained failing gates. Report resolved findings, passed/failed/unavailable checks, and remaining limitations. PRs omit subagent counts, tiers, and process narration. +1. Review once per coherent candidate, after checks and after every writer finishes. No edits to a candidate under review. Reviewers did not implement it. Give all reviewers the same fixed base, acceptance criteria, full task-owned diff (staged, unstaged, new files). Name unrelated changes. Focused briefs and entry points, not full conversation history. +2. Reviewers inspect independently, including needed callers/contracts. Read-only: no edits, commits, or delegation. Findings need location, impact, and failure scenario or engineering cost. Style preference alone is not a blocker. +3. Resolve all material findings. Add useful regression coverage. Rerun affected checks. Clean review is final for an unchanged candidate. Later edits: list changes since the reviewed candidate; get blocker-only follow-up from affected reviewers on them and their consequences. Broaden review or change tier only when scope/risk changes materially. +4. Never declare ready with material findings or unexplained failing gates. Report resolved findings, passed/failed/unavailable checks, remaining limits. PRs omit subagent counts, tiers, process narration. -If required independent review is unavailable, self-review the corresponding focuses and disclose this to the user; ordinary low-risk self-review needs no such disclaimer. Never label self-review independent. Implementation delegation is optional for independently useful tasks with clear ownership. +Required independent review unavailable: self-review the matching focuses and tell the user. Ordinary low-risk self-review needs no disclaimer. Self-review is never independent, including review by an implementing agent. + +## Delegated implementation + +Main agent never edits repository files: every edit goes to an implementation subagent, so the main conversation keeps its context for planning, coordination, and review. Harness without subagents: edit locally. + +- Main agent investigates, plans, briefs, integrates, validates, reports. It owns shared contracts and completion. +- Brief each implementer: objective, owned files, applicable `AGENTS.md`, agreed interfaces, acceptance criteria, focused checks. Implementer returns a short report: files changed, checks run, open issues. +- One writer per file at a time. Tightly coupled changes go to one implementer; split only independently verifiable work. +- Implementers own meaningful tests for their changes; by default never split production code from its tests. +- Follow-up fixes from review findings or failing checks go back to an implementer, preferably the original one. + +Main agent also owns machine-wide resources: dependency installs, generated API contracts, full suites and release gates, GPU/generation work, fixed-port servers, shared browser sessions. Implementers run focused checks only, use spare ports, clean up temporary output, and never stash, reset, or check out in shared worktrees. Pre-commit hook stashes unstaged changes while it runs: commit only when no other agent writes to the same worktree. One webv2 performance gate per machine: it measures timing and binds fixed ports. ## Commands and environments -- Use **pnpm 10**, never npm/yarn, with package-owned lockfiles and `pnpm -C ...`. Use `.nvmrc`'s Node version; consult package scripts, not root Makefile assumptions about webv2. -- Preserve the existing Python/accelerator environment and `uv.lock`. Fresh test setup: `uv sync --locked --extra test`; do not routinely replace accelerator backends. -- Root Python checks: `uv tool run ruff@0.11.2 check ` and `uv tool run ruff@0.11.2 format --check `. Use 120-column formatting, absolute imports, and configured vendored-code exclusions. -- Focused tests: `uv run --no-sync pytest `; full CI: `uv run --no-sync pytest -n logical`. Hardware/quiet-machine timing tests use `-m slow` on development machines; excluded by default and CI. See `tests/AGENTS.md` for isolation rules. -- `-n logical` is sized for CI runners that own the machine. On a development box cap workers (`-n 4`, or plain `pytest`), set `OMP_NUM_THREADS` when a run must stay light, and do not start a suite while a generation is running. -- `uv run --no-sync mypy scripts/invokeai-web.py` uses current exclusions; it is an optional diagnostic, not a CI gate or comprehensive type coverage. -- Select checks by behavior; run required milestone gates before review. Repeat unchanged checks only for a reason. -- After test dependencies are available, install hooks per checkout: `uv run --no-sync pre-commit install`. Committing configuration does not install hooks. +- **pnpm 10** only, never npm/yarn. Package-owned lockfiles. Run pnpm from the package directory: Corepack picks pnpm's version from the current directory, so `pnpm -C ` from the root can get the wrong one. Node version from `.nvmrc`. Read package scripts; do not assume the root Makefile covers webv2. +- Keep the existing Python/accelerator environment and `uv.lock`. Fresh test setup: `uv sync --locked --extra test`. Do not routinely replace accelerator backends. +- Root Python checks: `uv tool run ruff@0.11.2 check `, `uv tool run ruff@0.11.2 format --check `. 120 columns, absolute imports, configured vendored-code exclusions. +- Focused tests: `uv run --no-sync pytest `. Full CI: `uv run --no-sync pytest -n logical`. Hardware/quiet-machine timing tests: `-m slow` on dev machines; excluded by default and in CI. Isolation rules: `tests/AGENTS.md`. +- `-n logical` fits CI runners that own the machine. Dev box: cap workers (`-n 4`, or plain `pytest`). Set `OMP_NUM_THREADS` when a run must stay light. Never start a suite during a generation. +- `uv run --no-sync mypy scripts/invokeai-web.py` uses current exclusions. Optional diagnostic only: not a CI gate, not full type coverage. +- Pick checks by behavior. Run required milestone gates before review. Rerun unchanged checks only with a reason. +- Once test dependencies exist, install hooks per checkout: `uv run --no-sync pre-commit install`. Committed config does not install hooks. ## Scratch files and repository hygiene -- Never commit plans, work logs, investigation notes, review transcripts, or handoffs under any filename. Optional scratch belongs in `.scratch/agents//`; do not relabel it as durable docs. -- Version durable documentation of implemented architecture, decisions, APIs, and setup. -- Inspect staged diffs and new files for planning material. Ignore rules neither remove tracked files nor prevent force-adds. Never force-add planning artifacts or introduce `package-lock.json`/`yarn.lock`. +- Never commit plans, work logs, investigation notes, review transcripts, handoffs, under any filename. Optional scratch: `.scratch/agents//`. Never relabel scratch as durable docs. +- Version durable docs of implemented architecture, decisions, APIs, setup. +- Check staged diffs and new files for planning material. Ignore rules neither untrack files nor block force-adds. Never force-add planning artifacts. Never add `package-lock.json`/`yarn.lock`. ## Commits and pull requests -- Readiness does not authorize commits/pushes; follow existing user authorization without automatic per-subtask commits. -- Use Conventional Commits: short outcome-focused subject, then one to three factual lines of material changes without repetition or process narration. -- Use the configured Git author; no agent attribution or co-author trailers. -- Follow `.github/pull_request_template.md`: ordered headings, all applicable sections and inline guidance, omit only conditional sections, and tick only true checklist items. +- Readiness does not authorize commits or pushes. Follow the user's existing authorization. No automatic per-subtask commits. +- Conventional Commits. Subject: short, outcome first, scoped by feature area (`fix(queue)`). Never write the frontend package name `webv2` anywhere in the message. Body: one to three factual lines of material changes; no repetition, no process narration. +- Use the configured Git author. +- Follow `.github/pull_request_template.md`: headings in order, every applicable section and its inline guidance. Omit only conditional sections. Tick only true checklist items. diff --git a/docs/src/content/docs/contributing/new-model-integration.mdx b/docs/src/content/docs/contributing/new-model-integration.mdx index 710ec153f81..eda7821880a 100644 --- a/docs/src/content/docs/contributing/new-model-integration.mdx +++ b/docs/src/content/docs/contributing/new-model-integration.mdx @@ -851,7 +851,8 @@ Paths below are relative to `invokeai/frontend/webv2/src/features/generation/cor 9. **Regenerate the graph contract** ```sh - pnpm -C invokeai/frontend/webv2 test src/features/generation/core/graphCoverage.test.ts -u + cd invokeai/frontend/webv2 + pnpm test src/features/generation/core/graphCoverage.test.ts -u ``` This rewrites `__snapshots__/generateGraphNodeTypes.json`. Graph coverage and the remix round trip compile each base in the shapes listed in `graphCoverage.testing.ts`; a variant that changes the graph needs its own entry in `SHAPE_OVERRIDES`, and a component variant a slot filters for needs to be in `CANDIDATE_VARIANTS`, or neither test ever builds that graph. Then raise `SUPPORTED_BASE_COUNTS["generate"]` in `tests/app/invocations/test_frontend_graph_node_types.py`, and `LITERAL_FLOORS` if needed. That Python test checks every node type, edge field and literal value the builders emit against the backend's invocation registry. diff --git a/docs/src/content/docs/development/Guides/tests.mdx b/docs/src/content/docs/development/Guides/tests.mdx index b284f18ce46..dd84717e944 100644 --- a/docs/src/content/docs/development/Guides/tests.mdx +++ b/docs/src/content/docs/development/Guides/tests.mdx @@ -18,17 +18,18 @@ The default frontend is `webv2`, with unit and Chromium suites. Run commands fro ```bash - pnpm -C invokeai/frontend/webv2 test - pnpm -C invokeai/frontend/webv2 test:browser + cd invokeai/frontend/webv2 + pnpm test + pnpm test:browser # Complete milestone gates, including performance, project files, and accessibility. - pnpm -C invokeai/frontend/webv2 check:release + pnpm check:release ``` -Install Chromium with `pnpm -C invokeai/frontend/webv2 exec playwright install chromium` if needed. Read `webv2/AGENTS.md` for test ownership and browser verification rules. +Install Chromium with `pnpm exec playwright install chromium` from `invokeai/frontend/webv2` if needed. Read `webv2/AGENTS.md` for test ownership and browser verification rules. -The legacy frontend remains in `invokeai/frontend/webv1`. Run its separate suite with `pnpm -C invokeai/frontend/webv1 test:no-watch` and its checks with `pnpm -C invokeai/frontend/webv1 lint`. +The legacy frontend remains in `invokeai/frontend/webv1`. From that directory, run its separate suite with `pnpm test:no-watch` and its checks with `pnpm lint`. ## Backend Tests diff --git a/invokeai/app/AGENTS.md b/invokeai/app/AGENTS.md index bd25fa32eb9..c75e70120b4 100644 --- a/invokeai/app/AGENTS.md +++ b/invokeai/app/AGENTS.md @@ -2,28 +2,34 @@ ## Ownership and contracts -- Routers validate requests, enforce authentication/authorization, and map service errors to HTTP. Services own domain policy; storage owns persistence. -- Before changing flows, inspect `api/dependencies.py`, affected routers, service base/default/storage implementations, and tests. Preserve dependency/start/stop lifecycles; avoid parallel global services. -- Server-authorize every account-owned resource, including queries, writes, events, exports, and recovery; UI guards are insufficient. -- Trace frontend/saved-workflow consumers before changing Pydantic models, invocation fields/versions, HTTP DTOs, or socket-event names, defaults, validation, or errors. -- API changes may require regenerating `frontend/api/openapi.json` and `frontend/api/schema.ts` under `invokeai/`, even for webv2. Follow shared API package generation guidance; never hand-edit output. +- Routers validate requests, enforce authentication/authorization, map service errors to HTTP. Services own domain policy. Storage owns persistence. +- Before changing flows, read `api/dependencies.py`, affected routers, service base/default/storage implementations, tests. Keep dependency/start/stop lifecycles. No parallel global services. +- Server-authorize every account-owned resource, including queries, writes, events, exports, recovery. UI guards are not enough. +- Before changing Pydantic models, invocation fields/versions, HTTP DTOs, or socket-event names/defaults/validation/errors: trace frontend and saved-workflow consumers. +- API changes may need regenerated `frontend/api/openapi.json` and `frontend/api/schema.ts` under `invokeai/`, webv2 included. Follow shared API package generation guidance. Never hand-edit output. ## Persistence and lifecycle -- Preserve atomicity, revision checks, idempotency, and explicit conflicts. Inspect migrations/old-record readers for stored-data changes; add needed migration/compatibility tests. -- SQL lives only in `services/shared/database/`, reached through `Database.queries`; `tests/app/test_database_access_guard.py` enforces this. Query modules build their statements once, at module level with `bindparam()` (a statement built per call costs several times its execution), read rows by position, and build DTOs with `@mapped(...)` outside the transaction. A shape-dependent statement comes from a cached builder whose shapes a client cannot multiply (normalize its lists and flags, round its list lengths; see the guide). -- Use one transaction owner for multi-record writes: `with db.queries.transaction() as q:`, passing `q` to helpers. A second transaction on the same thread raises `NestedTransactionError`. See `services/shared/database/database.py`. -- SQLite serialises everything behind one lock; MySQL/MariaDB do not. Put an invariant like "only if still pending" into the statement (a conditional `UPDATE` with a checked row count), not into a read followed by a write. An invariant that spans rows (the last active administrator) gets a `DatabaseLock`, which every transaction changing what it reads acquires as its first call (`q.locks.acquire(...)`); its row is added by a migration. A decision about one row (may this board be claimed?) locks that row before reading what it decides on (a `@locking` query method, `SELECT ... FOR UPDATE`); every transaction changing what it reads writes or locks the same row, and rows are locked in one order (a project's before its board's). Run a unit whose work has no effects outside the database with `db.queries.run(work)`, which retries it after a deadlock. -- Tables are defined in `services/shared/database/schema/`, which must match the migrated SQLite schema exactly (`test_schema_parity.py`). Change the schema with a migration and the same change to the metadata, in one change. No database has triggers: queries set `updated_at` (also in upserts) and the queue's `started_at`, `completed_at`, `session_revision`. -- New migrations are `PortableMigration`s (Alembic operations; tables via `context.create_table()`) and idempotent: MySQL/MariaDB commit DDL as it runs, so a migration that failed halfway runs again. On SQLite they run with foreign keys off and are checked before commit. Migrations up to `PORTABLE_CUTOVER` stay SQLite-only and unchanged. See the Database Migrations guide. -- Preserve project/account isolation, document bounds, queue admission/receipts, and lost-response retry safety. Never silently discard recovery data or resolve conflicts with last-write-wins. -- Bound concurrency; support cancellation, timeouts/retries, and file/process/listener/task cleanup. Keep CPU/blocking I/O off the async server loop. -- Preserve file/path validation, remote-fetch protections, and sanitized errors. Never modify real user models, databases, outputs, or credentials during tests/debugging. +- Preserve atomicity, revision checks, idempotency, explicit conflicts. Stored-data change: read migrations and old-record readers; add needed migration/compatibility tests. +- SQL lives only in `services/shared/database/`, reached through `Database.queries`. `tests/app/test_database_access_guard.py` enforces this. +- Query modules build statements once, at module level, with `bindparam()` (per-call building costs several times execution). Read rows by position; build DTOs with `@mapped(...)` outside the transaction. Shape-dependent statement: cached builder whose shapes a client cannot multiply (normalize lists and flags, round list lengths; see the guide). +- Multi-record writes: one transaction owner, `with db.queries.transaction() as q:`, pass `q` to helpers. Second transaction on the same thread raises `NestedTransactionError`. See `services/shared/database/database.py`. +- Concurrency. SQLite serialises everything behind one lock; MySQL/MariaDB do not. + - Single-statement invariant ("only if still pending"): put it in the statement, a conditional `UPDATE` with checked row count. Never a read then a write. + - Invariant spanning rows (last active administrator): a `DatabaseLock`. Every transaction that changes rows the invariant reads acquires the lock as its first call (`q.locks.acquire(...)`). A migration adds its row. + - Decision about one row (may this board be claimed?): lock that row before reading what the decision uses (`@locking` query method, `SELECT ... FOR UPDATE`). Every transaction changing those inputs writes or locks the same row. Lock rows in one order (e.g. project before its board). + - Unit with no effects outside the database: run with `db.queries.run(work)`; it retries after a deadlock. +- Tables live in `services/shared/database/schema/` and must match the migrated SQLite schema exactly (`test_schema_parity.py`). Change schema with a migration plus the same metadata change, in one change. +- No database has triggers. Queries set `updated_at` (upserts too) and the queue's `started_at`, `completed_at`, `session_revision`. +- New migrations: `PortableMigration`s (Alembic operations; tables via `context.create_table()`), idempotent. MySQL/MariaDB commit DDL as it runs, so a half-failed migration runs again. On SQLite they run with foreign keys off and are checked before commit. Migrations up to `PORTABLE_CUTOVER` stay SQLite-only and unchanged. See the Database Migrations guide. +- Preserve project/account isolation, document bounds, queue admission/receipts, lost-response retry safety. Never silently discard recovery data. Never resolve conflicts with last-write-wins. +- Bound concurrency. Support cancellation, timeouts/retries, file/process/listener/task cleanup. Keep CPU and blocking I/O off the async server loop. +- Preserve file/path validation, remote-fetch protections, sanitized errors. Never modify real user models, databases, outputs, or credentials in tests or debugging. ## Efficiency and verification -- Inspect queries for repeated lookups, full scans/materialization, missing bounds, and serialization. Measure representative data/query counts before adding caches or indexes. -- Use real temporary SQLite for transaction, migration, revision, and ownership tests; cover affected malformed input, cross-user access, cancellation, repeated requests, and partial failures. Tests on the database fixtures also run against MySQL and MariaDB when `INVOKEAI_TEST_DB_URL` is set (see `tests/fixtures/database.py`). -- Read `tests/AGENTS.md`; use `tests/app/` or the existing owning suite. Run focused pytest and root Ruff; broaden to relevant service/router/invocation suites for milestones. -- For graph execution, consult `services/shared/README.md`; test scheduling and invocation lifecycles through owning interfaces. -- Report schema generation, migration validation, and unavailable integration/hardware checks. +- Check queries for repeated lookups, full scans/materialization, missing bounds, serialization. Measure representative data and query counts before adding caches or indexes. +- Transaction, migration, revision, ownership tests: real temporary SQLite. Cover affected malformed input, cross-user access, cancellation, repeated requests, partial failures. Database-fixture tests also run against MySQL and MariaDB when `INVOKEAI_TEST_DB_URL` is set (`tests/fixtures/database.py`). +- Read `tests/AGENTS.md`. Use `tests/app/` or the existing owning suite. Run focused pytest and root Ruff. Milestones: broaden to relevant service/router/invocation suites. +- Graph execution: read `services/shared/README.md`. Test scheduling and invocation lifecycles through owning interfaces. +- Report schema generation, migration validation, unavailable integration/hardware checks. diff --git a/invokeai/backend/AGENTS.md b/invokeai/backend/AGENTS.md index a253e065fc0..3b30a1c98c3 100644 --- a/invokeai/backend/AGENTS.md +++ b/invokeai/backend/AGENTS.md @@ -2,22 +2,22 @@ ## Design and resources -- Trace callers, loaders/caches, tensor shapes, device/dtype handling, and cleanup before inference changes. Reusable model/inference policy belongs here, not UI/API adapters. -- Use existing device/model-management abstractions, including `util/devices.py`; preserve CPU/MPS/ROCm/XPU/multi-device support without hardcoded CUDA, GPU zero, or dtype assumptions. -- Respect session/thread device ownership. Restore temporary model patches, device state, hooks, and allocations on success/cancellation/exceptions; avoid request-specific mutable globals. -- Define model/cache lifetimes and working-memory estimates. Bound queues, caches, downloads, batches, and buffers; consider peak memory and throughput. -- Preserve model validation/loading safeguards, vendored exclusions, and upstream provenance; avoid unrelated vendored rewrites/formatting. +- Before inference changes, trace callers, loaders/caches, tensor shapes, device/dtype handling, cleanup. Reusable model/inference policy lives here, not in UI/API adapters. +- Use existing device/model-management abstractions, `util/devices.py` included. Keep CPU/MPS/ROCm/XPU/multi-device support. No hardcoded CUDA, GPU zero, or dtype assumptions. +- Respect session/thread device ownership. Restore temporary model patches, device state, hooks, allocations on success, cancellation, and exceptions. No request-specific mutable globals. +- Define model/cache lifetimes and working-memory estimates. Bound queues, caches, downloads, batches, buffers. Consider peak memory and throughput. +- Keep model validation/loading safeguards, vendored exclusions, upstream provenance. No unrelated vendored rewrites or formatting. ## Performance -- Inspect duplicate work, device transfers, dtype conversions, tensor copies, synchronization, repeated loading, and retained tensors. -- Measure material latency/throughput/peak-memory changes with representative shapes, resolutions, batches, and devices; distinguish cold loading/compilation from warm inference. -- Preserve output semantics, cancellation, and correctness at larger inputs, using appropriate numerical tolerances and reproducible seeds where supported. -- Kernels, caches, compilation, or concurrency need demonstrated benefit and clear fallback/lifecycle behavior. +- Check duplicate work, device transfers, dtype conversions, tensor copies, synchronization, repeated loading, retained tensors. +- Measure material latency/throughput/peak-memory changes with representative shapes, resolutions, batches, devices. Separate cold loading/compilation from warm inference. +- Keep output semantics, cancellation, correctness at larger inputs. Use fitting numerical tolerances and reproducible seeds where supported. +- Kernels, caches, compilation, concurrency need demonstrated benefit and clear fallback/lifecycle behavior. ## Verification -- Read `tests/AGENTS.md` and relevant suites in `tests/backend/`, `tests/model_identification/`, or `tests/test_model_manager/`. -- Use small CPU fixtures and targeted device checks; real tensor operations must expose shape/dtype/numerical/memory errors mocks would hide. -- Cover affected failure cleanup, unsupported configurations, boundary shapes, and cancellation. No routine large-model downloads or user model/output directories for unit tests. -- Run focused pytest and root Ruff. For material inference changes, report hardware, dtype, shapes, measurements, and untested platforms/model paths; CPU passes do not prove accelerator behavior. +- Read `tests/AGENTS.md` and relevant suites in `tests/backend/`, `tests/model_identification/`, `tests/test_model_manager/`. +- Small CPU fixtures plus targeted device checks. Use real tensor operations: they expose shape/dtype/numerical/memory errors mocks hide. +- Cover affected failure cleanup, unsupported configurations, boundary shapes, cancellation. Unit tests never use user model/output directories and never routinely download large models. +- Run focused pytest and root Ruff. Material inference changes: report hardware, dtype, shapes, measurements, untested platforms/model paths. CPU passes do not prove accelerator behavior. diff --git a/invokeai/frontend/api/AGENTS.md b/invokeai/frontend/api/AGENTS.md index c59d5ad2541..67368d0565e 100644 --- a/invokeai/frontend/api/AGENTS.md +++ b/invokeai/frontend/api/AGENTS.md @@ -1,9 +1,9 @@ # Shared API contracts -This package owns `openapi.json`, generated `schema.ts`, and the generator used to check backend/frontend contract drift. It has no UI runtime. Frontend CI runs webv2; contract CI installs this package only. +Owns `openapi.json`, generated `schema.ts`, and the generator that checks backend/frontend contract drift. No UI runtime. Frontend CI runs webv2; contract CI installs only this package. -- Use pnpm 10 and this package's lockfile. Keep the pinned generator, TypeScript 5 peer, and formatter versions intentional; webv2's TypeScript version is independent. -- Never hand-edit generated output. In the repository's activated Python environment, run `make frontend-api-install`, `make frontend-openapi`, and `make frontend-typegen` from the root. Shell pipelines must preserve generator failures. -- Run `pnpm lint` and `pnpm test` here. The generator tests compile independent consumer expectations against real generated types and exercise invalid input. -- Keep the legacy `../webv1/src/services/api/schema.ts` type-only re-export compatible. Validate legacy TypeScript when shared types or that boundary change. -- API shape changes follow `invokeai/app/AGENTS.md` and require owning backend tests. Moving artifact ownership must preserve the generated contracts. +- pnpm 10 and this package's lockfile. Change pinned generator, TypeScript 5 peer, and formatter versions only on purpose. Webv2's TypeScript version is independent. +- Never hand-edit generated output. From the repo root, in the activated Python environment: `make frontend-api-install`, `make frontend-openapi`, `make frontend-typegen`. Shell pipelines must keep generator failures. +- Run `pnpm lint` and `pnpm test` here. Generator tests compile independent consumer expectations against real generated types and exercise invalid input. +- Keep legacy `../webv1/src/services/api/schema.ts` type-only re-export compatible. Shared types or that boundary changed: validate legacy TypeScript. +- API shape changes follow `invokeai/app/AGENTS.md` and need owning backend tests. Moving artifact ownership must keep generated contracts intact. diff --git a/invokeai/frontend/api/README.md b/invokeai/frontend/api/README.md index 3ff73422406..efc30132e07 100644 --- a/invokeai/frontend/api/README.md +++ b/invokeai/frontend/api/README.md @@ -8,7 +8,7 @@ From the repository root with its Python environment activated: make frontend-api-install make frontend-openapi make frontend-typegen -pnpm -C invokeai/frontend/api test +(cd invokeai/frontend/api && pnpm test) ``` Legacy webv1 keeps its existing `services/api/schema` import path through a type-only re-export. Webv2 maps backend wire contracts through its owning platform/features adapters. The API package does not add a frontend runtime dependency. diff --git a/invokeai/frontend/webv1/AGENTS.md b/invokeai/frontend/webv1/AGENTS.md index 2bf00db6f9f..63bb941b625 100644 --- a/invokeai/frontend/webv1/AGENTS.md +++ b/invokeai/frontend/webv1/AGENTS.md @@ -1,16 +1,16 @@ # Legacy frontend -Change legacy UI only when explicitly requested or required for shared compatibility. Ordinary frontend work belongs in `../webv2/`; read its guidance. +Change legacy UI only when explicitly asked or needed for shared compatibility. Ordinary frontend work belongs in `../webv2/`; read its guidance. -Shared OpenAPI/type artifacts and generation tooling belong to [../api/AGENTS.md](../api/AGENTS.md). This package consumes those types through `src/services/api/schema.ts`; keep that type-only compatibility import path. +Shared OpenAPI/type artifacts and generation tooling: [../api/AGENTS.md](../api/AGENTS.md). This package consumes those types through `src/services/api/schema.ts`; keep that type-only compatibility import path. ## Commands -Run from this directory with this package's lockfile: +Run from this directory, with this package's lockfile: -- `pnpm lint`: legacy TypeScript, dependency-cycle, ESLint, Prettier, and unused-dependency checks. -- `pnpm test:no-watch`: legacy Vitest; colocate tests following existing conventions. +- `pnpm lint`: legacy TypeScript, dependency cycles, ESLint, Prettier, unused dependencies. +- `pnpm test:no-watch`: legacy Vitest. Colocate tests per existing conventions. - `pnpm build`: legacy production build. -- Regenerate shared contracts from `../api/` using its commands and lockfile. +- Regenerate shared contracts from `../api/` with its commands and lockfile. -Use root Ruff/pytest for Python changes. Do not apply webv2's Chakra version, @dnd-kit APIs, Oxc commands, or browser-test assumptions here. Preserve existing persisted-state migrations when changing legacy state. +Python changes: root Ruff/pytest. Never apply webv2's Chakra version, @dnd-kit APIs, Oxc commands, or browser-test assumptions here. Changing legacy state: keep existing persisted-state migrations. diff --git a/invokeai/frontend/webv2/AGENTS.md b/invokeai/frontend/webv2/AGENTS.md index 8372215dfd0..8c7d4cf8f31 100644 --- a/invokeai/frontend/webv2/AGENTS.md +++ b/invokeai/frontend/webv2/AGENTS.md @@ -1,48 +1,59 @@ # Webv2 -Read [ARCHITECTURE.md](ARCHITECTURE.md) for ownership/interface changes and affected owner-local READMEs for lifecycle/persistence. Stack: React 19, Vite, Chakra UI 3, TanStack Query/Router, @dnd-kit; legacy web differs. Webv2 is served by default; `--webv2` is a compatibility alias and `--web-legacy` selects `../webv1/`. +Ownership or interface change: read [ARCHITECTURE.md](ARCHITECTURE.md). Its "Owner documents" table lists the spec to read before changing feature, window, settings, persistence, or Canvas behavior. Stack: React 19, Vite, Chakra UI 3, TanStack Query/Router, @dnd-kit. Legacy web differs. Served by default; `--webv2` is a compatibility alias, `--web-legacy` selects `../webv1/`. -Shared generated API contracts and their CI gates belong to [../api/AGENTS.md](../api/AGENTS.md). +Shared generated API contracts and their CI gates: [../api/AGENTS.md](../api/AGENTS.md). ## Ownership and state -- Follow `app -> workbench/features -> platform` and `src/architecture/` policy. App composes dependencies; Platform owns domain-neutral infrastructure; Features own domain behavior; Workbench owns the project aggregate, shell, and Canvas. -- Import Features through registered top-level public entries, never another owner's internals to avoid barrels. Pure cores exclude React/UI/transport dependencies, including type-only imports. -- Production Canvas callers use `canvas-engine/api.ts`. Contracts belong with their invariants' owner; consult the ownership manifest before adding Workbench modules. -- Map/serialize wire DTOs and persisted schemas explicitly. Do not turn compatibility re-exports into implementations or add architecture exceptions to pass checks. -- Query owns backend read models; non-React runtimes own orchestration/lifecycles. Use stable, narrowly selected external-store snapshots, not broad component-state mirrors. -- Preserve account/project isolation, stale-result fencing, revision conflicts, reconnects, and start/dispose ownership. No process-wide mutable per-account/project caches. -- For persistence, read `src/workbench/projects/README.md`: preserve backend authority, bounded recovery, cross-tab ownership, durable queue receipts, and explicit conflicts. +- Direction `app -> workbench/features -> platform`, enforced by `src/architecture/`. App composes dependencies. Platform owns domain-neutral infrastructure. Features own domain behavior. Workbench owns project aggregate, shell, Canvas. +- Import Features only through registered top-level public entries. Never reach into another owner's internals, not even to avoid barrel cost. Pure cores import no React/UI/transport code, type-only imports included. +- Production Canvas callers use `src/workbench/canvas-engine/api.ts`. Engine changes follow `src/workbench/canvas-engine/ARCHITECTURE.md`. Contracts live with the owner of their invariants. Check the ownership manifest before adding Workbench modules. +- Map and serialize wire DTOs and persisted schemas explicitly. Never grow compatibility re-exports into implementations. Never add architecture exceptions to pass checks. +- Query owns backend read models. Non-React runtimes own orchestration and lifecycles. Use stable, narrowly selected external-store snapshots, not broad component-state mirrors. +- Preserve account/project isolation, stale-result fencing, revision conflicts, reconnects, start/dispose ownership. No process-wide mutable per-account/project caches. +- Persistence: read `src/workbench/projects/README.md`. Preserve backend authority, bounded recovery, cross-tab ownership, durable queue receipts, explicit conflicts. ## React and product quality -- No direct `useEffect`: derive during render, act in event handlers, fetch through Query, subscribe via external-store adapters. -- `@platform/react/useMountEffect` is only for mount/unmount registration with stable dependencies and complete cleanup. No changing-dependency refs, wrapper aliases, or layout-effect evasions; layout effects require pre-paint DOM measurement. -- Migrate effects when changing their behavior, without unrelated sweeps. Identity changes use store/runtime lifecycles or deliberate keyed remounts without accidental state loss. -- Reuse Platform controls, Chakra/theme tokens, icons, and interactions; no second styling system or generic Next.js/SWR patterns. -- Typography uses the workbench scale: `xs` is 10px and `sm` is 11px. `architecture:check` (also in `lint`) rejects retired `2xs` literals in `fontSize`/`textStyle` JSX and style/recipe properties, including conditional and responsive values. Chakra permits arbitrary CSS strings, so typegen cannot enforce this. The syntax guard does not resolve variables or calls; `sm` remains valid, so review must catch uses that still intend its old 14px size (`lg`). Stock-name mapping data is allowed. -- Include disabled states; preserve keyboard operation, visible/restored focus, accessible names, and pointer alternatives. -- Icon-only controls carry the platform `Tooltip`, including menu and popover triggers (share `useTooltipTriggerIds` between the root and the Tooltip); never a native `title`. -- Keep dense layouts usable across window sizes/zoom. Check overflow, long/localized text, selected/hover/active states, and themes; follow localization conventions. -- Keep interactions immediate and motion restrained, interruptible, and reduced-motion compatible. Specify transition properties; animation must neither delay editing nor be the sole state cue. -- Provide visual evidence of browser-inspected flows; browser tests and axe do not replace visual/interaction review. +- No direct `useEffect`. Derive during render, act in event handlers, fetch through Query, subscribe via external-store adapters. +- `@platform/react/useMountEffect`: only mount/unmount registration with stable dependencies and complete cleanup. No changing-dependency refs, wrapper aliases, layout-effect evasions. Layout effects only for pre-paint DOM measurement. +- Changing an effect's behavior: migrate it. No unrelated sweeps. Identity changes use store/runtime lifecycles or deliberate keyed remounts, without accidental state loss. +- Reuse Platform controls, Chakra/theme tokens, icons, interactions. No second styling system, no generic Next.js/SWR patterns. Import controls by subpath (`@platform/ui/Button`): the `@platform/ui` barrel's importer budget is full. +- Typography scale: `xs` = 10px, `sm` = 11px. `architecture:check` (also in `lint`) rejects retired `2xs` literals in `fontSize`/`textStyle` JSX and style/recipe properties, conditional and responsive values included. Typegen cannot enforce this: Chakra accepts arbitrary CSS strings. Guard does not resolve variables or calls. `sm` stays valid, so review must catch uses still meaning its old 14px size (now `lg`). Stock-name mapping data allowed. +- Include disabled states. Keep keyboard operation, visible and restored focus, accessible names, pointer alternatives. +- Icon-only controls get the platform `Tooltip`, menu and popover triggers included (share `useTooltipTriggerIds` between root and Tooltip). Never native `title`. +- Dense layouts stay usable across window sizes and zoom. Check overflow, long/localized text, selected/hover/active states, themes. Follow localization conventions. +- Interactions immediate. Motion restrained, interruptible, reduced-motion compatible. Name transition properties. Animation never delays editing and is never the only state cue. +- Give visual evidence of browser-inspected flows. Browser tests and axe do not replace visual/interaction review. -## Performance and validation +## Browser verification -- Inspect rendering/subscriptions, galleries/lists, pointer handlers, Canvas resources, lazy loading, and request scheduling. Use existing virtualization/lazy boundaries; memoize only identified costs. -- Preserve disposal, bounded memory, account transitions, and responsive input. Never update goldens/baselines to hide regressions. -- Run commands here or via `pnpm -C invokeai/frontend/webv2`. Install with `pnpm install --frozen-lockfile`. +No Python backend: `pnpm dev:mock` serves the app against the in-repo mock backend, which picks its own free port. Arguments go to `vite dev`. Default Vite port 5174 taken: pick a spare, `pnpm dev:mock --port 5273 --strictPort`. `pnpm dev` alone proxies to a real backend at `http://127.0.0.1:9090`. -| Change/check | Command | -| ------------------------------------- | ------------------------------------------------------------------ | -| Formatting, lint, types, architecture | `pnpm lint` | -| Focused behavior | `pnpm test ` or `pnpm test:browser ` | -| Unit, Chromium, mock-backend fixtures | `pnpm test:all` | -| Architecture and browser performance | `pnpm test:performance:architecture` | -| Project-file integration | `pnpm test:project-files` | -| Accessibility in a fresh build | `pnpm test:accessibility` | -| Milestone/release completion | `pnpm check:release` | +- `INVOKEAI_MOCK_BACKEND_PROFILE`: `representative` (default) or `empty`. `POST /__reset` on the mock origin printed at startup restores fixtures; `?profile=` switches them. Unimplemented endpoints return 404. +- Stop servers by PID (`ss -ltnp`), not `pkill -f`: its pattern can match the invoking shell. Restart after merges that add mock endpoints. +- Provider/context error from a `?t=` module after source edits: full reload before calling it a product bug. HMR can split context modules. -Install missing Chromium: `pnpm exec playwright install chromium` (`--with-deps` in Linux CI). Colocate unit `*.test.ts`/`*.test.tsx` and browser `*.browser.test.ts`/`*.browser.test.tsx` tests. Use real IndexedDB, Web Locks, focus, pointer, and rendering where mocks cannot establish correctness. +## Performance and validation -Run focused checks during development and `check:release` before code-milestone readiness: lint, all tests, architecture performance/build, project-file journeys, and accessibility. Docs-only changes need formatting/link review, not application tests. Keep architecture inventories/performance reports in ignored `artifacts/`, never committed. +- Check rendering/subscriptions, galleries/lists, pointer handlers, Canvas resources, lazy loading, request scheduling. Use existing virtualization/lazy boundaries. Memoize only identified costs. +- Preserve disposal, bounded memory, account transitions, responsive input. Never update goldens/baselines to hide regressions. +- Run pnpm from this directory: Corepack picks pnpm's version from the current directory, so `pnpm -C` from the repo root can get the wrong one. Install: `pnpm install --frozen-lockfile`. + +| Change/check | Command | +| ------------------------------------- | --------------------------------------------------------------------- | +| Formatting, lint, types, architecture | `pnpm lint` | +| Focused behavior | `pnpm test ` or `pnpm test:browser ` | +| Unit, Chromium, mock-backend fixtures | `pnpm test:all` | +| Architecture and browser performance | `pnpm test:performance:architecture` ([guide](performance/README.md)) | +| Project-file integration | `pnpm test:project-files` | +| Accessibility in a fresh build | `pnpm test:accessibility` | +| Milestone/release completion | `pnpm check:release` | + +- Missing Chromium: `pnpm exec playwright install chromium` (`--with-deps` in Linux CI). +- Colocate tests: unit `*.test.ts`/`*.test.tsx`, browser `*.browser.test.ts`/`*.browser.test.tsx`. +- Use real IndexedDB, Web Locks, focus, pointer, rendering where mocks cannot prove correctness. +- Droppable enabled only after drag start: drag through dnd-kit mouse sensors with `dragWithMouse` (`@platform/browser/mouseDrag.testing`). Single-jump drags such as `userEvent.dragAndDrop` miss it. + +Focused checks during development. `check:release` before code-milestone readiness: lint, all tests, architecture performance/build, project-file journeys, accessibility. Docs-only changes: formatting and link review, no application tests. Architecture inventories and performance reports stay in ignored `artifacts/`, never committed. diff --git a/invokeai/frontend/webv2/ARCHITECTURE.md b/invokeai/frontend/webv2/ARCHITECTURE.md index 4fc653de4ca..d72f3f3a710 100644 --- a/invokeai/frontend/webv2/ARCHITECTURE.md +++ b/invokeai/frontend/webv2/ARCHITECTURE.md @@ -1,17 +1,24 @@ # Webv2 module architecture -Webv2 uses four top-level module groups: +Four top-level module groups: -- `app/` owns providers, routing, boot, and composition roots. -- `platform/` owns application infrastructure with no workbench or feature dependency. -- `workbench/` owns the atomic project aggregate, layout, shell, widget runtime, and extension runtime. -- `features//` owns domain behavior behind a small set of public top-level entry modules (`features//index.ts` first among them). +- `app/`: providers, routing, boot, composition roots. +- `platform/`: application infrastructure. No workbench or feature dependency. +- `workbench/`: atomic project aggregate, layout, shell, widget runtime, extension runtime. +- `features//`: domain behavior behind a few public top-level entry modules (`features//index.ts` first). -Dependency direction is `app -> workbench/features -> platform`. Feature callers import only a feature's public entry modules — the top-level modules each feature registers in `src/architecture/featureInterfaces.ts` (`index` is implicitly public; an unregistered feature or module is private, fail-closed); everything under `core/`, `data/`, `ui/`, and runtime implementation is private. A feature may group substantial behavior under `core/`, `data/`, and `ui/`; pure cores do not import transport, Data, React, or UI modules, including through type-only imports. Production canvas callers use `canvas-engine/api.ts`, not the engine implementation root. +Paths are relative to `src/`, except paths starting with `src/`, `scripts/`, `performance/`, or `artifacts/`: those are relative to this package. Workbench ownership tables use paths relative to `src/workbench/`. + +Dependency direction: `app -> workbench/features -> platform`. + +- Feature callers import only a feature's public entry modules: the top-level modules registered in `src/architecture/featureInterfaces.ts`. `index` is implicitly public. Unregistered feature or module is private (fail-closed). +- Everything under `core/`, `data/`, `ui/`, and runtime implementation is private. +- Features may group substantial behavior under `core/`, `data/`, `ui/`. Pure cores never import transport, Data, React, or UI modules, type-only imports included. +- Production canvas callers use `workbench/canvas-engine/api.ts`, not the engine implementation root. ## App and Platform ownership -Ownership is based on invariants, not the first caller: +Ownership follows invariants, not the first caller: | Module | Owned implementation | Must not own | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | @@ -23,77 +30,72 @@ Ownership is based on invariants, not the first caller: | `platform/logging` | Event contracts, recording filters, safe normalization, bounded in-memory history, subscriptions, console output, export envelope | Preference persistence, diagnostic presentation, event policy | | `platform/ui` | Chakra system/theme, reusable controls, dialog root and modal presence, toaster, brand mark | Workbench widgets or feature-specific views | | `platform/runtime` | Domain-neutral build/runtime metadata | Route or feature policy | -| `workbench/backend` | Queue/node/model progress interpretation and transient domain stores pending their feature migrations | HTTP, sockets, connection mechanics, generic stores | | `workbench/components` | Workbench hydration and Queue progress presentation | Reusable control or theme implementation | -The root of `src/` contains declaration files only. Runtime boot files live in App, the old root entry paths do not exist, and `index.html` names `src/app/main.tsx` directly. `@theme` resolves to Platform UI theme implementation; all other Platform callers use `@platform/*`. No compatibility path exists for moved App, HTTP, socket, external-store, selector, theme, i18n, or reusable-UI modules. - -Platform transport receives authentication through `app/main.tsx`, before React mounts: Identity owns the credential and every policy about it, while Platform captures it once per request and reports that request's outcome. The Platform socket publishes raw events and connection transitions; Queue and other domains interpret those events. Lifecycle-specific production composition remains explicit at its natural root: the authenticated socket in `app/router.tsx`, editor runtimes in `app/WorkbenchApp.tsx`, feature UI providers in `app/workbenchPorts.tsx`, and lazy editor bridges beside their lazy entry modules. App constructs the production Queue runtime from Queue, Workbench, Gallery, and Workflow adapters. - -Identity holds the tab's bearer token in memory, bound to the account lifetime it authenticates; once that lifetime ends no request can carry it. Each HTTP request captures token and lifetime together, and reports a returned `X-Refreshed-Token` (sliding renewal, or the replacement after the caller's own password change) and any 401 against that capture. Identity accepts a replacement only while the capture's lifetime and token are still current, and a 401 only for the current token, so a late response can neither renew nor expire a newer credential. Renewals replace the token in place: no new lifetime, remount or cleanup, and the media cookie is re-issued at most every five minutes because renewals do not extend it. An own password change runs as a credential rotation scoped to the lifetime that queued it; changes run one at a time, and one still queued when its lifetime ends is rejected unsent rather than dispatched with the next account's credential. Until a rotation settles, other replacements are ignored, and a 401 for the current token or another tab's renewal waits for its outcome, so a delivered replacement supersedes them and a failed change lets them apply; another tab's sign-out applies at once and discards the replacement, because storage cannot tell a deliberate sign-out from any other and a deliberate one must end every tab. The rotation is announced in `localStorage` for its principal while it is in flight, each under its own key so concurrent changes from several tabs never overwrite one another (withdrawn after the replacement is stored, and when the announcing tab leaves or ends its lifetime; announcing also clears markers older than the wait and the single marker earlier builds wrote, whose announcements are not seen during a rolling upgrade), so a tab whose token the change revokes holds the sign-out that 401 would cause, while the request itself still fails to its caller, until the replacement is stored and adopted. This includes a tab whose own concurrent change the other change revoked. The session expires if every announcement of that principal is withdrawn without a replacement, or thirty seconds after the latest one still announced if its tab never settles. A tab restoring or following a token that is rejected under an announced rotation waits the same way and then continues from whatever storage holds. A sign-in attempt leaves the stored token alone until it succeeds. The socket authenticates every connect attempt with the current token and reconnects after the server closes a session whose token was replaced. `localStorage` persists the token across reloads and carries other tabs' changes; it is read when a session resolves and on reconciliation (`storage` events, back/forward-cache restores, returning to view), never per request. Another tab's renewal for the same principal, judged from the token's `user_id` claim, is adopted in place; a removal signs this tab out; any other change ends the current lifetime first and then resolves and activates the new principal like a restored session, the latest stored value winning. A rejected token signs the tab out as expired; an unreachable backend leaves it signed out with the token stored and unobserved, so the next reconciliation retries. Writes this tab made itself are not read back as changes, and blocked storage leaves sign-in working for the tab's lifetime. - -Platform State also owns reusable projections of broad external stores into referentially stable read-only stores. App may construct those projections for a feature port, but the listener, caching, and equality mechanics remain domain-neutral Platform code. - -Platform Logging is the one recorder for application and project events. `createLogger` binds a source (namespace, area, optional project, widget and operation identity); a runtime passes the account scope it captured so writes after that lifetime are discarded, while module-level loggers stay unbound. Entries are normalized once at ingestion into immutable JSON-safe snapshots: errors keep name, message, stack, cause and status/code, credential-like keys are redacted, URL queries and fragments are stripped, and depth, collection, string and 8 KiB entry ceilings are marked explicitly. History is account-wide and memory-only in three bounded buffers (warn and above, lower severities, timings) so verbose traffic cannot evict problems; closed projects keep their entries until eviction or account cleanup. Publication is coalesced per microtask; clears and account transitions publish synchronously and restore default capture settings. Workbench owns the preference-to-config mapping and the diagnostics panel; App applies saved preferences before React mounts, installs uncaught-error and router catch reporting, and keeps configuration in step with the active account. Each handled terminal failure has one reporting owner: the Workbench store records reported errors, widget failures, autosave outcomes and queue item transitions; the persistence runtime records an exit checkpoint that did not save, since it outlives the editor; transport and coordinator events are debug breadcrumbs. - -`pnpm architecture:check` is the executable source of truth for these dependency directions. It uses the TypeScript parser to classify static imports, type imports, dynamic imports, re-exports, and inline imported types after resolving aliases and relative paths. Temporary violations must be recorded in `src/architecture/migrationExceptions.ts` with an owner and a removal ticket; architecture completion requires that registry to be empty. - -The queue pilot is the first migrated feature. Its private `QueueBackendPort` is the adapter seam; public callers use domain reads, commands, and production runtime factories from `@features/queue`. Private snake_case HTTP DTOs map explicitly to camelCase Queue read models before crossing that interface. TanStack Query owns the global backend queue read model, which reads and hydrates only a bounded recent window, and a status-only read for count summaries such as Home; non-React runtimes own reconciliation and realtime orchestration. Persisted per-project queue history remains in the Workbench aggregate. - -Queue exposes board-independent active backend sessions, including preparation and result settlement. The Gallery progress section includes every unfinished batch slot, including queued work; Preview follows executing and settling sessions. Both use gallery-destination projections of the active project's queue; eligibility is presentation policy, separate from discovery. Workbench owns transient overview/pinned follow selection for its mounted lifetime, clears pins on project changes or when following stops, and never persists pins or preview frames. Live sessions do not participate in board counts, saved-image selection, or navigation. Saved outputs retain their actual backend board destinations; automatic result selection does not change the browsed board or its paging window. - -Gallery ordinary listings are Query-owned sparse pages shared by its grid, picker, Preview, and deep-reveal flows. A page key combines the account lifetime, canonical listing filters, and aligned absolute offset; each result keeps its authoritative total and absolute item positions so hydrated gaps never compact. Consumers own separate visible ranges and scroll anchors. A cold numbered page after page zero first requests count-only metadata, then reads only its clamped 60-item page; virtual scrolling discovers the count from its first item page. Recent local completions use a temporary overlay, with listing-scoped membership evidence bounded by the recent-item limit so an authoritative item does not reappear in the overlay when its page is evicted. The Query layer retains at most ten inactive idle pages per listing while protecting observed pages, and schedules at most four distinct page reads per QueryClient. Deep reveal resolves an item's filtered listing rank, reads its containing page, verifies the item at the returned absolute position, and retries that rank/page check at most once if offsets shifted. A persisted Preview selection keeps the listing identity from when the item was selected; changing the active Gallery filters does not rewrite that cursor. Preview re-resolves a stale item page after listing mutations, and its asynchronous navigation is fenced by the captured account epoch so a late page cannot stamp selection into a newer account lifetime. - -Intermediates owns the manager for temporary media behind `@features/intermediates`: transport mapping, account-fenced query keys, the selection core, the confirmation dialog and the operation panel. It is a Settings section, contributed as metadata through `settingsContribution` so the dialog's catalog never loads the manager or its transport until the section opens. The eligibility policy (safe, referenced, active, recent) is server-owned; the manager requests a server preview, which fixes the instant recency is judged at and describes what a cleanup would delete and keep, and confirms it once. "All matching" selections travel as a `matching` scope carrying the summary's filters and the excluded rows, which the server resolves; the client never enumerates pages it has not loaded. Operations are not persisted by the server: a manager that opens with nothing followed lists the account's operations and follows the newest unfinished one, so a reload picks a running cleanup back up, and a settled operation that left failures behind offers to delete its scope again. The client's one input to the policy is a browser hold lease: `holdLease` (a public entry, started by Workbench for the provider's lifetime) keeps the media open editors still need (live content from every open project plus the undo state each live Canvas engine retains) classified as active. Workbench owns the name set: `projects/projectAssets` scans each immutable project subtree once, and each Canvas engine registers its retained undo state with the mounted Workbench, which re-reads it when the history changes; an engine released by one Workbench is replaced, not reused, by the next, and one another Workbench still uses is refused. The lease is one per tab, holding up to the server's per-kind limit of sorted unique names, which the server replaces atomically; it skips unchanged sets without re-serialising them, forgets a send the server did not acknowledge so the next change, heartbeat or return to the foreground sends again while the previous lease still stands, refreshes every five minutes and when a tab becomes visible after missing a refresh, and releases with `keepalive` on dispose and `pagehide`; the server keeps at most eight leases per account and expires a lease after 15 minutes, which bounds anything a crash leaves behind. Realtime updates attach for the section's mounted lifetime through the Platform socket hub and invalidate the account's summaries when an operation settles, by either the socket or the polled record, or when the socket reconnects. Workbench supplies the entry points (the Data & workspace link, the project actions menu, the Users page callback) and hands the manager a one-shot focus intent (a project to preselect or an account to filter by) rather than route state. - -Fonts owns its authenticated catalog, managed-library UI, and browser font-registration lifetime behind `@features/fonts`. App composes the session runtime; Canvas receives a font-resolution port and owns text geometry, rendering, and output readiness. Persisted text references and project-file remapping remain in Workbench. Launchpad font previews and Canvas share the same account lifetime; font binaries and browser registration names are not project state. The browser registry admits at most 64 faces and 128 MiB of font input data, including faces currently being parsed. Active references are protected from eviction; if they exhaust the budget, further loads fail until references are released. This bounds retained input, not browser-specific decoded font memory. Canvas reconciles active references only when the document forests or text draft source change. - -Font runtime composition and project-file option state travel in the existing shared route chunk. Catalog transport, the Fonts page, and project-file option dialogs load on demand. Missing-font recovery loads only for documents containing custom references. The locale asset plugin serves font-library strings as a separate namespace in development and production, loaded when the library or recovery UI opens; source translations remain in the canonical locale files. Text tool controls load when selected; their shared Canvas/Layer form and interaction dependencies remain grouped to avoid extra widget-activation requests. Catalog query keys have a lightweight public entry so invalidating an imported font does not pull the transport into startup. - -Projects own their workflows. The Workbench aggregate keeps an ordered workflow collection with an active selection and one session edit history per workflow (`src/workbench/projectWorkflows.ts`); the project document is schema 3 and `migrateProjectDocument` in `projects/projectDocument.ts` is the single migration boundary for older documents. Workflow reads the active workflow through the App-composed port and edits, publishes and presents documents; it never replaces authored project work. The library is written only by the Workflow-owned publication controller, which captures the account, project, workflow and content before it starts, refuses to overlap itself per workflow, reconciles a lost answer against the destination record before resending, and records the resulting library id and revision on the originating workflow alone. Queue submissions carry the originating project workflow so seed advances, node progress and run previews return to it whatever is active later. - -Workbench persistence follows the same lifetime rule. `createWorkbenchPersistenceRuntime` owns hydration ordering, debounced autosave, hidden-page saves, revision/generation checks, conflict application, reconnect replay, status publication, and the exit checkpoint without importing React. `createSyncedWorkbenchPersistence` constructs the local/remote project synchronization state for one mounted Workbench; no process-wide maps, sets, flags, or caches hold that state. It also owns the unload journal: the runtime asks it, through its persistence port, to journal unsaved state synchronously when the page is hidden or unloads, and its load reconciles the journal into the draft store under the draft store's fencing rules. `WorkbenchProvider` constructs these lifetimes, starts the runtime and ends it with its exit checkpoint through the mount lifecycle adapter, and selects aggregate hydration state. The checkpoint and the persistence lease outlive the unmount until it settles. The provider also hands the checkpoint to the account lifetime's next editor through a module-level map keyed by the account scope, and that editor loads after it, waiting at most 10 seconds; the tab's editor session is reference-counted across the two. See `workbench/projects/README.md`. Project transition commands commit pending editor drafts themselves, so callers do not flush before creating, opening, switching, or closing a project. +- `src/` root holds declaration files only. Runtime boot files live in App. Old root entry paths are gone; `index.html` names `src/app/main.tsx` directly. +- `@theme` resolves to Platform UI theme implementation. Every other Platform caller uses `@platform/*`. Moved App, HTTP, socket, external-store, selector, theme, i18n, reusable-UI modules have no compatibility path. +- Authentication: `app/main.tsx` hands it to Platform transport before React mounts. Identity owns the credential and every policy about it. Platform captures it once per request and reports that request's outcome. +- Platform socket publishes raw events and connection transitions. Queue and other domains interpret them. +- Lifecycle-specific production composition stays explicit at its natural root: authenticated socket in `app/router.tsx`, editor runtimes in `app/WorkbenchApp.tsx`, feature UI providers in `app/workbenchPorts.tsx`, lazy editor bridges beside their lazy entry modules. App builds the production Queue runtime from Queue, Workbench, Gallery, Workflow adapters. +- Platform State owns reusable projections of broad external stores into referentially stable read-only stores. App may build them for a feature port. Listener, caching, equality mechanics stay domain-neutral Platform code. + +`pnpm architecture:check` is the executable source of truth for dependency direction. It parses TypeScript and classifies static imports, type imports, dynamic imports, re-exports, inline imported types, after resolving aliases and relative paths. Temporary violations go in `src/architecture/migrationExceptions.ts` with owner and removal ticket. Architecture is complete only when that registry is empty. + +## Owner documents + +Behavior specs live beside their owning code. Read the owner's document before changing its behavior. + +| Topic | Document | +| ------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Identity credential and session lifetimes | [features/identity/README.md](src/features/identity/README.md) | +| Application and project event logging | [platform/logging/README.md](src/platform/logging/README.md) | +| Queue backend port and active sessions | [features/queue/README.md](src/features/queue/README.md) | +| Gallery listings and reveal | [features/gallery/README.md](src/features/gallery/README.md) | +| Intermediates manager and cleanup | [features/intermediates/README.md](src/features/intermediates/README.md) | +| Font catalog and registration | [features/fonts/README.md](src/features/fonts/README.md) | +| Project documents, workflows, persistence, and recovery | [workbench/projects/README.md](src/workbench/projects/README.md) | +| Widget placement, floating windows, focus, and shortcut hints | [workbench/widget-frame/README.md](src/workbench/widget-frame/README.md) | +| Settings contributions and the settings dialog | [workbench/settings/README.md](src/workbench/settings/README.md) | +| Widget manifests and extension discovery | [workbench/extensions/README.md](src/workbench/extensions/README.md) | +| Canvas engine | [workbench/canvas-engine/ARCHITECTURE.md](src/workbench/canvas-engine/ARCHITECTURE.md) | +| Performance gates and byte budgets | [performance/README.md](performance/README.md) | ## Contract ownership -Contracts live with the module that owns their invariants. The retired `workbench/types.ts` path is an architecture violation; Workbench aggregate, widget, Canvas, persistence, settings, and feature contracts each have an owner-local canonical path. Persisted schemas and backend DTOs remain private versioned/wire values and cross seams only through explicit serializers, hydrators, or mappers. - -`platform/core/outputImages.ts` is the explicit shared adapter for backend invocation-output image names. It is platform-owned because Queue and Nodes both need the projection and cannot import each other's private implementations. The adapter recognizes only the backend's direct image, image-collection, and workflow-return output contracts; it must not become a recursive walker for arbitrary metadata or input fields. - -Feature callers import a feature's public entry modules; imports of another feature's `core/`, `data/`, `ui/`, or runtime implementation are rejected outside a ticketed migration exception. Wildcard exports may aggregate files inside one owner, but may not cross an ownership seam. The contract caller matrix and development-invalidation budgets are generated by `pnpm architecture:check` and `pnpm test:performance:build` respectively. - -The byte budgets in `performance/architecture-baseline.json` and `performance/browser-baseline.json` record measurements only. Each byte ceiling is the lower of `max(1%, 4 KB)` over the **higher of the base reference and committed measurement**, and `max(10%, 32 KB)` over the committed measurement (`deriveLimit` in `scripts/performance-budgets.mjs`). Deliberately re-recording a higher baseline therefore accepts reviewed growth, such as a dependency upgrade, in the same PR. Growth beyond that allowance still fails. Previously recorded higher measurements remain accepted until a downward re-record protects the savings; the committed hard ceiling continues to bound cumulative growth in the base branch. Re-record with `pnpm test:performance:build:update-baseline` and `pnpm test:performance:browser:update-baseline`, and include the measured cost or savings in review. - -CI restores `main`'s own measurements from the Actions cache (`WEBV2_PERF_REFERENCE_DIR`) for pull requests into `main` and merge groups targeting `main`. Logs and reports name the restored reference. Local runs, pushes to `main`, and stacked PRs without a reference compare against committed measurements directly. Source-owner sets stay pinned exactly in every mode: a module entering a route's initial graph fails at zero bytes. Request counts carry no allowance and remain capped at the lower of the reference and committed counts. A byte rebaseline does not relax request counts, source ownership, inactive-widget loading, or other structural checks. - -Compatibility exports under `workbench/` are temporary. New behavior belongs behind the target module interface and compatibility files must not acquire new implementation logic. +- Contracts live with the module owning their invariants. Retired `workbench/types.ts` path is an architecture violation. Workbench aggregate, widget, Canvas, persistence, settings, feature contracts each have an owner-local canonical path. +- Persisted schemas and backend DTOs stay private versioned/wire values. They cross seams only through explicit serializers, hydrators, mappers. +- `platform/core/outputImages.ts` is the explicit shared adapter for backend invocation-output image names. Platform owns it because Queue and Nodes both need the projection and cannot import each other's private code. It recognizes only direct image, image-collection, workflow-return output contracts. Never grow it into a recursive walker of arbitrary metadata or input fields. +- Imports of another feature's `core/`, `data/`, `ui/`, or runtime implementation fail outside a ticketed migration exception. +- Wildcard exports may aggregate files inside one owner. Never across an ownership seam. +- `pnpm architecture:check` generates the contract caller matrix; `pnpm test:performance:build` generates development-invalidation budgets. +- Compatibility exports under `workbench/` are temporary. New behavior goes behind the target module interface. Compatibility files gain no new implementation logic. ## Workbench ownership destinations -`src/architecture/workbenchOwnershipManifest.json` is the executable current-to-target ownership manifest. `pnpm architecture:check` proves that every production Workbench module has exactly one target owner and writes the detailed import/export inventory to `artifacts/architecture/workbench-ownership-inventory.json`. The generated inventory is regenerated as modules change; tests and type-test fixtures are intentionally excluded. +`src/architecture/workbenchOwnershipManifest.json` is the executable current-to-target ownership manifest. `pnpm architecture:check` proves every production Workbench module has exactly one target owner. It writes the import/export inventory to `artifacts/architecture/workbench-ownership-inventory.json`, regenerated as modules change; tests and type-test fixtures excluded on purpose. -| Current Workbench path | Target owner | Split or locality decision | -| ---------------------------------------------------------------------------------------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| aggregate root modules, `projects`, `persistence`, `settings` | Workbench | Atomic project/account state and its persistence policy remain local to the aggregate. Persistence mechanics move behind the aggregate's adapter. | -| `shell`, `components/WorkbenchSplashScreen`, `launchpad` shell, `widget-frame`, non-domain `widgets` | Workbench | Route-independent shell, layout, widget hosts, and registration are Workbench implementation. | -| `extensions`, `hotkeys`, `diagnostics` | Workbench | These enforce the Workbench extension runtime; they are not independent vertical domains. | -| `canvas-engine`, `canvas-operations`, Canvas/layers widgets, root Canvas modules | Workbench | Canvas is a deep internal Workbench module behind `canvas-engine/api.ts`, not another top-level Feature. | -| `image-actions`, `images` | Workbench | Cross-surface image orchestration and transient display stay internal until a second independent implementation justifies a seam. | -| `auth`, `users`, Launchpad Users page | `feature:identity` | Session/token policy and user administration form one identity vocabulary. | -| `models`, Launchpad model manager/page, `backend/modelLoadStore` | `feature:models` | Model catalog, install/load lifecycle, and model administration co-locate. | -| `customNodes`, Launchpad node manager/page, `backend/nodeExecutionStore` | `feature:nodes` | Node catalog/template and execution lifecycle co-locate. | -| `backend` except model/node stores, queue root modules, queue widget, Queue progress component | `feature:queue` | Queue owns server-event interpretation, transient progress, submission, reconciliation, and Queue UI. Persisted project queue history stays in Workbench. | -| `gallery`, Gallery widget | `feature:gallery` | Gallery data, selection policy, and Gallery UI form one vertical module. | -| `generation`, Generate widget | `feature:generation` | Generation settings, graph compilation, and Generate UI form one vertical module. | -| `workflows`, `graph-preview`, Workflow widget | `feature:workflow` | Workflow documents, compilation, preview, editor, and library form one vertical module. | -| `upscale`, Upscale widget | `feature:upscale` | Upscale settings, validation, compilation, and UI form one vertical module. | -| `hooks/useMountEffect`, root `useModifierHeld` | Platform | These are domain-neutral React lifecycle/input adapters and must have no Workbench import. | +Every manifest directory rule targets Workbench. One override left: `workbench/components/QueueProgressIndicator.tsx` targets Queue, due to move to `features/queue/ui/`. Identity, Models, Nodes, Queue, Gallery, Generation, Workflow, Upscale code lives under `features/`. Domain-neutral React adapters `useMountEffect` and `useModifierHeld` live in `platform/react/`, with no Workbench import. -The mixed-folder rules are longest-prefix rules in the manifest, so the Backend, Components, Launchpad, and Widgets splits are explicit rather than inferred from imports. Root modules are listed individually. Compatibility re-exports keep the target owner of the implementation they forward and are deletion work, never a fifth owner. +| Workbench path | Locality decision | +| ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| aggregate root modules (including persistence), `projects`, `settings` | Atomic project/account state and its persistence policy remain local to the aggregate. Persistence mechanics move behind the aggregate's adapter. Persisted project queue history stays in Workbench. | +| `shell`, `components/WorkbenchSplashScreen`, `launchpad` shell, `widget-frame`, non-domain `widgets` | Route-independent shell, layout, widget hosts, and registration are Workbench implementation. | +| `extensions`, `hotkeys`, `diagnostics` | These enforce the Workbench extension runtime; they are not independent vertical domains. | +| `canvas-engine`, `canvas-operations`, Canvas/layers widgets, root Canvas modules | Canvas is a deep internal Workbench module behind `canvas-engine/api.ts`, not another top-level Feature. | +| `image-actions` | Cross-surface image orchestration and transient display stay internal until a second independent implementation justifies a seam. | -The owner dependency graph is acyclic: the only direction is `app -> workbench/features -> platform`, with cross-owner access through public module interfaces or composition-root adapters. `workbenchOwnership.test.ts` fails on any strongly connected component among owners, and `migrationExceptions.ts` is empty. +- Overrides are longest-prefix rules: a mixed folder's split is explicit, not inferred from imports. +- Compatibility re-exports keep the target owner of what they forward. They are deletion work, never a fifth owner. +- Owner dependency graph is acyclic. Only direction: `app -> workbench/features -> platform`. Cross-owner access only through public module interfaces or composition-root adapters. `src/architecture/workbenchOwnership.test.ts` fails on any strongly connected component among owners. `src/architecture/migrationExceptions.ts` is empty. -Feature interfaces remain ordinary module interfaces unless two adapters add leverage. Queue's backend port has production transport/realtime and test adapters; Identity's token/session port has browser-session and test adapters; Models and Nodes use production event/transport adapters; their sinks (model-load activity, node execution) are injected into Queue's runtime at the App composition root, with test doubles in Queue's runtime tests. Gallery, Generation, Workflow, and Upscale keep their compilation and UI seams internal until their feature tickets demonstrate another independent adapter. Workbench image actions likewise remain internal rather than becoming a shallow Image Feature. Gallery's picker and media slots read a narrow `GalleryHost` (gallery scope, notifications, an optional Gallery-widget reveal): the workbench derives it from the Gallery UI adapter, and the Launchpad's model manager, which has no workbench, supplies one itself. Media slots accept dragged gallery items only inside `GalleryDragScope`, which the workbench shell provides with its drag context; widget dialog hosts and the Launchpad sit outside it. +Feature interfaces stay ordinary module interfaces unless two adapters add leverage. + +- Queue backend port: production transport/realtime adapter plus test adapter. +- Identity token/session port: browser-session adapter plus test adapter. +- Models and Nodes: production event/transport adapters. Their sinks (model-load activity, node execution) are injected into Queue's runtime at the App composition root; Queue runtime tests use doubles. +- Gallery, Generation, Workflow, Upscale keep compilation and UI seams internal until a feature ticket shows another independent adapter. Workbench image actions also stay internal, not a shallow Image Feature. +- Gallery picker and media slots read a narrow `GalleryHost`: gallery scope, notifications, optional Gallery-widget reveal. Workbench derives it from the Gallery UI adapter. Launchpad's model manager has no workbench and supplies its own. +- Media slots accept dragged gallery items only inside `GalleryDragScope`, provided by the workbench shell with its drag context. Widget dialog hosts and the Launchpad sit outside it. | Feature | Responsibility and representative callers | Allowed dependencies and candidate public interface | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -106,67 +108,3 @@ Feature interfaces remain ordinary module interfaces unless two adapters add lev | Workflow | Workflow document, validation, compilation, preview/editor/library and explicit publication; App adapter, Workbench invocation, Nodes | Platform and public Nodes/Models/Gallery/Queue/Generation interfaces; document reads/commands, compiled submission envelope and widget contribution | | Upscale | Upscale settings, validation, compilation and UI; Workbench invocation and Queue | Platform and public Models/Gallery/Queue interfaces; value/validation reads, submission command and widget contribution | | Intermediates | Per-project intermediates read models, cleanup previews/operations and the Settings section that manages them; Workbench settings, project menus, account administration | Platform plus Identity's public session/capability reads; query keys, read models, the focus intent, the browser hold lease and the `settingsContribution` metadata whose loader owns the section body | - -## Widget placement and floating windows - -A project places each widget instance in regions (`widgetRegions`) or in a floating window (`floatingWidgets`), never both. `workbench/floatingWindows.ts` owns the rules; the reducer, placement normalization, and the rails all read placement through it. - -- **A floating instance belongs to no region's `instanceIds`.** Floating removes every membership, repairs the active selections it leaves behind, and records one return destination (`returnRegion`, `returnIndex`). -- **A return region keeps a marker for each of its windows.** `getRegionOrder` is the region's complete order — docked members plus markers — and a marker's `returnIndex` is its position in it. Docking counts the docked members ahead of the marker, so docking in any order never moves what the rail shows. Reducer edits that add, remove, or move members go through that order so the indices stay in step; reordering docked members fills the docked positions around stationary markers. -- **Empty-center recovery is the one exception to origin-only docking.** A center emptied by a float keeps naming the instance in `activeInstanceId`. While it is still empty and still names that instance, Dock restores center membership along with the origin, and Remove restores center membership only (no rail slot, no panel). No extra field is persisted; a center that has gained a view or changed its pointer is left alone. "Empty" means no members at all: the reducer has no widget registry, so a center left holding only a disabled or unregistered view is not recovered and shows that view's unavailable state until another view is chosen. -- **An instance opened in the center is shown once.** It may stay a member of a side panel, but a left or right panel fronting it fronts its next member instead, or collapses when it holds nothing else. Only the pointer moves, so a hidden panel stays hidden. The bottom region is left alone: the reducer has no registry to tell its panels from tooltip-only status items. This is what lets every "open in Preview" path, and the center Preview toggle, reuse the Preview a rail holds. -- **Region requests never discard a window.** Selecting, moving, or reordering an instance a region does not hold is a no-op, and so is enabling an instance that floats. Opening a widget type whose instance floats restores its return placement first, then opens the requested one, and auto-routes once. -- **One normalization.** `normalizeFloatingPlacement` runs on project hydration, on stored presets, on preset application, and inside preset comparison (which includes `returnIndex`), so an applied preset always matches the snapshot it came from. It is idempotent. Indices stored before markers were counted are read deterministically (ascending, ties by instance id, missing ones appended); that is best-effort and does not reconstruct the original arrangement. - -- **A closed window leaves its geometry behind.** When a window closes — docked, removed, or closed by a preset or an undo — its position and size move to `Project.lastFloatingGeometry` (`rememberClosedWindows`); floating the instance again takes them back out and reopens the window there, windowed, and only a first float cascades. The float command passes the viewport, so a rectangle remembered in a larger one is brought wholly on screen rather than reopening as a sliver. The memory holds only instances that exist and are not floating now, in every state the reducer produces, so a save is exactly what a load reads back. It is persisted with the project and validated on load, but it is not layout: preset snapshots, drift comparison, and undo snapshots do not carry it. - -- **Each preset keeps its working arrangement per project.** Switching away stores the outgoing arrangement in `Project.presetWorkingLayouts` when it differs from the saved preset, and switching to a preset lays out its copy there when it has one, else the saved snapshot. The copies are an ordered list, oldest first, with at most one per preset and at most 16 (the oldest goes first). Each is a `LayoutPresetSnapshot` (placement, panels, center view, instance identity and title — never widget state) and never names the active preset: the live layout is its copy. Opening or hydrating a project drops copies of presets the account no longer has and copies equal to their saved preset, and every switch drops the former, so orphans never hold one of the 16 places; deleting a preset drops its copies from every open project, and a project that was on it moves to the default preset the way a switch would. A renamed preset keeps its id and its copy. Save on the active preset writes the live layout; on another preset it writes that preset's copy to the account and leaves the copy in place, where it no longer reads as unsaved because it equals the saved preset, so a lost account write still leaves the arrangement recoverable until hydration proves the copy redundant. Saving a preset also drops every other open project's copy of it that equals its previous or its new saved arrangement: such a copy was no change of that project's own, and kept it would show the outdated arrangement as unsaved and could overwrite the newer save. Revert on the active preset reapplies the saved snapshot, as choosing the active preset again does; on another it drops the copy as an undoable step. Pressing the active tab while a cold switch is still loading cancels that switch instead. Saving the arrangement as a new preset moves the project onto the new preset. Copies are persisted, validated on load like stored presets, and captured in undo snapshots with `layout.presetId`, so undoing a switch restores both. - -The rails subscribe to placement only (`getWidgetPlacementProject` projects `floatingPlacements`), so a window's geometry, mode, and stacking change without re-rendering them. A marker is not a tab: it stays out of the sortable list, activating it raises and expands its window, and Dock and Remove live in its context menu. Marker UI exists only on the left and right rails. - -### Focus - -Workbench focus is transient and never persisted. `WorkbenchFocusProvider` (`workbench/WorkbenchRuntime.tsx`) owns one controller (`workbench/focusRegions.tsx`) above the UI ports, the hotkey runtime, and the shell; there is no module-level focus state. The target is a docked region or one floating instance. - -- **Keyboard navigation.** `Alt+Shift+Arrow` (`Ctrl+Cmd+Arrow` on macOS, where Option+Shift+Arrow selects text by word or paragraph, Ctrl+Arrow chords belong to Mission Control, and Ctrl+Option+Arrow is VoiceOver's navigation chord) moves focus to the nearest visible docked region in that direction, using current frame geometry and preferring alignment; without a current target it enters the center. It preserves widget selections, skips hidden regions, and stops at the edge. From a floating window, its center is the starting point on the movement axis so overlapping docks remain reachable; when no dock lies beyond it in that direction, focus returns to the dock underneath its center. `Alt+PageUp` and `Alt+PageDown` cycle the focused docked region's enabled panel views in placement order, wrapping at either end and focusing the owning region once the selected instance is visible. Side frames wait for their lazy view to load; the persistent center frame can take focus immediately. Toolbar, tooltip, popover, and floating placements are excluded; a single eligible widget stays open. All six commands are customizable in Hotkeys settings, work from editable fields (their defaults are chords no text field binds on that platform), and respect modal suspension. Ordinary Tab and arrow-key handling is unchanged. - -- **One judge of the target.** `getTarget()` returns the target only while it describes something on screen: set under the current project and account, and — for a window — still floating. The provider also clears eagerly when the active project changes, the account changes, or the workbench unmounts; that is what notifies subscribers and abandons a focus move still waiting for its widget to mount. Returning to a project never brings old focus back. -- **Activation.** Pointer-down and keyboard focus activate a region or window; hover does not. The active window carries the same accent outline, under the same preference, as a focused region, and no region is outlined while a window is active. A window passes the project id it was rendered for, so a late event from a project that has left the screen is refused before it can raise anything. -- **Raising.** A press inside a window always asks the reducer to raise it, which is a no-op for the topmost window. Focus raises a window only when it makes that window the active one: focus moving around inside the already-active window, or returning from a closing dialog or popover, writes nothing. Ordinary focus leaves a shaded window shaded; the explicit reveal (marker, center "Show window", recall) expands it. -- **Recall is the one reveal that moves no focus.** A recall raises and expands its window but leaves keyboard focus, the outline, and the hotkey target where the user had them; the next press or focus change brings them back into agreement. Because focus inside the active window does not re-raise, it cannot re-bury the window a recall just revealed. -- **Keys follow the outline.** A press inside a window moves keyboard focus into it when focus was elsewhere, and a press on a docked region pulls focus out of a floating window. The floating layer renders windows in a fixed DOM order and stacks them by z-index alone: raising a window never moves its node, which would drop the focus that raised it. -- **Focus follows placement:** into the window after Float and after a marker or "Show window" reveal, into the region a docked rail tab shows in when it is selected, so keys stop going to a window that held them, into the return region after Dock, and after Remove from a marker's menu into the center when it gets its view back, otherwise into the panel the window's own rail is showing — or the center when that rail shows none, emptied by the float or collapsed. Removing a window from a rail's enable menu moves no focus: that menu stays open and keeps it. A focus move targets the region frame that is on screen (side regions keep earlier panels mounted but hidden), waits a bounded time for its widget to mount, and is abandoned if the user presses somewhere else first. Every panel frame carries its instance's identity, including the stand-in for a widget whose view cannot render, so a move into it lands. -- **Hotkeys read the same target.** `HotkeyContext.focusedRegion` is `WidgetRegion | 'floating' | null`; the persisted `WidgetRegion` is unchanged. A key press is aimed at the widget under the event target first and, outside any widget's DOM, at the active floating instance or the focused region's active member (`resolveHotkeyTarget`). A region's active pointer counts only while it names a member, so a view that floated out of an emptied center answers as a window, not as the center. A floating command source carries `region: 'floating'` and is never used to index `widgetRegions`. Generic `focused-region` shortcuts match under a window, shortcuts tied to a named docked region do not, and a floating widget's own `focused-region` contribution follows its window at the same priority it has when docked. -- **Modal dialogs suspend application shortcuts.** Every dialog is built on the Platform `Dialog` root (`platform/ui/Dialog.tsx`; lint rejects Chakra's dialog and its drawer, which has no such root yet), which registers with `platform/ui/modalPresence` while its own open state holds, unless it is declared `modal={false}`. A dialog whose module is still loading uses `Dialog.Pending` as its suspense fallback, so it is modal from the keypress that opened it. While any registration stands, the hotkey runtime runs only hotkeys marked `allowInModal` — the command palette toggle — so invoke, global and widget shortcuts stay inactive wherever focus is, and keys the dialog handles itself (Enter, Escape, typing) keep their browser behavior; the Canvas engine's own key listener takes no key either. Both ask about the key press they are handling: a press that began under a modal still counts as aimed at it, because the Escape that dismisses a dialog releases its presence before the same press reaches bubble-phase listeners. Presence follows interactivity, not DOM: a closing dialog that stays mounted through its exit animation is inert and releases at close, so the next press reaches the application; reopening re-registers at once, an outer dialog stays registered while an inner one closes, and an unmount releases without a close. Media paste reads the same presence. Popovers and menus do not register. - -### Contextual shortcut guidance - -Shortcuts is a bottom-only popover widget. Built-in layouts put it on the right immediately before Notifications; saved and custom layouts retain their placements and can enable it through Bottom Widgets. The whole chip opens its guide, and normal widget controls own reordering, alignment, visibility and dismissal. Its `compactSizing` metadata asks the status-bar host for remaining capacity: the host observes its strip, two clusters and spacer, while the guide measures only its own keycaps. Up to four whole hints fit without increasing the status bar's height; a keyboard icon retains access when no hint or label fits. If all relevant bindings are unassigned, the popover explains that state and provides keyboard shortcut settings. - -The Workbench focus lifecycle also owns the transient hint-source registry and last editing element. A keyed Canvas registration mounts one stable source for its project/instance engine lease; changing generation/staging locks publish through a boolean-filtered project subscription rather than replacing that source. Lock reads cache the document revision, staged-candidate array and queue-item array; unrelated project updates do not rescan queue work. The lock predicate tests candidate presence and active work without constructing staging slots. Narrow tool/session subscriptions cache scalar hint inputs; the guide never acquires an engine lease or imports property forms. Sources are fenced by project and instance and released with their owner. Project/account changes clear the editing origin. The widget's `preserveWorkbenchFocus` metadata keeps its compact chip and portal from replacing that origin; closing returns focus there. Guide activation and Escape do not reach Canvas session handlers; configured global chords keep their normal runtime handling. - -Command hints resolve against the runtime catalog and mounted extension contributions, including custom bindings, scope conflicts, editable fields and modal exclusions. Command bindings reserved for guide activation or dismissal (bare Enter/Space and Escape chords) are omitted in favor of a usable alternative; fixed Canvas instructions may still describe those keys. Fixed gestures remain Canvas-owned and distinguish selection modifiers at the press from constraints during the drag. Properties shows them as “On Canvas”; active modal presence hides the background guide. Pointer coordinates and pixel epochs are not hint subscriptions. - -### Window interactions - -- **Geometry is stored once and adapted in CSS.** Every window shares one static rule set; the stored geometry and stacking rank arrive as inline custom properties (registered as non-inheriting, so a drag restyles the window and not the widget inside it), so a commit changes values rather than minting a class. The position keeps a 48px sliver reachable (`FLOATING_VIEWPORT_MARGIN_PX`, the same margin `clampWindowToViewport` applies to commits) and the displayed size is capped to the viewport. A viewport that shrinks therefore persists nothing; the stored geometry applies again when there is room. -- **Gestures start from the rendered rectangle**, not the stored geometry, so a window CSS is clamping or capping does not jump. A gesture commits only what it changed: a move keeps the stored size, and a resize keeps the stored values of any axis it did not change (`commitResizedAxes`), because what is on screen there may only be the viewport's clamp or cap. The viewport clamp applies to the changed axes alone, so a vertical resize never rewrites a stored x, nor a horizontal one a stored y. A press that never moves, or a drag against the minimum or the cap, commits nothing. Keyboard steps follow the same rules. -- **One commit per gesture.** Pointer moves are coalesced to animation frames and previewed as inline custom properties on the window element; the store is written once, when the gesture ends — on release, or when the pointer is taken away (`pointercancel`, a release the page never saw), which keeps what was dragged so far. Escape discards the preview. Each keyboard step is its own commit. -- **Resize handles.** Eight pointer-only handles (four edges, four corners) straddle the border with matching cursors and non-overlapping hit areas, above an isolated content frame so widget stacking cannot cover them. Inside the bottom-right corner sits the `ResizeCorner`, the single labelled, keyboard-operable resize control; the pointer-only handle there is clipped to the band outside the border. That control announces the size on screen — the stored size as the viewport caps it — and follows the viewport, so its value and its keyboard steps agree. `resizeFloatingGeometry` anchors the opposite side, holds the size between the minimum and the viewport (a viewport smaller than the minimum lowers it to the viewport, so the shown size is not mistaken for a resize), and stops the top edge at the viewport's top. -- **One gesture path.** The title bar, the grip, and every handle start through the window's single pointer session, so a new gesture replaces the one before it and resize gestures all raise the platform's resize-drag signal. The preview names the mode the gesture began in and applies only while the window is still in that mode: a window that maximizes or collapses under a gesture shows its new frame at once, and the gesture ends itself — releasing the drag cursor and committing nothing — the next time it hears from the pointer. Docking, removing, or switching project unmounts the host (hosts are keyed by project and instance), which aborts the session. -- **Title bar.** Double-click maximizes or restores, and expands a collapsed window; Restore returns to the windowed geometry, which maximizing never touches. Collapse and Expand are explicit buttons. The label truncates first, down to its icon; contributed widget actions then give way in a strip that scrolls, without a scrollbar, by focus, a sideways scroll, or touch, so a focused action is always in view with its ring; the window's own controls never shrink. -- **Shaded bodies stay mounted.** A shaded body is hidden with React `Activity`, so its local state survives and no second copy mounts. Drafts are flushed before shading, docking, and removing, because hiding or unmounting an editor stops its flusher. - -## Settings contributions - -Settings are declared by their owners and presented by Workbench. Platform UI owns domain-neutral field metadata and controls; it does not own setting values or persistence. A widget manifest exposes a `settings` contribution containing localized field metadata, an ordered `quick` subset, and a deferred field binding loader. The full dialog includes every declared field. Bindings retain the owner's normalization and command path, including Gallery's size/density conversion and project/instance fencing. - -Workbench composes widget and application contributions into a metadata-only catalog. Search, dialog navigation, quick popovers, and command-palette destinations share that catalog. Features publish contribution metadata through registered public entries; loading metadata must not load their UI or start data queries. Application-owned additions can join a widget section without changing the owner of their values. Deferred resources cache code only; field subscriptions exist only while their controls are mounted. - -Section placement does not determine storage lifetime. Account/install preferences, project settings, widget-instance values, and server configuration retain their existing persistence and authorization. Each surface lists only settings it can edit: the Launchpad Preferences page leaves out project and widget settings, and the workbench dialog leaves out widget settings for widget types the open project lacks. The shell resolves explicit project/instance targets and disposes target-bound editing when projects change. Owner bindings fence writes against stale account/project lifetimes. - -The settings host owns the dialog's presence lifecycle and keeps its root mounted through closing. The body is deferred until first opening, remains present during exit, and unmounts afterward. Quick-popover handoff carries both the instance target and the original gear for focus restoration. - -Before section navigation becomes available, the deferred dialog prepares binding/editor code without mounting editors or starting their queries. Section and scroll position survive closing within the account lifetime. The shell renders a shared modified-setting indicator; bindings determine whether a value differs from the owner’s canonical default. Quick settings use the shared arrowed popover with an inner scroll area, and dialog selects keep their bounded scroll areas inside the modal focus scope. - -Production chunking keeps the catalog and manifest metadata separate from editor boot UI. The build gate checks that Launchpad’s palette and settings dialog do not statically include the editor application or widget renderer. Shared settings controls remain in the common UI chunk; owner field bindings and custom editors remain deferred. diff --git a/invokeai/frontend/webv2/package.json b/invokeai/frontend/webv2/package.json index 1f3116e37a3..8cc729ef6b6 100644 --- a/invokeai/frontend/webv2/package.json +++ b/invokeai/frontend/webv2/package.json @@ -13,6 +13,7 @@ "check:release": "pnpm run check:architecture && pnpm run test:accessibility:browser", "dev": "vite dev", "dev:host": "vite dev --host", + "dev:mock": "node scripts/dev-mock.mjs", "fix": "pnpm run lint:oxc:fix && pnpm run format:write", "format:check": "oxfmt --check .", "format:write": "oxfmt --write .", diff --git a/invokeai/frontend/webv2/performance/README.md b/invokeai/frontend/webv2/performance/README.md new file mode 100644 index 00000000000..3c204b7f8b2 --- /dev/null +++ b/invokeai/frontend/webv2/performance/README.md @@ -0,0 +1,71 @@ +# Performance gates + +Paths relative to the webv2 package. `pnpm test:performance:architecture` runs two gates against baselines in `performance/`. `pnpm check:release` includes both. + +| Gate | Command | Baseline | Checks | +| ------- | ------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Build | `pnpm test:performance:build` | `performance/architecture-baseline.json` | Per-route bytes, request counts, source-owner sets; sources a route must not load eagerly; direct-importer budgets for barrels and hubs | +| Browser | `pnpm test:performance:browser` | `performance/browser-baseline.json` | Each route in Chromium against the mock backend: script requests, source-owner sets, required widgets, timing samples | + +## Byte budgets + +Baseline byte budgets record measurements only. Each byte ceiling is the lower of `max(1%, 4 KB)` over the **higher of base reference and committed measurement**, and `max(10%, 32 KB)` over the committed measurement (`deriveLimit` in `scripts/performance-budgets.mjs`). + +- Re-recording a higher baseline on purpose accepts reviewed growth (e.g. a dependency upgrade) in the same PR. Growth past the allowance still fails. +- Earlier higher measurements stay accepted until a downward re-record locks in the savings. The committed hard ceiling still bounds cumulative growth on the base branch. +- Re-record: `pnpm test:performance:build:update-baseline` and `pnpm test:performance:browser:update-baseline`. Put the measured cost or savings in review. + +Reference modes: + +- PRs into `main` and merge groups targeting `main`: CI restores `main`'s own measurements from the Actions cache (`WEBV2_PERF_REFERENCE_DIR`). Logs and reports name the restored reference. +- Local runs, pushes to `main`, stacked PRs without a reference: compare against committed measurements directly. +- Every mode pins source-owner sets exactly: a module entering a route's initial graph fails at zero bytes. +- Request counts have no allowance; cap is the lower of reference and committed counts. +- A byte rebaseline never relaxes request counts, source ownership, inactive-widget loading, or other structural checks. + +## Running + +- `check:release` stops at the first failing gate; later gates did not run. In CI logs the failure sits at the tail, after passing test summaries. +- Browser gate measures timing and binds ports 4176 and 4177 (`INVOKEAI_PERFORMANCE_PORT`, `INVOKEAI_PERFORMANCE_BACKEND_PORT`). One performance gate per machine, nothing else building or testing. +- One failing sample on a busy machine can be noise, "did not request required widgets" included. Rerun once on a quiet machine. Fails again: regression. + +## Fixing failures + +Never raise a budget or re-record a baseline to make a change pass. Re-record only intended, reviewed growth. Then check the baseline diff adds only intended sources and leaves request counts unchanged. + +### ` has N direct importers of (budget M)` + +New file imports the counted barrel or hub. Import the subpath or owning module instead: `@platform/ui/Button`, not `@platform/ui`. These budgets only go down. + +### `… eagerly includes editor runtime …`, `launchpad eagerly includes …`, `editor eagerly includes …` + +An eager module now reaches lazy code. Usual causes: + +- Module-scope expression reading a global (`Number.EPSILON * 100`) makes its module side-effectful. Every `export *` barrel through it stops tree-shaking. Move the expression into the function. +- Importing a value from a heavy module pulls in that module's graph. Move the value to a leaf module. +- A module the Launchpad palette reaches was added to `EDITOR_BOOT_SHARED_MODULES`. + +### Request count grew, `initial source-owner graph changed`, or `script source-owner set changed` + +A module shared by eager and lazy code got its own chunk, or one owner imports it lazily while another imports it statically. Group it in `vite.config.mts` (`ROUTE_SHARED_MODULES` for eager modules both routes share, `EDITOR_BOOT_SHARED_MODULES` for modules the editor shell and lazy widgets share, or the matching feature group), or fold it into the module already split. Prefer extending an existing module over adding a leaf file. Module legitimately becomes eager: re-record both baselines. + +### Route bytes grew + +Locale strings count: `en.json` is part of each route's `otherAssetRawBytes`. New strings need a reviewed re-record. + +### Finding a new startup request + +Build `main` in a separate worktree; compare hash-stripped `dist/assets/*.js` names with the branch build. Names only in the branch build that also appear in the route trace (`artifacts/architecture-performance/traces/-.zip`, `trace.network` URLs) are the extra requests. `dist/.vite/chunk-sources.json` maps chunks to sources; `vite build --minify false` keeps `//#region` module markers. + +## Remediation tickets + +Failures name an owner and a remediation ticket: structural work that would retire a budget. Context for the budget, not the fix for a failing change. + +| Ticket | Budgets | +| -------------------------------- | ----------------------------------------------------------------------------- | +| `set-performance-budgets` | Launchpad route budgets | +| `deepen-widget-registry-loading` | Editor route budgets and the inactive-widget check | +| `return-contracts-to-owners` | Importers of the `@platform/ui` barrel and the retired `@workbench/types` hub | +| `seal-canvas-engine-seam` | Importers of `@workbench/canvas-engine/contracts` | +| `finish-queue-ownership` | Importers of `@features/queue` | +| `define-gallery-feature` | Importers of `./GalleryWidgetView` | diff --git a/invokeai/frontend/webv2/scripts/check-architecture-performance.mjs b/invokeai/frontend/webv2/scripts/check-architecture-performance.mjs index 06c7af5d624..c09c19cf9c2 100644 --- a/invokeai/frontend/webv2/scripts/check-architecture-performance.mjs +++ b/invokeai/frontend/webv2/scripts/check-architecture-performance.mjs @@ -11,9 +11,11 @@ import { BUILD_REFERENCE_FILE, checkRouteBudget, createBuildReference, + IMPORTER_REMEDY, isBudgetFailure, loadPerformanceReference, measureRouteBuild, + PERFORMANCE_GUIDE, PERFORMANCE_REFERENCE_DIR_VARIABLE, PERFORMANCE_REFERENCE_LABEL_VARIABLE, validateArchitectureBaseline, @@ -132,7 +134,14 @@ for (const source of [ ]) { const overlay = measureRouteBuild(manifest, chunkSourceManifest, source, source, (file) => assetCache.get(file)); for (const owner of ['src/app/WorkbenchApp.tsx', 'src/workbench/widget-frame/WidgetRenderer.tsx']) { - assert.ok(!overlay.sourceOwners.includes(`source:${owner}`), `${source} eagerly includes editor runtime ${owner}.`); + if (overlay.sourceOwners.includes(`source:${owner}`)) { + failures.push({ + message: `${source} eagerly includes editor runtime ${owner}.`, + owner: 'workbench', + remediationTicket: 'deepen-widget-registry-loading', + routeId: 'launchpad', + }); + } } } const widgetImplementationSources = [...WIDGET_SOURCES.keys()]; @@ -225,7 +234,7 @@ try { const actual = importerCounts.get(budget.specifier) ?? 0; if (actual > budget.maxDirectImporters) { failures.push({ - message: `${metricId} has ${actual} direct importers (budget ${budget.maxDirectImporters}).`, + message: `${metricId} has ${actual} direct importers of ${budget.specifier} (budget ${budget.maxDirectImporters}).`, owner: budget.owner, remediationTicket: budget.remediationTicket, routeId: 'development-invalidation', @@ -267,6 +276,10 @@ try { ) .join('\n') .concat(failures.some((failure) => isBudgetFailure(failure.message)) ? `\n${BUDGET_REMEDY}` : '') + .concat( + failures.some((failure) => failure.routeId === 'development-invalidation') ? `\n${IMPORTER_REMEDY}` : '' + ) + .concat(`\n${PERFORMANCE_GUIDE}`) ); } diff --git a/invokeai/frontend/webv2/scripts/dev-mock.mjs b/invokeai/frontend/webv2/scripts/dev-mock.mjs new file mode 100644 index 00000000000..1702d1312eb --- /dev/null +++ b/invokeai/frontend/webv2/scripts/dev-mock.mjs @@ -0,0 +1,36 @@ +/** + * Serve the dev app against the in-repo mock backend. The mock takes a free port unless INVOKEAI_MOCK_BACKEND_PORT is + * set, and INVOKEAI_MOCK_BACKEND_PROFILE picks its fixtures. Remaining arguments go to `vite dev`, for example + * `pnpm dev:mock --port 5273 --strictPort`. + */ + +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { constants } from 'node:os'; +import { dirname, join } from 'node:path'; +import process from 'node:process'; + +import { startMockBackend } from './mock-backend.mjs'; + +// Resolve via vite/package.json because the exports map hides bin/vite.js. +const viteBin = join(dirname(createRequire(import.meta.url).resolve('vite/package.json')), 'bin', 'vite.js'); + +const profile = process.env.INVOKEAI_MOCK_BACKEND_PROFILE ?? 'representative'; +const backend = await startMockBackend(Number(process.env.INVOKEAI_MOCK_BACKEND_PORT ?? 0), { profile }); + +process.stdout.write(`Mock backend (${profile}) at ${backend.origin}\n`); + +const vite = spawn(process.execPath, [viteBin, 'dev', ...process.argv.slice(2)], { + env: { ...process.env, INVOKEAI_DEV_BACKEND: backend.origin }, + stdio: 'inherit', +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => vite.kill(signal)); +} + +vite.on('exit', async (code, signal) => { + await backend.close(); + // Report a signal death the way a shell would, so scripted callers see it. + process.exitCode = code ?? 128 + (constants.signals[signal] ?? 0); +}); diff --git a/invokeai/frontend/webv2/scripts/measure-architecture-performance.mjs b/invokeai/frontend/webv2/scripts/measure-architecture-performance.mjs index 66578ff6f5c..3e519526dd3 100644 --- a/invokeai/frontend/webv2/scripts/measure-architecture-performance.mjs +++ b/invokeai/frontend/webv2/scripts/measure-architecture-performance.mjs @@ -14,6 +14,7 @@ import { isBudgetFailure, loadPerformanceReference, createBrowserSamplePlan, + PERFORMANCE_GUIDE, summarizeBrowserResources, validateBrowserBaseline, validateChunkSourceManifest, @@ -870,7 +871,7 @@ try { ); } catch (error) { throw new Error( - `${error instanceof Error ? error.message : String(error)}${previewError ? `\n${previewError}` : ''}` + `${error instanceof Error ? error.message : String(error)}${previewError ? `\n${previewError}` : ''}\n${PERFORMANCE_GUIDE}` ); } finally { await browser?.close(); diff --git a/invokeai/frontend/webv2/scripts/performance-budgets.mjs b/invokeai/frontend/webv2/scripts/performance-budgets.mjs index 3d9e259edde..9f620313c40 100644 --- a/invokeai/frontend/webv2/scripts/performance-budgets.mjs +++ b/invokeai/frontend/webv2/scripts/performance-budgets.mjs @@ -204,6 +204,12 @@ export const BUDGET_REMEDY = '`pnpm run test:performance:browser:update-baseline`. Re-record reductions too, to protect the savings. ' + 'Request counts have no allowance: their limit remains the lower of the reference and committed counts.'; +export const IMPORTER_REMEDY = + 'Import a subpath or the owning module instead of the counted specifier, for example `@platform/ui/Button` ' + + 'rather than `@platform/ui`. Importer budgets only go down; do not raise them.'; + +export const PERFORMANCE_GUIDE = 'Causes and fixes: invokeai/frontend/webv2/performance/README.md.'; + const projectMetrics = (source, keys) => Object.fromEntries(keys.map((key) => [key, source[key]])); export const createBuildReference = (measurements) => ({ diff --git a/invokeai/frontend/webv2/src/features/fonts/README.md b/invokeai/frontend/webv2/src/features/fonts/README.md new file mode 100644 index 00000000000..19a45e0f417 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/fonts/README.md @@ -0,0 +1,7 @@ +# Fonts + +Paths are relative to the webv2 `src/` directory. + +Fonts owns its authenticated catalog, managed-library UI, and browser font-registration lifetime behind `@features/fonts`. App composes session runtime; Canvas gets a font-resolution port and owns text geometry, rendering, and output readiness. Persisted text references and project-file remapping stay in Workbench. Launchpad font previews and Canvas share the same account lifetime; font binaries and browser registration names are not project state. Browser registry admits at most 64 faces and 128 MiB of font input data, including faces being parsed. Active references are protected from eviction; if they exhaust budget, further loads fail until references are released. This bounds retained input, not browser-specific decoded font memory. Canvas reconciles active references only when document forests or text draft source change. + +Font runtime composition and project-file option state ride existing shared route chunk. Catalog transport, Fonts page, and project-file option dialogs load on demand. Missing-font recovery loads only for documents with custom references. Locale asset plugin serves font-library strings as a separate namespace in development and production, loaded when library or recovery UI opens; source translations stay in canonical locale files. Text tool controls load when selected; their shared Canvas/Layer form and interaction dependencies stay grouped to avoid extra widget-activation requests. Catalog query keys have a lightweight public entry so invalidating an imported font does not pull transport into startup. diff --git a/invokeai/frontend/webv2/src/features/gallery/README.md b/invokeai/frontend/webv2/src/features/gallery/README.md new file mode 100644 index 00000000000..65eaa4011ad --- /dev/null +++ b/invokeai/frontend/webv2/src/features/gallery/README.md @@ -0,0 +1,5 @@ +# Gallery listings + +Paths are relative to the webv2 `src/` directory. + +Gallery ordinary listings are Query-owned sparse pages shared by grid, picker, Preview, and deep-reveal flows. Page key = account lifetime + canonical listing filters + aligned absolute offset; each result keeps its authoritative total and absolute item positions so hydrated gaps never compact. Consumers own separate visible ranges and scroll anchors. Cold numbered page after page zero first requests count-only metadata, then reads only its clamped 60-item page; virtual scrolling discovers count from its first item page. Recent local completions use a temporary overlay, with listing-scoped membership evidence bounded by recent-item limit so an authoritative item does not reappear in overlay when its page is evicted. Query layer retains at most ten inactive idle pages per listing while protecting observed pages, and schedules at most four distinct page reads per QueryClient. Deep reveal resolves an item's filtered listing rank, reads its containing page, verifies item at returned absolute position, and retries that rank/page check at most once if offsets shifted. Persisted Preview selection keeps listing identity from when item was selected; changing active Gallery filters does not rewrite that cursor. Preview re-resolves a stale item page after listing mutations; its async navigation is fenced by captured account epoch so a late page cannot stamp selection into a newer account lifetime. diff --git a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardsPanel.browser.test.tsx b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardsPanel.browser.test.tsx index f38f3c0e8bc..27169afb536 100644 --- a/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardsPanel.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/gallery/ui/GalleryBoardsPanel.browser.test.tsx @@ -4,6 +4,7 @@ import type { GalleryBoard } from '@features/gallery/core/types'; import { ChakraProvider } from '@chakra-ui/react'; import { DndContext, KeyboardSensor, MouseSensor, pointerWithin, useSensor, useSensors } from '@dnd-kit/core'; import { DEFAULT_GALLERY_SETTINGS, type GallerySettings } from '@features/gallery/core/settings'; +import { dragWithMouse } from '@platform/browser/mouseDrag.testing'; import { system } from '@theme/system'; import { act, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; @@ -393,44 +394,17 @@ describe('GalleryBoardsPanel', () => { [...(host?.querySelectorAll('[data-part="trigger"], [role="heading"]') ?? [])].find((element) => element.textContent?.startsWith(label) )!; - const settle = () => - new Promise((resolve) => { - globalThis.setTimeout(resolve, 30); - }); - /** - * A pointer that travels, as a hand does: the tiers start taking boards once the drag has begun, and are measured - * on the moves after it. Outside act, as in the shell: dnd-kit settles the drop in effects act would hold back. - */ - const drag = async (source: HTMLElement, target: HTMLElement) => { - const center = (element: HTMLElement) => { - const rect = element.getBoundingClientRect(); - - return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; - }; - const from = center(source); - const to = center(target); - const mouse = (type: string, at: { x: number; y: number }, element: EventTarget = document) => - element.dispatchEvent(new MouseEvent(type, { bubbles: true, button: 0, clientX: at.x, clientY: at.y })); - - mouse('mousedown', from, source); - for (const progress of [0.25, 0.5, 0.75, 1, 1]) { - mouse('mousemove', { x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress }); - await settle(); - } - mouse('mouseup', to); - await settle(); - }; it('moves it to the project or Library it is dropped on, anywhere on that tier', async () => { await renderPanel(createGallery({ showOtherProjectBoards: true }), { withDrag: true }); - await drag(row('dogs'), heading('Harbor Tower')); + await dragWithMouse(row('dogs'), heading('Harbor Tower')); expect(actions.moveBoard).toHaveBeenLastCalledWith('dogs', 'p2', 'Harbor Tower'); - await drag(row('Stripes'), row('Façades')); + await dragWithMouse(row('Stripes'), row('Façades')); expect(actions.moveBoard).toHaveBeenLastCalledWith('theirs-member', 'p1', 'Mahogany House'); - await drag(row('Façades'), heading('Library')); + await dragWithMouse(row('Façades'), heading('Library')); expect(actions.moveBoard).toHaveBeenLastCalledWith('mine-member', null, 'Library'); expect(actions.moveBoard).toHaveBeenCalledTimes(3); }); @@ -438,10 +412,10 @@ describe('GalleryBoardsPanel', () => { it('moves nothing dropped on its own tier, and never drags an inbox, which moves with its project', async () => { await renderPanel(createGallery({ showOtherProjectBoards: true }), { withDrag: true }); - await drag(row('dogs'), row('Cats')); - await drag(row('Inbox'), heading('Library')); + await dragWithMouse(row('dogs'), row('Cats')); + await dragWithMouse(row('Inbox'), heading('Library')); // Pressing the row's menu button and moving away is no drag either. - await drag(row('Façades').querySelector('.board-row-actions')!, heading('Library')); + await dragWithMouse(row('Façades').querySelector('.board-row-actions')!, heading('Library')); expect(actions.moveBoard).not.toHaveBeenCalled(); }); @@ -462,19 +436,19 @@ describe('GalleryBoardsPanel', () => { { currentUserId: 'me', withDrag: true } ); - await drag(row('Cats'), heading('Mahogany House')); - await drag(row('dogs'), heading('Mahogany House')); - await drag(row('Façades'), heading('Harbor Tower')); + await dragWithMouse(row('Cats'), heading('Mahogany House')); + await dragWithMouse(row('dogs'), heading('Mahogany House')); + await dragWithMouse(row('Façades'), heading('Harbor Tower')); expect(actions.moveBoard).not.toHaveBeenCalled(); - await drag(row('Façades'), heading('Library')); + await dragWithMouse(row('Façades'), heading('Library')); expect(actions.moveBoard).toHaveBeenCalledExactlyOnceWith('mine-member', null, 'Library'); }); it('takes a drop on a collapsed tier, by its heading', async () => { await renderPanel(createGallery({ collapsedBoardSections: ['library'] }), { withDrag: true }); - await drag(row('Façades'), heading('Library')); + await dragWithMouse(row('Façades'), heading('Library')); expect(actions.moveBoard).toHaveBeenCalledExactlyOnceWith('mine-member', null, 'Library'); }); diff --git a/invokeai/frontend/webv2/src/features/identity/README.md b/invokeai/frontend/webv2/src/features/identity/README.md new file mode 100644 index 00000000000..9a68e6c31e8 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/identity/README.md @@ -0,0 +1,13 @@ +# Identity session + +Paths are relative to the webv2 `src/` directory. + +App and Platform transport hand credential to Identity per [ARCHITECTURE.md](../../../ARCHITECTURE.md). + +- Identity holds tab's bearer token in memory, bound to account lifetime it authenticates; once that lifetime ends no request can carry it. Each HTTP request captures token and lifetime together, and reports a returned `X-Refreshed-Token` (sliding renewal, or replacement after caller's own password change) and any 401 against that capture. Identity accepts a replacement only while capture's lifetime and token are still current, and a 401 only for current token, so a late response can neither renew nor expire a newer credential. +- Renewals replace token in place: no new lifetime, remount or cleanup. Media cookie re-issued at most every five minutes, since renewals do not extend it. +- Own password change runs as a credential rotation scoped to lifetime that queued it. Changes run one at a time; one still queued when its lifetime ends is rejected unsent, not dispatched with next account's credential. Until a rotation settles, other replacements are ignored, and a 401 for current token or another tab's renewal waits for its outcome: a delivered replacement supersedes them, a failed change lets them apply. Another tab's sign-out applies at once and discards replacement, because storage cannot tell a deliberate sign-out from any other and a deliberate one must end every tab. +- Rotation is announced in `localStorage` for its principal while in flight, each under its own key so concurrent changes from several tabs never overwrite one another. Withdrawn after replacement is stored, and when announcing tab leaves or ends its lifetime. Announcing also clears markers older than the wait and the single marker earlier builds wrote, whose announcements are not seen during a rolling upgrade. The announcement lets a tab whose token the change revokes hold the sign-out that 401 would cause (request itself still fails to its caller) until replacement is stored and adopted. Includes a tab whose own concurrent change the other change revoked. Session expires if every announcement of that principal is withdrawn without a replacement, or 30 seconds after latest one still announced if its tab never settles. A tab restoring or following a token rejected under an announced rotation waits the same way, then continues from whatever storage holds. +- Sign-in attempt leaves stored token alone until it succeeds. +- Socket authenticates every connect attempt with current token; reconnects after server closes a session whose token was replaced. +- `localStorage` persists token across reloads and carries other tabs' changes. Read when a session resolves and on reconciliation (`storage` events, back/forward-cache restores, returning to view), never per request. Another tab's renewal for the same principal (judged from token's `user_id` claim) is adopted in place; a removal signs this tab out; any other change ends current lifetime first, then resolves and activates new principal like a restored session, latest stored value winning. Rejected token signs tab out as expired; unreachable backend leaves it signed out with token stored and unobserved, so next reconciliation retries. Writes this tab made itself are not read back as changes; blocked storage leaves sign-in working for tab's lifetime. diff --git a/invokeai/frontend/webv2/src/features/intermediates/README.md b/invokeai/frontend/webv2/src/features/intermediates/README.md new file mode 100644 index 00000000000..38d88d2bbf8 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/intermediates/README.md @@ -0,0 +1,9 @@ +# Intermediates + +Paths are relative to the webv2 `src/` directory. + +Intermediates owns manager for temporary media behind `@features/intermediates`: transport mapping, account-fenced query keys, selection core, confirmation dialog, operation panel. It is a Settings section, contributed as metadata via `settingsContribution` so dialog's catalog never loads manager or its transport until section opens. Eligibility policy (safe, referenced, active, recent) is server-owned; manager requests a server preview, which fixes the instant recency is judged at and describes what a cleanup would delete and keep, and confirms it once. "All matching" selections travel as a `matching` scope carrying summary's filters and excluded rows, which server resolves; client never enumerates pages it has not loaded. Server does not persist operations: a manager that opens with nothing followed lists account's operations and follows newest unfinished one, so a reload picks a running cleanup back up; a settled operation that left failures behind offers to delete its scope again. + +Client's one input to policy is a browser hold lease: `holdLease` (public entry, started by Workbench for provider's lifetime) keeps media open editors still need (live content from every open project plus undo state each live Canvas engine retains) classified as active. Workbench owns name set: `workbench/projects/projectAssets` scans each immutable project subtree once, and each Canvas engine registers its retained undo state with mounted Workbench, which re-reads it when history changes; an engine released by one Workbench is replaced, not reused, by the next, and one another Workbench still uses is refused. One lease per tab, holding up to server's per-kind limit of sorted unique names, which server replaces atomically. Lease skips unchanged sets without re-serialising them; forgets a send the server did not acknowledge so next change, heartbeat or return to foreground sends again while previous lease still stands; refreshes every five minutes and when a tab becomes visible after missing a refresh; releases with `keepalive` on dispose and `pagehide`. Server keeps at most eight leases per account and expires a lease after 15 minutes, bounding anything a crash leaves behind. + +Realtime updates attach for section's mounted lifetime through Platform socket hub and invalidate account's summaries when an operation settles (by socket or polled record) or when socket reconnects. Workbench supplies entry points (Data & workspace link, project actions menu, Users page callback) and hands manager a one-shot focus intent (a project to preselect or an account to filter by), not route state. diff --git a/invokeai/frontend/webv2/src/features/queue/README.md b/invokeai/frontend/webv2/src/features/queue/README.md new file mode 100644 index 00000000000..a2273512f05 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/queue/README.md @@ -0,0 +1,7 @@ +# Queue + +Paths are relative to the webv2 `src/` directory. + +Queue's private `QueueBackendPort` is adapter seam; public callers use domain reads, commands, and production runtime factories from `@features/queue`. Private snake_case HTTP DTOs map explicitly to camelCase Queue read models before crossing that interface. TanStack Query owns global backend queue read model (reads and hydrates only a bounded recent window) and a status-only read for count summaries such as Home; non-React runtimes own reconciliation and realtime orchestration. Persisted per-project queue history stays in Workbench aggregate. + +Queue exposes board-independent active backend sessions, including preparation and result settlement. Gallery progress section includes every unfinished batch slot, queued work too; Preview follows executing and settling sessions. Both use gallery-destination projections of active project's queue; eligibility is presentation policy, separate from discovery. Workbench owns transient overview/pinned follow selection for its mounted lifetime, clears pins on project changes or when following stops, and never persists pins or preview frames. Live sessions take no part in board counts, saved-image selection, or navigation. Saved outputs keep their actual backend board destinations; automatic result selection does not change browsed board or its paging window. diff --git a/invokeai/frontend/webv2/src/platform/browser/mouseDrag.testing.ts b/invokeai/frontend/webv2/src/platform/browser/mouseDrag.testing.ts new file mode 100644 index 00000000000..3d613a33646 --- /dev/null +++ b/invokeai/frontend/webv2/src/platform/browser/mouseDrag.testing.ts @@ -0,0 +1,35 @@ +/** + * Drag between element centers the way a hand moves a mouse, for dnd-kit mouse sensors. Droppables that enable only + * once a drag is active are measured on later moves, so a single-jump drag (`userEvent.dragAndDrop`) ends over + * nothing. Call outside act: dnd-kit settles the drop in effects that act would hold back. + */ + +// The pointer arrives twice so the target, measured after the first arrival, sees a move over it. +const PROGRESS = [0.25, 0.5, 0.75, 1, 1]; +const STEP_DELAY_MS = 30; + +const center = (element: Element) => { + const rect = element.getBoundingClientRect(); + + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; +}; + +const wait = () => + new Promise((resolve) => { + globalThis.setTimeout(resolve, STEP_DELAY_MS); + }); + +export const dragWithMouse = async (source: Element, target: Element): Promise => { + const from = center(source); + const to = center(target); + const mouse = (type: string, x: number, y: number, element: EventTarget = source.ownerDocument) => + element.dispatchEvent(new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: y })); + + mouse('mousedown', from.x, from.y, source); + for (const progress of PROGRESS) { + mouse('mousemove', from.x + (to.x - from.x) * progress, from.y + (to.y - from.y) * progress); + await wait(); + } + mouse('mouseup', to.x, to.y); + await wait(); +}; diff --git a/invokeai/frontend/webv2/src/platform/logging/README.md b/invokeai/frontend/webv2/src/platform/logging/README.md new file mode 100644 index 00000000000..5d4568a84b8 --- /dev/null +++ b/invokeai/frontend/webv2/src/platform/logging/README.md @@ -0,0 +1,5 @@ +# Logging + +Paths are relative to the webv2 `src/` directory. + +Platform Logging is the one recorder for application and project events. `createLogger` binds a source (namespace, area, optional project, widget and operation identity); a runtime passes account scope it captured so writes after that lifetime are discarded; module-level loggers stay unbound. Entries normalized once at ingestion into immutable JSON-safe snapshots: errors keep name, message, stack, cause and status/code; credential-like keys redacted; URL queries and fragments stripped; depth, collection, string and 8 KiB entry ceilings marked explicitly. History is account-wide, memory-only, in three bounded buffers (warn and above, lower severities, timings) so verbose traffic cannot evict problems; closed projects keep entries until eviction or account cleanup. Publication coalesced per microtask; clears and account transitions publish synchronously and restore default capture settings. Workbench owns preference-to-config mapping and diagnostics panel; App applies saved preferences before React mounts, installs uncaught-error and router catch reporting, and keeps config in step with active account. Each handled terminal failure has one reporting owner: Workbench store records reported errors, widget failures, autosave outcomes and queue item transitions; persistence runtime records an exit checkpoint that did not save, since it outlives editor; transport and coordinator events are debug breadcrumbs. diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/AGENTS.md b/invokeai/frontend/webv2/src/workbench/canvas-engine/AGENTS.md new file mode 100644 index 00000000000..5fd53d338ca --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/AGENTS.md @@ -0,0 +1,3 @@ +# Canvas engine + +Read [ARCHITECTURE.md](ARCHITECTURE.md) before changing the engine, its capabilities, raster memory, history, or staged-result acceptance; its "Adding functionality" section gives the pattern for each kind of addition. Production callers use `api.ts`. Render goldens follow [render/golden/README.md](render/golden/README.md). diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md index c0d644b0af3..68ec69a8796 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md @@ -1,10 +1,12 @@ # Canvas Engine Architecture +Paths are relative to `src/workbench/canvas-engine/`, except those starting with `widgets/` or `canvas-operations/` (relative to `src/workbench/`) and `src/`, `scripts/`, or `vitest.*` files (relative to the webv2 package). + ## Ownership invariant -The workbench reducer owns the serializable canvas contract. A per-project canvas engine owns pixels, decoded images, rendering, interaction, history, transient editing state, and caller-owned raster snapshots. Engine-owned objects never enter reducer actions or persisted project state. +Workbench reducer owns serializable canvas contract. Per-project canvas engine owns pixels, decoded images, rendering, interaction, history, transient editing state, caller-owned raster snapshots. Engine-owned objects never enter reducer actions or persisted project state. -Canvas mutations are explicitly project-addressed. `CanvasProjectMutation` is the complete reducer mutation vocabulary, and the only `WorkbenchAction` that carries one is: +Canvas mutations are explicitly project-addressed. `CanvasProjectMutation` is the complete reducer mutation vocabulary; the only `WorkbenchAction` carrying one is: ```ts { @@ -15,106 +17,106 @@ Canvas mutations are explicitly project-addressed. `CanvasProjectMutation` is th } ``` -The workbench reducer handles this envelope only through `updateProjectById`. There are no canvas actions whose target is inferred from the active project. +Reducer handles this envelope only through `updateProjectById`. No canvas action infers its target from active project. -Each engine receives a `CanvasProjectMutationPort` bound to its immutable `projectId`. The port exposes `getCanvasState()`, `subscribe()`, `dispatch(mutation, origin?)`, and `commitEdit(intent)`. It neither exposes the global workbench dispatcher nor follows active-project changes. Widgets use `useCanvasProjectMutationDispatch`, which captures the current project ID and emits the same envelope. Consequently, project switches and colliding layer IDs cannot redirect a delayed engine mutation to another project. +Each engine gets a `CanvasProjectMutationPort` bound to its immutable `projectId`. Port exposes `getCanvasState()`, `subscribe()`, `dispatch(mutation, origin?)`, `commitEdit(intent)`. It neither exposes global workbench dispatcher nor follows active-project changes. Widgets use `useCanvasProjectMutationDispatch`, which captures current project ID and emits same envelope. So project switches and colliding layer IDs cannot redirect a delayed engine mutation to another project. -The port's `dispatch()` returns whether the named project's canvas identity changed. User-origin mutations may auto-route the invocation controller; history replay, synchronization, rollback, and bitmap persistence dispatch as `system`. Prepared transactions dispatch their document mutation as `system`, verify reducer and mirror postconditions, then call `commitEdit()` exactly once for a successful user edit. Paint calls `commitEdit({ kind: 'paint' })` synchronously at stroke commit, so a delayed persistence upload can never steal a later manual route. +Port `dispatch()` returns whether named project's canvas identity changed. User-origin mutations may auto-route invocation controller; history replay, synchronization, rollback, bitmap persistence dispatch as `system`. Prepared transactions dispatch their document mutation as `system`, verify reducer and mirror postconditions, then call `commitEdit()` exactly once for a successful user edit. Paint calls `commitEdit({ kind: 'paint' })` synchronously at stroke commit, so delayed persistence upload can never steal a later manual route. -Paint persistence clears a dirty result only after the intended layer accepts the bitmap reference. A rejected update retains dirty pixels for retry; deletion of the layer or project is terminal and discards obsolete persistence work. This applies equally to raster/control paint sources and paint-backed masks. +Paint persistence clears dirty result only after intended layer accepts bitmap reference. Rejected update keeps dirty pixels for retry; layer or project deletion is terminal and discards obsolete persistence work. Rule applies equally to raster/control paint sources and paint-backed masks. ## Document contract and hierarchy -`CanvasDocumentContractV3` holds four stack forests, `document.stacks.{raster, control, regional_guidance, inpaint_mask}`. Each forest is a top-first tree of leaves (the unchanged v2 leaf contracts) and pass-through groups (`type: 'group'` with `id`, `name`, `isEnabled`, `isLocked`, optional overlay-only `isHidden`, and `children`). A node belongs to exactly one stack, ids are unique across the document, groups nest at most ten levels deep, and a document holds at most 10,000 nodes. `selectedLayerId` may name a leaf or a group; leaf-only tools refuse a group rather than guessing a descendant. Groups carry no opacity, blend mode, transform, or raster surface: they only organise leaves and gate their effective state. Older document versions are refused by the loader; nothing migrates them. +`CanvasDocumentContractV3` holds four stack forests, `document.stacks.{raster, control, regional_guidance, inpaint_mask}`. Each forest: top-first tree of leaves (unchanged v2 leaf contracts) and pass-through groups (`type: 'group'` with `id`, `name`, `isEnabled`, `isLocked`, optional overlay-only `isHidden`, `children`). Node belongs to exactly one stack, ids unique across document, groups nest at most ten levels deep, document holds at most 10,000 nodes. `selectedLayerId` may name leaf or group; leaf-only tools refuse a group rather than guess a descendant. Groups carry no opacity, blend mode, transform, or raster surface: they only organise leaves and gate their effective state. Loader refuses older document versions; nothing migrates them. -`document/documentIndex.ts` builds one index per `stacks` identity (parent, path, sibling index, document order, and ancestor-effective enabled/locked/hidden per node). Selection and bbox edits keep `stacks` and cost no index. A value edit (transform, opacity, blend, name, source, config, or a flag) goes through `updateNodeValues`, which finds each edited node through the index and copies only the sibling arrays on its path (cost: the sibling counts along the edited paths, never the forest), so `stacks` changes; it then registers a derived index for the new forests that extends the previous one, and records the edit's replaced nodes (before and after; ancestors rebuilt only for a new child array are not listed) against the new forests: `byId` is a layered map that reads through to the previous index for every untouched id, `nodes` and `leaves` materialize only when a consumer reads them (and `leaves` keeps its identity when no leaf object changed), and the only entries constructed are the edited path plus the subtree under a group whose flags changed. After eight derivations the chain flattens onto its plain root: one override layer holding the newest entry of every id the chain touched, still pointing consumers at the root's cached compile so the flattening step recompiles only what the chain changed; only when overrides cover more than a quarter of the root is a plain index rebuilt. Lookups therefore read through at most nine layers, and nothing older than the root is retained. `documentModel.budget.test.ts` pins this at 10,000 nodes: a leaf edit revisits and constructs `depth + 1` entries, compiles `depth + 1` semantic nodes (ancestors keep their counts without walking their subtrees) and one leaf, and answers `lookupDocumentLeaf`/`lookupDocumentNodeState` without materializing the node list; a complete reducer → mirror value edit does the same with no forest diff. +`document/documentIndex.ts` builds one index per `stacks` identity (parent, path, sibling index, document order, ancestor-effective enabled/locked/hidden per node). Selection and bbox edits keep `stacks` and cost no index. Value edit (transform, opacity, blend, name, source, config, or a flag) goes through `updateNodeValues`, which finds each edited node via index and copies only sibling arrays on its path (cost: sibling counts along edited paths, never the forest), so `stacks` changes; it then registers a derived index for new forests extending previous one, and records the edit's replaced nodes (before and after; ancestors rebuilt only for a new child array are not listed) against new forests: `byId` is a layered map reading through to previous index for every untouched id, `nodes` and `leaves` materialize only when a consumer reads them (`leaves` keeps identity when no leaf object changed), and only entries constructed are edited path plus subtree under a group whose flags changed. After eight derivations chain flattens onto its plain root: one override layer holding newest entry of every id chain touched, still pointing consumers at root's cached compile so flattening recompiles only what chain changed; only when overrides cover more than a quarter of root is a plain index rebuilt. So lookups read through at most nine layers; nothing older than root is retained. `document-model/documentModel.budget.test.ts` pins this at 10,000 nodes: leaf edit revisits and constructs `depth + 1` entries, compiles `depth + 1` semantic nodes (ancestors keep counts without walking subtrees) and one leaf, answers `lookupDocumentLeaf`/`lookupDocumentNodeState` without materializing node list; complete reducer → mirror value edit does same with no forest diff. -`document-model/documentModel.ts` compiles `SemanticLeaf`s and `SemanticNode`s from that index: `contributionEnabled`, `effectiveLocked`, and `documentHidden` fold in every ancestor, and a leaf or node keeps its identity while its contract object, place, and ancestor-effective state are unchanged. Every consumer that decides visibility, contribution, editability, export, or what a panel row shows reads these (or the `isLeaf*` predicates) rather than a node's own flags; `model.refusalFor(command)` is the single eligibility authority for enabling a command before it runs; for `reparent`, `group`, `ungroup` and `remove` it runs the same validator `prepare` uses without materializing an edit (`editsMaterialized` stays at zero), so a drag hover or a toolbar render never builds an inverse or postconditions. Locks are all-or-nothing over subtrees: no command edits, moves, groups, ungroups, or inserts into a subtree that holds a locked node or sits under one, while name, enablement, and lock flips stay open so a locked node can be unlocked. +`document-model/documentModel.ts` compiles `SemanticLeaf`s and `SemanticNode`s from that index: `contributionEnabled`, `effectiveLocked`, `documentHidden` fold in every ancestor; leaf or node keeps identity while its contract object, place, and ancestor-effective state are unchanged. Every consumer deciding visibility, contribution, editability, export, or what a panel row shows reads these (or `isLeaf*` predicates), not node's own flags; `model.refusalFor(command)` is the single eligibility authority for enabling a command before it runs; for `reparent`, `group`, `ungroup`, `remove` it runs same validator `prepare` uses without materializing an edit (`editsMaterialized` stays zero), so drag hover or toolbar render never builds an inverse or postconditions. Locks are all-or-nothing over subtrees: no command edits, moves, groups, ungroups, or inserts into a subtree that holds a locked node or sits under one, while name, enablement, and lock flips stay open so a locked node can be unlocked. -Structural edits are prepared by the model (`insert`, `remove`, `duplicate`, `move`, `reorder`, `reparent`, `group`, `ungroup`, `patch*`, `set-*`, `translate`, `select`) and committed through `commitPrepared`. Restructuring lands as one `applyCanvasLayerStackMutation` that the reducer applies as `add`, then `move`, then `removeIds`, then flag and selection updates, refusing the whole mutation when any step is invalid or the result exceeds the limits. Insertion anchors are exact: before a surviving sibling under its current parent, after a surviving sibling, the top of the captured parent, the nearest surviving group on the captured path, then the stack top. Inverses restore removed or moved nodes as maximal sibling runs anchored to their unmoved neighbours, so undo reproduces the previous structure exactly. +Model prepares structural edits (`insert`, `remove`, `duplicate`, `move`, `reorder`, `reparent`, `group`, `ungroup`, `patch*`, `set-*`, `translate`, `select`); `commitPrepared` commits them. Restructuring lands as one `applyCanvasLayerStackMutation`; reducer applies it as `add`, then `move`, then `removeIds`, then flag and selection updates, refusing whole mutation when any step is invalid or result exceeds limits. Insertion anchors are exact: before a surviving sibling under its current parent, after a surviving sibling, top of captured parent, nearest surviving group on captured path, then stack top. Inverses restore removed or moved nodes as maximal sibling runs anchored to their unmoved neighbours, so undo reproduces previous structure exactly. -The document mirror reports in leaf terms, in document order. For a value edit it folds the recorded edits since its last document (`valueEditBetween`, at most sixteen; the record holds its predecessor weakly so superseded forests are never retained) and touches only those nodes: an edited leaf is changed, and source-changed when its raster source or mask bitmap changed; a group flag flip reports the descendant leaves whose ancestor-effective state actually moved as changed (never source-changed); a group adjustment, opacity or blend edit sends its descendant leaves down the non-destructive recomposite channel; a group rename reports nothing. Only a structural edit, an unrecorded step or a wholesale replacement diffs the forests by leaf identity and ancestor-effective state, where a reparent that changes no leaf's effective state reports an order change only. `onLayersChanged` says whether the forests were restructured, so the engine prunes group surfaces only then, and invalidates each changed layer once. +Document mirror reports in leaf terms, in document order. For value edit it folds recorded edits since its last document (`valueEditBetween`, at most sixteen; record holds its predecessor weakly so superseded forests are never retained) and touches only those nodes: edited leaf is changed, and source-changed when its raster source or mask bitmap changed; group flag flip reports as changed the descendant leaves whose ancestor-effective state actually moved (never source-changed); group adjustment, opacity or blend edit sends its descendant leaves down non-destructive recomposite channel; group rename reports nothing. Only a structural edit, an unrecorded step or a wholesale replacement diffs forests by leaf identity and ancestor-effective state, where a reparent that changes no leaf's effective state reports an order change only. `onLayersChanged` says whether forests were restructured, so engine prunes group surfaces only then, and invalidates each changed layer once. ### Layers panel -`widgets/layers` consumes the seam and owns nothing about the document. `layerTreeRows.ts` turns `compileDocumentNodes` plus per-project panel state (expansion, a name filter) into `LayerTreeRow`s keyed on the forests, so a selection or bbox change rebuilds no row, and a row keeps its identity while its node, place, effective state, sibling position and expansion are unchanged; `layerPanelRows.ts` flattens the four stacks into one list with a header per non-empty stack. `LayersTree.tsx` virtualizes that list (fixed row heights per density, the focused item always mounted, each slot drawing the row its key names so a list change never relabels a mounted node) and is one WAI-ARIA tree: stack headers are level-one tree items that expand or collapse their stack, rows are tree items beneath them, every control inside an item is pointer-only (`tabIndex={-1}`) and hands focus back to its item when pressed, and the whole tree is a single tab stop with a roving focus that survives the focused item unmounting; the selection toolbar above it is likewise one tab stop with arrow keys between its buttons. Keyboard: arrows, Home/End, Left to the parent or header, Right into a stack or group, Enter/Space to select (with Shift/Ctrl), Alt+arrows to reorder and reparent (carrying the selection when the focused row is part of it, as a drag would), F2 to rename (a rename committed by clicking elsewhere leaves focus where the click put it), Shift+F10 for the row or stack menu, which carries every pointer action including hide-on-canvas. The tree holds the single drag context (pointer-within collision scoped to the stack, deterministic edge auto-scroll, hover-expand, indicator projected in linear time and checked with `model.refusalFor` so a locked, cyclic or too-deep landing shows its refusal inline), the single menu/properties host addressed by node id, and every store subscription and commit; a row receives one view model and one stable command handle, and a primary that changes outside the panel is revealed once: its collapsed stack and groups open and it is scrolled into view, after which the panel's scroll position is its own. Above 2,000 nodes the panel degrades explicitly: thumbnails and drag reordering switch off, every command stays. `layersPanel.budget.test.ts` pins the 2,000-node facts (exact row counts, one node visit per build, row identity across selection and a local expand, linear drop projection) and `LayersTree.browser.test.tsx` pins the mounted-row window, per-row commit counts (`layerPanelDiagnostics`) for selection and scroll, thumbnail requests, drag validation counts, the tab stop, degraded mode on a real 2,001-node document, external reveal, edge auto-scroll, and the keyboard journeys in Chromium; `scripts/run-accessibility-journeys.mjs` runs Axe over the panel on the representative project. +`widgets/layers` consumes the seam and owns nothing about the document. `widgets/layers/layerTreeRows.ts` turns `compileDocumentNodes` plus per-project panel state (expansion, name filter) into `LayerTreeRow`s keyed on forests, so selection or bbox change rebuilds no row, and row keeps identity while its node, place, effective state, sibling position and expansion are unchanged; `widgets/layers/layerPanelRows.ts` flattens four stacks into one list with a header per non-empty stack. `widgets/layers/LayersTree.tsx` virtualizes that list (fixed row heights per density, focused item always mounted, each slot drawing the row its key names so a list change never relabels a mounted node) and is one WAI-ARIA tree: stack headers are level-one tree items that expand or collapse their stack, rows are tree items beneath them, every control inside an item is pointer-only (`tabIndex={-1}`) and hands focus back to its item when pressed, and whole tree is a single tab stop with roving focus that survives focused item unmounting; selection toolbar above it is likewise one tab stop with arrow keys between its buttons. Keyboard: arrows, Home/End, Left to parent or header, Right into a stack or group, Enter/Space to select (with Shift/Ctrl), Alt+arrows to reorder and reparent (carrying the selection when focused row is part of it, as a drag would), F2 to rename (rename committed by clicking elsewhere leaves focus where the click put it), Shift+F10 for row or stack menu, which carries every pointer action including hide-on-canvas. Tree holds the single drag context (pointer-within collision scoped to the stack, deterministic edge auto-scroll, hover-expand, indicator projected in linear time and checked with `model.refusalFor` so a locked, cyclic or too-deep landing shows its refusal inline), the single menu/properties host addressed by node id, and every store subscription and commit; row receives one view model and one stable command handle, and a primary that changes outside the panel is revealed once: its collapsed stack and groups open and it scrolls into view, after which panel's scroll position is its own. Above 2,000 nodes panel degrades explicitly: thumbnails and drag reordering switch off, every command stays. `widgets/layers/layersPanel.budget.test.ts` pins 2,000-node facts (exact row counts, one node visit per build, row identity across selection and a local expand, linear drop projection); `widgets/layers/LayersTree.browser.test.tsx` pins mounted-row window, per-row commit counts (`layerPanelDiagnostics`) for selection and scroll, thumbnail requests, drag validation counts, tab stop, degraded mode on a real 2,001-node document, external reveal, edge auto-scroll, keyboard journeys in Chromium; `scripts/run-accessibility-journeys.mjs` runs Axe over panel on representative project. ### Properties and Transform -Tool and operation settings live off the surface, as editor panes of the Layers panel (`widgets/layers/panes`). The Properties pane renders the running operation first and then the active tool, each as a `ToolPropertyForm` / `OperationPropertyForm` (`widgets/canvas/tool-presentation`): named, collapsible groups of typed rows (an optional preview card on top), with ONE sticky footer pinned to the pane's bottom edge — the running operation's verbs (status chip, Process/Reset, Apply with its save-as menu, Cancel), else the tool form's (the transform session's Apply/Cancel). Group ids are global so tools sharing a group's component keep its DOM and collapse state across the switch. The Transform pane edits the selected layer's transform without a tool — each field commits one undoable `patch`, and while the Transform tool holds a session the same fields drive its preview and Apply / Cancel land or drop it. Forms read the engine's option stores and document transactions directly: no state mirror. Staging and generation lock the tool groups in place (`inert`), the footer honors the same lock itself (it sits outside the sections), and an operation keeps its own controls live with Cancel always reachable. The panes are fixed parts of the panel — `LayerEditorPanes` owns their tabs, preferred height and collapse, persisted through the Layers widget's project state — not movable widgets. The same blocks host the Color/Swatches workspace (the project foreground/background pair), the History pane (the engine's labeled undo/redo stack via `history.getEntries()`/`stepBy`), and the Overview navigator (`previews.drawDocumentOverview` plus the live viewport handle). +Tool and operation settings live off the surface, as editor panes of Layers panel (`widgets/layers/panes`). Properties pane renders running operation first, then active tool, each as `ToolPropertyForm` / `OperationPropertyForm` (`widgets/canvas/tool-presentation`): named, collapsible groups of typed rows (optional preview card on top), with ONE sticky footer pinned to pane's bottom edge — running operation's verbs (status chip, Process/Reset, Apply with its save-as menu, Cancel), else tool form's (transform session's Apply/Cancel). Group ids are global so tools sharing a group's component keep its DOM and collapse state across the switch. Transform pane edits selected layer's transform without a tool — each field commits one undoable `patch`, and while Transform tool holds a session same fields drive its preview and Apply / Cancel land or drop it. Forms read engine's option stores and document transactions directly: no state mirror. Staging and generation lock tool groups in place (`inert`), footer honors same lock itself (it sits outside the sections), and an operation keeps its own controls live with Cancel always reachable. Panes are fixed parts of panel — `LayerEditorPanes` owns their tabs, preferred height and collapse, persisted through Layers widget's project state — not movable widgets. Same blocks host Color/Swatches workspace (project foreground/background pair), History pane (engine's labeled undo/redo stack via `history.getEntries()`/`stepBy`), and Overview navigator (`previews.drawDocumentOverview` plus live viewport handle). ## Package and capability boundaries -`canvas-engine/` is the rendering and editing core. It may import engine-internal modules and shared canvas contracts, mutations, and the project mutation port. It must not import React, widgets, application canvas operations, generation graphs, socket infrastructure, or backend networking. `importBoundaries.test.ts` enforces that rule. +`canvas-engine/` is rendering and editing core. May import engine-internal modules and shared canvas contracts, mutations, and project mutation port. Must not import React, widgets, application canvas operations, generation graphs, socket infrastructure, or backend networking. `canvas-engine-independence` rule in `src/architecture/dependencyPolicy.ts` enforces widget, canvas-operation, and generation boundaries through `pnpm architecture:check`; review must catch React, socket, and networking imports. -`canvas-operations/` owns application workflows such as Filter and Select Object. Coordinators may depend on queues, uploads, model selection, and narrow engine capabilities. Their stores are React-free and compatible with `useSyncExternalStore`; React adapters remain under `widgets/`. +`canvas-operations/` owns application workflows such as Filter and Select Object. Coordinators may depend on queues, uploads, model selection, narrow engine capabilities. Their stores are React-free and `useSyncExternalStore`-compatible; React adapters stay under `widgets/`. -The public engine handle is divided into capability groups such as `CanvasDocumentCapability`, `CanvasExportCapability`, `CanvasLayerCapability`, `CanvasLifecycleCapability`, and the interaction/store capabilities. Production consumers request the smallest aggregate they need. The full `CanvasEngine` type is limited by `fullEngineImports.test.ts` to the registry and composition roots; tests may construct full-engine fakes. The unused `fflate` dependency has been removed. +Public engine handle splits into capability groups such as `CanvasDocumentCapability`, `CanvasExportCapability`, `CanvasLayerCapability`, `CanvasLifecycleCapability`, and interaction/store capabilities. Production consumers request smallest aggregate they need. `canvas-construction-private` rule in `src/architecture/dependencyPolicy.ts` limits full `CanvasEngine` type to `canvas-operations/createCanvasEngine.ts` and `canvas-operations/engineRegistry.ts`; tests may construct full-engine fakes. Unused `fflate` dependency removed. -`CanvasCoreStores` contains interaction state only. Filter and Select Object snapshots are created and owned by `canvas-operations`; application-session state cannot leak into the core store contract. +`CanvasCoreStores` holds interaction state only. `canvas-operations` creates and owns Filter and Select Object snapshots; application-session state cannot leak into core store contract. -Controllers communicate through constructor-injected ports or public capabilities. They do not reach into another controller's private maps, and the core engine never receives the global workbench dispatcher. +Controllers talk through constructor-injected ports or public capabilities. They do not reach into another controller's private maps, and core engine never receives global workbench dispatcher. ## Controllers -`engine.ts` is the core composition root and document-event router. New behavior belongs in a focused controller with explicit dependencies and an idempotent `dispose()` method. +`engine.ts` is core composition root and document-event router. New behavior goes in a focused controller with explicit dependencies and idempotent `dispose()`. Current extracted ownership includes: -- `RasterController`: base layer caches, decoded bitmap leases, derived, adjusted and group surfaces, rasterization jobs, invalidation, residency pins, working-set eviction, and the unified raster-memory controller. +- `RasterController`: base layer caches, decoded bitmap leases, derived, adjusted and group surfaces, rasterization jobs, invalidation, residency pins, working-set eviction, unified raster-memory controller. - `RasterMemoryBudgetController`: base, derived, group, decoded, detached, reserved and overage byte accounting plus generation-scoped reservations and operation-owned reservations and pins. -- `RenderController`: render targets, scheduler construction, previews, and render lifecycle. -- `HistoryController`: engine-owned history, guarded asynchronous replay with failure reporting, and inactive byte trimming. +- `RenderController`: render targets, scheduler construction, previews, render lifecycle. +- `HistoryController`: engine-owned history, guarded async replay with failure reporting, inactive byte trimming. - `PersistenceController`: dirty-bitmap flush barriers and disposal. - `EditingController`: selection state and exclusive edit-lease lifecycle. - `InteractionController`: public tool commands and their disposal boundary. - `LayerController`: guarded layer mutations and thumbnail capability ports. -- `RasterExportController`: guarded layer rasterization, transformed copies, encoding, reservations, and pin leases. -- `PsdExportController`: immutable PSD planning, snapshot capture, reservation, execution, and cancellation. +- `RasterExportController`: guarded layer rasterization, transformed copies, encoding, reservations, pin leases. +- `PsdExportController`: immutable PSD planning, snapshot capture, reservation, execution, cancellation. - `StagedResultController`: guarded staged-candidate acceptance and its history entry. -- `PixelEditController`, `SelectionImageController`, and `LayerMutationController`: atomic pixel/document publication at their respective boundaries. The pixel editor materializes control layers and raster-image layers in place before destructive edits, keeping conversion plus pixels in one undoable transaction. -- `MaskResultController`, `FilterResultController`, and `GeneratedResultController`: guarded application-result adoption through narrow host ports. +- `PixelEditController`, `SelectionImageController`, `LayerMutationController`: atomic pixel/document publication at their respective boundaries. Pixel editor materializes control layers and raster-image layers in place before destructive edits, keeping conversion plus pixels in one undoable transaction. +- `MaskResultController`, `FilterResultController`, `GeneratedResultController`: guarded application-result adoption through narrow host ports. -Controller-local tests instantiate these boundaries with fakes. Integration tests in `engine.test.ts` cover composition, document routing, and cross-controller invariants. +Controller-local tests instantiate these boundaries with fakes. Integration tests in `engine.test.ts` cover composition, document routing, cross-controller invariants. ## Immutable document and raster snapshots -`CanvasDocumentCapability.captureSnapshot()` returns a `CanvasDocumentSnapshot` or `null`. A snapshot contains a structured clone of the exact `CanvasStateContractV3` and the engine's `documentGeneration`. The engine privately associates it with the reducer canvas identity from which it was captured, so a caller cannot manufacture a current snapshot by copying fields. +`CanvasDocumentCapability.captureSnapshot()` returns `CanvasDocumentSnapshot` or `null`. Snapshot holds structured clone of exact `CanvasStateContractV3` and engine's `documentGeneration`. Engine privately ties it to the reducer canvas identity it was captured from, so caller cannot manufacture a current snapshot by copying fields. -`CanvasExportCapability.captureRasterSnapshot(snapshot, layerIds, options)` requests exactly the required layer caches, temporarily pins them, reserves detached-surface capacity, and copies their pixels into independent surfaces. It returns a typed `ok`, `stale`, `aborted`, `not-ready`, or `over-budget` result. Freshness is checked before and after every asynchronous rasterization boundary and once after all copies are detached. +`CanvasExportCapability.captureRasterSnapshot(snapshot, layerIds, options)` requests exactly the required layer caches, temporarily pins them, reserves detached-surface capacity, copies their pixels into independent surfaces. Returns typed `ok`, `stale`, `aborted`, `not-ready`, or `over-budget` result. Freshness checked before and after every async rasterization boundary and once after all copies are detached. -An `ok` result contains a `CanvasRasterSnapshot`: the cloned canvas contract, its document generation, a map of detached layer surfaces, and an idempotent `release()`. The snapshot is caller-owned. Its surfaces do not read live caches and remain valid while the engine cools down; cooldown releases generation-scoped reservations but does not invalidate detached snapshots. The caller must release the snapshot in `finally`. Engine disposal releases any snapshots still outstanding. +`ok` result holds a `CanvasRasterSnapshot`: cloned canvas contract, its document generation, map of detached layer surfaces, idempotent `release()`. Snapshot is caller-owned. Its surfaces do not read live caches and stay valid while engine cools down; cooldown releases generation-scoped reservations but does not invalidate detached snapshots. Caller must release snapshot in `finally`. Engine disposal releases any snapshots still outstanding. -This creates a deliberate transaction boundary: +Snapshots create a deliberate transaction boundary: -1. Before detachment completes, a document change makes the capture `stale`; no downstream submission may use a mixture of generations. -2. After a successful invocation detachment, ordinary editing may continue. Composite, upload, graph compilation, and queue submission read only the frozen contract and detached surfaces. -3. A wholesale canvas replacement remains a separate session, identified by `documentRevision`; completed results from an older session are dropped. +1. Before detachment completes, document change makes capture `stale`; no downstream submission may use a mix of generations. +2. After successful invocation detachment, ordinary editing may continue. Composite, upload, graph compilation, queue submission read only frozen contract and detached surfaces. +3. Wholesale canvas replacement stays a separate session, identified by `documentRevision`; completed results from an older session are dropped. -Canvas invocation first crosses the paint-flush barrier, then captures the post-flush document, plans every raster/control/regional composite, and detaches exactly the contributing layers. Composite dedupe writes are staged in transaction-local maps. They are published to the persistent per-engine dedupe cache only after all frozen compositing, uploads, compilation, and queue dispatch complete; stale or failed captures leave no persistent dedupe entries. +Canvas invocation first crosses paint-flush barrier, then captures post-flush document, plans every raster/control/regional composite, and detaches exactly the contributing layers. Composite dedupe writes stage in transaction-local maps. They publish to persistent per-engine dedupe cache only after all frozen compositing, uploads, compilation, and queue dispatch complete; stale or failed captures leave no persistent dedupe entries. -`submitCanvasInvocationSnapshot` carries the exact cloned canvas state used for compilation. `enqueueCompiledSnapshot` extracts its bounding box, dimensions, and document revision rather than reading the live project canvas. Generated candidates are placed from `queueItem.snapshot.canvas.document.bbox`, so later bbox edits do not move the result. `documentRevision` still prevents results from a replaced canvas session from entering current staging. +`submitCanvasInvocationSnapshot` carries exact cloned canvas state used for compilation. `enqueueCompiledSnapshot` extracts its bounding box, dimensions, document revision instead of reading live project canvas. Generated candidates are placed from `queueItem.snapshot.canvas.document.bbox`, so later bbox edits do not move result. `documentRevision` still keeps results from a replaced canvas session out of current staging. ## Frame demand before allocation -Each frame is described once by a `CompositePreparation` (`render/compositor.ts`): the leaves drawn bottom first, each leaf's effective matrix with transient transform overrides applied, the isolated group scopes, and each leaf's document-space bounds (persisted source bounds joined with any larger live cache rect, through that matrix). `compositeFrame` reuses the description until the document identity, isolation or override contents change, and each leaf's bounds until its contract, matrix, live cache extent or the document size does. Frame demand and the screen composite read that one description; frame demand also keeps every filter-previewed layer resident, since its preview draws through the layer's cache entry. The color sampler, overview and extraction prepare their own descriptions of the committed document without transient overrides. +Each frame is described once by a `CompositePreparation` (`render/compositor.ts`): leaves drawn bottom first, each leaf's effective matrix with transient transform overrides applied, isolated group scopes, each leaf's document-space bounds (persisted source bounds joined with any larger live cache rect, through that matrix). `compositeFrame` reuses the description until document identity, isolation or override contents change, and each leaf's bounds until its contract, matrix, live cache extent or document size does. Frame demand and screen composite read that one description; frame demand also keeps every filter-previewed layer resident, since its preview draws through layer's cache entry. Color sampler, overview and extraction prepare their own descriptions of committed document without transient overrides. -`calculateActiveFrameLayerIds` is the pure pre-allocation pass over that description: it intersects each drawable leaf's bounds with the document-space viewport. An explicitly isolated operation target is the one exception to normal document visibility: it remains demanded and drawable when disabled or display-hidden because the user is acting on that named layer directly. +`calculateActiveFrameLayerIds` is the pure pre-allocation pass over that description: intersects each drawable leaf's bounds with document-space viewport. Explicitly isolated operation target is the one exception to normal document visibility: stays demanded and drawable when disabled or display-hidden, because user acts on that named layer directly. -The render path computes this set before `ensureLayerCaches`. Only demanded layers are rasterized for the frame. Offscreen enabled layers are not eagerly allocated; they remain on-demand and LRU-evictable, then rasterize again when panning or a transform reveals them. +Render path computes this set before `ensureLayerCaches`. Only demanded layers rasterize for the frame. Offscreen enabled layers are not eagerly allocated; they stay on-demand and LRU-evictable, then rasterize again when panning or a transform reveals them. -The frame's demanded layers form its working set. After drawing, `RasterController.enforceBudget` reclaims memory in a fixed order (a bounded or empty repaint reads only part of the working set, so its usage window stays open from the last full repaint): +Frame's demanded layers form its working set. After drawing, `RasterController.enforceBudget` reclaims memory in fixed order (bounded or empty repaint reads only part of working set, so its usage window stays open from last full repaint): 1. derived and group artifacts the frame did not read (obsolete parameter variants, inactive groups); -2. least-recently-used base caches that are outside the working set and unprotected. +2. least-recently-used base caches outside working set and unprotected. -A layer is protected while an operation pins it (`RasterMemoryBudgetController.pin`, held by every read lease, snapshot capture, export and duplicate) or while it is held: its pixels are not yet persisted (`BitmapStore.hasPendingWork`) or an edit session (transform, text, float, pixel edit, operation lock) owns them. Whatever the working set and protected layers still need stays resident; the bytes above the budget are reported as overage and leave no room for background reservations. Export and thumbnail callers request and pin their own caches. Deterministic tests assert allocation counts and bytes for pan/reveal, rotated bounds, unflushed paint bounds, isolation, transient transforms, eviction, and re-rasterization. +Layer is protected while an operation pins it (`RasterMemoryBudgetController.pin`, held by every read lease, snapshot capture, export and duplicate) or while it is held: its pixels not yet persisted (`BitmapStore.hasPendingWork`) or an edit session (transform, text, float, pixel edit, operation lock) owns them. Whatever working set and protected layers still need stays resident; bytes above budget are reported as overage and leave no room for background reservations. Export and thumbnail callers request and pin their own caches. Deterministic tests assert allocation counts and bytes for pan/reveal, rotated bounds, unflushed paint bounds, isolation, transient transforms, eviction, re-rasterization. ## Raster resource ownership and memory policy -`RasterController` is the single raster owner. It owns base layer caches, derived and adjusted surfaces, group composites, decoded bitmaps, detached snapshot accounting, reservations and pins. Each store keeps a running byte total, updated at every allocation, resize, transfer and release, and pushes it to `RasterMemoryBudgetController`; nothing resynchronizes totals by rescanning. Cache surfaces change only through `LayerCacheStore` (growth, trim, replacement, `publishRasterized`, `captureState`/`restoreState`), so the accounting cannot drift from the pixels. +`RasterController` is the single raster owner. Owns base layer caches, derived and adjusted surfaces, group composites, decoded bitmaps, detached snapshot accounting, reservations and pins. Each store keeps a running byte total, updated at every allocation, resize, transfer and release, and pushes it to `RasterMemoryBudgetController`; nothing resynchronizes totals by rescanning. Cache surfaces change only through `LayerCacheStore` (growth, trim, replacement, `publishRasterized`, `captureState`/`restoreState`), so accounting cannot drift from pixels. -The raster soft limit is 512 MiB. The memory snapshot reports: +Raster soft limit is 512 MiB. Memory snapshot reports: - `baseBytes`: layer cache surfaces; - `derivedBytes`: derived and adjusted display surfaces; @@ -122,98 +124,98 @@ The raster soft limit is 512 MiB. The memory snapshot reports: - `decodedBytes`: leased `ImageBitmap` instances; - `detachedBytes`: caller-owned raster snapshot surfaces and retained operation pixels; - `reservedBytes`: capacity promised to background work but not yet represented by another class; -- `overageBytes`: the amount the protected working set holds above the limit. +- `overageBytes`: amount the protected working set holds above the limit. -Background snapshots, thumbnails, raster exports, transformed/adjusted copies, and PSD exports reserve sufficient available bytes before allocating and return typed `over-budget` outcomes when they cannot. +Background snapshots, thumbnails, raster exports, transformed/adjusted copies, and PSD exports reserve enough available bytes before allocating and return typed `over-budget` outcomes when they cannot. -Asynchronous raster reads return a `RasterReadLease` (`rasterTransactions.ts`): the pixels, their layer-local extent, a freshness guard and an idempotent `release`. The lease pins its source layer from before rasterization until release, so neither eviction nor paint trimming can reshape pixels an operation is still reading (persistence encodes a pinned layer untrimmed instead of waiting); the guard still rejects pixels a later edit replaced. Leases on detached copies (adjusted or baked pixels) release the source pin as soon as the copy exists. Extraction, merges, crops, copies, filters, snapshots and composite exports all read through leases and release them in `finally`. +Async raster reads return a `RasterReadLease` (`rasterTransactions.ts`): pixels, their layer-local extent, a freshness guard, an idempotent `release`. Lease pins its source layer from before rasterization until release, so neither eviction nor paint trimming can reshape pixels an operation is still reading (persistence encodes a pinned layer untrimmed instead of waiting); guard still rejects pixels a later edit replaced. Leases on detached copies (adjusted or baked pixels) release source pin as soon as copy exists. Extraction, merges, crops, copies, filters, snapshots and composite exports all read through leases and release them in `finally`. -Reservations are idempotent leases. Cancellable preparation work uses lifecycle-generation reservations, which activation, cooldown, and disposal release if the normal `finally` path has not already done so. Once an asynchronous raster composite is in flight, it uses operation-owned reservations instead: lifecycle changes may make its result stale, but cannot erase its accounting while a yielded allocation is still able to resume. Pins are always operation-owned. Detached snapshot accounting follows the snapshot and is released only by `CanvasRasterSnapshot.release()` or engine disposal. +Reservations are idempotent leases. Cancellable preparation work uses lifecycle-generation reservations, which activation, cooldown, and disposal release if normal `finally` path has not released them. Once an async raster composite is in flight, it uses operation-owned reservations instead: lifecycle changes may make its result stale but cannot erase its accounting while a yielded allocation can still resume. Pins are always operation-owned. Detached snapshot accounting follows the snapshot; only `CanvasRasterSnapshot.release()` or engine disposal releases it. -Paint persistence protects dirty pixels until the latest generation of them is acknowledged: across encoding, upload failures, an open retry circuit and later strokes. A dirty paint layer whose cache is missing keeps its pending work and fails the flush barrier; the store never reports a save that uploaded nothing. +Paint persistence protects dirty pixels until their latest generation is acknowledged: across encoding, upload failures, an open retry circuit and later strokes. Dirty paint layer whose cache is missing keeps its pending work and fails flush barrier; store never reports a save that uploaded nothing. -`DecodedBitmapPool` replaces permanent decoded-image caching. `acquire(imageName, decode, signal)` refuses an already-aborted request before any work starts, coalesces concurrent decodes for the same image and returns a short-lived `DecodedBitmapLease`. Each rasterizer releases its lease in `finally`. The bitmap closes after the final lease, a pending decode is aborted when all interested callers cancel and its late bitmap is closed, the pool observes its own decode promise so an abandoned decode cannot surface an unhandled rejection, and disposal aborts pending work and closes every resolved bitmap. Pool byte changes feed `decodedBytes`. +`DecodedBitmapPool` replaces permanent decoded-image caching. `acquire(imageName, decode, signal)` refuses an already-aborted request before any work starts, coalesces concurrent decodes for same image, returns a short-lived `DecodedBitmapLease`. Each rasterizer releases its lease in `finally`. Bitmap closes after final lease; pending decode aborts when all interested callers cancel and its late bitmap is closed; pool observes its own decode promise so an abandoned decode cannot surface an unhandled rejection; disposal aborts pending work and closes every resolved bitmap. Pool byte changes feed `decodedBytes`. -Staged results use a separate compressed-Blob cache rather than retaining a decoded surface for every candidate. Once a candidate's thumbnail settles, the UI asks the engine to prefetch its full-resolution bytes with at most two full-image requests in flight. Selecting a queued candidate promotes it immediately and preempts stale work, while completed/in-flight requests and concurrent decodes of the same image are coalesced. The per-engine cache is LRU-bounded to 24 entries and 64 MiB, retries a failed selected request once as foreground demand, and releases outstanding work on engine cooldown or disposal. +Staged results use a separate compressed-Blob cache instead of retaining a decoded surface for every candidate. Once a candidate's thumbnail settles, UI asks engine to prefetch its full-resolution bytes, at most two full-image requests in flight. Selecting a queued candidate promotes it immediately and preempts stale work; completed/in-flight requests and concurrent decodes of same image coalesce. Per-engine cache is LRU-bounded to 24 entries and 64 MiB, retries a failed selected request once as foreground demand, releases outstanding work on engine cooldown or disposal. ## Composition, damage and overlays -Damage is explicit: `FrameDamage` is a full repaint, no visible repaint, or layer-local regions. The scheduler widens to full on any invalidation without a region, a viewport change or a forced repaint. The compositor resolves regions through the frame's matrices into one padded screen rect: regions entirely off the target draw nothing, and a bounded repaint clips to the rect and culls every leaf, group scope and staged preview whose screen bounds miss it. Unknown layers or non-finite geometry repaint everything. +Damage is explicit: `FrameDamage` is full repaint, no visible repaint, or layer-local regions. Scheduler widens to full on any invalidation without a region, a viewport change or a forced repaint. Compositor resolves regions through frame's matrices into one padded screen rect: regions entirely off target draw nothing; bounded repaint clips to the rect and culls every leaf, group scope and staged preview whose screen bounds miss it. Unknown layers or non-finite geometry repaint everything. -Compositing always renders through the canonical `RasterBackend`; there is no approximate backend-free path. Filter previews and the floating selection of a grouped member compose inside the group's isolated surface, under the group's opacity, blend and adjustments, exactly as the member's committed pixels would. Committed content composes with the same semantics for display, sampling and export; previews and floats are display-only. A layer's own adjustments apply to its layer-local pixels before resampling: export draws the display's memoized adjusted copy of a leased live cache, and otherwise adjusts only the layer region the output samples (plus one pixel of resampling margin). A downscaled layer samples more pixels than the output holds, so a detached copy of it is adjusted after resampling instead; export work stays bounded by the output size. +Compositing always renders through canonical `RasterBackend`; no approximate backend-free path. Filter previews and floating selection of a grouped member compose inside group's isolated surface, under group's opacity, blend and adjustments, exactly as member's committed pixels would. Committed content composes with same semantics for display, sampling and export; previews and floats are display-only. Layer's own adjustments apply to its layer-local pixels before resampling: export draws display's memoized adjusted copy of a leased live cache, and otherwise adjusts only the layer region the output samples (plus one pixel of resampling margin). Downscaled layer samples more pixels than output holds, so a detached copy of it is adjusted after resampling instead; export work stays bounded by output size. -A group surface is keyed by everything except its members' pixels and the float's placement (scope shape, member appearance and matrices, in-scope previews, the float's surface). While that key and the bounds hold, the cache refreshes in place: it unions the damage each member's cache recorded since the version it last drew, padded by one source pixel before the member's matrix maps it (bilinear reach at any scale), plus the old and new landing of a moved float, clears that region with one pixel of antialiasing margin and redraws every contributor there, then applies the group's per-pixel adjustments to the region alone. Unknown member damage, a changed member set or new bounds redraw the whole surface, reallocating only when its size changed; a new key reuses the least-recently-used of a group's two slots once both are taken. +Group surface is keyed by everything except its members' pixels and float's placement (scope shape, member appearance and matrices, in-scope previews, float's surface). While that key and bounds hold, cache refreshes in place: it unions damage each member's cache recorded since the version it last drew, padded by one source pixel before member's matrix maps it (bilinear reach at any scale), plus old and new landing of a moved float, clears that region with one pixel of antialiasing margin and redraws every contributor there, then applies group's per-pixel adjustments to the region alone. Unknown member damage, changed member set or new bounds redraw whole surface, reallocating only when its size changed; new key reuses least-recently-used of a group's two slots once both are taken. -The overlay renders in CSS pixels. It receives the document→CSS view, the CSS viewport size and the device-pixel ratio, clears the backing store in device pixels, then draws every handle, nub, dash and cursor under a DPR base transform (nested image and path transforms compose with it), so chrome keeps its CSS size at any ratio and matches the CSS-pixel hit tests. The viewport owns the ratio (capped at `MAX_DPR`, sub-one ratios kept) and both the backing-store size and the composite view matrix derive from it. +Overlay renders in CSS pixels. Receives document→CSS view, CSS viewport size and device-pixel ratio, clears backing store in device pixels, then draws every handle, nub, dash and cursor under a DPR base transform (nested image and path transforms compose with it), so chrome keeps its CSS size at any ratio and matches CSS-pixel hit tests. Viewport owns the ratio (capped at `MAX_DPR`, sub-one ratios kept); backing-store size and composite view matrix both derive from it. -The color picker shows a loupe of the pixels under the pointer. It is anchored to the pipeline's last pointer position in screen space, so pans and zooms keep it under the pointer, and it shows as soon as the picker activates over a still pointer. Each overlay frame composites the square around that point through the same sampler, providers and coverage rules as a pick, so the magnified center pixel and its RGB readout are exactly what a click picks; the square holds fewer, larger pixels once the canvas zoom passes the loupe's own magnification. Hover chrome (the loupe and the brush ring) leaves the canvas with the pointer unless a gesture still owns it, and input handlers are stable so detach removes every listener attach added. +Color picker shows a loupe of pixels under the pointer. Anchors to pipeline's last pointer position in screen space, so pans and zooms keep it under pointer, and shows as soon as picker activates over a still pointer. Each overlay frame composites the square around that point through same sampler, providers and coverage rules as a pick, so magnified center pixel and its RGB readout are exactly what a click picks; square holds fewer, larger pixels once canvas zoom passes loupe's own magnification. Hover chrome (loupe and brush ring) leaves canvas with pointer unless a gesture still owns it; input handlers are stable so detach removes every listener attach added. ## Derived surfaces and invalidation -Every display-only pixel effect uses `DerivedSurfaceCache`. A slot is identified by layer ID and effect kind, and guarded by source surface identity, monotonic source version, and a deterministic parameter key. Reuse is safe only when every guard matches. Source replacement therefore cannot publish or reuse a surface produced for an older preview. +Every display-only pixel effect uses `DerivedSurfaceCache`. Slot identified by layer ID and effect kind, guarded by source surface identity, monotonic source version, and a deterministic parameter key. Reuse safe only when every guard matches. So source replacement cannot publish or reuse a surface produced for an older preview. -Invalidate only the affected effect when parameters change. Delete every derived slot when a layer is removed, replaced, or its base cache is evicted. Frame-demand culling happens before derived lookup or construction. +Invalidate only affected effect when parameters change. Delete every derived slot when a layer is removed, replaced, or its base cache evicted. Frame-demand culling happens before derived lookup or construction. ## Edits, history and replay -Every recorded document and pixel edit goes through one protocol, `CanvasMutationContext.begin`, and is admitted before it mutates anything. Admission reserves the undo entry's bytes in `History` without clearing redo or evicting older steps, so an edit whose entry could never be kept is refused as `over-budget` while the document, pixels and history are untouched. Two kinds of edit are admitted before they change anything but publish afterwards: live strokes paint as they are admitted (see below), and a floating selection holds its admission from lift to landing. Selection-mask changes (`selection/selectionHistory.ts`) admit their step right after applying, because the new mask's size is only known then, and roll back to the exact prior selection when it cannot be kept. The resulting `EditTransaction` also reserves raster memory for its preparation (`reserveRaster`), holds leases and pins until `end()`, and publishes once: `publish(label, step, entry)` applies an `EditStep` — an optional document mutation with its postcondition and rollback, prepared pixels to install, and ancillary notifications — verifies the reducer and mirror, records the entry, then routes a user edit and notifies. A step that is rejected or fails its postconditions records nothing; a failed postcondition dispatches the step's rollback and reports when the reducer or mirror could not return. Transactions refuse while the permit is stale, a gesture runs (unless the edit belongs to that gesture), a replay runs, or the engine is disposed. Coalescing (nudges) names the entry it replaces by its `HistoryEntryToken` and is refused once anything else became the newest step. +Every recorded document and pixel edit goes through one protocol, `CanvasMutationContext.begin`, and is admitted before it mutates anything. Admission reserves undo entry's bytes in `History` without clearing redo or evicting older steps, so an edit whose entry could never be kept is refused as `over-budget` with document, pixels and history untouched. Two kinds of edit are admitted before they change anything but publish afterwards: live strokes paint as they are admitted (see below), and floating selection holds its admission from lift to landing. Selection-mask changes (`selection/selectionHistory.ts`) admit their step right after applying, because new mask's size is only known then, and roll back to exact prior selection when it cannot be kept. Resulting `EditTransaction` also reserves raster memory for its preparation (`reserveRaster`), holds leases and pins until `end()`, publishes once: `publish(label, step, entry)` applies an `EditStep` — optional document mutation with its postcondition and rollback, prepared pixels to install, ancillary notifications — verifies reducer and mirror, records the entry, then routes a user edit and notifies. Step that is rejected or fails its postconditions records nothing; failed postcondition dispatches step's rollback and reports when reducer or mirror could not return. Transactions refuse while permit is stale, a gesture runs (unless edit belongs to that gesture), a replay runs, or engine is disposed. Coalescing (nudges) names the entry it replaces by its `HistoryEntryToken` and is refused once anything else became newest step. -Layer controllers build their steps from `controllers/editSteps.ts`: byte accounting, replay reservations (a replay whose preparation memory does not fit throws `REPLAY_RASTER_REFUSAL` and stays in place), the steps that add, replace and remove layers with their prepared pixels, and new paint layers. It also names how a publication that did not land is reported: ordinary layer operations (merge, crop, copy, rasterize, insert) report a reducer refusal or failed postcondition as `failed` (`layerOperationStatus`), while guarded result operations (generated, filter, mask and staged results, mask edits, duplicate) report it as `stale` (`guardedResultRefusal`), because their target moved on after the guard was taken. +Layer controllers build their steps from `controllers/editSteps.ts`: byte accounting, replay reservations (replay whose preparation memory does not fit throws `REPLAY_RASTER_REFUSAL` and stays in place), steps that add, replace and remove layers with their prepared pixels, and new paint layers. It also names how a publication that did not land is reported: ordinary layer operations (merge, crop, copy, rasterize, insert) report reducer refusal or failed postcondition as `failed` (`layerOperationStatus`), while guarded result operations (generated, filter, mask and staged results, mask edits, duplicate) report it as `stale` (`guardedResultRefusal`), because their target moved on after the guard was taken. -A floating selection (`FloatingSelectionController`) computes its commit footprint from geometry before it copies or cuts anything: before and after pixels over the union of the hole and the landing, the selection alpha before and after, and the entry overhead. It is admitted for that footprint as part of the drag that lifts it, and keeps the transaction until it lands or is dropped. Every move, scale or rotation grows the admission to the footprint at the new transform, from geometry alone; a transform that does not fit keeps the last accepted one and reports `over-budget` once per float, so an accepted transform always fits its undo entry while the permit stays current; the admission never shrinks, so a live float holds its largest accepted footprint until it lands or is dropped. A refused lift is reported and moves nothing (`lift` returns `refused`), including content whose cache is not rebuilt yet; only a lift with nothing to take (`unavailable`) lets the Move tool fall back to moving the layer. The cut layer's persistence is suspended while the float lives. Commit lands the pixels and the moved selection as one entry under the retained admission, renewed once when its permit went stale (an operation locked and released the document meanwhile); a commit that cannot be admitted or published puts the pre-lift pixels and selection back. Cancellation refills the hole before persistence resumes, and a hole that cannot be refilled keeps the layer suspended rather than uploading it. Every terminal path ends the transaction: commit, cancel, Escape, undo/redo, the layer's deletion or replacement, document replacement, cooldown (which banks the float before the flush), disposal, and exceptions while cutting or landing. +Floating selection (`FloatingSelectionController`) computes its commit footprint from geometry before it copies or cuts anything: before and after pixels over union of hole and landing, selection alpha before and after, entry overhead. Admitted for that footprint as part of the drag that lifts it; keeps the transaction until it lands or is dropped. Every move, scale or rotation grows admission to the footprint at the new transform, from geometry alone; transform that does not fit keeps last accepted one and reports `over-budget` once per float, so an accepted transform always fits its undo entry while permit stays current; admission never shrinks, so a live float holds its largest accepted footprint until it lands or is dropped. Refused lift is reported and moves nothing (`lift` returns `refused`), including content whose cache is not rebuilt yet; only a lift with nothing to take (`unavailable`) lets Move tool fall back to moving the layer. Cut layer's persistence is suspended while float lives. Commit lands pixels and moved selection as one entry under retained admission, renewed once when its permit went stale (an operation locked and released the document meanwhile); commit that cannot be admitted or published puts pre-lift pixels and selection back. Cancellation refills hole before persistence resumes; hole that cannot be refilled keeps layer suspended rather than uploading it. Every terminal path ends the transaction: commit, cancel, Escape, undo/redo, layer's deletion or replacement, document replacement, cooldown (which banks the float before the flush), disposal, exceptions while cutting or landing. -Live pixel edits (brush, eraser, shapes on paint layers or as opaque mask coverage, pixel transactions on control and image layers) are admitted before their first pixel changes and grow their admission before the stroke's before/after footprint does; a refused growth restores the layer's pixels and extent and the engine reports the refusal through `tools.onEditRefused`, which the canvas widget turns into a notice. Widget previews of structural edits run in one owned `StructuralPreviewSession` (`useStructuralPreview`), which coalesces live dispatches to one per frame, captures the values its first preview of each field replaces (the session's baseline) and records the gesture as a single step from it; a refused commit or a cancel restores that baseline, even while edits are locked. A commit from elsewhere, a history replay, a newer session or disposal ends an open session first, dropping pending previews and restoring the baseline, so previewed values never land over a replayed document or inside another step: every engine edit starts from `CanvasMutationContext.getDocument()`, which ends it first (the structural controller's commits and nudges, the pixel and layer operations such as merge down and duplicate, result commits); widget-prepared edits (`commitPreparedEdit`, the layers tree's child-row commands) end it before reading the model; `HistoryController` ends it (with any live float) before a replay that has a step to land, and a replay with nothing to land disturbs nothing; a document replacement drops it without restoring, since the document it previewed on is gone. The widget's next preview starts a new session from the changed document, and a commit after the engine ended its session records from the live values; its refusal as `missing` stays silent, since the document change that ended the gesture also unmounted the editor. +Live pixel edits (brush, eraser, shapes on paint layers or as opaque mask coverage, pixel transactions on control and image layers) are admitted before their first pixel changes and grow their admission before the stroke's before/after footprint does; refused growth restores layer's pixels and extent and engine reports the refusal through `tools.onEditRefused`, which canvas widget turns into a notice. Widget previews of structural edits run in one owned `StructuralPreviewSession` (`useStructuralPreview`), which coalesces live dispatches to one per frame, captures the values its first preview of each field replaces (session's baseline) and records the gesture as a single step from it; refused commit or cancel restores that baseline, even while edits are locked. A commit from elsewhere, a history replay, a newer session or disposal ends an open session first, dropping pending previews and restoring baseline, so previewed values never land over a replayed document or inside another step: every engine edit starts from `CanvasMutationContext.getDocument()`, which ends it first (structural controller's commits and nudges, pixel and layer operations such as merge down and duplicate, result commits); widget-prepared edits (`commitPreparedEdit`, layers tree's child-row commands) end it before reading the model; `HistoryController` ends it (with any live float) before a replay that has a step to land, and a replay with nothing to land disturbs nothing; document replacement drops it without restoring, since the document it previewed on is gone. Widget's next preview starts a new session from changed document, and a commit after engine ended its session records from live values; its refusal as `missing` stays silent, since the document change that ended the gesture also unmounted the editor. -Undo and redo are asynchronous and failure-atomic: an entry moves to the other stack only after its replay completes, a replay that throws leaves it in place and is reported, and edits and new gestures are refused while one runs. Replays apply steps through the same verified path (`applyStep`). Pixel replays land only on trustworthy pixels: an evicted or stale cache is rasterized first under a read lease, and a layer that no longer exists moves the step as a no-op. +Undo and redo are async and failure-atomic: entry moves to the other stack only after its replay completes, replay that throws leaves it in place and is reported, and edits and new gestures are refused while one runs. Replays apply steps through same verified path (`applyStep`). Pixel replays land only on trustworthy pixels: evicted or stale cache is rasterized first under a read lease, and a layer that no longer exists moves the step as a no-op. ## Edit leases and staged-result acceptance -Application operations acquire one exclusive lease from `CanvasEditGate`. A lease carries an `AbortSignal`, has an explicit `isCurrent()` freshness check, and is idempotently released. Release, document/project invalidation, cooldown, or disposal aborts and stales the lease. Expected cancellation and stale results use status unions; exceptions represent unexpected failures. Operation sessions publish typed error codes rather than copy (`FilterSessionError`, `SamSessionError`): the view translates the code when it renders, so a visible error follows a language change, and an unexpected failure shows a translated summary with its original message as technical detail. +Application operations acquire one exclusive lease from `CanvasEditGate`. Lease carries an `AbortSignal`, has explicit `isCurrent()` freshness check, is idempotently released. Release, document/project invalidation, cooldown, or disposal aborts and stales the lease. Expected cancellation and stale results use status unions; exceptions represent unexpected failures. Operation sessions publish typed error codes, not copy (`FilterSessionError`, `SamSessionError`): view translates the code when it renders, so a visible error follows a language change, and an unexpected failure shows a translated summary with its original message as technical detail. -Staged-result acceptance is engine-owned. The UI enables Accept from interaction capabilities, but `StagedResultController` remains authoritative. It captures a document-edit permit, rejects an active gesture, and verifies the selected candidate's stable key immediately before dispatch. +Staged-result acceptance is engine-owned. UI enables Accept from interaction capabilities, but `StagedResultController` stays authoritative. It captures a document-edit permit, rejects an active gesture, verifies selected candidate's stable key immediately before dispatch. -The initial `commitStagedImage` mutation atomically inserts the new raster layer at the top of the raster stack, clears staging, and records the project event. It keeps a selected overlay leaf (an inpaint or regional mask, a control layer) as the editing target while the next stroke could edit it (the leaf and every ancestor enabled and unlocked, as painting requires) and otherwise selects the new layer: nothing, a group or a refusing leaf was selected, or raster work the result now covers; `selectionAfterAcceptedResult` (`document/selectionRepair.ts`) decides this once for the controller's postcondition and history steps, the reducer, and its rollback. The controller verifies both the reducer postcondition and the document mirror before pushing exactly one engine-history entry. It returns `busy`, `stale`, or `missing` without changing staging or history when any guard fails. +Initial `commitStagedImage` mutation atomically inserts new raster layer at top of raster stack, clears staging, records project event. It keeps a selected overlay leaf (inpaint or regional mask, control layer) as editing target while next stroke could edit it (leaf and every ancestor enabled and unlocked, as painting requires) and otherwise selects new layer: nothing, a group or a refusing leaf was selected, or raster work the result now covers; `selectionAfterAcceptedResult` (`document/selectionRepair.ts`) decides this once for controller's postcondition and history steps, the reducer, and its rollback. Controller verifies both reducer postcondition and document mirror before pushing exactly one engine-history entry. Returns `busy`, `stale`, or `missing` without changing staging or history when any guard fails. -The canvas widget stops the candidate's originating batch only after an accept has committed (`widgets/canvas/stagedAcceptance.ts`), and only while that batch still has queued or running work the queue can cancel; the action keeps its ordinary "Accept to Layer" label. The request is the Workbench reducer action `cancelQueueItem` (exposed as `queue.cancel`) with an accept-specific notice in place of the generic cancellation one. It marks the batch cancelled at once, so it shows as cancelled in the queue and siblings that finish before the backend stops are dropped instead of re-staged; the queue runtime then cancels by batch id, retries a failed request and reports the failure once per batch. Other batches are untouched, the continue-staging save never stops generation, and undoing the accepted layer does not re-enqueue anything. +Canvas widget stops candidate's originating batch only after an accept has committed (`widgets/canvas/stagedAcceptance.ts`), and only while that batch still has queued or running work the queue can cancel; action keeps its ordinary "Accept to Layer" label. Request is Workbench reducer action `cancelQueueItem` (exposed as `queue.cancel`) with an accept-specific notice in place of the generic cancellation one. It marks batch cancelled at once, so it shows as cancelled in queue and siblings that finish before backend stops are dropped instead of re-staged; queue runtime then cancels by batch id, retries a failed request, reports the failure once per batch. Other batches untouched, continue-staging save never stops generation, undoing accepted layer re-enqueues nothing. -The continue-staging variant uses the same guarded transaction but inserts the candidate as a disabled raster layer, preserves the exact staging-area state and current layer selection, and keeps the staged preview active. This lets users bank candidates without visually doubling the preview or ending comparison. Undo and redo affect only the banked layer after the initial transaction. +Continue-staging variant uses same guarded transaction but inserts candidate as a disabled raster layer, preserves exact staging-area state and current layer selection, keeps staged preview active. Lets users bank candidates without visually doubling preview or ending comparison. Undo and redo affect only the banked layer after initial transaction. -Undo removes the accepted layer and restores the prior selection. Redo restores the identical layer and selection without recreating the event or staging candidate. The history entry replays failure-atomically, and a successful commit publishes through an admitted transaction, which clears any previous redo stack. Undo intentionally does not return the accepted candidate to staging. +Undo removes accepted layer and restores prior selection. Redo restores identical layer and selection without recreating event or staging candidate. History entry replays failure-atomically; successful commit publishes through an admitted transaction, which clears any previous redo stack. Undo intentionally does not return accepted candidate to staging. ## PSD export -PSD export is a planner/executor transaction over immutable snapshots. `PsdExportController` captures one `CanvasDocumentSnapshot`, walks the raster tree into planner inputs (groups as pass-through folders, exportable leaves with their captured content rects), then obtains a `CanvasRasterSnapshot` including disabled layers. `planPsdExport` reverses every level of the top-first tree into ag-psd's bottom-to-top order, writes each node's own visibility flag (a disabled group is a hidden folder), and flattens the merged preview from the leaves that contribute (enabled with every ancestor enabled), which is what Photoshop renders for the same flags. Folders whose subtree exports nothing are dropped. Execution reads only the detached surfaces; it cannot mix live layer caches from different document generations. +PSD export is a planner/executor transaction over immutable snapshots. `PsdExportController` captures one `CanvasDocumentSnapshot`, walks raster tree into planner inputs (groups as pass-through folders, exportable leaves with their captured content rects), then obtains a `CanvasRasterSnapshot` including disabled layers. `planPsdExport` reverses every level of top-first tree into ag-psd's bottom-to-top order, writes each node's own visibility flag (disabled group is a hidden folder), flattens merged preview from the leaves that contribute (enabled with every ancestor enabled), which is what Photoshop renders for the same flags. Folders whose subtree exports nothing are dropped. Execution reads only detached surfaces; cannot mix live layer caches from different document generations. -The controller retains the 30,000-pixel side cap and also derives a pixel-area limit from currently available reserved bytes. It accounts for the flattened composite plus each transformed layer surface, budgets eight bytes per required pixel, and reserves the full execution allocation before creating PSD surfaces. A final freshness check occurs before that reservation; once execution starts it uses only frozen pixels. Capture and execution reservations are released in `finally`, and cancellation aborts before the next allocation or side effect. +Controller keeps 30,000-pixel side cap and also derives a pixel-area limit from currently available reserved bytes. Accounts for flattened composite plus each transformed layer surface, budgets eight bytes per required pixel, reserves full execution allocation before creating PSD surfaces. Final freshness check happens before that reservation; once execution starts it uses only frozen pixels. Capture and execution reservations release in `finally`; cancellation aborts before next allocation or side effect. -The capability returns `exported`, `nothing`, `too-large`, `not-ready`, `over-budget`, `stale`, or `aborted`. The layers widget awaits the promise and maps every non-success result to a user-visible notice. +Capability returns `exported`, `nothing`, `too-large`, `not-ready`, `over-budget`, `stale`, or `aborted`. Layers widget awaits the promise and maps every non-success result to a user-visible notice. ## Input ownership and frame scheduling -The pointer pipeline (`input/pointerPipeline.ts`) owns gesture capture and the temporary holds (Space → view, Alt → picker, C → bbox). Keyboard ownership is explicit: the widget passes its focusable keyboard root to `surface.attach`, and Enter applies a session only while focus is on that root or nowhere, never on a control or an editable field. Hold keys and the Escape ladder also act after a pointer click left focus on a control such as a tool button or a layer row, since the pipeline records on `focusin` whether focus arrived by pointer (Chromium marks the focused element `:focus-visible` at any keydown, so arrival is the only reliable signal); they never act over an editable field, a control reached by keyboard, or an open dialog, menu or listbox. An owned Space keydown is prevented, so the clicked control does not activate; key releases are always observed and never consumed. A genuine tool switch first cancels the gesture through the outgoing tool and ends any hold, restoring the held tool, so the switch leaves the tool the user was really on and a later key release restores nothing. +Pointer pipeline (`input/pointerPipeline.ts`) owns gesture capture and temporary holds (Space → view, Alt → picker, C → bbox). Keyboard ownership is explicit: widget passes its focusable keyboard root to `surface.attach`, and Enter applies a session only while focus is on that root or nowhere, never on a control or editable field. Hold keys and Escape ladder also act after a pointer click left focus on a control such as a tool button or layer row, since pipeline records on `focusin` whether focus arrived by pointer (Chromium marks focused element `:focus-visible` at any keydown, so arrival is the only reliable signal); they never act over an editable field, a control reached by keyboard, or an open dialog, menu or listbox. Owned Space keydown is prevented, so clicked control does not activate; key releases are always observed, never consumed. Genuine tool switch first cancels the gesture through outgoing tool and ends any hold, restoring held tool, so the switch leaves the tool the user was really on and a later key release restores nothing. -`RenderScheduler.beforeNextFrame(task)` runs tasks once before the next frame takes its flags, so their invalidations join that frame. Tools reach it through the required `ToolContext.scheduleFrame`; a detached engine runs the task immediately because it produces no frames. Brush strokes append samples and decimate them incrementally per event, render the accumulated stroke at most once per presented frame, and render synchronously on commit. +`RenderScheduler.beforeNextFrame(task)` runs tasks once before next frame takes its flags, so their invalidations join that frame. Tools reach it through required `ToolContext.scheduleFrame`; detached engine runs the task immediately because it produces no frames. Brush strokes append samples and decimate them incrementally per event, render accumulated stroke at most once per presented frame, render synchronously on commit. ## Lifecycle -Lifecycle states are `active`, `cooling`, `cool`, and `disposed`. Losing the final registry reference immediately banks a live floating selection, detaches input/render targets, cancels background PSD work and rasterization, releases the old generation's reservations, and starts a dirty-pixel flush. Successful, still-current flushes release every derived and group surface and each base cache that no read lease pins and no session holds, then trim history. Failed flushes retain dirty pixels and remain retryable; the registry retains the engine and retries after another grace period, disposing it only after a `cooled` result. Reacquisition increments the lifecycle generation so late flush or grace-period work cannot clean up or dispose a live engine. Operation-owned reservations and pins survive these generation transitions until their owning operation settles. +Lifecycle states: `active`, `cooling`, `cool`, `disposed`. Losing final registry reference immediately banks a live floating selection, detaches input/render targets, cancels background PSD work and rasterization, releases old generation's reservations, starts a dirty-pixel flush. Successful, still-current flushes release every derived and group surface and each base cache that no read lease pins and no session holds, then trim history. Failed flushes keep dirty pixels and stay retryable; registry keeps the engine and retries after another grace period, disposing it only after a `cooled` result. Reacquisition increments lifecycle generation so late flush or grace-period work cannot clean up or dispose a live engine. Operation-owned reservations and pins survive these generation transitions until their owning operation settles. -Operations that write an open project's document elsewhere (close, export, duplicate) first cross its engine's paint barrier through the open-project handle and the Workbench's `LiveCanvasEngines` registry; close crosses it again after the push so pixels painted meanwhile are not lost. These barriers do not wait on an edit that holds dirty pixels (a session, gesture or suspension): they fail fast and ask the user to finish or cancel it, and if unsaved pixels cannot be persisted the project stays open with its engine state intact. Rename keeps its tolerant flush. Invocation waits for short-lived holds. Disposal cancels outstanding work and releases every resource category: caches, decoded bitmaps, reservations, pins and outstanding snapshots. +Operations that write an open project's document elsewhere (close, export, duplicate) first cross its engine's paint barrier through open-project handle and Workbench's `LiveCanvasEngines` registry; close crosses it again after the push so pixels painted meanwhile are not lost. These barriers do not wait on an edit that holds dirty pixels (session, gesture or suspension): they fail fast and ask user to finish or cancel it, and if unsaved pixels cannot be persisted the project stays open with its engine state intact. Rename keeps its tolerant flush. Invocation waits for short-lived holds. Disposal cancels outstanding work and releases every resource category: caches, decoded bitmaps, reservations, pins, outstanding snapshots. -Caller-owned `CanvasRasterSnapshot` objects are not reconstructible lifecycle caches. They remain usable through cooldown and continue to count against detached bytes until released. Final engine disposal force-releases them. +Caller-owned `CanvasRasterSnapshot` objects are not reconstructible lifecycle caches. They stay usable through cooldown and keep counting against detached bytes until released. Final engine disposal force-releases them. ## Browser acceptance -Node and browser suites are separated by filename and config. `vitest.config.mts` excludes `*.browser.test.ts(x)`; `vitest.browser.config.mts` runs those files through `@vitest/browser-playwright` 4.1.9 in headless Chromium. `test:browser` runs the browser suite and `test:all` runs Node followed by browser tests. CI installs Chromium with `pnpm exec playwright install chromium` before the browser job. +Node and browser suites split by filename and config. `vitest.config.mts` excludes `*.browser.test.ts(x)`; `vitest.browser.config.mts` runs those files through `@vitest/browser-playwright` in headless Chromium. `test:browser` runs browser suite; `test:all` runs Node, then browser tests. CI installs Chromium with `pnpm exec playwright install chromium` before browser job. -The real-Canvas2D suite verifies transformed composition, clipping, alpha and blend modes, browser text metrics, PNG blob encoding, `createImageBitmap` decoding, pixel undo/redo, and a small PSD write/read round trip. A React `StrictMode` harness verifies surface attachment, `ResizeObserver` sizing, detachment, registry cooldown/reacquisition, grace-period safety, and project switching. +Real-Canvas2D suite verifies transformed composition, clipping, alpha and blend modes, browser text metrics, PNG blob encoding, `createImageBitmap` decoding, pixel undo/redo, small PSD write/read round trip. React `StrictMode` harness verifies surface attachment, `ResizeObserver` sizing, detachment, registry cooldown/reacquisition, grace-period safety, project switching. ## Adding functionality -- New tool: implement the tool interface under `tools/`, keep durable state in `CanvasProjectMutation`, and expose only required tool capability methods. -- New derived effect: add a stable effect kind/parameter key, construct through `DerivedSurfaceCache`, and test warm hits, precise invalidation, stale source guards, culling, and byte accounting. -- New layer operation: put pure pixel/document math in the core and orchestration in a controller; accept narrow ports, use project-bound mutations, reserve background allocations, and return status unions for expected no-op/stale cases. -- New background raster workflow: capture a document snapshot, plan required layers, reserve and pin before allocation, detach immutable surfaces, release every lease in `finally`, and define whether edits after detachment are allowed. -- New application coordinator: place it under `canvas-operations/`, acquire an edit lease, capture through narrow document/export capabilities, own its session store and cancellation, and publish through preview/layer capabilities. +- New tool: implement tool interface under `tools/`, keep durable state in `CanvasProjectMutation`, expose only required tool capability methods. +- New derived effect: add stable effect kind/parameter key, construct through `DerivedSurfaceCache`, test warm hits, precise invalidation, stale source guards, culling, byte accounting. +- New layer operation: pure pixel/document math in core, orchestration in a controller; accept narrow ports, use project-bound mutations, reserve background allocations, return status unions for expected no-op/stale cases. +- New background raster workflow: capture document snapshot, plan required layers, reserve and pin before allocation, detach immutable surfaces, release every lease in `finally`, define whether edits after detachment are allowed. +- New application coordinator: place under `canvas-operations/`, acquire edit lease, capture through narrow document/export capabilities, own its session store and cancellation, publish through preview/layer capabilities. Deterministic CI assertions use operation counters and byte totals. Wall-clock timing is informational only. diff --git a/invokeai/frontend/webv2/src/workbench/extensions/AGENTS.md b/invokeai/frontend/webv2/src/workbench/extensions/AGENTS.md new file mode 100644 index 00000000000..58012043497 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/extensions/AGENTS.md @@ -0,0 +1,3 @@ +# Workbench extensions + +Read [README.md](README.md) before adding or changing widget manifests, settings contributions, or extension discovery. diff --git a/invokeai/frontend/webv2/src/workbench/extensions/README.md b/invokeai/frontend/webv2/src/workbench/extensions/README.md index 4ff85642dc5..7434982f395 100644 --- a/invokeai/frontend/webv2/src/workbench/extensions/README.md +++ b/invokeai/frontend/webv2/src/workbench/extensions/README.md @@ -1,11 +1,11 @@ # Workbench Widget Extensions -The workbench shell is now structured around widget types and widget instances. +Workbench shell now built around widget types and widget instances. -- A widget manifest registers a widget type. -- A project layout stores widget instances in ordered region lists. -- A widget instance owns persisted project state. -- The shell owns placement, drag/drop, resizing, failure containment, and contribution APIs. +- Widget manifest registers a widget type. +- Project layout stores widget instances in ordered region lists. +- Widget instance owns persisted project state. +- Shell owns placement, drag/drop, resizing, failure containment, contribution APIs. ## Manifest Shape @@ -38,47 +38,47 @@ Third-party widgets should ship icons as JSX/SVG components in their compiled wi ## Settings contributions -The optional `settings` contribution declares fields once and exposes a `quick` array of field IDs for the compact popover. All fields appear in the widget's full settings section and participate in search. A manifest with quick fields gets a shell-rendered popover and an “All settings” link; one without quick fields opens the full section directly. Widgets without preferences omit the contribution and the gear. +Optional `settings` contribution declares fields once and exposes a `quick` array of field IDs for the compact popover. All fields appear in the widget's full settings section and join search. Manifest with quick fields gets a shell-rendered popover plus “All settings” link; one without quick fields opens the full section directly. Widgets without preferences omit the contribution and the gear. -Field metadata includes stable IDs, localized labels, optional descriptions/groups/search aliases, control type/options, and storage scope. The deferred `load` returns a `Field` binding, which receives the field, presentation surface, and resolved project/instance target. Bindings use the owning module's reads and commands and the shared Platform `SettingControl`. They must not create a widget view or engine merely to edit a preference. Complex editors use the custom field kind and remain searchable as destinations. +Field metadata: stable IDs, localized labels, optional descriptions/groups/search aliases, control type/options, storage scope. Deferred `load` returns a `Field` binding, which receives the field, presentation surface, and resolved project/instance target. Bindings use the owning module's reads and commands and shared Platform `SettingControl`. They must not create a widget view or engine just to edit a preference. Complex editors use the custom field kind and stay searchable as destinations. -Optional `WidgetImplementation.settingsActions` contributes owner-rendered actions below quick preferences, such as Canvas diagnostics. These remain actions with their own availability checks rather than persisted fields. Ordinary widget header actions remain available independently of settings. +Optional `WidgetImplementation.settingsActions` adds owner-rendered actions below quick preferences, e.g. Canvas diagnostics. These stay actions with their own availability checks, not persisted fields. Ordinary widget header actions stay available independent of settings. -The settings catalog is composed from first-party manifests, not live instance registration, so preferences remain discoverable when a widget is closed. A missing project or instance is an explicit unavailable state, not a request to create one. Settings navigation carries the originating instance and returns focus to its gear after closing. +Settings catalog is composed from first-party manifests, not live instance registration, so preferences stay discoverable while a widget is closed. Missing project or instance is an explicit unavailable state, not a request to create one. Settings navigation carries the originating instance and returns focus to its gear on close. ## Discovery Options -Custom nodes today are server-discovered. The backend scans `custom_nodes_path`, skips hidden/underscore directories, requires `__init__.py`, and imports each pack with Python `importlib`. The management API can clone a git repo into that directory, reload nodes, and list installed packs. +Custom nodes today are server-discovered. Backend scans `custom_nodes_path`, skips hidden/underscore directories, requires `__init__.py`, imports each pack with Python `importlib`. Management API can clone a git repo into that directory, reload nodes, list installed packs. -Widgets should not mirror Python import execution directly in the browser. The safer server-first shape is: +Widgets should not mirror Python import execution directly in the browser. Safer server-first shape: -- Add a configured `widgets_path` beside `custom_nodes_path`. -- Require each widget pack directory to include a manifest file, for example `invokeai-widget.json`. -- Let the server list widget packs and expose static compiled assets from each pack. -- The browser fetches a signed/validated widget catalog from the server. -- The shell dynamically imports approved widget entrypoints by URL only after the server has validated the pack manifest. +- Add configured `widgets_path` beside `custom_nodes_path`. +- Require each widget pack directory to include a manifest file, e.g. `invokeai-widget.json`. +- Server lists widget packs and exposes static compiled assets from each pack. +- Browser fetches a signed/validated widget catalog from the server. +- Shell dynamically imports approved widget entrypoints by URL only after the server validated the pack manifest. This avoids symlink assumptions and lets multi-user/admin rules follow the existing custom-node management pattern. ## Implemented Shell Systems - Workbench and Launchpad command palette hosts with shared command and settings entries. -- First-party Workbench search providers for workflows, boards, models, images, queue items, and prompt history. +- First-party Workbench search providers for workflows, boards, models, images, queue items, prompt history. - Widget command-palette contributions backed by the per-Workbench extension registry. - Widget search-provider aggregation with source-safe identities and result adaptation into palette entries. -- Scoped provider search, fixed section ordering with ranked results inside each section, and persistent command recents. +- Scoped provider search, fixed section ordering with ranked results inside each section, persistent command recents. - Created-at date filtering for providers that explicitly advertise range support. ## Missing Core Systems -The contribution registries exist in `extensionRegistry.ts` (one registry per Workbench mount, constructed by `WorkbenchProvider`), but these systems still need real shell features: +Contribution registries exist in `extensionRegistry.ts` (one registry per Workbench mount, constructed by `WorkbenchProvider`), but these systems still need real shell features: - Command registry UI and lifecycle ownership. - Hotkey manager with context-aware `when` clauses. -- Menu contribution rendering beyond widget header menus. +- Menu contribution rendering. `menus.register` stores contributions, but nothing reads them yet, widget header menus included. - Toolbar contribution rendering for locations like `center.tabs.trailing` and status bar slots. - Extension install/uninstall/reload API for widget packs. -- Widget pack trust, signing, permission prompts, and admin policy. +- Widget pack trust, signing, permission prompts, admin policy. - Extension asset serving and cache invalidation. -Undo/redo history controls were removed from normal widget registration because they render buttons and do not belong inside another button-like widget tab/slot. They should return as command and toolbar contributions when the command/toolbar systems exist. +Undo/redo history controls were removed from normal widget registration: they render buttons and do not belong inside another button-like widget tab/slot. They should return as command and toolbar contributions once command/toolbar systems exist. diff --git a/invokeai/frontend/webv2/src/workbench/projects/AGENTS.md b/invokeai/frontend/webv2/src/workbench/projects/AGENTS.md new file mode 100644 index 00000000000..24ac89cc435 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/projects/AGENTS.md @@ -0,0 +1,3 @@ +# Project persistence + +Read [README.md](README.md) before changing project documents, saving, browser recovery, project workflows, or schema. The backend record is authoritative; document fields follow its additive-field and cutover rules. diff --git a/invokeai/frontend/webv2/src/workbench/projects/README.md b/invokeai/frontend/webv2/src/workbench/projects/README.md index 71a5ecd08c0..a96bdc93e79 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/README.md +++ b/invokeai/frontend/webv2/src/workbench/projects/README.md @@ -1,70 +1,78 @@ # Project persistence -The backend project record is authoritative. Writes use `expected_revision`; a divergent revision or remotely deleted project requires an explicit user decision. Editing can continue while that decision is pending. Saving a copy reuses one reserved identity through `createProjectSettled`, including after a lost response. +Paths are relative to the webv2 `src/` directory. -`ProjectDocumentV3` allowlists editable document fields. Optional fields added within schema 3 (`lastFloatingGeometry`, `presetWorkingLayouts`) are additive: absent means none, so older schema-3 documents load unchanged, and a build that predates a field ignores it and drops it from the next document it saves to the server rather than refusing the project, which a schema bump would do. Queue runs, events, undo and per-workflow edit histories are not project documents. Documents are limited to 32 MiB of UTF-8 JSON on both sides of the API. +Backend project record is authoritative. Writes use `expected_revision`; divergent revision or remotely deleted project needs explicit user decision. Editing can continue while that decision pends. Saving a copy reuses one reserved identity through `createProjectSettled`, including after a lost response. + +`ProjectDocumentV3` allowlists editable document fields. Optional fields added within schema 3 (`lastFloatingGeometry`, `presetWorkingLayouts`) are additive: absent means none, so older schema-3 documents load unchanged, and a build predating a field ignores it and drops it from the next document it saves to the server instead of refusing the project (a schema bump would refuse it). Queue runs, events, undo and per-workflow edit histories are not project documents. Documents capped at 32 MiB of UTF-8 JSON on both sides of the API. ## Workflows -A project owns an ordered workflow collection (`workflows.entries`) and remembers its active workflow. Each entry carries the editable document, optional source metadata (the library template it was opened from or last explicitly saved to, with the content revision then observed, or `null` when unknown) and the newest successful run's output. Source metadata sits beside the document, outside graph edit history, so undoing an edit never undoes a save. `projectWorkflows.ts` owns membership, selection and the session histories (one per workflow, 40 entries per project across all of them, structurally shared); Workflow owns editing, serialization, library transport and presentation. +Project owns an ordered workflow collection (`workflows.entries`) and remembers its active workflow. Each entry carries the editable document, optional source metadata (library template it was opened from or last explicitly saved to, with the content revision then observed, or `null` when unknown) and the newest successful run's output. Source metadata sits beside the document, outside graph edit history, so undoing an edit never undoes a save. `workbench/projectWorkflows.ts` owns membership, selection and session histories (one per workflow, 40 entries per project across all, structurally shared); Workflow owns editing, serialization, library transport, presentation. + +Projects autosave their workflows. Library changes only through explicit publication (`features/workflow/data/publication.ts`): save as new template, or update the source at exactly the revision the copy last saw; stale revision is a 409 the user resolves. Every media reference in every workflow, active or not, joins reference indexing, cleanup protection, duplication and archive export. + +`migrateProjectDocument` is the one migration boundary. Unversioned (schema 1) and schema 2 documents become schema 3 with their single `projectGraph` as first, active workflow; a `libraryWorkflowId` becomes a source with unknown revision, never assumed to match the library's current contents. Server loads, recovery drafts, duplication and `.invk` import all pass through it; every write path emits schema 3. Malformed collections (dangling active id, repeated ids, damaged entries) and structurally damaged workflow documents are refused, not repaired or replaced with blanks; refused document is reported, left untouched on the server, raw content stays exportable. Same-server duplication keeps source references; `.invk` import clears them, since a portable file never names a library write target. + +Canvas uploads carrying a project id await that identity’s acknowledged server creation through the mounted persistence service. Uploads waiting on an unacknowledged project share one serialized creation attempt; a failed attempt answers further uploads for a few seconds before another is made; later uploads use the acknowledged identity without flushing unrelated edits. Only a closed project, account change, engine disposal or the operation's own cancellation stops an upload, and those stop the wait immediately. Any other outcome (creation failed or offline, conflict, or server that no longer knows the project and refuses the id) sends the upload without project id. Such uploads are never re-associated with the project later, so project's intermediates cleanup does not see them. -Projects autosave their workflows. The library changes only through explicit publication (`features/workflow/data/publication.ts`): saving as a new template or updating the source at exactly the revision the copy last saw; a stale revision is a 409 the user resolves. Every media reference in every workflow, active or not, takes part in reference indexing, cleanup protection, duplication and archive export. +Project links are consumed after successful open, so reload restores the saved active project, not an earlier link. New-project startup selects the newly created draft before saving the session. -`migrateProjectDocument` is the one migration boundary. Unversioned (schema 1) and schema 2 documents become schema 3 with their single `projectGraph` as the first, active workflow; a `libraryWorkflowId` becomes a source with an unknown revision, never assumed to match the library's current contents. Server loads, recovery drafts, duplication and `.invk` import all pass through it; every write path emits schema 3. Malformed collections (dangling active id, repeated ids, damaged entries) and structurally damaged workflow documents are refused rather than repaired or replaced with blanks; a refused document is reported, left untouched on the server, and its raw content stays available for export. Same-server duplication keeps source references; `.invk` import clears them, because a portable file never names a library write target. +Workflow reads the active workflow through the App-composed port and edits, publishes, presents documents; it never replaces authored project work. Library is written only by the Workflow-owned publication controller, which captures account, project, workflow and content before start, refuses to overlap itself per workflow, reconciles a lost answer against the destination record before resending, and records resulting library id and revision on the originating workflow alone. Queue submissions carry the originating project workflow so seed advances, node progress and run previews return to it whatever is active later. -Canvas uploads carrying a project id await that identity’s acknowledged server creation through the mounted persistence service. Uploads waiting on an unacknowledged project share one serialized creation attempt, and a failed attempt answers further uploads for a few seconds before another is made; later uploads use the acknowledged identity without flushing unrelated edits. Only a closed project, an account change, engine disposal or the operation's own cancellation stops an upload, and those stop the wait immediately. Any other outcome (creation failed or offline, a conflict, or a server that no longer knows the project and refuses the id) sends the upload without a project id. Such uploads are never re-associated with the project later, so the project's intermediates cleanup does not see them. +## Persistence runtime -Project links are consumed after a successful open, so reloading restores the saved active project rather than replaying an earlier link. A new-project startup selects the newly created draft before saving the session. +Workbench persistence is scoped to one mounted Workbench and its account lifetime. `createWorkbenchPersistenceRuntime` owns hydration ordering, debounced autosave, hidden-page saves, revision/generation checks, conflict application, reconnect replay, status publication, exit checkpoint, without importing React. `createSyncedWorkbenchPersistence` builds local/remote project sync state for one mounted Workbench; no process-wide maps, sets, flags, or caches hold that state. It also owns the unload journal: runtime asks it, via its persistence port, to journal unsaved state synchronously when page is hidden or unloads, and its load reconciles the journal into the draft store under the draft store's fencing rules. `WorkbenchProvider` constructs these lifetimes, starts the runtime and ends it with its exit checkpoint through the mount lifecycle adapter, and selects aggregate hydration state. Checkpoint and persistence lease outlive the unmount until it settles. Provider also hands the checkpoint to the account lifetime's next editor through a module-level map keyed by account scope; that editor loads after it, waiting at most 10 seconds; the tab's editor session is reference-counted across the two. Project transition commands commit pending editor drafts themselves, so callers do not flush before creating, opening, switching, or closing a project. ## Browser recovery -- Account-owned IndexedDB stores only unacknowledged project drafts, unload journal entries, active queue runs, receipt acknowledgements, and bounded recall values. Clean server documents are not mirrored. The journal has a database of its own, deleted with the workbench database by account cleanup and by clearing workspace data. -- Draft generations fence acknowledgements. Draft writer ownership and cross-tab notifications prevent one editor from overwriting another editor's unacknowledged work. -- **Unload journal.** Staging a draft takes fenced read-then-write transactions, which an unloading page never finishes. So when the page is hidden or unloads, before the save starts, each open project whose document is not what the next load would restore (its durably staged draft, or without one the acknowledged server copy) is written as one blind, explicitly committed IndexedDB write. It lives in its own database because Chromium drops such a write at unload while a read-write transaction of the same database is still open, which a stage in flight is. An entry carries the account, project, lineage (an editor session, possibly another tab's that the session blob handed on), the editor session of the page that wrote it, writer, base revision and canvas schema floor, and a draft generation: above everything the writer staged or journaled before, or equal to a stage of the same document still in flight. A writer's generations never restart while its page lives, even when a project is closed and reopened. Each write also deletes the writer's lower generations for the lineage, so there is at most one entry per lineage and writer, however long saves cannot be acknowledged. -- **Settled mark.** A lineage's writer claim records the generation through which its writer has settled its journal: everything it acknowledged, discarded, or holds in recovery. The acknowledgement and the draft deletion write it in the same transaction that settles or deletes the draft; a document back to what recovery holds (an undo, say) writes it on its next save, and claims the lineage for the writer if it has none. It is an optional claim field, so claims written earlier read as having settled nothing and the workbench database stays at version 4; a mark of an earlier writer is ignored. It is what tells "never staged" from "acknowledged and moved on" once an acknowledgement has deleted the draft, so an entry whose deletion failed, or that another tab reconciles at the moment of the acknowledgement, can never come back. -- The next load of the account reconciles every entry, in pages, before any writer claims a lineage. An entry becomes its lineage's next draft only where its writer could still have staged it: the lineage is unclaimed or still claimed by that writer, the writer has not settled that far, and the lineage holds no generation at or above the entry's. It keeps the stored draft's conflict, schema refusal, copy reservation, and base revision, as staging does, and then goes through the usual load rules, so a server revision the base no longer matches surfaces as a conflict. One exception: when the entry replaced a staged draft whose document the server now holds exactly (its save arrived but its answer did not), the draft is rebased onto that revision, as the late answer would have done, instead of conflicting with the writer's own save. An entry is discarded when another writer adopted, reclaimed, or retargeted the lineage, when its writer settled it, when the lineage already holds it or something newer, when it belongs to another account, or when its record schema is unknown or it is damaged. The draft is written before the entry is removed, so a crash in between leaves an entry the next pass discards as superseded. -- Another tab's load reconciles only the entries of pages that are gone. A page proves it is still running by its editor session's Web Lock, which it holds until its last editor releases it or the page unloads or crashes; an entry whose writing page (the editor session it names, not the lineage it is written under) another page still holds is left in place (a merely hidden tab is still writing its lineages, and staging its entry elsewhere would hand that tab's unsaved edit to another tab, where an undo could never retire it). An entry written before it named its page is treated as a gone page's. The hidden page settles the entry with its own saves, or the next load after it is gone recovers it. A browser that keeps a page holding its session in the back/forward cache keeps the lock held, so the entry waits until the page is restored (and saves) or evicted; one that evicts such pages (Chromium today) releases the lock, and the entry is recovered as a gone page's. A reload of the same tab reclaims its session, retrying for up to half a second (five retries 100 ms apart) while the old document lets go, and then reconciles its own entries. Two cases fall outside it. If the old document holds the lock longer, the reload takes a fresh session, its own entries and staged drafts count as a running page's on that load, and a later load recovers them once the old document is gone. A duplicated tab waits the same half second before taking a session of its own; if the original reloads within it, the duplicate can take the original's session instead and then takes the original's entries and staged drafts, while the reloaded original takes a fresh session. Staged drafts follow the same rule: a load does not adopt, auto-open, or offer for recovery a draft whose lineage is named after another running page's session, and the editor route does not count it. A draft records its lineage, not the page staging it, so it is judged by the session that lineage is named after: a lineage isolated for an unopenable draft names no page and is adopted as before. A lineage the session blob hands on (today only isolated ones) is not judged at all: a loading page claims its handed-on lineage directly, whether or not the page staging it still runs. Without Web Locks (a plain-HTTP deployment, say) liveness cannot be told, so every entry is reconciled and every draft is adopted as before. A hidden event during save-as-new journals the source project: if the page goes away before the copy exists, the edit is recovered into the conflicted source and the retried copy keeps its reserved document; once the copy exists, the source lineage has moved, so the entry is discarded and the copy is never written from it. -- An entry the draft store cannot take waits. One for a damaged lineage waits for that lineage's cleanup, for at most 7 days, and is then discarded. One refused for lack of storage is applied by a later load only while its lineage is unclaimed; the load that refused it usually claims the lineage when it opens the project, and then the edit is lost. Both report degraded local recovery for that project until its next stage succeeds. An unavailable journal (its database will not open, or reconciliation fails) is logged, not reported: recovery then works exactly as it did without the journal. A journal operation that fails on its own (a deletion whose transaction could not be opened, say) fails alone; the next hidden event journals again. -- A writer settles and removes its entries when the server acknowledges a generation at or above them, when the document returns to what recovery holds (on the next save; a hidden event meanwhile also deletes the entry, and the save still writes the mark), and when it deletes the project or discards its draft; a removal that fails leaves the entry settled, and one whose mark fails is retried with the next save. When an older generation is acknowledged, a newer entry is rewritten onto the acknowledged revision, as a newer staged draft is rebased. A journal write that aborts is written again on the next hidden event. Nothing is journaled for a closed session, after the account lifetime ended, for a project being deleted, or beyond 8 MiB of documents per event; larger projects rely on autosave, and the skip is logged. The editor route counts projects with entries as ones that would open, so a new project typed into and reloaded before its first save reopens. +- Account-owned IndexedDB stores only unacknowledged project drafts, unload journal entries, active queue runs, receipt acknowledgements, bounded recall values. Clean server documents not mirrored. Journal has its own database, deleted with the workbench database by account cleanup and by clearing workspace data. +- Draft generations fence acknowledgements. Draft writer ownership and cross-tab notifications stop one editor overwriting another editor's unacknowledged work. +- **Unload journal.** Staging a draft takes fenced read-then-write transactions, which an unloading page never finishes. So when page is hidden or unloads, before save starts, each open project whose document is not what next load would restore (its durably staged draft, or without one the acknowledged server copy) is written as one blind, explicitly committed IndexedDB write. Own database because Chromium drops such a write at unload while a read-write transaction of the same database is still open, which a stage in flight is. Entry carries account, project, lineage (an editor session, possibly another tab's that the session blob handed on), editor session of the page that wrote it, writer, base revision and canvas schema floor, and a draft generation: above everything the writer staged or journaled before, or equal to a still-in-flight stage of the same document. A writer's generations never restart while its page lives, even when a project is closed and reopened. Each write also deletes the writer's lower generations for the lineage, so at most one entry per lineage and writer, however long saves cannot be acknowledged. +- **Settled mark.** A lineage's writer claim records the generation through which its writer settled its journal: everything it acknowledged, discarded, or holds in recovery. Acknowledgement and draft deletion write it in the same transaction that settles or deletes the draft; a document back to what recovery holds (an undo, say) writes it on its next save, and claims the lineage for the writer if it has none. Optional claim field, so earlier claims read as settled nothing and workbench database stays at version 4; an earlier writer's mark is ignored. It tells "never staged" from "acknowledged and moved on" once an acknowledgement deleted the draft, so an entry whose deletion failed, or that another tab reconciles at the moment of acknowledgement, can never come back. +- Next load of the account reconciles every entry, in pages, before any writer claims a lineage. Entry becomes its lineage's next draft only where its writer could still have staged it: lineage unclaimed or still claimed by that writer, writer has not settled that far, and lineage holds no generation at or above the entry's. It keeps the stored draft's conflict, schema refusal, copy reservation, and base revision, as staging does, then goes through usual load rules, so a server revision the base no longer matches surfaces as conflict. One exception: when the entry replaced a staged draft whose document the server now holds exactly (its save arrived, its answer did not), the draft is rebased onto that revision, as the late answer would have done, instead of conflicting with the writer's own save. Entry discarded when another writer adopted, reclaimed, or retargeted the lineage, when its writer settled it, when lineage already holds it or something newer, when it belongs to another account, or when its record schema is unknown or it is damaged. Draft written before entry removed, so a crash between leaves an entry the next pass discards as superseded. +- Another tab's load reconciles only entries of pages that are gone. Page proves it still runs by its editor session's Web Lock, held until its last editor releases it or the page unloads or crashes; an entry whose writing page (the editor session it names, not the lineage it is written under) another page still holds is left in place (a merely hidden tab still writes its lineages, and staging its entry elsewhere would hand that tab's unsaved edit to another tab, where an undo could never retire it). Entry written before it named its page counts as a gone page's. Hidden page settles the entry with its own saves, or next load after it is gone recovers it. Browser that keeps a page holding its session in the back/forward cache keeps the lock held, so the entry waits until the page is restored (and saves) or evicted; one that evicts such pages (Chromium today) releases the lock, and the entry is recovered as a gone page's. Reload of same tab reclaims its session, retrying up to half a second (five retries 100 ms apart) while old document lets go, then reconciles its own entries. Two cases fall outside it. If old document holds the lock longer, reload takes a fresh session, its own entries and staged drafts count as a running page's on that load, and a later load recovers them once old document is gone. Duplicated tab waits the same half second before taking its own session; if the original reloads within it, the duplicate can take the original's session instead and then takes the original's entries and staged drafts, while reloaded original takes a fresh session. Staged drafts follow same rule: a load does not adopt, auto-open, or offer for recovery a draft whose lineage is named after another running page's session, and editor route does not count it. Draft records its lineage, not the page staging it, so it is judged by the session that lineage is named after: a lineage isolated for an unopenable draft names no page and is adopted as before. A lineage the session blob hands on (today only isolated ones) is not judged at all: loading page claims its handed-on lineage directly, whether or not the page staging it still runs. Without Web Locks (plain-HTTP deployment, say) liveness cannot be told, so every entry is reconciled and every draft adopted as before. Hidden event during save-as-new journals the source project: if page goes away before the copy exists, edit is recovered into the conflicted source and retried copy keeps its reserved document; once copy exists, source lineage has moved, so entry is discarded and copy is never written from it. +- Entry the draft store cannot take waits. One for a damaged lineage waits for that lineage's cleanup, at most 7 days, then is discarded. One refused for lack of storage is applied by a later load only while its lineage is unclaimed; the load that refused it usually claims the lineage when it opens the project, and then the edit is lost. Both report degraded local recovery for that project until its next stage succeeds. Unavailable journal (database will not open, or reconciliation fails) is logged, not reported: recovery then works exactly as without the journal. A journal operation failing on its own (a deletion whose transaction could not open, say) fails alone; next hidden event journals again. +- Writer settles and removes its entries when server acknowledges a generation at or above them, when document returns to what recovery holds (on next save; a hidden event meanwhile also deletes the entry, and the save still writes the mark), and when it deletes the project or discards its draft; failed removal leaves the entry settled, and one whose mark fails is retried with the next save. When an older generation is acknowledged, a newer entry is rewritten onto the acknowledged revision, as a newer staged draft is rebased. Aborted journal write is written again on next hidden event. Nothing journaled for a closed session, after account lifetime ended, for a project being deleted, or beyond 8 MiB of documents per event; larger projects rely on autosave, and the skip is logged. Editor route counts projects with entries as ones that would open, so a new project typed into and reloaded before its first save reopens. - Queue ownership uses Web Locks. Journal writes precede submission; backend receipts make retries safe after lost responses. Accepted IDs must be durable before their receipt can expire. Fresh runs that never reached browser storage use best-effort terminal acknowledgement instead. -- Completed, failed, and cancelled runs retain exact recall values in an LRU cache, capped at 500 entries / 32 MiB. Eviction removes recall convenience, not project data or active-run recovery. -- Projects with journal entries remain reachable even if another tab saved an empty session. Automatic reopening is bounded; remaining entries can be opened, exported as queue-recovery JSON, or explicitly discarded. Discarding local recovery does not cancel backend work. -- Browser-storage failures do not block backend project loads or saves. Recovery warnings distinguish degraded local durability from successful server persistence. +- Completed, failed, and cancelled runs keep exact recall values in an LRU cache, capped at 500 entries / 32 MiB. Eviction removes recall convenience, not project data or active-run recovery. +- Projects with journal entries stay reachable even if another tab saved an empty session. Automatic reopening is bounded; remaining entries can be opened, exported as queue-recovery JSON, or explicitly discarded. Discarding local recovery does not cancel backend work. +- Browser-storage failures do not block backend project loads or saves. Recovery warnings separate degraded local durability from successful server persistence. ## Leaving the editor -Navigation is never blocked; local capture of committed state is the contract, within the limits below. +Navigation never blocked; local capture of committed state is the contract, within limits below. -- **Exit checkpoint.** When the editor unmounts while its account is current, the persistence runtime stops observing the aggregate and commits drafts editors still hold (the draft registry). It then requests a save of the committed state at once, which queues behind any save in flight, and meanwhile flushes live Canvas pixels for at most 3 seconds. If that flush (or anything else) moved the state on, it saves once more; otherwise there is exactly one write. Each save stages the local recovery draft before pushing to the backend. The runtime and its persistence lease outlive the unmount until the checkpoint settles; if the page is hidden or unloads meanwhile (leaving the editor and reloading at once), it writes the unload journal for the committed state, unless a newer editor has superseded it. A failed or unacknowledged save, and a pixel flush that failed or timed out, are logged; recovery is left exactly as after a failed autosave. -- **Closing the last tab.** The empty session is marked closed when it is requested. From then until the editor leaves, nothing saves, so the project cannot be written back into the session. If the close does not complete (queue work started, the project changed, or the session write failed), the session is reopened and written again, and a save that came due while it was closed (an edit committed meanwhile) runs at once, so its outcome reaches the autosave status and retries as usual. -- **Account changes.** An ended account lifetime abandons its checkpoint: nothing of the old account is written afterwards. -- **Re-entry.** A remounted editor of the same account loads only after the previous checkpoint has settled and its persistence has closed, so it never hydrates an older snapshot and two editors never write at once. The provider keeps that handoff per account lifetime, keyed by its scope, so another account never waits on it. -- **Re-entry after 10 seconds.** The new editor loads anyway. The previous checkpoint is superseded: it starts no further write and gives up its project mutation locks. If it still had an unsaved revision, it logs that the revision was not written. Staging happens inside the persistence queue, so while a request stalls nothing newer than the save before it reaches local recovery; edits committed after that are lost in this case, and also if the tab is closed during the wait. The stalled request is not cancelled. Until it returns, or the tab closes, the superseded persistence keeps its draft-store connection and its hold on the tab's editor session. The new editor holds the same session, which stays locked to this tab until its last holder releases it. A late write is fenced like another tab's, by draft-writer claims and revision conflicts. -- **Hidden pages.** On `pagehide` and when the page becomes hidden, the runtime commits drafts editors still hold (the draft registry), writes the unload journal for an unsaved revision (including one whose save is in flight), then saves it at once instead of after the autosave debounce. Committing a draft leaves the control's value, caret and focus as they were. A revision whose save failed, or one already being saved, is not sent again; while the session is closed nothing is committed, journaled, or saved. The save is asynchronous, so it completes when the hidden page keeps running (switching tabs or apps, minimising, or entering the back/forward cache), and its acknowledgement retires the journal. It cannot complete while the page unloads (reload, closing the tab, navigating away), so across an unload the journal is what survives: the next load of the account recovers the edit as a pending draft and saves it. A tab restored from the back/forward cache writes through the same account-scope, draft-writer, and revision checks as every other write. There is no unload prompt. -- **What is and is not guaranteed.** In Chromium, an ordinary reload, tab close, or navigation away right after an edit keeps the edit, including one still held in a debounced field: the journal write is committed before the handler returns and the browser finishes it. Nothing is guaranteed when the browser or operating system is killed, crashes, or loses power before the write reaches disk (the transaction uses the default, relaxed durability), when a page is discarded without a `pagehide` or hidden event (an edit made after the last hidden event is lost), for projects over the journal budget, or when browser storage is unavailable or full. An undo made just before the page goes away is retired by the same kind of blind write and can be lost the same way: then the undone edit comes back on the next load, silently when the server revision has not moved. Other browsers follow the same IndexedDB rules but have not been verified to finish a committed write during unload. -- **Journal side effects.** An entry kept for a damaged lineage or for full storage reports degraded local recovery for its project on every load that keeps it, even when that project is not opened in the session. The editor route counts any project with an entry of a page that is gone (or of this page, by the session it persisted) as one that would open, so with an empty session such an entry, or one that reconciliation will discard, opens the editor instead of Home; an entry or staged draft of another page that still runs does not, since the editor would leave it alone. -- **Project transitions.** Creating, opening, switching, and closing projects commit pending editor drafts at the moment the open or active projects change, so an edit typed while a caller awaited hydration or a close push lands on the project it was typed in. Close can require the version it pushed; drafts that change the project refuse it, and the close flow pushes again. +- **Exit checkpoint.** When editor unmounts while its account is current, persistence runtime stops observing the aggregate and commits drafts editors still hold (the draft registry). Then requests a save of committed state at once, queued behind any save in flight, and meanwhile flushes live Canvas pixels for at most 3 seconds. If that flush (or anything else) moved the state on, it saves once more; otherwise exactly one write. Each save stages the local recovery draft before pushing to backend. Runtime and its persistence lease outlive the unmount until checkpoint settles; if page is hidden or unloads meanwhile (leaving editor and reloading at once), it writes the unload journal for committed state, unless a newer editor superseded it. Failed or unacknowledged save, and pixel flush that failed or timed out, are logged; recovery left exactly as after a failed autosave. +- **Closing the last tab.** Empty session is marked closed when requested. From then until editor leaves, nothing saves, so project cannot be written back into the session. If close does not complete (queue work started, project changed, or session write failed), session is reopened and written again, and a save that came due while closed (an edit committed meanwhile) runs at once, so its outcome reaches autosave status and retries as usual. +- **Account changes.** Ended account lifetime abandons its checkpoint: nothing of the old account is written afterwards. +- **Re-entry.** Remounted editor of same account loads only after previous checkpoint settled and its persistence closed, so it never hydrates an older snapshot and two editors never write at once. Provider keeps that handoff per account lifetime, keyed by its scope, so another account never waits on it. +- **Re-entry after 10 seconds.** New editor loads anyway. Previous checkpoint is superseded: starts no further write and gives up its project mutation locks. If it still had an unsaved revision, it logs that the revision was not written. Staging happens inside the persistence queue, so while a request stalls nothing newer than the save before it reaches local recovery; edits committed after that are lost in this case, and also if tab is closed during the wait. Stalled request is not cancelled. Until it returns, or tab closes, superseded persistence keeps its draft-store connection and its hold on the tab's editor session. New editor holds the same session, which stays locked to this tab until its last holder releases it. Late write is fenced like another tab's, by draft-writer claims and revision conflicts. +- **Hidden pages.** On `pagehide` and when page becomes hidden, runtime commits drafts editors still hold (the draft registry), writes the unload journal for an unsaved revision (including one whose save is in flight), then saves it at once instead of after autosave debounce. Committing a draft leaves control's value, caret and focus as they were. Revision whose save failed, or one already being saved, is not sent again; while session is closed nothing is committed, journaled, or saved. Save is asynchronous, so it completes when the hidden page keeps running (switching tabs or apps, minimising, or entering back/forward cache), and its acknowledgement retires the journal. It cannot complete while page unloads (reload, closing tab, navigating away), so across an unload the journal is what survives: next load of the account recovers the edit as a pending draft and saves it. Tab restored from back/forward cache writes through the same account-scope, draft-writer, and revision checks as every other write. No unload prompt. +- **What is and is not guaranteed.** In Chromium, an ordinary reload, tab close, or navigation away right after an edit keeps the edit, including one still held in a debounced field: journal write is committed before the handler returns and the browser finishes it. Nothing guaranteed when browser or OS is killed, crashes, or loses power before the write reaches disk (transaction uses default, relaxed durability), when a page is discarded without a `pagehide` or hidden event (edit made after last hidden event is lost), for projects over the journal budget, or when browser storage is unavailable or full. Undo made just before page goes away is retired by the same kind of blind write and can be lost the same way: then the undone edit comes back on next load, silently when server revision has not moved. Other browsers follow same IndexedDB rules but are not verified to finish a committed write during unload. +- **Journal side effects.** Entry kept for a damaged lineage or full storage reports degraded local recovery for its project on every load that keeps it, even when that project is not opened in the session. Editor route counts any project with an entry of a gone page (or of this page, by the session it persisted) as one that would open, so with an empty session such an entry, or one reconciliation will discard, opens the editor instead of Home; an entry or staged draft of another still-running page does not, since editor would leave it alone. +- **Project transitions.** Creating, opening, switching, and closing projects commit pending editor drafts the moment open or active projects change, so an edit typed while a caller awaited hydration or a close push lands on the project it was typed in. Close can require the version it pushed; drafts that change the project refuse it, and close flow pushes again. ## Intentional cutover -The old workbench mirror, sync map, and refused-project localStorage keys are deleted on initialization. Their contents are not migrated. Export any needed browser-only work with the old build before upgrading. +Old workbench mirror, sync map, and refused-project localStorage keys are deleted on initialization. Contents not migrated. Export any needed browser-only work with the old build before upgrading. -Cold-start offline editing is not supported. When the backend cannot load, the unavailable screen provides retry and local draft/run exports. Conflicted or schema-refused drafts are retained until explicitly resolved or deleted; they are never silently evicted. +Cold-start offline editing not supported. When backend cannot load, unavailable screen offers retry and local draft/run exports. Conflicted or schema-refused drafts kept until explicitly resolved or deleted; never silently evicted. ## Additive canvas values -A new value in a closed canvas set, such as a control layer's adapter kind (`anima_lllite`), is added without raising the canvas schema version or the project's compatibility floor. Builds that know the value load and save it unchanged; builds that do not refuse the canvas rather than rewrite it, because coercing an unknown kind to a known one would replace the user's adapter on the next autosave. In an older build the project is not opened and the user sees that it "contains canvas data this version of Invoke cannot read"; the saved copy is untouched and can be exported, and a build that knows the value opens it as saved. Raise the schema version instead when older builds could misread a document without refusing it. +New value in a closed canvas set, such as a control layer's adapter kind (`anima_lllite`), is added without raising canvas schema version or project's compatibility floor. Builds that know the value load and save it unchanged; builds that do not refuse the canvas instead of rewriting it, since coercing an unknown kind to a known one would replace the user's adapter on next autosave. In an older build the project is not opened and user sees that it "contains canvas data this version of Invoke cannot read"; saved copy is untouched and exportable, and a build that knows the value opens it as saved. Raise schema version instead when older builds could misread a document without refusing it. -Load does not reinterpret a known value either. An Anima control layer saved as ControlNet before ControlNet-LLLite was supported keeps its kind and model; the layer settings name the adapter type to switch to and offer a one-step switch that keeps the model, weight and step range where the new kind can use them. +Load does not reinterpret a known value either. Anima control layer saved as ControlNet before ControlNet-LLLite was supported keeps its kind and model; layer settings name the adapter type to switch to and offer a one-step switch that keeps model, weight and step range where the new kind can use them. ## Font dependencies -Custom text retains an immutable font reference and explicit variation coordinates. Browser registration names are runtime state and never enter the project document. New typography requires Canvas schema 4; documents without it retain their existing compatibility floor. +Custom text keeps an immutable font reference and explicit variation coordinates. Browser registration names are runtime state, never enter the project document. New typography requires Canvas schema 4; documents without it keep their existing compatibility floor. -Project archives record font dependencies by content hash. Export defaults to references only; **Include font files** embeds the original referenced files, once per hash, after authenticated download and checksum verification. A failed required font download fails the embedded export rather than producing a silently incomplete archive. +Project archives record font dependencies by content hash. Export defaults to references only; **Include font files** embeds original referenced files, once per hash, after authenticated download and checksum verification. Failed required font download fails the embedded export instead of producing a silently incomplete archive. -Import verifies declared entries and checksums, then validates all embedded fonts before creating server resources. Fonts are uploaded privately and the canonical document's references are remapped to the destination IDs. Duplicate uploads reuse existing private records. Rollback removes only newly created fonts and only when project creation has not been attempted or has confirmed absence; an ambiguous create response retains the resources. Font quota failures offer an explicit references-only retry. Changing accounts cancels the operation and prevents cleanup under another user's credentials. +Import verifies declared entries and checksums, then validates all embedded fonts before creating server resources. Fonts uploaded privately; canonical document's references remapped to destination IDs. Duplicate uploads reuse existing private records. Rollback removes only newly created fonts and only when project creation was not attempted or has confirmed absence; ambiguous create response keeps the resources. Font quota failures offer explicit references-only retry. Changing accounts cancels the operation and prevents cleanup under another user's credentials. ## Verification -Run `pnpm lint`, `pnpm test`, `pnpm test:browser`, and `pnpm run test:performance:build` from webv2. Queue receipt tests also cover backend admission, account isolation, project deletion, partial acceptance, and idempotent retries. Browser tests exercise actual IndexedDB transactions and Web Locks. +Run `pnpm lint`, `pnpm test`, `pnpm test:browser`, and `pnpm run test:performance:build` from webv2. Queue receipt tests also cover backend admission, account isolation, project deletion, partial acceptance, idempotent retries. Browser tests exercise real IndexedDB transactions and Web Locks. diff --git a/invokeai/frontend/webv2/src/workbench/settings/README.md b/invokeai/frontend/webv2/src/workbench/settings/README.md new file mode 100644 index 00000000000..43834fa312d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/settings/README.md @@ -0,0 +1,15 @@ +# Settings contributions + +Paths are relative to the webv2 `src/` directory. + +Owners declare settings; Workbench presents them. Platform UI owns domain-neutral field metadata and controls, not setting values or persistence. Widget manifest exposes a `settings` contribution: localized field metadata, ordered `quick` subset, deferred field binding loader. Full dialog includes every declared field. Bindings keep owner's normalization and command path, including Gallery's size/density conversion and project/instance fencing. + +Workbench composes widget and application contributions into a metadata-only catalog, shared by search, dialog navigation, quick popovers, and command-palette destinations. Features publish contribution metadata through registered public entries; loading metadata must not load their UI or start data queries. Application-owned additions can join a widget section without changing owner of their values. Deferred resources cache code only; field subscriptions exist only while their controls are mounted. + +Section placement does not determine storage lifetime. Account/install preferences, project settings, widget-instance values, and server configuration keep existing persistence and authorization. Each surface lists only settings it can edit: Launchpad Preferences page omits project and widget settings; workbench dialog omits widget settings for widget types the open project lacks. Shell resolves explicit project/instance targets and disposes target-bound editing when projects change. Owner bindings fence writes against stale account/project lifetimes. + +Settings host owns dialog's presence lifecycle and keeps its root mounted through closing. Body deferred until first opening, stays present during exit, unmounts afterward. Quick-popover handoff carries both instance target and original gear for focus restoration. + +Before section navigation becomes available, deferred dialog prepares binding/editor code without mounting editors or starting their queries. Section and scroll position survive closing within account lifetime. Shell renders a shared modified-setting indicator; bindings decide whether a value differs from owner’s canonical default. Quick settings use shared arrowed popover with inner scroll area; dialog selects keep their bounded scroll areas inside modal focus scope. + +Production chunking keeps catalog and manifest metadata separate from editor boot UI. Build gate checks that Launchpad’s palette and settings dialog do not statically include editor application or widget renderer. Shared settings controls stay in common UI chunk; owner field bindings and custom editors stay deferred. diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/README.md b/invokeai/frontend/webv2/src/workbench/widget-frame/README.md new file mode 100644 index 00000000000..b9ca9b03a8f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/README.md @@ -0,0 +1,60 @@ +# Widget placement, focus, and windows + +Paths are relative to the webv2 `src/` directory. + +Placement: `workbench/floatingWindows.ts`; focus: `workbench/focusRegions.tsx`; shortcut hints: `workbench/hotkeys/`; window chrome: `workbench/widget-frame/`. + +## Placement and floating windows + +Project places each widget instance in regions (`widgetRegions`) or a floating window (`floatingWidgets`), never both. `workbench/floatingWindows.ts` owns rules; reducer, placement normalization, and rails all read placement through it. + +- **A floating instance belongs to no region's `instanceIds`.** Floating removes every membership, repairs active selections it leaves behind, and records one return destination (`returnRegion`, `returnIndex`). +- **A return region keeps a marker for each of its windows.** `getRegionOrder` is region's complete order (docked members plus markers); a marker's `returnIndex` is its position in it. Docking counts docked members ahead of marker, so docking in any order never moves what rail shows. Reducer edits that add, remove, or move members go through that order so indices stay in step; reordering docked members fills docked positions around stationary markers. +- **Empty-center recovery is the one exception to origin-only docking.** A center emptied by a float keeps naming instance in `activeInstanceId`. While still empty and still naming that instance, Dock restores center membership plus origin; Remove restores center membership only (no rail slot, no panel). No extra field persisted; a center that gained a view or changed its pointer is left alone. "Empty" means no members at all: reducer has no widget registry, so a center holding only a disabled or unregistered view is not recovered and shows that view's unavailable state until another view is chosen. +- **An instance opened in the center is shown once.** It may stay a member of a side panel, but a left or right panel fronting it fronts its next member instead, or collapses when it holds nothing else. Only pointer moves, so a hidden panel stays hidden. Bottom region left alone: reducer has no registry to tell its panels from tooltip-only status items. This lets every "open in Preview" path, and center Preview toggle, reuse the Preview a rail holds. +- **Region requests never discard a window.** Selecting, moving, or reordering an instance a region does not hold is a no-op; so is enabling an instance that floats. Opening a widget type whose instance floats restores its return placement first, then opens the requested one, and auto-routes once. +- **One normalization.** `normalizeFloatingPlacement` runs on project hydration, on stored presets, on preset application, and inside preset comparison (which includes `returnIndex`), so an applied preset always matches snapshot it came from. Idempotent. Indices stored before markers were counted are read deterministically (ascending, ties by instance id, missing ones appended); best-effort, does not reconstruct original arrangement. + +- **A closed window leaves its geometry behind.** When a window closes (docked, removed, or closed by a preset or an undo), its position and size move to `Project.lastFloatingGeometry` (`rememberClosedWindows`); floating instance again takes them back out and reopens window there, windowed; only a first float cascades. Float command passes viewport, so a rectangle remembered in a larger one is brought wholly on screen, not reopened as a sliver. Memory holds only instances that exist and are not floating now, in every state the reducer produces, so a save is exactly what a load reads back. Persisted with project and validated on load, but not layout: preset snapshots, drift comparison, and undo snapshots do not carry it. + +- **Each preset keeps its working arrangement per project.** Switching away stores outgoing arrangement in `Project.presetWorkingLayouts` when it differs from saved preset; switching to a preset lays out its copy there when it has one, else saved snapshot. + - Copies are an ordered list, oldest first, at most one per preset and at most 16 (oldest goes first). Each is a `LayoutPresetSnapshot` (placement, panels, center view, instance identity and title — never widget state) and never names active preset: live layout is its copy. + - Opening or hydrating a project drops copies of presets the account no longer has and copies equal to their saved preset; every switch drops the former, so orphans never hold one of the 16 places. Deleting a preset drops its copies from every open project; a project that was on it moves to the default preset the way a switch would. Renamed preset keeps its id and its copy. + - Save on active preset writes live layout; on another preset it writes that preset's copy to account and leaves copy in place, where it no longer reads as unsaved because it equals saved preset, so a lost account write still leaves arrangement recoverable until hydration proves copy redundant. Saving a preset also drops every other open project's copy of it that equals its previous or its new saved arrangement: such a copy was no change of that project's own, and keeping it would show outdated arrangement as unsaved and could overwrite newer save. + - Revert on active preset reapplies saved snapshot, as choosing active preset again does; on another it drops copy as an undoable step. Pressing active tab while a cold switch is still loading cancels that switch instead. Saving arrangement as a new preset moves project onto new preset. + - Copies are persisted, validated on load like stored presets, and captured in undo snapshots with `layout.presetId`, so undoing a switch restores both. + +Rails subscribe to placement only (`getWidgetPlacementProject` projects `floatingPlacements`), so a window's geometry, mode, and stacking change without re-rendering them. A marker is not a tab: it stays out of sortable list, activating it raises and expands its window, and Dock and Remove live in its context menu. Marker UI exists only on left and right rails. + +## Focus + +Workbench focus is transient, never persisted. `WorkbenchFocusProvider` (`workbench/WorkbenchRuntime.tsx`) owns one controller (`workbench/focusRegions.tsx`) above UI ports, hotkey runtime, and shell; no module-level focus state. Target is a docked region or one floating instance. + +- **Keyboard navigation.** `Alt+Shift+Arrow` (`Ctrl+Cmd+Arrow` on macOS, where Option+Shift+Arrow selects text by word or paragraph, Ctrl+Arrow chords belong to Mission Control, and Ctrl+Option+Arrow is VoiceOver's navigation chord) moves focus to nearest visible docked region in that direction, using current frame geometry and preferring alignment; with no current target it enters center. Preserves widget selections, skips hidden regions, stops at edge. From a floating window, its center is starting point on movement axis so overlapping docks stay reachable; when no dock lies beyond it in that direction, focus returns to dock under its center. `Alt+PageUp` and `Alt+PageDown` cycle focused docked region's enabled panel views in placement order, wrapping at either end and focusing owning region once selected instance is visible. Side frames wait for their lazy view to load; persistent center frame can take focus immediately. Toolbar, tooltip, popover, and floating placements excluded; a single eligible widget stays open. All six commands are customizable in Hotkeys settings, work from editable fields (defaults are chords no text field binds on that platform), and respect modal suspension. Ordinary Tab and arrow-key handling unchanged. + +- **One judge of the target.** `getTarget()` returns target only while it describes something on screen: set under current project and account, and (for a window) still floating. Provider also clears eagerly when active project changes, account changes, or workbench unmounts; that notifies subscribers and abandons a focus move still waiting for its widget to mount. Returning to a project never brings old focus back. +- **Activation.** Pointer-down and keyboard focus activate a region or window; hover does not. Active window carries the same accent outline, under the same preference, as a focused region; no region is outlined while a window is active. Window passes project id it was rendered for, so a late event from a project that left screen is refused before it can raise anything. +- **Raising.** A press inside a window always asks reducer to raise it (no-op for topmost window). Focus raises a window only when it makes that window the active one: focus moving inside already-active window, or returning from a closing dialog or popover, writes nothing. Ordinary focus leaves a shaded window shaded; explicit reveal (marker, center "Show window", recall) expands it. +- **Recall is the one reveal that moves no focus.** Recall raises and expands its window but leaves keyboard focus, outline, and hotkey target where user had them; next press or focus change brings them back into agreement. Since focus inside active window does not re-raise, it cannot re-bury the window a recall just revealed. +- **Keys follow the outline.** A press inside a window moves keyboard focus into it when focus was elsewhere; a press on a docked region pulls focus out of a floating window. Floating layer renders windows in fixed DOM order and stacks them by z-index alone: raising a window never moves its node, which would drop the focus that raised it. +- **Focus follows placement:** into window after Float and after a marker or "Show window" reveal; into the region a docked rail tab shows in when it is selected, so keys stop going to a window that held them; into return region after Dock; after Remove from a marker's menu, into center when it gets its view back, else into the panel the window's own rail is showing, or center when that rail shows none (emptied by float or collapsed). Removing a window from a rail's enable menu moves no focus: that menu stays open and keeps it. A focus move targets region frame on screen (side regions keep earlier panels mounted but hidden), waits a bounded time for its widget to mount, and is abandoned if user presses elsewhere first. Every panel frame carries its instance's identity, including stand-in for a widget whose view cannot render, so a move into it lands. +- **Hotkeys read the same target.** `HotkeyContext.focusedRegion` is `WidgetRegion | 'floating' | null`; persisted `WidgetRegion` unchanged. A key press is aimed at widget under event target first and, outside any widget's DOM, at active floating instance or focused region's active member (`resolveHotkeyTarget`). A region's active pointer counts only while it names a member, so a view that floated out of an emptied center answers as a window, not as center. Floating command source carries `region: 'floating'` and is never used to index `widgetRegions`. Generic `focused-region` shortcuts match under a window, shortcuts tied to a named docked region do not, and a floating widget's own `focused-region` contribution follows its window at the same priority it has when docked. +- **Modal dialogs suspend application shortcuts.** Every dialog is built on Platform `Dialog` root (`platform/ui/Dialog.tsx`; lint rejects Chakra's dialog and its drawer, which has no such root yet), which registers with `platform/ui/modalPresence` while its own open state holds, unless declared `modal={false}`. A dialog whose module is still loading uses `Dialog.Pending` as suspense fallback, so it is modal from keypress that opened it. While any registration stands, hotkey runtime runs only hotkeys marked `allowInModal` (command palette toggle), so invoke, global and widget shortcuts stay inactive wherever focus is, and keys the dialog handles itself (Enter, Escape, typing) keep browser behavior; Canvas engine's own key listener takes no key either. Both ask about the key press they are handling: a press that began under a modal still counts as aimed at it, because the Escape that dismisses a dialog releases its presence before the same press reaches bubble-phase listeners. Presence follows interactivity, not DOM: a closing dialog that stays mounted through its exit animation is inert and releases at close, so next press reaches application; reopening re-registers at once, an outer dialog stays registered while an inner one closes, and an unmount releases without a close. Media paste reads the same presence. Popovers and menus do not register. + +## Contextual shortcut guidance + +Shortcuts is a bottom-only popover widget. Built-in layouts put it on right immediately before Notifications; saved and custom layouts keep their placements and can enable it through Bottom Widgets. Whole chip opens its guide; normal widget controls own reordering, alignment, visibility and dismissal. Its `compactSizing` metadata asks status-bar host for remaining capacity: host observes its strip, two clusters and spacer; guide measures only its own keycaps. Up to four whole hints fit without increasing status bar height; a keyboard icon keeps access when no hint or label fits. If all relevant bindings are unassigned, popover explains that state and provides keyboard shortcut settings. + +Workbench focus lifecycle also owns transient hint-source registry and last editing element. A keyed Canvas registration mounts one stable source for its project/instance engine lease; changing generation/staging locks publish through a boolean-filtered project subscription, not by replacing that source. Lock reads cache document revision, staged-candidate array and queue-item array; unrelated project updates do not rescan queue work. Lock predicate tests candidate presence and active work without constructing staging slots. Narrow tool/session subscriptions cache scalar hint inputs; guide never acquires an engine lease or imports property forms. Sources are fenced by project and instance and released with their owner. Project/account changes clear editing origin. Widget's `preserveWorkbenchFocus` metadata keeps its compact chip and portal from replacing that origin; closing returns focus there. Guide activation and Escape do not reach Canvas session handlers; configured global chords keep normal runtime handling. + +Command hints resolve against runtime catalog and mounted extension contributions, including custom bindings, scope conflicts, editable fields and modal exclusions. Command bindings reserved for guide activation or dismissal (bare Enter/Space and Escape chords) are omitted in favor of a usable alternative; fixed Canvas instructions may still describe those keys. Fixed gestures stay Canvas-owned and distinguish selection modifiers at press from constraints during drag. Properties shows them as “On Canvas”; active modal presence hides background guide. Pointer coordinates and pixel epochs are not hint subscriptions. + +## Window interactions + +- **Geometry is stored once and adapted in CSS.** All windows share one static rule set; stored geometry and stacking rank arrive as inline custom properties (registered as non-inheriting, so a drag restyles window, not widget inside it), so a commit changes values rather than minting a class. Position keeps a 48px sliver reachable (`FLOATING_VIEWPORT_MARGIN_PX`, the same margin `clampWindowToViewport` applies to commits); displayed size is capped to viewport. So a shrinking viewport persists nothing; stored geometry applies again when there is room. +- **Gestures start from the rendered rectangle**, not stored geometry, so a window CSS is clamping or capping does not jump. A gesture commits only what it changed: a move keeps stored size, and a resize keeps stored values of any axis it did not change (`commitResizedAxes`), because what is on screen there may only be viewport's clamp or cap. Viewport clamp applies to changed axes alone, so a vertical resize never rewrites a stored x, nor a horizontal one a stored y. A press that never moves, or a drag against minimum or cap, commits nothing. Keyboard steps follow the same rules. +- **One commit per gesture.** Pointer moves are coalesced to animation frames and previewed as inline custom properties on window element; store is written once, when gesture ends: on release, or when pointer is taken away (`pointercancel`, a release the page never saw), which keeps what was dragged so far. Escape discards preview. Each keyboard step is its own commit. +- **Resize handles.** Eight pointer-only handles (four edges, four corners) straddle border with matching cursors and non-overlapping hit areas, above an isolated content frame so widget stacking cannot cover them. Inside bottom-right corner sits `ResizeCorner`, the single labelled, keyboard-operable resize control; pointer-only handle there is clipped to band outside border. That control announces size on screen (stored size as viewport caps it) and follows viewport, so its value and keyboard steps agree. `resizeFloatingGeometry` anchors opposite side, holds size between minimum and viewport (a viewport smaller than minimum lowers it to viewport, so shown size is not mistaken for a resize), and stops top edge at viewport's top. +- **One gesture path.** Title bar, grip, and every handle start through window's single pointer session, so a new gesture replaces the one before it and resize gestures all raise platform's resize-drag signal. Preview names the mode the gesture began in and applies only while window is still in that mode: a window that maximizes or collapses under a gesture shows its new frame at once, and gesture ends itself (releasing drag cursor, committing nothing) next time it hears from pointer. Docking, removing, or switching project unmounts host (hosts keyed by project and instance), which aborts session. +- **Title bar.** Double-click maximizes or restores, and expands a collapsed window; Restore returns to windowed geometry, which maximizing never touches. Collapse and Expand are explicit buttons. Label truncates first, down to its icon; contributed widget actions then give way in a strip that scrolls, without a scrollbar, by focus, sideways scroll, or touch, so a focused action is always in view with its ring; window's own controls never shrink. +- **Shaded bodies stay mounted.** Shaded body is hidden with React `Activity`, so its local state survives and no second copy mounts. Drafts are flushed before shading, docking, and removing, because hiding or unmounting an editor stops its flusher. diff --git a/tests/AGENTS.md b/tests/AGENTS.md index b75684872b9..cc896580507 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -1,17 +1,17 @@ # Python tests -- Read production-code guidance; use existing suites (`tests/app/`, `tests/backend/`, or the owning root suite) and appropriate `conftest.py`/`fixtures/` fixtures. UI copy and docs checks belong to their packages. -- Tests reading outside `tests/`, `scripts/`, or the Python package tree must add those paths to `.github/workflows/python-tests.yml` change filters so fixture-only changes trigger pytest. -- Name tests by outcome; cover meaningful edge/failure cases and survive behavior-preserving refactors. Prefer real temporary SQLite/filesystem/tensor operations. Mock external services, expensive models, or unavailable hardware without mocking the transaction, authorization, concurrency, or numerical behavior under test. -- Isolate paths, databases, accounts, clocks, seeds, environment, and network behavior. Clean up subprocesses, listeners, patches, and resources; never use production model/database/output directories. -- Cover affected rollback, revisions, cross-user isolation, retries, cancellation, bounds, and cleanup. IndexedDB, Web Locks, focus, and rendering belong in webv2 browser tests. -- Use root pytest commands. CI runs xdist workers per vCPU, except windows, which runs fewer; `--dist loadfile` in `addopts` keeps each file on one worker, with arbitrary file assignment/order. Each file must establish its own process-global state (e.g. JWT secret) and pass alone; verify new files independently. -- Default tests assert behavior, not throughput/latency under runner contention. Generous hang ceilings are acceptable; CPU time tolerates contention better than wall time. Hardware/quiet-machine measurements require `slow`, excluded by default and CI. On development machines, use `-m slow` for that lane or `-m ""` for everything. +- Read guidance for the production code. Use existing suites (`tests/app/`, `tests/backend/`, or the owning root suite) and matching `conftest.py`/`fixtures/` fixtures. UI copy and docs checks belong to their packages. +- Tests that read files outside `tests/`, `scripts/`, or the Python package tree: add those paths to `.github/workflows/python-tests.yml` change filters, so fixture-only changes trigger pytest. +- Name tests by outcome. Cover meaningful edge and failure cases. Survive behavior-preserving refactors. Prefer real temporary SQLite/filesystem/tensor operations. Mock external services, expensive models, unavailable hardware; never mock the transaction, authorization, concurrency, or numerical behavior under test. +- Isolate paths, databases, accounts, clocks, seeds, environment, network behavior. Clean up subprocesses, listeners, patches, resources. Never use production model/database/output directories. +- Cover affected rollback, revisions, cross-user isolation, retries, cancellation, bounds, cleanup. IndexedDB, Web Locks, focus, rendering belong in webv2 browser tests. +- Use root pytest commands. CI runs one xdist worker per vCPU; windows runs fewer. `--dist loadfile` in `addopts` keeps each file on one worker; file assignment and order are arbitrary. Each file sets up its own process-global state (e.g. JWT secret) and passes alone. Verify new files on their own. +- Default tests assert behavior, not throughput or latency under runner contention. Generous hang ceilings fine. CPU time tolerates contention better than wall time. Hardware/quiet-machine measurements need `slow`, excluded by default and in CI. Dev machines: `-m slow` for that lane, `-m ""` for everything. - Keep a test's run time to the work it checks; windows runs the suite on two workers, so idle seconds there set the CI wall clock. Check new files with `--durations=10`, which splits setup, call and teardown. - No real waits. Poll intervals, retry backoffs and grace periods belong in module-level constants that a fixture can shorten. The root `conftest.py` already shortens the download, installer and image-index queue polls, so a service's `stop()` in teardown returns at once. Shorten a retry backoff only in the tests that script its failures, as `_fake_move` in `test_model_install.py` does: the backoff is what lets a real Windows file handle close. - Passwords hash at bcrypt cost 4 in tests. Request `production_password_hashing` to assert or measure the production cost. - A fresh interpreter costs about ten seconds of imports. Spawn one only when isolation is the point (import order, `sys.modules`, process-global state), and check many cases inside one subprocess unless each case needs its own cold start. - A full `gc.collect()` walks the worker's whole heap, a third of a second late in a session. Tests that collect repeatedly freeze the pre-existing heap first, as `test_model_cache_ram_budget.py` does. - Mark a test `platform_independent` only when no operating system can change its outcome for the same dependency set: import-graph boundaries in a fresh interpreter, static source scans, deterministic counts or shapes. Windows CI, which installs the linux dependency set, deselects them; macOS runs a different torch and keeps them. Numerics (BLAS kernels differ per platform), process-spawning behavior (start methods, helper executables), filesystem paths and locking, and dependency smoke tests stay unmarked. -- State hardware prerequisites and skipped/unavailable devices. CPU/mocked passes do not establish CUDA/ROCm/MPS/XPU behavior. -- Do not run a CPU bf16 matmul unless low precision is the behavior under test: torch's CPU bf16 GEMM faults with an illegal instruction (`0xc000001d`) on part of the hosted windows fleet, which aborts the worker and fails the job. Run the forward at float32 and assert the width separately, as the nvfp4 loader tests do; skip on win32 only when bf16 itself is the subject. +- State hardware prerequisites and skipped/unavailable devices. CPU or mocked passes do not prove CUDA/ROCm/MPS/XPU behavior. +- No CPU bf16 matmul unless low precision is the behavior under test. Why: torch's CPU bf16 GEMM faults with an illegal instruction (`0xc000001d`) on part of the hosted windows fleet; that aborts the worker and fails the job. Run the forward at float32 and assert the width separately, as the nvfp4 loader tests do. Skip on win32 only when bf16 itself is the subject.