# Editor and managed sessions

The editor and its managed sessions: the workspace and session lifecycle, session-bound control of the running game, and the human-facing editor surface.

This document describes what is implemented. The editor has its interface — toolbar, viewport, project and inspector, activity feed, jobs and evidence, drawn with egui in one pass with the game image — and behind it the workspace service link and session authority, focus with held-input release, the cross-OS placement rule, and the Windows GUI-subsystem build.

## Contents

1. [Entry points](#entry-points)
2. [Session model](#session-model)
3. [Control policy](#control-policy)
4. [Device input](#device-input)
5. [Focus](#focus)
6. [Handshake](#handshake)
7. [Headless worker mode](#headless-worker-mode)
8. [Scenes](#scenes)
9. [Editing the scene](#editing-the-scene)
10. [Selection, Outliner and view state](#selection-outliner-and-view-state)
11. [Transform gizmos](#transform-gizmos)
12. [Play controls](#play-controls)
13. [Viewport modes](#viewport-modes)
14. [Unsaved work: the prompt, autosave and recovery](#unsaved-work-the-prompt-autosave-and-recovery)
15. [Editor Preferences](#editor-preferences)
16. [Prefabs](#prefabs)
17. [World Settings](#world-settings)
18. [Project Settings](#project-settings)
19. [Behaviours](#behaviours)
20. [Importing models](#importing-models)
21. [The UI category and the viewport preview](#the-ui-category-and-the-viewport-preview)
22. [The Output Log](#the-output-log)
23. [The Undo History](#the-undo-history)
24. [The Input table](#the-input-table)
25. [Exporting a game](#exporting-a-game)
26. [Restart state](#restart-state)
27. [The workspace service](#the-workspace-service)
28. [The interface](#the-interface)
29. [The shell](#the-shell)
30. [The theme](#the-theme)
31. [Windows build](#windows-build)
32. [Simulating the human in tests](#simulating-the-human-in-tests)
33. [Known limits](#known-limits)

## Entry points

| Command | Build | Does |
|---|---|---|
| `argus-editor` | `cargo build -p argus-editor` | Opens the window on the **launcher** (see [The launcher](#the-launcher)): recent projects, the projects in the default projects folder, New project from the bundled template, Open by path. What the desktop shortcut runs |
| `argus-editor <project>` | as above | Opens the project (its folder or its `argus.project.json`) in the editor window. A project that does not open shows the launcher with the reason instead of no window; a project on the other operating system's file system is still refused before the window |
| `argus editor <project> [flags]` | `cargo build -p argus --features editor` | Runs `argus-editor` beside this executable (or `$ARGUS_EDITOR`) with the same arguments. On Unix it `exec`s, leaving no wrapper process; on Windows it waits for the editor as a child and exits with its status, so a launcher that checks pids starts `argus-editor.exe` directly |
| `argus-editor worker <scene> --session-id … --session-kind test --job-id … [flags]` | as above | Runs one test session as a headless worker, in this process (`argus::runner::run`, no child): the spawned pid is the serving pid. What the editor starts for every test session |
| `argus run <scene> --session-id … --session-kind … [flags]` | `cargo build -p argus --features managed` | The same managed runtime through the engine binary, for other launchers (SchedB's jobs, the harness) |
| `argus run <scene> …` | default | Standalone, unchanged: no session, every documented flag and default as before |

**Launchers** (the scheduler, the harness, anything that verifies a worker's pid, socket owner or start time) start `argus-editor worker …` or `argus run …` built with `--features managed` directly. Never through the `argus editor` shim, `cargo run` or a shell wrapper: each of those leaves the process the launcher spawned wrapping the one that serves (the shim does on Windows; on Unix it `exec`s).

The editor is its own package (`crates/editor`), so the exported game links no editor code. Managed sessions are the `managed` feature of `crates/app`, off by default: it brings `argus-workspace` (and with it `regex`), and the default build is the deploy engine that `scripts/size-gate.sh` measures against its 100 MB gate (SPEC amendment 10). A standalone `argus run` behaves the same with or without the feature.

`argus-editor <project>` flags: `--window-size WxH` (window and render size, default `1280x720`), `--no-vsync`, `--no-service` (do not start a workspace service; `ARGUS_NO_SERVICE=1` too), `--port`/`--ws-port` (each Play session's query API; default `0`, ephemeral), `--log-file PATH` and `--console` (see [Windows build](#windows-build)), and `--test-inject-script <file>` (test only; see [below](#simulating-the-human-in-tests)). The project is opened, and refused if it must be, before the window is created.

`argus run` gained these flags; with none of them it behaves exactly as before:

| Flag | Meaning |
|---|---|
| `--seed <u64>` | The world seed, set before the first tick; see [World seed](#world-seed). A managed session's seed must be at most 2^53−1 (the P1 contract's limit); a standalone one takes the whole `u64`. |
| `--session-id ses_…` | Run as this managed session. Needs `--session-kind`, implies `--serve`, needs the `managed` feature. |
| `--session-kind preview\|human-play\|test` | The P1 session kind. `test` refuses `--window`; `human-play` needs it. |
| `--job-id job_…` | The owning job. Required for `test`. |
| `--project-id <uuid>`, `--artifact-id art_…` | What the session runs. With both given, the flags also make a full P1 `Session` record (owner: service `argus-engine`), validated by the contract's rules. |
| `--capabilities a,b` | What the session holds (P1 names; default `observe`). API callers get these capped by the kind; in-process controls check them directly. |
| `--anonymous-capabilities a,b` | What a caller presenting no identity may use (default `observe`), never more than the holder. |
| `--base-dir DIR` | Where `script.module` and relative asset paths resolve, instead of the scene file's folder; a worker running an artifact's frozen scene copy needs it. |

Every id is checked against its P1 format before anything loads, and a refused flag names itself. The engine generates the nonce; it never takes one. Every serving `argus run` prints one `argus-ready` line on stdout (see [Handshake](#handshake)). SchedB's launch, `argus run <scene> --serve --paused --frames 0 --port 0 --ws-port 0 [--headless]`, works unchanged.

A project is opened through `argus_contract::resolve_project` (docs/PROJECT-MANIFEST.md): the manifest's `startupScene` is the scene the editor shows and plays.

## Session model

Session kinds, records and lifecycles are the P1 workspace contract's (`docs/WORKSPACE-CONTRACT.md` §7, `argus_workspace::Session`); the editor keeps them in a `SessionRegistry` and moves a record only along the contract's lifecycle table.

| Kind | What it is | Runs in | Owner | Record capabilities |
|---|---|---|---|---|
| `preview` | The authoring view: a `World` loaded from the current artifact, **never stepped**, rendered through the editor camera | the editor process | the human | `observe`, `capture` |
| `human-play` | One Play, from Play to Stop: `argus::window::Session`, the same World stepping, query bridge and rendering `argus run --window` uses | the editor process | the human | all nine: the human holds them, in-process |
| `test` | One test session, owned by a job | its own worker process (`argus-editor worker`) | an agent | what the job is granted |

**Authoring state is the editor's, never the game's.** Camera and selection live in the editor (`EditorCamera`, a list of stable entity ids). The authoring world renders and captures through the camera as `argus::ViewOverride`, so the scene's camera entity, the gameplay rig and the audio listener are untouched.

**Play.** `Editor::play_start(PlayStart::Fresh | PlayStart::Restore(snapshot))` opens a new human-play session from the current ready artifact: a fresh load of its frozen scene, optionally with a Play snapshot the human saved on purpose (`save_play_snapshot`, F8) applied over it. Pause (`set_paused`, F10), step (`step(n)`, F11) and the time scale (`set_time_scale`) go through the same `POST /time` code as the API (`QueryBridge::owner_time`), as the session's owner rather than as an API caller. Stop (`stop`, Shift+Esc) ends the session (`running|paused → stopping → stopped`), drops the Play world and returns to authoring exactly as the human left it. Play, pause, step and stop repeat without closing the editor; each Play is a new session id and a new query server with a new nonce.

**One renderer, two worlds.** The renderer whose device presents to the window moves into the Play world at Play (`World::take_renderer`) and back into the authoring world at Stop (`World::attach_renderer`). The authoring world keeps its meshes, render target and camera across Play.

**What Play loads (C2).** When the workspace service has a Ready build and has verified its content, Play loads **exactly that build**: a `crate::artifact::LoadDescriptor` binding the revision, the service build and artifact, the SDK check policy, the verified content root, the build job's compiled behaviours, the engine build and the run profile, together with a manifest of every byte it will read. The scene and the assets come from the revision's read-only snapshot; the behaviours come from the build job's output, already transpiled and already checked. Nothing is read from the authored tree and nothing is transpiled at Play startup.

The decision is taken **once**, before the world opens, and handed on as a value, so a newer build landing while a world loads cannot mix its bytes into the session or change which build the session says it is. A build whose content is missing, short, unverified or unchecked is an `Unavailable` with a stable code (`content-missing`, `content-incomplete`, `content-unverified`, `modules-missing`, `typecheck-skipped`, `engine-incompatible`, `content-mismatch`) and the repair in it. **It is never a reason to load the authored tree instead.** A refused Play leaves the editor as it was: the same artifact, the same document, the authoring view intact, and the previously selected build still playable.

`Editor::selected_build` is the build a runtime here would open; `Editor::play_descriptor` is the one a running session opened; `Editor::play_unavailable` is why the last attempt did not start. Ready, selected, running and failed are four different things and the editor keeps them apart.

**Two identities.** `LoadDescriptor::content_id` is the revision plus the digest of every loaded byte: the editor's Play, a background test worker and a native export of one build share it. `LoadDescriptor::runtime_id` adds the engine binary and the run profile, so those three do **not** share it. A verdict reached on one engine is not a verdict about another, and a deploy binary is never relabelled with an editor artifact id.

**Test sessions load the same build, by check.** The editor writes the descriptor's hand-over (`crate::artifact::Handover`) beside the worker's session record; the worker resolves that selection itself and refuses to run unless the content identity it computes is the one it was handed. Two processes agreeing on the digest of every byte they loaded are running the same build; two processes pointed at one folder are only probably running it.

**Retention.** Before a world opens, the editor asks the service to keep that build usable (`POST /v1/retention`), and the link renews the lease while the session lives. Stop releases it; a worker that exits releases its own; an export holds one while it composes (`Editor::export_retain`). A lease is short and renewed, so an editor that is killed simply stops renewing and its content becomes collectable within one collection round. See `docs/WORKSPACE-SERVICE.md`.

**The authoring preview.** A project with no workspace service has no checked build, and Play then runs the preview: the editor's own `ReadyArtifact`, with behaviours and assets read from the tree behind the artifact's stale check. It is labelled as the preview it is (`PlayContent::provenance`) and it never claims to have applied a build. `Editor::play_last_ready` — the artifact this editor kept after a save the service refused — is the same preview path, for the same reason. The preview is a separate thing, not a fallback from a checked build that failed.

**Applied is a report, not a prediction.** The `update-applied` acknowledgement is written only after the runtime opened the descriptor, reported its identity and passed the loaded-byte check, and it names the artifact the descriptor carries. A Play that failed, that partially opened, that ran the preview or that fell back to the retained artifact says nothing at all. The mode is `restart`: this delivery applies a build by loading it again, and does not claim state-preserving hot reload.

**Ready artifacts.** The authoring preview starts from a `ReadyArtifact`, never from the live scene file. Preparing one (at open, or Ctrl+F2) reads the manifest, the startup scene, and each project file the scene makes a world read (each `script.module` that resolves to a `.ts`/`.js` file, each asset file). Their bytes are the P1 revision inputs; the artifact id is that revision on this engine build (the running executable's sha256, version, target, profile, features), and the record is a P1 `BuildArtifact`. The scene and manifest are copied to `<project>/.argus/editor/artifacts/<artifactId>/`, and Play and workers load that copy. Behaviours and assets are still read from the project folder when a world loads, so their hashes are compared with the files twice: before the load, and again once the world (or the worker, by its ready line) has read them. A changed, missing or newly present file makes the artifact stale, and Play and test sessions refuse it by name until a new artifact is prepared; a save between the two checks refuses too, rather than run other bytes under the artifact's id.

**Test sessions.** `Editor::start_test_session(TestSessionSpec)` makes a `test` record (kept under `.argus/editor/workers/<sessionId>/session.json`), starts a worker with that record's identity flags (`worker::identity_args`) and, on a checked build, the descriptor hand-over (`--load-descriptor`), waits for its ready line, and folds the bound endpoints and pid into the registry. A worker that exits is marked `failed` (non-zero or a signal) or `stopped` (clean) the next time the editor polls, which is every frame. Nothing else happens: the human's Play is a different process's business.

**Lifecycle steps.** A runtime reports where its session has got to, so a bound worker arrives as `running` straight after `starting`. The P1 lifecycle goes `starting → ready → running`; when the workspace service is the session authority, the registry records every step in between (`registry::lifecycle_steps`), so the service, which checks each transition, sees a legal history.

### Keys

Every key is a **keymap action** with a stable id (`crates/editor/src/keymap.rs`). The defaults are the `unreal` preset's; the `unity` preset is one choice away, and any action can be rebound on **Edit ▸ Editor Preferences… ▸ Keyboard Shortcuts** (also the toolbar's Settings ▾). The keymap is the person's, not the project's, as the usual editor's Editor Preferences are: `<config dir>/argus/editor/keymap.json` (`%APPDATA%` on Windows, `~/Library/Application Support` on macOS, `$XDG_CONFIG_HOME` or `~/.config` elsewhere; `ARGUS_KEYMAP_FILE` overrides it for scripted runs), as `{ "preset": "unreal" | "unity", "overrides": { "edit.duplicate": "Ctrl+Shift+D" } }`. An override is one key, a list, or `""` for none, in `stopPlayKey`'s grammar (modifiers and one key joined by `+`, matched exactly;  ` ``, `[`, `]` and the other punctuation keys may be written as the character). A file that does not read is the default preset with the reason on stderr; a bad entry stays at the preset and is named, and the rest applies. Agents read the effective keymap with `GET /v1/keymap` and run an action by id with `POST /v1/do {"do":"action","id":"…"}` ([AGENT.md](AGENT.md)).

| Action id | What | `unreal` preset (default) | `unity` preset |
|---|---|---|---|
| `tool.select`, `tool.move`, `tool.rotate`, `tool.scale` | select mode (no gizmo), move, rotate, scale tool | Q, W, E, R | Q, W, E, R |
| `tool.cycle` | cycle move / rotate / scale | Space | Space |
| `tool.space`, `tool.pivot` | gizmo in world or local space, at the entity's origin or the selection's centre ([Transform gizmos](#transform-gizmos)) | Ctrl+\`, — (toolbar) | X, Z |
| `camera.focus` | frame the selection | F | F |
| `camera.fly-*` | while the right mouse button is held: forward, back, left, right, up, down | W, S, A, D, E, Q | same |
| `view.game` | game view: hide every editor-only drawing (grid, overlays, outline, gizmo) ([Viewport modes](#viewport-modes)) | G | G |
| `view.presentation` | Presentation view: the viewport alone, a slow camera, the agents' activity ([Presentation view](#presentation-view)) | F11 | F11 |
| `view.perspective` / `top` / `front` / `side` | viewport projection: perspective, or Top, Front, Side (left) orthographic | Alt+G / Alt+J / Alt+H / Alt+K | same |
| `view.wireframe` / `unlit` / `lit` | view mode | Alt+2 / Alt+3 / Alt+4 | same |
| `grid.smaller`, `grid.larger` | grid snap size, down and up the usual editor's list (0.01 m to 100 m) | [, ] | same |
| `edit.undo`, `edit.redo` | undo, redo ([Editing the scene](#editing-the-scene)) | Ctrl+Z; Ctrl+Y or Ctrl+Shift+Z | same |
| `edit.copy`, `edit.cut`, `edit.paste` | clipboard; Ctrl+V pastes on the surface under the pointer ([Selection](#selection-outliner-and-view-state)) | Ctrl+C, Ctrl+X, Ctrl+V | same |
| `edit.paste-in-place` | paste where the entities were copied from | Ctrl+Shift+V | same |
| `edit.duplicate`, `edit.delete` | duplicate, delete the selection | Ctrl+D or Ctrl+W, Delete | Ctrl+D, Delete |
| `edit.rename` | rename | F2 | F2 |
| `edit.select-all`, `edit.select-none` | select all, clear the selection | Ctrl+A, Esc | Ctrl+A, Esc or Shift+D |
| `edit.hide`, `edit.unhide-all`, `edit.isolate` | hide selected, unhide all, hide unselected | H, Ctrl+H, Shift+H | same |
| `edit.drop-to-floor` | drop to floor | End | End |
| `snap.vertex` | held while dragging a Move handle, the pivot snaps to the nearest vertex ([Transform gizmos](#transform-gizmos)) | V | V |
| `camera.bookmark-save-1` … `-9`, `camera.bookmark-1` … `-9` | keep the view in a bookmark, go back to it ([Viewport modes](#viewport-modes)) | Ctrl+1 … 9, 1 … 9 | same |
| `file.save` | save the scene | Ctrl+S | Ctrl+S |
| `artifact.prepare` | prepare a new artifact from the current sources | Ctrl+F2 | Ctrl+F2 |
| `play.start`, `play.from-snapshot` | Play fresh, Play from the last saved Play snapshot | F5 or Alt+P, F6 | F5 or Ctrl+P, F6 |
| `play.stop` (Play) | Stop, besides the project's `stopPlayKey` (Shift+Esc) | — | Ctrl+P |
| `play.pause`, `play.step` (Play) | pause and resume, step one tick | F10 or Pause, F11 | F10 or Ctrl+Shift+P, F11 or Ctrl+Alt+P |
| `play.eject` (Play) | detach the view into a free camera, or return to the game's ([Play controls](#play-controls)) | F8 | F8 |
| `play.simulate` | Simulate: Play without the player | Alt+S | Alt+S |
| `play.slower`, `play.faster` (Play) | the Play clock one notch down or up 0.1x, 0.25x, 0.5x, 1x, 2x, 4x | Ctrl+[, Ctrl+] | same |
| `play.snapshot`, `play.capture`, `play.overlays` (Play) | save a Play snapshot, capture a frame, debug overlays | Shift+F8, F9, F1 | same |

**Surface snapping in the alternate preset** is also that preset's own gesture, Ctrl+Shift held while dragging the move handle's centre or a plane square; modifiers alone are not a keymap chord, so it belongs to the preset (`GET /v1/keymap` lists it under `mouse` there) and the toolbar's Surface toggle does the same in both presets. In the default preset Ctrl+Shift during a drag inverts the grid, as Ctrl does.

The mouse is the same in both presets and is not rebound: **right button held flies** (drag to look, W A S D to move, Q / E down and up, the wheel sets the speed step 1 to 8, Shift is faster; see **Camera speed** under [Viewport modes](#viewport-modes)), Alt+left drag orbits, middle drag pans, Alt+right drag dollies, the wheel alone zooms. **In an ortho view** (Top, Front, Side) there is no fly and no orbit: the right or middle button drags the plane with the pointer, and the wheel zooms. A left click selects, Ctrl+click toggles and Shift+click adds, and Ctrl+Alt+left drag is box select (with Shift, it adds) ([Selection](#selection-outliner-and-view-state)). A left press on a **gizmo handle** drags it instead, and wins over the click, the Alt orbit and everything but Ctrl+Alt: Alt+drag duplicates first, Ctrl while dragging inverts the grid snap, and Esc or the right button during the drag puts everything back ([Transform gizmos](#transform-gizmos)). In Play every key the keymap does not keep, Escape included, goes to the game: the scene's action map, exactly as `argus run --window`. Esc in a text field leaves the field and does nothing else. While Preferences waits for a key to rebind, no shortcut runs.

Copy, cut, select all, hide, isolate, drop to floor and rename (F2 opens the Outliner's name field on the selection) run the selection's own calls. Paste is the window's: it reads the system clipboard for the command key and V and pastes entities copied from an editor, so `edit.paste` rebound elsewhere does not read the clipboard. Space, pivot and grid change the gizmo settings, and the view keys the viewport's projection, view mode and game view ([Viewport modes](#viewport-modes)); the status bar says what they are now. The transform tool is one of the gizmo settings (`Editor::gizmo`, `Shell::tool`; `view.tool` and `view.gizmo.tool` in `/v1/state`), so it is kept in `state.json`.

**Where Talos keeps its own key.** Other editors disagree with Talos in a few places, and the editor keeps these on purpose:

1. **F2** renames, as in both; preparing an artifact moved to Ctrl+F2 (the toolbar button stays).
2. **Esc during Play** is the game's; Stop is Shift+Esc (below).
3. **F8 during Play** ejects the camera, as in the usual editor; the Play snapshot moved to Shift+F8 (an older keymap file that overrides `play.snapshot` keeps its key).
4. **F1 during Play** toggles overlays, where the usual editor shows wireframe; Alt+2 is the authoring wireframe key.
5. **W A S D** fly only while the right button is held; the earlier plain-WASD pan is gone.
6. **Q / E** are tools with the right button up and fly down / up while it is held, exactly as in the usual editor.

**Stop is Shift+Escape, and Escape is the game's.** Escape alone used to stop Play, which took it from every game that opens a pause menu on it. The key is the project's to choose: `stopPlayKey` in `<project>/.argus/editor/settings.json` (the file that also keeps `liveUpdates`), as modifiers and one key joined by `+` — `"Shift+Escape"` (the default), `"F7"`, `"Ctrl+Alt+F12"`, key names as winit or a scene's input map spells them. The modifiers must match exactly, so with the default Escape reaches the game and only Shift+Escape stops. A value that is not a key, is a modifier alone or is one of Play's own keys (F1, F8 to F11) is refused with the reason on stderr, and Stop stays Shift+Escape. The file is read when the project opens (`shell::StopKey::for_project`). The toolbar's Stop button stops Play whatever the key.

The window closes with its close button, never a key. The title shows the project and either `authoring` or the Play session id.

## Control policy

A managed runtime serves its query API through `argus_query::serve_managed`. The guard in `crates/query/src/guard.rs` checks every request, `HEAD` and `WS /stream` included, before any handler runs ("Managed sessions" in `crates/query/API.md`):

1. An identity that is not this server instance's (`x-argus-session` naming another session, or an `x-argus-nonce` another instance issued) is `409 session_mismatch`, on every route.
2. A request presenting this instance's nonce may use what the session's holder is granted, or it gets `403 capability_denied`.
3. A request presenting no identity may use what anonymous callers are granted, or it gets `428 session_required`.

The runtime decides the two grants (`argus::session::HostedSession::declaration`) from what the session holds (`--capabilities`, or the editor's record) and what anonymous callers were asked for (`--anonymous-capabilities`), capped by the kind:

- **preview** and **human-play**: an API caller never gets more than `observe`, `capture` and `snapshot-read`. The human's pause, step, stop and reload are in-process controls in the editor, not API capabilities. The editor grants anonymous callers exactly those three, so agents can watch the human; a plain `argus run` grants `observe` unless asked.
- **human-play owned by an agent** (`argus::session::holder_ceiling_for`): the Play of an editor an agent started with `editor.start`, which names its owner through `ARGUS_EDITOR_ACTOR`. Nobody sits at that editor and the API is its owner's only hand, so the identified holder gets what the record holds (every capability, for a Play record); `ui.activate` and `ui.push` answer there. Anonymous callers keep the three above. A person's Play, and every preview, keep the kind's ceiling whoever asks. The table below is a person's (`tests/managed_session.rs` builds its human-play records with a human owner); `crates/app/src/session.rs` (`an_agents_own_play_grants_its_holder_what_the_record_holds`) and `crates/agent/tests/play_parity.rs` hold the agent's case.
- **test**: the holder gets what the session holds; anonymous callers get what was asked for (default `observe`).
- `observe` is always granted, and anonymous callers never get more than the holder.

**Layered checks.** `QueryBridge::drain_commands` repeats the caller-independent half on the simulation thread: a command for a capability the holder is not granted — so no API caller at all is — is answered `capability_denied` without being applied. The guard has already refused such a request (403 or 428), so this only answers if something bypasses the router; identity-dependent refusals stay with the guard, the only side that sees identity.

**In-process controls.** A managed runtime's own controls check what the session *holds*, not the API grant: in `window::Session::hotkey`, F5 needs `reload`, F10 needs `time`, F9 needs `capture` and the quit keys need `stop` (Shift+Esc, Alt+F4, and Esc when the game binds nothing to it; docs/EXPORT.md); `argus run --watch` needs `reload`. A standalone runtime allows all of them. `camera` has no in-process control yet.

Each row below is a session holding every capability, so the row shows what the kind itself permits. The columns are a caller presenting this session's id and this instance's nonce; a caller presenting nothing, with the default anonymous grant (`observe`); a caller presenting nothing, when the launcher asked for every capability for anonymous callers; and a caller presenting this session's id with another instance's nonce (a stale port, or a replaced process). `camera`, `reload` and `stop` have no endpoint in API v1.0.0; their rows are the declaration. `tests/managed_session.rs` (`the_policy_table_in_docs_editor_md_is_what_a_managed_server_enforces`) checks every row against a real managed server.

| Session kind | Capability | Own identity | No identity (default) | No identity (all asked) | Stale nonce |
|---|---|---|---|---|---|
| preview | observe | allowed | allowed | allowed | 409 |
| preview | capture | allowed | 428 | allowed | 409 |
| preview | snapshot-read | allowed | 428 | allowed | 409 |
| preview | input | 403 | 428 | 428 | 409 |
| preview | time | 403 | 428 | 428 | 409 |
| preview | snapshot-restore | 403 | 428 | 428 | 409 |
| preview | camera | 403 | 428 | 428 | 409 |
| preview | reload | 403 | 428 | 428 | 409 |
| preview | stop | 403 | 428 | 428 | 409 |
| human-play | observe | allowed | allowed | allowed | 409 |
| human-play | capture | allowed | 428 | allowed | 409 |
| human-play | snapshot-read | allowed | 428 | allowed | 409 |
| human-play | input | 403 | 428 | 428 | 409 |
| human-play | time | 403 | 428 | 428 | 409 |
| human-play | snapshot-restore | 403 | 428 | 428 | 409 |
| human-play | camera | 403 | 428 | 428 | 409 |
| human-play | reload | 403 | 428 | 428 | 409 |
| human-play | stop | 403 | 428 | 428 | 409 |
| test | observe | allowed | allowed | allowed | 409 |
| test | capture | allowed | 428 | allowed | 409 |
| test | snapshot-read | allowed | 428 | allowed | 409 |
| test | input | allowed | 428 | allowed | 409 |
| test | time | allowed | 428 | allowed | 409 |
| test | snapshot-restore | allowed | 428 | allowed | 409 |
| test | camera | allowed | 428 | allowed | 409 |
| test | reload | allowed | 428 | allowed | 409 |
| test | stop | allowed | 428 | allowed | 409 |

## Device input

| Runtime | Keyboard, mouse, pads | `POST /input` |
|---|---|---|
| standalone `argus run --window` | into the world's action mapper | merged with the devices in the same tick, as documented in `crates/app/src/input/mod.rs` (unchanged) |
| human-play (editor Play, or `argus run --window --session`) | the human's: into the Play world | `403 capability_denied` with the session's identity, `428 session_required` without |
| preview | the editor camera and selection; never a world | `403` / `428` |
| test worker | none: no window, no pads | the job's, with the session's identity |

In the editor, a key only reaches the Play world through `Shell::on_keyboard`, and only while the game has input (see [Focus](#focus)). A human-play `window::Session` passes device events to its world (`DeviceAccess::device_input`); a preview session does not. An API caller cannot write into the human's held-input state at all: no command that could reaches the world.

## Focus

Input belongs to the **viewport** (the game image) or to the editor's **interface** (`focus::Owner`, `Shell::set_owner`). The game gets keys, mouse buttons, motion, wheel and pads only while Playing, with the window focused and the viewport owning input (`Shell::game_has_input`).

**Nothing stays held.** Every key and mouse button sent to the game down is tracked (`focus::Held`); an up is sent only for what the game holds. Whatever takes input away from the game sends the matching ups first:

| What happens | The game is sent |
|---|---|
| the interface takes input (`set_owner(Owner::Ui)`) | an up for every held key and button |
| the window loses focus | the ups, then `DeviceEvent::Reset` |
| the stop key in Play (Shift+Esc by default) | the ups, before the Play world is dropped |
| a zero-size resize (minimised) | the ups |

**Pads** follow the keyboard: `ActionMapper::set_gamepads_suspended` holds them whenever the game has no input. While suspended, the mapper still drains the pad backend and drops what it says; suspending releases held sticks, triggers and buttons; resuming drops what the pads said in between, so there is no burst of stale input (a stick still held on resume reads neutral until it moves).

**Escape** in a focused text field leaves the field and does nothing else; otherwise it goes to the game in Play, or clears the selection in authoring. The stop key (Shift+Escape unless `stopPlayKey` says otherwise) stops Play. Neither closes the editor. While a text field has the keyboard, the game gets no keys and its pads are suspended.

`crates/editor/tests/focus.rs` gates focus loss, the interface taking input, a zero-size resize, Escape reaching the game, the stop key and `stopPlayKey` at the winit boundary.

## Handshake

The handshake type, the headers and the error codes are `crates/query`'s (`SessionHandshake`, `x-argus-session`, `x-argus-nonce`; "Managed sessions" and `GET /session` in `crates/query/API.md`). The runtime's part:

- Every serving runtime prints `argus-ready <SessionHandshake JSON>` on **stdout**, once, flushed, after both listeners are bound and the first snapshot is published, and **before** any human-readable stderr line about its listeners (a launcher reads a stderr address with no ready line as a legacy engine): `argus run --serve` (with `"managed": false`), `argus run --session-id …`, each editor Play session, each test worker. It carries the session id, kind, project id, artifact id, job id, the holder's and anonymous capabilities, pid, the server instance's nonce, the bound endpoints, the API version, and `devices`.
- `devices` is `{"window", "gamepads", "audio_output"}`, declared from the session kind's device policy (`DeviceAccess::declared`, through `Config::with_devices`), managed or standalone.
- The nonce is fresh for every server instance: a worker restarted with the same session id, or a new Play, has a different one, so an identity held from before is `409 session_mismatch`.
- `GET /session` answers the same body, with the nonce only to a caller that presented it.
- The editor reads a worker's ready line **from stdout only**: a ready line on stderr is refused and the process killed, as the harness launcher and argus-jobs refuse it. It adopts a worker only when that line names the session it started, as a `test` session, from the pid it spawned, with a nonce, declaring no window, gamepads or audio output (`TestWorker::spawn`; refused otherwise, and the process killed). It keeps that nonce for whoever drives the session (`TestWorker::nonce`) and shows it nowhere: every logged worker line and every refusal has it replaced by `redacted`. The human-play identity is `Editor::play_identity`. Neither nonce is written to `.argus/editor/`; with the workspace service as the session authority, each is posted to it beside its record (see [Session authority](#session-authority)).

## World seed

`argus run --seed <u64>` (and a worker's `--seed`, from its record) sets the world seed before the first tick (`World::set_seed`). The app's `StateSource` reports it, so `GET /snapshot` carries it as `rng_seed`, and `POST /snapshot` restores it.

**The seed drives both random streams.** Behaviours' `Math.random` and particle scatter derive from the world seed, a stable authored identity (a behaviour's entity id, module and duplicate ordinal; a scene emitter's entity id; for `ctx.particles.emit`, the behaviour calling it), the frame, and the draws so far on that frame. Equal seeds and equal steps give equal snapshots, whatever order the scene declares its entities in, and a snapshot restored at a tick boundary resumes the same draws.

**This is a behaviour change for scripts.** `Math.random` used to be seeded from the clock, so a behaviour saw different numbers on every run. Now a run without `--seed` has world seed `0`, and every behaviour gets the same sequence on every run; pass another `--seed` for other numbers.

Not covered: `Date.now()`, `new Date()` and `performance.now()` follow the simulation clock between ticks but the wall clock within one (so a behaviour can still time its own loop), which a behaviour that branches on sub-tick timing can see; `Date` also starts from the wall-clock time the run began; particle state is not in snapshots, so live particles are not restore-equal; and a snapshot taken after draws made between ticks (an `init` on hot reload) is not restore-equal.

## Headless worker mode

A test session never runs in the human's process. Its worker:

- **opens no window and so takes no focus**: `HostedSession::devices` refuses a `test` session with a window before anything loads, in `argus run` and in `window::Session::open`. On Windows the editor starts workers through `worker::no_console` (`CREATE_NO_WINDOW`), so no console is allocated and no console window appears; `crates/editor/tests/windows_spawn.rs` checks that by effect without opening a window;
- **polls no gamepads**: pads are opened only by `window::Session::open`, and only when the session's `DeviceAccess::gamepads` allows. A worker never runs that path, and a preview session in a window does not open them either;
- **declares all of this on its handshake**: `devices` is `{"window": false, "gamepads": false, "audio_output": false}`, and the editor refuses a worker that declares otherwise;
- **opens no audio output**: `DeviceAccess::audio_output` is `false` for every test worker (and for a preview), and `window::Session::open` copies it into `LoadOpts::audio_output`, so the worker's mixer runs on `NullBackend` while the human's Play opens the real output device (`crates/app/src/audio_out.rs`). The editor's own tests never make a sound either: only the `argus-editor` binary allows device output, so an in-process Play in a test process declares `audio_output: false`;
- **renders offscreen** unless started `--headless`, so `POST /capture` works;
- **dies with its editor**: the editor holds the worker's stdin (`--parent-pipe`), and the worker exits when it closes.

A human-play session is the reverse: it needs a window, and `argus run --session <human-play record>` without `--window` is refused.

## The launcher

`crate::launcher::Launcher` is the window's other screen (`crate::app::Screen`). Its pages, the recent list, the project templates and the palette are the `argus-projects` crate (`crates/projects`), which the Talos Launcher draws too; `crate::launcher`, `crate::recent`, `crate::template` and `crate::theme` re-export them under their old names. The launcher is the same window, surface and painter, with the renderer parked in the App until a project holds it. It is a project browser in the manner of a game engine's: a sidebar (`launcher.tab.projects`, `launcher.tab.new`) and two pages.

**Projects** is a grid of cards (`launcher.project.<n>`): every project opened here, newest first, each with when it was last opened ("Opened 2 hours ago"), then every project sitting in the projects folder that is not on that list ("In the projects folder"), each with the thumbnail the editor wrote for it (`<project>/.argus/editor/thumbnail.png`, written by `crate::thumbnail` at every save and close from the authoring view, at most 320 pixels a side) or a tinted placeholder with its initial. One click selects a card, a double click opens it, and the footer names the selected project's folder with **Open** (`launcher.open`) and **Remove from list** (`launcher.forget`, recent projects only; the project stays where it is). **Browse…** (`launcher.browse`, Windows) asks the operating system for a project folder through a native dialog (`rfd`, Windows only; elsewhere it is not drawn); **Open by path…** (`launcher.open.toggle`) shows a typed path field (`launcher.open.path`, `launcher.open.open`) which is the default on Linux. A project that does not open says why in red (`launcher.error`) and the launcher stays.

**New project** picks a template card (`launcher.new.template.starter`: a floor, three boxes, a fixed camera and the player spawn; `launcher.new.template.empty`: the camera and the spawn only, the least the contract accepts), a Name (`launcher.new.name`) and a Location (`launcher.new.location`, with Browse… on Windows), shows the folder it will make, and **Create** (`launcher.new.create`; Enter in a field presses it) writes `<Location>/<Name>/scenes/main.scene.json` then `argus.project.json` (`crate::template::create_project_with`), with `application.id` `dev.argus.<slug>` and `executableName` the slug of the name, checked against the manifest's field rules; a non-empty folder or an empty name is refused without writing anything. The default Location is the projects folder: `ARGUS_PROJECTS_DIR` when it is set, then the person's projects folder setting (`projectsDir` in `%LOCALAPPDATA%\Argus\settings\projects.json`, one per person for every engine version and the Talos Launcher; `argus_projects::write_projects_dir` sets it), then `%USERPROFILE%\Documents\Argus` (`~/Argus` elsewhere). The Projects page scans the same folder. Paths with spaces and non-ASCII characters are fine. The third card, **Collection course**, is the starter course project; see [`docs/STARTER-COURSE.md`](STARTER-COURSE.md). The cards follow the **Type** row beside the Template heading (`launcher.new.type.2d`, `.3d`, `.3d hd`): 3D and 3D HD show those three, and 2D shows the 2D ones, **2D starter** (`launcher.new.template.2d starter`: a small side-scroller with a tilemap level, a planar player that runs and jumps, a pickup, a goal flag, a HUD, a camera that follows and stops at the level's ends (the built-in `camera-2d` behaviour), its three behaviours and two CC0 Kenney sheets under `scenes/`) and **2D empty** (`launcher.new.template.2d empty`: an orthographic camera, the spawn and a floor, set up for pixel art). Picking 2D selects the 2D starter, and going back to 3D the 3D starter; 3D and 3D HD keep the card that was picked (`argus_projects::template::Template::for_type`). This is setup only: the scenes carry their own orthographic camera and render settings, and run the same in any project (docs/PROJECT-MANIFEST.md, `kind`).

**The New page fits the window the launcher promises, at every display scale it is likely to meet.** A 960x640 px window at 200 % scale is a 480x320 pt page, which is the ordinary setting on a 4K laptop, and three template cards beside a fixed 200 pt sidebar did not come close to fitting it: the cards, the Location field and Create all ended up outside. The chrome is what gives way, in this order — the sidebar shrinks with the window (never below 140 pt), the page margins tighten, the heading drops a few points, the cards lose their descriptions to a tooltip and shorten, and the "Creates <path>" preview goes away. What never gives way is the three cards, the two fields and Create, all inside the window and not overlapping. `crates/editor/tests/launcher_d4_review_probes.rs` asserts exactly that at 100, 125, 150, 175 and 200 %.

**Recent projects** live outside any project, in `%LOCALAPPDATA%\argus\recent.json` (`$XDG_DATA_HOME/argus/recent.json` or `~/.local/share/argus/recent.json` elsewhere; `ARGUS_RECENT_FILE` overrides it): schema 1, ten newest first, deduplicated by path; a missing or unreadable file is an empty list, never a failure. Opening a project remembers it; **Close project** in the toolbar returns to the launcher (after the unsaved-work prompt) with the renderer handed back (`Editor::close_into_renderer`), so moving between projects never rebuilds the window. The disk is read when something changed, never per frame: the projects folder is scanned when the Location comes to name another folder and after a create; thumbnails once per project, again after a project closes.

## Scenes

**A project's scenes are its `*.scene.json` files, and one of them is open.** That is the same
definition the workspace service builds by (`argus_workspace_service::inputs::role_for`), reused
rather than restated, so what the File menu lists and what the service builds cannot drift apart.
There is no plural field in the manifest and none is needed: `startupScene` names the one a
packaged build boots into, and every other scene is simply a file in the project. The usual editor vocabulary maps directly — a scene is a level, the startup scene is the Game Default Map.

One scene is open at a time. Docking, multi-document tabs and Save Current Scene As are deliberately
not built; the shape chosen is one open document, switched.

| File menu | Hit name | What it does |
|---|---|---|
| New scene… | `scene.new` | A dialog: a name (`scene.new.name`) and a template, Starter or Empty (`scene.new.template.<t>`). Create (`scene.new.create`) writes `<the open scene's folder>/<name>.scene.json` and opens it; Cancel (`scene.new.cancel`) writes nothing. **Empty is a camera, the player spawn and a floor** — the floor is not decoration: the navmesh is built from static, non-trigger colliders, so a scene with none is one the critic reports `navmesh-failed` on, and New scene ▸ Empty used to hand a person exactly that |
| Open scene ▸ | `scene.open`, then `scene.open.<project-relative path>` | A submenu of every scene in the project, the open one marked and disabled. Opens it behind the unsaved-work prompt |
| Set as startup scene | `scene.set-startup` | The open scene becomes the manifest's `startupScene`. Disabled when it already is. Allowed on a scene that would not make a playable game — a person may be part way through a level — but the message says when the scene has no static collider, because then the navmesh is empty and nothing in it can route |

**New scene refuses before it writes.** A scene's name is a file name, not a path: `/` and `\` are
refused rather than quietly making a folder. Everything else is the contract's own project-path rule
(`argus_workspace::path_problem`), asked about a project-relative path built **as text** and never
through `Path::join` — `join` parses, and what it parses depends on the platform, so `a:b` was once
refused as "not inside the project" on Windows and as "Windows forbids …" on Linux. The characters
come first and the escape check follows, so a name Windows will not give a file — a device name like
`CON`, a colon, a trailing dot — is refused in the same words everywhere. Three more, each of which the design review found being refused *after* the file was written or not at all: a **dot-leading** name, which
`inputs::is_excluded` drops, so the scene was written and then called "not part of the project" by
the editor that had just made it; a name longer than `Editor::SCENE_NAME_MAX`, which used to fail at
the write with a raw `ENAMETOOLONG` after the click; and a name that differs from an existing scene
only in **case**, which coexisted on Linux and collided on Windows (`argus_workspace::path_key` is
the comparison, the same one the rest of the workspace uses). A name that already names a file is
refused too: New scene creates a scene, and overwriting one somebody forgot about is not creating
one. The reason appears in the dialog as the name is typed, and Create is disabled until there is
none.

**Open scene rebuilds the artifact.** `Editor::open_scene` freezes the named document
(`ReadyArtifact::prepare_scene`, which is `prepare` against a path rather than a derived one) and
replaces what described the previous one: the selection and camera are entity ids and a viewpoint
into a world no longer loaded, the edit history is a history of another file, and the retained "last
Ready" build and the Play snapshot are builds and saves of another scene — offering either as a
fallback would run the scene the person just navigated away from. The inspector's drafts go with
them. **The build view is repointed in the same call**: a revision is matched by the editor's scene
path and the bytes it last wrote (`builds.rs`), so a panel still matching the previous scene's
revision would show a stale Ready and let Play run the previous scene's build.

Unsaved work is asked about first, with the same question and the same `prompt.*` names the close
prompt uses, because it is the same question ("Save changes to <scene>?", Yes / No / Cancel).
Cancel keeps the current scene and its changes.

**Play runs the open scene** with no extra machinery: Play has always run `self.artifact`, and the
artifact is now the open document's. The workspace service already built every scene in a revision
(`build/stages.rs`), so a build for the open scene is there to be matched.

**Set as startup** is one `/startupScene` field edit. It goes through the settings backend whenever
that backend can edit — the same expected-value rule and the same conflict and lease answers as
every other setting. Where the backend is read-only the editor writes the manifest itself, in the
bytes a settings apply would have written
(`argus_workspace_service::settings::manifest_text`), and validates the result by opening the
project again; a refusal puts the previous bytes back, because a manifest the editor made invalid
would stop the project opening at all.

**The title bar and the Outliner name the open scene** (`Editor::window_title`, computed in the
editor so a headless gate reads exactly what a person reads).

**Export packs one scene: the manifest's startup scene**, never the one that is open. The Export
panel names it, and when the open scene is a different one it says so and names Set as startup scene
as the way to change it. A shipped game cannot reach a second scene until runtime scene loading
exists, so packing more would add bytes no player reaches — see [Exporting a game](#exporting-a-game)
and `docs/EXPORT.md`.

**Not built:** loading a scene at run time. `ctx.session.loadScene` does not exist; `SessionHandle`
exposes `restart()` alone. It needs a world teardown and rebuild against a different document
mid-session, with asset lifetimes across the swap, which is runtime work and not editor work. It is
also the reason an export carries one scene: a game that cannot reach a second scene has no use for
one in its payload.

## Editing the scene

`crate::document::Document` is the scene as the human is editing it: the working `Scene`, the scene as last saved or loaded, the SHA-256 of the file's bytes as last read or written, and undo and redo as whole-scene snapshots with the selection at the time (`UNDO_CAP` = 200 steps). Every edit is computed first as a complete new scene that already passes the contract (`argus_contract::validate`, errors only; warnings pass, since the loaded scene may carry them), then shown by bringing the authoring world in line with it (`World::reconcile`, the hot-reload path, which touches only what differs), and only then committed. An edit the contract refuses — a zero scale, deleting the primary camera or the player spawn — changes nothing and the toolbar names the rule. Nothing here goes through the workspace service: the editor writes the scene file itself, and the revision identity edits live inside is the ready artifact's (`ReadyArtifact::prepare` hashes the saved file), so a save is a new revision and a new build, not a second persistence system. Change sets through the service are the next increment.

Each step records who made it (the editor's owner; for a scene an agent commits through the control endpoint, `/v1/commit` with its `actor`, or an action it asks for, `/v1/do` with `actor` beside `do`, that agent; `argus-agent` sends its own), when, a short `label` the toolbar's Undo and a refusal quote ("move #box"), and a `title` in words for the [Undo History](#the-undo-history) ("Move Crate"). A step site that knows better passes both (`StepLabel::titled`: a rename's old and new names, a prefab's name); otherwise the title is made from the label (`document::title_of`).

| Edit | Does | Undo step |
|---|---|---|
| Transform fields (position; rotation as degrees about X, Y, Z; scale) | a drag or a typed value is shown live; the fields are `DragValue`s, so a click types and a drag scrubs | one step from the first change to the field's release (`begin_transform`, `set_transform_live`, `end_transform`); `set_transform` is one step on its own |
| Duplicate | a copy after each selected entity, id `<id>-copy` (then `-copy-2`, …), name `<name> copy`, same parent, tags and components; children are not copied unless selected too, when the child's copy hangs under the parent's copy; the copies are selected | one for the whole selection |
| Delete | the selected entities and their descendants; the selection is cleared | one for the whole selection |
| Transform fields over several entities | the fields they share; a value that differs between them shows "—", and a value typed or dragged is set on every one of them | one for all of them (`begin_transforms`, `set_transforms_live`, `end_transform`) |
| Reparent, rename, paste, cut, drop to floor | see [Selection, Outliner and view state](#selection-outliner-and-view-state) | one each |
| Undo, Redo | restore the scene and the selection of the time | — |
| World Settings rows | one field of the scene's `meta`; see [World Settings](#world-settings) | one step per row, the same run (`begin_meta`, `set_meta_live`, `end_property`); a checkbox, toggle, dropdown or reset arrow is a step each |
| Typed property rows | a component's behaviour fields, by kind: a number drags and types, a boolean is a checkbox, a string a text field, an array or an object the JSON text of one | one step per row, the same run as a transform field (`begin_property`, `set_property_live`, `end_property`); a checkbox and Add and Remove are a step each |
| Discard | back to what was last saved | one |

Edits, Save, Undo and Redo are authoring actions: during Play the controls are disabled and the calls refuse ("stop Play first"). Play runs the saved scene, and `Editor::play_start` refuses a dirty document outright; the shell asks instead — **Save and Play / Discard and Play / Cancel** (`play.*`) — for the toolbar's Play and F5 alike, so nothing is written on the way to Play without the person choosing it. Stop returns to the authoring world, which Play never touched.

**Save** (`Editor::save`) writes the scene as pretty-printed JSON with a trailing newline through `state::write_atomically` (beside the file, then one rename), so a failed write leaves the last saved file intact and the error names the path; then it prepares the artifact Play will run, without reloading the authoring world when the scene is what it already shows. Before writing it reads the file (`Editor::check_disk`, `DiskState`): only a file that can be read and hashes to what the document was loaded from or last saved as is written over. A file with other bytes (`Changed`), no file (`Missing`) or a file that cannot be read (`Unreadable`, with the error) writes nothing and opens the prompt **Overwrite / Reload / Cancel** with the reason (`SaveOutcome::Conflict`): not being able to check the file is not evidence that it is unchanged. The file is also looked at every 2 s (`DISK_CHECK_EVERY`; Windows raises no file events for a WSL path) and the toolbar says "Scene changed on disk", "Scene file missing on disk" or "Scene file cannot be read", with Reload, while it does not match. Reload reads the file again, shows it and drops the edit history, which described another base. On open, an artifact restored from `state.json` whose scene file has changed since is replaced by a new one, with a note, so the editor always shows the file as it is.

**Closing with unsaved work asks one question, every time**: **"Save changes to <files>?"** with **Yes / No / Cancel**, naming every file with unsaved work — see [Unsaved work: the prompt, autosave and recovery](#unsaved-work-the-prompt-autosave-and-recovery). `Shell::request_close` returns `false` and the decision arrives through `Shell::take_exit`. Unsaved changes live in memory and in the autosave's copy, never in the file, which keeps what was last saved.

**While a prompt is up** (`Shell::modal_open`: unsaved work, a save conflict, Play with unsaved work, the Import modal, the Export panel, the New behaviour dialog or the New scene dialog) input reaches the interface only: no shortcut (F5, Ctrl+S, Ctrl+Z, Ctrl+D, Delete, Ctrl+F2), no camera key or wheel, no picking, and nothing to the game but the releases of what it already holds; Escape answers Cancel — over a running export it asks the export to stop rather than closing the panel out from under a thread that is still writing, and while a name field has the keyboard it leaves the field, so the next Escape is the one that cancels. A frame is still drawn, which is how the Export panel's progress keeps moving while nothing but Cancel can be pressed: the rule is about where input goes, not about whether the interface is painted. A conflict raised by a prompt's Save stacks over it, and once the conflict is answered and the scene is saved the close or Play it was waiting on goes ahead (`Shell::continue_after_save`).

### The inspector's typed property rows

The inspector first showed the transform as numeric fields and every component as read-only JSON below it. Typed rows now give the fields a person actually tunes while authoring a course — a behaviour's speed, an objective's label, whether a trigger fires once — rows of their own, so changing them is not editing JSON by hand.

**The rows are read from the values that are there** (`crate::inspector::component_rows`). A component is serialised, and each field below becomes a row whose control is the value's own kind: a number is a `DragValue`, a boolean a checkbox, a string a single-line text field, and an array or an object the JSON text of one. That is what lets `script.props` have rows at all — its keys are whatever the behaviour module reads, and nothing in the editor knows them ahead of time.

| Component | Rows |
|---|---|
| `script` | one per key of `props`, by the kind of the value there, plus the add-property row |
| `controller` | `speed`, `jump`, `crouch` |
| `goal` | `labelKey`, `order` |
| `trigger` | `event`, `once` |
| `collider` | `isTrigger` |

Hit names are `inspector.prop.<component>.<key>`, where `<component>` is the component's `type`. An entity carrying two of one type gets `<type>-2`, `<type>-3` … for the later ones, so every control still has a name of its own.

**The inspector's own controls live behind `@`**, so a property can be called anything without taking a button's name: Add is `inspector.prop.<component>.@add`, its name field `…@add.name`, its kind buttons `…@add.kind.{number,boolean,text,list}`, and the button that removes property `k` is `inspector.prop.<component>.@remove.<k>`. A property whose name *starts* with `@` has it doubled in its row's hit name, so no property name can spell a control's, whatever it contains. Before this a property called `add` answered to the Add button and a property called `x.remove` answered to `x`'s remove button — clicking the row pressed the button.

A field that names a file or an asset — `script.module`, `mesh.asset` — deliberately has **no** typed row. It is a reference, changed by the import flow, and a text field over it would be a way to break a scene by typing. Those stay in the raw view.

**An edit is one undo step, through the same live run as a transform field.** A row's first change opens the run and later changes in it are not steps of their own, so dragging a number is one step from the first change to the release, and typing a string is one step however many characters it takes (`Editor::begin_property`, `set_property_live`, `end_property` over `Document::begin_live`/`commit_live`/`end_live`). A checkbox, Add and Remove are each a whole edit in one click, so they open no run and commit a step of their own — were a checkbox to hold a run open, a second click would be folded into the first and one undo would take back both.

**Escape abandons a live run**, whether the field is being typed into or dragged, and takes it back as one step: the value is what it was before the field was touched. It keeps the selection — the row belongs to the entity that is still selected — closes the run, and stops the drag, so the pointer being still held down does not write the value straight back. The next edit is then a step of its own. Escape used to reach this only while a field had the keyboard, and a drag does not ask for the keyboard, so Escape mid-drag left the dragged value where it was, the run open, and the *next* edit folded into the abandoned one, where a single undo took back both.

**What the scene will not hold is refused before anything changes.** `Document::component_edited` edits the component as JSON and reads it back as a `Component`, so the contract's own types decide what a field takes; then the whole scene is checked as every other edit is. A goal given an `order` of 0.5, or a string typed where a number belongs, changes nothing and the toolbar names the rule. A JSON field commits only what parses, so half-typed text reaches nothing; while the field has the keyboard it keeps what was typed, and leaving the field shows the committed value again. It also refuses `null`, for the same reason the number rows refuse `NaN` and `1e309`: the editor's own save would take it and `argus_contract::to_canonical_string` would then refuse the whole scene ("refusing to write null"), so one keystroke in one field would produce a document that cannot be written. Removing the property is what the × is for.

**A draft never outlives the value under it.** The text a field is holding is kept while it has the keyboard, so half-typed JSON is not overwritten under the typing — but if the document is replaced beneath it, by Reload, by discarding changes or by preparing an artifact, the field takes the new value, moves the caret to the end of it and closes the run. Before this a draft survived a Reload and the next keystroke wrote it back over the file's own value: the row wrote `"typed!"` where the file said `"from-disk!"`.

**An optional field the component does not carry** shows its default with "unset" beside it and is written only once it is changed. Opening the inspector on a goal with no `order` does not give it one.

**Adding a property** (`inspector.prop.script.@add.name`, the kind at `inspector.prop.script.@add.kind.{number,boolean,text,list}`, and `inspector.prop.script.@add`) introduces a `props` key — an objective count a behaviour reads — without opening the JSON, starting at `0.0`, `false`, `""` or `[]`. A number starts at `0.0` rather than `0` deliberately: a number row always writes an `f64`, so a new property that began as an integer would change type the first time it was touched. A name that is empty or already taken disables Add and says why. `inspector.prop.script.@remove.<key>` takes one away. `props` is the one open map in the contract, so this row is a `script`'s alone.

**The raw view stays.** Behind the overflow button at the right of each component's category band, **Raw JSON** (the button is `inspector.raw.<component>`; pressing it shows or hides the view, collapsed by default, its text at `inspector.raw.<component>.text`) is the whole component as the file holds it, serialised after every edit — so it is the same value the typed rows are read from, and anything with no typed row is still readable there.

Property rows are authoring actions like every other: during Play they are disabled and the calls refuse. A property edit makes the artifact stale in the ordinary way, so the next Save prepares the build Play runs — edit a `speed`, save, and it is the new value the running world has.

`crates/editor/tests/d4_inspector.rs` drives all of it by hit name through the winit boundary, and `crates/editor/tests/d4_review_probes.rs` adds the adversarial half: the name collisions, Escape mid-drag, the stale draft and the `null` a JSON row must not write.

## Selection, Outliner and view state

The usual editor conventions are the reference, with one rule over all of it:
whatever a person does here an agent can do through a tool, and whatever a
person did is on the agent's next `scene.read` or `editor.state`.

**Selecting.** The selection is a list of stable entity ids (`Editor::selection`). In the viewport a
click replaces it with what is under the pointer (background clears it), Ctrl+click toggles that
entity in or out and Shift+click adds it (`Editor::select_at_with`, `SelectHow`). **Box select** is
The usual editor's marquee: Ctrl+Alt and drag with the left button; the rectangle is drawn over the image
(`viewport.box`) and on release every entity the id buffer has inside it is selected, in scene order
(`World::pick_rect` beside `World::pick`, one read of the id buffer), replacing the selection, or
adding to it with Shift held as well. Select all (`Editor::select_all`) takes every entity that is
drawn; Escape clears. The overlay under the scene name says `#first +N (M selected)`.

**The Details panel over several entities** shows how many, Duplicate and Delete over all of them,
and the transform fields they share; a field whose value differs between them reads "—", and an edit
to it sets that one value on each, so dragging Y moves them all to the same height and leaves X and Z
alone. The whole drag or typed value is one undo step (`Document::begin_live` / `commit_live` over a
scene computed by `Document::transformed_many`; a run's step grows to cover every entity it touches).

**Their shared components** (`crate::multi_details`). Below the transform, every component all
the selected entities carry, matched by type and by position among that type's (`script`,
`script-2`, as the single entity's hit names are), is drawn with the typed rows they all have, under
the same names (`inspector.prop.<component>.<key>`). A value that differs reads "—" (a checkbox shows
its third state; a click sets every one of them on, as the usual editor's does), and an edit sets that one value
on every selected entity as **one undo step** (`Editor::set_properties_live`, one scene for the whole
run; label `set <field> on N entities`). On a prefab instance, or a part inside one, each lands as an
override on its own instance, exactly as a single entity's edit does, and the prefab file is never
touched. Components only some of them carry are named on one line, "(not on all selected)"
(`inspector.not-shared`). A lock stops moving and picking, so it leaves these rows alone. **Bounded:**
the shared rows are worked out once per selection and document change (`multi_details::Shared`),
not once a frame. Measured on the llvmpipe gate machine (`tests/d18_scale.rs`,
`two_hundred_selected_entities_keep_the_frame_bounded`): an idle frame with 200 of 220 scripted
entities selected costs about the same as with one selected, and an edit of all 200 plus the frame
that shows it stays well under the test's bound; the report of each run prints the numbers.

**The Details search box** (`details.search`, at the top of Details, as in the usual editor). It filters the
rows by name across the transform (Position, Rotation, Scale; "transform" keeps all three), every
component (a component whose type matches keeps all its rows) and the prefab bar (it answers to
"prefab", Create Prefab…, Apply, Revert, overrides and its source path). While it holds text only
rows are drawn: Add component, the asset and scan tools, the raw JSON and the failures come back when
it is cleared. The × (`details.search.clear`) or Escape in the field clears it, and it is kept when
the selection changes, as the usual editor's is. It works the same over a multi-selection.

**The Outliner** (`crate::outliner`, split out of `ui.rs`) selects as the viewport does, and
Shift+click selects the range from the last row clicked (Ctrl+Shift adds the range). **Drag a row
onto another** and it hangs under it, keeping where it is in the world (`Editor::reparent`,
`document::reparent_scene`: the entity's world matrix, composed up its parents as the runtime does,
times the new parent's inverse); dragging a selected row moves the whole selection, and only its roots
move. **Drag onto the space under the rows** (`outliner.empty`) and it becomes a root. A drop onto the
entity itself or anything under it is refused with the contract's `parent-cycle` and the reason is in
the status line. **Rename** is a double-click on a row, or F2 through the keymap (`Shell::begin_rename`):
the name becomes a field (`outliner.rename`); Enter or clicking away renames (`Editor::rename`, one
step, the id stays), Escape keeps the old name. Each row has an **eye** and a **lock** at its right
edge (`entity.<id>.eye`, `entity.<id>.lock`).

**Folders** are the usual editor's Outliner folders, and like the usual editor's they are kept in the level: the
scene's `meta.outliner.folders`, a map of root entity id to a folder path (`"Monsters/Grunts"`;
`docs/SPEC.md`, "Outliner folders"), so an agent reads and writes the same map and a diff shows it.
They are organisation only and move nothing in the world. The verbs are `crate::folders`, each one
undo step through `Editor::commit_scene`:

| Do | How | Hit names |
|---|---|---|
| New Folder | the folder button beside the search: a top-level folder holding the selection (or an empty one, with nothing selected), its name open for typing. Right-click the empty space under the rows ▸ New Folder makes an empty top-level one. Right-click a folder ▸ New Subfolder nests an empty one | `outliner.new-folder`, `menu.outliner.new-folder`, `menu.folder.new` |
| Rename | double-click the folder, or right-click ▸ Rename; Enter or clicking away renames every folder under it too, Escape keeps the name; renaming onto a folder that exists merges them | `outliner.folder-rename`, `menu.folder.rename` |
| File entities | drag rows (or the selection) onto a folder. A child is unparented first, staying where it is in the world, as the usual editor detaches an attached actor dragged into a folder; one step | `folder.<path>` |
| Take them out | drag onto the space under the rows: out of every folder and parent | `outliner.empty` |
| Move to Folder ▸ | as the usual editor's: right-click an entity ▸ Move to Folder ▸ a folder of the scene, **New Folder…** (a top-level folder holding them, its name open) or **(root)** (out of every folder). It moves the selection when the row is in it, as a drag does; the prefab items stay in the same menu, above it. Not on a part of a prefab instance, which follows its instance. One step each | `menu.entity.move-to`, `menu.entity.move-to.<folder.<path>\|new\|root>` |
| Nest a folder | drag a folder onto another, or onto the space under the rows for the top level | |
| Open and close | the caret; kept per user and per scene in `layout.json` (`outlinerCollapsed`) | `folder.<path>.caret` |
| Select contents | a click selects every root filed in it and in the folders inside it (Ctrl+click adds them); right-click ▸ Select Contents too. The usual editor does this on a double-click; here a double-click renames, as it does on an entity row | `menu.folder.select` |
| Hide and lock | the folder row's eye and lock act on everything inside it, children included | `folder.<path>.eye`, `.lock` |
| Delete Folder | right-click ▸ Delete Folder: what is in it (entities and folders) moves up to its parent, or the top level; no entity is deleted | `menu.folder.delete` |
| Organize | the header menu (…, at the right of the search row) ▸ Organize, or right-click the space under the rows ▸ Organize: every root entity with no folder is filed by prefab (`Prefabs/<prefab>`), then by kind (Cameras, Lights, Gameplay, Environment, Meshes, Other; `docs/SPEC.md`, "Outliner folders", Organize). A folder already given is kept, a child is never filed, nothing moves or is reparented; one undo step ("organize outliner"), and the status line reads "Filed N entities into M folders" (or "Nothing to organize" with no step). `crate::organize` over `argus_contract::organize`; an agent's is `scene.patch` `organize` | `outliner.menu`, `outliner.organize`, `menu.outliner.organize` |

Only roots are filed: a child follows its parent, and a part of a prefab instance follows its
instance (filing one is refused with the reason). **Empty folders are kept**, as the usual editor keeps them:
`meta.outliner.empty` lists each by its deepest path. A New Folder with nothing selected is one
(drawn with "(empty)"), and a folder whose last entity is deleted, reparented or dragged out stays,
empty. A folder leaves the list when something is filed in it or under it
(`argus_contract::outliner::tidy_empty`, run by every folder verb and by `sync`). Folder rows come
first under each parent and sort by name whatever the Sort (reversed for Name Z-A), as the usual editor's do;
a search lists matching folders, then matching entities flat with the folder each is in after its id
(an entity matches by its folder's name too). Folders are refused while a prefab is open in Edit
Prefab, whose file has none.

**Every tool keeps the map in step** in the same change (Talos's rule), so the contract's warning
`outliner-folder-unknown-entity` (rule 75) fires only for a hand-edited file: `Document::commit` and
`commit_live` run `argus_contract::outliner::sync`, which drops an entry whose entity the edit
deleted or gave a parent (delete, cut, reparent, a prefab Create taking a child into the prefab, an
agent's `POST /v1/commit`), and the world is shown the same synced scene (`Editor::show`). An entry
the edit itself wrote is left as written. Duplicate files the copy beside its original, as the usual editor
does (`outliner::carry`); copy puts each root's folder on the clipboard (`"folders"`) and paste files
a pasted root that stays a root in it; Create Prefab… keeps the entity's id for the instance, so the
instance keeps the folder; placing and importing add ids and re-id nothing. The agent's tools do the
same on disk and through the service.

**Sorting the Outliner.** The Sort menu at the right of the search row (`outliner.sort`; its rows
`outliner.sort.scene`, `.name`, `.name-desc`, `.type`) orders the rows the way the usual editor's Outliner
sorts by its Item Label and Type column headers: **Scene order** (the scene file's, the default),
**Name (A-Z)**, **Name (Z-A)**, or **Type**. Names compare ignoring case, with runs of digits as
numbers ("Crate 2" before "Crate 10"), and the id breaks a tie. Sorting orders the rows under each
parent and keeps the tree; a search, which flattens it, lists its matches in the same order, and
Shift+click ranges follow the order drawn. Type is a fixed order of kinds, decided from the entity's
own components by the first that fits (`outliner::TypeBucket`), then by name:

| Order | Kind | An entity with |
|---|---|---|
| 1 | prefab | a `prefab` (an instance) |
| 2 | camera | `camera` |
| 3 | light | `light` |
| 4 | mesh | anything drawn: `mesh`, `sprite`, `splat` or `particles` |
| 5 | collider | `collider` or `rigidbody` and nothing drawn |
| 6 | trigger | `trigger`, `goal`, or a collider with `isTrigger` |
| 7 | spawn | `spawn` |
| 8 | script | `script` and none of the above |
| 9 | other | any other components (audio, a label, navigation, …) |
| 10 | empty | no components: a folder or a pivot |

The kinds are listed in that order, but decided spawn first, then trigger, then drawn, so a visible
trigger volume is a trigger and a spawn point with a marker mesh is a spawn. The button shows the
choice (Sort, A-Z, Z-A, Type). The choice is the person's, kept in `layout.json` (below).

**Hide, isolate and lock are view state, not scene state** (the usual editor's hidden-in-editor): they never
reach the scene file, a build or Play, and they are kept per scene in `state.json` (`views`, below).

| | Does | Key (keymap) |
|---|---|---|
| Hide | not drawn in the authoring view, nor anything under it, so it cannot be picked or box-selected; its Outliner row is dimmed; hiding the selection deselects it | H (`hide_selected`), the eye |
| Show all | everything drawn again, and isolation ends | Ctrl+H (`unhide_all`) |
| Isolate | only the selection and what is under it is drawn; again, everything is | Shift+H (`toggle_isolate`) |
| Lock | still drawn, but a click or a box does not pick it and a person's transform edit is refused ("#id is locked"); drop to floor leaves it | the lock (`toggle_lock` over the selection) |

A hidden entity is left out of the authoring world's instance list (`World::set_editor_hidden`, entity
indices the editor recomputes when the set changes), so it is in neither the image nor the id buffer.
Only meshes are hidden this way; a light or a sprite still shows. **An agent's edit goes through a lock**
and its answer says `"locked": true` with `lockedIds`: the lock stops a person's pointer, not the
agent (`POST /v1/commit` compares the edit with the locked ids).

**Copy, cut and paste** (`crate::clipboard`). Copy puts the selection and everything under it on the
system clipboard as scene JSON — `{"argusEntities": [...], "assets": {...}, "parents": {...}}`, the
format the agent guide describes, with `"folders"` when a copied root is filed — so a person can paste it into a chat with an agent, and paste back
what an agent answers. The text goes out as egui's `copy_text` on the next frame
(`UiState::copy_out`), which is how the window's clipboard hears of it. **Paste** is the window's own
Ctrl+V: the App reads the clipboard for that key and hands egui a paste event, and the editor pastes
it when no text field has the keyboard and the text is copied entities (a word on the clipboard is left
alone). Every entity gets a fresh id by Duplicate's rule, each root is named `<name> copy` and goes
back where it was copied from (under its old parent if that is in the scene, at the same place in the
world), and the pasted roots are selected; one undo step. Cut is copy then delete, one step.

**Paste under the pointer** (the usual editor's Paste Here). With the pointer over the viewport, Ctrl+V
lands the paste on the surface under it, found as a model dropped from the Content browser is
(`content_browser::drop_point`: the mesh the id buffer shows there, else the ground plane y = 0):
the pasted roots move together so the bottom of their bounds rests on that point, centred over it
(`Editor::paste_at`). With the pointer off the viewport, or with **Ctrl+Shift+V**
(`edit.paste-in-place`), the paste goes where it was copied from, as before. Either way it is one
undo step, the folder a root was copied from still files it, and one key is one paste: the window
still pastes only through its paste event, and the key only says which kind
(`Shell::paste_event`).

**Drop to floor** (End, `Editor::drop_to_floor`) moves each selected root straight down until the
bottom of its bounds (its mesh and its descendants' meshes, or its origin when it draws nothing)
rests on the first thing below the bounds' centre: a collider (the authoring world's physics, a ray
that passes through the entity itself) or another entity's mesh (`World::surface_hit`), whichever is
nearer. One undo step; something already resting moves nowhere and makes no step. Agents have it as
`entity.align` with `ground: true`.

**For the keymap.** The entry points the keymap binds are on `Shell`: `select_all`,
`clear_selection`, `copy_selection`, `cut_selection`, `paste_text`, `hide_selected`, `unhide_all`,
`toggle_isolate`, `toggle_lock`, `drop_to_floor`, `begin_rename` and `rename`.

## Transform gizmos

The usual editor conventions are the reference. W, E and R (or the toolbar) put a gizmo on the selection; Q takes it away.

**Handles.** Move has three axis arrows, three plane squares (drag in that plane) and a centre ring
that moves in the plane facing the camera. Rotate has three rings and a screen ring around them that
turns about the view direction; a ring's far half is drawn faint and loses to a near one where they
cross. Scale has an axis line with a box at each end and a centre box for uniform scale (up and to
the right grows). X is red, Y green, Z blue, and the handle under the pointer, or being dragged, is
yellow. The gizmo keeps one size on screen whatever the distance, is drawn over the scene, and is
painted with egui over the game image, so the renderer knows nothing about it. A square seen exactly
edge-on is a line with nothing to grab, as in the usual editor.

**Picking.** A handle is found on the CPU: the pointer against the handles' shapes projected through
the authoring camera, within 7 points of a line or ring. The GPU id buffer stays for entities. The
centre handle wins, then the nearest axis, then a square. A left press on a handle starts a drag and
is not a pick; Ctrl+Alt is always box select, even over the gizmo.

**Space and pivot.** Move and Rotate use the world's axes or the pivot entity's own (Ctrl+\`, X in the alternate preset, or the toolbar's World / Local); Scale always uses the entity's own axes. The pivot entity is the
one selected last (as in the usual editor). The gizmo sits at its origin, or with Centre (the toolbar, Z in the alternate preset) at the
centre of the selection's world bounds (meshes, or the origin of an entity with none).

**Snapping.** Three toggles and values, in the toolbar at the top right of the viewport (the usual editor's
layout: a toggle, then a menu of values showing the current one). Defaults, as the usual editor's: grid on at
0.1 m, angle on at 10°, scale on at 0.25.
- Grid: in world space the pivot lands on the world grid along the axes being dragged; in local space
  the move is a whole number of grid steps. [ and ] step through 0.01, 0.05, 0.1, 0.5, 1, 5, 10, 50 and
  100 m. **Ctrl held while dragging inverts the grid toggle**, checked on every pointer move and when
  Ctrl goes down or up. Ctrl on a handle is a drag, not Ctrl+click's toggle select.
- Angle: the turn is a whole number of steps.
- Scale: the factor changes in whole steps of the value (× 1.25, × 1.5, …). A drag never scales below
  × 0.01, so it never flips or flattens an entity.

**Surface snapping (see** `crate::placement`**).** The usual editor's Surface Snapping: the toolbar's
**Surface** toggle (`gizmo.snap.surface`, off by default as in the usual editor; Ctrl+Shift held during the drag
in the alternate preset). With it on, a drag on the move handle's centre or a plane square puts the
selection on the surface under the pointer: the nearest mesh (the authoring world's own geometry) or
collider (a floor with no mesh) along the pointer's ray, **passing through the dragged entities and
everything under them**. The selection moves as one, anchored at the gizmo's pivot, and is lifted
along the surface's normal until the lowest corner of its meshes' boxes rests on the surface rather
than in it (a unit crate dragged over a floor at y = 1 lands at y = 1.5). **Align** beside it
(`gizmo.snap.surface.align`, the usual editor's Rotate to Surface Normal) also turns the selection about the
pivot so the pivot entity's up axis is the surface's normal. The grid does not apply to a surface
landing. Where the ray meets nothing the drag moves as it would without snapping. **The axis arrows
are unchanged.** Still one undo step, and Esc still puts everything back.

**Vertex snapping.** The usual editor's V key: hold **V** (`snap.vertex`) while dragging a Move handle and
the pivot goes to the vertex nearest the pointer on screen, within 24 points, of any mesh but the
dragged ones. The handle still decides the direction: the centre takes the vertex's position, a
plane square its position in that plane, an arrow only its position along the axis. With no vertex
that near, the drag moves as without V (or lands on a surface with surface snapping on). V can go
down or up mid-drag; the entities follow at once.

**What the queries cost.** The first snap of a drag indexes the rest of the scene's meshes once
(`placement::Targets`: each mesh's world box, from its own box through its matrix, sharing the
authoring world's geometry rather than copying it); the scene does not move during a drag, so the
index lasts until the release. A surface query tests every box and runs the triangle test only in
the boxes the ray enters, nearest first. A vertex query projects every box and reads the vertices
only of boxes within the snap radius of the pointer, nearest first, and never more than 200,000 a
query. Measured on the llvmpipe gate machine over 3,000 boxes
(`d18_place::the_vertex_query_is_bounded_on_a_three_thousand_entity_scene`, which prints them):
the index takes under a millisecond, a vertex query reads 7 boxes (168 vertices) in about 0.3 ms,
and a surface query ray-tests one box in about 0.05 ms.

**Several entities.** One gizmo, at the pivot, moves every selected root by the same change: a
child whose parent is also selected moves with the parent and is not moved again. Rotate turns
positions about the pivot as well as orientations; Scale spreads positions from the pivot along the
gizmo's axes and multiplies each entity's own local scale. Children's local transforms are worked
out through their parents, so an entity under a scaled or turned parent goes where the pointer says.

**Undo, cancel, locks.** A drag is one undo step for everything it moved
(`Editor::begin_transforms`, `set_transforms_live`, `end_transform`), labelled like
"move 3 entities". Esc or the right button during a drag takes the whole drag back, and the release
that follows does nothing; losing window focus or a dialog opening keeps what was done. A locked
entity refuses the drag: the press on the handle is not a pick either, and the status bar says why.
**Alt + drag duplicates** the selection and drags the copies, in one undo step with the move
(`Editor::begin_duplicate_transforms`); Esc takes back the copies too, and the selection is the
originals again.

**Prefab instances.** A drag on an instance moves its own transform. A drag on a part inside one
(selected in the Outliner) writes a transform override on the instance instead, within the drag's one
undo step; see [Prefabs](#prefabs).

**For agents.** The gizmo is a human input device; what an agent needs is the state behind it.
`/v1/state` (and `editor.state`) has `view.gizmo` with the fields of `state.json`'s `gizmo`, and
`view.tool`. `/v1/do {"do":"set-view","gizmo":{…}}` (and `editor.set {gizmo}`) changes any of them;
an unknown field or a value outside the limits refuses the whole call. The keys' actions run through
`{"do":"action","id":"tool.space"}` and the rest. The handles publish hit names (`gizmo.move.x`,
`gizmo.move.xy`, `gizmo.move.screen`, `gizmo.rotate.y`, `gizmo.rotate.screen`, `gizmo.scale.z`,
`gizmo.scale.uniform`) and the toolbar its controls (`gizmo.tool.move`, `gizmo.space`, `gizmo.pivot`,
`gizmo.snap.grid`, `gizmo.snap.grid.value`, `gizmo.snap.grid.0.5`, …, and `gizmo.snap.surface`,
`gizmo.snap.surface.align`), so a script can press them. `view.gizmo` has `surfaceSnap` and
`surfaceAlign`, set the same way.

**The compact toolbar.** The full row is about 760 points wide, more than the viewport at a UI
scale of 125 % or more in a 1280-wide window, where Select, Move, Rotate and Scale used to run off the
image. Wherever the full row does not fit the image it is drawn compact, as the usual editor's is: the tools,
World/Local and the pivot are painted icons (a pointer, four arrows, a turning arc, two squares; a
globe or a cube; cross hairs or a bounds box) with their names and keys in the tooltips; the three
snaps and surface snapping move into one **Snap ▾** menu (`gizmo.snap`, highlighted while any snap is
on), a row per snap with its toggle, its value and every step; the camera speed stays. Every hit name
is the same in both forms, the snaps' once the Snap menu is open, and the menu stays open for several
picks. The full row's width is remembered from the last frame it was drawn, so the choice does not
flicker, and the full row comes back once the viewport is wide enough. `d20_browse.rs` checks every
control whole and apart at 1280×720 and 1600×900, 100 to 200 %.

## Play controls

Pause, step, time scale, Eject and Simulate are one set of controls over one mechanism: the Play world's clock (`TimeControl`) and the view it is drawn through. Nothing here is a second clock.

| Control | What it does | Where |
|---|---|---|
| Pause / Resume | holds the world: `sim.now`, physics, behaviours, animation, timers and `ctx.time` stand still; rendering, the UI and the free camera carry on | toolbar `toolbar.pause`, F10 or Pause, `play.pause` |
| Step | runs exactly one fixed tick (`1/60 s`) and leaves the world paused; repeats while the key is held | `toolbar.step`, F11, `play.step` |
| Time scale | 0.1x, 0.25x, 0.5x, 1x, 2x, 4x (the API takes 0.1 to 10): how many fixed ticks run per wall second. The tick is never stretched, so the same number of ticks gives the same result at any scale  | the viewport's top-right strip `toolbar.timescale`, Ctrl+[ / Ctrl+], `POST /time {scale}` |
| Eject | detaches the view into a free-fly camera that starts at the game camera's pose; the game keeps running (paused or not, at its scale). Right button flies, Alt+left orbits, middle pans, the wheel sets the speed; a left click selects the live entity under it; F8 again (or the button) returns to the game camera and gives the game its keys back | `toolbar.eject`, F8, `play.eject` |
| Simulate | Play with the view ejected from the first frame and no input reaching the game | the Play menu's caret `toolbar.play-menu.simulate`, Alt+S, `play.simulate` |

**What Simulate maps to.** Talos has no separate player spawn to skip: the player is a scene entity (`spawn` for `player`, else the first character controller) that the device layer drives. So "not possessed" means exactly that: physics, behaviours, AI and timers run, the player entity stays where the scene put it, and nothing the person presses reaches the game (`Shell::game_has_input` is false and `game_key_dropped` says so to a caller). F8 in a Simulate shows the game's camera; keys still do not reach the game.

**Play-time edits.** While ejected, the Outliner and a click on the viewport select live entities (`Editor::select_live_at`), and the Inspector shows them as they run now (`Editor::live_transform`). A root entity's Position can be edited (`Editor::set_live_position`, the world's `teleport_entity`): it moves the running entity only, the scene document is untouched and never dirty, and the Inspector says "Play only: this position reverts when Play stops". Stop drops the world and puts the selection back as it was before Play. Everything else stays read-only during Play.

**The status.** The viewport's title plate reads `Play`, then whatever is not the ordinary run: `Play · Paused · 0.25x · Ejected`, or `Simulate · …` (`ui::play_status`); `GET /v1/state` carries the same as `play.status`, with `play.paused`, `play.timeScale`, `play.ejected` and `play.simulating`.

**Agents.** No new default agent tool. The control endpoint takes `{"do":"time-scale","scale":0.25}`, `{"do":"eject","on":true}` (omit `on` to flip), `{"do":"simulate"}` and `{"do":"live-position","id":"drifter","position":[x,y,z]}`, as well as `{"do":"action","id":"play.eject"}` for every keymap action above; `{"do":"pause"}` and `{"do":"step","ticks":n}` are as before.

**Cost.** Nothing runs when unused: the ejected camera is an `Option` in the Play session, read once per Play frame, and the authoring path is untouched. Editor start to playable is unchanged.

## Viewport modes

The usual editor conventions are the reference. Three menus float over the top left of the viewport, as the usual editor's
viewport toolbar does, and the camera speed sits at the end of the top-right toolbar, where the usual editor
reads it out:

| Menu (hit name) | Choices | Keys |
|---|---|---|
| Projection (`viewport.projection`, `.perspective` / `.top` / `.front` / `.side`) | Perspective; Top, Front and Side (left) orthographic | Alt+G, Alt+J, Alt+H, Alt+K |
| View mode (`viewport.mode`, `.lit` / `.unlit` / `.wireframe`) | Lit, Unlit, Wireframe | Alt+4, Alt+3, Alt+2 |
| Show (`viewport.show`, `.grid` / `.collision` / `.navmesh` / `.triggers` / `.bounds` / `.selection` / `.game`) | the grid, colliders, the navmesh, trigger volumes, bounds, the selection outline; Game view | G for game view |
| Speed (`viewport.speed`, `.1` to `.8`, `.scalar.<x>`, `.scalar.value`), in the top-right toolbar after the snaps | the camera speed step and scalar | right mouse + wheel |

**The buttons are meant to be seen.** People did not find the
projection or the view mode: the menus were plain words over the scene. They are now framed buttons,
as the usual editor's viewport toolbar has them: `viewmodes::MENU_BUTTON_H` (22 points) tall, a light outline
over a dark fill, a painted icon on the left (a camera for the projection; a lit sphere, a flat disc
or a wire triangle for Lit, Unlit and Wireframe) and the caret on the right. Show is its eye
alone, its tooltip naming it, so the row fits beside the gizmo toolbar in a 1280-wide window. Where the
two would still meet (a narrow viewport, a UI scale of 125 % or more at 1280) the row drops below the
toolbar rather than under it (`viewmodes::menus_at`), and the scene title follows it down. Their
tooltips name the keys, read from the person's keymap (`viewmodes::projection_tooltip`,
`mode_tooltip`): "The camera's projection: Perspective (Alt+G), Top (Alt+J), Front (Alt+H), Side
(Alt+K)" and "View mode: Lit (Alt+4), Unlit (Alt+3), Wireframe (Alt+2)". `d20_browse.rs` checks at
1280 wide and 100 to 200 % that each is whole: inside the image, in a row, clear of every
gizmo-toolbar control and the speed readout.

**The first-run hint.** Until the person has flown once (right mouse held and moved over the viewport)
**and** opened the projection or view-mode menu once, a one-line hint sits at the foot of the viewport:
"Hold right mouse + WASD to fly · Perspective and Lit, top left, change the view" (`viewport.hint`,
`ui::VIEWPORT_HINT`), as the usual editor's first-run tips do. Its × (`viewport.hint.close`) puts it away for
good; **Help ▸ Viewport controls** (`menu.help.viewport`) brings it back until both are done again.
Per user, in `preferences.json` as `"hints": {"viewportFlown", "viewMenuOpened"}`
(`preferences::Hints`), which `editor.state`'s `view.prefs.hints` reports and `editor.set {prefs:
{hints: …}}` sets. Not in game view, Play, the UI preview or over a settings page; a foreground area
wrapped to the viewport's width, so a click on it never picks and a narrow viewport keeps it whole.
A `Shell` with no preferences store (a test, a gate) shows it, since that is a first run.

**Camera speed.** The usual editor's Camera Speed and Camera Speed Scalar (`crate::camera`). The step, 1 to 8,
is one of 0.1, 0.3, 1, 4, 10, 30, 80 and 200 m/s, about three times apart as the usual editor's eight are, so
one wheel notch while flying is a clear change and the whole range runs from nudging along a tabletop
to crossing a large map. Step 4, the default, is 4 m/s, as it was, so the default fly feels the
same. **Steps other than 4 changed meaning** (they were 0.5, 1, 2, and 8, 16, 32, 64 m/s): a
state file written before keeps its step number, which now flies slower or faster. The **scalar** multiplies the step: the Speed menu offers ×0.1,
×0.25, ×0.5, ×1, ×2, ×4 and ×8 and a field that takes any value from 0.01 to 100
(`viewport.speed.scalar.value`), so the camera flies from 1 mm/s to 20 km/s. The readout says what
the camera flies at: `Speed 4 · 4 m/s`, or `Speed 4 ×0.5 · 2 m/s` with a scalar; Shift still flies
three times faster. Right mouse + wheel steps through the eight steps and the status line says the
new speed. Both are the camera's, so both are kept in `state.json` (`camera.speed`,
`camera.speedScalar`, which reads as 1 from an older file).

**Projection.** Top looks down -Y with +X right and -Z up the screen; Front looks down -Z (the
default camera's way) with +X right; Side is the usual editor's Left view, from -X, with +Z right. An ortho
view keeps its own zoom (`orthoHeight`, half the height it shows, in metres), starts at what
perspective showed at the target, and clips nothing in front of or behind the eye. It shares the
target with perspective, so F, a pan in Top and a switch back all agree on what is looked at;
perspective comes back at the angle it had. In an ortho view the right or middle button drags the
plane exactly with the pointer (no fly, no orbit), the wheel zooms, F frames the selection by the
zoom, and the grid lies on the view's plane, faint and over everything. The gizmo works the same in
every view: its hit test and drag rays are orthographic there, and a handle behind the eye's plane
is still in front of its ray.

**View mode.** Unlit draws every surface as its base colour and texture, with no light and no
tonemap. Wireframe draws each mesh's edges over a flat dark background. Both are a view-level flag
in the renderer (`frame.style.w`), not materials, and **Lit is untouched**: no pass, no pipeline, no
allocation, one uniform compare in the surface and sky shaders. The surfaces still write depth and
the id buffer in wireframe, so a click picks what is drawn. The edges go through the overlay's line
list (not `POLYGON_MODE_LINE`, which not every backend has): each mesh's unique edges are made once,
the first time it is drawn in wireframe, and a frame transforms them by each instance's matrix only
when the instances moved (`argus_render::debug_view::WireCache`); more than 2 million line vertices
are cut short rather than stall. Sprites and billboards: unlit draws the picture as it is (cut-out
and see-through as in Lit); wireframe draws each billboard's quad as its outline (four sides and the
diagonal) facing the camera, and a see-through one is left out of the flat surfaces. Particles draw
as they do in Lit. Skinned meshes are drawn in their bind pose. **HD:** the HD renderer does not
draw Unlit or Wireframe yet: the menu row says "Unlit/Wireframe not supported yet in HD", the two
choices are greyed out, and the keys, `set-view` and `editor.capture` refuse them with that reason;
the grid, overlays and outline still draw. The hooks are all in the standard renderer's
`opaque.wgsl` and `sky.wgsl` (each between `// debug_view {` and `// debug_view }`), none in HD's
files, and `crates/render/tests/view_modes.rs` holds Lit byte for byte to the renderer with them cut
out. Play always draws Lit, with none of
this: the renderer is handed back plain before Play takes it.

**Show.** The overlays are Play's debug overlays in authoring, built by `argus_capture::overlay`
exactly as a `POST /capture` with overlays draws them: colliders green, the navmesh cyan (turning it
on asks the authoring world for its existing navmesh build, when the scene declares navigation;
`crates/nav` is unchanged, and the menu says when there is nothing to build), triggers amber, bounds
white. Hidden entities' overlays are hidden with them. The grid is on the ground (y = 0) in
perspective, depth-tested, its finest lines the gizmo's grid snap, times ten until about sixty
lines fit either way, every tenth line brighter and the X and Z axes red and blue. The selection
outline draws the selection's (and its children's) mesh edges in the usual editor's orange over the frame.
The grid and the outline are on by default, as in the usual editor. Every line is cut to the view before it is
handed to the renderer (`argus_capture::overlay::clip_lines`, which `POST /capture` overlays and
Play's F1 overlays use too: a line crossing behind the eye is drawn as a smear, or not at all, by
llvmpipe). The lists are rebuilt only when the camera, the scene, the selection or a flag changes:
a still frame builds and uploads nothing.

**Game view** (G, or Show ▸ Game view) hides everything the editor draws over the game — grid,
overlays, the selection outline, the gizmo, the viewport's scene and selection labels and agents'
name tags — and keeps the selection.

**Kept and shared.** The view mode, the Show flags and game view are `state.json`'s `viewport`; the
projection, ortho zoom, speed step and scalar are the camera's. `/v1/state` (and `editor.state`) has
`view.viewport`: `{projection, orthoHeight, mode, show: {grid, collision, navmesh, triggers, bounds,
selection}, gameView, cameraSpeed, cameraSpeedScalar, flySpeed}` (`flySpeed` is what the two make, in
m/s, read only). `/v1/do {"do":"set-view","viewport":{…}}` (and
`editor.set {viewport}`) changes any of them; `show` is an object of flags (a patch) or a list of
the ones on; an unknown field or a bad value refuses the whole call. `POST /v1/capture` with a JSON
body `{view, mode, overlays, camera}` (and `editor.capture` with the same arguments) draws one frame
of the authoring view that way — `view` persp|top|front|side, `mode` lit|unlit|wireframe, `overlays`
the Show names that are on (game view off), `camera` any of `view.camera`'s fields or `eye` and
`target` — reads it back, and puts the person's view back before it answers: nothing they see or
have set changes, and nothing is saved. A bad argument is `422`; during Play these arguments are
refused (the capture is of the authoring view).

**Camera bookmarks (see** `crate::bookmarks`**).** The usual editor's: **Ctrl+1 … 9** keeps the view in that
slot and **1 … 9** goes back to it (`camera.bookmark-save-N`, `camera.bookmark-N`). A bookmark is the
whole view — target, yaw, pitch, distance, field of view, projection and ortho zoom — and going back
keeps the camera speed, which is the person's rather than the view's. An empty slot changes nothing
and the status line says so. The Perspective menu lists them under **Bookmarks** (each saved slot,
its projection and key; `viewport.bookmark.N`) with a row of **Set** buttons (`viewport.bookmark.set.N`),
which run the same actions as the keys. Kept per project in `state.json` (`bookmarks`, by slot; a file
from before has none). Agents read them in `view.viewport.bookmarks` (`{"3": {…view.camera's
fields}}`) and set them with `{"do":"set-view","viewport":{"bookmarks":{"3": {…} | "current" | null},
"bookmark": 3}}` (`editor.set {viewport}`): `bookmarks` sets a slot to a view (`view.camera`'s fields,
or `eye` and `target`), to the view now, or clears it; `bookmark` goes to a slot, and the rest of the
patch applies to that view. An empty slot refuses the whole call.

**Caches.** The gizmo's Centre pivot walks every selected mesh for its bounds; it is worked out
once per selection, scene edit and authoring world (`Editor::selection_bounds_center`), not per
frame.

### 2D projects

A project whose manifest has `kind: "2d"` opens in the **2D view** (`crates/editor/src/two_d.rs`);
a 3D project is unchanged by all of this.

- **The view** is Front ortho (looking down -Z, +X right, +Y up, the plane flat sprites, tilemaps
  and planar physics use), opened on the scene's primary camera's X and Y at the half-height the
  game shows (`orthoSize`, else from `pixelsPerUnit`, else 5 m). The wheel zooms about the pointer
  (the point under it stays put), middle and right drags pan, and nothing orbits or flies. F fits
  the selection's XY bounds. The grid is the XY plane's, at Z = 0. The projection button reads
  **2D**; its menu's **2D view** row (`viewport.2d`) switches to a perspective 3D view of the
  project for debugging and back. That is this session's view state only: never written to the
  project or `state.json`. The other projections are greyed while the 2D view is on.
- **The gizmo** keeps the X and Y arrows and the XY square (Move), the Z ring (Rotate) and the X
  and Y boxes (Scale); its axes turn only about Z, so no drag changes Z. The Details transform stays
  full 3D: it is how a stray Z is fixed.
- **Snapping**: the grid snap as in 3D, and the toolbar's **Pixel** (`gizmo.snap.pixel`), which
  snaps a move to 1 / P metres instead, P being the primary camera's `pixelsPerUnit` when it is
  orthographic with one, else 100. Ctrl inverts it as it inverts the grid.
- **Details** has typed rows for the 2D fields (`crates/editor/src/inspector_2d.rs`): a camera's
  projection, and while orthographic its `orthoSize` and `pixelsPerUnit` (unset is "auto"; the ×
  takes one away) and `pixelSnap`, with `fov` greyed; a sprite's billboard, anchor, `sortLayer` and
  `order` (flat only), flips, `pixelsPerMetre`, and the grid and cell of a view cut from a sheet's
  grid; a rigidbody's `planar` and six axis locks (planar's own three shown ticked and fixed; both
  greyed on a body that is not dynamic); a tilemap's sheet, cell size, alpha cutoff, filter and
  each layer's sort layer, order, collision and visibility (its size shown; its rows are painted in
  the viewport with the palette under the rows).
- **New scene** offers the kind's one-file templates (`Template::scene_templates`): 2D empty in a 2D
  project. The control surface's `new-scene` takes `starter` and `empty` as the kind's own, and
  `2d-empty` in a 2D project.

Agents read it all in `editor.state`: `view.viewport.twoD` (whether the 2D view is on; `null` in a
3D project), `view.viewport.pixelStep` (1 / P) and `view.gizmo.pixelSnap`, and set it with
`editor.set`: `{"viewport": {"twoD": false}}` and `{"gizmo": {"pixelSnap": true}}`. A projection
other than front asked for with the 2D view on refuses the whole call.

## Presentation view

`crate::presentation`, for watching a game grow while agents build it, and for recording that. **View ▸ Presentation view** (`menu.view.presentation`) or F11 (`view.presentation`) turns it on and the same again turns it off. It is off unless asked: nothing starts, reads or computes until then.

While it is on the menu bar, toolbar, status bar, Details and the drawer are not drawn, so the viewport fills the window (prompts and windows still draw, because a save conflict must stay answerable). Editor-only drawing is off, as in game view. The camera orbits the scene's bounds (one turn in 90 s), framed again once a second so a growing scene stays in view; or it sits on the transform of a chosen camera entity. The overlay (`presentation.overlay`) shows an elapsed clock and the newest eight lines of an activity feed. Turning it off puts back the camera and game-view setting the person had; starting Play ends it.

**Live.** An agent's scene edit made through the workspace protocol reaches any open editor at once (Live updates on, the default: the change is folded into the document and the authoring world through the hot-reload path, never into undo). A scene file written straight to disk is only noticed by an editor, which waits for Reload; the Presentation view reloads it itself once a second, when the person left no unsaved edits, because nobody edits in this view.

**Activity feed.** A JSONL file, by default `.argus/activity.jsonl` in the project, in the format `tools/timelapse` documents: `{"t": "2026-09-30T14:05:12Z", "agent": "builder-2", "action": "adds the ground"}`, where `t` is an ISO-8601 string with a zone or epoch milliseconds (`time` is accepted for `t`; a number under 1e11 is read as seconds) and `agent` is optional. A line that does not parse is skipped. The file is checked twice a second by length and modification time and read again only when either changed; the overlay shows each line's time as an offset from the file's first line.

## Unsaved work: the prompt, autosave and recovery

Nothing a person has done is lost without them
being asked, and nothing is lost to a crash that an autosave could have kept.

**Unsaved work** is any of: the open scene's document; a prefab in Edit Prefab (and the scene put
aside under it); Project Settings typed and not applied (the page keeps them when it is closed, so
they count, as `argus.project.json`). The editor has no text panels: behaviours and screens are
edited outside it, and saved by whatever edits them. `Shell::unsaved` lists the files, in the order
Yes saves them; `editor.state`'s `dirty` is the same list.

**Every way out asks the same question**: **"Save changes to <files>?"** with **Yes / No / Cancel**,
one file named in the question, several listed under it (`prompt.file.<path>`). The ways out are the
window's close button (`Exit::Quit`), Close project, File ▸ Open project… and New project… (each
`Exit::ToLauncher`), the renderer restart a `render.profile` change asks for (`Exit::Reopen`), and
opening or making another scene (the prompt names only the scene: Open scene is refused in Edit
Prefab, and settings are not left behind by it). The launcher is shown only once a project has
closed, so quitting it or opening a project from it has nothing unsaved to ask about. Nothing is
asked when nothing is unsaved.

- **Yes** saves each file in order — the prefab, then Back, then the scene, then Project Settings'
  Apply — and then goes. It **stops** at the first that does not save, with the prompt still up: a
  file changed, missing or unreadable on disk raises **Overwrite / Reload / Cancel** over it, and
  once that is answered the rest are saved and the close goes ahead (`Shell::continue_after_save`);
  an error (a failed write, a refused Apply: a field conflict, a held lease, a read-only manifest)
  is shown in the prompt in red (`prompt.error`).
- **No** drops every listed change — the prefab's, the scene's, the typed settings — and goes. The
  files keep what was last saved.
- **Cancel** stays.

The names are `prompt.yes`, `prompt.no` and `prompt.cancel`; Yes and No are also drawn under the
the original prompt's `prompt.save` and `prompt.discard`, so an older gate presses the same answer.
**Enter is Yes and Escape is Cancel**, while the prompt is the one on top (a conflict or a dialog
over it takes the key). An agent reads `editor.state`'s `prompt` (`{kind, exit, scene, files,
error}`) and answers with `/v1/do {"do":"answer","which":"prompt-yes"|"prompt-no"|"prompt-cancel"}`
or `editor.click`.

**Autosave** (`crate::autosave`). Every *N* seconds of unsaved edits (default 60, as the usual editor's Editor
Preferences ▸ Loading & Saving has it), each dirty document is copied to
`<project>/.argus/autosave/<its relative path>` — never over the file — with a record beside it,
`<path>.autosave.json`: `{schema, path, kind: scene|prefab, baseSha256, copySha256, savedAtMs}`,
the base being the hash of the file's bytes the document was loaded from or last saved as. A copy
is not a Save: no revision, no build, no journal entry (the service ignores `.argus/`), and the
document stays unsaved. The interval starts at the first edit not yet copied. **It does not stall a
frame**: the frame clones the document's scene, and one writer thread serializes it and writes the
copy and then its record, each atomically; removals go down the same queue, so a removal is never
overtaken by an older write. `tests/d18_safety.rs` measures it on a 3,000-entity scene. A document
that is no longer dirty — saved, discarded, or undone back to the file — has its copy removed on the
next frame. A session that ends with unsaved work nobody was asked about (a quit signal, a scripted
close) copies it as it closes, so the next open offers it back.

The switch and the interval are the person's, **Edit ▸ Editor Preferences… ▸ Loading & Saving**
(`prefs.autosave.enabled`, `prefs.autosave.interval`, 10–3600 s), beside Keyboard Shortcuts, kept per
user in `<config dir>/argus/editor/preferences.json` as `{"autosave": {"enabled": true,
"intervalSeconds": 60}}` (`ARGUS_PREFERENCES_FILE` overrides the path). Only the window's editor
reads it (`Shell::set_preferences_store`); a `Shell` built directly — every test, a headless gate —
autosaves nothing unless told to (`Shell::set_autosave`). `editor.state`'s `autosave` is
`{enabled, intervalSeconds, last, error}`.

**Crash recovery.** A project that opens with copies left in `.argus/autosave/` shows **Restore
unsaved work**, a modal listing each (`recovery.<path>`) with when it was made and **Restore** /
**Discard**, as the usual editor's "Restore Packages" does. Scenes are listed before prefabs. Restore is
offered only when the file still hashes to the copy's base: a copy of a file that has changed since
(another writer, a checkout) says so and where the copy is, and offers Discard only, because
restoring it would silently undo whatever changed the file. **A restored document opens unsaved**:
the copy becomes the document as one undo step ("restore autosave"; Ctrl+Z gives the file's version
back), a scene that is not the open one is opened first, and a prefab opens in Edit Prefab. The copy
stays until the document is saved or discarded. **Later** (`recovery.later`, or Escape) is
The usual editor's Skip: the dialog closes touching nothing, and the next open offers the copies again.
**A copy put off with Later is never written over**: until its offer is answered, the autosave
skips that document altogether (the simpler of skipping it and writing a second copy beside it),
so work done on it this session is protected by the prompt on the way out, not by an autosave. An agent reads `editor.state`'s `recovery` (and
`recoveryLater`) and answers with `editor.set {recover: {path, restore}}` or `{recover: {later:
true}}`, or `/v1/do {"do":"recover","path","restore"}` / `{"do":"recover","later":true}`.

## Editor Preferences

**Edit ▸ Editor Preferences…** (also the toolbar's Settings ▾) is the usual editor's page for the person's own
settings: none of them is the project's, and all but the keymap are kept in the same
`<config dir>/argus/editor/preferences.json` as autosave's (`ARGUS_PREFERENCES_FILE` overrides the
path), each section under its own field beside `"autosave"`. Every change is written at once, a
number field's when the drag ends or the typing is done. A file written before this change (autosave alone)
reads as the defaults; a section that does not read is that section's defaults, with the reason on
the page (`prefs.note`). `crates/editor/src/preferences.rs`, `prefs_ui.rs`.

- **Loading & Saving**: autosave's switch and interval ([Unsaved work](#unsaved-work-the-prompt-autosave-and-recovery)).
- **Appearance** (`"appearance": {accent, uiScale, fontSize, density}`):
  - **Density** (`prefs.appearance.density`, `comfortable` 24 pt rows, the default, or `compact` 20
    pt rows; `theme::row_height()`): how tall a list, tree or property row is.
  - **Accent colour** (`prefs.appearance.accent`, teal, the default, blue or violet): selection, focus, the active
    tab and the switches' fill, egui's and the painter's alike (`theme::accent()`). **Only the
    accent, not a light theme**: the shell's surface and text colours are constants that about two
    hundred draw calls name directly, so a second palette would mean every one of them asking which
    theme is on. Each accent holds the contrast the usual editor's blue has (`theme::tests`).
  - **UI scale** (`prefs.appearance.ui-scale`, 75 % to 200 %), over the display's own scale: points
    are `display × UI scale` pixels. A scale above 100 % stops where the window would have fewer than
    480×320 points, the smallest shell the layout-fit sweep proves (960×640 at 200 %); the display's
    own scale is never cut. `tests/d19_prefs.rs` sweeps every UI scale over displays at 100, 150 and
    200 % in both window sizes, at the default and the largest text size.
  - **Font size** (`prefs.appearance.font-size`, 10 to 16 pt, default 12.5): the body text; small,
    heading and monospace text keep their distance from it. Text the painter lays out itself (the
    Outliner's rows, the status bar's chips) keeps its size.
- **Viewport** (`"viewport": {fovDegrees, speed, speedScalar, mouseSensitivity, invertY}`):
  - **Field of view** (`prefs.viewport.fov`, 30° to 120°, default 60°): the perspective view's, in
    every project, from the moment it is changed.
  - **Mouse sensitivity** (×0.1 to ×5) scales how far the view turns for the mouse's travel, flying
    and orbiting; **Invert Y while flying** turns the right-button look over. Every project.
  - **Camera speed** and **Camera speed scalar**: what a *new* project's camera starts at (the Speed
    menu's step and scalar). A project keeps its own after.
- **Grid & Snapping (new projects)** (`"snapping": {gridSnap, grid, angleSnap, angle, scaleSnap,
  scale}`): the snaps a *new* project's gizmo starts at. A project that already has its own
  (`state.json`, [Restart state](#restart-state)) keeps them; the viewport toolbar changes them there.
  "New" is a project the editor has no restart state for.
- **Keyboard Shortcuts**: the person's keymap ([Keys](#keys)).

**Search** (`prefs.search`): one box at the top filters every section. A row is shown when its name
or its keywords match ("fov", "invert", "snap"), a shortcut row when its action, id, category or keys
do ("duplicate", "F2"); a section whose own name matches ("viewport") is shown whole, one with no row
that matches is not drawn at all, and nothing matching says so (`prefs.search.none`). The section
headings are `prefs.section.<saving|appearance|viewport|snapping|keys>`.

The viewport's menus and the gizmo toolbar are foreground areas over the image; while Project
Settings or Editor Preferences covers the viewport they are not drawn (they drew over both pages and
took the clicks meant for them).

Agents read the effective preferences in `editor.state`'s `view.prefs`, in the file's spelling
(`{appearance, viewport, snapping}`), and change any field with `editor.set {prefs}` (`/v1/do
{"do":"set-view","prefs":{"viewport":{"fovDegrees":75}}}`); an unknown field or a value outside its
limits refuses the whole call. An agent's change is the person's too, written to the same file.

## Prefabs

The editor's half of prefabs. The data (`*.prefab.json`,
instances, override keys, expansion) is `docs/SPEC.md` "Prefabs"; the agent's tools are `prefab`
and `prefab.place`. The usual editor is the reference, and the parity rule is kept: every
verb below calls the code the agent's tool calls (`crates/editor/src/prefab_edit.rs`, which
`crates/agent/src/prefab_ops.rs` wraps), and what it writes is the file an agent reads next.

**The document stays as written; the world expands.** The document (`Editor::document`) holds the
scene file of record, with instances and overrides, and saves it unchanged except for your edits.
The authoring world, the Outliner, Details and the gizmo show the scene as it runs
(`Editor::resolved`): each instance's parts (`<instance>/<prefab entity id>`) in place, resolved from
the project's prefab files. Every edit is checked in the project with instances expanded
(`Document::set_context`), so an override that breaks the contract is refused like any edit. A
prefab file changed on disk (an agent's apply, a text editor) is picked up on the next disk check
(every 2 s), and every instance shows it; undo, reconcile and Play are unchanged. A scene entity may
be parented inside an instance (drop it on a part in the Outliner, or `entity.reparent` to
`<instance>/<part>`).

**Content browser.** Prefab tiles (the Blueprint blue, a box in a box) and a **Prefabs** type in the
Filters menu (it was a chip of its own, shown once the project had a prefab). Drag a tile onto the viewport to put an instance where you let
go (on the surface under the cursor, else on the ground plane: `content_browser::drop_point`, the
same placement a dropped model gets), or select it and press **Place** for
one at the origin (one undo step, the agent's `prefab.place` with one item).
Double-click or **Open** is Edit Prefab. **Instances** (and the tile's right-click **Select all
instances**) selects every instance of it in the open scene.

**Create Prefab…** Right-click an entity in the Outliner, right-click the viewport with it selected (a
right click that flies nowhere, as in the usual editor; a right drag still flies), press **Create Prefab…** in
Details, or drag an Outliner row onto the Content browser. The right-click menus also offer an
instance's Select instance, Edit Prefab, Apply and Revert. A path picker suggests
`prefabs/<name>.prefab.json` (or the folder the browser shows). Create writes the prefab file from
the entity and everything under it, and puts an instance in their place, as one undo step (the
agent's `prefab` create). Undo puts the entities back; the file stays, as an import's copy does.

**Outliner.** An instance's row carries the prefab icon. Its parts are listed under it in grey: a
part is its prefab's, so it is not dragged, and Delete, Duplicate and reparenting refuse it, naming
the instance. Scene entities parented under an instance, or under a part, show normally. A click in
the viewport on a part selects its instance, as a click on a Blueprint's component selects the actor;
the Outliner reaches the part itself.

**Details.** Over an instance, or a part of one, a bar reads `Prefab: <name> · N overrides` with
**Edit Prefab**, **Apply** and **Revert**. Every overridden property (a component field, or a part's
position, rotation or scale) carries the usual editor's yellow reset arrow, which drops that override (one undo
step). An override that no longer applies (`prefab-override-unknown`) is listed at the bottom in red,
with **Remove**. An edit to a value the prefab owns, on an instance or a part, is written as an
override on the instance, under the key grammar `scene.patch` with `resolved: true` writes; it never
edits the prefab. **Apply** (a person's, or an agent's `prefab` apply while the editor runs: the agent's goes through the
editor's own Apply, `/v1/do {"do":"prefab-apply"}`) writes the instance's overrides into the prefab file and takes them off
the instance (one undo step; every other instance changes at once, and the status line says how many
there are in the project). **Revert** drops them all. Undo of an Apply puts the overrides back on the
instance **and** the prefab file's bytes back as they were before the Apply, so every other instance
is as it was too; redo writes the applied bytes again. Either is refused, changing nothing, when the
prefab file no longer holds what the step left there (someone changed it since): the status line says
"prefab changed since Apply; not undone".

**Edit Prefab** opens the prefab file as the document, the scene put aside as it was (its undo, its
selection, its camera). A bar over the viewport reads `Editing prefab: <name> (N instances)` with
**Save** and **Back**. Everything that edits a scene edits the prefab here (transforms, Details,
the Outliner, undo); Save (or Ctrl+S) writes the prefab file. **Back** returns to the scene, with
every instance resolved from the prefab as saved; it is refused while the prefab has unsaved changes,
and refused (staying in the prefab) if the scene would not load with it. No nesting: a prefab holds no
instance. Play (its button disabled, with the reason), Reload, opening or making another scene
(File ▸ Scene items disabled), importing and placing are refused until Back. Closing the window or
the project asks one question naming the prefab and the scene under it: Yes saves the prefab,
goes Back, then saves the scene, the established order; No drops both. Neither is dropped silently. An
agent's scene commit is refused while a person has a prefab open, with the reason; `/v1/state`
(`editor.state`) says `editingPrefab: {path, name, instances, dirty}`.

**Hit names.** `prefab.header`, `prefab.name`, `prefab.edit`, `prefab.apply`, `prefab.revert`,
`prefab.select-instance`, `prefab.create`, `prefab.create.path`, `prefab.create.confirm`,
`prefab.create.cancel`, `menu.prefab.create` / `.select-instance` / `.edit` / `.apply` / `.revert`
(the right-click menus), `prefab.stale.<key>` and `.remove`, `<row>.reset` (for example
`inspector.transform.position.reset`), `prefab.editing`, `prefab.save`, `prefab.back`,
`content.filters.prefab` (the Filters menu's Prefabs row) and its chip `content.filter.prefab`, `content.prefab.instances`.

## World Settings

The usual editor's **World Settings**: the open scene's own
settings as typed rows, in a tab beside **Details** in the right column (click the tab, or
**Window ▸ World Settings**; choosing it again goes back to Details). The code is
`crates/editor/src/world_settings.rs`.

**It edits the scene's `meta`, and nothing else.** Every row is one field of the scene file under
`/meta`, the same field an agent reads with `scene.read` and writes with `scene.patch` (the mapping is
in the agent guide). A change is shown in the viewport as it is made
(`World::reconcile` applies gravity, physics quality, the look, the sky and fog in place), is one undo
step (titled "World Settings: Set Exposure" in the Undo History), and is saved with the scene.

| Section | Rows |
|---|---|
| World | Gravity (x, y, z), Bounds min, Bounds max |
| Physics | Quality; **Overrides**: solver iterations, substeps, continuous collision, collider detail; **Sleep**: speed, spin, after |
| Rendering | Style, Shading, Texture filter, Tonemap, Exposure, Resolution scale, Anti-aliasing |
| Environment | Sky colour, Env map (old), Ambient; **Sky**: Custom sky, kind, preset, panorama asset, intensity, rotation, sun from sky, turbidity; **Sun**: Explicit sun, elevation, azimuth; **Atmosphere**; **Clouds**; **Fog**: Fog, mode, colour, density, height falloff, base height, start, end, max opacity; **Fog (HD)** |
| Navigation | **Auto links**: max drop, max jump up, max gap, spacing, max links |
| Budgets | Triangles, Draw calls, Texture MB, Dynamic bodies |
| Audio | Audio mixer, Voices, Output device, Buses, Snapshots, Sounds, Music |
| UI | Start screen, HUD layout |
| Input | the scene's input action map, `meta.input`: the input table (`crate::input_ui::section`), the same one Window ▸ Input opens, in a sideways scroller of its own since it is wider than the column |

Not here: the scene's name, genre and string table, and `meta.outliner` (the Outliner's folders).
The search keeps the Input section whole when it matches "input", and leaves it out otherwise (its
rows are the table's, not World Settings'). The table disables itself during Play.

**The rows** reuse the inspector's kinds. A number drags and types, three numbers make a vector, a
boolean is a checkbox, an enum is a dropdown of exactly the contract's spellings, a colour is a picker
beside its hex text (only a whole `#RGB` or `#RRGGBB` is written, so half-typed hex writes nothing), a
string is a text field (emptying an optional one leaves the field out rather than writing `""`).
**JSON rows:** Audio's Buses, Snapshots, Sounds and Music, and UI's HUD layout, are arrays and maps
with no typed editor yet; each is the field's JSON text, written only once it parses and is not
`null`, as the inspector's JSON rows are. **Toggles** put an optional object there or take it away:
Custom sky, Explicit sun, Fog and Audio mixer. Ticking one writes the least that validates and looks
the same as its absence (a procedural sky at intensity 1; fog with every default; a master bus), so
it changes nothing until a row under it is changed. A row under an absent object shows its default,
and changing it writes the object first, from the same template.

**Every edit goes through the scene's own check.** The meta is edited as JSON and read back through
the contract's `SceneMeta`, then the whole scene is validated, so an exposure of 100 changes nothing
and the toolbar says `render-value-range … it must be more than 0 and at most 64`. A drag is one step
from the first change to the release; typing is one step however many letters; a checkbox, a toggle,
a dropdown and the reset arrow are a step each. Escape abandons a run as it does in Details. During
Play the rows are disabled; while a prefab is open they are replaced by a line saying a prefab has no
World Settings.

**Where a value comes from.** A row whose field the scene writes is **this scene's**: normal text,
and, when the scene may leave the field out, the usual editor's yellow **reset arrow** at the end of the row,
which removes the scene's value (one undo step). A row whose field the scene leaves out shows what
stands in for it, greyed: the **engine's default**, which follows what the scene chose (Rendering's
fields follow the Style, retro's scale is 0.5; Physics' overrides follow the Quality preset,
`argus_physics::quality`), or `auto` when the engine decides from more than `meta` (a procedural
sky's turbidity follows its preset; whether the sun follows the sky depends on the scene's lights). The label's hover names the field's pointer and its
source. A required field (Bounds, Ambient, the Budgets) has no arrow. Gravity has one: a scene without it takes the project's `physics.gravity`, else `[0, -9.81, 0]`.

**Project defaults (option A).** Sean chose project-wide defaults in `argus.project.json`
([Project Settings](#project-settings) ▸ Physics, Graphics, Input, Audio) that scenes override field
by field. `world_settings::project_defaults` reads them from the document's context, shaped like
`/meta`, and `world_rows` shows a row the scene leaves out as **from project** (in the link colour),
with the project's value; the reset arrow's hover names the value that will stand. The editor takes
the defaults again whenever the manifest changes (`Editor::adopt_scene_defaults`: a Project Settings
apply, or an agent's `project.settings.set` the service reports), so the viewport and these rows follow
without the scene being opened again. The row reads `WorldRow::source` and `WorldRow::fallback`, and
an edit only ever writes or removes the scene's value. Over the project's defaults an edit writes
only its own field (`world_settings::meta_edited_over`): an audio block made for Voices or Output
device, or by ticking Audio mixer, starts with no buses of its own rather than a master bus that
would override the project's, and the Buses JSON row keeps only the buses that differ from the
project's same-named ones.

**Search.** A box at the top (in the header strip beside the tabs when the column is wide enough,
else the first line of the panel; the Details search moves the same way) filters rows by label across
every section, as the Details search does: a section's or a group's title keeps all its rows, and
every section is opened while a search is on. Escape in it, or its ×, clears it.

**Which tab is in front** is kept for the run (not in `layout.json`), and is `/v1/state`'s
`view.layout.detailsTab`, `details` or `world`; `/v1/do {"do":"set-view","layout":{"detailsTab":"world"}}`
(`editor.set {layout}`) sets it, opening the column if it was closed.

**Hit names.** `world-settings` (the tab; `details` is the Details tab), `menu.window.world-settings`,
`world.search` and `world.search.clear`, `world.section.<key>` (`world`, `physics`, `render`,
`environment`, `nav`, `budgets`, `audio`, `ui`, `input`; the Input table's own controls are `input.…`), `world.group.<section>.<group>`, one row
`world.<path>` (`world.render.exposure`, `world.environment.fog.density`), a vector's fields
`world.gravity.x`/`.y`/`.z`, an enum's items `world.<path>.<item>` (`world.render.tonemap.neutral`),
a colour's picker `world.<path>.pick`, and the reset arrow `world.<path>.reset`.

`crates/editor/tests/d19_world.rs` drives it through the winit boundary: gravity, exposure and fog
density each one step, shown and saved; a drag and a typed word each one step; a refused exposure;
a dropdown; the reset arrow; the search; and the panel at 100-200% display scale.

## Project Settings

The usual editor's **Project Settings**: a page over the
editor (Edit ▸ Project Settings…, the toolbar's Settings, the Export panel's Edit in Project Settings)
with a category list on the left and typed rows on the right. It edits `argus.project.json` through the
workspace service (`crates/editor/src/settings.rs`, below under [The workspace service](#project-settings-1)):
what is typed waits for **Apply**, Revert drops it, and closing the page keeps it.

| Group | Category | Rows (manifest pointer) |
|---|---|---|
| Project | Identity | **Project type** (2D, 3D, 3D HD: `/kind` with `/render/profile`, below); Display name, Application id, Executable name, Version, Build number (`/application/*`) |
| Project | Startup and window | Startup scene; Window width and height (whole pixels, 64..8192, checked as typed with the contract's `project-window-size`), Resizable (a checkbox) |
| Project | Packaging | Icon, with Choose… and a preview (`/application/icon`) |
| Project | Asset generation | **Concept images** and **3D model generator**, each an Enabled checkbox (off by default) and a Provider dropdown (the concept step's shows tripo until another is picked, and the file holds it only once one is); **Image model** and **Concept views** (Auto by asset type, 1 or 2; Auto leaves `views` out of the file, 1 and 2 write a number) for the concept step; **Style** (render, proportion, wear: dropdowns over the taxonomy's values, None leaves the facet out); **Spend** (project and session caps, Ask before paying, on by default), with the running spend from the library's ledger and each provider's key status, with where the key comes from in plain words, and a **Set key…** button that opens the person-only `keys set` in a terminal (a key is never typed in the editor and never stored in the project); **Budgets** as one table, a line per class and a column per profile, each cell triangles / texture side, muted at the gate's own ceiling until typed (`settings.assets.budget.<class>.<profile>.<triangles\|textureMaxSide>`). A note says generation uses your own provider keys and costs money. Nothing is generated in a project until it opts in here. Authoring only: a change needs no restart or export |
| Engine | Physics | Default gravity (`/physics/gravity`), Quality (`/physics/quality`); **Overrides** and **Sleep** (`/physics/overrides/*`) |
| Engine | Graphics | Renderer, standard or HD (`/render/profile`), shown and not edited: "set by Project type"; **Default look**: style, shading, filter, tonemap, exposure, resolution scale, anti-aliasing (`/graphics/render/*`); **Quality (HD)**: preset, target frame rate, the knobs it overrides, the custom profile (`/render/quality/*`); **Windows** and **Linux**: each platform's preset, frame rate and knob overrides over it (`/render/quality/platforms/<platform>/*`), and what that platform runs |
| Engine | Input | The project's action map (`/input`) as [the Input table](#the-input-table) |
| Engine | Audio | **Buses**: Master, Music and Effects volume (the buses named `master`, `music`, `sfx` in `/audio/buses`); Voices, Output device (`/audio/voices`, `/audio/device`) |

**Project type is one row for two fields**. It is the first row
of Identity (`settings./kind`), a choice of **2D** (`kind` `2d`, `render.profile` `standard`), **3D**
(`3d`, `standard`) and **3D HD** (`3d`, `hd`), the same `argus_projects::template::ProjectType` New
project offers. A choice waits for Apply like any row, and Apply sends **both** `/kind` and
`/render/profile` in one change set, the pair an agent sends with `project.settings.set`; choosing what
the project already is sends nothing. The Graphics ▸ Renderer row only shows the renderer, with "set by
Project type", so two controls never write `render.profile`; the service's own `/kind` field ("Project
kind") is this row, not a second one. A manifest from before `kind` reads as 3D or 3D HD by its
renderer. A pair the row does not offer (a hand-edited `2d` on `hd`) reads **Custom (2d, hd)** with the
`project-kind-2d-hd` warning on the row (`settings./kind.diagnostic.project-kind-2d-hd`); the project
still loads and draws, and choosing any type replaces the pair. The row publishes what it reads as
`settings./kind.shows.<2d|3d|3d-hd|custom>`.

An applied change goes the renderer's restart path: the project reopens (`Exit::Reopen`), after the
unsaved-work prompt when anything is unsaved, whose extra line says the project reopens and scenes are
not changed (`prompt.then`). **Changing the type converts nothing.** No scene is rewritten; the kind
sets the defaults new game cameras and physics take (orthographic and planar for 2D) and how the
viewport behaves, and the Identity section says so (`settings.identity.project-type`).

**The Engine categories are the defaults every scene starts from** (option A): a scene that leaves a
field out of its `meta` takes the project's, and one that writes it overrides it
([World Settings](#world-settings) shows which). The rows are World Settings' own for the same field —
label, editor, hover line — from `world_settings::describe`, and a row the manifest leaves out shows,
greyed, the engine's default that then stands. A row the manifest writes has the usual editor's reset arrow,
which takes the value out of the manifest. A typed row not yet applied has its label in the warning
colour.

**Graphics reuses the quality system; it adds none.** The presets are the contract's
`RENDER_QUALITY_PRESETS`, the knobs and their types `RENDER_QUALITY_KNOBS` (a boolean is a checkbox,
an integer a whole-number field, a choice a dropdown), and a platform's line under its rows ("Linux
(this machine) runs: high at 60 fps, 1 knob overridden") is `effective_render_quality`, the layering
the launch plan hands the renderer. The base quality and each platform are the same rows over another
pointer. A knob is overridden with **Override a knob…**, which lists every knob not yet overridden;
the arrow gives it back to the preset. Quality is the HD renderer's (`project-render-quality-without-hd`),
so its rows are disabled, with a line saying so, until the Renderer is HD.

**Input** is the input table over the project's map (`input_ui::InputTarget::Project`), read from the
manifest with anything typed over it (`project_settings::ProjectMaps`). A project with no map shows the
built-in one, and the first change copies it into `/input`. A change waits for Apply like every other
row; **Remove the project's map** takes `/input` out.

**One edit per settings field.** The service takes an edit only at one of its fields, so a row inside
one — one override, one platform's preset, one bus's volume — is typed by its own pointer and Apply
sends the whole field with the row applied (`project_settings::edits`): `/physics/overrides`,
`/render/quality/platforms`, `/audio/buses`. An object left empty is taken out.

**Diagnostics and refusals are on their rows.** Every diagnostic of the manifest is shown under the
row whose pointer it names (a bus by its index is matched to its name), and the rest at the top of
the section. An Apply the service refuses (422 `invalid-settings`) writes nothing, keeps what was
typed, says why at the foot, and puts each diagnostic on its row until the next Apply or Revert: a
substep count of 99 is `project-default-invalid: physics-quality-invalid: …; every scene that does not
set its own takes this default`. What a change needs (`requires.restart`, `requires.reExport`, from
the service) is said once per section, or with a `×` on the row where the section is mixed.

**After an apply the editor follows.** The shell re-reads the manifest (`Editor::adopt_manifest`),
takes the scene defaults into the document's context and shows the scene again with them, so the
viewport and World Settings reflect the new defaults without the scene being reopened. Play reads the
manifest when it starts (`Editor::preview_run_scene` merges the saved scene's meta over the defaults
as they are then). A change made elsewhere — an agent's `project.settings.set`, another window — is
found the same way (the service's change report, or the two-second disk check), and the page reads
the settings again, keeping what was typed on it (`EditorUi::refresh_settings_view`).

**Search.** One box filters every category at once, as the usual editor's does: a row by its label, pointer or
group title, the Input section by an action's or a context's name (or "input", "key", "gamepad").
The tree counts each category's hits, and while a search is on the form shows every category with a
hit under its own header.

**Not here yet** (they are not manifest fields): export targets, development and release builds and
installers, which belong to the launcher; default friction and bounciness, collision layers, and a
fixed timestep, which the engine does not have as settings; `settings.ui` (default theme and locale),
which `project.settings.set` edits and this page does not draw yet.

**Hit names.** `settings.category.<slug>` (`identity`, `startup-window`, `packaging`, `physics`,
`graphics`, `input`, `audio`), `settings.section.<slug>` (and `.note`, `.defaults`), one row
`settings.<pointer>` (`settings./physics/gravity`), a vector's fields `settings./physics/gravity.x`,
a dropdown's items `settings.<pointer>.<item>`, the reset arrow `settings.<pointer>.reset`, a row's
first diagnostic `settings.<pointer>.diagnostic`, a group title `settings.group.<slug>.<group>`,
Override a knob… `settings.<block>/overrides.add` and its items `.add.<group>.<knob>`, a platform's
line `settings.<block>.effective`, and the Input section's `settings.input.source`; the table's own
controls are `input.…`.

`crates/editor/tests/d19_project.rs` drives it through the winit boundary: a gravity default shown
"from project" in World Settings and run in Play; a scene's override and the reset arrow; a project
input rebinding a scene without its own plays; a platform preset and knob stored under
`render.quality.platforms` and a refused frame rate on its row; `project-default-invalid`; the search;
and the page at 100-200% display scale. `crates/agent/tests/d19_project_settings.rs` holds the parity.

## Behaviours

This was the one step of the product's exit gate — New project → Import model → **Add Behaviour** → Play → Revise → Export → Run — the editor could not do. An entity could carry a behaviour, and a person had no way to give it one; `script.module` was reachable only by editing the scene file by hand or by asking an agent.

**Everything here lands as one undo step through `Editor::commit_scene`**, the seam the Content browser's Place already uses: authoring only, computed as a whole new scene that already passes the contract, shown in the authoring world before it is committed, and carrying the selection as it was. So a behaviour a person attaches and a behaviour an agent attaches (`crates/agent`'s `entity.add_behaviour`) are the same edit arrived at two ways, and Play, the build and undo cannot tell them apart.

### Add component

At the **top** of the Details panel, above the transform, where the usual editor's own "+ Add" is. Not a matter of taste: the panel is 320 points wide at most and as little as 480 tall at 150 % display scaling, and an entity with three components already fills it — a catalogue under the property sections was drawn below the bottom of the window, where a control is not published at all and neither a person nor a gate can press it.

**Add component** (`inspector.@add-component`) opens a list of the component kinds the contract will take, each added with the contract's own defaults, and the one under the pointer described on the single line below. A kind is `inspector.@add-component.<kind>`. The `@` is the same control namespace the typed property rows use, so a property can be called anything without taking a control's name.

| Offered | Added as | One per entity |
|---|---|---|
| Behaviour (`script`) | nothing on its own — it opens the behaviour catalogue, because a `script` with no module is a scene the contract refuses | no |
| Collider | a unit box | no |
| Rigid body | `dynamic` where the entity has a non-trigger collider, `kinematic` where it has not (`dynamic-needs-collider`) | yes |
| Trigger | `triggered`, not once | yes |
| Goal | the next number in the 1..n sequence the contract requires, with a goal id no goal in the scene has | yes |
| Controller, Camera, Light, Spawn point, Nav area, Nav agent, Label, HUD element, Particles | the contract's defaults; a new camera is `primary` only when no camera in the scene is (`primary-camera`) | yes |

**`mesh`, `animator` and `audio` are not offered.** Each names an asset, and an asset reference is the import flow's to write — the same reason `script.module` and `mesh.asset` have no typed property row. They arrive through Import and through the Content browser's Place, where the file being named actually is.

**A kind that cannot go here says so before it is pressed.** A second controller on one entity is a component nothing reads, because `crates/app`'s `wire_entity` seats one per entity; a trigger with no trigger collider is `trigger-needs-trigger-collider`. Both are drawn disabled with the reason on hover, rather than left to a refusal in the toolbar after the click. A collider and a behaviour are the two an entity may have several of — an entity may carry a solid shape and a sensing volume, and the starter course runs several behaviours on the player. The contract still has the last word: `Document::component_added` reads the component back as an `argus_contract::Component` and then checks the whole scene, so anything the catalogue's precondition misses changes nothing and the toolbar names the rule.

### Add behaviour

**Add behaviour** (`inspector.@add-behaviour`), or Behaviour in the component list, opens the catalogue of modules `script.module` may name (`behaviour.catalogue`), narrowed by a search box (`behaviour.search`):

- **This project's own**, from the `.ts` and `.js` files under the open scene's folder. A module name is the file's path under that folder without the extension — `behaviors/door` — which is what `crates/app/src/modules.rs` resolves and what the build writes. It is the same enumeration `crates/agent`'s `argus://behaviours` resource lists a project by, so what an agent will accept and what this catalogue offers cannot drift apart.
- **The built-ins** that ship with the engine: `player`, `camera`, `animator`, `hud`, `audio`, `particles`, `label`. `packages/gameplay/src/behaviors/catalogue.ts` is the source of truth, and an installed editor has no checkout beside it to read, so the editor carries a copy that `tests/d9_behaviour.rs` holds to that file — the modules and the props each one reads.
- **New behaviour…** (`behaviour.new`), below.

A module is `behaviour.item.<module>`. Choosing one writes a `script` component naming it, as one undo step, and **the typed property rows are there the moment it lands**.

**What a behaviour is attached with is only what its own source states.** A built-in's props are the defaults its module spells out (`props.x ?? v`), so attaching one changes nothing and still gives the person rows to tune. `player.speed` is deliberately not written: its default is `props.speed ?? component?.speed ?? 4`, so a `speed` prop would silently override the entity's own `controller` component, and an attach that changes how the player moves is not a no-op whatever the number says. Nothing is known about a project behaviour's props, so none are asserted; the add-property row is how they are given.

**A second copy of one module on one entity is refused**, in the same words `entity.add_behaviour` uses: two ticks of the same behaviour is never what was meant. The row is drawn and disabled rather than left out, so the person can see that it is already running.

### New behaviour from a template

**New behaviour…** opens a dialog: a name (`behaviour.new.name`), a template (`behaviour.new.template.<t>`), Create (`behaviour.new.create`) and Cancel (`behaviour.new.cancel`). Nothing is written before Create.

| Template | `t` | Is |
|---|---|---|
| Empty | `empty` | `init` and `update`, and nothing else yet |
| Rotate | `rotate` | spins the entity about one axis at a steady rate |
| Collectable | `collectable` | the starter course's pickup, byte for byte |
| Trigger door | `trigger-door` | the starter course's `door.ts`, byte for byte |
| Interactable | `interactable` | the starter course's `interactor.ts`, byte for byte |
| Timer | `timer` | a HUD countdown that emits once when it runs out |

Three of the six are `fixtures/starter-course/scenes/behaviors/{collectable,door,interactor}.ts` **byte for byte**: a person starting a pickup should start from the pickup the documentation describes, not from a second almost-identical one, and `d9_behaviour.rs` is what keeps the two copies one thing — the same test `tests/template.rs` runs over the collection course's scene. The file written is the template's bytes exactly; nothing on the way to disk reformats a file somebody is about to open and read.

**It is refused for the same names New scene is refused for**, and for the same reasons (see [Scenes](#scenes)): a name is a file name and not a path, `.` and `..` are not file names, a dot-leading name is in no revision so nothing would ever build it, a name longer than `Editor::SCENE_NAME_MAX` fails at the write with a raw `ENAMETOOLONG` instead of in the dialog, the characters are the contract's own project-path rule asked about a path built **as text**, and a name that differs from an existing file only in case coexists on Linux and collides on Windows. Two more are this file's own: a name is compared against `.js` as well as `.ts`, because two files that resolve to one module name are a module the loader picks one of; and an extension the person typed is stripped rather than doubled. The reason appears in the dialog as the name is typed, and Create is disabled until there is none.

**The file is written first and then attached, and a refused attach keeps the file.** The two halves fail for different reasons — the write for the name or the disk, the attach for what the scene or the authoring view will not take — and deleting source code somebody has just asked for because the preview could not load it is destroying the wrong thing. The dialog then says what happened to each half.

Once it is written the dialog says so and offers **Open in editor** (`behaviour.open`), which opens the file with whatever this system opens a `.ts` with: Explorer on Windows (which starts the associated program with no console window of its own, exactly as Reveal does), `open` on macOS, `xdg-open` elsewhere, with `code` and `notepad` behind them. It is spawned and let go — waiting on it would freeze the editor behind somebody else's window — and the error names every program it tried, because "nothing opened and I do not know what it looked for" is the unhelpful half of this failing. A gate opens no windows, so it is tested by dispatch alone.

**The dialog is in `Shell::modal_open`**, and so is New scene. Neither used to be (wave-2 F10), on the reasoning that Escape in a name field has to leave the field rather than throw away the name typed into it. That is true, and it is not a reason to leave the authoring shortcuts live behind a dialog. `egui::Modal` already stops anything beneath the dialog being clicked; the keyboard gate is `modal_open`, and the only other gate is whether egui wants the keyboard — which goes false the moment a template chip takes the focus off the name field. So with the dialog still up and a chip pressed, Escape cleared the selection, Delete deleted the selected entity, Ctrl+S saved and F5 started Play. Escape is now answered in two stages, the way a running export already answers it: while the name field has the keyboard it leaves the field, and once the field is given up it cancels the dialog. Cancel still closes it. `d9_behaviour.rs` drives both dialogs through the chip and the shortcut table.

### The round trip to Play

Saving the scene makes a revision the workspace service builds, and a behaviour file on disk is an input of that revision whether or not the editor wrote it. A build that refuses the TypeScript names the file and the line in the activity feed and on the toolbar already (see [Activity feed](#activity-feed) and `d2_feed.rs`). What neither could say is *which* of the behaviours on the selected entity it is about — so the newest build's first error also appears on that module's own script row, `inspector.<component segment>.diagnostic`, which is `inspector.script.diagnostic` for the first script on the entity. The diagnostic's project-relative path is compared against the module resolved under the open scene's folder, case-insensitively and against both extensions the loader takes, so another module's row stays quiet and a built-in never matches — it is not a file in this project and nothing here compiles it. Fixing the file and letting the next build through clears the row.

### From the Content browser

A behaviour in the Content browser is a file a person opens and a behaviour a person attaches, so the tab offers both (see [The Content browser](#the-content-browser)):

- **Double-clicking a behaviour opens it** in the system's editor, the way double-clicking a scene opens the scene and double-clicking a model places it. Before this a behaviour tile was the one tile that answered a double-click with silence.
- **Open** (`content.open`) serves both roles a person opens: a scene, or a behaviour.
- **Attach** (`content.attach`) runs the selected behaviour on the selected entity. It needs both ends and the disabled message says which one is missing. The module name is the file's path under the open scene's own folder, so a behaviour beside a *different* scene cannot be attached from here and says so, rather than writing a module name nothing would load.

Both are in the row of verbs as well as in a tile's right-click menu, because a headless gate cannot press an item in a popup menu — the same reason every other Content action is a button.

### Names

| Name | Is |
|---|---|
| `inspector.@add-component` | opens the component catalogue |
| `inspector.@add-component.<kind>` | adds that kind with the contract's defaults |
| `inspector.@add-behaviour` | opens the behaviour catalogue |
| `behaviour.catalogue` | the catalogue's surface |
| `behaviour.search` | narrows it |
| `behaviour.item.<module>` | attaches that module |
| `behaviour.new` | opens the New behaviour dialog |
| `behaviour.new.name`, `behaviour.new.template.<t>`, `behaviour.new.create`, `behaviour.new.cancel` | the dialog |
| `behaviour.open` | opens the file that was just written, in the system's editor |
| `inspector.script.diagnostic` | the last build's error for that module |
| `content.open`, `content.attach` | the Content browser's two |

`crates/editor/tests/d9_behaviour.rs` drives all of it by hit name through the winit boundary, with the workspace service in the test process for the diagnostic.

## Importing models

**Import model…** on the toolbar (`toolbar.import`) opens the operating system's file picker filtered to `.glb` and `.gltf`; where the editor links no picker — everything but Windows — the same flow is reached by handing the path to `UiAction::ImportFile`, which is also how the gates drive it.

**The file is never rewritten.** glTF has no unit field and no up-axis field, so the import declares both in the scene's asset entry and the runtime composes them into one transform above the loaded mesh (`argus::import_transform`, and `docs/SPEC.md` amendment 15). Nothing is baked into the vertices, which is what keeps the import correct for a skinned mesh, whose vertices are in skin space and whose joints are not.

### What is measured

`crate::import::measure` reads the **POSITION vertex data** of every triangle primitive reachable from the default scene — the GLB's BIN chunk, or a `data:` buffer URI in a self-contained `.gltf` — folds each accessor's vertices into one box, and puts that box through its node's world transform before unioning it. Reachability and the node transforms are what `argus::assets::merge_gltf` does when the runtime actually draws the file, so the editor measures what the runtime draws — not something near it.

The accessor's declared `min`/`max` is read as well, but only to be **checked**. A declaration further from the vertices than 1e-4 of the box's own size is a warning that names the accessor — "POSITION accessor 0 declared bounds differ from the vertices; the vertices are used" — and the vertices win. A file that declares a 1 cm box over vertices spanning 20 m therefore imports as 20 m, with a box collider 20 m across and a line in the modal saying the declaration was wrong. An accessor whose vertices cannot be read at all — a sparse accessor, or a component type this reader does not decode — falls back to its declaration and says so in the same way.

**It is still fast.** Each accessor is read once however many nodes instance it, streamed at its own byte stride and folded into a running min/max with nothing allocated per vertex. The four-megabyte, 243,602-triangle model in `d3_import.rs::probe_a_four_megabyte_model_stays_responsive` measures in **4.2–4.4 ms** and the modal opens in 8.1–8.7 ms; the gate refuses anything over 100 ms.

`AssetRef.bounds` and `AssetRef.triangles` are therefore **the source file's own numbers, in the file's own units and axes, before the import transform**. That is what the runtime needs: `targetSizeM` and `baseCenter` are resolved against exactly those, and pre-applying the settings here would apply them twice. Everything a person sees, and everything a box collider is sized from, goes through `import::preview_bounds`, which calls the runtime's own `argus::import_transform` — one implementation, so the editor and the runtime cannot disagree.

### Vertex colours

A primitive's `COLOR_0` is drawn. It may be float, or normalised unsigned byte or short, and RGB or RGBA (an RGB colour has alpha 1); `argus_assets` hands it on as linear RGBA in `LoadedMesh::colors`, `merge_gltf` carries it into `MeshData::colors`, and the opaque pass multiplies it into the base colour the way glTF specifies — `baseColorFactor × baseColorTexture × COLOR_0` — and by the entity's tint, for static and skinned meshes and for PBR, unlit and toon materials alike. That is what baked lighting or ambient occlusion, flat-shaded low-poly art and per-vertex tinting need, with no material setting to turn it on.

A primitive without `COLOR_0` in a mesh or file where another has it is white — glTF's "no tint" — rather than the column being dropped. A file with no `COLOR_0` anywhere carries no column and draws exactly as it did before vertex colours were read: the GPU vertex gets white, and a tint times 1.0 is the tint bit for bit (`crates/render/tests/vertex_colors.rs` checks that directly, and the render goldens are byte-identical). Only `COLOR_0` is read; `COLOR_1` and above are ignored. On the GPU it is `Unorm8x4`: the vertex grew by 4 bytes (static 48 → 52, skinned 72 → 76), and white packs as 255, which decodes to exactly 1.0. Float and 16-bit `COLOR_0` are therefore quantised to 8 bits per channel; smooth baked lighting across large triangles can show 1/255 steps in the darks.

### What is refused, before anything is copied

| Refused | Message names |
|---|---|
| An `extensionsRequired` this engine does not implement | the extension, what *is* implemented (`argus_assets::gltf_load::IMPLEMENTED_EXTENSIONS`, read from the loader so the two cannot drift), and a way out for Draco, `KHR_texture_basisu` and `KHR_mesh_quantization` |
| Geometry in a sidecar (a `.gltf` with a separate `.bin`) | the sidecar, and that a self-contained `.glb` is what to export |
| An image in a separate file, **missing** | the image, where it was looked for, and the material and slot that want it |
| An image in a separate file, **present** | the same, and that a one-file copy would leave it behind |
| Nothing with triangles in the default scene | that there is no size to import it at — this renderer draws triangles, so a points-only file would import as an invisible entity |

Every one of these says "Nothing has been copied into the project", because nothing has: the measurement happens first.

### The Import modal

`import.*`. It shows the file name, the measured size in the file's own units, the triangle, material and texture counts, and any warnings (non-triangle primitives, meshes outside the scene graph, a skinned mesh whose bounds are its bind pose, extensions used but not required).

| Control | Hit name | Does |
|---|---|---|
| Unit scale | `import.unit-scale`, and `import.unit.{m,cm,mm,in}` | the file's units multiplied by this are metres. No range on the field itself: a zero, or anything outside `[1e-6, 1e6]`, is refused **in words**, with Import disabled, rather than silently clamped. That range is `asset-import-invalid`'s, read from the rule, and it is what a 32-bit transform can hold — a micrometre and a thousand kilometres per file unit |
| Up axis | `import.up-axis`, options `import.up-axis.{Y,Z,-Y,-Z,X,-X}` | which axis of the FILE points up. Forward defaults to `Z`, except `Z` up → `-Y` and `-Z` up → `Y`, the convention every Z-up exporter uses |
| Target size | `import.target-size.on`, `import.target-size` | scales the longest side to this many metres, after the unit scale |
| Base centre | `import.base-center` | puts the origin at the base centre, measured *after* the scale and the rotation, so the floor is whichever axis ended up down |
| Collider | `import.collider.{none,box,trimesh}` | **explicit, default none.** A line of text says what a box would be; nothing is ever chosen from it |
| Target size | as above | the same range as the unit scale, refused the same way |
| After import | `import.after`, `import.size-warning.*` | `X × Y × Z m, Y-up` from `preview_bounds`, live. Over 100 m across warns that the unit scale is probably wrong. Settings that make no usable transform at all — a scale the file's own measurement drives out of range — show the refusal at `import.problem` instead, with Import disabled: the line never reads `NaN × NaN × NaN` |
| Import, Cancel | `import.ok`, `import.cancel` | |

The modal is modal: while it is up, input reaches it alone (`Shell::modal_open`), Escape cancels it, and no shortcut, camera key or pick goes anywhere else.

### What Import does

1. Copies the file to `assets/<file name>` **beside the scene file, wherever that is** — `<project>/scenes/assets/` for the usual `scenes/main.scene.json`, `<project>/scenes/levels/act1/assets/` for a scene three folders down, `<project>/assets/` for a scene at the project root. The path in the asset entry is scene-relative and needs no `..`, and the artifact's staleness check tracks it. A name already taken becomes `<name>-2.glb`, `-3`, and so on, **even when the bytes are identical**: two asset entries sharing one file would mean a reimport of either silently rewriting the other's model.
2. Adds `assets[<id>]` — `kind: "glb"`, the scene-relative `path`, `triangles`, `bounds` (the file's own) and `import` (the source file's name, its SHA-256, the settings and the collider choice). The id is the file stem slugged to ASCII (`Café Chair v2.glb` → `caf-chair-v2`), free as both an asset id and an entity id.
3. Adds an entity with that same id **at the origin**, with a `mesh` component and the chosen collider, and selects it. The origin, not in front of the camera: it is where the person can find it and the same place every time, which is what lets a gate assert the result.
4. Commits all of it as **one undo step**, labelled `import <file>`. The scene must pass the contract, which refuses an absolute or `..` asset path (`asset-path-not-relative`), so the editor never writes one.

The model file is written **at import time**; the scene is not written until Save, and the modal says so. Undo takes the entity and the asset entry back out and leaves the copy where it is: nothing references it, so nothing loads it, and a redo finds it already there.

### An import copy is immutable

Once the editor has written a model file into `assets/`, it never writes to that file again. That one rule is what keeps undo out of the project's files, and it is why undo is a document operation and nothing more.

A **reimport** therefore writes a *new* file beside the scene, named `<stem>-<the first eight hex of its SHA-256>.glb`, and points the asset's `path` at it. The name is the content's, not a counter's, so reimporting the same bytes twice lands on the same file instead of piling up `-2`, `-3`, `-4`; and a hash already on the stem is replaced rather than appended to, so a file reimported ten times is still `chair-<hash>.glb`. The stem is the asset's current file name, not the source's, so an asset keeps its own name across reimports however the file it was read from is called — `import.source` is what records where the bytes came from. On the vanishingly unlikely eight-hex collision with *different* bytes, the copy takes the next free number instead of overwriting anything.

**Undo then writes nothing at all.** It restores the document, the document names the file it named before, and that file is still on disk untouched. The undo stack holds no model bytes: `Document` carries whole-scene snapshots and nothing else, which is also what keeps eight reimports of a 24 MB model from costing 384 MB of undo history (`d3_review_probes.rs::memory::probe_eight_reimports_do_not_hold_the_model_in_the_undo_stack` measures 0 MB).

### Unreferenced model files

The other side of that rule is that reimports and undone imports leave files behind. Nothing deletes them on its own.

`Editor::orphaned_models` lists the `.glb` and `.gltf` files directly inside the scene's `assets/` folder that **neither the scene being edited nor the scene last saved** names. Both are asked deliberately: a file the working document has stopped naming may still be the one the saved scene loads, and a person who then discards their changes would find it gone. The inspector's asset section shows "N unreferenced model files in scenes/assets" (`inspector.orphans`) with **Delete unreferenced** (`inspector.delete-orphans`) beside it, which deletes exactly that list — recomputed at the moment it is pressed, so a file that became referenced in between is never deleted. It is not an undo step: these are files no scene the editor knows about names, so there is nothing in the document to take back. Disabled during Play, like every other authoring action. It is also **refused outright when the project could not be read in full** — a sibling scene that does not parse (half-written, a merge-conflict marker, a trailing comma) or a scenes folder that could not be listed — and the refusal names the file and says to fix or remove it first: a scene the editor cannot read still owns its models, and "that scene is broken" must never become "so the editor deleted its models".

Importing is an authoring action. During Play it is refused ("stop Play first") and the toolbar button is disabled: Play runs a build prepared from saved files, and copying a model in underneath it would change what a restart loads.

### The inspector's asset section

For a selected entity with a `mesh`, under the transform fields: the asset id, its path, the source file and hash it came from, the import settings as one line, the post-transform size and the triangle count.

- **Reimport…** (`inspector.reimport`) reads a model file into the asset again. On Windows it picks a file; where there is no picker it reads the asset's own file again, which is what a re-export over it needs. The asset id, the entity's transform, every other reference to the asset and every per-entity `material` override survive, because none of them is touched: only `path`, `bounds`, `triangles` and `import` change, and the new `path` is the new copy's (see [An import copy is immutable](#an-import-copy-is-immutable)). `import.sourceSha256` is the field that makes a running world drop its cached mesh, since the hot-reload diff compares `AssetRef` values and not file contents. A file that does not measure leaves the asset **and every file** exactly as they were, and names the reason: the measurement happens before anything is written. One undo step, and undo restores the document alone.
- **Collider…** (`inspector.collider.{none,box,trimesh}`) changes the entity's collider, one undo step, and records the choice in `import.collider` so a later reimport can offer the same one. A box is sized and centred from the **post-transform** bounds. A reimport does not resize a collider: geometry a person will stand on is not changed without being asked.
- **Reveal** (`inspector.reveal`) copies the model file's absolute path.

### Limits

- **No FBX, no OBJ, no retargeting, no PBR authoring.** glTF 2.0 only, as `.glb` or a self-contained `.gltf`.
- **One file per import.** A `.gltf` with a sidecar `.bin`, or with images beside it, is refused rather than imported half-copied; export a `.glb` with everything embedded.
- **A trimesh collider is previewed only in Play's F1 overlay.** The authoring view draws the mesh, not the collision shape.
- **A reimport makes the artifact stale**, because the asset entry changed: Play refuses until the next Save prepares a new build. That is the same rule every behaviour and asset file is under.
- **Reimporting the asset's own file cannot undo bytes the editor never saw.** Undo points the asset back at the file it named before; if an authoring tool re-exported over that file, its contents are the tool's and not the ones the restored measurements describe.
- **A reimport leaves the previous copy on disk.** That is the point — see [Unreferenced model files](#unreferenced-model-files) — but a project reimported many times accumulates them until someone presses **Delete unreferenced**.
- **A reimport costs no memory per round, and both things that used to make it cost are gone.** The undo stack held the model's bytes before and after every edit; it holds whole-scene snapshots and nothing else now. `Renderer::register_mesh` had no counterpart, so `World::reconcile` dropping an asset's `mesh_cache` entry freed the parsed glTF and the CPU geometry while the vertex and index buffers stayed; `Renderer::free_mesh` is that counterpart and `World::free_evicted_meshes` calls it on exactly the meshes the reload evicted, skipping any a draw binding still names. Measured on this machine with the software Vulkan adapter, warmed up with eight reimports and measured over the next eight: a **3.0 MB** model moves the resident set by **0 MB** and a **23.9 MB** model by **1 MB**. With the meshes left unfreed the same runs cost **6 MB** and **48 MB** *per reimport* — 2.1 and 2.0 times the file, every round, for as long as the editor stayed open. What is left is inside the noise of a process that parses a 24 MB glTF between the two readings, which is why the probe's bound is a whole file per round rather than zero. Two probes hold it there: `memory::probe_eight_reimports_do_not_hold_the_model_in_the_undo_stack` isolates the undo stack by reimporting the same bytes, and `memory::probe_repeated_reimports_grow_the_undo_stack_without_a_byte_budget` alternates the geometry so every round really does register a mesh.
- **A freed `MeshId` is permanently dead.** Ids are positions in the renderer's list, so a freed slot is a tombstone and is never handed out again rather than coming back attached to different geometry. The tombstone costs a word; a session that reimports thousands of times accumulates thousands of words, which is not a budget anyone will notice. The four builtin meshes are never freed, because every scene shares them.
- **`import.collider` is a record, not the collider that runs.** The entity's `collider` component is.


### Scans

**Import model…** also takes an `.obj` (with its `.mtl` and textures beside it) or a `.ply`: a photogrammetry or scanner mesh of millions of triangles that cannot be drawn as delivered. The file is **cooked** first, on a thread, behind a small modal with Cancel (`import.scan.cooking`, `import.scan.cancel`); the cook writes nothing. `crates/scan` (`argus-scan`, also a CLI: `argus-scan cook in.obj out.glb`) reads it, welds it, decimates it to a triangle budget (500,000 by default) with meshoptimizer, makes two coarser levels, brings its textures to 4096 px, fits up to 32 convex hulls, and writes **one GLB**: the default scene draws full detail, the coarser levels are `MSFT_lod` nodes outside it, the hulls a mesh named by the root node's `extras.argusCollisionHulls`.

The Import modal then opens on that GLB, exactly as for any model, with the cook's report above the settings (`import.scan.N`), **Convex hulls** among the collider choices (`import.collider.hulls`, selected when hulls were made) and **Keep the scan's source** (`import.scan.keep-source`, on). Import is the ordinary one: the GLB copied into `assets/`, the asset and entity added as one undo step. The asset's `import` record names the file picked (`source`, `sourceSha256`) and carries the cook's settings in `import.scan`; the source and its MTL and textures are kept under `assets/scan-src/<stem>-<sha8>/` (never exported, since nothing at run time names them). A scan too large for the scene's asset budget raises `meta.budgets.textureMB` to fit, and says so.

**Levels of detail.** The runtime draws one level per entity per frame, chosen by the share of the screen's height the asset's bounding sphere covers (the file's `MSFT_screencoverage`, 0.25 and 0.075 by default) with a 10% band so an object on a switch does not flicker. The same instance rows reach the standard and HD renderers, so both switch. `argus run --lod-freeze N` pins every level for captures; `World::set_lod_bias` moves every switch (a headset renders at 0.5).

**The inspector's Scan section** (`inspector.scan.*`), for a scan asset: the triangle, hull and texture numbers and **Reprocess** (cooks the kept source again on a thread and lands as a reimport: a new content-named GLB, identity kept, one undo step); **Ruler** (click two points on the scan, type the real distance; sets `unitScale` and records `import.scan.ruler`); **Align part** (click a point on the part, then the same point on the scan, three pairs or more; moves the part, rigid or with a scale, and reports the RMS residual); **Cut region** (click the scan where a replacement part sits; its triangles inside a box there are removed at the next cook, split cleanly along the box); **Box collider** (click the corners of a bench; a static box collider fitted to the scan) and **Floor and walls** (for a room scan: static boxes fitted to the floor and up to four walls). While a tool is picking, viewport clicks land on surfaces instead of selecting. A Reimport… of a scan asset cooks the picked file (or the kept source) with the recorded settings.

**Triangle budget.** An import never changes `meta.budgets.triangles`: the scene's full-detail total against it is in the import's answer (`triangleBudget`: `sceneTriangles`, `budget`, `over`, and a `suggestion` naming the budget that would hold it), for the author or the agent to act on.

Agents reach all of it: `asset.import` with a `scan` object, `asset.reprocess`, `asset.ruler`, `entity.align` and `entity.fit_proxies`, and the control actions `import`, `reprocess`, `scan-ruler`, `align`, `proxies` and `surface`.

**Collision.** `collider { shape: "hulls" }` is the cooked hulls as one compound body, so a concave scan can be grabbed or placed as a dynamic body and its notches stay open.

**CUI.** The cook and the import never use the network. Every cooked GLB records its source's SHA-256 and the settings (`asset.extras.argusScan`), and every kept source folder a `provenance.json`. `argus-scan derived <source-sha256> --project <dir>` (also the control action `derived` and the agent tool `asset.derived`) lists every file in the project derived from a source (cooked GLBs, the kept source, byte-identical copies under `.argus/`, the project thumbnail) so they can be purged; a file another scan's provenance claims is never listed, even when two scans share a texture byte for byte. Keeping the source is a choice on purpose: data minimisation says keep no copy unless a re-cook will be needed. `scan-src/` never enters a revision, a build or an export: the workspace service does not read it and the exporter refuses it by name. Untick **Keep the scan's source** to keep no copy at all.

## The Content browser

The **Content** tab of the bottom drawer (`content.tab`) is every file in the project, not the open scene's asset map: a folder tree on the left, tiles on the right, and the handful of things a person does to a file. `crate::content_browser`.

**What is in the project, and what each file is, are the workspace service's answers.** The walk is `argus_workspace_service::inputs::walk`, the exclusions are its `is_excluded` (`.argus` and every other dot folder, `node_modules`, top-level `target` and `dist`), and the role is its `role_for`. The browser refines that one step further, because a browser has to colour a mesh differently from a texture and a revision does not: the service's single `Asset` role becomes **Models**, **Textures**, **Audio** and **Other**, beside its **Scenes**, **Scripts** and everything else as Other; the table under **Narrowing** has every extension. One more refinement, and the same kind: anything `inputs::ui_role` classifies is **UI** rather than the Behaviour a `.tsx` would otherwise be — see [The UI category and the viewport preview](#the-ui-category-and-the-viewport-preview). Nothing about exclusion or role is decided twice, so a file the browser shows and a file a revision captures can never disagree.

**The tree always shows the folder that is selected.** Clicking a folder row selects **and expands** it, as the usual editor's does — somebody clicking a folder is asking to see what is in it — and clicking the folder that is already selected is the press that collapses it again. Whatever else sets the selected folder, a breadcrumb or a script, opens every ancestor of it, so the row a script asks for by name exists to be pressed.

**Layout.** A left column of indented folder rows with a painted disclosure triangle where a folder has folders under it, and a tile grid on the right. Above them a compact row of actions, and under it the breadcrumb, the **Filters** button, the search box and a chip for each filter that is on. The actions are on top, which is the usual editor's order and which decides what survives a short drawer: at a small window and a high display scale the drawer can be shorter than its contents, and what a person has to keep is the row of verbs rather than the row that refines a list. The tree's root row is named after the project's own folder, because the breadcrumb beside it already says "Content".

**Tiles.** A painted role mark, the name over up to two lines, the size, and along the bottom a role-coloured stripe in the usual editor's convention — levels yellow, static meshes cyan, scripts blue, textures red. A model whose file the open scene already has an asset entry for also carries its triangle count and the size the runtime gives it, which the inspector's asset section computes from the same numbers. The open scene is marked `◎` and the startup scene `★` in the tile's corner, both at once when they are the same file. A tile is `content.item.<project-relative path>`, a folder row is `content.tree.<path>`, and the project root's own row is `content.tree`. **A UI screen's tile carries a rendered thumbnail** — a real frame of the screen, drawn by the real runtime. Every other role still carries its painted mark; a model thumbnail is still deferred, since `crates/capture` rendering a one-entity world from four angles is a change of its own.

**Narrowing.** The type filter is the usual editor's filter bar: a **Filters ▾** button (`content.filters`) left of the search opens a menu with a check box per type (`content.filters.<key>`) and **Reset Filters** (`content.filters.reset`). Several can be on at once; the menu stays open while they are ticked, as the usual editor's does, and closes on a click outside it or Escape. Each type that is on gets a chip after the search box (`content.filter.<key>`, "Models ×"), outlined in the type's colour; clicking the chip takes that type off. None on is every type, and the button says how many are on ("Filters (2)"). The search (`content.search`, case-insensitive over the names in the folder shown) narrows whatever the filters leave, and a breadcrumb goes back up the path (`content.crumb.<n>`, 0 being the project root). The old Prefabs chip is now the Prefabs row of this menu, one system with the rest.

| Filter (key) | Files | Decided by |
|---|---|---|
| Models (`model`) | `.glb`, `.gltf`, `.obj`, `.fbx` | the service's `Asset` role, by extension |
| Textures (`image`) | `.png`, `.jpg`, `.jpeg`, `.webp`, `.ktx2`, `.hdr`, `.exr` | the service's `Asset` role, by extension |
| Materials (`material`) | `*.material.json` | `argus_contract::is_material_file_path` |
| Audio (`audio`) | `.wav`, `.ogg`, `.mp3`, `.flac` | the service's `Asset` role, by extension |
| Prefabs (`prefab`) | `*.prefab.json` | `prefab_ui::is_prefab` (the contract's `source_problem`) |
| Scripts (`behaviour`) | `.ts`, `.tsx`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs` outside `ui/` | the service's `Behavior` role |
| Screens (UI) (`ui`) | screens, components, styles, themes, scenarios and locales under `ui/` | `inputs::ui_role`, checked first |
| Scenes (`scene`) | `*.scene.json` | the service's `Scene` role |
| Other (`other`) | everything else: `argus.project.json`, data `.json`, a glTF's `.bin`, fonts, text | — |

The checks run in the table's "decided by" order where two could claim a file: UI first, then prefab, then material, then the service's role. **The choice is the person's**, kept in `layout.json` as `contentFilter` (the keys, in the menu's order; see the layout below), so it survives a restart and follows the person to every project. Agents read it as `editor.state`'s `view.layout.contentFilter` and set it with `editor.set {layout: {contentFilter: [...]}}` (`/v1/do set-view`), in the keys or the menu's words, singular or plural, any case (`["Scripts", "materials"]`); `[]` is every type, and a word that is no type refuses the whole call. `project.files` keeps its own `role` and `prefix` filters, the service's: the browser's types are the editor's reading of those, and a second classifier in the agent crate was not worth the duplicate.

**Actions.** Double-clicking a scene opens it, and double-clicking a model places it. Both are also buttons, and so are the rest, because a headless gate cannot press an item in a popup menu: `content.place`, `content.open`, `content.ui.preview`, `content.ui.thumbnails`, `content.reveal`, `content.import`, `content.delete-unreferenced` and `content.refresh` are always drawn and act on the selected tile — the names are the old ones and the labels are short, so that the row fits the narrowest window the editor promises without wrapping onto a line a short drawer would cut off. Right-clicking a tile gives the same items. A model tile is also a drag source carrying a `content_browser::DragModel`; the click path never depends on the drop, and `d6_shell.rs` drives a whole drag through the winit boundary.

| Action | What it does |
|---|---|
| Open | `UiAction::Scene(SceneAction::Open(…))` — the same path the File menu takes, unsaved-work prompt and all (see [Scenes](#scenes)) |
| Place | Puts the model in the open scene at the origin, as one undo step. Refused while Play runs, while an unsaved-work prompt is waiting, and when the file has gone since the walk |
| Reveal | Shows the file in the platform's file manager (`export::reveal`) |
| Import… | Opens the Import modal. The copy still lands in the open scene's `assets/`, because an asset path is relative to its scene and may not climb out of it (`asset-path-not-relative`) — so the folder the person asked from cannot be where it goes. Refused, at the button and at the action, while Play runs or an unsaved-work prompt is waiting: it opens the operating system's file picker, and a second picker over a prompt is worse than a button that says why it cannot |
| Generate | Opens the Generate panel (`content.generate`). Disabled, with the reason and what to turn on as its hover text, while the project's `assets.generation.enabled` is not true (`asset_generation::refusal`) |
| Generate panel | An in-app window (`generate.window`, closable with `generate.close`) over the asset library CLI, one step at a time: **prompt** (`generate.prompt`), **class** (prop, scenery, vehicle, structure; no character; `generate.class`), **Hero asset** (vehicles always; `generate.hero`) and the project style read-only -> **reuse first** (`generate.search`; matches with thumbnail, score, provenance, licence, visibility, `generate.match.<i>.use` imports free, `generate.new`) -> with concept images on, **drafts** (`generate.draft.<i>`), **Change only…** (`generate.change`; no cap on rounds: "Round N" and the concept's running cost sit beside it, `generate.round`), **Approve** (`generate.approve`, records the OS user name; required for hero) -> **3D** and the gate verdict per profile (`generate.verdict.<profile>`; each reject with the gate's own `Next:` fix text under it, `generate.verdict.<profile>.reject.<i>[.next]`; the answer's warnings, `generate.warnings`) -> **Import** (`generate.import`, offered only on a clean pass), then the Content Browser refreshes. The window opens at about 60% by 70% of the editor window, resizable and never larger than it. The verdict step shows a **3D preview** (`generate.preview.image`, `.prev`, `.next`): the generated GLB drawn on the CPU from four turns (`glb_preview`, no window or GPU; the editor has no model-thumbnail path yet), else the gate's own thumbnail or views picture (`generate.preview.source` says which). Each step shows its own cost (`generate.cost.step`, `generate.cost.concept`) and the asset total, `Asset total: US$1.30 (concept 0.20 + 3D 1.10)` (`generate.cost.total`; the CLI's `costUsd` is the two together). Drafts: the highlighted draft is the approved one or none until the person picks (Approve and Apply change wait for a pick). After a gate refusal there is no Import: **Re-check (free)** (`generate.recheck`; `regate {id}`, shows the new verdict; "Re-check is not available in this build" on an unknown-command refusal, `generate.recheck.unavailable`), **Use anyway (warnings)** (`generate.anyway`, only for warnings without rejects, or a pass with warnings; one-line confirmation `generate.anyway.confirm` naming the warnings; `import {id, project, acceptWarnings: true, by: <person>}`), **Change only... on the concept** (`generate.change-concept`: back to the drafts with the approved draft selected, a concept image edit priced by `estimate`) and **Retry with feedback** (`generate.retry`, priced by `estimate`), each with its cost beside it. Every call runs off the UI thread with the step name, elapsed seconds, a spinner and **Cancel** (kills the child; `generate.progress`, `generate.cancel`). Where the project does not ask first, `estimate` is called before a paid step and "This step costs about US$X (summary)" shows while it runs (`generate.estimate`), with no dialog. Before a paid step the CLI's `library-needs-approval` (its `estimateUsd`, `key`, `summary` fields) shows "about US$X" with **Confirm** (`approve`, then the same call once; `generate.confirm`) or Cancel (nothing sent); `library-spend-cap` shows the cap, what is spent and this step's estimate (its fields) and no retry until the spend settings change. A failed step shows the CLI's message and **Retry with feedback** (`generate.retry`). **Pending approvals** (agents' paid steps) list with Approve / Decline (the request whose cost dialog is on screen is not listed a second time) (`generate.approval.<key>.approve\|.decline`). With generation off the panel never calls the CLI |
| Library tab | Window > Library (`library.tab`) opens the **Library** tab of the bottom drawer: what the asset library already holds, browsable in every project with generation on or off (search, import and rate are free and never generate). A search field (`library.search`, `library.search.go`), a class filter (all, prop, scenery, vehicle, structure; `library.class`) and **Project style only** (`library.style-only`, on by default: the manifest's `assets.style` goes to `search`'s `style` filter; off shows everything, with "not the project's style" on each off-style row). Rows (`library.item.<i>`) show thumbnail, name, class, mean score and count, triangles, provider and model, cost, licence, visibility and style. Selecting one opens its details (`library.details`): every human score with who, when and note, prompt, provider, model, task, date, cost, concept trail (chosen draft, approved by, edits), gate verdict per profile (`library.verdict.<profile>`), licence, visibility and content hash, read from the cache's `records/<id>.json` (the CLI has no verb for one full record). **Import into project** (`library.import`, free, offered on a clean pass; an asset the gate rejected shows its rejects with their `Next:` text and no Import, `library.reject.<i>[.next]`; one with warnings only offers **Import anyway (accept warnings)**, `library.import-anyway`, behind a one-line confirmation naming them, `library.import-anyway.confirm`, which sends `import {id, project, acceptWarnings: true, by}`; **Re-check (free)**, `library.regate`, runs `regate {id}` and reads the record again; a shared asset is fetched first; the Content Browser refreshes), **Rate** (`library.rate`: 0 to 10 `library.rate.score`, optional note `library.rate.note`, **Send** `library.rate.send` as the OS user name, the same name as the Generate panel's approval) and **Generate something new** (`library.generate`: opens the Generate panel with the search text as its prompt; disabled with the reason while generation is off). Every call runs off the UI thread; an empty library says so in one line (`library.empty`); a missing CLI shows its error (`library.error`) |
| Delete unused | `Editor::delete_orphaned_models`, the same rule as the inspector's: model files under the open scene's `assets/` that no scene in the project names. It walks the project itself before it decides, because a destructive action never reads a cache |
| Refresh | Walks the project again |

**Place, in three cases and a half.** A model already in the project is not imported again. If the open scene has an asset entry for that file, a new entity simply names it and no second entry is written. If it has one imported from *these exact bytes* under another name, that entry is named too — an asset entry's `path` points at the copy and never at the source, so without this a second Place of the same library model wrote a byte-identical `chair-2.glb` and a second asset entry for one model (`AssetImport.source_sha256` is the comparison, which is what the importer records and what a reimport already compares). If the file is under the scene's own folder, a new asset entry names it by its scene-relative path, with the bounds and triangle count measured from the file and no `import` block — nothing here declared its units, and the inspector says so rather than inventing them. Only a model elsewhere in the project is imported, which copies it in, because an asset path may not climb out of its scene's folder. All three are one undo step through `Editor::commit_scene`, and the entity is the import path's: at the origin, with a `mesh` component and the file's stem for a name.

**The manifest is read again when its answer is about to be used.** `Editor::adopt_manifest_if_changed` compares the manifest's length and modified time against what the editor last read and re-resolves the project when they differ; the Export panel calls it as it opens, and so does every frame the File menu is open. Before this the editor adopted the manifest only from its own Set as startup scene, so a manifest changed by the workspace service, by another window or by a `git pull` was never taken while the project stayed open — and the Export panel, which is the last thing a person reads before they ship, then named a scene the export would not pack. A manifest that has changed into one the project resolver refuses leaves the editor holding the last good one and says so on standard error: a bad edit elsewhere must not close the project out from under someone with unsaved work.

**One walk, and only when something asks for it.** The browser and File ▸ Open scene read the *same* walk: `Editor::project_tree` and `Editor::scenes` both come from one `content_browser::scan`, so a project with both open is walked once, not twice. Nothing polls. The walk happens again when the workspace service reports a file change, when the person presses Refresh, when the editor itself writes a file (an import, a new scene, Delete unreferenced), and when the File menu is opened or the Content tab comes into view with the walk older than two seconds. Before this, `Editor::pump_service` re-walked the whole project every two seconds on the frame thread for as long as the editor was open — a `stat` per file and a JSON parse of every scene but the open one, about 10 ms at 1,500 files and roughly 100 ms at 15,000, whether or not anything was looking.

**A frame's work is what it draws.** The walk builds two indices once: a folder → files map, so a folder's tiles are not a pass over the project, and a folder → subfolders map, so the tree is not quadratic in the number of folders. Each file's name is lowercased at scan time, so a search compares rather than allocating a string per file per frame, and `tiles` makes one pass, stopping one item past `TILES_CAP`. `content_browser::State::scans` counts the walks the browser has adopted and `Editor::project_walks` the walks themselves; `tests/d6_content.rs` asserts neither moves between frames that nothing changed, over a folder of 5,000 files.

### Folders, and moving files

`crate::content_folders` for the controls, `crate::relocate` for the move.

| Verb | How | Hit names |
|---|---|---|
| New Folder | right-click a folder in the tree ▸ New Folder (inside it), the project's own row (at the root), or the space under the tiles (in the folder shown). Made on disk at once as `NewFolder` (`NewFolder1`, … beside one), its name open for typing: Enter or clicking away renames it, Escape keeps the name | `menu.content.new-folder`, `content.folder-rename`, `content.empty` |
| Rename | right-click a folder ▸ Rename. A folder with files in it is a move of every file in it (below), and its subfolders, empty ones too, go with it | `menu.content.rename` |
| Delete | right-click a folder ▸ Delete. Only an empty folder is deleted; one with anything in it stays, and the status line says what is inside ("scenes/models is not empty: it holds crate.glb, … and 3 more") | `menu.content.delete` |
| Move | drag tiles onto a folder in the tree. Ctrl+click picks several (drawn selected), and dragging one of them moves them all; any tile can be dragged, and a model's, a prefab's and a screen's still onto the viewport too | `content.tree.<folder>` |

The tree lists empty folders too (a walk only sees files, so `scan` adds the directories that hold
none). A folder is a directory, not revision content: it is made and removed on disk, and the
workspace service sees the files that go in it.

**A move rewrites every reference to the files moved, or refuses and moves nothing.** What names a
file is what the contract and the loaders resolve, each against its own base: a scene's
`assets.*.path` (relative to the scene's folder, never climbing out, `asset-path-not-relative`), its
`script.module`s (the same folder's `<module>.ts|.js`) and each instance's `prefab.source`
(project-relative); a prefab file's asset paths and modules (read from the open scene's folder, where
its assets merge); a material's `*Texture` paths (relative to it, never climbing); a `.gltf`'s buffer
and image `uri`s; quoted `./` and `../` specifiers and `url(…)` in behaviours, UI files and
stylesheets (with or without the extension, `.js` naming a `.ts`); and anywhere in a JSON or text
file (the manifest's `startupScene`, a data file), a string that is exactly a moved file's project
path. A moved file's own references are rebased the same way. A reference that cannot be written from
where the files end up (a model dragged out of the folder of a scene that uses it) refuses the whole
move with the file and the field in the status line. The open scene itself cannot be moved.

**The open scene is rebased, not edited.** Its references are rewritten in the document, its saved
baseline and every step of its history alike (`Document::rebase`): a move adds no undo step, is not
an unsaved change, and no undo can bring back a path whose file is gone. Its file on disk is
rewritten with the other referrers, and when the document was saved, that file is its saved baseline
again, so the move raises no "changed on disk". As in the usual editor, a move is not on the undo stack.

**One change set.** With a workspace service (a human's editor, `EditorOpts::human`), the moves
(`put-file` at the new path, `delete-file` at the old) and every rewritten file land as one change
set under the editor's owner (`Editor::land_files`), which a revert of that change (the service's
`POST /v1/changes/<id>/undo`, an agent's `revert`) takes back whole. Without one, the new files and rewritten
referrers are written first and the old files removed last, so no reference is ever dangling. An
agent moves files the same way with `project.files` and `move`, through a running
editor's `do: move-files` when there is one.

## The UI category and the viewport preview

A project's game UI lives under `ui/` at the project root — `ui/screens/**.tsx`, `ui/components/**.tsx`, `ui/styles/**.css`, `ui/theme/**.css`, `ui/scenarios/**.json` — and the editor gives it a Content browser category, a preview in the viewport, and hot reload. `crate::ui_screen`, `crate::ui_preview`, `crate::ui_reload`.

**What it previews is the real game UI runtime.** The screen is described by esbuild, registered through `argus::UiState::screen_from_json` — which is `ctx.ui.screen`'s own path — laid out by Taffy against the project's own cascade, and drawn by `crates/render`'s UI pass into the same offscreen target the authoring view uses. The editor's own interface stays egui; nothing here reimplements a node, a style or a focus rule. §5 says that distinction is the whole point and must not erode, so the two overlays are the only things this preview computes for itself, and both are read straight off the laid-out tree the frame drew.

### The UI category

Anything `argus_workspace_service::inputs::ui_role` classifies is one category, the Filters menu's **Screens (UI)** (`content.filters.ui`, its chip `content.filter.ui`). One rather than five, because a person looking for "the UI" is looking for all of it — and a screen must not be filed with the game's behaviours, which is where the service's own `role_for` would put a `.tsx`. A file under `ui/` that is not one of the five roles, a `ui/README.md` say, is not a UI input: it is Other, and it fires no reload.

**A screen's tile is a picture of the screen, in the theme the preview shows.** It is described at its default scenario, the project's stylesheets are applied **in the engine's own order** and the theme `settings.ui.defaultTheme` names is applied by name — the same two calls `UiPreview::load_styles` makes, so a tile is never a picture of a theme the preview will not show. The world renders one frame at 1080p — §2.5's reference height, so the tile shows the layout the budgets are measured at rather than one that exists only at tile size — and the frame is read back and box-filtered to a fixed 256 px side into `.argus/editor/ui-thumbnails/`. The authoring world is left exactly as it was found: the screen is popped, the target put back **and the theme the world was on restored**, so pressing Thumbnails while a preview is up does not change the screen under the operator's eyes.

**Nothing is rendered while a frame draws.** Rendering a screen is esbuild, an evaluation and a GPU frame, which a `draw` call may not do. A tile with no thumbnail on disk asks for one through `content_browser::Action::RenderThumbnail`, **at most one screen per frame and at most once per screen**, so a folder of screens fills in over a few frames rather than costing an esbuild each in one. A failure is remembered with its reason, so a screen that will not compile is not rebuilt on every frame the drawer is open. **Thumbnails** (`content.ui.thumbnails`) forgets them all, which is what a person presses after editing a stylesheet that changed how they all look.

| Action | What it does |
|---|---|
| Preview | `content.ui.preview`, double-click, or the tile's context menu: shows the screen in the viewport |
| Set as the scene's start screen | The context menu, and dropping a screen tile on the viewport: writes `meta.ui.startScreen` — see below |
| Render thumbnail | The context menu, for one screen |
| Thumbnails | `content.ui.thumbnails`: every screen again |

**Dropping a screen on the viewport sets the scene's start screen.** §5 asks for the gesture and the scene contract now has the field: `meta.ui.startScreen`, which `argus::World::load` reads and pushes once the project's screens are registered. **The name written is the screen's own** — `defineScreen({ name })` — and not the file's stem, because only the first is what the screen is registered under; writing the stem would produce a scene that validates, loads and then reports `ui.screen.unknown` for a screen sitting right there. So the file is described first, which is the same describe the preview and the tile already pay for and usually already cached. A second drop of the screen that is already the start screen says so rather than writing an undo step that changes nothing.

**The browser says which screen it is.** `content.ui.start-screen` names the open scene's start screen beside Preview and Thumbnails — "Start screen: main-menu", or "none" — and the screen's own tile carries `▶`. The line is right the moment a project opens; the mark needs the file's declared name, which is knowable only by describing it, so it arrives with the tile's thumbnail and a file that has not been described carries no mark rather than a guessed one.

### Describing a screen

esbuild, with `packages/ui`'s own JSX flags — the list `argus_host::esbuild_args()` carries and `crates/host/tests/tsx.rs` holds it to — plus `--bundle`, because a project's screen imports `@argus/ui` and its own components and the engine's per-file transpile has nothing to resolve those against. The bundle is then evaluated, and `describeScenario` writes the frozen JSON of `docs/UI.md` §7.

**The bundle carries no scenario.** The request — which scenario, and any props from `ui/scenarios/` — arrives as one JSON argument at evaluation time, so one bundle answers every scenario of one screen. That is what makes a `ui/scenarios/**` change cost an evaluation rather than a compile, which is the difference the three reload tiers are named for. A bundle is cached against the screen file's length and modification time and dropped whenever a screen or a component changes.

**`ui/scenarios/<stem>.json`** is an object of scenario name to props, matched to a screen by file stem — `ui/screens/main-menu.tsx` takes `ui/scenarios/main-menu.json`. A screen's own `defineScreen({ scenarios })` is the default; a file here adds to it and overrides by name, which lets a project keep a screen's shipped scenarios and still preview it against the one awkward case without editing the screen.

**Two tools, found the way the engine finds its own.** esbuild through `argus_host::find_esbuild` (`ARGUS_ESBUILD`), and `@argus/ui` through `ARGUS_UI_PACKAGE`, then the project's `tsconfig.json` `compilerOptions.paths`, then `node_modules/@argus/ui`, `packages/ui` or `sdk/packages/ui` walking up from three roots in turn: the project, the editor's own executable, and the directory the editor was started in. The third root is what makes a checkout answer for a project made anywhere — `argus run ~/projects/Course` typed in the repository, and every test that creates a project under the target directory — where before the project had no `@argus/ui`, nothing described, and every screen became `ui.screen.unknown` at run time. **The packed SDK carries `packages/ui`** (`scripts/sdk/pack.mjs` stages the checker, Node, esbuild and the package at `sdk/packages/ui`, every file named in the SDK manifest and covered by its id), so an installed editor finds `@argus/ui` under its own `sdk/` without `ARGUS_UI_PACKAGE`; a pack made before the package was added is refused as incomplete rather than used. **Node is not one of the tools any more**: the bundle is evaluated in process through `argus_host::Host::eval_json`, so opening a screen in an installed editor needs no Node installation. `crates/host/tests/describe_screen.rs` still compares the in-process description against Node's where a Node is present, which is the test that let the subprocess go away — and it spells every path for esbuild the way the describer does, so it runs on a Windows runner rather than tripping over `\\?\C:\…`. A missing tool is a reason shown on the tile and in the preview, not a silent blank.

**The package's `exports` map becomes the aliases**, one per entry. esbuild's `--alias` is a path substitution rather than a package resolution, so aliasing the package *directory* rewrites `@argus/ui/components` to `<dir>/components`, which is not where the file is — and a screen that imports the component library then fails to bundle. `ui_screen::ui_package_aliases` reads what the package declares (`"."` becomes `@argus/ui`, `"./components"` becomes `@argus/ui/components`) and emits an alias apiece, so the editor honours the package rather than guessing at its layout.

### The preview in the viewport

`ui-preview.*`. A bar of pickers above the image, the overlays over it.

| Control | What it does |
|---|---|
| `ui-preview.close` | Back to the authoring view; the screen comes off the stack |
| `ui-preview.screen` | What is being previewed, by the name `defineScreen` gave it |
| `ui-preview.scenario.<name>` | Describes the screen against that scenario's props |
| `ui-preview.theme.<name>` | Re-resolves the cascade against that theme's tokens. No reparse: `Parsed` holds every theme's table. The chip marks the theme the preview is *showing*, which is never inherited — see below |
| `ui-preview.scale.<n>` | The player's UI-scale setting (§2.12), 0.75 to 2 |
| `ui-preview.locale.none` / `.<locale>` | See below |
| `ui-preview.resolution.1080p` / `.1440p` / `.2160p` / `.custom` | The size the offscreen target is made at |
| `ui-preview.overlay.bounds` | Every visible node's computed border box |
| `ui-preview.overlay.focus` | The focusable nodes numbered in Tab order |
| `ui-preview.node.<id>` | Every control of the previewed screen, at the rectangle it is drawn at |

**The resolution picker owns the target while a preview is up.** The offscreen target follows the viewport panel (`crate::preview::follow_panel`); a preview at 2160p in a 900-point panel must not, or "2160p" would mean "whatever this window happens to be". So `follow_panel` stands down while previewing and the panel scales the image to fit.

**A theme, scale, resolution or overlay change is a re-resolve.** `docs/UI.md` §9 says switching theme "is a re-resolve, not a reparse, so it is instant and touches no disk", and §2.8 says a frame that only restyles does not rebuild. `UiPreview::sync` compares what the pickers say against what it last applied: when nothing but those moved, it calls `argus::UiState::set_theme` — which resolves against the token tables the parser already built — and the project's stylesheets are not read, no bundle is built and the tree the runtime holds is the one it already had. Moving the screen or its scenario is what costs a describe. The locale is on the cheap side too, now that it goes through `argus::UiState::set_locale` rather than being resolved into the description.

**The preview loads the theme layer in the engine's order, and always applies a theme explicitly.** The default theme is the first `@theme` block the parser meets, so which file is read first decides it. The engine reads `ui/theme/tokens.css`, then the file `settings.ui.defaultTheme` names in `argus.project.json`, then the rest of `ui/theme/**` sorted — `argus::theme_layer_order` — and `ui_screen::theme_layer_order` is a call to that and nothing else. `UiPreview::load_styles` then calls `set_theme` by name rather than inheriting whatever the parser decided, so the chip always has an answer. Only the **theme** layer is reordered, in the engine as in the preview: `@tokens` and `@theme` blocks are token tables, where order decides which table a name resolves against, and ordinary rules are a cascade, where moving one would change what a screen looks like.

**There is one order, so there is nothing to warn about.** The preview used to reorder the theme layer for itself and report the divergence in the feed as `ui.theme.load-order`, because the engine sorted `ui/theme/**` by path and `game.css` sorts before `sim.css`. That was a preview deliberately showing a different theme from the game and saying so. The engine orders now, the preview reads the same function, and what you see is what the game draws. A project whose default is not the one it wanted names the file: the design system's gallery names `ui/theme/sim.css`, which is what puts its layer in `docs/UI.md` §10's order. `crates/editor/src/ui_screen.rs`'s `THEME_LAYER` still mirrors `packages/ui/src/tokens.ts` as the documentation of that layer, and `tests/ui_editor.rs` reads the TypeScript and holds the Rust to it, the way `crates/host/tests/tsx.rs` does for the esbuild flags.

**The overlays cannot disagree with the picture.** The boxes are `LayoutBox::painted` — where a node is drawn, clicked and navigated to, transforms already resolved — and the order is `argus_ui::tab_order`, the function the runtime's own Tab key calls. Both are read off the same laid-out tree the frame drew. A disabled button keeps its place in the tree and loses it in the order, because the runtime refuses to focus one.

**A scripted human can press a real game control.** `ui-preview.node.<id>` is published for every focusable node whether or not the numbers are switched on, and `Shell::on_mouse_button` turns a press there into a `UiEvent` for the runtime's own hit-test rather than into an entity pick. The event is handed to `UiState::handle` rather than queued: `World::queue_ui_event` parks it for `drive_ui_input`, which runs inside `World::step`, and the authoring world is never stepped.

**The locale picker moves the runtime's own locale.** A project keeps its translations in `ui/locales/<locale>.json`, a flat map of key to string, which the scene's `meta.strings` knows nothing about. `ui_screen::read_locales` folds every file into the shape `argus::UiState::set_locale_overlay` takes — `"<tag>.<key>" -> text` per translation, plus `"<key>" -> key` per key so the base locale draws the key itself — and `UiPreview::load_locales` installs it. The overlay is merged over the scene's table and survives the per-frame `sync_hud`, so `UiState::locales` lists the project's languages and `UiState::set_locale` accepts them. Picking one is then the same call `POST /ui/locale` makes and the same one a game makes.

**So a language switch is a re-resolve, like a theme switch.** The description is registered with its `textKey`s intact, the runtime holds the keys, and moving the picker re-resolves them in place: no describe, no re-register, no tree rebuild. `tests/ui_editor.rs` asserts all three counters hold across the switch. This used to be the one picker where §5's "what it previews is **the real game UI runtime**" was untrue — the preview rewrote every key into a literal before registering, which left the runtime nothing to re-resolve and made a language change cost a full re-register.

With `keys` chosen the preview shows the keys, which is what the game draws with no table loaded; that is the base locale plus the key-to-key entries, not a second code path. **A key no locale file mentions renders as `[[key]]`**, §2.11's convention for a key with no string, so an untranslated label is visible rather than indistinguishable from a translated one. A locale file that will not read or parse is a `ui.locale.unreadable` diagnostic naming it, in the feed with the rest; the locales that did read stay in the picker.

**`ui/locales/**` has no `UiRole`**, so a translation edit on its own fires no hot reload. The tables are re-read on any sync that describes, and on the cheapest reload tier (`UiReload::Scenarios`), which covers the two folders a person edits without touching a screen.

### Hot reload

`crate::ui_reload`. The paths one change set carried go to `argus_workspace_service::inputs::ui_reload_for`, which answers with the strongest reload they require — `Screens` beats `Styles` beats `Scenarios` — and the editor does exactly that. The ranking is not decided twice.

| Reload | What happens | What survives |
|---|---|---|
| `Styles` (`ui/theme/**`, `ui/styles/**`) | The stylesheets are reparsed and the sheet swapped | Focus, scroll position, an open dialog, every binding. §5 states this as a requirement, and it holds because swapping the sheet is all that happens: no screen is re-registered, so no tree is rebuilt |
| `Screens` (`ui/screens/**`, `ui/components/**`) | The bundles are dropped, the screen is compiled again and re-registered | Hover, press and focus by id. State only while the props shape is unchanged |
| `Scenarios` (`ui/scenarios/**`) | The mock data is re-read and the picker refreshed | Everything: nothing is recompiled and no sheet is reparsed |

**It rides the polling path, not a watcher.** `crates/workspace-service/src/external.rs:4-7` records that Windows delivers no events for files on the WSL path and inotify inside WSL does not see writes made from Windows to `/mnt/c`, so the service polls and journals what it found. The editor reads those paths off the journal into its feed (`FeedStore::take_ui_changes`) and hands them here, once per change set. §5 is explicit that UI hot reload has to ride that same path "or it will silently fail to fire on the user's own machine".

**A failed reload keeps the working screen.** A stylesheet with a refused declaration, or a screen that stops compiling, leaves what is on screen where it is and reports the reason — with the file, the line, the column and the nearest supported alternative that §2.2 requires (`ui_screen::UiDiagnostic`). A blank viewport is worse than a slightly stale one.

**Every diagnostic and every reload reaches the activity feed.** §5 asks for the diagnostics to be there "with file and line and click-to-source, exactly as script errors do", and a script error is a row with a file link — so a UI diagnostic is the same row. `Editor::report_ui_diagnostics` runs once a frame and writes one per stylesheet refusal, screen that would not compile, described-screen warning, refused binding and runtime diagnostic; the file and line become the link a person presses. `Editor::reload_ui` writes what the reload did, in `Reloaded::label`'s words, with the files it touched as links.

**The diagnostic list is the last set, not a log.** `UiPreview` holds what the last parse and the last describe raised and nothing older: a reload replaces the set, `report_ui_diagnostics` drains it into the feed and hands it back marked, and nothing is written to the feed twice. Six reloads of one broken stylesheet therefore leave six feed rows and one list, where the list used to gain a copy per reload for as long as the editor was open.

**A changed props shape is announced.** §5 promises state is kept when the props shape is unchanged and says nothing about the other case, where a screen gains a required prop that no scenario supplies and JavaScript interpolates `undefined` into a sentence the player reads. The description is read before it is registered, the three spellings a missing value has — `undefined`, `NaN` and `[object Object]` — become `[[…]]` holes in the `docs/UI.md` §2.11 convention, and each raises a `ui.screen.props` diagnostic naming the node, the scenario and the text. A whole word only: a literal with "undefined" inside a longer word is left alone.

### Screens in Play

**The engine registers a project's screens itself now.** `World::load_ui_screens` describes every `ui/screens/**` through `argus_host::screen::Describer` — the same describer the preview and the tile use, so what Play registers and what the editor draws are one description — and registers each through `UiState::screen_from_json`, which is `ctx.ui.screen`'s own path. A behaviour can then `ctx.ui.push("main-menu")` by name in a Play session, in `argus run`, and in an exported build; this used to be the editor standing in for a hook the engine did not have, and it is not any more. `meta.ui.startScreen` goes up straight after, before `wire_all`, so a behaviour reading `ctx.ui.stack()` in `init` sees what the scene asked for rather than racing an empty stack.

**What a screen that cannot be described costs is a named error, not silence.** A machine with no esbuild or no `@argus/ui` records one `ui.screen.tools` error rather than one per screen, a screen that will not compile records `ui.screen.compile` naming the file, and a `meta.ui.startScreen` nothing answers to records `ui.screen.unknown` naming the screens that did register. All of them are `ScriptError`s, which `argus run` prints, a test asserts on and `GET /events` carries — and `fixtures/starter-course`'s `course.ts` catches `ui.screen.unknown` and carries on with the HUD either way. Finding `@argus/ui` is therefore the difference between a project's screens existing and not; see "Describing a screen" above for the three roots it is looked for under.

### The Collection course's screens

`fixtures/starter-course/ui/` — and the same files in the bundled template, byte for byte, which `tests/template.rs` checks the way it checks the scene. `main-menu.tsx` (start, restart, exit) and `results.tsx` (pass or fail, the score and the time), a `sim` theme binding the eleven reserved semantic names, two stylesheets in the documented CSS subset, named mock data, and English and French locale tables. `course.ts` puts the menu up at the start of a run and pushes the results screen when the player leaves by the exit, through `ctx.ui` and by name; it never imports a screen and never walks one.

`fixtures/walkthroughs/ui-preview.txt` drives the whole preview on the desktop, and `tests/ui_editor.rs` runs the same file headless through the same `Shell` calls.


## The Output Log

The usual editor's Output Log. **Window ▸ Output Log**
(`menu.window.log`) opens the **Output Log** tab of the bottom drawer (`drawer.tab.log`, also
`log.tab`), and the status bar's **Log** button (`status.drawer.log`), as the usual editor's bar has one. `crate::output_log`.

**What is in it.** One line per message, each with a time (UTC, `12:03:04.123`), a category, a
severity (verbose, info, warning, error), the text, and an entity and a file when it names them:

| category | what writes it |
|---|---|
| `editor` | every message the editor wrote to stderr (or `--log-file`) before saves, imports, opening and stopping Play, refusals, an action that failed. Written through `log_line!`, which is `eprintln!` plus the log, so stderr and the log file keep every word they had |
| `engine` | Play starting (what it runs and from where), and the UI diagnostics the preview and a running game raise (`ui.css.*`, `ui.describe.*`, … with the file and line) |
| `service` | starting, reaching and stopping the workspace service, and what it refused |
| `build` | the service's journal: each build going Ready, Failed (with its reason) or Superseded, and each diagnostic a build recorded, with its `location` as the file and line and its entity |
| `script` | a running game's behaviours, read from the Play world after each of its frames. Script errors (`World::errors`): the entity, the module, the phase, the thrown message, whether the behaviour was disabled; the entity and the behaviour's file as links, and for a `.js` behaviour the line the stack names. `ctx.log` lines (info, with the data argument) and script warnings (`null-handle`, with the module's file) from `World::script_output`, the newest 512 the engine keeps; ctx.log's severity maps onto the line's, so `console.warn`/`console.error` show as warning/error once the engine has them |
| `critic` | check results (`check <id> passed/failed: summary`) and the diagnostics a check recorded |

Journal history from before the editor started is left out: the log is this session's, and the
Activity feed is the project's history.

**Controls.** A chip per severity (`log.severity.<sev>`, errors and warnings with their counts) and
per category (`log.category.<cat>`), a search box (`log.search`, case-blind over the text, the
entity and the file), **Clear** (`log.clear`) and **Follow** (`log.follow`). The view follows the
newest line; scrolling up pauses it, and scrolling back to the end or pressing Follow resumes it.
Rows are `log.row.<id>`; a line's entity is `log.row.<id>.entity`, which selects it as the Outliner
does, and its file is `log.row.<id>.file`, which switches the drawer to Content with the file's
folder open and the file selected.

**Bounded, and cheap with a full buffer.** The log keeps 10,000 lines (`output_log::CAP`) of at
most 2,048 bytes each; the oldest go first. It is process-wide, as stderr is, so it outlives a
project closed and another opened. The tab keeps the ids that pass its filter and adds only the
lines that arrived since each frame, rebuilding them only when the filter changes or the log is
cleared, and draws only the rows on screen (`ScrollArea::show_rows`). `tests/d18_log.rs` measures
it with the buffer full.

**Parity.** An agent reads the same lines through the same filter: `POST /v1/log` on the control
endpoint, which an agent reads as `editor.state {log}` ([AGENT.md](AGENT.md)), with `after` as a cursor. `shown:
true` reads through the filter the person has on. Clear clears the stream for both; ids never
repeat, so an agent's cursor stays good across it. `GET /v1/state` carries `log: {last, lines,
errors, warnings}`.

**Not in it (yet).** Scripts have no `console` global: a behaviour calling `console.log` throws a
`ReferenceError`, which does reach the log as a script error. A
`.ts` behaviour's error has no line link, because it runs as esbuild's output with no source map
and the stack's line is the compiled file's. The engine's own `argus:` stderr lines (a navmesh
rebuilt, a reload) are not captured.

## The Undo History

The usual editor's Edit ▸ Undo History. **Window ▸ Undo
History** (`menu.window.history`) opens the **Undo History** tab of the bottom drawer
(`drawer.tab.history`, also `history.tab`). It has no status-bar button: the bar is full at 150 %, and
The usual editor's has none either. `crate::history_ui`.

**What it lists.** Every step the shown document's undo and redo hold (up to `UNDO_CAP` each) and
every change another writer landed that the editor folded in (`Document::apply_external`, up to
`UNDO_CAP`), newest at the top, in the order they happened (`Document::history`). Each row is the
time (UTC, as the Output Log's), what the step did in words, and who made it: **You** for the
person at this editor, an agent by the display name presence has for it (else its own name, else
its id; the id is in the tooltip), the workspace service, or a change from outside the editor. The
**current position** is the newest step that is done, marked with `›` on a highlighted row; the
steps after it, the redo side, are greyed. Below them all is **Start of history**, before every step.

| Edit | Row |
|---|---|
| a move, a drag or typed transform, over one or many | Move Crate, Move 12 entities |
| rename | Rename Goblin to Grunt |
| duplicate, delete, cut, paste | Duplicate 2 entities, Delete Crate, Paste 3 entities |
| Details property rows, over one or many | Edit speed on Goblin, Edit speed on 40 entities |
| folders | Create folder Monsters, Move Goblin to folder Monsters, Rename folder A to B, Delete folder A |
| prefabs | Create prefab Crate, Place prefab Crate, Apply prefab Crate, Revert prefab overrides on Crate |
| import, place, discard, restore autosave | Import tree.glb, Place tree.glb, Discard changes, Restore autosave |
| an agent's scene through the editor | its own label ("Move box-b") under its name |
| another writer's change folded in | Rename Orc to Brute, Add Crate, Delete Crate, Edit Crate, Edit 3 entities, Edit scene settings |

**External changes are not undone from here.** Undo belongs to the person (see
[Editing the scene](#editing-the-scene)): an agent's change folded in from the journal never enters
the undo stack, and nothing the person undoes or redoes takes it back. The panel still lists it where
it happened, so a person reads an agent's work in order with their own, marked `· external` with the
reason in its tooltip ("Undo and Redo leave it as it is; revert it from the Activity feed").

**Jump.** Clicking a row undoes or redoes, one step at a time, until the history stands just after
that row (`Editor::history_jump`, `UiAction::HistoryJump`): one click, several steps, as the usual editor's
does. A click on an external change lands just after it (the person's steps before it done, those
after it undone); Start of history undoes everything listed. Each step is the plain Undo or Redo,
refused on the same terms — another writer's later change to what it touched, or a prefab file
changed since Apply — and the **first refusal stops the jump there**: what was already undone stays
undone, and the panel's top line (`history.note`, whole on hover) and the toolbar say how far it got
and why ("the jump back stopped after 2 of 3 steps: cannot undo "move #goblin": #entity/goblin was
changed by agent Layout since …"), until the next jump. A new edit ends the redo line, as it always has.

**Names drawn.** `history.rows` (what of the list can be pressed), `history.row.<index>` for each row
on screen (`history.row.0` for the start) and `history.note`. The rows are drawn with
`ScrollArea::show_rows`, so only those on screen are published. Five drawer tabs do not fit the
strip at 200 % of 1280x720 (the drawer under the viewport is about 370 points): there the strip
uses the status bar's short names (`DrawerTab::bar_label`), and below the promised window size it
scrolls sideways rather than running under Hide.

**Parity.** `editor.state` answers `history: {current, total, steps}`, the newest 50
(`history_ui::DEFAULT_LIMIT`) first, each `{index, label, actor: {kind, id, name}, at, undoable}`
plus `redo: true` on the greyed side and `why` on an external change; `editor.state {history: N}`
reads N (`POST /v1/history {"limit": N}`), and `GET /v1/state` carries the same `history`. An agent
jumps with `editor.set {history: N}`, which is `/v1/do {"do":"history-jump","index":N}` and answers
`moved`; a stopped jump is the refusal, with the same words. `index` counts from 1 per document and
is never reused, so the index an agent read still names that step after more are added; Reload and
opening another scene start a new history. See [AGENT.md](AGENT.md).

## The Profiler

Window > Profiler (`menu.window.profiler`, also `profiler.tab`) opens the **Profiler** tab of the bottom drawer (`drawer.tab.profiler`; "Prof" on the crowded status bar and the compact strip). It reads the flight recorder: the last few seconds of timed spans that the running game records into a fixed ring at a cost of a few microseconds a frame. Nothing is recorded, saved or sent by looking at the tab; while it is open and a game runs it copies the newest 240 frames five times a second.

- **The headline** (`profiler.bound`): what the frames are bound by (CPU script, CPU physics, CPU render, GPU with the pass, or paced by vsync), the frame cost and its p95, and the top finding of the analyser (`GET /profile/explain`'s first). With nothing running it says to play.
- **The frame-time strip** (`profiler.strip`): a column for each frame, height by cost on a linear scale that follows the 99th-percentile frame of the window (never less than 25 ms), with the 16.7 ms budget line drawn over the bars (and 33 ms when it fits), coloured by what dominated it (script, physics, render, GPU). An ordinary frame sits well under the budget line and a hitch towers over it; a bar taller than the scale is cut off at the top with a white cap. A frame far above its neighbours is a **spike**: a red mark under the cap, and its own `profiler.strip.spike.<frame>` control. Pressing the strip, or a spike, selects that frame and **freezes** the strip so the frame stays under the pointer; Follow (`profiler.follow`) puts the newest back. The rule is the same one that saves a capture on its own: over 2.5 times the median frame and over it by 2 ms, or over 33 ms.
- **The timeline** (`profiler.timeline`, mode `profiler.mode.timeline`): the selected frame's spans on one clock, a row per depth on each track: Main (the frame and tick, the phases, each behaviour call), worker threads, GPU (one span per pass, when the run has GPU timestamps), present and load. Hover names a bar, its length and, for a behaviour, its entity; **pressing a behaviour bar selects its entity** in the viewport and Outliner. `profiler.bar.<span name>` is the widest bar of each name.
- **The flame graph** (mode `profiler.mode.flame`): the same spans merged by path over the frames shown, the widest child first; `profiler.flame.<path>`.
- **Capture now** (`profiler.capture`, while playing): saves the ring as a capture under `.argus/profile/`. The editor also saves one **on its own** when a frame spikes, and the status bar shows `Spikes N` (`status.profiler`) while a game runs and has hitched; pressing it opens this tab. The newest 8 captures are kept.
- **Source** (`profiler.source`) picks the live ring or a saved capture (`profiler.source.pick.<id>`), so a spike can be read after the game stopped. **Compare with...** (`profiler.compare`, items `profiler.compare.pick.<id>`) draws the shown window against a capture: a before strip and an after strip, the frame cost change with its noise band, and the spans that changed significantly (`profiler.diff.row.<path>`), green for faster and red for slower.
- **Heat** (`profiler.heat`, while playing) tints each entity's bounds in the viewport by what it costs, one metric at a time: Script (ms per tick), Triangles or Contacts (`profiler.heat.metric.<name>`), cold blue to hot yellow, entities that cost nothing untinted. **Surfaces** (`profiler.heat.surfaces`) also paints the meshes themselves in the standard renderer's Cost view (every surface its heat colour, the rest dark grey); the HD renderer does not read the view-mode flag, so there the boxes are the whole map. The map turns the draw counters (`POST /profile/detail`) on while it shows and off when it closes.
- **Play records its input.** Every Play that starts from the authored scene writes `session-<id>.replay` beside the captures (the newest four are kept), so a capture has a **replay recipe**: `profile.diff` (or `POST /profile/rerun`) can replay that run against the project as it is now and check a fix like for like. A Play restored from a snapshot cannot be replayed from load; its captures say so.
- **F3 in the game window** shows two more rows under the stats when the recorder is on: what the last frames are bound by with the top finding, and the last 48 frames as a strip of characters (`.` cheap to `@` costly, `!` a spike).

The pictures are laid out by `argus_profile::layout`, and drawn a second time, with no window and no GPU, by `argus_profile::raster` for the query API's `GET /profile/image?kind=strip|timeline|flame|heat` and the `profile.view` tool; a test puts the editor's bar list beside the rasteriser's. Files: `crates/editor/src/profiler.rs`, `crates/profile/src/layout.rs`, `crates/profile/src/raster.rs`, `crates/app/src/profile_image.rs`; tests `crates/editor/tests/p7_profiler.rs`, `crates/app/tests/profile_image.rs`.

## The Input table

**Window ▸ Input** edits the open scene's action map,
`meta.input` ([`docs/INPUT.md`](INPUT.md)), as a table (`crates/editor/src/input_ui.rs`).

- **Contexts** down the left. The radio marks the one live when the scene loads (`active`); Rename (or
  a double-click) renames one in place, and Enter keeps the name; × deletes one with its actions;
  the field below adds one. Click a context to show its actions.
- **Actions** of that context on the right, one row each: name, kind (button, axis1, axis2), keys,
  gamepad, and the kind's options (toggle on a button; mouse, deadzone and sensitivity are shown and
  edited in JSON). The row below adds an action by name and kind; × removes one; Rename (or a
  double-click on the name) renames one in place with its bindings, Enter keeps the name, and a
  name the context already has is refused.
- **Keys.** Click a key and press the new one, as Editor Preferences ▸ Keyboard Shortcuts does:
  while the slot waits, no shortcut runs and the game hears nothing, and Escape on its own gives up.
  Modifiers are keys here (a game binds LShift). An axis takes its keys a set at a time, shown with
  `|` between sets: four for `axis2` (up, left, down, right, the tooltip says which), two for
  `axis1` (negative, positive). "+ set of 4" waits for four presses and adds them as one change;
  × beside a set removes it. A slot waiting for a key also takes a typed name (for Escape, which
  the press cancels on), in any spelling the key list answers to; it is stored in the engine's.
- **Gamepad** is a dropdown of every source the contract takes (`GamepadBinding`), or none.
- **Shared keys.** Two actions of one context on one key or one pad source are allowed, as the
  engine allows them, and each is named above the table ("Shared: LShift drives dash and sprint")
  with the key drawn in the warning colour.
- **Refused.** A typed name that is not a key, or a pressed key the engine has no name for (Home, a
  numpad key), is refused in the contract's own words and nothing changes:
  `input-key-unknown: "Back" on gameplay.jump is not a key name, so it binds nothing; it is a pad
  button: write "gamepad": "select"`. A change the contract refuses for another reason is refused
  the same way, with its message.
- **Undo and save.** Every change is one undo step through the scene edit path (it is in the Undo
  History, Ctrl+Z takes it back), and Save writes it. The table shows the map the scene plays: its
  own actions over the project's (Project Settings › Input), or the built-in map when the project
  has none. **Over a project map a change writes only what it changes** — the one action, keyed by
  context and action name, and `active` if that changed (`input_ui::scene_delta`) — so a later change
  to any other project action still reaches the scene. A scene cannot take away a context or an
  action the project has (the merge would add it back): removing or renaming one is refused and
  says so. Without a project map the first change copies the built-in map in, as before. The table is read-only during Play: Play runs the saved scene.

**Two maps.** The table edits *an input map at a JSON pointer* (`input_ui::InputTarget`): the open
scene's `/meta/input`, or the project's default map, `/input` in `argus.project.json`, which
[Project Settings ▸ Input](#project-settings) draws. Where each is read and written is an
`input_ui::Maps`: the editor for the scene's (one undo step each), the settings page for the
project's (typed, sent with Apply). The edits, key checks, capture and drawing (`input_ui::table`)
are shared. Window ▸ Input is not drawn over Project Settings, whose table has the same names.

**Parity.** An agent edits the same map with `scene.patch` on `/meta/input`; no
tool of its own. Each control is named `input.*` in `editor.controls` (`input.action.jump.key.0`,
`input.action.jump.gamepad.north`, `input.context.menu.rename`), so `editor.click` and `editor.key`
drive the table as a person does.

## Exporting a game

**Export…** on the toolbar (`toolbar.export`) opens the Export panel: one branded executable a person with no Talos can double-click. The installed editor has no cargo, no Node and no checkout, so nothing here builds anything — the two binaries an export is composed from are installed beside the editor, and the composition itself is `crates/export` running in this process (`docs/EXPORT.md`).

**What is exported is a revision, not what is on screen.** The workspace service turns each save into a revision and builds it; the panel exports the last build it found **Ready**, and says so by name. Two directories are read, both the service's: the revision's read-only snapshot (`.argus/snapshots/<revisionId>/`) for the manifest, scene, icon and assets, and the build job's output (`.argus/jobs/<jobId>/out/modules/`) for the behaviours that build transpiled. Neither is the authored tree, so nothing typed since the build can reach the executable, and "this file is exactly revision X" holds by construction. Nothing is transpiled at export time: bytes no build ever checked do not go into a game.

**With no Ready build, Export is refused in the build view's own words** — "Failed — scenes/jump.ts:5:19", "No workspace service: builds are not checked here" — rather than in a second vocabulary for the same fact (`BuildsView::export_blocked`).

**What is left out is said, not implied** (`export.excluded.<i>`). Unsaved changes to the scene get a line naming the file and the revision that *is* being exported; a build still running for a newer revision gets a line saying which build this is instead; an edit the service is still waiting to settle gets a line naming the files; and files changed outside the editor that are waiting for an explicit Apply get a line naming them and sending the person to the feed, where the Apply is ([Activity feed](#activity-feed)).

**Branding is shown and never edited here** (`export.branding.*`): display name, executable name, version, build number and icon, read from Project Settings through the same `SettingsBackend` the settings section edits, with **Edit in Project Settings** (`export.settings`) closing the panel and bringing that section into view. Two places to change one name is one place too many.

**The destination** is a folder (`export.destination`, with **Browse…** where the editor links a picker — Windows), defaulting to `dist/` beside the project. The executable is named from the manifest's `executableName`. A folder that is not there yet is created by the export; a path that is a file is refused.

**The job runs on a thread** and reports through a channel, as the service link does: the panel drains it each frame, so nothing about an export that reads every asset and hashes a whole executable happens on the UI thread. Progress is by stage (`export.stage`, `export.progress`) — checking the inputs, verifying the launcher, reading the engine, collecting files, writing the icon and version, building the payload, composing, verifying, putting it in place — with a file count and the file being worked on inside the long one. **Cancel** (`export.cancel`) sets a flag the composer checks between stages and inside the asset loop; the panel goes on saying "Cancelling…" until the composer answers, because saying "cancelled" while a thread is still writing would be untrue.

**On success** the panel names the file, its SHA-256 and its payload id (`export.done`, `export.sha256`, `export.payload-id`), and offers **Open Output** (`export.open-output`), which shows the executable in the file manager and never runs it. The payload id hashes the revision, artifact and build ids along with the content, so the executable reports which build it is out of its own bytes.

**On failure** the panel shows the stable code and the message (`export.error`: `binary-mismatch`, `size-gate`, `verify-failed`, … — the codes `docs/EXPORT.md` lists), and states that a previous export at that path was not touched (`export.untouched`). That is a fact about the mechanism, not a hope: the executable is composed under a temporary name beside the destination, read back and verified, and renamed over the destination only then.

**The two binaries.** The deploy engine (`argus-deploy-engine.exe`) and the launcher template (`argus-launcher.exe`) live beside `argus-editor.exe`, where `scripts\windows\install-editor.ps1 -Deploy … -Launcher …` puts them, each with its SHA-256 recorded beside it as `<name>.sha256`. The editor reads the launcher and checks it against that recorded hash **by its bytes** before composing, so a swapped or corrupted launcher is caught rather than shipped. Neither missing binary is built on demand: the panel says which file is missing and which flag installs it.

## Restart state

`<project>/.argus/editor/state.json`, schema version 1, written atomically (write beside, rename over; on Windows a replace refused with `Access is denied` is retried up to 40 times 25 ms apart, about a second, before it fails) 250 ms after the last authoring change, on every Play snapshot save, and on close:

| Field | Meaning |
|---|---|
| `schemaVersion` | `1` |
| `projectId` | the manifest's `projectId` when written; a state file from another project is ignored, with a note |
| `camera` | `target`, `yaw`, `pitch`, `distance`, `fovYDegrees`, `speed`, and `projection` and `orthoHeight`; a camera outside the editor's limits is ignored |
| `selection` | stable entity ids; ids the scene no longer has are dropped, with a note |
| `viewport` | the viewport's `mode` (lit, unlit, wireframe), `show` flags and `gameView` ([Viewport modes](#viewport-modes)); absent reads as the defaults. The camera's `projection`, `orthoHeight` and `speed` are in `camera` |
| `gizmo` | the transform gizmo's `tool`, `space`, `pivot`, `gridSnap`, `grid`, `angleSnap`, `angle`, `scaleSnap`, `scale` ([Transform gizmos](#transform-gizmos)); absent reads as the defaults, and values outside the limits are ignored, with a note |
| `views` | by scene path, the `hidden`, `isolated` and `locked` entity ids of that scene's authoring view; left out when nothing is hidden or locked. An editor from before this change refuses a file that has it (unknown field) and opens with defaults, with a note |
| `lastArtifact` | the artifact the authoring view showed; reopened if it still matches the sources, otherwise a new one is prepared, with a note |
| `lastSnapshot` | the last Play snapshot saved on purpose, project-relative |
| `savedAtMs` | milliseconds since the Unix epoch |

An unreadable or foreign state file never stops the editor opening: it opens with defaults and `Editor::reopened().notes` says what was ignored and why. Play state is never restored implicitly: Play always starts fresh, or from a snapshot the human chooses (F6). There is no player save system.

Also under `.argus/editor/`: `artifacts/<artifactId>/` (`artifact.json`, `inputs.json`, `scene.json`), `snapshots/`, `captures/`, `workers/<sessionId>/`, and `sessions.json`, the session registry when no workspace service is the authority (see below). When the editor opens without a service, any session a previous editor process left non-terminal — a crash mid-Play, a killed editor — is marked `interrupted` ("the editor process that owned it ended") rather than left `running`.

## The workspace service

### Placement

The editor runs on the operating system that owns the project's files, as the workspace service does. `OpenProject::open` asks the service crate's rule (`argus_workspace_service::placement::placement`, which decides by file-system identity, not by spelling) before it reads or writes anything, and refuses a project on the other OS's file system (`wrong-os-for-project`) or one whose file system it cannot identify (`placement-unverified`). `argus-editor <project>` opens the project before its window, so a refused project shows no window and writes nothing under `.argus/`. A project under WSL is edited by an editor running in WSL; a Windows-native editor refuses `\\wsl.localhost\…`, and a Linux editor refuses a project on `/mnt/c`.

### The service on the desktop

A developer runs `argus-workspace-service serve --project <dir>` in a terminal. Someone who opens the editor from a desktop shortcut has no terminal and no reason to know the service exists, so **the editor starts one itself** (`crate::supervise::Supervisor`). Without it there is no activity feed, no jobs panel, no build states and no session authority — the half of the editor that managed sessions are about.

**When.** Opening a project, before `Editor::open`, so the editor's own open-time discovery finds it and takes its journal as the session authority for the run ([Session authority](#session-authority)). Closing the project stops it: returning to the launcher stops it and opening the next project starts one for that root. The order is deliberate — the editor closes first, because closing posts the run's last session records to the service.

**What it starts.** `argus-workspace-service serve --project <root> --no-selection`, as a child process, on an ephemeral port. The build pipeline runs (never `--no-build`): Ready is the service's Ready, which is what the toolbar shows. `--no-selection` keeps a service the editor spawned from changing the person's remembered project outside the project folder; the editor has its own recent-projects list. On Windows the child is started with `CREATE_NO_WINDOW` through `worker::no_console`, so it can open no console window and take no foreground.

**Which binary.** `$ARGUS_WORKSPACE_SERVICE` when it is set, otherwise beside the editor's own executable: the multi-call `argus[.exe]`, run as `argus workspace-service serve …` (an install has one, and its `argus-workspace-service[.exe]` is only a small forwarding stand-in, `docs/INSTALL.md` "One executable for the tools"), or a real `argus-workspace-service[.exe]` when that is what is there (a checkout's `target` folder) — which is where `scripts\windows\install-editor.ps1 -Service <path>` puts it, with its SHA-256 recorded beside it as the editor's is. With neither, the editor says so, naming both places, and runs without a service. The installer warns when it has none to bundle.

**The log.** The service's standard output and error are appended to `<project>\.argus\editor\service.log`, moved aside to `.log.1` past 8 MB, as the editor's own log is. When that file cannot be opened — a read-only `.argus\editor`, for instance — the service still starts, its output is discarded, and the editor says which file and why.

**It never touches a service it did not start.** If one already answers for the project — the CLI, CI, another editor — the editor uses it, starts nothing, and leaves it running at close. Two editors on one project are therefore the second adopting the first's service. The discovery file is removed on stop only while it still names the instance the editor started.

**Stopping.** The service has no shutdown request: it stops on Ctrl-C or SIGTERM. So the editor sends SIGTERM and waits up to five seconds for the service to close its workspace, then terminates it. On Windows there is no SIGTERM, and a windowless child shares no console to be sent Ctrl-C on, so the process is terminated and the editor removes the discovery file the service had no chance to remove itself. The service takes no parent pipe, unlike a test worker: **an editor that is killed outright leaves its service running**, and the next editor adopts it.

**A WSL project opened from Windows.** Refused, by the same placement rule as everything else ([Placement](#placement)): a Windows-native process cannot be the writer for a project on the WSL file system, so it does not start a service for one either. In practice `argus-editor.exe <\\wsl.localhost\…>` never gets this far — the project itself is refused before the window. Where a project opens but placement will not have this process serve it, the editor opens with no service and says which refusal it was (`wrong-os-for-project`, `placement-unverified`). Run the editor, and its service, on the operating system that owns the files.

**Opting out.** `--no-service` on the command line, or `ARGUS_NO_SERVICE=1` in the environment. Either way the editor does exactly what it did before it follows a service that is already running and starts none. A refusal is never fatal — the editor opens, and the reason goes to its messages (stderr, or `--log-file`), not to the launcher's error line, which belongs to a project that did not open.

### Content roots

Play and test workers load a revision's **read-only snapshot** (`<project>/.argus/snapshots/rev_<hex>/`) and the build job's compiled output (`<project>/.argus/jobs/<jobId>/out/modules/`), never the authored tree: the workspace service applies a multi-file change set as an intent entry followed by writes, so the authored files can be briefly mixed on disk, and a behaviour read from there is bytes no check ever saw.

**Where those two directories come from is the service's answer, not a guess.** `GET /v1/builds/{revision}/content-root` re-materialises the revision's snapshot from the object store and hashes every file in it against the revision's own inputs before it answers; only then does it say `verified: true`, and only a selection carrying that is loaded. A path assembled from a revision id is a guess about what is on disk, and a read-only flag is not an integrity boundary. `GET /v1/builds`'s own `contentRoot` field is **not** that answer and the editor does not load from it.

`crate::content::ContentRoots` remains the authoring preview's seam — "the content root for revision X" — with `AuthoredTree` as its implementation: the artifact's frozen scene copy, with behaviours and assets from the authored tree behind the artifact's stale check. Standalone `argus run <scene>` keeps its file watcher and reload, and resolves `script.module` beside the scene as it always has.

### The link

`crate::service::ServiceLink` is a background thread. It finds the project's service through `.argus/service.json` and `GET /v1/instance` (`argus_workspace_service::discovery::resolve`: the pid check on the service's own OS, then the instance handshake; never a guessed port) and follows it:

- `GET /v1/log?after=<cursor>&limit=500`, every 250 ms, up to eight pages a round;
- `GET /v1/status`, every second;
- `GET /v1/evidence/{sha256}` when a screenshot is opened (at most 32 MiB held).

Everything it reads reaches the editor through a bounded channel (256 events), and each editor frame folds in at most 64 (`EVENTS_PER_FRAME`), so a busy service never stalls a frame. **The cursor survives reconnects**: it is the last sequence number read, and after a dropped connection or a restarted service reading resumes after it, so no entry is shown twice and none is skipped silently. The first connection starts at the newest 2,000 entries. While no service answers, the link retries every second and reports each distinct reason once.

### Session authority

Whether a service answers **as the editor opens** decides who keeps the session registry for the whole run.

**A service answered: its journal is the authority.**

- Every record change is posted to `POST /v1/sessions?source=argus-editor`, one lifecycle step at a time, with the serving engine's instance from its ready line (`nonce`, `pid`, `endpoints`): the Play runtime's for a human-play session, the worker's for a test session.
- Each live session is heartbeated every five seconds (`POST /v1/sessions/{id}/heartbeat`).
- The editor writes no `sessions.json` and does no interrupted-recovery of its own: the service recovers sessions a dead editor left.
- While the service is unreachable, records wait in bounded queues (1,024 in the registry, 256 in the link) and go in order once it answers; anything dropped from a full queue is counted (`ServiceState::dropped_records`).
- A record the service refuses (`wrong-project`, `illegal-transition`, `terminal-session`, `engine-replaced`, `revision-not-runnable`) is reported (`ServiceState::refused`, the feed's `record-refused` row, and a stderr line) and never retried.
- On close, the last records are posted (close waits at most two seconds for the service), then `sessions.json` gets its one write, as a record of how the run ended.

**No service answered: `sessions.json` is the registry**, as before, for the whole run. A service that starts later is followed for the feed and jobs, but is not given the sessions.

**Unverified content.** Sessions naming an artifact the service does not know are accepted and marked `artifactVerified: false`; that is every editor session until Play runs revision snapshots. The feed says so on the session's row ("unverified content: not a build the service checked"), and `ServiceState::artifact_verified(session)` gives the toolbar the same.

`crates/editor/tests/service_link.rs` and `crates/editor/tests/e2_service.rs` gate the link and the authority against a real service.

### Activity feed

`crate::feed::FeedStore` folds log pages into rows a person can act on: actor, action and result, with links to a diff, an entity, stored evidence (a screenshot, a trace), a job, or a source file.

- **Bounded.** The newest 2,000 rows (`FEED_CAP`); every row's raw entry is cut at 2,048 bytes, its label at 512, each diff at 8,192, and its links at 12. Memory is bounded by the row cap however long the journal grows.
- **Truncation is a row.** History the feed did not load (`feed-history-not-loaded`) and entries the service's retention removed before the feed read them (`journal-truncated-unread`, from the page's `truncatedBefore`) are marker rows with counts. Rows dropped to keep the cap are counted (`dropped()`).
- **Filters** (All, Failures, Jobs, Changes) each keep an index of the rows they admit, updated as rows arrive and leave, so a frame reads only the rows it shows.
- **Labels are for people.** A per-revision story follows a change through its build, checks and apply: "`jump` change compiled; `traversal` passed; applied to your session". **Applied comes only from the runtime's acknowledgement**: an `update-applied` entry whose outcome is `applied`, `ok`, `succeeded` or `success`. A completed change, a build or a passed check never sets it. The raw entry stays behind the row.
- **Tolerant.** An entry kind the editor does not describe is still a row, with its raw JSON.

**Changes made outside the editor are applied from the feed.** The workspace service reads files that change outside the workspace protocol once they have stopped changing; one file it applies itself, but several wait for an actor to say the edit is finished, because stable reads do not prove that it is (`docs/WORKSPACE-SERVICE.md`). Until someone does, those files are in no revision at all, so nothing builds, plays or exports from them. The row that found them names them and carries **Apply** (`feed.apply-external`, and `feed.apply-external.<group id>`, which is the name to press when more than one group is waiting), which posts `POST /v1/external/<group>/apply` as `human:local` through the service link. From the press until the service answers, the button is disabled and the row says it is applying (`feed.apply-external.<group id>.applying`); on success the row names the revision the group became, and that revision builds as any other does; on failure the row shows the service's own message and offers the Apply again, because a refusal is not a reason to hide the changes. A group that a later one superseded says why and offers nothing. While a group is waiting, the Export panel says those files are in no revision and are not in the export, and sends the person to the feed to apply them.

### Agents

`crate::presence` is what the human sees of the agents that work in the background. `PresenceModel` holds the roster, what each agent has in scope or has just changed, and the updates held behind Refresh. It is folded from `PresenceEvent`s that a `PresenceSource` hands over once a frame (`Presence::pump`, from `Shell::frame_with_dt`). `NoPresence` is the source until the service and the agent supply one, `FakePresence` is the gates' source, and `ARGUS_TEST_PRESENCE=<script.json>` gives a scripted run a fake one. The buttons emit `UiAction::Presence(PresenceAction)`: the switch, Refresh and Apply are the model's own, and Pause, Revert and Diff are forwarded to the source.

- **Roster.** The status bar's chip counts the agents and opens a list of them. Each row shows the agent's colour, hashed from its name into `theme::AGENT_PALETTE`, and its activity, with Pause/Resume, Revert all since… (the last 1, 5 or 15 minutes, or since this editor opened) and Show changes, which filters the feed to that agent.
- **Marks.** An entity an agent has in scope, or changed in the last 10 s (`RECENT_MS`), gets a tinted Outliner row with a stripe and the agent's initial. It also gets a name tag in the agent's colour over it in the authoring view, and a ring flashes for 700 ms when a change lands. The tags are painted by the viewport overlay's painter, so they add no render pass and take no clicks. The human's selection is never touched, and it still wins the row's fill.
- **Live updates.** Remembered per project in `.argus/editor/settings.json` (`liveUpdates`, default on), which is written when the switch is flipped. The editor core owns `state.json`, so the switch is not kept there. When the switch is on, a change is applied to the view as it lands. The exception is a staged set while Play runs, which queues until Refresh or Stop. When the switch is off, everything queues. The toolbar then shows "N updates ready (x scene, y code)" (`toolbar.updates`) with **Refresh** (`toolbar.refresh`). The button says "Refresh (restarts Play)" when something queued cannot reach a running game. Turning the switch back on with a queue offers "Apply N queued updates?" rather than applying them unasked. `PresenceModel::take_applied` hands what was applied, in order, to the editor core to fold into the document and Play.
- **Feed.** A per-agent filter (`FeedStore::count_by`/`get_by`, an index like the others). Every change that landed carries **Revert** and **Diff**, and a change held behind Refresh carries **Apply**, which applies that one alone.

### Jobs panel model

`crate::jobs::JobsView` is built from `job` and `check-result` entries and `GET /v1/status`'s live jobs: the newest 256 jobs, 16 results each, and 64 results that name no job, with counts of what was dropped.

- Each job shows its state, the phase the service gives it (queued, running, session ready, checking, done), its kind as its
  resource class, and either the progress a runner reported or "progress not reported". Progress lives in the service's
  memory only, so it is absent for a job that never reported one and is lost when the service restarts.
- **Stale:** a result for any artifact other than the running one stays inspectable and is marked stale; it cannot validate what the human is playing.
- **Incomplete because:** the job's state and reason, whether the service is waiting for it to report again after a restart, and each result's outcome, failure kind and limitations, in words.
- **Cancel** goes through `JobControl`. `ServiceJobControl` posts `POST /v1/jobs/{id}/cancel` with the human as the actor;
  the service answers `terminal-job` for a job that has already finished, and the refusal is shown rather than swallowed.
  A job can be cancelled while it is not terminal (`argus_workspace::is_terminal_job_state`, the service's own rule).
  Cancelling needs a runner polling: `GET /v1/jobs` lists only runners that polled within the service's `RUNNER_TTL_MS`
  (15 s), so an empty list is the whole signal, and the button then carries that reason instead of being dead.

### Project Settings

`crate::settings::SettingsBackend` is shaped like the service's settings API (`GET /v1/settings`, `POST /v1/settings/check`, `POST /v1/settings/apply`, `GET /v1/settings/icon?path=`; an edit is `{field, expect, value}` by JSON pointer, and a stale `expect` is a field conflict with the expected, actual and proposed values).

**`ServiceSettings` is what the editor uses.** `Shell::new` resolves `<project>/.argus/service.json`, verifies the instance and talks to those routes, so a settings change is a change set on `argus.project.json` with a lease, an expected version and a journal entry — the same path an agent's edit of that file takes — and the write is a revision the build pipeline builds. The apply is made as the person at this editor (`Actor`), and its three refusals are shown rather than raised: a field someone changed underneath the edit (409 `field-conflict`, naming the field and what it holds now), another change holding the manifest (409 `lease-held`, naming the holder), and a merged manifest that would not validate (422 `invalid-settings`, with the diagnostics).

The page holds the view it last read until something says the manifest moved underneath it: its own Apply says so, and so does anything else that edits a settings field (`EditorUi::forget_settings_view`), which today means Set as startup scene. Now a change made elsewhere does too: whenever the editor takes the manifest again (`Editor::manifest_generation`), the page reads the settings again and keeps what was typed (`EditorUi::refresh_settings_view`).

**`ManifestView` is the fallback**, when no service answers or the discovery file names one that has gone. It reads `argus.project.json` through the project resolver, shows each violation on its field, previews the icon by reading the file, and edits nothing — only the service can record a settings change, and it says so where a person would otherwise type. `RecordingSettings` is the test seam the automated gate edits through.

### Authoring context

`crate::state::EditorStateStore` is the whole interface for the restart state (`load`, `save`); `FileStateStore`, writing `state.json`, is its only implementation.

## The interface

`crate::ui::EditorUi` is the editor's interface: egui 0.36, run once per editor frame over the editor core, after the editor's own frame. A frame of it reads the editor and records what the person asked for (`ui::UiAction`); the shell applies those after the frame through the same calls its keys make (`Shell::apply`), so a button and a key are one path.

| Area | What it shows and does |
|---|---|
| Menu bar | File (New project…, Open project…, Save, Close project), Edit (Undo, Redo, Duplicate, Delete, Project Settings…), Window (Activity, Jobs, Content, Outliner, Details), Help (Viewport controls, About Talos Editor, Report a bug…, Request a feature…, Copy the documentation path), each item with its keyboard shortcut as text and each dispatching the same `UiAction` the toolbar and the keys do |
| Toolbar | **one row** — left **Save** (a disk mark), Undo, Redo, **Import model…** (see [Importing models](#importing-models)), **Export…** (see [Exporting a game](#exporting-a-game)); centre **Edit** and the transport as marks, Play a green triangle, Pause two bars, Step a triangle against a bar, Stop a red square, each naming itself on hover; right the project's name in the quiet style every state item has (`toolbar.project`; it follows a settings apply, as the window title does), **Settings** (the Project Settings page) and **Close project**. What the editor is *holding* is the status bar below, not a second toolbar row |
| Status bar | along the bottom of the window (the usual editor's bottom bar). Left: **Activity**, **Jobs**, **Content**, **Log** (`status.drawer.<tab>`; short labels, because at 150 % in a 1280-wide window the bar is full), which open the drawer on that tab and close it when pressed again; then, only while a Play session is live, "Play is up to date" or "N updates pending" (`toolbar.pending`) — at rest it says "Play runs the latest save", because the count is about restarting a run and there is nothing to restart — then the agent chip ("3 agents", `status.agents`), which opens the roster, and the **Live updates** switch (`status.live-updates`; see [Agents](#agents)). Right, reading left to right: the build state (`toolbar.build-state`), the workspace service's state (`toolbar.service`), "Unsaved changes" or "Saved" (`toolbar.save-status`), and `Content <id>` for the artifact Play loads (`toolbar.artifact`). Above the bar, and only when there is something to say, a wrapped row of notices: "Scene changed on disk" with **Reload**, the last Ready build and **Play last Ready**, "unverified content", the last refusal and the last error |
| Viewport | the game image, drawn across the whole panel at the render target's size, which follows the panel's, composed with the panels in one pass; a click selects the entity drawn under the pointer (pointer, to the image rect, to a target pixel, to the authored id). A painted overlay at the top-left names the open scene and whether it is authoring or playing (`viewport.overlay`), with the selected entity's id under it in the usual editor's orange (`viewport.selection`); both are painted, not widgets, so a click there still reaches the viewport |
| Outliner and Details | the right column, the tree over the inspector with a splitter between them, either one hidden from the Window menu, each under a header strip with its name as a tab (`outliner`, `details`); a search box over the tree (`outliner.search`) narrows it by name, id or tag and flattens it while it is typed in. The scene's entities as rows (`entity.<id>`): a kind mark coloured by what the entity is for, its name, and its id dimmed after it, the selection filling the row in the accent. Then the selected entity's Duplicate and Delete, its transform as numeric fields (`inspector.transform.position.x`, …), its components' behaviour fields as typed rows (`inspector.prop.controller.speed`, …) over a collapsed raw JSON view, the asset section for an entity with a `mesh` (where it was imported from, its settings, Reimport, Collider, Reveal, and Delete unreferenced when the scene's `assets/` folder holds model files nothing names — see [Importing models](#importing-models)), components, a copy-path link to its behaviour source, and failures in the feed that name it |
| Project Settings | a page over everything between the toolbar and the status bar (`settings.page`), opened from Edit → Project Settings…, the toolbar's Settings and the Export panel's Edit in Project Settings — **not while Play runs**, because the page would cover the viewport the run is drawing into, which is the rule every other File and Edit item already followed. A search that matches nothing says so (`settings.empty`), and edits typed and not applied are kept when the page is closed and counted at the top when it is reopened (`settings.unapplied`), so nobody pushes an edit they thought they had abandoned: a search box, the category tree from `Section::ALL` on the left with each section's count (`settings.category.<slug>`), the form under section headers on the right, capped at 720 pt with a fixed label column, each field's diagnostics (the categories are grouped Project and Engine, and the Engine ones are the scene defaults, see [Project Settings](#project-settings)), and "needs a restart or a new export" said **once per section** (`settings.section.<slug>.note`) with a `×` beside the individual fields only where the section is mixed; the icon with **Choose…** and a preview served by the workspace service, then Apply, Revert and Close |
| Activity feed | `build` entries as "Building scenes/main.scene.json", "Ready", "Failed: scenes/jump.ts:5:19 — message" with a source link, and `revision-runnable` entries; the newest rows the scroll area shows, and an explicit line for rows dropped, entries never read and housekeeping not shown. **Consecutive entries that say the same thing are one row and a count** ("… ×8"), and a garbage collection that freed no bytes is counted rather than kept — but a row that leads anywhere (a diff, an entity, a job, a file, a group of external changes) is never folded into another, because folding it would take away something a person can press; a row opens an "Activity entry" window with its links (diff, entity, screenshot or trace, job, source) and its raw entry, so the links are never cut off by the panel's height; "Applied" only on a runtime acknowledgement |
| Build state | the toolbar shows, for the revision this scene's last save produced, the workspace service's own state in plain words (`toolbar.build-state`: Editing… / Building… / Checking… / Ready / Failed — file:line — message; a click opens the feed row), the last Ready build while the newest is Failed (`toolbar.last-ready`), and **Play last Ready** (`toolbar.play-last-ready`) to play the artifact this editor prepared before the failed one, without adopting it (Stop puts the current one back); `Content <id>` names the artifact Play loads. With no service: "No workspace service: builds are not checked here" |
| Prompts | modal: **Unsaved changes** ("Save changes to <files>?", Yes / No / Cancel, `prompt.*`) when closing with unsaved work or opening another scene over it; **Restore unsaved work** (`recovery.*`) when a project opens with autosaves the last session left; **Unsaved changes** before Play (Save and Play / Discard and Play / Cancel, `play.*`); **The scene cannot be saved over as it is** (Overwrite / Reload / Cancel, `conflict.*`) when a save finds the file changed, missing or unreadable; **Import** (`import.*`), what a model file measured and what the declared settings will make of it; **Export** (`export.*`), the revision that would be exported, its branding read-only, the destination, progress, Cancel and what the last export wrote |
| Drawer | over the viewport, above the status bar, tabs **Activity** / **Jobs** / **Content** / **Output Log** (`drawer.tab.<tab>`, also drawn as `feed.tab`, `jobs.tab`, `content.tab` and `log.tab`; the Output Log is [its own section](#the-output-log)), a grab handle that sizes it (`drawer.splitter`) and a Hide/Show button (`drawer.toggle`) leaving the tab strip; pressing the tab already showing closes it too, and so does the status bar's button for it. **Jobs** holds what used to be a column of its own, as a grid of cards that fills the width, newest first: each job's state and phase, resource class and reported progress ("progress not reported" only while it could still be reported); stale marking against the service's `currentArtifact`; why it is incomplete; Cancel through the service with its reason when it cannot, and no Cancel at all on a job that has finished; its screenshots |

**Help ▸ Report a bug… and Request a feature…** (`menu.help.report-bug`, `menu.help.feature`; `crate::report`). Report a bug opens the Launcher's own report, not a second kind: the same `argus_usage::reports::Report`, the same form (category Bug / Feature request / Question, a title of up to 120 characters, a description, and **Attach logs**, off until ticked, with the warning "Logs can contain file names and paths from your project."), and the same **Review and send…** screen, which shows the report's JSON and the log text exactly as they will go (`report.review.fields`, `report.review.logs`). Always included: the engine version (this editor's), the launcher version (`0.0.0`: the editor cannot ask the Launcher) and the OS class and build. The editor's log bundle begins with its version, the open project's **name** and where the editor's log is, then the last 2,000 lines of the editor's log (`--log-file`), the workspace service's (`.argus/editor/service.log`) and the Launcher's (`argus-hub.log`), each only if it exists, with the profile folder replaced by `%USERPROFILE%`. Project files are never attached. **Only Send writes anything**: Cancel, Back, the window's × and Escape (once no field has the keyboard) save nothing. Send writes the reviewed report to the reports outbox (`%LOCALAPPDATA%\Argus\reports\outbox\<id>\`, `argus_usage::reports::save`) and starts `argus-hub --send-reports` from the Launcher's install, which sends it; the editor makes no network call. With no Launcher to start, the dialog says the report waits in the outbox for the Launcher's next run; on a managed or offline install it is saved for email to feedback@cadarn.ai and nothing is started. The dialog is modal (`Shell::modal_open`) while it is open, so typing in it drives nothing else. There is no shortcut: a menu item is enough (Ctrl+Shift+F1 is the Launcher's). **Request a feature…** opens `https://cadarn.ai/talos/roadmap` (`report::ROADMAP_URL`), the testers' roadmap, in the system browser. Tests replace the outbox and the Launcher start (`Shell::set_outbox`) and the browser (`Shell::set_url_opener`), so no test writes the person's outbox, starts a program or opens a page (`tests/ed_help.rs`).

**Painting.** The game keeps drawing into its `RenderTarget`, at the viewport panel's size. Each frame `crate::paint::Painter` copies the target's colour into a texture registered with egui-wgpu (remade, with the same id, whenever the target's size changes), and paints every egui primitive, the viewport's image included, into the swapchain image through `argus_render::surface::Surface::present_with`, on the renderer's device. Nothing is read back to the CPU. A headless shell runs and tessellates the interface each frame and paints nothing.

**Input.** Every winit event goes to the interface's input (`crate::egui_input::EguiInput`, the editor's own translator, so tests and the window take one path) and to the shell's routing in the same call. A press on the game image gives input to the viewport and a press anywhere else to the interface (see [Focus](#focus)). While a text field has the keyboard, keys go to it and not to the game or the editor's shortcuts, and Esc leaves the field and does nothing else. The window's clipboard is egui-winit's; IME is allowed while a text field wants it.

**Bounded.** The feed draws only the rows its scroll area shows (`ScrollArea::show_rows` over the store's per-filter index); failures for the selected entity are gathered once per selection and feed position. With 100,000 events folded into the feed, a frame draws at most 13 feed rows in the gate's layout (`tests/ui_bound.rs`).

**Where things are.** Each control a person clicks is recorded by name with its rectangle (`EditorUi::hit`: `toolbar.play`, `menu.edit.undo`, `drawer.tab.jobs`, `feed.row.<seq>`, `feed.link.evidence.<sha256>`, `entity.<id>`, `settings./application/displayName`, `export.ok`, `viewport`, and so on), so the scripted-human gate presses exactly what a person would. The interface pass renamed nothing: every name the suites pressed before it is still drawn, and the new surfaces took new names — `menu.file`, `menu.file.<item>`, `menu.edit.<item>`, `menu.window.<item>`, `menu.help.<item>`, `toolbar.settings`, `outliner`, `details`, `drawer.tab.<tab>`, `drawer.toggle`, `drawer.splitter`, `feed.tab`, `jobs.tab`, `content.tab`, `settings.page`, `settings.search`, `settings.category.<slug>`, `settings.section.<slug>`, `settings.revert`, `settings.close`, `settings.error`, `settings.icon.browse`, `settings.icon.preview`, `settings.icon.error`, `settings.icon.none`, `about.window`, `about.version`, `about.build` and `about.docs`.

The fix pass renamed nothing either. It **moved** the status items out of the toolbar into the status bar, where they keep the names they had (`toolbar.save-status`, `toolbar.artifact`, `toolbar.pending`, `toolbar.live`, `toolbar.freeze`, `toolbar.service`, `toolbar.build-state`, and in the notices row `toolbar.disk-changed`, `toolbar.reload`, `toolbar.last-ready`, `toolbar.play-last-ready`, `toolbar.unverified`, `toolbar.error`) — the name is where a gate presses, not where the control lives. The names it added are `status.drawer.content`, `status.drawer.activity`, `status.drawer.jobs`, `toolbar.project`, `outliner.search`, `viewport.overlay`, `viewport.selection`, `settings.section.<slug>.note`, `settings.empty` and `settings.unapplied`.

MaPresence renamed nothing. The Live and Freeze placeholders became the Live updates switch, which is drawn under `status.live-updates` and under both old names. It added `status.agents`, `roster`, `roster.<agent>`, `roster.<agent>.name`, `roster.<agent>.pause`, `roster.<agent>.revert-since`, `roster.<agent>.revert-since.{1m,5m,15m,all}`, `roster.<agent>.changes`, `toolbar.updates`, `toolbar.refresh`, `entity.<id>.agent.<agent>` (the initial on a marked Outliner row), `viewport.tag.<id>`, `feed.filter.agent`, `feed.filter.agent.all`, `feed.filter.agent.<agent>`, `feed.revert.<seq>`, `feed.diff.<seq>` and `feed.apply.<seq>`. `<agent>` is the agent's actor id.

**Marks are painted, not typed.** The font egui bundles has no glyph for a good many symbols, and a character it lacks is drawn as *nothing at all* — no box, no warning, no failing test. Six marks the editor drew were invisible for that reason: `◧` and `▤` on Content tiles, `▾` and `▸` on the folder tree, `✓` in the File menu and `●` for the open scene, and the drawer's toggle was an empty button for the same reason. The toolbar's transport, the Content tiles' role marks and the folder tree's disclosure triangles are shapes now (`ui::Mark`, `content_browser::Role::mark`); `theme::TEXT_MARKS` is the list of characters the interface still draws as text, and `theme::tests::every_mark_has_a_glyph_in_the_bundled_font` asks the bundled font about each of them.

## The shell

The window is laid out the way the usual editor's window is, in this order (`crate::ui::draw`):

| Claim | Panel | Size |
|---|---|---|
| 1 | `menubar`, a top panel | the row's height, not resizable |
| 2 | `toolbar`, a top panel | one row of controls, not resizable |
| 3 | `status`, a bottom panel | one row, plus a wrapped notices row when there is something to say; claimed before the right column, so it runs the width of the window as the usual editor's bottom bar does |
| 4 | `details`, a right panel | 320 pt, capped at 40 % of the width, holding `outliner` over the inspector; claimed before the drawer, so it runs the full height |
| 5 | `drawer`, a bottom panel | `UiState::drawer_height`, capped at 45 % of what is left, spanning the viewport rather than the window |
| 6 | the viewport, the central panel | the rest |

The order matters. The right column is claimed before the drawer, so it runs from the toolbar to the bottom of the window and the drawer spans only the viewport beside it — which is both what the usual editor does and what the inspector needs, because **a control scrolled out of a panel is drawn, and named, and cannot be pressed**. A shorter Details panel is not a cosmetic change; it silently puts the property rows out of reach.

**The left edge belongs to the viewport.** Before this change one 280-point left column held the scene tree, the inspector, the asset section and Project Settings in a single scroll, and the jobs held 300 points of their own on the right; Project Settings was below the fold in both screenshots the design review came from. The tree and the inspector are the right column now, Project Settings is a page, and the jobs are a tab.

**The toolbar is laid out, not wrapped.** Its three groups sit at computed rectangles — the centre takes `TOOLBAR_CENTRE_WIDTH` and the two sides split what is left — so Play stays in the middle of the window whatever else changes. A wrapped row moved it every time a build state grew a word. The status bar is laid out the same way, its right-hand group capped at `STATUS_RIGHT_WIDTH` so the drawer buttons on the left always have room at 150 % display scaling; the notices above it *are* wrapped, so they can never reach past the window however much they have to say.

**The viewport fills its panel** (ViewportD6). The view is no longer drawn at the size the project was opened at and letterboxed into the panel: the panel's size in physical pixels drives the offscreen target, so the frame is rendered at the size it is shown at and framed for the shape it is shown in. Each frame `Shell::frame_with_dt` measures the rect `ui::viewport` drew the image in and hands it to `preview::follow_panel`, which clamps it to 64..=4096 a side and asks the world the viewport shows — the authoring view's, or Play's while playing, because the editor runs Play inside the editor process, on the one renderer it moves between the two worlds. `World::request_target` allocates a new `RenderTarget` once that size has held for 150 ms, and `World::resize_target` is what replaces it: the four attachments, the two bind groups that sample them and the readback buffer, and nothing else — the scene, the entity store, the mesh and texture caches, the physics world and the renderer all stay as they were. The camera follows with no help, because `World::submit` reads its aspect from the target every frame. `paint::Painter` remakes the egui texture the image is sampled from when it sees a new target size, keeping its `TextureId`, so a resize costs one texture rather than a leaked one per resize — and while the panel and the target disagree, for the settle after a drag or the first frame of a Play, the last frame is stretched over the panel rather than the copy being skipped and the image freezing. **Why it is debounced:** a resize allocates roughly 24 bytes a pixel, and a window or a splitter being dragged reports a new size every frame; nothing is asked for at all while the pointer is holding something, so a drag of any length costs one reallocation, at the end. `crates/app/tests/d6_resize_target.rs` holds the engine half (the world survives, the aspect follows, the clamp, the debounce, fifty resizes and the memory); `crates/editor/tests/d6_viewport.rs` holds the editor's (the target follows the panel and not the window, picking is exact before and after a resize, the thumbnail and F9 are written at the target's size).

**The drawer is sized from editor state, not from egui's panel memory** (`UiState::drawer_height`), so closing it and opening it again gives back the height it had. Its height settles on the frame after the toggle, because the panel's size is decided before the strip inside it is drawn. A drag on its grab handle (`drawer.splitter`, which shows the resize cursor) is kept per user in `layout.json` (`drawerHeight`, below), inside the same limits the drawer is drawn in; a double-click on the handle puts the default 240 points back.

**The right column and the Outliner resize, and the person's sizes are kept** (`crate::layout`), as the usual editor's panels are. Two splitters, The usual editor's: `panel.splitter`, a strip just inside the column's left edge (so a press on it never picks in the viewport), sets the column's width, and `outliner.splitter`, over the edge between the Outliner and the Details, sets the Outliner's height. Hovered or dragged, a splitter shows the resize cursor and an accent line; a drag follows the pointer exactly from where it was pressed; a **double-click puts that size back to its default**. The limits keep both sides usable at every display scale: the column is at least 160 points and leaves the viewport at least 360; the Outliner is at least 48 points (its header, the search row and a row) and leaves the Details at least 120. A size the person has not set is the default, which follows the window (the column 320 points or 40 % of the window, whichever is less; the Outliner a third of the column, up to 200 points), so the default layout is exactly what it was. **Per user, not per project**, beside the keymap: `<config dir>/argus/editor/layout.json` (`ARGUS_LAYOUT_FILE` overrides it for scripted runs), as `{ "panelWidth": 380, "outlinerHeight": 260, "drawerHeight": 300, "outlinerSort": "type" }`; a size left out or `null` is the default. It also holds `contentFilter`, the Content Browser's type filter (`["model", "prefab"]`; left out is every type, and a word the editor does not know is dropped when the file is read). It also holds `outlinerCollapsed`: the Outliner folders the person closed, per scene (keyed `<project folder>#<scene path>`, at most 64 scenes, the least recently changed dropped first); a folder not listed is open. It is written once when a drag ends, and when the sort or a folder's caret changes. Only the window's editor reads and writes it (`crate::app` calls `Shell::set_layout_store(LayoutStore::user())`); a `Shell` built directly — every test, a headless gate — keeps the defaults in memory unless handed a store. A file that does not read is the default layout with the reason on stderr; a size that is not a positive number keeps its default; a field it does not know is ignored. `/v1/state` has `view.layout`: `{panelWidth, outlinerHeight, drawerHeight}` as drawn last frame (points; `outlinerHeight` is `null` unless the Outliner and the Details share the column, both are `null` while it is closed, and `drawerHeight` is `null` while the drawer is closed) and `outlinerSort`; `/v1/do {"do":"set-view","layout":{…}}` (and `editor.set {layout}`) sets any of them, a size to `null` for its default. With no editor running, `editor.state` answers from the project's `state.json` and has no `view.layout`: the layout is the person's, not the project's.

**A model dragged out of the Content drawer and let go on the viewport is placed in the open scene** (`content_browser::dropped_model` on the viewport's response, which answers only for the widget the pointer is over on the frame the button comes up), **where it was let go**, as the usual editor places a dragged asset: on the surface under the cursor (the authoring view's own pick, `Editor::surface_at`), else on the ground plane y = 0 along the cursor's ray, else at the origin. One function, `content_browser::drop_point`, places a dropped model and a dropped prefab alike. Place and a double-click still put it at the origin. The drop handlers ask for their payload's type before taking it: egui's `take_payload` takes whatever is carried, so a model check on a prefab's release used to drop it. Place is a button in the drawer as well, so nothing the browser does is reachable only by dragging. The two halves are asserted separately: that a model tile carries its path while it is dragged in `tests/d6_content.rs`, and that a release over the game image puts the model in the scene in `tests/d6_shell.rs`.

**Project Settings blocks the viewport, not the shell.** The page is an `egui::Area` over the panels, so `EditorUi::over_viewport` stops giving the game the pointer while it is open, and the menu bar and the toolbar above it stay live — there is always a way back out. The prompts and the menus are `Modal`s and popups, which sit above it.

**A hit name is a control a person can press.** Every name the interface publishes carries `Response::interact_rect` — the widget's rectangle clipped to what is actually on screen — and a widget whose clipped rectangle is empty is not published at all, because egui refuses to interact with a clipped widget. The panels under the Project Settings page publish nothing while it is up, for the same reason: they are still drawn, because leaving the page has to give back exactly what was there, but a click on one of them lands on the page. A modal prompt is the same rule: `egui::Modal` blocks input to everything beneath it, so while one is up the map holds the prompt's controls and nothing else — the suppression is applied where the prompts draw, not decided from a flag before them, so a prompt added later is covered without anything to keep in step. The design review measured what the old rule cost: 178 of 200 Outliner rows and the 300th content tile were published as controls, and pressing them did nothing at all; the re-review measured 51 more under the Play prompt.

**A skipped test says so, and a gate can forbid skipping.** Some of `tests/d6_content.rs` and of the
design review probes need `packages/gameplay/dist` and an esbuild binary to open a project whose scene
carries behaviours. Without them those tests print `SKIPPED <test>: <reason>` and return — which
cargo reports as `ok` and shows only under `--nocapture`, so a whole file can be green without
having run, which is what the design review hit. `ARGUS_REQUIRE_TOOLING=1` makes a skip a failure
instead, and every gate and CI run should set it. The other half of the answer is needing the
toolchain in fewer places: a test whose project has no behaviours builds one and runs anywhere, so
8 of the 16 tests in `d6_content.rs` need it rather than 13 of 14.

**Everything fits the window the editor promises, at the scales a person has.** `crates/editor/tests/d6_shell.rs` asserts that every rectangle the interface draws is inside a 1280x720 window at 100, 125 and 150 % — 150 % of that window is 853x480 points — with and without the settings page. `tests/d1_workflow.rs` and `tests/d3_review_probes.rs` hold the same line for the workflow's and the importer's controls at 150 %.

## The theme

`crate::theme` is one `Visuals` and one `Style` for the whole product: the launcher and the editor each call `theme::apply` on their context, and neither carries a colour of its own. Before this change only the launcher was themed — the editor ran in stock egui dark — and the two halves disagreed about red (`#e15555` against `#eb6464`).

The values approximate a familiar dark editor theme: window `#1A1A1A`, panels `#242424`, the header strip and the menu bar `#1C1C1C`, borders `#383838`, recessed fields `#1E1E1E`, buttons `#3C3C3C`, text `#DCDCDC` over `#9E9E9E` over `#6B6B6B`, accent blue `#0E6BB8`–`#2C7FE8`, 12.5 pt body text, 20 pt rows and 3 px radii. Epic publish no swatch list, so **every value is meant to be adjusted**: open the editor you are matching, eyedrop the surface, and change the one constant. Nothing else in the editor holds a colour.

Separately from the chrome, eight constants carry meaning rather than surface — `ERROR`, `WARNING`, `OK`, `ACCENT`, `LINK`, `PLAY`, `STOP` and `SELECTION` (the selection orange familiar from other editors, which is also the colour the viewport marks the selected entity in).

**Every colour the editor draws words in reads on every surface it draws them on**, which is the design review's finding: the theme's own test checked the panel alone, while the same words are drawn on a button, in a field and on the header strip — where `ERROR` was 3.21:1, `OK` 3.97:1, the hyperlink colour 2.79:1 and disabled text 2.07:1. So `ERROR` is a coral rather than a red (a red dark enough to look like the usual editor's `#ED1B34` cannot reach 4.5:1 on a `#3C3C3C` button at all), `OK` and `SELECTION` are one step lighter than the usual editor's, disabled text is lifted to clear 3:1 and still sits below muted, and the accent — right for a selection fill, a focus ring and an active tab's underline, and not readable as a word — keeps its value while the one place it would be a word, a link, has its own `LINK`. `STOP` and `PLAY` keep the usual editor's values because they are shapes, not words. The unit test now checks every colour against `PANEL`, `INPUT`, `HEADER` and `CONTROL`, and the surfaces to their order, so an eyedropper pass cannot quietly flatten the shell or make a failure unreadable.

the person may choose the **accent** (blue, teal or violet) and the body **text size** in [Editor Preferences](#editor-preferences) ▸ Appearance. `theme::apply_appearance` puts them on the context, and whatever the painter draws in the accent reads it through `theme::accent()` and `theme::accent_deep()` rather than the `ACCENT` constants, which stay the usual editor's blue (the launcher's). The shell stays dark: a light theme would need every surface and text constant above to become a lookup.

## Windows build

`argus-editor.exe` is a **GUI-subsystem** program (`windows_subsystem = "windows"`): opened from Explorer or a shortcut it opens no console, and so no console window takes the foreground.

- Whenever it has no usable standard error, its messages go to `--log-file`, by default `%LOCALAPPDATA%\argus\logs\argus-editor.log` (moved aside to `.log.1` past 8 MB). On any OS, `--log-file PATH` sends them there explicitly.
- `--console` attaches to the console it was started from, or opens one, and points the standard handles its launcher did not redirect at it.
- The `worker` subcommand keeps the pipes its launcher gave it; the editor starts workers with `CREATE_NO_WINDOW`.
- The project is opened before the window, so a refused project exits with its reason in the log and never shows a window.

## Simulating the human in tests

The gates play the human **at the winit event boundary**, with no OS-level input synthesis and nothing taking focus. `argus_editor::test_inject` is clearly labelled test-only: it builds winit's own values (`KeyCode`, the logical `Key` and the text a key types, `ElementState`, `MouseButton`, `MouseScrollDelta`, `Ime`, `ModifiersState`) and calls `Shell::on_keyboard`, `on_mouse_button`, `on_cursor_moved`, `on_cursor_left`, `on_mouse_motion`, `on_wheel`, `on_focus`, `on_modifiers`, `on_ime` and `on_scale_factor` — exactly the calls the real event loop (`shell::App::window_event`) makes. Everything from there on is the production path, the interface's input included. `test_inject::click(shell, name)` moves the cursor to the centre of the control the interface drew under `name` and presses and releases it; `type_text` types characters.

`crates/editor/tests/e2_human.rs` is the scripted-human gate: through those calls alone, a person watches a change arrive, inspects a failure, opens its screenshot, selects the affected entity, edits a Project Setting through the settings seam and resumes Play.

For a real windowed run, `argus-editor <project> --test-inject-script <file>` replays the same events inside a real window at rendered frame numbers. A scripted run needs no focus, so its window is created non-activating (`with_active(false)`; honoured on Windows and macOS, ignored by X11 and Wayland) and never grabs the cursor. Its events are: one event per line, `<frame> key <KeyCode> down|up`, `button left|right|middle down|up`, `cursor X Y`, `motion DX DY`, `wheel LINES`, `focus in|out`, `click <control>`, `hover <control>`, `type <text>`, `modifiers none|ctrl|shift|alt[,…]`, `scale FACTOR`, `resize W H`, `import <path>`, `close`.

**A scripted step that cannot be applied is named and the run fails.** A `click` or a `hover` on a control that is not drawn is refused — the message names what *is* drawn under the same prefix, not every control on screen — and the runner logs `MISSED frame <n>: <event> was not applied: <why>` and ends the run non-zero with every miss listed. The script carries on after one, as a person does after a misclick, but a walkthrough can no longer pass having quietly done nothing: that is how a run clicked a Content tree folder row inside a collapsed parent, never selected the folder, and still reported success. A control that is still drawn but has *moved* between the aim and the press is the silent half of the same problem, so a press is re-aimed at the name the script took aim at, on the frame it is delivered.

`hover <control>` rests the pointer on a control and leaves it there, which is how a script reaches anything inside a submenu: `scene.open.<path>` is drawn only while File ▸ Open scene is open, so a click alone can never press it. Three lines, with frames between them, walk it:

```text
10 click menu.file
14 hover scene.open
20 click scene.open.scenes/arena.scene.json
```

What neither covers is winit and the OS themselves: key repeat, the order a platform delivers events in, focus the OS decides to move.

## Known limits

- **Describing a screen needs `@argus/ui`.** The packed SDK carries it at `sdk/packages/ui` and a checkout answers for a project made anywhere, because the directory the editor was started in is one of the three roots discovery walks; `ARGUS_UI_PACKAGE` overrides both. A missing package is the reason on the tile, not a blank one.
- **A project that names no default theme gets a path sort for everything after `ui/theme/tokens.css`**, which for the design system's layer is not the order `docs/UI.md` §10 fixes — `game.css` sorts before `sim.css`. Setting `settings.ui.defaultTheme` in `argus.project.json` is how a project chooses; the editor no longer second-guesses it, so a project that has not chosen previews exactly as it plays.
- **A translation edit fires no hot reload.** `ui/locales/**` has no `UiRole`, so nothing classifies it as a UI input on its own. The tables are re-read on any sync that describes and on a `ui/scenarios/**` change, which covers editing a string and previewing again, but not editing one while a preview sits idle.
- **A UI preview is an authoring activity.** It draws on the authoring world, so it shows nothing while Play is running, and the screen it puts up is popped when it closes.
- **The target follows the panel, within a frame or two.** It is resized 150 ms after the panel settles, and clamped to 64..=4096 a side, so a panel dragged shut renders 64×64 and a panel on a wall of monitors renders 4096 wide and is scaled up to fill.
- **Agents appear only when a presence source is attached.** Until the service and the agent wire `GET /v1/presence` and the journal into a `PresenceSource`, the chip says "0 agents". The switch and the queue work, but nothing lands in them. Viewport name tags are drawn in authoring only, because Play's camera is the game's. Refresh (restarts Play) reports that a restart is needed; the restart itself is the editor core's. **Cancel** works through the workspace
  service; it is disabled, with the reason, for a job that has already finished and when no runner is polling.
- **Rotation is edited as degrees about X, Y, Z** and stored as the quaternion they make (rounded to 1e-6), so a rotation authored by hand as a quaternion shows as the nearest Euler angles, and typing the shown values back may change the stored quaternion by rounding.
- **A save rewrites the file in the editor's form**: pretty-printed JSON, 2-space indent, every array element on its own line, floats as `2.0`. A scene file kept in another layout gets a large first diff.
- **Undo is in memory** and does not survive a restart or a Reload; unsaved changes are lost when the editor crashes.
- **Children are not duplicated with their parent**, and an entity's children go with it when it is deleted.
- **A project whose `startupScene` names a file that is not there does not open.** The manifest is the project's definition and the contract refuses it, as it does for the builder, the exporter and the workspace service; the editor does not repair someone's manifest unasked. The refusal lists the scenes that *are* in the project and says which line to change. An offer in the launcher to set one of them and open is the real fix and is a change of its own.
- **The interface keeps little layout across restarts.** The right column's width, the Outliner's height and sort, and the drawer's height are kept per user (`layout.json`, above); which drawer tab shows, the open feed row and the selected filter live for the run. The three splitters show the resize cursor.
- **The interface in a real window has not been run by a gate.** The headless gates run and tessellate it; painting through `present_with` needs a window, which needs the coordinator's OK.
- **Play worlds' renderer resources are not released.** `argus_render::Renderer` can give a mesh back — `free_mesh`, which `World::reconcile` calls on the meshes an asset swap evicts — but a texture has no unregister API at all, and nothing walks a finished Play world's caches on the way out. So each Play of an asset-heavy scene uploads its glTF meshes and textures onto the shared renderer again and they stay, and a long session of many Plays grows GPU memory until the editor restarts. Built-in primitives are shared and are never freed. A session of many **reimports** no longer grows it: see [Limits](#limits) under Import.
- **A Play that fails to load after taking the renderer loses it.** The Play world is loaded from the same frozen document the authoring world already loaded, and the query server binds ephemeral ports, so this is not expected; if it happens the editor reports it and must be restarted.
- **Scripts and assets are not bundled into artifacts.** The stale checks refuse a changed one instead. Built-in gameplay modules (`packages/gameplay/dist`) are not revision inputs.
- **The engine identity is the running executable.** In the test suite that is the test binary, not `argus-editor`.
- **`camera`, `reload` and `stop` have no API endpoints.** Their capabilities are declared and enforced by declaration; a worker stops when killed or when its editor goes. The editor's F5/F9 equivalents are the human's in-process controls.
- **Behaviours that branch on the calendar or on sub-tick wall time are not reproducible** (`Date` starts at the run's wall-clock start; `performance.now()` is wall time within a tick and simulation time across ticks), and `Math.random` repeats across runs unless `--seed` changes: see [World seed](#world-seed).
- **Content comes from the authored tree**, so the service marks every editor session unverified, until Play and test sessions run revision snapshots; see [Content roots](#content-roots).
- **Session authority is decided when the editor opens.** A service that starts later does not take the sessions over for that run.
- **A service that hangs, rather than exits,** holds the link for up to the service client's 120 s request timeout; frames are unaffected, but the feed and records wait.
- **One editor per project** is assumed: `state.json` and `sessions.json` are not locked.
- **A Windows-native editor cannot open a project under `\\wsl.localhost`**; run the editor in WSL for a WSL project.
- **Windows TypeScript scenes**: a Windows engine against a project on NTFS needs a Windows esbuild on `PATH` or `ARGUS_ESBUILD`. The gate scenes use a plain `.js` behaviour and need no esbuild.

