# Feedback Console Contract

**Status:** Current V1 Studio contract
**Owner surface:** `fixthis-mcp`

## Canonical Labels

| Surface | DOM id | Label |
| --- | --- | --- |
| Prompt copy | `copyPromptButton` | `Copy Prompt` |
| Agent handoff | `sendAgentButton` | `Save to MCP` |
| Canvas select tool | `selectToolButton` | `Select` |
| Canvas annotate tool | `annotateToolButton` | `Annotate` |
| Pending target secondary action | `inspectorFooter[data-editor-state="pendingTarget"] [data-action="cancel"]` | `Cancel` |
| Pending target primary action | `inspectorFooter[data-editor-state="pendingTarget"] [data-action="addAnnotation"]` | `Add annotation` |
| Saved annotation destructive action | `inspectorFooter[data-editor-state="saved"] [data-action="delete"]` | `Delete annotation` |
| Refresh devices | `refreshDevicesButton` | `Refresh devices` |
| Clear FixThis device selection | `disconnectDeviceButton` | `x` icon |
| Workflow progress | `workflowProgress` | `FixThis feedback workflow` |
| Prompt readiness | `promptReadiness` | `Prompt readiness` |
| Compact history drawer | `historyToggleButton` | `History` |
| Preview frame state | `previewFrameStatus` | `Live preview` / `Frozen for annotation` / `Saved screen` / `Stale frame` / `No screenshot` / `Interaction blocked` |

## Mode Semantics

- Select mode is the normal preview mode. Preview clicks navigate the debug app when the bridge is ready.
- Annotate mode freezes the latest preview so the user can select Compose nodes or draw visual areas.
- Stale preview state keeps the last preview visible while live bridge actions are disabled.
- The workflow progress surface uses `data-workflow-step` values `connect`,
  `preview`, `annotate`, and `handoff`, with `data-state` values `complete`,
  `active`, and `upcoming`. It must remain visible whenever the console shell
  is visible.
- The prompt readiness surface stays visible near the handoff controls even
  when `Copy Prompt` and `Save to MCP` are disabled. It must explain the empty,
  draft-only, ready-to-copy, and ready-to-save states without requiring a toast
  or failed click.
- The preview frame state badge stays attached to the preview stage and must
  use one of the canonical labels above. `Interaction blocked` takes precedence
  over stale or frozen labels; `Stale frame` takes precedence over a live frame
  when bridge actions are disabled but the last image remains visible.
- Draft/history view shows persisted local feedback groups and sent handoff history.
- On compact layouts below the desktop history breakpoint, history remains
  reachable from the top-bar `History` control. Opening the drawer must expose
  the same saved evidence groups as the desktop history pane and support
  dismissal without changing the active session.
- The global status surface (`#error.global-status`) displays console-level
  error, warning, success, and info messages. Long bridge errors, partial
  handoff failures, and recovery diagnostics must wrap inside the viewport.
- Saved annotation rows render `draft`, `sent`, `sent_modified`,
  `in_progress`, `needs_clarification`, `wont_fix`, and `resolved` as distinct
  lifecycle phases. `needs_clarification` remains editable; `wont_fix` and
  `resolved` are terminal agent outcomes. Agent notes and summaries appear in
  the saved annotation detail.

## Persistence Semantics

- `Annotate` starts targeting and freezes the latest available preview. It does not write a session item by itself.
- Clicking a Compose node or dragging a visual area while a draft is frozen creates a browser-side pending annotation and focuses its detail editor immediately.
- `Add annotation` is reserved for the transient pending-target state where a target exists but has not yet been committed; it must not appear while editing an existing draft annotation.
- `Copy Prompt` persists written pending annotations when needed, then copies compact agent-facing prompt text.
- `Save to MCP` persists written pending annotations when needed, then creates a local handoff batch for MCP tools.
- `Clear Draft` deletes unsent draft feedback after confirmation.
- Live preview frames are transient. Persisted `screens` are evidence snapshots, not every preview frame.
- Browser-only pending work is stored as a schema-v2 DraftWorkspace envelope
  under `localStorage["fixthis.workspace.<sessionId>.<workspaceId>"]`, with a
  per-session index at `localStorage["fixthis.workspace.index.<sessionId>"]`.
  The envelope carries `workspaceId`, `revision`, `lifecycle`, immutable
  `context`, frozen `screen`, `screenshotUrl`, `items`, and `history`.
  Recoverable draft items are current-schema items and must carry client draft
  identity (`workspaceId` plus per-item `draftItemId`) before they can be
  matched to persisted session items.
- v0.4 supports schema-v2 `fixthis.workspace.*` draft recovery only. Pre-v0.4
  `fixthis.pending.<sessionId>` mirrors are ignored; use `fixthis clean` or
  clear browser storage if an old local recovery entry is confusing the
  console.
- On browser reload or session reattach, recovered pending work is not exposed
  automatically. The console shows an explicit Recover / Recapture / Discard
  banner; Recover is available only when the frozen preview context is present.
- Mutating saved/draft item APIs and preview/screen artifact URLs must carry
  the session that created the item, preview, or screen. They must not fall
  back to "current active session" when an explicit session id is available.
- Server-sent `session-updated` and `preview-ready` events carry top-level
  `sessionId`. The browser applies them to detail/preview state only when that
  session is currently active, except for the initial `snapshot` event.
- Live preview delivery is push-first. `preview-ready` SSE events and fallback
  preview polling must route through the same preview-application path and the
  same active-session fence; polling is a recovery path while `/api/events` is
  disconnected, not a second source of durable truth.
- Deleting a feedback session clears browser-local draft recovery for that
  session, including schema-v2 workspace entries.
- `Copy Prompt` / `Save to MCP` can persist the subset of pending annotations
  that have written comments. `Copy Prompt` keeps pin-only residual annotations
  browser-local so the user can resume them later. `Save to MCP` completes the
  handoff and discards residual pin-only annotations instead of carrying them
  into local recovery or session history.
- Draft batch persistence is idempotent. The browser sends `workspaceId` and
  per-item `draftItemId` values to `/api/items/batch`; the server stores those
  values as `clientWorkspaceId` and `clientDraftItemId` and uses the pair as
  the primary duplicate key. A full duplicate save must be a no-op and must not
  append a new event-log entry. A partial retry must append only the new draft
  items and reuse the already-persisted evidence screen when any incoming item
  matches by client draft key.
- Current retry and browser recovery dedupe require client draft keys. Keyless
  browser-local recovery items are not semantically matched by target/comment;
  v0.4 drops them instead of promoting or double-counting unsupported
  pre-client-key local data.
- History row counts include persisted session items plus browser-local
  draft/recovery items for that session. Completing `Save to MCP` must clear the
  residual local draft state for that action so the history count does not
  double-count saved items plus stale recovery items.
- Async save, update, delete, undo/redo, and session refresh responses are
  fenced by the annotation context that issued them. If the user switches
  sessions while a mutation is in flight, stale responses are discarded or
  followed by a fresh session refresh before rendering.
- Persisted feedback item `sequenceNumber` values are stable and monotonic
  within a session. Deleting or resolving a saved item does not renumber
  existing saved overlays or compact handoff item numbers.
- Before persisting a pending batch, the console sends the frozen screen
  fingerprint to `/api/items/batch`. The server compares it with a lightweight
  current capture when both fingerprints exist. HTTP 409 with
  `error: "screen_fingerprint_mismatch"` is recoverable UI state: prompt for
  re-capture, force-save, or cancel.
- Browser request cancellation is normal local transport behavior. Console HTTP
  routes must use `ConsoleHttp` response helpers so client disconnects during
  response writes are closed quietly while unrelated server failures still
  surface as errors.
- SSE `/api/events` computes its initial snapshot before committing streaming
  headers. Once the stream is open, keep-alive or event write failures caused by
  client disconnect close the subscription quietly.

## Device Semantics

- The device-chip `x` clears only FixThis's active device selection and owned bridge resources.
- The device-chip `x` must not run `adb disconnect`, detach USB, or affect Wi-Fi ADB outside FixThis-owned resources.

## Studio workflow exception policy

Studio keeps the existing preview, connection card, annotation panel, and
handoff controls. Exception handling is centralized as a workflow policy:

- **Automatic recovery:** heartbeat retry, preview refresh, session polling
  resume, app foreground recovery, and blocked-reason clearing may happen
  without a confirmation dialog. These actions do not mutate draft, handoff,
  claim, or resolve state.
- **Confirmation-required mutations:** dirty-draft session switch, stale
  preview Save to MCP, activity drift during handoff, server draft conflict,
  and stale force-save require a boundary dialog before durable state changes.
- **Blocked mutations:** unsupported builds, unavailable devices, missing
  active sessions, closed sessions, missing items, and in-flight durable
  mutations disable or no-op the requested action.
- **Ignored stale responses:** late SSE, late preview poll, late save, and
  generation-mismatched responses do not mutate a newer session or workspace.

Connection loss never clears a draft workspace, sent handoff batch, claim
state, resolve state, or persisted evidence. Primary UI copy should describe
the safe next action, such as "Connection paused - draft preserved"; raw bridge
diagnostics remain in Details.

## Privacy Semantics

- `Save to MCP` stores a local handoff batch.
- Pending recovery envelopes remain browser-local until a handoff action
  persists written items into `.fixthis/feedback-sessions/`. `Copy Prompt` may
  leave residual pin-only recovery browser-local; `Save to MCP` clears residual
  pin-only draft state after the local handoff batch is created.
- FixThis does not upload screenshots, comments, prompt text, source hints, or target evidence by default.

## Compact handoff schema

Rule: source hints are candidates; verify screenshot, target, and code before editing.

### v2 prompt grammar (BNF-ish)

```
prompt        = header rule package_line source_root_line? quality_line? "" screen_block+ footer
header        = "# FixThis Feedback Handoff" ""
rule          = "Rule: source hints are candidates; verify screenshot, target, and code before editing." ""
package_line   = "- Package: `" pkg "`"
source_root_line = "- Source root: `" prefix "`"           ; emitted iff ≥2 distinct candidate paths share a directory-boundary prefix ≥10 chars
quality_line  = "Handoff quality: " quality_token (", " quality_token)*
screen_block  = screen_header screenshot_line? viewport_line? activity_line? "" (overlap_block | item_block)+
screen_header = "Screen " short_id ": " display_name
short_id      = first 8 chars of UUID
screenshot_line = "screenshot: " path
viewport_line   = "viewport: " width "×" height          ; emitted iff screenshot has dims
activity_line   = "activity: " activity_name             ; emitted iff != display_name
overlap_block = "Overlap group " N " (resolve one marker at a time):" item_block+
item_block    = item_header id_line target_summary_line target_line crop_line? edit_surface_block source_block reliability_block? ""
item_header   = "[" N "] " title                         ; title may include severity prefix
id_line       = "  id: " item_id
target_summary_line = "  target: " target_summary
target_line   = "  " [ "role=" role "  " ] [ "tag=" tag "  " ] "box=(" x1 "," y1 ")-(" x2 "," y2 ")"
                 [ "  instance " i "/" total ]
                 [ "; targetRisk=overlap" ]
                 [ "; targetRisk=duplicate-of-marker-" M ]
crop_line     = "crop: " path
edit_surface_block = edit_surface_line{0,2}
edit_surface_line  = "  editSurface: " kind [ "  role=" role ] " -> " file [ ":" line ] "  conf=" lvl "  why=[" terms "]"
role               = "call-site" | "component-definition" | "copy-or-data" | "layout-or-style" | "visual-area" | "interop-risk"
source_block  = candidate_line{1,3} caution_line?
candidate_line= "  " file ":" line "  conf=" lvl [ "  owner=" composable ] "  margin=" margin "  matched=[" terms "]"
                                                                          ↑ owner/margin/matched are first line only; runner-ups omit them
                                                                          ; file is stripped of source_root prefix when present
caution_line  = "  note: " text                          ; emitted iff caution OR collision
reliability_block = target_confidence_line warning_line*
target_confidence_line = "  targetConfidence=" ("high" | "medium" | "low" | "unknown")
warning_line   = "  warning: " text
footer         = "---"
                 "agent_protocol:"
                 "  before_work: fixthis_claim_feedback({sessionId, itemId})"
                 "  on_complete: fixthis_resolve_feedback({sessionId, itemId, status: resolved|wont_fix|needs_clarification, summary})"
                 "  user_console_reflects_within: 2s"
                 "session_id: " session_id
```

The `screenshot:` line is optional and omitted when no screenshot artifact is available for the screen.

The `crop:` line is optional and emitted only when a per-item screenshot crop path is available.

When no source candidates are available for the item, the source block consists of a single `  unknown` line.

- `N` — 1-based annotation number matching the numbered overlay marker.
- `short_id` — first 8 characters of the screen UUID.
- `viewport:` — screen dimensions in pixels; emitted only when the screenshot artifact has width and height metadata.
- `activity:` — Android activity name; emitted only when it differs from `display_name`.
- `Source root:` — directory-boundary common prefix of all candidate file paths in the session, with trailing `/`. Emitted only when at least two distinct candidate paths share a prefix of ≥10 chars; otherwise omitted and candidate paths are written verbatim. When emitted, the prefix is removed from every candidate line so each candidate appears as a relative path.
- `Handoff quality:` — optional aggregate warning summary for the rendered item
  set. It is emitted only when at least one low-confidence target, warning,
  overlap group, duplicate marker, visual area, redacted target, stale source
  candidate, or item without source candidates exists.
- `target:` — redaction-safe semantic target summary derived from the selected
  node when available. It may include `tag="..."`, `text="..."`,
  `contentDescription="..."`, and `role=...` in that order. For visual-area
  selections it renders `target: visual area`; for sensitive/editable/password
  targets it omits text-like values and renders `redacted sensitive target`.
- target line — emits only the tokens that have content. `role=` and `tag=` are dropped when the corresponding `selectedNode` field is blank; the line collapses to bare `box=` for area selections.
- `[!]` severity prefix — prepended to the title when item severity is `HIGH`; absent for `MED` and `LOW`.
- `id:` — feedback item id. Agents should use this with
  `fixthis_claim_feedback` before editing and `fixthis_resolve_feedback` after
  finishing.
- `agent_protocol:` — footer that repeats the queue contract inline for agents
  that only see copied Markdown.
- `session_id:` — feedback session id to pass with item ids when the agent needs
  to avoid relying on the active session default.
- `instance i/N` — emitted on the target line when multiple items on the same screen share the same `(top_candidate file:line, testTag)`; index assigned by `path`-leaf string sort order.
- `targetRisk=overlap` — present when the target participates in an overlap group (see below).
- `targetRisk=duplicate-of-marker-M` — present when this item is an exact duplicate of an earlier item (same source, testTag, path leaves, and bounds).
- `editSurface:` — optional inspection hint for visual/style/layout feedback.
  It is derived from the user comment, selected target, target owner, and source
  candidates. It does not replace source candidates and is not an auto-edit
  instruction.
- `editSurface role=` — optional role for the edit-surface hint:
  `call-site`, `component-definition`, `copy-or-data`, `layout-or-style`,
  `visual-area`, or `interop-risk`.
- `sourceCandidates` identify where selected or nearby strings came from.
  `editSurface` identifies where a style/layout change is likely rendered.
- candidate lines — up to 3 in score order. Rank 1 includes optional `owner=<Composable>` when the source index knows the enclosing `@Composable fun`, `margin=` (score gap to rank 2, formatted to 2 decimal places), and `matched=[...]` (up to 4 reason tokens). Runner-up lines include only `conf=`.
- `note:` — emitted when the rank-1 candidate has a `caution` field, or when multiple items in an instance group share the same call site (collision note on the first item only).
- Confidence is lowercase: `high`, `medium`, `low`, or `none`.
- `targetConfidence=` — optional target-level reliability. It describes how
  much the selected UI target can be trusted before editing; it is not task
  priority and is distinct from source-candidate `conf=`.
- `warning:` — optional target-level caveats, such as visual-area-only,
  possible AndroidView/WebView interop, stale source index, forced screen
  mismatch, missing fingerprint, or sensitive text redaction.

### Reason-token mapping

| Reason string | Token |
| --- | --- |
| `selected text` | `text` |
| `selected contentDescription` | `contentDescription` |
| `selected testTag` | `tag` |
| `selected testTag convention composable` | `compTag` |
| `selected role` | `role` |
| `selected resolved stringResource` | `resolvedStringRes` |
| `nearby text` | `nearbyText` |
| `nearby contentDescription` | `nearbyContentDescription` |
| `nearby testTag` | `nearbyTag` |
| `nearby role` | `nearbyRole` |
| `activity` | `activity` |
| `selected stringResource` | `stringRes` |
| `arbitrary literal` | `literal` |
| `legacy fallback` | `legacy` |

These tokens appear in `matched=[...]` on rank-1 candidate lines.

Precise and full Markdown handoffs render owner-aware source candidates as
`<file>:<line> inside fun <Composable>` when `ownerComposable` is present.

### Overlap groups

When two or more annotations on the same screen have targets that overlap (visual-area intersection IoSA >= 0.25, or weak-label center distance <= 24dp at default density 1.0), they are collected into an explicit overlap group:

```
Overlap group N (resolve one marker at a time):
```

Each item in the group carries `targetRisk=overlap` on its coordinate target line. Resolve the group one marker at a time to avoid editing the wrong composable. Coordinate space is window pixels at default density 1.0; the 24dp center-distance fallback is conservative on high-density screens.

### v1 → v2 token migration

Tool-using agents that parsed the v1 compact prompt format must update the following token patterns. The v2 format is strictly more verbose and uses different but equally parseable tokens; no information is lost.

| v1 token | v2 token | Rationale |
| --- | --- | --- |
| `src? <file>:<line> <conf>` (single line) | indented `<file>:<line>  conf=<lvl>` lines (up to 3, no header, no `~` prefix) | Two-space indent + `conf=` token is enough to distinguish candidate lines for an agent. The header and `~` prefix were boilerplate. Runner-up candidates are now visible. |
| `bounds=L,T - R,B` | `box=(L,T)-(R,B)` | Matches typical `(x,y)` notation. The earlier `[W×H]` size suffix was dropped — derivable from the box, and agents can compute it on demand. |
| `target: Node "tag"` | `[role=<role>  ][tag=<tag>  ]box=...` (with `role=`/`tag=` dropped when blank) | Drops unvarying `Node`/`Area` fallback role and the `tag=(none)` placeholder. Each token is keyed and only emitted when it carries information. |
| `why=<token>+<token>` | `matched=[<token>, <token>]` | Renamed for clarity: `matched` describes what evidence was found; `why` was ambiguous metadata. The same reason-token vocabulary is reused. |
| `risk=<token>` (on source line) | `targetRisk=<token>` (on the target coordinate line) and `note: <text>` (after candidate lines) | Splits target-level risk (`overlap`, `duplicate-of-marker-N`) from source-level caution; v1 conflated them on one line. |
| Screen UUID `<full-uuid>` | `<first-8-chars>` (short-id) | Reduces visual noise; 8 hex chars are unique enough for disambiguation within a session. |
| (absent) | `viewport: W×H` | New: lets agents interpret pixel bounds without opening the screenshot. |
| (absent) | `activity: <name>` | New: emitted when Android activity name differs from `displayName`. |
| (absent) | `Handoff quality: ...` | New: summarizes aggregate target/source warning signals for the rendered item set. |
| (absent) | `target: ...` | New: gives a redaction-safe semantic summary of the selected UI target before source candidates. |
| (absent) | `instance i/N` on the target coordinate line | New: disambiguates list-rendered widgets that share a call site. |
| (absent) | `note: N markers map to same call site — likely list-rendered; disambiguate by instance index` | New: collision signal on the first item of each instance group. |
| (absent) | `targetRisk=duplicate-of-marker-M` | New: surfaces true marker duplication so agents do not double-resolve. |
| (absent) | `- Source root: \`<prefix>\`` header + relative candidate paths | New (2026-05-10 trim): hoist the directory-boundary common prefix of all candidate paths once, strip from each candidate line. Net token saving on long monorepo paths; absent for sessions whose candidates do not share a prefix. |

## Console state model

### Canonical Runtime State

The browser console uses one canonical `ConsoleAppState`. DOM events and
network responses dispatch commands/events into a reducer. The reducer returns
the next state plus effect descriptions; browser adapters execute those effects
and dispatch fenced results back into the store.

Renderers consume selector view models only. They do not mutate session,
preview, draft, tool, polling, or prompt state. Draft work is represented by
`workspace.kind = "draft"` and is locked to an immutable session/preview/screen
context until saved, moved to recovery, or discarded.

Browser-internal legacy state holders such as `activeDraftFlow`,
`draftFeedbackItems`, `focusedPendingItemIndex`, and `currentSelection` are not
supported. This does not change MCP tool contracts, HTTP route payloads,
persisted feedback-session JSON, or local draft storage migration
compatibility.

Cross-session navigation with unsaved draft work creates `pendingBoundary`; the
boundary sheet is the only UI path that can save, recover, discard, or cancel
that transition. The same boundary state renders as a centered modal on desktop
and a bottom sheet/full-width modal on mobile.

## Bundle pipeline

The console bundle is generated by `scripts/build-console-assets.mjs` from
the source files in `fixthis-mcp/src/main/console/`. Source order is a
topological sort over `// @requires` directives at the top of each file.
The build emits a minified `app.js` (≤ 225 KiB raw / ≤ 58 KiB gzip), an
external source map linked via `//# sourceMappingURL=app.js.map`, and a
`console-build-meta.json` sidecar that `FeedbackConsoleAssets.kt` inlines
into `window.FixThisConsoleConfig.buildMeta` at serve time.

Identifier minification is disabled (`minifyIdentifiers: false`) so
asset-contract tests grep for stable function names. A `CONTRACT_SYMBOLS`
list in the build script guards each test-asserted symbol against
accidental minifier elimination. Tests now read unbundled JS from
`fixthis-mcp/src/main/console/*.js` via `ConsoleSourceFixtures` (see
`fixthis-mcp/src/test/kotlin/io/github/beyondwin/fixthis/mcp/fixtures/ConsoleSourceFixtures.kt`)
rather than the minified `app.js`.

## v0.3 first-run trust follow-up — surfaces and contracts

### Readiness state catalog

- `CAPTURE_UNAVAILABLE` — emitted by the server when `/api/preview` or
  `/api/screenshot` returns 404 or the capture has only semantics with
  no image bytes. The response stays HTTP 200 with
  `payload.previewAvailable = false` and a `readiness` object built by
  `FirstRunReadiness.captureUnavailable(cause, details)`
  (`primaryAction: "Retry capture"`). Rendered by the console via the
  existing connection-card readiness slot
  (`#connectionReadiness`, populated by
  `applyPreviewReadinessToConnectionCard` in `preview.js`).

### NotificationCenter surfaces

| dedupeKey | surface | severity | Fired by |
| --- | --- | --- | --- |
| `reload_console_403` | banner | error | `surfaceReloadConsoleNotice(err)` in `state.js`, wired into `requestJson` catch in `api.js`. Triggered when `requestJson` throws a `ConsoleRequestError` with `action === 'reload_console'` (HTTP 403 from origin/token check). primaryAction "Reload console" calls `window.location.reload()`. |
| `clipboard_fallback` | banner | warning | `copyPrompt` in `prompt.js` when `copyTextToClipboard` rejects. Detail tells the user to copy manually. Suppresses the legacy `showError` toast and short-circuits the handoff/`copied = true` flow. |

### Draft save conflict (`/api/items/batch`)

The save endpoint now implements optimistic-etag concurrency:

- Successful save → `200 OK` plus header `ETag: "<rev>"` (monotonic per
  session, in-memory in `DraftSaveService.kt`).
- Subsequent save → must include `If-Match: "<previous-rev>"`. Missing
  or mismatched header → `412 Precondition Failed` with body
  `{ "state": "STALE_PREVIEW", "readiness": <FirstRunReadiness>, "serverDraft": <current draft snapshot> }`.
- Override sentinel — `If-Match: *` forces the save regardless of the
  server revision (used by the "Keep mine (overwrite)" boundary
  branch).

Console handles 412 by opening the new `staleDraftConflict` boundary
variant with three buttons:

- Primary "Keep mine (overwrite)" → resends the save with
  `If-Match: *`.
- Secondary "Use server's version" → discards local pending state and
  loads `serverDraft` into the workspace.
- Cancel → keeps the local draft (pending pins preserved).

No automatic three-way merge.

### Session-mismatch ignore

`sse.js` (`dropStaleSse(msg, state)`) and
`previewPoll.js` (`dropStalePreviewPoll(response, state)`) drop messages
and poll responses whose `sessionId` does not match
`state.session?.sessionId`. The drop emits a `console.warn` for
diagnostics only — no state mutation, no NotificationCenter
notification, no UI change. Tested by `scripts/sessionMismatchIgnore-test.mjs`.

### Bundle budgets

The console bundle budget is intentionally enforced by
`node scripts/build-console-assets.mjs --check`, but it carries working
headroom so small console changes do not fail on byte-level churn:

- Gzip budget — `58 KiB` (59,392 B).
- Raw budget — `225 KiB` (230,400 B).

The `state.js` hotspot budget in `ArchitectureHotspotBudgetTest` was
bumped from 440 → 470 lines to accept the
`surfaceReloadConsoleNotice` helper.
