Skip to content

refactor: structure rewrite — one owner per rule, topic namespaces, presentation-only renderer - #129

Merged
mottihoresh merged 28 commits into
mainfrom
refactor/structure
Oct 17, 2026
Merged

mottihoresh merged 28 commits into
mainfrom
refactor/structure

Conversation

@mottihoresh

@mottihoresh mottihoresh commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

The structure rewrite (#101), integrated on refactor/structure through 27 squash-merged PRs (#102–#128). Structure only. No new functionality and no intended behaviour change. Every behaviour fix waits on the fix track in #101 and lands on main after this merge.

Merge with a merge commit (not squash), so each step stays on main and can be bisected.

What changed

  • Process wall: src/main decides, src/renderer only presents, src/shared holds the per-topic IPC contracts, the generated firmware reference and the formatting helpers, and src/preload holds the bridge. Lint enforces the wall.
  • Topics: app, gateway, dcs, nodes, hid, hid/calibration, events, config. Each has an index.ts as its only door, and the allowed import directions are enforced.
  • Main is split into one owner per job:
    • GatewayConnection, DeviceLineTap, SerialTrafficMonitor
    • NodeHub (roster poll + Gateway health), MappingStore
    • HidService, HidCalibrationService (it hosts the calibration workflow)
    • EventJournal
    • DcsConnection, DcsRelay, StreamLog, SessionRecorder
    • SettingsService (one zustand store, handed to services)
    • AppServices does wiring only. DeviceLink and Session are gone.
  • The renderer decides nothing. The calibration workflow, the cockpit snapshot, and the link, relay, HID and settings facts are all pushed from main. Where screens disagreed before, each screen's version now sits side by side in its owner; unifying them is the fix track. Each topic has its own store, and app/ipc-adapter.ts is the only window.skyhawk reader.
  • Firmware values come only from npm run sync. That includes the 9 values refactor(firmware): name the comment-only protocol values so the client sync can read them (#346) OpenSkyhawk#347 named.
  • push:batch: each main turn reaches the renderer as one message and one commit.
  • ARCHITECTURE.md is the contributor guide.

How "no behaviour change" was checked, PR by PR

  • Each PR's tests passed with their assertions unchanged. The suite grew from 698 to 1131, including 35 component tests and the seam tests, and every new test was proven able to fail.
  • A side-effect trace of main (pushes, serial, files, timers, return values; up to 48 scenarios and about 11,800 events) matched base byte for byte.
  • A render comparison (up to about 2,050 DOM states across every component-test file plus extra flows) matched base in sequence.
  • A built-bundle boot probe (app events, IPC registration order, files written) matched base.
  • An async-IPC audit replayed every user action with IPC delayed. It found frames where part of the screen had updated and a related part hadn't; these were fixed (refactor(renderer): last renderer decisions move to main #125, refactor(app): one consistent frame per main update #127), and a re-audit found no new ones.

Lint, boundary lint, typecheck (3 projects) and build are clean, with every rule at error and 0 warnings.

Before merging

  • Hardware check on the real Gateway at 19cdcd9 (2026-10-17): Gateway connects with Health, board adopted, CAN state/TEC/REC/MCU temp shown, Replay with Drive plays a recorded capture to the Gateway (serial traffic confirmed), mode switching clean.
  • Live DCS check on the DCS computer: can follow after the merge.

After merging

R0.1 of the structure rewrite (#101). Tooling only: no production code,
UI or behaviour changes.

## What it adds

- **`.nvmrc`** = `24`. Consistent with `engines.node >=24` and
`node-version: 24` in CI.
- **`dependency-cruiser@18.5.0`** (exact), started from `depcruise
--init oneshot`. The default rule set is replaced by the boundary rules;
the scaffold's options are kept.
- **`.dependency-cruiser.cjs`**: the arch-phase0-v2 §5 rules, written
for the **target** layout (`src/main/<topic>/`,
`src/renderer/src/<topic>/`, `src/shared/<topic>/{index,ipc}.ts`, leaves
`src/shared/firmware/`, `src/shared/format/`,
`src/renderer/src/app/kit/`):
- process: `P1-renderer-not-main`, `P2-main-not-renderer`,
`P3-shared-is-a-leaf`, `P4-renderer-imports-contracts-only`,
`P6-electron-only-in-app`
- contracts and leaves: `C1-contracts-import-contracts`,
`L-firmware-leaf`, `L-format-leaf`, `L-kit-leaf`
- topic directions, one rule per topic and per process
(`topic-main-<t>`, `topic-renderer-<t>`): app→all; nodes→gateway,
events; dcs→gateway, events; hid→gateway; hid/calibration→hid, gateway;
gateway→events; config→nodes, hid/calibration; events→nothing. Every
topic may also import firmware, format, kit and the shared contracts.
- deep imports: `T2-main-no-deep-topic-import`,
`T2-renderer-no-deep-topic-import` (across topics, only
`<topic>/index.ts`)
- cycles: `C2-no-circular`, type-only imports included
(`tsPreCompilationDeps: true`)
- tests, `src/test-fixtures/` and `src/renderer/test/` are excluded. The
generated `src/main/reference/*.generated.ts` files stay in: they import
nothing, so they can't originate a violation, and excluding them would
hide edges *into* them.
- aliases resolve through `tsconfig.json`, which carries both
`@shared/*` and `@renderer/*`. Main and preload use only `@shared`,
which resolves the same way.
- **`.dependency-cruiser.severity.json`**: the only place a rule family
flips from `warn` to `error`. Every key is `warn` in this PR.
- **ESLint P5** in `eslint.config.mjs`: `no-restricted-syntax` on
`window.skyhawk` member access in `src/renderer/**`, ignoring
`src/renderer/src/app/ipc-adapter.ts` and `src/renderer/test/**`,
severity `warn`. `npm run lint` has no `--max-warnings`, so warnings
don't fail CI.
- **`npm run lint:deps`** (`depcruise src --config
.dependency-cruiser.cjs --output-type err-long`), run as a new step in
the existing **Lint · Typecheck · Test** job (job name unchanged).
Warn-severity findings exit 0.

## Severity keys and flip plan

| Key | Rules | Flips to `error` in |
|---|---|---|
| `firmwareLeaf` | L-firmware-leaf | R1.1 |
| `process` | P3 | R1.3 |
| `contracts` | C1 | R1.3 |
| `cycles` | C2 | R1.3 |
| `formatLeaf` | L-format-leaf | R1.3 |
| `crossProcess` | P1, P2 | R1.4 |
| `electron` | P6 | R1.4 |
| `topicsMain` | topic-main-* | R2.3 |
| `deepMain` | T2-main | R2.3 |
| `rendererShared` | P4 | R3.4 |
| `kitLeaf` | L-kit-leaf | R3.4 |
| `topicsRenderer` | topic-renderer-* | R3.4 |
| `deepRenderer` | T2-renderer | R3.4 |
| ESLint P5 | `window.skyhawk` | R3.4 (in `eslint.config.mjs`) |

## Today's counts (warn-only)

The rules target folders that don't exist yet, so today's counts are not
the §5.4 planning estimate (that estimate mapped flat files to their
future topics). `npm run lint:deps`: **65 warnings, 0 errors**, 97
modules, 298 dependencies.

| Rule | Today | Notes |
|---|---|---|
| P1, P2, P3, C1 | 0 | P3/C1 match on `src/shared/<topic>/` paths, which
don't exist yet |
| P4-renderer-imports-contracts-only | 53 edges from 28 files | 36 to
model files (node-rules 11, psu 4, node-telem 4, nodes 3, node-mapping
3, events 3, node-kind 2, gateway 2, axis-capture 2, config-file 1,
cal-cache 1), plus 17 to the flat `shared/ipc.ts`, which isn't at a
contract path yet |
| P6-electron-only-in-app | 5 files | main/index.ts, debug.ts,
settings.ts, node-mapping.ts, cal-cache.ts |
| C2-no-circular | 6 | node-kind ↔ node-mapping; events ↔ ipc;
config-file ↔ ipc; cal-cache ↔ ipc; config-file → cal-cache → ipc →
config-file; gateway → events → ipc → gateway |
| T2-renderer-no-deep-topic-import | 1 | tabs/Overview.tsx →
assets/logo.png (today's `tabs/` and `assets/` folders read as topics) |
| topic-*, T2-main, L-* | 0 | no topic or leaf folders yet |
| ESLint P5 | 32 in 10 files | store.ts 16, SidePanels 4,
CalibrationDialog 2, AdoptDialog 2, ConfigurationSection 2, ResetDialog
2, AboutSection 1, CockpitSection 1, ImportDialog 1, useNodeModel 1 |

## Proof each rule family fires

Throwaway files in the target layout (deleted before commit) produced:

```
warn P1-renderer-not-main: src/renderer/src/zzP1.ts → src/main/debug.ts
warn P2-main-not-renderer: src/main/zzP2.ts → src/renderer/src/freshness.ts
warn P3-shared-is-a-leaf: src/shared/zz/ipc.ts → src/main/debug.ts
warn C1-contracts-import-contracts: src/shared/zz/ipc.ts → src/main/debug.ts
warn C1-contracts-import-contracts: src/shared/zz/index.ts → src/shared/psu.ts
warn L-firmware-leaf: src/shared/firmware/zz.ts → src/shared/format/zz.ts
warn L-format-leaf: src/shared/format/zz.ts → src/shared/events.ts
warn L-kit-leaf: src/renderer/src/app/kit/zz.ts → src/renderer/src/freshness.ts
warn topic-main-events: src/main/events/zz.ts → src/main/nodes/zz.ts
warn topic-main-hid: src/main/hid/zz.ts → src/main/hid/calibration/index.ts
warn topic-renderer-gateway: src/renderer/src/gateway/zz.ts → src/renderer/src/app/zz.ts
warn T2-main-no-deep-topic-import: src/main/nodes/zz.ts → src/main/gateway/deep.ts
warn T2-renderer-no-deep-topic-import: src/renderer/src/app/zz.ts → src/renderer/src/nodes/deep.ts
warn C2-no-circular: src/main/zzcycleA.ts → src/main/zzcycleB.ts → src/main/zzcycleA.ts
```

P4 and P6 fire on today's tree (above). Checks that should stay silent
did: nodes → `gateway/index.ts` (allowed direction, entry import) and
hid/calibration → hid. The `src/renderer/src/app/zz.ts` import was
written as `@renderer/nodes/deep`, and it resolved to
`src/renderer/src/nodes/deep.ts`.

- **Aliases resolve:** on today's tree, every `@shared/...` import
resolves to `src/shared/...` (main 37 edges, preload 4, renderer 54),
with 0 unresolvable modules.
- **Severity wiring:** with `firmwareLeaf` set to `error`, the
L-firmware-leaf finding was reported as `error` and `lint:deps` exited
1. The change was reverted before commit.

## Checks

`npm run lint` (0 errors, 32 P5 warnings), `npm run typecheck`, `npx
vitest run` (40 files, 698 tests) and `npm run lint:deps` all exit 0.

Also in the lockfile: adding dependency-cruiser bumped three hoisted
dev-only transitive packages (acorn 8.17→8.18, is-core-module
2.16.2→2.17.0, semver 7.8.4→7.8.5).

Refs #97
Phase 0, R0.4 of the structure rewrite (#101). Tests only: no behaviour
or UI change. The one production edit removes the test-only export
`DEFAULT_APP_SETTINGS` from `shared/config-file.ts`. Its only importer
was `config-file.test.ts`, and it now lives in the fixtures.

## Seam tests (new files)

| Row | Seam | File | What it pins |
|---|---|---|---|
| B8 | HUB | `src/main/node-hub.seam.test.ts` | Forget removes the whole
entry (name, console, power zone, hidden flag, last seen). If the board
reports again, it is `new`: a registry board comes back pre-filled from
NODE_IDS.md, an adopted one comes back blank. It is never written. |
| B10 (HUB half) | HUB | `src/main/node-hub.seam.test.ts` | Hiding a PDU
keeps its boards' links, both the explicit `pduId` in the file and the
default link in the model. When the PDU reports, its hidden flag clears
and the group is whole again. |
| B33 | HUB | `src/main/node-hub.seam.test.ts` | A board identified only
by PDU telemetry gets no "new on the bus" row when the pages arrive. It
gets one row, stamped with that time, at its next status row, and only
once. |
| B13 | SESSION | `src/main/session.seam.test.ts` | Disconnect, unplug
(port close) and app quit each write `lastSeen` = the time of the close
to node-mapping.json. |
| B29 | SESSION | `src/main/session.seam.test.ts` | The v2-Gateway
warning is raised once per link, and raised again after a reopen. **See
the discrepancy below.** |
| B17 | FILE | `src/main/node-mapping.seam.test.ts` | v2 / v1 /
unversioned files are upgraded on read. The ignore list is dropped.
Seen, placed, renamed and hidden boards become `added`. Adopted entries
are kept but not marked `added`. The save writes v3, and from then on
the file is read as written. |
| B94 | XFER | `src/main/config-transfer.seam.test.ts` | Non-object and
unknown sections are skipped and listed. A file with no readable section
is refused. Each known section goes through its own store's sanitizer:
bad fields are dropped and good ones kept. |

**Brittle test (T16):** the `session.test.ts:439–454` test spied on
`session.link.roster.reset` / `session.link.nodes.reset`. It is replaced
by B31 in `session.seam.test.ts`, which goes through the harness and
checks only outcomes:
- After relay Start / Stop / Start, the stray node is still in the
`nodes:model` push and in `nodes:get`, and so is its telemetry.
- After a port reopen, the next push and the snapshot have dropped them.

No test spies on `session.link.*` any more.

## Services harness: `src/main/test-harness/services.ts`

`makeServices({ serial, transport, clock, paths })` launches main the
way `index.ts` does: settings from `config.json`, then `session.open()`.
It runs against fakes for the serial port, the DCS transport, HID and
the clock (vitest fake timers), with a temporary `<userData>`.
`node-mapping`, `settings` and `cal-cache` are real, so they write files
there.

Options:
- `serial: { portPath, serialNumber }`
- `transport: { connectOnStart }`
- `clock: { start }`
- `paths: { files }`: seeds `<userData>` before launch.

Tests only see:
- `pushes` / `pushesOf(ch)` / `lastPush(ch)`
- `invoke(CTRL.x, ...args)`: the same handlers `registerIpc()` wires,
typed per channel
- `serial.open() / close() / receive() / notFound() / fail() /
written()`
- `transport.export() / connected() / fail() / sent()`
- `clock.advance() / now()`
- `files.json() / text() / list()`
- `closeWindow()`, `quit()`, `dispose()`. Cleanup is automatic via
`onTestFinished`.

Only the harness touches `Session` / `session.link`. When Session
becomes `AppServices` in R2.3, only this file changes.

## Fixtures: `src/test-fixtures/`

- `nodes.ts` holds the shared builders:
  - `nodeStatus()`, `modelNode()` and `nodeModel()` (T11)
  - `statusRow()` / `statusLine()` for v3 `_NODE_STATUS` rows
- These builders replace the copies in ten test files:
connection-summary, overview, cockpit, cockpit-panels, cockpit-adopt,
gateway-view, nodes, events, node-rules and gateway. `session.test.ts`
now uses `statusLine` from the fixtures too. Where a file's defaults
differed, it passes those fields explicitly, so every test gets exactly
the inputs it had before. No assertion changed.
- `config.ts` holds `DEFAULT_APP_SETTINGS`.
- `src/test-fixtures` is added to `include` in both `tsconfig.json` and
`tsconfig.node.json`. Imports are relative, so no new path alias.

## Contract vs code: discrepancies (pinned as the code behaves today)

- **B29.** The contract says the v2 warning repeats once after a port
reopen (2 rows in total). Today, `DeviceLink` does reset
`legacyNodesWarned` on open and raises the warning again: the
stream-stats error count goes 1 → 2. But `Session.logError` dedupes per
tag (`lastErr`), and nothing ever clears `'NODES'`, so the identical
second message never reaches the event log. **The event log keeps 1
row.** The test pins both halves and carries a `// contract B29 says …;
today the code does …` comment.
- **B17 (not pinned).** The legacy `added` upgrade marks *any*
non-adopted entry with an override, not only registry entries (M9 → F28,
already pinned by `node-mapping.test.ts:109`). It's a FIX-track item, so
B17 deliberately sticks to registry ids and adopted entries.

## Fail-check

Each seam test was seen to go red against a deliberately broken
production line, then restored:

| Test | What was broken |
|---|---|
| B8 | `forgetNode` made a no-op |
| B10 | `clearReportedHidden` made a no-op; separately, `setHidden` made
to unlink the PDU's boards |
| B33 | telemetry made to emit the status events itself |
| B13 | `linkClosed()` removed from `disconnect`, `onClose` and
`shutdown` (one at a time) |
| B29 | the `legacyNodesWarned` reset removed from `onOpen` |
| B31 | `roster` / `nodes` reset added to relay Start; separately,
removed from `onOpen` |
| B17 | the legacy `added` derivation removed; separately, v3 treated as
legacy |
| B94 | the non-object skip removed; separately, the `nodeMapping`
sanitizer bypassed |

## Checks

`npm run lint`, `npm run typecheck` and `npx vitest run` all pass: **44
files, 713 tests**. The baseline was 40 / 698: one brittle test removed,
16 tests added.

Refs #97
R0.2 of the structure rewrite: a jsdom component harness, plus the first
8 tier-1 component tests and the 15 phase-0 behaviour probes. **Test
tooling and tests only.** No production code changes, so no UI or
behaviour changes.

## Tooling

- devDependencies, pinned exactly: `jsdom@29.1.1` (not 30.x, which needs
Node ^24.15), `@testing-library/react@16.3.3`,
`@testing-library/dom@10.4.2`, `@testing-library/user-event@14.6.7`,
`@testing-library/jest-dom@7.0.1`.
- `vitest.config.mts` now uses `test.projects`:
- **node**: `src/**/*.test.ts`, in the node environment with the
`@shared` alias. Every test that existed before runs here, unchanged.
- **dom**: `src/renderer/**/*.test.tsx`, in jsdom with
`@vitejs/plugin-react`, the `@shared` and `@renderer` aliases, and the
setup file. `__APP_VERSION__` is defined the way
`electron.vite.config.ts` defines it, from `package.json`'s version.
- `.dependency-cruiser.cjs`: added `'^src/main/test-harness/'` to
`options.exclude.path`. The test-only services harness from #103 is no
longer counted as a main topic. No rule or severity changed.

## Harness API (`src/renderer/test/`)

| File | What it gives a test |
|---|---|
| `setup.ts` | jest-dom matchers. Before each test, a fresh store
(`resetStore()`) and a fresh `window.skyhawk` mock. After each,
`cleanup()` and real timers. It also polyfills a no-op `ResizeObserver`,
and shims `globalThis.jest.advanceTimersByTime` so that Testing
Library's async wrapper advances Vitest fake timers (without it,
`user-event` hangs under `vi.useFakeTimers()`). |
| `skyhawk-mock.ts` | `installSkyhawkMock()` builds a typed `{ [K in
keyof SkyhawkApi]: Mock<…> }`, so an IPC change breaks it at typecheck.
`skyhawk()` returns the current mock. Intents are `vi.fn`s with harmless
defaults, so a test can assert them (`sky.forgetNode`) or override them
(`mockResolvedValueOnce`). The mock also has `sky.scenario` and
`sky.emitScenario(s)`. |
| `scenarios.ts` | Scenario builders: `link(state)`,
`connected(gateway?)`, `gateway(row)` / `gatewayNotResponding()` (these
call main's own `gatewayStatus()`), `panel`, `pdu`, `newBoard`,
`offline`, `notSeen`, `noStatusYet`, `outage`, `model`. They are built
on `src/test-fixtures/nodes.ts`. |
| `render.ts` | `startBridge(scenario)` subscribes the store the way
`App` does and unsubscribes when the test ends, which removes the "one
`initBridge` per file" snag. `renderScenario(ui, scenario)` returns `{
sky, user, …render }`. Under fake timers, `user` runs its delays on the
fake clock. |
| `store-reset.ts` | `resetStore()` restores the store's initial state.
|

### Mock design rule (for R3)

Tests never set store fields and never push channels by hand. A test
describes main's state as a `Scenario` (device link, relay, node model,
telemetry, config, events, cal, HID, aircraft, registry) and feeds it
through one path, `emitScenario()`:

- it keeps the scenario as main's current state, so the pulls
(`getStatus`, `getNodes`, `getConfig`, `getEvents`, `getNodeRegistry`)
answer from it, and
- it pushes each part on today's channel (`device:status`,
`relay:state`, `nodes:model`, `nodes:telemetry`, `events:batch`,
`cal:data`, `hid:report`, `aircraft:changed`).

When R3 moves a decision into main and adds a push (`cockpit:snapshot`,
`cal:workflow`), `emitScenario` derives that push by calling main's pure
builder on the same inputs. The change stays inside the mock, and the
T1–T35 scenarios and assertions don't change. The file's header comment
says the same.

## Tests T1–T8

| # | File | What it pins | Rows |
|---|---|---|---|
| T1 | `t1-cockpit-attention-strays` | With fake timers: an unidentified
new board is nothing at t0, then "!NODE_ID 9 · UNIDENTIFIED · can’t be
adopted · #9" after 5 s, and the band reads "1 warning". SECOND GATEWAY
and UNKNOWN BOARD TYPE show at once. With the link down there are no
strays and the band shows "/ 0". A stray's cog opens Adopt with that id
selected. The grace counts from `firstSeenAt`. The Overview ignores the
stray. | B66, B65, X1, F26 |
| T2 | `t2-cockpit-attention-order` | Gateway first ("✕ Gateway /
Bus-off"), then the outage "OFFLINE · 1 node affected", then offline and
warnings by NODE_ID, with "+N more". The "The only node on this PDU…"
hint. A Gateway warning shows "!", a healthy Gateway shows no item. The
Gateway cog opens the Device popover. The outage cog opens the PDU
panel, a board cog opens the board panel. "Show all on map" turns Issues
on. The empty states: "Nothing needs attention." and "No live node
status — connect the Gateway." | B65, B42, B37 |
| T3 | `t3-overview-tile-banners` | Tile "1 / 5" with ✕ and "nodes up ·
— W · 3 PDUs". The banner "4 offline (PDU_Right + 1 node,
Center_Armament, PDU_Center)". `attentionBanner: false` hides the
banner. A Gateway bus-off is a red alert placed before the banner, and
its Details opens the popover. A Gateway warning gives no alert. A stray
isn't counted. The counts match the Cockpit band. | B58, B59, B42, X1 |
| T4 | `t4-overview-new-devices` | Registry board: "…from NODE_IDS.md,
not added to your cockpit yet." Unregistered board: "…not in
NODE_IDS.md." A mix gives "— 1 from NODE_IDS.md". Unidentified and
non-adoptable boards aren't mentioned. Hidden while the link is down.
"Open Cockpit →" opens the Cockpit tab with Adopt on that board. | B60 |
| T5 | `t5-connection-nodes-card` | Counts match the Cockpit band. Rows
come in Attention order, with symbol, chip and meta ("#4 · 1 node
affected · offline since 21:11:18", "not seen this session · last seen
14 Oct, 21:14", "no power zone"). Footer "+1 more · 1 no status yet ·
healthy nodes hidden". Refresh calls `refreshNodes`, and is disabled
with the "Device link released…" hint while the link is down. "›" opens
the Cockpit on that board. | B63, B14, B37, X2 |
| T6 | `t6-device-popover` | Each of the 6 `DeviceState`s: label, title,
line and button (Connect / Retry / Disconnect call the mock). The port
only in the tooltip, plus the "busy" line. Health "OK · 42 °C". A
warning shows "!" and its note. "Not responding" replaces the title and
line, with no Health row. Nothing about the Gateway while the link is
down. Esc and the scrim close it, and focus returns to the indicator.
"Connection tab →". | B44, B42, B40 |
| T7 | `t7-board-footer-panel` | `offline`: Forget and Hide are enabled,
with the "waits in New on the bus…" note. `online` and `unknown` disable
them, with their reasons. Reset is greyed at the defaults and calls
`resetNode` when the board differs. An adopted board has no Reset and
the "shows as new" note. Forget and Hide call main, the panel closes on
success, and a refusal shows the error. | B73, X4 |
| T8 | `t8-board-footer-pdu` | "Its 2 boards move to No power zone… Hide
keeps the links". The fed count covers visible boards only, not the
hidden one. The note when the PDU feeds nothing. Online is disabled.
Hide calls `hideNode(4, true)` and Forget calls `forgetNode(4)`. Reset
covers "name and console" only. | B73, B9, B10, X8 |

Each describe title lists its rows.

## Probes

The 15 probes from the contract's scratch copy pass on this branch.
Their assertions are unchanged; only imports moved. They are split by
process, so main never imports renderer code:

| File | Project | Probes |
|---|---|---|
| `src/main/phase0-probes.test.ts` | node | 11 that use main's `NodeHub`
or `@shared/*` only: C1, M1/C3, M3, C4, M7, M7 contrast, G1, D7, D3, #95
bug 1, #95 bug 3 |
| `src/renderer/src/phase0-probes.test.ts` | node (a `.ts` helper test,
like the existing `src/renderer/src/*.test.ts`) | 4 that call the
renderer helpers: D1 conflict, S6, C6, #95 bug 2 |

`DEFAULT_APP_SETTINGS` now comes from `src/test-fixtures/config` (it
moved there in #103). Neither tsconfig changes.

## Discrepancies: contract wording vs today (pinned today, commented in
the test)

- **T6 port line.** The contract says the popover shows a port line
while connected and drops it once the Gateway reports health. Today the
popover has no port line while connected, with or without health. The
port is in the indicator's tooltip (`Gateway connected · <port>`) and on
the Connection tab. Pinned as is.
- **T6 gold vs red "!".** The colour is CSS-only, so it isn't asserted
(the brief rules out CSS checks). The tests assert the "!" and the
tooltip.
- **T2 "Gateway · Bus-off".** On screen the item reads "Gateway" over
"Bus-off" and has no " · #id". Pinned as rendered.
- Deliberate pins of today's conflicts, each with a `// contract says …;
today …` comment: X1 (strays only on the Cockpit), F26 (the grace counts
from `firstSeenAt`), X2 (Connection meta names a PDU on the bus that
isn't added), X4 (the footer needs `offline`, while main accepts
`unknown`), X8 (the fed count is visible boards only).

## Fail-checks

For 22 mutations, one behaviour was broken in production code locally,
the matching test file was run, then the change was reverted with `git
checkout`. Every mutation turned at least one test red. Examples:

| T | Broken locally | Red |
|---|---|---|
| T1 | `IDENTIFY_WAIT_MS` 5000→0 · "SECOND GATEWAY" text · strays not
gated on the link | t0 / bridge / link-down tests |
| T2 | rank order swapped · `ATTENTION_SHOWN` 3→4 · Gateway cog no-op |
order (2), cap (3), cog (1) |
| T3 | `BANNER_NAMES` 3→2 · banner ignores the setting · "no nodes
known" text | 1 each |
| T4 | notice shown while the link is down · registry detail text | 1
each |
| T5 | "offline since" text · Refresh always enabled · footer separator
| 1 each |
| T6 | Gateway shown while the link is down · Retry label · Esc doesn't
refocus | 1 / 2 / 1 |
| T7 | `unknown` treated as offline · Hide sends `false` · adopted note
| 1 each |
| T8 | fed count +1 · PDU Hide tooltip | 3 / 1 |

The first run of the T1 link-down mutation stayed green, because
Attention also hides items while the link is down. I added a band-count
assertion (`/ 0`), and it now goes red.

## Checks

- `npx vitest run`: **54 files / 791 tests**. That's 44 files / 713
tests as before, plus 2 probe files / 15 probes, plus 8 dom files / 63
tests.
- `npm run lint`: 0 errors (32 warnings, unchanged). `npm run
lint:deps`: 0 errors (65 warnings, unchanged). `npm run typecheck`:
clean. `npm run build`: OK locally.

Refs #97
Second half of the tier-1 component tests from the phase-0 plan: T9–T16.
Tests only, no production change. Uses the R0.2 harness as is
(`renderScenario` / `startBridge`, scenarios through `emitScenario`,
intents checked on the mock).

**Total: 849 tests in 62 files** (was 791 in 54; +58). Lint 0 errors (32
warnings, unchanged), lint:deps 0 errors (65 warnings, unchanged),
typecheck clean.

## What each test pins

| # | File | What it pins | Rows |
|---|---|---|---|
| T9 | `t9-cockpit-new-on-bus` | "New on the bus · N" + "not linked yet"
+ Adopt… for identified new boards (PDUs counted); none for
identifying/bridge; **still shown after the link drops (R5 today)**;
Adopt… opens the dialog on the first in its queue | B68, F12, X7 |
| T10 | `t10-adopt-dialog-queue-form` | Queue order (first seen, then
NODE_ID) and badges PANEL GROUP / PDU / IDENTIFYING / BRIDGE / UNKNOWN
TYPE; registry pre-fill + "Add to cockpit" → `adoptNode` spec; unlisted
panel: Adopt disabled until name + console + Powered by; PDU form has no
Powered by; non-adoptable rows show their note, no form; Later moves on
/ closes past the end; after an add the selection moves to the next; a
refused adopt shows main's error | B74, B21, B22 |
| T11 | `t11-adopt-dialog-add-all` | "Add all 2 panel groups from
NODE_IDS.md" never adds a PDU; an untouched form keeps its default PDU
only if it's in your cockpit, else None; an edited form is taken as
written (None stays None); **an edited form keeps a "(not added)" PDU
(D7 today)**; Powered by lists "(other console)" and "(not added)" + its
hint; **a hidden PDU reads "(not added)" and Add all sends None (#95 bug
2 / X3 today)**; a row that drops while open stays listed, ✕, form kept,
and turns green when back | B74, F15, F24, X3 |
| T12 | `t12-settings-hidden-boards` | Rows "#id · name · console ·
returns when it reports again" from `model.hiddenNodes`; Un-hide →
`hideNode(id,false)`; Un-hide all un-hides each; a refusal shows main's
error; empty state + Un-hide all disabled | B88 |
| T13 | `t13-settings-import-preview` | Subtitle; rows "key from → to";
"no change" sections can't be picked; "This file matches your current
setup" + no Apply; **`"nodeMapping": {}` previews as a normal,
pre-picked section offering to drop every adopted board (F1 today)**;
unknown section → "Skipped — written by a newer client…"; Apply disabled
with nothing picked; `applyConfigImport(token, sections, backup)`; done
line with the backup name; refused apply / refused file | B90, B94, F1 |
| T14 | `t14-app-shell` | Wordmark, `v${package.json version}`, Device
indicator, Relay pill; 7 tabs with `aria-selected`, click switches tab
and page; "SOURCE <mode>"; `App` itself subscribes on mount (rendered
without `startBridge`), takes main's pulled state, and a `device:status`
push changes the pill | B43 |
| T15 | `t15-relay-pill` | Stopped / Relaying; Start saves settings then
calls `startRelay`, optimistic Relaying; Stop calls `stopRelay`; the
waiting tooltips (DCS-BIOS connecting; Bridge waiting for the Gateway);
Bridge without a board → "!", `aria-disabled`, alertdialog "Can't start
the relay", Connect only connects; the board arriving lifts the block;
Esc returns focus; Monitor starts with no board; a Start main refuses
goes back to Stopped | B45, B47 |
| T16 | `t16-board-panel-settings` | Display name + "Registry: …, Right
console (NODE_IDS.md)."; Power zone = that console's placed PDUs + None
(not a new PDU); "(other console)"; a console change picks its first
PDU; Save sends only what changed (trimmed name; console + pduId);
no-change Save just closes; empty name → main's error inline, panel
stays open; a PDU has no power zone and Save sends `{ console }` only |
B72, X2 |

`scenarios.ts` gains `setup()`, `configFileText()` and
`importPreview()`: `openConfigImport`'s answer built the way main's
`ConfigTransfer.preview` builds it (shared `parseConfigFile` +
`diffImport`), so T13 — F1 especially — pins what main would actually
preview, not a hand-written preview.

## Discrepancies (pinned as today, marked in the tests)

1. **T9 / R5, F12** — the CTA stays from the last model after the link
drops (as the plan expected).
2. **T11 / D7, F24** — an edited Adopt form keeps a "(not added)" PDU
and Add all sends it; the form offers "(not added)" at all.
3. **T11 / X3, F15** — a hidden PDU in your cockpit reads "(not added)",
and Add all sends `pduId: null` for its boards.
4. **T13 / F1** — `"nodeMapping": {}` previews as a normal section,
pre-picked, offering to take every adopted board out of the cockpit.
5. **T16 / B72** — "an empty name gives an inline error": today the
panel has no check of its own. It sends `setNodeMapping(id, { name: ''
})` and shows main's refusal ("Name cannot be empty") inline. Same
user-visible result, but the gate is main's.
6. **T16 / B72 wording** — the Settings tab's field is labelled "Power
zone"; "Powered by" is the Status tab section and the Adopt form label.
7. **T15** — when main refuses Start, the pill goes back to Stopped but
shows no reason (main's error text is dropped).

## Fail-check (each break applied locally, test file run, restored with
`git checkout`; production tree clean after)

| T | Break | Result |
|---|---|---|
| T9 | CTA gated on `live` (the R5 fix) | 1 failed |
| T9 | `newCount` counts unidentified new boards | 1 failed |
| T10 | queue tie-break by NODE_ID reversed | 3 failed |
| T10 | panel spec complete without Powered by | 1 failed |
| T11 | Add all includes PDUs | 1 failed |
| T11 | untouched default PDU kept even if not in cockpit | 2 failed |
| T11 | rows that drop are not kept | 1 failed |
| T11 | identify dot never turns red | 1 failed |
| T12 | Un-hide sends `hideNode(id, true)` | 2 failed |
| T13 | empty section skipped in `parseConfigFile` (the F1 fix) | 1
failed |
| T13 | Apply enabled with nothing picked | 1 failed |
| T14 | `App` doesn't call `initBridge` on mount | 1 failed |
| T14 | `aria-selected` stuck on Overview | 1 failed |
| T15 | refused Start not taken back | 1 failed |
| T15 | blocked pill starts anyway | 3 failed |
| T16 | console change keeps the old PDU | 2 failed |
| T16 | Save always sends the name | 3 failed |

Refs #97
…eplace hand-typed copies (#106)

R1.1 of the structure rewrite (#101, phase #98). This PR is mechanical:
behaviour, UI and wording stay the same.

## What changed

- **The generated reference moves to `src/shared/firmware/`.**
`scripts/sync-a4ec.ts` now writes there (`OUT_DIR`). The six
`*.generated.ts` files were moved with `git mv`. `npm run sync` at the
same pin (d29433ab) regenerates them byte-for-byte: `git diff
--exit-code -- src/shared/firmware` is clean after the sync. A
hand-written barrel, `src/shared/firmware/index.ts`, is now the only
import path. CI's freshness gate (`ci.yml`) now diffs the new folder.
README and PRD path mentions are updated to match.
- `src/main/reference/dcsbios-metadata.ts` stays where it is. It is
hand-curated DCS-BIOS data and moves in R1.4.
- **All 35 hand-typed copies (§3.3 A1–A35) are now imports.** Callers
still use the same names, so call sites barely change. For example,
`CAL_TYPE` / `CAL_NACK` are built from the generated tables
(`byName(CAL_TYPES)`), and `RAIL_FLAG` / `PDU_FLAG` / `TELEM_PAGE` /
`NODE_KIND` are aliases of the generated objects. The cockpit /
cockpit-panels / events re-key sites are unchanged because they already
key by `RAIL_FLAG.X` / `PDU_FLAG.X`.
- **`L-firmware-leaf` is now `error`.** `lint:deps` exits 0 with 0
errors and 65 warnings, the same baseline as before. I checked that the
rule can fail: a deliberate `export * from '../hid'` in the barrel
produced `error L-firmware-leaf`, and I reverted it.
- **`reference.test.ts`** moves next to the generated files and becomes
a version and shape guard (see below).

## A1–A35

How each value was checked:
- **(R)** the existing `reference.test` copy-equality assertions. The
full suite ran against the new imports with those assertions still in
place: 849/849 at commit ffe0a2d.
- **(P)** a one-off proof test, not committed. It holds the old literals
copied verbatim from 2598104 and compares them with the replacements.
8/8 passed.
- **(S)** the existing suite (849 at ffe0a2d), whose assertions are
unchanged.
- **(H)** a one-off render proof, also not committed. In jsdom it
rendered `<Hid />` twice (default store, then a store with calibration,
axis/hat/button values and availability) and `<CalibrationDialog>` once
(its controller fed a device snapshot). The `innerHTML` and
`textContent` were dumped once with the base (2598104) `Hid.tsx`,
`CalibrationDialog.tsx` and `store.ts` checked out, and once with this
branch. The two 28 KB dumps are **identical** (same SHA-1).

| # | Was | Now | Verified |
|---|---|---|---|
| A1 | `CAL_PROTO_VERSION = 1` | `CAL_PROTO.version` | R, P |
| A2 | `CAL_MAGIC` AA 53 4B 43 | `Uint8Array.from(CAL_PROTO.magic)`
(still a `Uint8Array`) | R, P (incl. instanceof) |
| A3 | `CAL_ENVELOPE_BYTES = 10` | `CAL_PROTO.envelopeBytes` | R, P |
| A4 | `CAL_MAX_PAYLOAD = 82` | `CAL_PROTO.maxPayload` (`CAL_MAX_FRAME`
still 92) | R, P |
| A5 | `AXIS_CAL_SLOTS = 8` | `CAL_PROTO.axisSlots` | R, P |
| A6 | `CAL_AXIS_NONE = 0xff` | `CAL_PROTO.axisNone` | R, P |
| A7 | `CAL_TYPE` (14) | `byName(CAL_TYPES)` | R (both directions), P
(entries + key order) |
| A8 | `CAL_NACK` (8) | `byName(CAL_NACK_REASONS)` | R (both
directions), P (entries + key order) |
| A9 | `CAL_PAYLOAD_LEN` (14) | generated `CAL_PAYLOAD_LEN`, re-exported
from the codec | R (both directions), P (entries + key order) |
| A10 | callink `NACK_NAME` (5) | `nackRefusalName(reason)` in
shared/calibration.ts (X11) | P: about 610 inputs (−100…499, fractions,
±0, NaN, ±Infinity, 1e21) against the old expression |
| A11 | device-link reverse lookup over `CAL_NACK` |
`nackReasonName(reason)` in shared/calibration.ts (X11) | P: same inputs
against the old `Object.entries(…).find` |
| A12 | `NODE_MSG` | `NODE_STATUS.msgName` | R, P |
| A13 | `NODE_END_MSG` | `NODE_STATUS.endMsgName` | R, P |
| A14 | `NODE_REQ_ADDR = 0x86fe` | `NODE_STATUS.reqAddress` | R, P |
| A15 | `HFLAG_OVERHEAT` | `NODE_HEALTH_FLAGS.OVERHEAT` | R, P |
| A16 | `HFLAG_DEGRADED` | `NODE_HEALTH_FLAGS.DEGRADED` | R, P |
| A17 | `NODE_KIND` | `= NODE_KINDS` | R, P (entries + key order) |
| A18 | `NODE_TELEM_MSG` | `NODE_TELEM.msgName` | R, P |
| A19 | `TELEM_PAGE` | `= TELEM_PAGES` | R, P |
| A20 | `RAIL_FLAG` | `= PDU_RAIL_FLAGS` | R, P |
| A21 | `PDU_FLAG` | `= PDU_STATUS_FLAGS` | R, P |
| A22 | `HID_REPORT_SIZE = 34` | `HID_REPORT_LAYOUT.size` | P, S
(shared/hid.test) |
| A23 | `HID_AXIS_COUNT = 8` | `.axes.count` | P, S |
| A24 | `HID_BUTTON_COUNT = 128` | `.buttons.count` | P, S |
| A25 | `HID_HAT_COUNT = 4` | `.hats.count` | P, S |
| A26 | offsets 0/16/18, `subarray(…, +16)` |
`.buttons/.hats/.axes.offset`, `.buttons.bytes` | P, S |
| A27 | `nibble <= 7` | `nibble <= hats.centeredAtOrAbove - 1` (written
as `<= … - 1`, not `< …`, so non-integer input also behaves the same) |
P: about 610 inputs against the old expression |
| A28 | `hidAvailability` `< 8`, `< 4`, `< 128` |
`axes/hats/buttons.count` (stays in main/index.ts; the move is a later
PR) | P |
| A29 | `AXIS_LABELS` Roll…Zoom, Spare |
`HID_CONTROLS_BY_ID.get(HID_ID.AXIS_MIN + i)?.label ?? 'Spare'` over
`axes.count` | P (jsdom: imported the real `AXIS_LABELS` and `toEqual`'d
the old 8 strings), H. **All 8 labels identical** |
| A30 | `Array(8).fill(0)`, `[0,0,0,0]` | `Array(axes.count).fill(0)`,
`Array(hats.count).fill(0)` | P (`toStrictEqual` on the real store
initial state), H |
| A31 | Hid.tsx "of 8 slots", "8-way POV", "128 × 1-bit", `length: 128`
| `axes.count`, `hats.centeredAtOrAbove`, `buttons.count` | P, H |
| A32 | Hid.tsx `/ 32768`, "int16 · ±32768" |
`-HID_REPORT_LAYOUT.axes.min` | P, H |
| A33 | CalibrationDialog `− 32768`, `/ 65535` (21, 25, 39-40, 318, 326)
| `AXIS_OFFSET = -axes.min`, `AXIS_SPAN = axes.max - axes.min` | P
(numeric identity on sample values), H |
| A34 | CalibrationDialog "of 8 slots" | `of {CAL_PROTO.axisSlots}
slots` | P, H |
| A35 | `FAULT_NOTE` 0x05/0x09, `FAULT_TEXT` 0x09 | keyed by
`NODE_FAULT_CODES` name (`HOST_LINK_LOST`, `DUPLICATE_NODE_ID`); wording
unchanged | P: `gatewayStatus(…).note` and `withFaultInfo(…)`
(`toStrictEqual`) for faultId 0–299 against the old tables |

No value differed, so every one was swapped.

## X11 (A10/A11)

The two disagreeing NACK namers are now **separate functions side by
side in `shared/calibration.ts`**. Both are built on `CAL_NACK_REASONS`,
and they are deliberately not unified; unifying them is the fix track.
- `nackRefusalName(reason)`: CalLink's `CalNackError` message. Names
only the 5 reachable reasons (`BAD_INDEX`, `BAD_ORDER`, `NO_SESSION`,
`NO_STORAGE`, `BAD_DEADZONE`). Anything else becomes `reason 0x…`.
- `nackReasonName(reason)`: device-link's `CalFailure.reasonName`. Names
all 8, else `0x…`.

## reference.test: deleted assertions

These assertions compared a hand-typed copy with its generated value.
Each copy is now the generated value, so the comparisons were deleted.
Five whole `it` blocks went with them, so the suite goes from 849 to 844
tests.

- `node-status.generated › matches the shared decoder contract`:
`NODE_STATUS.reqAddress` = `NODE_REQ_ADDR`, `.msgName` = `NODE_MSG`,
`.endMsgName` = `NODE_END_MSG`. The `protoVersion` =
`SUPPORTED_NODE_PROTO` guard is kept.
- `node-status.generated › health-flag masks match the decoder` (whole
`it`): `OVERHEAT` = `HFLAG_OVERHEAT`, `DEGRADED` = `HFLAG_DEGRADED`.
- `node-telem contract › matches the shared decoder`:
`NODE_TELEM.msgName` = `NODE_TELEM_MSG`. The `protoVersion` =
`SUPPORTED_TELEM_PROTO` guard is kept.
- `node-telem contract › page ids and flag bits match the decoder
exactly` (whole `it`): `TELEM_PAGES`/`TELEM_PAGE`,
`PDU_RAIL_FLAGS`/`RAIL_FLAG`, `PDU_STATUS_FLAGS`/`PDU_FLAG`,
`NODE_KINDS`/`NODE_KIND`.
- `axis-cal.generated › scalar contract matches the codec`: `axisSlots`,
`axisNone`, `envelopeBytes`, `maxPayload` and `magic` against the codec
copies.
- `axis-cal.generated › every message type agrees, in both directions`
(whole `it`).
- `axis-cal.generated › every payload length agrees, for every type`
(whole `it`).
- `axis-cal.generated › every NACK reason agrees, in both directions`
(whole `it`).

**The calibration version gate became a pin.**
`expect(CAL_PROTO.version).toBe(cal.CAL_PROTO_VERSION)` would now
compare a value with itself, so it is
`expect(CAL_PROTO.version).toBe(1)`, in an `it` renamed "is the protocol
version the codec is written for". A firmware protocol bump still fails
loudly. This is the only assertion whose content changed.

Kept unchanged: the a4ec / hid-controls / hid-report-layout / node-names
/ dcsbios-metadata shape checks, the fault-code pins and unique-badge
check, "marks device-originated types by the high bit", and "COMMIT
carries exactly one axis".

## Grep proof

Each pattern was run with `git grep -nE <pattern> -- src
':!*.generated.ts' ':!*.test.ts' ':!*.test.tsx'`, with comment lines
filtered out. At the base (2598104) **every pattern hits**. At this head
every pattern has 0 hits, except the one noted below.

```
CAL_PROTO_VERSION = [0-9]                        CAL_ENVELOPE_BYTES = [0-9]
0xaa, 0x53, 0x4b, 0x43                           CAL_MAX_PAYLOAD = [0-9]
AXIS_CAL_SLOTS = [0-9]                           CAL_AXIS_NONE = 0x
(HELLO|SESSION_OPEN|STREAM_SELECT|HELLO_ACK|RAW): 0x
(BAD_CRC|BAD_INDEX|BAD_DEADZONE|NO_SESSION): 0x
\[CAL_TYPE\.[A-Z_]+\]: [0-9]                     NACK_NAME|\[CAL_NACK\.|Object\.entries\(CAL_NACK\)
'_NODE_STATUS'|'_NODE_STATUS_END'|'_NODE_TELEM'  0x86fe
HFLAG_(OVERHEAT|DEGRADED) = 0x                   (PANEL_GROUP|BRIDGE): 0x
(PDU_RAIL_12V|PDU_STATUS): 0x                    (UNDER_V|OVER_CURRENT|FUSE_OPEN|SENSOR_FAULT): 0x
(UNCAL_VDDA|TEMP_CRIT|NTC_FAULT): 0x             HID_(REPORT_SIZE|AXIS_COUNT|BUTTON_COUNT|HAT_COUNT) = [0-9]
(BUTTONS|HATS|AXES)_OFFSET = [0-9]               BUTTONS_OFFSET \+ 16
nibble <= 7                                      AXIS_MIN < 8|HAT_MIN < 4|BUTTON_MIN < 128
'Brake L'|'Throttle'|'Spare',                    Array\(8\)|\[0, 0, 0, 0\]
of 8 slots|8-way|128 ×|length: 128               0x0[59]: ['{]
/ 32768|±32768'|- 32768|/ 65535|>−32768<
```

The one exception is `/ 65535` in `main/decode.ts:143-145`. That code
decodes the A-4E-C altimeter drums: it is DCS module data, not firmware,
and it is out of scope (§3.3 "Not firmware").

## Not done here, on purpose

- `CalibrationDialog.tsx:202` "signed ±32767" and `:328`
`<span>32767</span>` are still literals. They equal `axes.max`, but
A33's line list (21, 25, 39-40, 318, 326) does not include them.
- The generated banner in `axis-cal.generated.ts` still says "The codec
in shared/calibration.ts restates these…", and that is now stale. The
text comes from `sync-a4ec.ts`, and changing it would break the
byte-identical regeneration this PR has to prove. It belongs with R1.2's
sync changes.
- §3.3 group B is untouched.

## Checks

`npm run typecheck` is clean. `npm run lint` has 0 errors (32 warnings).
`npm run lint:deps` has 0 errors (65 warnings). `npx vitest run` passes
62 files: 849 tests with the old assertions at ffe0a2d, 844 after the
guard rewrite. `npm run build` succeeds.

Refs #98
R1.2 of the structure rewrite (#101, phase #98). This PR extends the
firmware sync and swaps the matching client copies for imports.
Behaviour, UI and wording stay the same. The sync pin is unchanged at
d29433ab.

## What changed

- **`scripts/sync-a4ec.ts`** now also reads
`PanelBridge/PanelBridge.cpp` and `PowerMonitor/TelemSchedule.h` (two
new `SRC` entries, same sparse fetch). Two new generated files are
exported from the `shared/firmware` barrel:
  - `node-bus.generated.ts`: `MAX_NODE_ID`, `TELEM_PERIOD_MS`
- `usb-identity.generated.ts`: `USB_IDENTITY` (`vid`, `pid`,
`manufacturer`, `product`, `cdcInterface`, `baud`)
- **`constexprNum(src, name, where)`** now names its source file. It
also evaluates a sum of literals and other constants from the same file,
which `CAL_MAX_FRAME = CAL_ENVELOPE_BYTES + CAL_MAX_PAYLOAD` needs. Any
other expression fails the sync.
- **`CAL_PROTO`** gains `maxFrame` (from `AxisCal.h`) and
`sessionTimeoutMs` (from `SimGateway.cpp` `CAL_SESSION_TIMEOUT_MS`).
- **`HID_REPORT_LAYOUT.hats.directions`** comes from the descriptor's
hat Logical Minimum/Maximum bytes (`0x15 00` / `0x25 07`), giving 8.
`centeredAtOrAbove` is now derived from the same range (logical max + 1
= 8, the same value as before). The sync fails if the logical minimum is
ever not 0. The direction names (`HAT_DIRS`, CTR N … NW) stay client
display labels.
- **B10, keys only.** `PDU_RAIL_FLAG_INFO` and `PDU_STATUS_FLAG_INFO`
map each bit to `{ name, description }`, with the description taken
verbatim from the `parseEnum` comment capture, the same way
`NODE_FAULT_CODES` does it. The client's flag wording
(`cockpit-panels.ts`, `events.ts`) is **not** replaced. It differs from
the firmware comments, so replacing it would be a behaviour change.
- **The `axis-cal.generated.ts` banner** no longer claims the codec
"restates these". It now says the codec imports them through the barrel
and that `reference.test.ts` pins the values.

## Values → generated name → client site

| # | Value | Firmware source (d29433ab) | Generated | Client site
replaced | Verified |
|---|---|---|---|---|---|
| B1 | 63 | `PanelBridge.cpp` `static constexpr uint8_t MAX_NODE_ID =
63` | `MAX_NODE_ID` | `shared/node-mapping.ts` `isNodeId` `v <= 63`;
`shared/nodes.ts` `nodeId > 63` (and the field comment);
`shared/node-telem.ts` `nodeId > 63` | P (both decoders' gates for every
id 0–255), R |
| B12 | 0x2E8A / 0x4134 | `SimGateway.cpp` `TinyUSBDevice.setID(0x2E8A,
0x4134)` | `USB_IDENTITY.vid/.pid` | `main/serial.ts`
`SIMGATEWAY_VID/PID` (also feeds `main/hid.ts`); `Connection.tsx`
"0x2E8A / 0x4134" (both rows) | P, R, H |
| B13 | "A-4E Skyhawk", "OpenSkyhawk", "A-4E Skyhawk DCS-BIOS" |
`SimGateway.cpp` `setProductDescriptor` / `setManufacturerDescriptor` /
`Serial.setStringDescriptor` |
`USB_IDENTITY.product/.manufacturer/.cdcInterface` | `Connection.tsx`
Identity row and "A-4E Skyhawk · Gateway" | P, R, H |
| B14 | 250000 | `SimGateway.cpp` `Serial.begin(250000)` (the CDC one,
not `_uart->begin`) | `USB_IDENTITY.baud` | `main/serial.ts` `BAUD` | P,
R |
| B15 | 92 | `AxisCal.h` `CAL_MAX_FRAME = CAL_ENVELOPE_BYTES +
CAL_MAX_PAYLOAD` | `CAL_PROTO.maxFrame` | `shared/calibration.ts`
`CAL_MAX_FRAME` (was the same sum, client-side) | P (= envelope +
payload = 92), R |
| B16 | 30000 | `SimGateway.cpp` `constexpr uint32_t
CAL_SESSION_TIMEOUT_MS = 30000` | `CAL_PROTO.sessionTimeoutMs` |
`main/callink.ts` `deviceTimeoutMs` default (`30_000`). `KEEPALIVE_MS =
10_000` stays client policy; only its comment cites the synced timeout.
| P (read off a fresh `CalLink`), R |
| B20 | 1000 | `TelemSchedule.h` `static constexpr uint32_t
TELEM_PERIOD_MS = 1000` | `TELEM_PERIOD_MS` | None in code.
`TELEM_STALE_MS = 10_000` stays client policy, and its comment now cites
the period. | R |
| B11 | 8 | `SimGateway.cpp` HID descriptor, hat Logical Min/Max |
`HID_REPORT_LAYOUT.hats.directions` | `tabs/Hid.tsx` "8-way POV" (was
built from `hats.centeredAtOrAbove`) | P, R, H |
| R1.1 (b) | 32767 | `SimGateway.cpp` axis Logical Maximum (already
synced) | `HID_REPORT_LAYOUT.axes.max` | `CalibrationDialog.tsx` "·
signed ±32767" and the scale's `<span>32767</span>` | P, R, H |

How each value was checked:
- **(R)** A new `reference.test.ts` assertion pins the value equal to
the old literal, so a firmware change fails loudly. The pinned values
are VID `0x2e8a`, PID `0x4134`, the three strings, baud 250000,
`MAX_NODE_ID` 63, `TELEM_PERIOD_MS` 1000, `hats.directions` 8 (plus
`centeredAtOrAbove` 8 and `axes.max` 32767), `maxFrame` 92 (= envelope +
payload) and `sessionTimeoutMs` 30000.
- **(P)** A one-off proof test, not committed. It compared every
replacement with the old literal: the exported `SIMGATEWAY_VID/PID`, the
built `"0x2E8A / 0x4134"` string, `CAL_MAX_FRAME`, a fresh `CalLink`'s
`deviceTimeoutMs`, `parseNodeStatus`/`parseNodeTelem` acceptance for
node ids 0–255, and `CAL_TYPE`/`CAL_NACK` entries in key order. 5/5
passed.
- **(H)** A one-off render proof, also not committed. In jsdom it
rendered `<Connection />` twice (connected, and no-device), `<Hid />`
once (connected), and `<CalibrationDialog>` twice (link up, and link
down/paused), with its controller fed an 8-axis snapshot. It dumped
`innerHTML` and `textContent` once at the base (f833939) and once at
this head. The two 27.8 KB dumps are **identical** (SHA-1
`07bd9daca558bfadb75841b4beabd061ee5baecf`). The dumps contain every
swapped string: "0x2E8A / 0x4134", "0x2E8A / 0x4134 · target", "A-4E
Skyhawk", "A-4E Skyhawk · Gateway", "8-way POV", "· signed ±32767" and
">32767<". Each swapped JSX text is still a single text node (a template
literal), so the markup does not split.

**No value differed, so every one was swapped.**

## Typed names (R1.1 carry-over)

The generator now emits literal name unions next to the code-keyed
tables: `CalTypeName`, `CalNackName` and `NodeFaultName`.
`PduRailFlagName` / `PduStatusFlagName` are `keyof typeof` the flag
objects. The info interfaces now type `name` with these unions. The
tables themselves keep `Partial<Record<number, …>>`, so indexing by a
wire byte is still allowed and still yields `| undefined`.

- `byName<N>()` in `shared/calibration.ts` now returns
`Readonly<Record<N, number>>`. `CAL_TYPE.HELLO` type-checks and
`CAL_TYPE.FOO` does not compile.
- `REACHABLE_NACK` is a `ReadonlySet<CalNackName>`.
- `FAULT_NOTE` (`shared/gateway.ts`) and `FAULT_TEXT`
(`main/node-hub.ts`) are keyed by `NodeFaultName`, not `string`.
- `reference.test.ts` proves this with `// @ts-expect-error` on
`CAL_TYPE.FOO`, `CAL_NACK.BAD_FOO` and a misspelt `NodeFaultName`. I
checked that the directives carry weight: with the `CAL_TYPE.FOO` one
removed, `tsc` fails with `TS2339: Property 'FOO' does not exist on type
'Readonly<Record<CalTypeName, number>>'`.

## Grep proof

Each pattern was run with `git grep -nE <pattern> -- src
':!*.generated.ts' ':!*.test.ts' ':!*.test.tsx'`, with comment lines
filtered out. The counts are base (f833939) → head:

```
3 -> 0   (<=|>) 63([^0-9]|$)
5 -> 2   0x2e8a|0x2E8A|0x4134
1 -> 0   250000
2 -> 0   'A-4E Skyhawk'|>A-4E Skyhawk
1 -> 0   CAL_ENVELOPE_BYTES \+ CAL_MAX_PAYLOAD
1 -> 0   deviceTimeoutMs = 30_000
2 -> 0   signed ±32767|<span>32767</span>
1 -> 0   centeredAtOrAbove\}-way
4 -> 0   Partial<Record<string, (string|\{ label)|Readonly<Record<string, number>>|ReadonlySet<string>
```

The two remaining VID/PID hits are in
`src/main/test-harness/services.ts:126-127`, the `vi.doMock('../serial',
…)` double. The same double is in `session.test.ts:80-81`. They are test
fakes of the module's exports, not production copies, and I left them
alone.

## Not done here, on purpose

- Out of scope until R1.5 / the pin bump: B2–B9, B17, B18 and B21. B19
is untouched, since it is already derived.
- B13 `manufacturer` and `cdcInterface` are synced and pinned, but no
client code reads them today, so no site changed.
- No client wording was replaced with firmware comments (B10).

## Checks

- `npm run sync` at d29433ab regenerates every file. Running it twice
gives no diff, and it was run again after the commit hooks: `git diff
--exit-code -- src/shared/firmware` is clean, which is the CI freshness
gate.
- `npm run typecheck` is clean.
- `npm run lint` has 0 errors (32 warnings, the same as the baseline).
- `npm run lint:deps` has 0 errors (65 warnings, the same as the
baseline).
- `npx vitest run` passes 62 files: 852 tests, which is 844 plus 8 new
`reference.test` cases. No existing assertion changed.
- `npm run build` succeeds.

Refs #98
R1.5 of the structure rewrite. Firmware PR OpenSkyHawk/OpenSkyhawk#347
gave names to nine protocol values that used to exist only as comments
or bare literals. This PR syncs those names and swaps the client's
hand-typed copies for them (plan §3.3 rows B2–B9 and B17). Nothing
changes in behaviour: no UI, wording or output differs.

## 1. Pin bump: d29433ab → a78dd902

`PIN.commit` now points at `a78dd90276fffb820b64ba74b82f354ab167348a`,
the #347 merge. It is the only firmware commit between the two pins.

The pin was bumped and `npm run sync` run in a commit of its own
(c150ca8), before any generator change. That commit touches exactly one
line in each of the 8 generated files: the `// Source: … @ <commit>`
banner. `node-names.generated.ts` also has
`NODE_REGISTRY_SOURCE.commit`. Every value is identical, and the
registry `date` stays 2026-10-16.

```
 scripts/sync-a4ec.ts                               | 2 +-
 src/shared/firmware/*.generated.ts (8 files)       | 1+/1- each (node-names 2+/2-)
```

## 2. What the sync now generates

These are additions to the existing generators. No new script.
`constexprNum` accepts a leading `-` on its first term, which the two
signed sentinels need. A new `parseOffsets` reads an offset enum and
checks two things: the offsets are strictly ascending, and each lies
inside the row or payload it indexes. A relayout the decoders can't read
field by field fails the sync instead of mis-decoding.

**`node-status.generated.ts`** (from `NodeStatus.h`):
- `GATEWAY_NODE_ID` = 0
- `NODE_STATUS_ROW_BYTES` = 14 and `NODE_STATUS_FIELD` {ID 0, PRESENT 1,
HB_FLAGS 2, UPTIME 3, RX_COUNT 5, ESR 7, DIE_TEMP_C 9, HEALTH_FLAGS 10,
FAULT_MASK 11, FAULT_ID 12, NODE_KIND 13}
- `HEARTBEAT_FLAGS` {BOFF 0x1, EPVF 0x2}
- `HB_ESR_TEC_SHIFT` = 0 and `HB_ESR_REC_SHIFT` = 8
- `DIE_TEMP_UNAVAILABLE` = -128
- `NODE_TELEM_ROW_BYTES` = 9 and `NODE_TELEM_FIELD` {ID 0, FRAME 1}
- `PDU_RAIL_FIELD` {FLAGS 1, V_AVG_MV 2, V_MIN_MV 4, I_AVG_MA 6}
- `PDU_STATUS_FIELD` {FLAGS 1, NTC_TEMP_DC 2, I12_PEAK_MA 4, I5_PEAK_MA
6}
- `NTC_TEMP_UNAVAILABLE` = -32768

**`axis-cal.generated.ts`** (from `AxisCal.h`):
- `CAL_PROTO.dataRecBytes` = 10, from `CAL_DATA_REC_BYTES`.
- `CAL_HELLO_ACK_FIELD`, `CAL_DATA_FIELD`, `CAL_DATA_REC_FIELD`,
`CAL_SESSION_OPEN_FIELD`, `CAL_SESSION_ACK_FIELD`,
`CAL_STREAM_SELECT_FIELD`, `CAL_COMMIT_FIELD`, `CAL_RESET_FIELD`,
`CAL_ACK_FIELD`, `CAL_NACK_FIELD`, `CAL_RAW_FIELD`. The type prefix is
stripped from each entry, so `CAL_COMMIT_MIN` becomes
`CAL_COMMIT_FIELD.MIN`. The sync fails if an entry lacks the expected
prefix. Each table is bounded by its type's synced payload length; the
per-axis record table is bounded by `CAL_DATA_REC_BYTES`.

Every table is an `as const` name-keyed object, so a misspelt field name
doesn't compile. Each entry keeps the firmware's trailing comment (`//
u16: AxisCal::min`). `reference.test.ts` pins every new value at today's
literal, so a future firmware relayout fails there.

## 3. Client sites replaced

| Name | Client site replaced | How equality was proven |
|---|---|---|
| `GATEWAY_NODE_ID` (B2) | `shared/nodes.ts` `BRIDGE_NODE_ID = 0`
(export kept, now `= GATEWAY_NODE_ID`); `tabs/settings/settings-view.ts`
`nodeId === 0` | 0 === 0. `registryOnBus` base vs head over node IDs
0–70 × 4 models × link up/down. Settings › About › registry render
identical |
| `NODE_STATUS_ROW_BYTES` + `NODE_STATUS_FIELD` (B3) | `nodes.ts`
`HEX_LEN = 28`, the `{28}` regex, and every `f(i, n)` hex slice |
`parseNodeStatus` base vs head, `toStrictEqual`, over **89,949 rows**
(below) |
| `HEARTBEAT_FLAGS` (B4) | `nodes.ts` `flags & 0x01` / `& 0x02` | same
corpus, all 256 HB_FLAGS bytes |
| `HB_ESR_TEC_SHIFT` / `HB_ESR_REC_SHIFT` (B5) | `nodes.ts` `esr & 0xff`
/ `(esr >> 8) & 0xff` | same corpus, all 65,536 ESR values |
| `DIE_TEMP_UNAVAILABLE` (B6) | `nodes.ts` `traw === 0x80` | all 256
die-temp bytes, base vs head (sign convention below) |
| `NODE_TELEM_ROW_BYTES` + `NODE_TELEM_FIELD` (B7) | `node-telem.ts`
`HEX_LEN = 18`, the `{18}` regex, `slice(0, 2)`, the frame-byte slice `2
+ i*2`, `slice(4)` for the unknown-page body | `parseNodeTelem` base vs
head, `toStrictEqual`, over **87,133 rows** (below) |
| `PDU_RAIL_FIELD` / `PDU_STATUS_FIELD` (B8) | `node-telem.ts` `b(1)`,
`u16(2/4/6)`, `i16(2/6)` | same corpus, both rail pages at the u16/i16
extremes |
| `NTC_TEMP_UNAVAILABLE` (B9) | `node-telem.ts` `NTC_UNAVAILABLE =
-0x8000` | -0x8000 === -32768. All 65,536 NTC words on the status page,
base vs head |
| `CAL_HELLO_ACK_FIELD` (B17) | `calibration.ts` `decodeHelloAck`
`p[0..5]` | every decoder, base vs head, payload lengths 0–90 (short,
exact and long) × 7 payloads each (all-0, all-0xff, all-0x80, all-0x7f,
random), plus 2,000 random 82-byte payloads |
| `CAL_DATA_FIELD` + `CAL_DATA_REC_FIELD` + `CAL_PROTO.dataRecBytes`
(B17) | `decodeCalData` `p[0]`, `p[1]`, `2 + i * 10`, `o + 2/4/6/8` |
same |
| `CAL_SESSION_ACK_FIELD` (B17) | `decodeSessionAck` `u16(p, 0)`,
`u16(p, 2)`, `p[4]` | same |
| `CAL_ACK_FIELD` / `CAL_NACK_FIELD` / `CAL_RAW_FIELD` (B17) |
`decodeAck` `p[0]`; `decodeNack` `p[0..2]`; `decodeRaw` `p[0]`, `u16(p,
1/3)` | same |
| `CAL_COMMIT_FIELD` (B17) | `encodeCommit` `p[0]`, `put(1/3/5/7)`, and
`new Uint8Array(9)` → `CAL_PAYLOAD_LEN[COMMIT]` (already synced, already
pinned at 9) | base vs head bytes: 6 seqs × 5 idx × 12 values (incl.
0x10000, -1, 1.5, NaN), plus 2,000 random axes per seq. Demux round-trip
identical |
| `CAL_SESSION_OPEN_FIELD` / `CAL_STREAM_SELECT_FIELD` /
`CAL_RESET_FIELD` (B17) | `one(type, seq, byte)` with its implicit
offset 0 (`Uint8Array.from([byte])`) | base vs head bytes: 6 seqs × 20
byte values (incl. -1, 256, 1.5, NaN, ±Infinity, 1e9). `one` now sizes
the payload `at + 1`, so if the field ever moved off byte 0,
buildFrame's length check would throw rather than send a zero |

**Status-row corpus (89,949 rows):**
- every hex literal in the repo's test files and fixtures
- `statusRow()` for node IDs 0–255 × present 0/1 × kinds 0–3, 4 and 0xff
- each field at its min/max, with the rest all-min and all-max
- every die-temp byte, every ESR value, and every byte of flags, present
and hFlags
- 20,000 random mixed-case rows
- malformed rows: lengths 26/27/29/30, whitespace, non-hex, `0x`,
`+`/`-` prefix, Arabic-Indic digit

`isLegacyNodeStatus` was compared over the same rows.

**Telemetry corpus (87,133 rows):**
- every fixture literal
- node IDs 0–255 × pages 0–4
- every page byte
- every NTC int16
- rail extremes
- 20,000 random rows
- malformed rows

The proof tests were mutation-checked. With `esr >> 7` and `p[F.DETAIL]`
swapped in for the reason byte, they fail. They are throwaway files and
are not committed.

**Render compare:** each view is fed rows decoded through the decoders:
a gateway row, panels with bus-off/error-passive, the die-temp sentinel,
-40 °C and overheat+fault, a PDU, a removed node, rail/status/unknown
TELEM pages including the NTC sentinel and an INT16_MIN current, and
decoded CAL_DATA / SESSION_ACK / RAW. I dumped the HTML of 8 views:
Cockpit, the Cockpit panel sheet, the Cockpit PDU sheet, Connection,
HID, the Calibration dialog, Settings › About, and the Settings registry
dialog. Head was dumped twice to confirm the output is deterministic.
Then the four changed source files were swapped back to base and the
views dumped again. `diff -r` base vs head: **identical**.

## 4. Sign conventions

- **B6, die temperature.** The firmware value is signed int8 -128. The
row carries its two's-complement byte, 0x80. The decoder already
converted the byte to signed (`traw < 128 ? traw : traw - 256`). It now
converts first and compares the signed value with
`DIE_TEMP_UNAVAILABLE`. That is the same test as the old `traw ===
0x80`, because the signed value is -128 exactly when the byte is 0x80.
Proven for all 256 byte values. `reference.test` also pins
`DIE_TEMP_UNAVAILABLE & 0xff === 0x80`.
- **B9, NTC.** `i16()` already produced the signed value, and the old
constant was `-0x8000`, so `NTC_TEMP_UNAVAILABLE` (-32768) drops
straight in. Proven for all 65,536 words. `reference.test` pins `&
0xffff === 0x8000`. The `i16` sign conversion (`v >= 0x8000 ? v -
0x10000 : v`) is generic int16 handling, not the sentinel, so it stays.

## 5. What stays hand-typed, and why

- `V2_HEX_LEN = 26` in `nodes.ts`. The legacy v2 row is detected
client-side, only to say "this bridge is too old". The firmware no
longer describes it, so there is nothing to sync from. A comment now
says so.
- Hat direction labels (CTR N NE … NW): client wording.
- The TELEM page byte, read as `b(0)`. The firmware names no field for
it; `NodeTelemField` says "TelemPage at FRAME + 0".
- The calibration envelope offsets in `buildFrame` / `classify` (type
@4, seq @5, len @6–7, payload @8). The firmware has no envelope-field
enum, and they were outside B17.
- Out of scope per the brief: B18 (CRC parameters; the vector test is
kept) and B21 (hysteresis text). Cockpit-adopt's Gateway wording is
untouched.

## 6. Grep proof

Each pattern was grepped over `src/shared/nodes.ts`, `node-telem.ts`,
`calibration.ts` and `renderer/src/tabs/settings/settings-view.ts`.
Every count is 0:

```
0  BRIDGE_NODE_ID = 0
0  nodeId === 0
0  HEX_LEN = (28|18)
0  \{28\}|\{18\}
0  f\([0-9]+, [0-9]\)
0  flags & 0x0[12]
0  esr >> 8|esr & 0xff
0  traw === 0x80
0  NTC_UNAVAILABLE|-0x8000
0  hex\.slice\(4\)|slice\(2 \+ i
0  b\([1-7]\)|u16\([2-6]\)|i16\([2-6]\)
0  p\[[0-9]\]
0  u16\(p, [0-9]|o \+ [0-9]|2 \+ i \* 10
0  put\([0-9]
0  new Uint8Array\(9\)
0  Uint8Array\.from\(\[byte\]\)
```

A repo-wide search for `0x80`, `0x8000`, `INT16_MIN`, `=== 0` on node
IDs and `>> 8) & 0xff` outside tests and fixtures found only generic
byte handling: LE writes, CRC, int16 sign conversion, and dcsbios export
frames.

## Verification

- `npm run sync` is idempotent at the new pin: the diff is
byte-identical before and after a re-run. That is the CI freshness gate.
- `npm run typecheck`: clean.
- `npm run lint`: 0 errors. No warnings in the changed files.
- `npm run lint:deps`: 0 errors.
- `npx vitest run`: 62 files, 856 tests. That is 852 plus 4 new
reference pins. Existing assertions are unchanged.
- `npm run build`: passes.

Refs #98
Refs OpenSkyHawk/OpenSkyhawk#346
…109)

R1.3 of the structure rewrite (#101, phase #98). Mechanical only: UI,
wording and behaviour are unchanged. IPC channel strings and payload
shapes are byte-identical. The shared format helpers are not in this PR;
they come in R1.3b.

## Topic contracts

`src/shared/ipc.ts` is gone. Each topic now has its own
`src/shared/<topic>/ipc.ts` and an `index.ts` that re-exports it.

| Topic | What its contract holds |
|---|---|
| **app** | `SkyhawkApi` (verbatim). `PushChannels`, the union of the
topics' push maps. `PushChannel`. `IPC` and `CTRL`, spread from the
topics' channel objects. `RelayStatus`, the `relay:status` rehydrate
bundle. `DebugDumpResult`. `APP_CTRL` (`relay:status`,
`debug:dump-ports`, `debug:reveal`) |
| **gateway** | `DeviceState`, `isLinkUp`, `DeviceStatus`,
`GatewayKind`, `GatewayStatus`, `SerialFrame`. Pushes `device:status`
and `serial:traffic`. Intents `device:connect`, `device:disconnect`,
`serial:monitor` |
| **dcs** | `EXPORT_MULTICAST_ADDR`, `EXPORT_PORT`, `COMMAND_PORT`.
`StreamState`, `RelayState`, `AircraftStatus`, `StatsSnapshot`,
`LogRow`, `TelemetryReadout`, `RelayResult`, `ExportResult`,
`CaptureState`, `ReplayLoad`, `CaptureEvent`, `CaptureMeta`. Pushes
`relay:state`, `aircraft:changed`, `stats:tick`, `log:batch`,
`telemetry:tick`. Intents `relay:start`, `relay:stop`, `log:export`,
`capture:toggle`, `replay:open` |
| **nodes** | `NodeStatus`, `NodeSeen`, `NodeIdentity`, `NodeKind`. The
mapping shapes. The telemetry page shapes and `NodeTelemSnapshot`. The
model shapes. The PSU shapes and `DEFAULT_PSU`. `NodeRegistryInfo`,
`NodeSnapshot`, `NodeMappingResult`. Pushes `nodes:telemetry` and
`nodes:model`. Intents `nodes:*` and `node-mapping:*` |
| **hid** | `HidSnapshot`, `HidAvailability`. Push `hid:report`. Intent
`hid:availability` |
| **hid/calibration** | `CalRawSample`, `CalSnapshot`, `CalFailure`,
`CalResult`, `CalHello`, `CalCommitAxis`, `CalCacheEntry`. `CachedAxis`,
`CachedBoard`, `CalCache`. The capture shapes (`CaptureConfig`,
`CaptureStep`, `CapturePhase`, `CapturedSweep`, `CaptureResult`,
`CaptureState`). Pushes `cal:data` and `cal:raw`. Intents: the ten
`cal:*` |
| **events** | `EventSource`, `EventSeverity`, `LogEventInput`,
`LogEvent`, `MAX_EVENTS`. Push `events:batch`. Intents `events:get`,
`events:clear`, `events:export` |
| **config** | `SourceMode`, `DcsTransport`, `SOURCE_MODES`,
`TRANSPORTS`, `AppConfig`, `DEFAULT_CONFIG`, **`SettingsStore`** (new,
type-only). `CONFIG_FORMAT`, `AppSettings`, `ConfigSnapshot`,
`SectionId`, `ConfigFile`, `DiffRow`, `SectionDiff`.
`ConfigExportResult`, `ImportPreview`, `ConfigImportOpen`,
`ConfigImportApplyResult`. Intents `config:*` |

Each topic also exports a `<TOPIC>_PUSH` / `<TOPIC>_CTRL` channel object
and a `<Topic>PushChannels` map. `IPC` and `CTRL` are spreads of those
objects, so their key order differs from before. Nothing iterates either
object; the guard below checks their exact types.

`DEFAULT_CONFIG` now reads `commandPort: COMMAND_PORT` and `listenPort:
EXPORT_PORT` from the dcs contract. The values are the same: 7778 and
5010, checked at runtime.

`SettingsStore` is a read-only store over `AppConfig`: `getState()` plus
both `subscribe` overloads, shaped like zustand/vanilla's `StoreApi`
under `subscribeWithSelector`. Nothing uses it yet; R2.3 wires it. The
guard file checks that a real zustand `Mutate<StoreApi<AppConfig>,
[['zustand/subscribeWithSelector', never]]>` is assignable to it.

## Pushed shapes moved out of the models (the 52 "MOVE (pushed shape)"
rows)

- `nodes.ts`: `NodeStatus`, `NodeSeen`
- `node-telem.ts`: `PduRailPage`, `PduStatusPage`, `UnknownTelemPage`,
`TelemPageData`, `NodeTelem`, `TelemPageView`, `NodeTelemSnapshot`. The
page literal types read `typeof TELEM_PAGES.X` from `shared/firmware`;
`TELEM_PAGE` was an alias of it, so the types are identical.
- `node-kind.ts`: `NodeIdentity`
- `node-mapping.ts`: `NodeKind`, `NodeOverride`, `NodeMappingFile`,
`NodeDefaults`, `MappedNode`, `MappingPatch`, `AdoptSpec`,
`ResetOptions`, `ResetRow`, `AdoptedRow`, `ResetPreview`
- `node-rules.ts`: `NodeAvailability`, `NodeModelEntry`, `PduOutage`,
`HiddenNode`, `NodeModel`
- `psu.ts`: `PsuLimits`, `PsuPresetId`, `PsuPreset`, `PsuSetting`
- `events.ts`: `EventSource`, `EventSeverity`, `LogEventInput`,
`LogEvent`
- `gateway.ts`: `GatewayKind`, `GatewayStatus`
- `config-file.ts`: `AppSettings`, `ConfigSnapshot`, `SectionId`,
`ConfigFile`, `DiffRow`, `SectionDiff`
- `cal-cache.ts`: `CachedAxis`, `CachedBoard`, `CalCache`
- `axis-capture.ts`: `CaptureStep`, `CapturePhase`, `CapturedSweep`,
`CaptureResult`, `CaptureState`
- `capture.ts`: `CaptureEvent`, `CaptureMeta`

These also had to move, because a contract value or shape is defined by
them. Without them, C1 can't reach 0:

- `DEFAULT_PSU` → nodes. `DEFAULT_CONFIG.psu` is `DEFAULT_PSU`, and it's
still the same object.
- `CONFIG_FORMAT` → config (`ConfigFile.format` is `typeof
CONFIG_FORMAT`).
- `MAX_EVENTS` → events (plan row 236).
- `CaptureConfig` → hid/calibration (`CaptureState.config`).
- `EXPORT_MULTICAST_ADDR` / `EXPORT_PORT` / `COMMAND_PORT` → dcs (plan
rows 1–3).

The model files import their types from the contracts. No names changed:
`LogRow` → `StreamRow` and `CaptureState` → `RecordState` wait for R3.4.
All importers moved to the topic path: 49 files imported `@shared/ipc`,
99 existing files changed in total (imports, plus the moved declarations
in the 13 model files). Tests changed import paths only. The one dynamic
import in `session.test.ts` now reads `@shared/config/ipc`.

## Cycles broken (C2)

| Cycle | How |
|---|---|
| config-file ↔ ipc (value) | `config-file` imports the config contract.
`sanitizeConfig` calls a model (`sanitizePsu`), so it can't stay in a
contract. It moves to `shared/settings-schema.ts`, the plan's file name,
still in shared until R1.4 moves it to `main/config/` |
| config-file → cal-cache → ipc → config-file | `cal-cache` takes
`CalSnapshot` from the hid/calibration contract, and the contract no
longer imports `config-file` |
| events ↔ ipc (type) | `LogEvent` and friends are in the events
contract. `events.ts` reads `DeviceStatus`, `RelayState` and
`SourceMode` from the gateway, dcs and config contracts |
| gateway → events → ipc → gateway | `GatewayStatus` is in the gateway
contract |
| cal-cache ↔ ipc (type) | The cache shapes are in the hid/calibration
contract |
| node-kind ↔ node-mapping (model ↔ model) | `NodeKind` and
`NodeIdentity` both move into the nodes contract. `node-kind` no longer
imports `node-mapping`; `node-mapping` → `node-kind` (`isAdoptableKind`)
is the only edge left. No code moved between the models |

## Lint counts

| | Before (552b050) | After |
|---|---|---|
| `lint:deps` | 0 errors, 65 warnings | **0 errors, 23 warnings** |
| C2 cycles | 6 | **0** (→ error) |
| P3 shared → process | 0* | **0** (→ error) |
| C1 contract → non-contract | 0* | **0** (→ error) |
| P4 renderer → non-contract shared | 53 | 17 (the rest are value
imports that leave in R3.x) |
| P6 electron outside app | 5 | 5 (R1.4) |
| T2 renderer deep import | 1 | 1 (`logo.png`, R3.4) |
| `npm run lint` | 0 errors, 32 warnings | 0 errors, 32 warnings |
| `vitest` | 62 files, 856 tests | 62 files, 856 tests |

\* Before this PR, no file matched the `shared/<topic>/(index\|ipc).ts`
contract pattern, so P3 and C1 had nothing to report. §5.4 counted the
same 8 edges by hand-mapping; those are gone now. I checked that the
rules fire: a probe import from `events/ipc.ts` into `events.ts` errors
on both C1 and C2.

`.dependency-cruiser.severity.json` flips `process`, `contracts`,
`cycles` and `formatLeaf` to `"error"`. `formatLeaf` has nothing to
match until R1.3b.

## API-equality guard

`src/shared/app/ipc.compat.test-d.ts` is type-only; vitest and the build
don't pick it up. `npm run typecheck` checks it under both tsconfigs. It
holds a frozen copy of the pre-split `SkyhawkApi`, `PushChannels`, `IPC`
and `CTRL`, and asserts exact `Equal<Old, New>` for all four.

The first commit (8e3830f) added it against the old `@shared/ipc` and
typechecked green. It's still green after the split.

**Fail-proof.** I made each change temporarily, ran typecheck, then
reverted:
- `exportLog(text: string, mode?: number)` gives
`ipc.compat.test-d.ts(187,10): error TS2344: Type 'false' does not
satisfy the constraint 'true'` (SkyhawkApi).
- `serialTraffic: 'serial:traffik'` in `GATEWAY_PUSH` gives the same
error at (189,10) (IPC).
- `SettingsStore.getState(): AppConfig & { extra: 1 }` gives the same
error at (199,3) (zustand fit).

**Preload runtime.** The preload still keeps its explicit method table.
It now imports `CTRL` and the types from `@shared/app/ipc` and the topic
contracts. Its exposed API was diffed at runtime, before and after: the
preload ran with `electron` stubbed, and every method was called with
sentinel args while recording what reached `ipcRenderer`. The result is
identical: key `skyhawk` and 41 methods (`on`, `getConfig`, `setConfig`,
`exportConfig`, `openConfigImport`, `applyConfigImport`, `startRelay`,
`stopRelay`, `getStatus`, `connectDevice`, `disconnectDevice`,
`exportLog`, `getEvents`, `clearEvents`, `exportEvents`,
`toggleCapture`, `openReplay`, `getHidAvailability`, `refreshNodes`,
`getNodes`, `setNodeMapping`, `adoptNode`, `forgetNode`, `resetNode`,
`resetNodeMapping`, `previewNodeReset`, `getNodeRegistry`, `hideNode`,
`setSerialMonitor`, `dumpSerialPorts`, `revealDebugLog`, `calHello`,
`calRead`, `calSessionOpen`, `calStreamSelect`, `calCommit`, `calReset`,
`calCacheRead`, `calCacheStore`, `calCacheDrop`, `calSessionClose`).
Each one invokes the same channel with the same args. `IPC` and `CTRL`
hold the same key→channel pairs as before (also checked at runtime).

## Shim status

There is **no shim**. `src/shared/ipc.ts` is deleted, and nothing in
`src/` imports `@shared/ipc` or a relative `./ipc` that points at it.

Importers use `@shared/<topic>/ipc`, not the bare `@shared/<topic>`.
Today `@shared/gateway`, `@shared/events`, `@shared/hid` and
`@shared/nodes` resolve to the model files `gateway.ts`, `events.ts`,
`hid.ts` and `nodes.ts`, because a file wins over a directory index. The
`index.ts` files are in place; once R1.4 moves those models to main, the
imports can drop `/ipc`.

## Verification

`npm run typecheck`, `npm run lint` (0 errors), `npm run lint:deps` (0
errors), `npx vitest run` (856/856) and `npm run build` all pass. The
T14 app-shell component test passes unchanged.

Refs #98
R1.3b of the structure rewrite (#101). Adds the `src/shared/format` leaf
and points main, shared and the renderer at it. Every merged copy has
one home now. Nothing changes on screen, in wording or in behaviour.

- `src/shared/format/{text,time,units,events,psu}.ts`, with `index.ts`
as the only import path. Main and the renderer import `@shared/format`.
Files inside `src/shared` use `./format`, the same convention as
`./firmware`.
- The folder imports nothing else in `src`, type-only imports included.
`L-format-leaf` stays at error and is clean.
- Each module has its own test file (27 tests). The expected values come
from the callers' existing tests and from today's rendered output. They
pass in UTC, America/Los_Angeles and Asia/Kolkata.
- The existing tests' assertions are unchanged. Only their import paths
moved (11 files).

## Merged helpers

| Helper (`@shared/format`) | Copies merged (base `ef57688`) | How
equality was proven |
|---|---|---|
| `plural` | `renderer/overview.ts`,
`renderer/tabs/settings/settings-view.ts`, `shared/config-file.ts` |
sweep A |
| `onOff` | `shared/config-file.ts` (moved) | sweep A |
| `title` | `shared/gateway.ts` (moved) | sweep A (incl. `''`, which
throws in both) |
| `DASH` | `renderer/cockpit-panels.ts` (+ the `'—'` in `SidePanels.tsx`
`pv`) | value identity |
| `fileName` | `renderer/log-view.ts` (moved) | sweep A |
| `errorText` | `main/node-mapping.ts` `message`, plus the inline copy
in `main/config-transfer.ts` | sweep A over Error, its subclasses,
strings, numbers, null, undefined, objects, Symbol, BigInt and
null-prototype objects |
| `pad`, `pad3` | `renderer/store.ts`, `renderer/tabs/Serial.tsx`,
`renderer/tabs/settings/settings-view.ts`; `shared/events.ts` `pad(n, w
= 2)`: `pad(n)` → `pad`, `pad(n, 3)` → `pad3` (its only callers) | sweep
N |
| `fmtTime` | `renderer/store.ts`, `renderer/tabs/Serial.tsx` | sweep T
|
| `hhmmss` | `renderer/connection-summary.ts` `clock`, plus the inline
`fmtTime(e.t).slice(0, 8)` in `tabs/cockpit/BoardEvents.tsx` | sweep T |
| `hhmm` | `renderer/tabs/Cockpit.tsx` `hhmm`,
`renderer/tabs/settings/settings-view.ts` `clock` | sweep T |
| `day` | `renderer/tabs/settings/settings-view.ts` (moved) | sweep T |
| `fmtDateTime` | `shared/events.ts` (moved) | sweep T |
| `lastSeenLabel` | `renderer/cockpit.ts` (moved) | sweep T |
| `seenAgo` | `renderer/cockpit-adopt.ts` (moved) | 40k (firstSeenAt,
now) pairs incl. `undefined`, NaN and ±Infinity |
| `fmtUptime` | `renderer/store.ts` (moved) | sweep N |
| `stamp` | `main/config-transfer.ts`, plus the inline
`toISOString().replace(…)` in `main/node-mapping.ts` | 5k dates incl. an
invalid date (throws `RangeError` in both) |
| `volts` (`12.00 V`) | `shared/events.ts` (moved) | sweep N |
| `voltsValue` (`12.00`) | `renderer/tabs/Cockpit.tsx` `volts`; the
number inside `SidePanels.tsx` `pv` | sweep N |
| `amps` | the inline `${(… / 1000).toFixed(2)} A` in `shared/events.ts`
`railReading` and in `renderer/cockpit-adopt.ts` `rail` | sweep N
through `displayCurrentMa` |
| `fmtBytes` | `renderer/store.ts` (moved) | sweep N |
| `fmtPct` | `renderer/cockpit.ts` (moved) | sweep N |
| `hex2`, `hex4` | `renderer/store.ts` (moved) | sweep N |
| `eventText` | `shared/events.ts` (moved). The parameter is now the
structural `{ event; detail? }`, because the leaf can't import
`LogEvent`. Every caller passes a `LogEvent` | every event × detail
combination incl. `''` and `undefined` |
| `PSU_LIMITS` (`psu.ts`) | `shared/config-file.ts` `LIMITS`
`{key,label,unit}`, `renderer/tabs/settings/CockpitSection.tsx` `LIMITS`
`{k,label,unit,custom}` | deep-equal against both base tables |

**Equality proof.** This is a throwaway vitest file and is not
committed. A script copied every old copy verbatim out of `git show
ef57688:<file>` and checked that each snippet appears byte-for-byte in
base. The test then compared each old copy with its new home. It checked
the return value, or the thrown error's class and message, using
`Object.is`, so `NaN` and `-0` count.
- **Sweep N** (numbers): NaN, ±Infinity, ±0, ±0.5, the rounding edges
`0.005`, `9.95`, `1023.5` and `1024`, the 16-bit edges `65535`, `65536`
and `0x1ffff`, negatives, `±1e21`, the safe-integer limits, every
integer in −3000…3000 (plus /7 and ×1.0001), and 20k seeded random
values spanning 12 orders of magnitude.
- **Sweep T** (times): NaN, ±Infinity, the ±8.64e15 Date limits, 40k
seeded timestamps within ±13 years of 2026-10-16, and Jan 1 / Dec 31
23:59:59.999 of each year from 1969 to 2040.
- **Sweep A** (text): plurals over 8k counts × 9 words × an omitted,
irregular or empty `many`; 14 strings for `title`, incl. `''`, `ß` and
an emoji; 10 path shapes for `fileName`.

The sweeps ran **1,724,217 checks, all equal**. The proof can fail:
changing `pad3`'s fill or `DASH` turned it red, and it went green again
after reverting.

**Render compare.** A throwaway vitest file, not committed, rendered 23
views through the R0.2 harness. The views are:
- Overview, at two stats ticks;
- Cockpit (live, and link-down);
- four node sheets, two PDU sheets, and the Adopt dialog with two
selections;
- Connection, in two scenarios;
- Serial;
- Log › Stream and Log › Events;
- all five Settings sections, plus the Import preview.

They ran on base and on head, after prettier. `diff -r` shows them
identical, and the joined HTML has sha1 `e59c2ce8…` on both sides.

## Copies kept separate (they differ)

- **`volts` vs `voltsValue`.** `events.ts` prints `12.00 V`.
`Cockpit.tsx`'s `volts` and `SidePanels.tsx`'s `pv` print `12.00`, with
no unit.
- **`hhmm` vs `hhmmss`.** `hhmm` is `21:14` (`Cockpit.tsx` `hhmm`,
`settings-view` `clock`). `hhmmss` is `21:14:07` (`connection-summary`
`clock`, BoardEvents).
- `hhmmss` stays defined as `fmtTime(ms).slice(0, 8)`. Its NaN output is
`NaN:NaN:`, so rebuilding it from `pad` would change it.

## Left in place (and why)

- **`SEVERITY_GLYPH`, `isProblem`** (`shared/events.ts`) and
**`SEVERITY_NAME`** (`tabs/Log.tsx`). They are keyed by the events
contract's `EventSeverity`. The leaf can't import it, because depcruise
counts type-only imports.
- Restating the severity union in the leaf would put a second owner on a
contract rule.
- §3.6 row 908 also schedules `SEVERITY_NAME` for R3.4.
`format/events.ts` holds only `eventText`.
- **`rail` text** (`cockpit-adopt.ts`). It needs `displayCurrentMa`, a
model rule from `shared/node-telem`, so it stays local. It is now built
from the shared `volts` and `amps`, with identical output.
- **`pv`** (`SidePanels.tsx`). Its live/undefined gating is view logic,
so it stays local. It is now `voltsValue` + `DASH`.
- **`main/index.ts:107, :126`**: the inline `${err instanceof Error ?
err.message : err}`. This is not merged into `errorText` because it
differs on a `Symbol`: a template literal throws a `TypeError`, while
`String()` returns `Symbol(x)`.

Further copies found and left untouched (different output, or inline in
a file this PR doesn't otherwise change):
- `Connection.tsx:11` `usbId` is like `hex4` but has no 16-bit mask.
- `Connection.tsx:259` has an inline `m[0]!.toUpperCase() + m.slice(1)`.
- `Cockpit.tsx:257` has `const dash = '—'`, and there are other inline
`'—'` literals.
- `cockpit-panels.ts:313-317` has inline `(x / 1000).toFixed(2)`.
`reportAge`'s `ago` is there too.
- `Cockpit.tsx` has `${a.toFixed(2)} A`, where the input is already in
A.
- `config-transfer.ts:82` has `toISOString().slice(0, 10)`, which is the
UTC date and not the local `day`.

## Grep proof

```
$ git grep -nE "(const|function) (plural|onOff|title|DASH|fileName|message|errorText|pad|pad3|fmtTime|hhmm|hhmmss|clock|day|fmtDateTime|lastSeenLabel|seenAgo|fmtUptime|stamp|volts|amps|fmtBytes|fmtPct|hex2|hex4|eventText|LIMITS)\b" -- src ':!src/shared/format' | grep -v '\.test\.'
(no output)
$ git grep -nE "err instanceof Error \? err\.message : String\(err\)|toISOString\(\)\.replace\(/\[:\.\]/g|fmtTime\([^)]*\)\.slice\(0, 8\)|padStart\(3, '0'\)" -- src ':!src/shared/format' | grep -v '\.test\.'
(no output)
```

## Checks (local)

- `npm run typecheck` is clean.
- `npm run lint` has 0 errors. It has 32 warnings, and each changed file
has the same count as on base.
- `npm run lint:deps` has 0 errors and 23 warnings, the same list as
base (P4 17, P6 5, T2 1).
- `npx vitest run`: 67 files, 883 tests. That is 62 / 856 plus the 5 new
files and 27 new tests.
- `npm run build` passes.
- `prettier --check src` is clean.

Refs #98
…d log (#111)

R1.4a of the structure rewrite (#101, phase #98). This PR only moves
files. Nothing changes for the user: the IPC channels, the files written
under `<userData>`, and the startup and shutdown order are all the same.

## What changes

- Main's own files move into `src/main/<topic>/` folders. Each topic
gets an `index.ts` that exports only what other topics use.
- `src/main/index.ts` is now a one-line `import './app'`. The old entry
moved whole to `app/index.ts`.
- `biosconfig.ts` and its test are deleted (UD-E). Nothing imported
them.
- **P6:** only `main/app` imports electron now. Paths and the debug log
are handed in as ports.
- `.dependency-cruiser.severity.json`: `crossProcess` (P1/P2) and
`electron` (P6) are now `error`.

DeviceLink and Session stay whole until R2.2 and R2.3. The `src/shared`
models are untouched (R1.4b). No exported symbol was renamed.

## Old path → new path

| Old | New |
|---|---|
| `index.ts` | `app/index.ts` (the entry `index.ts` is now `import
'./app'`) |
| `session.ts` (+ `.test`, `.seam.test`) | `app/session.ts` |
| `debug.ts` | `app/debug-log.ts` |
| `user-data.ts` (+ test) | `app/user-data.ts` |
| `serial.ts` (+ test) | `gateway/serial-bridge.ts` |
| `device-link.ts` | `gateway/device-link.ts` |
| `net.ts` | `dcs/transports.ts` |
| `decode.ts` (+ test) | `dcs/sim-telemetry-decoder.ts` |
| `replay.ts` (+ test) | `dcs/replay-source.ts` |
| `stats.ts` | `dcs/stream-stats.ts` |
| `reference/dcsbios-metadata.ts` | `dcs/dcsbios-metadata.ts` |
| `node-hub.ts` (+ test, seam test) | `nodes/node-hub.ts` |
| `phase0-probes.test.ts` | `nodes/phase0-probes.test.ts` (it probes
NodeHub) |
| `node-mapping.ts` (+ test, seam test) | `nodes/mapping-store.ts` |
| `hid.ts` (+ test) | `hid/hid-reader.ts` |
| `callink.ts` (+ test) | `hid/calibration/cal-link.ts` |
| `cal-cache.ts` | `hid/calibration/cache-store.ts` |
| `settings.ts` | `config/settings-service.ts` |
| `config-transfer.ts` (+ test, seam test) | `config/config-transfer.ts`
|
| `biosconfig.ts` (+ test) | deleted |

All of these are `git mv`. Over the whole diff, git detects every one as
a rename except `app/index.ts`. The entry file stays at
`src/main/index.ts`, so `app/index.ts` shows as a copy of it, and
`src/main/index.ts` as rewritten. The first commit (c836b36) is renames
only.

## Topic `index.ts` exports

| Topic | Exports |
|---|---|
| `gateway` | `DeviceLink`, `DeviceLinkDeps`, `listSerialPorts` |
| `dcs` | `createTransport`, `Transport`, `Decoder`, `Recorder`,
`ReplaySource`, `ReplayInfo`, `Stats` |
| `nodes` | `NodeHub`, `withFaultInfo` |
| `hid` | `HidReader` |
| `hid/calibration` | `CalLink`, `CalNackError`, `CalTimeoutError`,
`loadCalCache`, `saveCalCache` |
| `config` | `loadConfig`, `saveConfig`, `ConfigTransfer` |
| `app` | the entry itself (its side effects, unchanged) |

## Ports added (P6)

Each port is declared by the module that uses it. `app` provides the
implementation.

| Port | Shape | Declared in | Provided by |
|---|---|---|---|
| `MappingPaths` | `{ nodeMappingFile(): string }` |
`nodes/mapping-store.ts` | `app/user-data.ts` `storePaths()`, via
Session → DeviceLink → NodeHub |
| `CachePaths` | `{ calibrationCacheFile(): string }` |
`hid/calibration/cache-store.ts` | `storePaths()`, via Session →
DeviceLink, and `app/index.ts` for ConfigTransfer |
| `SettingsPaths` | `{ configFile(): string }` |
`config/settings-service.ts` | `storePaths()` in `app/index.ts` |
| `Log` | `(tag: string, data?: unknown) => void` |
`gateway/serial-bridge.ts`, `hid/calibration/cal-link.ts`,
`nodes/node-hub.ts` (one each: one shared declaration would create an
import cycle or a cross-topic import) | `app/debug-log.ts` `debugLog`,
via Session → DeviceLink |
| `DeviceLinkDeps` | `{ paths: { nodeMappingFile();
calibrationCacheFile() }, log: Log }` | `gateway/device-link.ts` |
`app/session.ts` |
| `NodeHubDeps` | `{ paths?: MappingPaths, log?: Log }` |
`nodes/node-hub.ts` | DeviceLink (passes its own deps) |

- **`storePaths(userData: () => string)`** returns getters.
`app/index.ts` calls it after the userData override, as `storePaths(()
=> app.getPath('userData'))`. Each path is still worked out at every
use, the way `join(app.getPath('userData'), name)` was. So the timing is
the same as before.
- **`app/debug-log.ts` is unchanged.** It is in `app`, so it may keep
using electron for `debug.log`.
- **Optional parameters.** The new constructor arguments on
`SerialBridge`, `CalLink` and `NodeHub` are optional and trailing: `log`
defaults to a no-op, and NodeHub's `paths` defaults to one that throws
on first use. Existing unit tests build these classes with their old
arguments.
- DeviceLink's deps are required, and production always passes
everything.
- `Session(emit, paths?)` defaults to the same `storePaths(() =>
app.getPath('userData'))`, for tests that build a Session on its own.
Session is in `app`, so it may import electron.

## lint:deps, per rule

| Rule | Before (ca57ba6) | After |
|---|---|---|
| P4-renderer-imports-contracts-only | 17 warn | 17 warn |
| P6-electron-only-in-app | 5 warn | **0** (now `error`) |
| P1 / P2 | 0 | 0 (now `error`) |
| T2-renderer-no-deep-topic-import | 1 warn | 1 warn |
| topic-main-gateway | n/a (no folders) | 3 warn |
| T2-main-no-deep-topic-import | n/a | **0** |
| **Total** | 23 warn, 0 error | 21 warn, 0 error |

The only new main warnings are the expected ones, all due to DeviceLink
spanning topics until R2.2:

- `gateway/device-link.ts → nodes/index.ts` (NodeHub, withFaultInfo)
- `gateway/device-link.ts → hid/index.ts` (HidReader)
- `gateway/device-link.ts → hid/calibration/index.ts` (CalLink, cache)

The old `→ app` edges (`debugLog` from gateway, nodes and
hid/calibration) are gone: Log is injected now.

`hid/hid-reader.ts` used to import `SIMGATEWAY_VID/PID` from `serial`.
Those two constants are just `USB_IDENTITY.vid/pid`, so it now reads
`USB_IDENTITY` from `@shared/firmware` directly. The values are
identical, and hid has no import from gateway at all. `serial-bridge`
still exports `SIMGATEWAY_VID/PID` as before.

## Proof the paths are unchanged

1. **B13** (`app/session.seam.test.ts`, through `makeServices()`) passes
unchanged. The harness now builds its paths with `storePaths(() =>
world.userData)` and passes them exactly as `app/index.ts` does. B13
writes `node-mapping.json` and `config.json` through those paths and
reads them back by name.
2. **B17** (`nodes/mapping-store.seam.test.ts`) and
`mapping-store.test.ts` hand the store `storePaths(() => userData.dir)`.
They then check the file at `join(userData.dir, 'node-mapping.json')`,
and all their assertions are unchanged.
3. **New `storePaths` unit tests** (`app/user-data.test.ts`, +2) check:
- the three names: `config.json`, `calibration-cache.json`,
`node-mapping.json`;
   - that `<userData>` is worked out at each use.
4. **Built-bundle boot probe.** `out/main/index.js` was built from
ca57ba6 and from this branch, and each was loaded in node with electron,
serialport and node-hid stubbed. Each run went through `whenReady`,
`config:set`, `debug:dump-ports`, `node-mapping:set` and `will-quit`.
The two outputs are **byte-identical**, with both `SKYHAWK_USER_DATA`
set and the dev default (`<appPath>/.userdata`). They match on:
   - the `setPath('userData', …)` call;
   - the order of the `app.on` registrations;
   - the preload and renderer paths;
   - all 40 IPC channels;
   - which files appear in `<userData>`, and what they contain.

## Tests

- 881 tests in 66 files. The old count was 883 in 67 files; the
difference is the 4 `biosconfig` tests removed and the 2 `storePaths`
tests added.
- The tests changed only import paths and `vi.mock` paths, with these
exceptions:
- **`mapping-store.test.ts` and `mapping-store.seam.test.ts`.** The
store no longer reads electron, so their `vi.mock('electron',
…getPath…)` line became a `storePaths(() => userData.dir)` handed to the
store, through a two-line local `loadNodeMapping`/`saveNodeMapping`
wrapper. Every call and assertion line is unchanged.
- **The `makeServices()` harness** passes `paths` to `Session`,
`loadConfig` and `saveConfig`, the way `app/index.ts` does.
  - **Two comments** in tests that named old paths were updated.
- The other `vi.mock('electron')` lines are left as they were; in
modules that no longer import electron they now do nothing.

## Checks

- `npm run typecheck`: passes.
- `npm run lint`: 0 errors, 32 warnings, the same P5 renderer warnings
as on base.
- `npm run lint:deps`: 0 errors.
- `npx vitest run`: 881 passed.
- `npm run build`: passes.

Refs #98
R1.4b of the structure rewrite (#101, phase #98). This PR moves code and
splits files. Nothing changes for the user, and no exported symbol was
renamed.

## What changes

- Every model in `src/shared` that isn't a contract, `firmware` or
`format` moves into its `src/main/<topic>/` file (arch-phase0-v2 §1.2,
§3.6).
- The rules the renderer still calls move into
`src/shared/<topic>/legacy-rules.ts`. These files are pure and marked
`@deprecated`, and main imports them too, so each rule has one copy.
- Imports:
- contracts come from the bare `@shared/<topic>`, since
`shared/gateway.ts`, `events.ts`, `hid.ts` and `nodes.ts` no longer
shadow the folders;
- other main topics are reached through their `index.ts`, with five
exceptions listed below;
  - inside a topic, imports are relative.
- depcruise:
  - P4 accepts `legacy-rules.ts`;
- a new rule `P4-legacy-rules` (severity key `legacyRules: "warn"`)
counts the renderer's calls into those files;
  - `rendererShared` (P4) is now `error`.
- `src/shared` now holds only `<topic>/{index,ipc}.ts`,
`<topic>/legacy-rules.ts`, `firmware/`, `format/` and their tests.

DeviceLink and Session are not split; that is R2.2 and R2.3.

## Commits

1. `68ac0fb` is renames only. Each `git mv` has 100% similarity: a file
that moved whole went to its new home, and a file that split went to the
home of its largest part. The build is broken at this commit.
2. `24d4d3c` splits the files and fixes the imports.
3. `79a3cfe` changes the depcruise rule and severity.

## Old → new

| `src/shared/…` (old) | New home |
|---|---|
| `dcsbios.ts` (+ test) | `main/dcs/bios-protocol.ts`, plus
`main/gateway/link-framing.ts` (split) |
| `main/dcs/dcsbios-metadata.ts` | merged into
`main/dcs/bios-protocol.ts`, then deleted |
| `capture.ts` (+ test) | `main/dcs/capture-file.ts` |
| `calibration.ts` (+ test) | `main/hid/calibration/codec.ts` (CalDemux
included) |
| `cal-cache.ts` (+ test) | `main/hid/calibration/cache-rules.ts` (+
`mergeCalibration`, from config-file) |
| `config-file.ts` (+ test) | `main/config/config-file.ts` |
| `settings-schema.ts`, `config.test.ts` |
`main/config/settings-schema.ts` (+ `.test`) |
| `gateway.ts` (+ test) | `main/gateway/gateway-health.ts`. `isLinkUp`
stays in the gateway contract |
| `events.ts` | split, see below. The test moves to
`main/events/events.test.ts` |
| `nodes.ts` (+ test) | `main/nodes/node-status-wire.ts` (minus
`nodeFaultTag` → legacy) |
| `node-telem.ts` (+ test) | `main/nodes/node-telem.ts` (minus
`displayCurrentMa`, `RAIL_FLAG`, `PDU_FLAG` → legacy) |
| `node-kind.ts` (+ test) | `main/nodes/node-kind.ts` (minus
`reportedIdentity`, `isAdoptableKind` → legacy) |
| `node-mapping.ts` (+ test) | `main/nodes/mapping-rules.ts`,
`registry.ts`, `membership.ts` (split) |
| `node-rules.ts` (+ test) | `main/nodes/node-model.ts`, `power-zone.ts`
(split; `isReportableNew` → legacy) |
| `psu.ts` (+ test) | `main/nodes/psu-limits.ts` (minus the renderer's
four → legacy) |
| `hid.ts` (+ test) | `main/hid/hid-report.ts` |
| `axis-capture.ts` (+ test) | `shared/hid/calibration/legacy-rules.ts`
(+ `.test`), whole |

Each test file moved whole and its cases were not split. A test that
covers symbols that now live in several files imports each one from its
new home.

## The split files

Code is cut and pasted verbatim. A script compared every non-comment,
non-import line of the 17 old files with the new files, as multisets,
with the `export` keyword ignored. There is no difference in either
direction. What changed is new header comments, the `@deprecated` tags,
and `export` added where a private helper is now used across files.

| From | Symbols | To |
|---|---|---|
| `events.ts` | `NodeLookup`, `NodeEventTracker` |
`main/nodes/node-events.ts` |
| | `RAIL_FLAGS`, `PDU_STATUS_FLAGS`, `TEMP_FLAGS`, `railReading`
(private → exported) | `main/nodes/faults.ts` |
| | `StatusFlag`, `STATUS_FLAGS`, `faultLabel` |
`main/events/status-flags.ts` (OQ4) |
| | `clientErrorEvent`, `EventLog`, `cell`, `EVENTS_TSV_HEADER`,
`eventsToTsv` | `main/events/event-log.ts` |
| | `deviceEvent` | `main/gateway/link-events.ts` |
| | `RelayContext`, `MODE_LABEL`, `relayEvents` |
`main/dcs/relay-events.ts` |
| | `SEVERITY_GLYPH`, `isProblem` | `shared/events/legacy-rules.ts` |
| `dcsbios.ts` | `encodeExportFrame`, `parseCommand`, `LineAssembler` |
`main/gateway/link-framing.ts` (OQ2) |
| | the rest (`formatCommand` included, for R3.4) + `ACFT_NAME`,
`A4EC_ADDRESS_RANGE` | `main/dcs/bios-protocol.ts` |
| `node-mapping.ts` | `isPduName`, `registryDefaults` |
`main/nodes/registry.ts` |
| | `adopted`, `inCockpit`, `forgettable` (private → exported) |
`main/nodes/membership.ts` |
| | the rest (`checkPowerZone` included; `isNodeId` now exported for
registry) | `main/nodes/mapping-rules.ts` |
| `node-rules.ts` | `PDU_GROUP_WINDOW_MS`, `PDU_GROUP_POLL_CATCH_MS`,
`droppedTogether`, `pduOutages` | `main/nodes/power-zone.ts` |
| | `NodeModelInput`, `buildNodeModel`, `cameOnline` |
`main/nodes/node-model.ts` |
| | `isReportableNew` | `shared/nodes/legacy-rules.ts` |
| `config-file.ts` | `mergeCalibration` |
`main/hid/calibration/cache-rules.ts` |

## legacy-rules contents

Each file opens with: *"Renderer still calls these; they move to main in
R3.x and this file disappears."* Each export is `@deprecated`.

| File | Exports | Renderer callers |
|---|---|---|
| `shared/nodes/legacy-rules.ts` | `RAIL_FLAG`, `PDU_FLAG`,
`displayCurrentMa`, `nodeFaultTag`, `reportedIdentity`,
`isAdoptableKind`, `isReportableNew`, `PSU_PRESETS`, `PSU_LIMIT_MAX`,
`isLimit`, `effectivePsuLimits`, `parsePsuLimit` | `cockpit.ts`,
`cockpit-panels.ts`, `cockpit-adopt.ts`, `overview.ts`,
`tabs/Cockpit.tsx`, `tabs/settings/CockpitSection.tsx` |
| `shared/events/legacy-rules.ts` | `SEVERITY_GLYPH`, `isProblem` |
`log-view.ts`, `tabs/Log.tsx` |
| `shared/hid/calibration/legacy-rules.ts` | the whole of `axis-capture`
| `CalibrationDialog.tsx`, `calibration-controller.ts` |

- `DEFAULT_PSU` was already in the nodes contract and stays there.
- `PSU_LIMIT_MAX` and `isLimit` aren't called by the renderer. They are
here because `parsePsuLimit` needs them, and shared can't import main.
- `reportedIdentity` reads `NODE_KINDS` from `../firmware`, aliased as
`NODE_KIND` so its body is unchanged.
- The renderer's production code now imports from `src/shared` only
contracts, `firmware`, `format` and these three files.

## lint:deps, per rule

| Rule | Before (111e907) | After |
|---|---|---|
| P4-renderer-imports-contracts-only | 17 warn | **0** (now `error`) |
| P4-legacy-rules (new) | n/a | 10 warn |
| topic-main-gateway | 3 warn | 3 warn (unchanged: DeviceLink until
R2.2) |
| T2-main-no-deep-topic-import | 0 | 5 warn |
| T2-renderer-no-deep-topic-import | 1 warn | 1 warn |
| every other rule | 0 | 0 |
| **Total** | 21 warn, 0 error | 19 warn, 0 error |

No topic-direction edge is new.

The five T2-main deep imports are deliberate:
- **`nodes/node-status-wire.ts → gateway/link-framing.ts`**
(`encodeExportFrame`). Going through `gateway/index.ts` would close an
import cycle (C2, error): gateway index → `device-link` → nodes index →
`node-hub` → `node-status-wire` → gateway index. The cycle goes away
when R2.2 splits DeviceLink.
- **`config/config-file.ts → nodes/mapping-rules.ts`,
`nodes/psu-limits.ts`, `hid/calibration/cache-rules.ts`**, and
**`config/settings-schema.ts → nodes/psu-limits.ts`**. The renderer's
test helpers (`renderer/test/scenarios.ts`, the t13 test and
`test-fixtures/config.ts`) import `config-file`, so the renderer
`tsconfig.json`, which has no Node types, typechecks its closure. The
nodes and hid/calibration indexes also export NodeHub and CalLink, which
pull in `node:fs` and `Buffer`, so going through the indexes fails `npm
run typecheck`. Each import carries a comment that says why.

## Proof nothing changed

1. **Tests:** 881 passed in 66 files, the same count as base. Test files
changed only import paths, plus four comments that named old paths:
three notes in `reference.test.ts` and one JSDoc in `scenarios.ts`. A
diff filter over every test, fixture and harness file shows no other
line changed.
2. **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`).
`out/main/index.js` from base and from head, each with electron,
serialport and node-hid stubbed, through `whenReady`, `config:set`,
`debug:dump-ports`, `node-mapping:set` and `will-quit`. The outputs are
**byte-identical**, both with `SKYHAWK_USER_DATA` set and with the dev
default (`<appPath>/.userdata`). That covers:
   - the `setPath` call;
   - the `app.on` order;
   - the preload and renderer paths;
   - all 40 channels;
   - the files in `<userData>` and what they contain.

   The preload bundle is byte-identical too.
3. **Render compare.** A one-off jsdom test (not committed) dumps the
innerHTML and text of each view below, base vs head. The dumps are
byte-identical (sha1 `4ea8c9e3…`):
   - Cockpit;
- Cockpit with the PDU board panel open, the degraded board panel open,
and the Adopt dialog open;
   - Overview;
   - Connection;
   - HID;
   - the Calibration dialog, link up and link down;
   - Settings › Cockpit, PSU preset and custom;
   - Log, in the Stream view, the Events view and Problems only.

The scenario was built to exercise each moved rule: a degraded node
(`nodeFaultTag`), PDU pages with 12 V UNDER-V and TEMP WARN raised, a
negative 5 V current (`displayCurrentMa`, `RAIL_FLAG`, `PDU_FLAG`), two
reportable new boards (`isReportableNew`, `isAdoptableKind`,
`reportedIdentity`), the NX650 preset and custom limits (`PSU_PRESETS`,
`effectivePsuLimits`), and events of all four severities
(`SEVERITY_GLYPH`, `isProblem`).

## Left as is, on purpose

- `checkPowerZone` stays in `mapping-rules.ts`. §3.6 puts it in
`power-zone.ts`, but it calls `effectiveMapping` and `MappingError`, and
mapping-rules calls it back, so the move would create an import cycle
(C2, error). Its X4 co-location is R3.2 work anyway.
- `MODE_LABEL` is in `dcs/relay-events.ts`, next to its only user, as
§1.2 lists it. §3.6 names `relay-rules.ts`, a file that doesn't exist
until R3.3.
- `isObject` in config-file is not merged into mapping-rules'
`isPlainObject` (§3.6 MERGE). That is a dedupe, not a move.
- `nodes/faults.ts` holds only the rail and PDU flag text from
`events.ts`. `FAULT_TEXT` and `withFaultInfo` live in `node-hub.ts`,
which is main's own file, and `nodeFaultTag` is in legacy-rules.
- The generated firmware files (`node-status.generated.ts`,
`axis-cal.generated.ts`) and the matching `scripts/sync-a4ec.ts` doc
strings still say `shared/nodes.ts`, `shared/node-telem.ts` and
`shared/calibration.ts`. Changing them means regenerating under the
freshness gate, so they wait for the next sync.

## Checks

- `npm run typecheck`: passes.
- `npm run lint`: 0 errors, 32 warnings, the same P5 renderer warnings
as on base.
- `npm run lint:deps`: 0 errors, 19 warnings.
- `npx vitest run`: 881 passed in 66 files.
- `npm run build`: passes.

Refs #98
R2.1 of the structure rewrite. Main's three JSON files under
`<userData>` now share one set of read, parse and write mechanics. Each
file keeps its own **policy object**, so it behaves exactly as it does
today. The node mapping's state and persistence move out of `NodeHub`
into a `MappingStore` service.

There is no UI change and no functional change. Each file keeps today's
read, write, error and recovery behaviour (X13). Unifying them (fsync,
`.bak`) is fix track, not this PR.

## Commits

1. `test(config): pin config.json load and save behaviour (B97)`. This
tier-2 FILE-seam test was written first, against today's code. It was
proved able to fail by four mutations, each turning at least one case
red:
   - rethrowing write errors;
   - writing compact JSON;
   - returning the shared `DEFAULT_CONFIG` object;
   - skipping `sanitizeConfig`.
2. `refactor(nodes): the MappingStore service and the JSON store`.

## The JSON store: `config/json-store.ts`

`jsonFile(path, policy)` returns `{ read(), write(value) }`.

- **`read()`** resolves the path; a throw there propagates. It then
reports what it found:
  - `missing`;
  - `read` (with the parsed data);
  - `unreadable` (with the error);
- for a file that doesn't parse: `damaged` (left in place), or
`movedAside` / `stuck` (moved aside, or couldn't be).
- **`write()`** always writes `JSON.stringify(value, null, 2)`.

The store knows nothing about what a file means. The owner decides that
through its port: defaults, an empty cache, an empty or read-only
mapping.

| Policy field | What it reproduces (base 64310ec) | config.json |
calibration-cache.json | node-mapping.json |
|---|---|---|---|---|
| `atomic` | Write to `<file>.tmp`, rename it over the file, and
`rmSync(tmp, {force})` on failure (`nodes/mapping-store.ts:67-80`). When
false, `writeFileSync` writes in place (`settings-service.ts:23-27`,
`cache-store.ts:27-32`). | false | false | **true** |
| `reportWriteErrors` | When true, a failed write is handed back as `{
error }` and `saveNodeMapping` throws `MappingSaveError("Could not save
node mapping: …")`. The path is resolved *outside* that guard, so a
throwing path throws raw, as at `mapping-store.ts:68`. When false,
everything is swallowed, the path included, because it was inside `try`
(`settings-service.ts:23`, `cache-store.ts:28`). | false | false |
**true** |
| `moveAsideDamaged` | When true, an unparseable file is renamed to
`<file>.bad-<stamp>`. If the rename fails, the result is `stuck`, which
loads read-only (`mapping-store.ts:38-53`). When false, the file is left
where it is and the defaults load. | false | false | **true** |

**Read-only.** Read-only behaviour (newer version, unreadable file,
stuck file) isn't a store mechanic. It is the mapping format's meaning,
so it lives with the owner in `nodes/mapping-file.ts`, with today's
exact messages (`mapping-store.ts:34-59`). The plan's `readOnlyIfNewer`
field would have made json-store know the mapping's version, so it isn't
a field. The plan's `throwOnError` became `reportWriteErrors`; the
reason is under *Didn't match the brief* below.

## What moved from NodeHub into MappingStore

`nodes/mapping-store.ts` is the `MappingStore` service. These moved into
it from `node-hub.ts`:

- `loaded`;
- `store()` (lazy load);
- `mapping()`;
- `save()` (no-op for the same file, refused while read-only, written
before it is committed);
- `commit()`;
- `persist()` (logs `nodes.mapping` and commits unsaved);
- the `NO_PATHS` fail-loud default, now `NO_FILE` with the same message.

NodeHub builds one MappingStore and delegates to it:

- `mapping()` → `store.mapping()`;
- `persist` → `store.persist`;
- `update` → `store.save`.

`commit` marks the model dirty through an `onCommit` callback. The
mapping rules stay in `mapping-rules.ts`.

Today's `loadNodeMapping` / `saveNodeMapping` / `MappingSaveError` /
`LoadedMapping` are now the file format in **`nodes/mapping-file.ts`**,
over the port. They are a separate module from the service because
NodeHub's tests mock the file layer below the store
(`vi.mock('./mapping-file')`). That way the real
`save`/`persist`/read-only logic still runs under B11, B12, B18 and B19.

## Ports

- **`MappingFileIO`** is declared in `nodes/mapping-file.ts`,
re-exported by `mapping-store.ts` and `nodes/index.ts`. It has `read()`
(missing / read / unreadable / movedAside / stuck) and `write(file) → {
error } | undefined`. Because `JsonRead` is typed by the policy, only a
JsonFile under a move-aside policy can satisfy it; TypeScript checks
this at the wiring.
- **`CacheFileIO`** is declared in `hid/calibration/cache-store.ts`. It
has `read()` (read / missing / unreadable / damaged) and a best-effort
`write(cache)`. `loadCalCache` / `saveCalCache` take it in place of
`CachePaths`.
- **Wiring.** `app/user-data.ts` `storeFiles(paths)` builds both ports
from `storePaths` and `JSON_POLICY`:
- `Session` passes them to `DeviceLink`, whose deps are now `{
mappingFile, cacheFile, log }`, and `DeviceLink` passes them on to
`NodeHub`;
- `app/index.ts` uses `cacheFile` for ConfigTransfer's calibration
section.
- **Settings.** `config/settings-service.ts` is in `config`, so it
builds its own `jsonFile` under `JSON_POLICY.config`. It stays a pair of
functions, with the same `loadConfig(paths)` / `saveConfig(config,
paths)` signatures.
- **Imports.** No new import from `nodes` or `hid/calibration` into
`config`.

## Proofs

- **Contract rows.** B11, B12, B16, B17, B18, B19, B97 and B104 pass
with their assertions unchanged. The `makeServices()` seam tests
(`session.seam.test.ts`) pass, and the harness is unchanged.
- **Test changes.** Only `vi.mock` paths changed, `./mapping-store` →
`./mapping-file`, in `node-hub.test.ts`, `node-hub.seam.test.ts`,
`phase0-probes.test.ts` and `session.test.ts`. In
`mapping-store.test.ts` and `mapping-store.seam.test.ts`, the import
path changed and the two-line local wrapper now hands the store
`storeFiles(storePaths(...)).mappingFile` in place of `paths`. Every
call and assert line is unchanged.
- **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`).
`out/main/index.js` was built at base and at head. The outputs are
**byte-identical** with `SKYHAWK_USER_DATA` set and with the dev default
(`<appPath>/.userdata`). The `config.json` and `node-mapping.json`
written to `<userData>` have identical sha1s.
- **One-off per-file proof** (not committed). The same script was run at
base and at head, and the outputs are **byte-identical** (71 cases, 223
lines). For config.json, calibration-cache.json and node-mapping.json,
it records the return value or the thrown error class and message, plus
the full `<userData>` listing with modes and contents, for:
  - missing file;
  - round trip;
  - damaged JSON;
  - damaged JSON in a read-only folder (move-aside fails);
  - unreadable file (EACCES, and a folder);
  - newer version;
  - older version;
  - write error (read-only folder, and read-only file);
  - a stale `.tmp`, on a successful and on a failed save;
  - a path port that throws on load and on save.

It also drives NodeHub through the real `Session → DeviceLink` wiring
(`importMapping`, `setMapping`, `mappingFile`) with the mapping missing,
good, damaged, damaged-and-stuck, unreadable, newer, and failing to
write. It also calls `DeviceLink.calCacheRead`.

## Didn't match the brief

- **`throwOnError` became `reportWriteErrors`.** For node-mapping.json,
today's save resolves the path *outside* its `try`
(`mapping-store.ts:68`). A path that throws there propagates raw, not as
`MappingSaveError`, and NodeHub's `persist` rethrows it rather than
logging and committing. A store that simply threw would leave the owner
unable to tell a write failure from a path failure without importing a
`config` error class. So the store hands the write failure back as `{
error }`, `saveNodeMapping` wraps only that, and the proof's "path port
throws on save" case is byte-identical.
- **A new file, `nodes/mapping-file.ts`.** It holds the file-format half
(the old `loadNodeMapping`/`saveNodeMapping`), so the tests that mock
the file layer keep running the real MappingStore logic. The brief puts
both halves in `mapping-store.ts`, but that would force those tests to
mock MappingStore itself.
- **No `readOnlyIfNewer` policy field.** Read-only stays in the mapping
format, as described under *Read-only* above.

## Checks

| Check | Result |
|---|---|
| `npx vitest run` | 67 files, 889 tests: 881 plus the 8 B97 tests |
| `npm run typecheck` | clean |
| `npm run lint` | 0 errors, 32 warnings. None of them is in a changed
file. |
| `npm run lint:deps` | 0 errors, 19 warnings, the same count as base |
| `npm run build` | passes |

Refs #99
…eviceLink (#114)

R2.2a of the structure rewrite (#101, phase #99): the first half of the
DeviceLink split. HID, calibration and event reporting move into their
own services. The connection, the line taps, the serial monitor and the
node poll stay in DeviceLink for R2.2b.

Nothing changes for the user. The IPC channels, the pushes, the bytes on
the serial port, the files written and the order of all of them are the
same. The trace proof below checks this.

## What moved where

| From DeviceLink | To | Notes |
|---|---|---|
| `startHid`, `.hid` (the reader), `pushHid`, `.hidPushedAt`,
`.hidPushTimer`, `HID_PUSH_MS`, `HID_RATE_MS` + the rate timer,
`hid.reopen()` on port open, the HID part of `flushLink`, the HID part
of `shutdown` | `hid/hid-service.ts` **HidService** | Its I/O is the HID
reader (`hid/hid-reader.ts`) |
| `.cal` (a CalLink per `connect()`), `readCalibrationState`,
`gatewaySerialNumber`, `calRun`, `calHello` / `calRead` /
`calSessionOpen` / `calStreamSelect` / `calCommit` / `calReset` /
`calSessionClose`, `calCacheRead` / `calCacheStore` / `calCacheDrop`
(through `CacheFileIO`), `.calRawBuf` + `flushCalRaw` + its timer,
`.lastCal` + `calSnapshot`, `.calSerialNumber`, `exportGated`, and the
calibration parts of `teardown` and `onClose` |
`hid/calibration/hid-calibration-service.ts` **HidCalibrationService** |
Same `CalResult`s. It is also the link's calibration frame filter (see
below) |
| `report()`, `.eventCbs`, `onNodeEvents` | `events/event-journal.ts`
**EventJournal** | `report(events)` + `onEvents(cb)`, the same fan-out
as before. Session still hosts the EventLog (R2.3) |

Callers change their receiver only: `app/index.ts` IPC handlers call
`session.calibration.cal*`, and Session uses `calibration.exportGated`
and `calibration.calSnapshot()`. No method was renamed.

**The frame filter.** CalDemux stays where it was, inside CalLink in the
codec. DeviceLink still runs it first on every incoming chunk, at the
same call site. It now calls it through a `CalFrameFilter` port
(`ingest(chunk)`, `exportGated`), which the calibration service
implements with the same code as before: `this.cal ?
this.cal.ingest(chunk) : chunk`. That is how DeviceLink drops its
`hid/calibration` import.

## Open/close reset order, and how it is kept

`onOpen`/`onClose` and the other moments become a `LinkRider`
subscription. Session adds the riders in a fixed order:
`ride(calibration)`, then `ride(hid)`. At each moment, DeviceLink calls
them in that order, at the exact line where their code used to be:

| Moment | DeviceLink order (unchanged) |
|---|---|
| `start()` | link flush timer (200) → serial flush timer (50) →
**riders' timers**: cal:raw (50), HID rate (1000) → roster poll timer
(5000) → **riders' `start`**: HID reader → connect |
| `connect()` | teardown → new SerialBridge → **`attach`**: CalLink →
port callbacks → `scanning` → port start |
| `teardown()` | **`release`**: CalLink close + cal state cleared → port
stop → gateway reset |
| port opens | roster/nodes/gateway resets, assembler → `connected` →
roster request → **`opened`**: calibration read-on-open, then HID reopen
|
| port closes | `nodes.linkClosed()` → **`closed`**: CalLink close, held
bytes passed back down the stream, snapshot cleared → disconnect or
`reconnecting` |
| 200 ms tick | **`flush`**: HID snapshot → node flush, gateway tick,
health push (link up only) |
| `shutdown()` | `nodes.linkClosed()` → clear all link timers (riders'
included, same order) → teardown → **`stop`**: HID pending push, reader
→ `started = false` |

The riders' timers go on the link's own timer list through `every(ms,
fn)`. That keeps both the creation order and the clearing order the
same. This matters because same-period timers fire in the order they
were created.

The CalLink writes through the injected `write`, which is
`DeviceLink.write`, where before it wrote through the SerialBridge
handle directly. These are the same port: a CalLink only writes
synchronously inside a call, while it is the current one, or from its
keepalive, which `close()` stops before the port changes.

## Proof

- **Side-effect trace (not committed).** A one-off harness drives
`Session` with fakes for the serial port, the DCS transport, the HID
reader, the debug log and `node:fs`, under fake timers. It records, in
order: every emit (channel + payload), the serial port's lifecycle and
every byte written, DCS sends, the HID reader's lifecycle and
`sampleRate` calls, every timer created or cleared (with its period),
the debug-log calls, the fs writes and renames under `<userData>`, the
values returned to callers, and the final `<userData>` files. The
outputs at base (afadc63) and head are **byte-identical**: 9 scenarios,
1,362 recorded events (595 emits, 127 serial, 211 timer, 130 HID, 59
returned values, 38 debug-log, 13 fs, 19 DCS) and the same sha1. The
scenarios:
- HID at different rates (a burst inside 16 ms, spaced reports, a 1 ms
flood, idle across the rate window and the roster poll, a trailing push
pending at quit);
- calibration frames split across chunks and interleaved with DCS-BIOS
lines while the relay runs, with export gated and ungated;
- every `cal*` command, including NACK, timeout, a bad protocol version,
the keepalive, the assumed expiry after a failed close, and an unmatched
reply;
  - the offline paths (no port, `scanning`, after disconnect);
  - cache read/store/drop, before and after the serial is known;
- node status and telemetry lines, the gateway row, a v2 row, a mapping
write, the roster poll and silence;
  - the serial monitor, including overflow;
- port close mid-exchange with held bytes, requests while reconnecting,
reopen, close with auto-connect off, error and Retry;
- shutdown with an exchange, a held candidate and a HID push all in
flight.

As a sanity check, swapping the two `ride()` calls, or moving the HID
reader start ahead of the roster timer, changes the trace (102 and 36
diff lines).
- **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`): base and head
outputs are byte-identical, both with `SKYHAWK_USER_DATA` set and with
the dev default. `config.json` and `node-mapping.json` have the same
sha1s.
- **Tests.** 67 files, 889 tests, all passing. One test edit:
`session.test.ts` reached `calHello` through `session.link`, so those 2
lines now call `session.calibration.calHello()`. Their assertions are
unchanged. The `makeServices()` seam tests and the component tests are
untouched.

## Lint

- `npm run typecheck`: clean.
- `npm run lint`: 0 errors. The 32 warnings are the same renderer P5
warnings as before; `src/main` has none.
- `npm run lint:deps`: 0 errors, **17 warnings** (was 19).
`topic-main-gateway` goes from 3 to 1. The one left is `device-link →
nodes`, which is for R2.2b.
- `npm run build`: OK.

Refs #99
R2.2b of the structure rewrite (#101, phase #99): the second half of the
DeviceLink split. What was left in DeviceLink after R2.2a goes to its
owners from the decision table: the USB port to **GatewayConnection**,
the line taps to **DeviceLineTap**, the Serial tab's raw view to
**SerialTrafficMonitor**, and the roster poll, node lines and Gateway
health to **NodeHub** (UD-C). `gateway/device-link.ts` is deleted. No
test needed an alias for it.

Nothing changes for the user. The IPC channels, the pushes, the bytes on
the serial port, the files written and the order of all of them are the
same. The trace proof below checks this.

## What moved where

| From DeviceLink | To | Notes |
|---|---|---|
| `.serial`, `.status`, `.autoConnect`, `.started`, `.timers`, `.riders`
+ `ride()`, `start`, `shutdown`, `setAutoConnect`, `connect`,
`disconnect`, `teardown`, `onOpen`, `onClose`, `write`, `deviceStatus`,
`onSerialData` (the `CalFrameFilter` call site), `fromDevice` (its byte
half), `flushLink` (the 200 ms timer and its `isLinkUp` guard),
`setStatus`, `raise`, `onBytes` / `onStatus` / `onError`,
`LINK_FLUSH_MS`, `CalFrameFilter` / `LinkRider` / `LinkTimer` |
`gateway/gateway-connection.ts` **GatewayConnection** | New: `onTraffic`
(the serial monitor's tap), `attached` (was `!!this.serial` inside
`requestNodes`), `exportGated` (was `deps.frames.exportGated` inside
`flushLink`), `pushGatewayHealth` (the health half of `flushLink`) |
| `gatewaySerialNumber` (moved into HidCalibrationService by R2.2a) |
**GatewayConnection** `usbSerialNumber()` | Same body. Calibration gets
it injected as `serialNumber` and still awaits it first thing in its
read-on-open |
| `.assembler`, the line loop of `fromDevice`, `onLine`, `DeviceLine` |
`gateway/device-line-tap.ts` **DeviceLineTap** | Read-only. Node lines
go to NodeHub through a gateway-declared `NodeLineSink` port, so gateway
never imports nodes |
| `.serialMonitor`, `.serialBuf`, `setSerialMonitor`, `onSerialTraffic`,
`flushSerial`, `SERIAL_FLUSH_MS`, `MAX_SERIAL_BUF` |
`gateway/serial-traffic-monitor.ts` **SerialTrafficMonitor** | Its 50 ms
timer goes on the connection's timer list as a rider, in the same slot |
| `.roster`, `.gateway` (GatewayWatch), `.legacyNodesWarned`,
`requestNodes`, `NODES_REFRESH_MS` + the 5 s poll, `gatewayLine`, the
v2-legacy warning, the node branches of `fromDevice`, the node and
Gateway-health part of `flushLink`, the node resets in `onOpen`,
`gateway.reset()` in `teardown`, `nodes.linkClosed()` in `shutdown` /
`disconnect` / `onClose` | `nodes/node-hub.ts` **NodeHub** | It writes
the roster request through the connection's `write`, through the
`NodeHubLink` port. Its link moments are `NodeHub.rider`. NodeHub's own
`flush` must never be called as a rider's `flush`, so the rider is a
separate object and not the hub itself. The v2 warning goes out on
`NodeHub.onError`, which Session wires to the same handler as the
connection's errors. That keeps the `NODES` row's once-per-run dedupe in
`logError` |
| `isBoardPresent`, `isBusy` (`link-state.ts`) | not created | Neither
exists in main today: they are renderer logic (R3.3). `isLinkUp` stays
in `shared/gateway` for the renderer |

Callers change their receiver only. `app/index.ts` and
`test-harness/services.ts` call `session.connection.connect()` /
`.disconnect()`, `session.nodes.*` (with `requestNodes`) and
`session.serialMonitor.setSerialMonitor()`. `session.test.ts` changes 9
`session.link.connect()` / `.disconnect()` lines to
`session.connection.*`. Their arguments and assertions are unchanged. No
method was renamed.

The `nodes` index now exports only what other topics use: `NodeHub`,
`NodeRoster` and `MappingFileIO`. Its node-wire re-exports were
DeviceLink's.

## Wiring and the order it keeps

Session constructs, in this order: calibration, HID, GatewayConnection,
NodeHub, SerialTrafficMonitor, DeviceLineTap. None of these constructors
has a side effect; NodeHub's MappingStore still loads lazily. Two of the
dependencies are circular, and both are lazy arrows, the same way
calibration's `write` already was: the connection's `health` calls
`nodes.gatewayHealth()`, and NodeHub's `link` is the connection.

Riders are added in this order: `serialMonitor`, `calibration`, `hid`,
`nodes.rider`, `lines`. At each moment, the connection calls them in
that order, at the line where DeviceLink's own code used to sit:

| Moment | Order (unchanged from DeviceLink) |
|---|---|
| `start()` | link tick (200, the connection's own) → **`timers`**:
serial flush (50), cal:raw (50), HID rate (1000), roster poll (5000) →
**`start`**: HID reader → connect |
| `connect()` | teardown → new SerialBridge → **`attach`**: CalLink →
port callbacks → `scanning` → port start |
| `teardown()` | **`release`**: calibration → port stop →
**`released`**: the Gateway's health reset |
| port opens | **`opening`**: roster, node data, Gateway health, the v2
flag, then the line assembler → `connected` status (carrying `health()`)
→ **`connected`**: health `takeChanged`, roster request → **`opened`**:
calibration read-on-open, HID reopen |
| port closes | **`closing`**: nodes' last seen → **`closed`**:
calibration, held bytes back down the stream → disconnect or
`reconnecting` |
| 200 ms tick | **`flush`**: HID snapshot → (link up only) **`tick`**:
node flush, Gateway tick, health push |
| `disconnect()` | **`closing`** → teardown → `disconnected` |
| `shutdown()` | **`closing`** → all link timers cleared (same order) →
teardown → **`stop`**: HID → `started = false` |

Subscriptions, in order:
- `connection.onTraffic` → serial monitor;
- `connection.onBytes` → the relay to DCS first, then `lines.push`. That
is the order DeviceLink handed bytes on in: relay first, then the line
loop;
- `lines.onLine` → command rows;
- `connection.onError` and `nodes.onError` → the same handler;
- `journal.onEvents`;
- `connection.onStatus`.

The rider table and this list are also written as a comment in
`Session`'s constructor.

## Proof

- **Side-effect trace (not committed).** This is the R2.2a harness,
extended. It drives `Session` with fakes for the serial port, the DCS
transport, the HID reader, the debug log and `node:fs`, under fake
timers. It records, in order: every emit (channel + payload), the serial
port's lifecycle and every byte written, DCS sends, the HID reader's
lifecycle, every timer created or cleared (with its period), the
debug-log calls, fs writes, returned values and the final `<userData>`
files.
  - **Changes to the harness:**
- the fake port now feeds the monitor tap as `SerialBridge` does (`tx`
on write, `rx` before data), so the serial monitor sees real traffic;
- the intents resolve through accessors that work at both base and head.
  - **New scenarios:**
- **roster-poll-disconnect**: the poll while scanning, steady polls,
Disconnect (the polls stop writing), connect/not-found/open, a drop with
a reply cut mid-line, a poll while reconnecting, a reopen (the cut line
does not prefix the next), and an error with Retry mid-poll;
- **gateway-health**: the Gateway's own row going ok, bus-off,
error-passive and cleared; unanswered polls going "not responding";
answering again; a calibration exchange pausing the clock; a drop while
not responding, then a reopen;
- **monitor-roster-bursts**: the monitor switched on mid-burst, the
roster request tapped as tx, off before a flush, on twice, telemetry +
commands + RAW, a flood across flushes, a disconnect while on;
- **relay-node-lines**: node rows, a v2 row, telemetry and commands
while the Bridge relay runs. This one makes the relay-before-line-tap
order observable.
- **Result:** base (3b9d5da) and head outputs are **byte-identical**,
with the same sha1 (`a5ed9ba0…`): 13 scenarios and 2,304 recorded events
(1,332 emits, 197 serial, 281 timer, 267 HID, 71 returned values, 93
debug-log, 37 fs, 26 DCS). Base is deterministic over two runs.
  - **It can fail.** One deliberate reorder at a time:
    - swapping the serial monitor and calibration riders: 4 diff lines;
    - subscribing the line tap before the relay: 6;
    - riding NodeHub before HID: 52.
- **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`), both with
`SKYHAWK_USER_DATA` set and with the dev default:
  - base and head probe outputs are byte-identical;
  - `config.json` and `node-mapping.json` have the same sha1s;
  - `debug.log` is identical apart from its timestamp.
- **Tests.** 67 files, 889 tests, all passing. The `makeServices()` seam
tests and the component tests are untouched.

## Lint

- `npm run typecheck`: clean.
- `npm run lint`: 0 errors. The 32 warnings are the same renderer P5
warnings as before.
- `npm run lint:deps`: 0 errors, **15 warnings (was 17)**:
- `topic-main-gateway` goes from 1 to **0**, because gateway no longer
imports nodes;
- `T2-main` goes from 5 to **4**: `nodes/node-status-wire` now imports
`encodeExportFrame` through `gateway/index.ts`, with no cycle. The 4
left are the known `config → nodes` / `config → hid/calibration` deep
imports, for R2.3.
- `npm run build`: OK.

Refs #99
R2.3a of the structure rewrite (#101, phase #99). This is the DCS half
of R2.3. Session's relay, stream and capture code moves, verbatim, to
the owners in the decision table (DCS side, out of Session). Settings,
the event log, AppServices and the per-topic IPC are left for R2.3b.

Nothing changes for the user. The IPC channels, the pushes, the bytes on
the serial port and to DCS, the files written, the timers and the order
of all of them are the same. The trace proof below checks this.

## Tests written first

Each one is its own commit, before the refactor. Each was shown to fail,
then committed.

- **B107: DCS link transports** (`dcs/transports.seam.test.ts`, 10
tests).
- **TCP to host** runs on real sockets on 127.0.0.1. It covers connect,
export in and commands out on one socket, a command queued while
connecting, the 2 s reconnect after DCS drops the socket, and a refused
connect (an error, then a close, retried every 2 s until Stop). It also
covers a DCS that comes up after Start, and the close after Stop being
reported asynchronously.
- **Unicast listen** runs on real sockets on 127.0.0.1. It covers bind,
then connected, export in, and commands to host:commandPort. It also
covers Stop reporting the close synchronously, sharing the port with
another reuseAddr listener, and a port held exclusively (`EADDRINUSE`,
never connected).
- **Loopback multicast** runs on a stand-in socket: whether a CI runner
can join a group is not ours to pin. It covers the export port bound
with reuseAddr (not listenPort), the group join, then connected. A
failed join is an error and still reports connected. Socket errors pass
through, and commands go to host:commandPort.
  - **It can fail.** One mutation at a time:
    - TCP reconnect delay 2000 → 1000: 1 test fails;
    - TCP retrying after Stop: 1;
    - multicast binding listenPort: 1;
    - no `connected` after the join: 2;
    - UDP Stop not reporting the close: 3;
    - socket without reuseAddr: 2.
- **B108: the stats tick** (`dcs/stream-stats.seam.test.ts`, 7 tests).
This runs through `makeServices()`, so it survives the move.
  - It covers:
    - one `stats:tick` a second, only while relaying;
    - bytes in and decoded frames per second;
- command rate, total and last command, and bytes out to DCS in Bridge;
    - commands counted in Monitor with nothing sent;
    - errors and reconnects from both DCS and the Gateway link;
    - uptime;
- a fresh start on every Start, and on a settings change that rebuilds
the relay.
  - **It can fail.** One mutation at a time:
    - `reset` keeping errors: 2 tests fail;
    - total not counted: 2;
    - `STATS_MS` 1001: 6;
    - link `reconnecting` not counted: 1;
    - bytes out not counted: 1.

## What moved where

| From Session | To | Notes |
|---|---|---|
| `start`, `run`, `stop`, `setRelay`, `.running`, `.relay`, `.timers`,
`.replay`, `.parser`, `.decoder`, `onExport`, `fromDevice`, `onWrite`,
`TELEMETRY_MS`, the telemetry and stats ticks | `dcs/dcs-relay.ts`
**DcsRelay** | Bodies verbatim. `this.config` is a getter over the
`RelayOptions` port; `this.events` is a `RelayEventSink` port (see
below) |
| the `running && relayConfigChanged → stop(); run()` block of
`setConfig` | **DcsRelay** `reconfigure(prev, next)` | `run` stays
private. Session's `setConfig` calls it at the same point |
| `loadReplay` | **DcsRelay** `loadReplay` | See *Not as briefed* |
| `.transport`, its creation and subscriptions in `run`, the stale
guards, its stop in `stop`, `send` in `fromDevice` |
`dcs/dcs-connection.ts` **DcsConnection** `open` / `start` / `stop` /
`send` | `open` builds and subscribes; `start` binds or dials. In
between, the relay sets `running` and `connecting`, as before |
| `exportToDevice`, `deviceToDcs`, `RELAY_KEYS`, `relayConfigChanged`,
`ForwardConfig` | `dcs/relay-rules.ts` | Adds `RelayOptions` =
`Pick<AppConfig, RELAY_KEYS>`, which is exactly what the relay reads.
`relayConfigChanged` is generic over it, so `AppConfig` still passes |
| `relayContext` | `dcs/relay-events.ts` | Next to `relayEvents` (X9) |
| `.logBuf`, `.logTimer`, `flushLog`, `LOG_FLUSH_MS`, `MAX_BATCH`, the
`lines.onLine` command-row body | `dcs/stream-log.ts` **StreamLog**
`open` / `close` / `onLine` / `rows` / `flushLog` | `flushLog` still
emits `events:batch` (drained from the EventLog it is handed) before
`log:batch`, until R2.3b splits it. `open()` returns false when already
open, so `Session.open` keeps its early return |
| `.recorder`, `.recordPath`, `startRecording`, `stopRecording`,
`isRecording`; `Recorder` (from `replay-source.ts`) |
`dcs/session-recorder.ts` **SessionRecorder** | `record(dir, chunk)` is
`recorder?.record` |
| `flushTelemetry` | `dcs/sim-telemetry-decoder.ts`
`flushTelemetry(decoder, emit)` | Next to the decoder. The relay's 200
ms tick reads `this.decoder` at call time, as before |
| `STATS_MS` | `dcs/stream-stats.ts` | |
| `config`, `getConfig`, `setConfig`, `events`, `lastErr`, `logError`,
`lastDevice`, `status()`, `open`, `shutdown`, the wiring | stay in
**Session** | `status()` reads `{ running, relay }` from
`relay.status()` |

`createTransport` and the transports now take `TransportOptions`, the
four settings they read. This is a type-only change.

**Ports.** DcsRelay writes to the board through `RelayDevice`
(`deviceStatus`, `write`, `exportGated`), which Session fills with the
GatewayConnection itself. `connection.exportGated` is the same
`calibration.exportGated` getter that Session read, one hop further, so
`dcs` does not import `hid`. Session fills `RelayEventSink` with
`events.add`, `logError` and `lastErr.delete`.

**Callers change their receiver only.**
- `app/index.ts` and `test-harness/services.ts` use
`session.relay.start()`, `.stop()` and `.loadReplay()`, and
`session.recorder.*`.
- `session.test.ts` changes 24 `session.start()` / `.stop()` lines to
`session.relay.*`, and imports the three rules from
`../dcs/relay-rules`.
- Arguments and assertions are unchanged. The `makeServices()` seam
tests and the component tests are untouched.

The `dcs` index now exports only what `app` uses: `DcsConnection`,
`DcsRelay`, `SessionRecorder`, `Stats`, `StreamLog`.

## Wiring and the order it keeps

Session constructs, in this order: calibration, HID, GatewayConnection,
NodeHub, SerialTrafficMonitor, DeviceLineTap, then **SessionRecorder,
StreamLog, DcsConnection and DcsRelay**. None of the new constructors
has a side effect. The parser and the first decoder are built inside
DcsRelay, as they were inside Session.

The subscriptions are unchanged, in the same order:
- `onTraffic`;
- `onBytes`: `relay.fromDevice`, then `lines.push`;
- `lines.onLine`: `streamLog.onLine`;
- `onError` ×2;
- `journal.onEvents`;
- `onStatus`.

The DCS side, now written in the constructor comment too:

| Moment | Order (unchanged from Session) |
|---|---|
| `open()` | stream-log flush (33) first, then `connection.start` |
| relay Start | Bridge / driven Replay refused unless the link is up → a
running relay stopped → decoder, parser, stats reset → DCS error
forgotten → Replay: `connected`, playback; otherwise the transport built
and subscribed → `connecting` → transport start; then the telemetry
(200) and stats (1000) ticks |
| relay Stop | ticks cleared → replay stopped → transport forgotten,
then stopped → stream log flushed → `stopped` |
| export chunk | stats in → parser (named rows: stats frames, then the
log) → recorder → device write (Bridge / driven Replay, not while
calibration gates it) |
| `setConfig` | auto-connect → the relay rebuilt if a setting it is
built from changed |
| `shutdown()` | relay Stop → `connection.shutdown` → stream-log flush
cleared |

## Proof

- **Side-effect trace (not committed).** This is the R2.2b harness,
extended. It drives Session with every edge faked under fake timers. It
records, in order: emits, the serial lifecycle and writes, DCS transport
lifecycle and sends, the HID lifecycle, timer create/clear, debug-log
calls, fs writes, returned values and the final `<userData>` files.
  - **Changes to the harness:**
- relay and recorder intents resolve through accessors that work at both
base and head;
    - the fake transport can be told to throw on construction.
  - **New scenarios:**
- **relay-bridge**: start, both directions, DCS drop and back, the same
error twice, stop, stop again, restart;
    - **relay-monitor**: no board, then a board, still read-only;
- **start-refused**: scanning, no-device, reconnecting, a drop while
running, a relay setting changed during the drop (rebuilt, not refused),
a released link, driven Replay with no board, undriven Replay with no
capture;
- **replay-undriven**: no capture, load, play to the end, restart,
reload mid-play, a bad capture;
- **replay-driven**: refused, then plays into the board, with a
calibration exchange gating the forward;
- **record-during-relay**: record across start, export and commands;
across a stop; restarted;
- **dcs-reconnect**: refused before ever up, up and dropping, the error
being news again, stale transport callbacks after a rebuild and after
Stop;
- **config-while-running**: non-relay settings, transport, listen and
command port, to Bridge with no board, to Replay with nothing loaded
(the relay ends), stopped, a transport that throws on Start and on a
rebuild, into a loaded Replay;
- **commands-stopped-running**: commands while stopped and running in
Bridge, a burst over one flush, Monitor.
- **Result:** base (f667979) and head outputs are **byte-identical**,
with the same sha1 (`1e67f88f…`): 22 scenarios (13 from R2.2b + 9 new)
and 4,639 recorded lines (1,699 emits, 583 timer, 389 DCS, 314 HID, 282
serial, 147 returned values, 115 debug-log, 40 fs). Base is
deterministic over two runs.
  - **It can fail.** One deliberate reorder at a time:
- Stop flushing the log after `stopped` instead of before: 82 diff
lines;
    - the stats tick created before the telemetry tick: 116;
    - the transport stopped before it is forgotten: 76;
    - the log flush not draining the event log: 84.
- **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`), both with
`SKYHAWK_USER_DATA` set and with the dev default:
  - base and head probe outputs are byte-identical;
  - `config.json` and `node-mapping.json` have the same sha1s;
  - `debug.log` is identical apart from its timestamp.
- **Tests.** 69 files, 906 tests: the 889 from before plus B107 (10) and
B108 (7). The existing assertions are unchanged.

## Not as briefed

- `loadReplay` is on **DcsRelay**, not on ReplaySource. It swaps the
capture in the slot that `run()` plays and `stop()` stops, so it lives
with that slot. ReplaySource keeps `load` / `info` / `play` / `stop`
unchanged.
- `flushLog` still drains the event log (`events:batch`), because
Session keeps the event log in R2.3a. StreamLog is handed the EventLog's
`take`. R2.3b splits it.

## Lint

- `npm run typecheck`: clean.
- `npm run lint`: 0 errors. The 32 warnings are the same renderer P5
warnings as before.
- `npm run lint:deps`: 0 errors, 15 warnings. The list is identical to
base (diffed). There are no new warnings: `dcs` imports only `gateway`
and `events` (types: `DeviceLine`, `EventLog`) and the shared contracts.
- `npm run build`: OK.

Refs #99
R2.3b of the structure rewrite (#101, phase #99). This is the settings
and event-log half of R2.3. Session's config and event log move to their
owners. Session keeps only its wiring, `status()`, and `open()` /
`shutdown()`. AppServices and the per-topic IPC are left for R2.3c.

Nothing changes for the user. The IPC channels, the pushes, the bytes on
the serial port and to DCS, the files written, the timers and the order
of all of them are the same. The trace proof below checks this.

## What moved where

| From | To | Notes |
|---|---|---|
| `Session.config`, `getConfig`, `setConfig` |
`config/settings-service.ts` **SettingsService** `get` / `update` | The
live `AppConfig` is now a zustand/vanilla store under
`subscribeWithSelector` (see below). `update` is `setConfig`: it merges,
the store's subscribers react, and nothing is saved |
| `app/index.ts` `applyConfig` | **SettingsService** `apply` | `update`
→ debug-log switch → `saveConfig`, in that order. `config:set` and
ConfigTransfer's `setConfig` both call it |
| `app/index.ts` whenReady seed (`setConfig(loadConfig(paths))` +
`setDebugEnabled`) | **SettingsService** `load` | It seeds and switches
the debug log. It does not write `config.json`, as before |
| `Session.events` (EventLog), `logError` + `lastErr`, the
`lastErr.delete` calls | `events/event-journal.ts` **EventJournal**
`add` / `error` / `forget` | The per-tag dedupe is verbatim. `NODES` is
still never forgotten, so the v2 warning stays once per run (B29) |
| `Session.lastDevice` and the onStatus row logic | **EventJournal**
`linkState(state, row)` | `deviceEvent` stays in
`gateway/link-events.ts`. Session hands the journal a closure that makes
the row from the last state the journal holds. The make-row → add →
remember-unless-scanning order is unchanged |
| the `events:batch` half of `StreamLog.flushLog` | **EventJournal**
`flush` | StreamLog now calls `events.flush()` where it used to `take()`
and emit, so it still runs first, ahead of `log:batch` |
| `events:get` / `clear` / `export` (`session.events.*`, `eventsToTsv`)
| **EventJournal** `snapshot` / `clear` / `exportTsv` | See *Not as
briefed* |
| the `journal.onEvents → events.add` hop | gone | `report` now adds to
the log it hosts. It had one subscriber |

**Ports.**
- DcsRelay's `options: () => RelayOptions` is replaced by `settings:
RelaySettings` (`{ getState(): RelayOptions }`), which the SettingsStore
fits.
- StreamLog's `Pick<EventLog, 'take'>` is replaced by `StreamLogEvents`
(`{ flush(): void }`). `dcs` no longer imports `events` for StreamLog.
- DcsRelay's `RelayEventSink` (`add` / `error` / `forget`) is the
journal itself.
- NodeHub still reports through its `onEvents` callback, now pointed at
`journal.report`.
- The journal is handed `emit` and `debugLog`. `events` imports only
`@shared/events` and `@shared/gateway` (types).

`events/index.ts` now exports only `EventJournal` and the status-flag
vocabulary. `config/index.ts` exports `SettingsService` in place of
`loadConfig` / `saveConfig`, which have no callers left outside
`config`.

## The settings store, and who gets it

- `SettingsService` builds one store per instance with
`createSettingsStore(initial = DEFAULT_CONFIG)`. It is not a module
singleton, and a test can build its own.
- The service holds the writable store. It hands out `store:
SettingsStore`, the read-only view from `@shared/config`, which the
compile guard already checks a real zustand store fits.
- **The relay** gets `settings.store` and reads `getState()` wherever it
used to call `options()`.
- **The connection** gets its slice. `open()` passes
`settings.get().autoConnect` to `start`, and a change reaches
`setAutoConnect` through the subscriber below. GatewayConnection's own
`autoConnect` field and `start` / `setAutoConnect` are unchanged.
- **The debug log** (app's `setDebugEnabled`) is injected into
SettingsService as `debugMode(on)`, because `config` cannot import
`app`.

**How the reaction order is kept.** zustand runs subscribers
synchronously inside `setState`, after the state is replaced. That is
the same moment `setConfig` ran its side effects, after `this.config`
was reassigned. Session registers **one** subscriber, and it is
`setConfig`'s body verbatim: auto-connect if it changed, then
`relay.reconfigure(prev, next)`. The order is in that one listener, not
in registration order. `apply` then runs the debug-log switch and the
save, as `applyConfig` did.

`update` calls `setState({ ...prev, ...patch }, true)`, replacing rather
than merging. zustand's own merge treats a non-object `partial` as the
whole new state. A `config:set` with no patch would therefore have set
the settings to `undefined`, where `setConfig` kept a copy. The trace
covers this: dropping the `replace` gives a 1,019-line diff.

## Wiring

Session's constructor comment has a new block for R2.3b: the store's one
subscriber, what `config:set` / import / launch seed do after it, and
the flush order. Construction order: SettingsService and EventJournal
are built first, then the rest as before. Neither constructor has a side
effect. Session is now 173 lines (was 193); `app/index.ts` is 289 (was
299).

**Callers change their receiver only.**
- `app/index.ts` and `test-harness/services.ts` use
`session.settings.get` / `apply` / `load` and `session.journal.*`. The
harness's own copy of `applyConfig` is gone.
- `session.test.ts` changes eight `session.setConfig(` to
`session.settings.update(`, three `session.events.snapshot()` to
`session.journal.snapshot()`, and one `Parameters<…>` type.
- Arguments and assertions are unchanged. The `makeServices()` seam
tests and the component tests are untouched.

## Proof

- **Side-effect trace (not committed).** This is the R2.3a harness,
extended. It records the same edges, plus every `setDebugEnabled` call.
- **Changes to the harness:** settings, events and export go through
accessors that work at both base and head. Seeding can go through the
launch path (`load`).
  - **New scenarios:**
- **config-set-autoconnect**: off and on with the link looking, after a
Disconnect, while open (left open, then the drop is not reopened), back
on after the drop, and on while up (nothing);
- **config-set-autoconnect-off-launch**: off from launch, then on, then
off while looking;
    - **config-set-relay**:
      - a relay setting while running (rebuilt and saved);
- debug on together with a relay setting (the switch lands after the
rebuild and before the save);
- non-relay settings (`attentionBanner`, `psu`, `debugMode`), an empty
patch, an undefined patch, and a whole config as an import sends it;
      - a mode switch while running, then while stopped;
- auto-connect and a relay setting in one change, with the link dropped
and the relay running;
- **config-load-seed**: a `config.json` with bad fields is sanitized,
the debug log is switched, nothing is written back, and auto-connect off
holds until a `config:set`;
- **config-load-damaged**: defaults, and the file is left as it is until
a `config:set`;
    - **error-dedupe**:
- the same SERIAL error twice, then a DCS error twice, then a new SERIAL
message;
      - Retry forgetting SERIAL;
      - DCS up forgetting DCS;
      - the v2 warning once, and not again after a reopen;
- **events-get-clear-export**: empty, then rows with tabs and newlines
in a field, a clear with a row pending (never pushed), ids continuing
after a clear, and a double clear;
- **device-rows-reconnect**: no board at launch (no row),
plug/unplug/replug, Disconnect twice, Connect with nothing there, then a
fault and a Retry.
- **Result:** base (6228947) and head outputs are **byte-identical**,
sha1 `38a4e2b5…`. That is 30 scenarios (22 from R2.3a + 8 new) and 6,028
recorded lines: 1,933 emits (105 of them `events:batch`), 747 timer, 416
DCS, 384 serial, 367 HID, 213 returned values, 156 debug-log, 69 fs
writes and 27 debug-log switches. Base is deterministic over two runs.
  - **It can fail.** One deliberate reorder at a time:
- the relay rebuilt before auto-connect, inside the subscriber: 6 diff
lines;
    - `config.json` saved before the debug log is switched: 50;
- the debug log switched before the change (and so before the rebuild):
75;
    - `events:batch` pushed after `log:batch`: 40;
    - no error dedupe: 30;
    - `lastDevice` remembered before the row is made: 153;
    - zustand's merge instead of a replace: 1,019.
- **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`), both with
`SKYHAWK_USER_DATA` set and with the dev default:
  - base and head probe outputs are byte-identical;
- `config.json` after `config:set` and `node-mapping.json` have the same
sha1s;
  - `debug.log` is identical apart from its timestamp.
- The probe copies the bundle out of the repo, so it now runs with
`NODE_PATH` set to the repo's `node_modules`, at both base and head.
Main now `require`s `zustand/vanilla` and `zustand/middleware`, which
are externalized like `serialport`. `zustand` is already a runtime
dependency, so the packaged app ships it.
- **Tests.** 69 files, 906 tests, as before. The existing assertions are
unchanged.

## Not as briefed

- **The settings reaction lives in Session's wiring, not inside
SettingsService.** It calls `connection.setAutoConnect` and
`relay.reconfigure`, and `config` may not import `gateway` or `dcs`. So
SettingsService owns the store and the `update` / `apply` / `load`
paths, and Session (AppServices in R2.3c) registers the one ordered
subscriber.
- **GatewayConnection gets a slice, not the store.** Its `autoConnect`
is pushed to it at `start` and through `setAutoConnect`, and its `was`
check compares against its own field. Reading the store instead would
change `start`'s signature and that check, so the connection keeps both.
It is handed the value from the store.
- **`events:export` builds the TSV before the save dialog, not after.**
It used to take the snapshot before the dialog and format it after.
`exportTsv()` does both at the snapshot's moment. Formatting is pure, so
the file is the same. The only difference is that a cancelled dialog has
still formatted the text.

## Lint

- `npm run typecheck`: clean.
- `npm run lint`: 0 errors. The 32 warnings are the same renderer P5
warnings as before.
- `npm run lint:deps`: 0 errors, 15 warnings. The list is identical to
base (diffed). There are no new warnings: `events` imports no topic, and
`config` imports only `zustand` beyond its own files and the contracts.
- `npm run build`: OK.

Refs #99
R2.3c of the structure rewrite (#101, phase #99), the last part of R2.3.
`Session` becomes **`AppServices`** (UD-F), a composition object that
only builds the services and wires them. `app/index.ts` is split by job,
and each main topic gets its own `ipc.ts`. The renderer tests get their
own tsconfig, so the last four T2-main deep imports can go through the
topic indexes. The main topic rules and T2-main are now **error**.

Nothing changes for the user. The IPC channels and their registration
order, the `app.on` order, the pushes, the bytes on the serial port and
to DCS, the files written and the timers are all the same. The proof is
below.

## Old → new

| Old | New | Notes |
|---|---|---|
| `app/session.ts` `Session` | `app/app-services.ts` **`AppServices`** |
`git mv`, then the class is renamed. The wiring, the settings
subscriber, `open()`, `shutdown()` and `status()` are verbatim. It
imports `Emit` from `@shared/app`. |
| `app/session.test.ts`, `app/session.seam.test.ts` |
`app/app-services.test.ts`, `app/app-services.seam.test.ts` | `git mv`.
Only the import, `new AppServices(`, `typeof AppServices` and one
`describe` title change. Assertions are unchanged. |
| `app/index.ts`: the userData override, the store paths, the
construction of Session and ConfigTransfer, `whenReady`,
`window-all-closed`, `will-quit` | `app/bootstrap.ts` `start()` | Moved
verbatim, in the same order. ConfigTransfer is still built here, as
before (see *Not as briefed*). |
| `app/index.ts`: `mainWindow`, `emit`, `createWindow` | `app/window.ts`
| |
| 9 local `type Emit` copies: AppServices, GatewayConnection,
SerialTrafficMonitor, NodeHub, HidService, HidCalibrationService,
DcsRelay, StreamLog, and EventJournal's events-only variant | **one
`Emit` in `@shared/app/ipc.ts`**, next to `PushChannels` | A type only.
`Emit<Channels = PushChannel>`. Every service imports it from the
contract. EventJournal takes `Emit<keyof EventsPushChannels>`, which is
derived from it rather than redefined. The full `emit` is still
assignable to it. |
| `app/index.ts`: `CONFIG_FILTER`, `CONFIG_MAX_BYTES`, `CAPTURE_FILTER`
and the six `dialog.show*Dialog` calls | `app/dialogs.ts` `dialogs:
Dialogs` | `Dialogs = ConfigDialogs & DcsDialogs & EventsDialogs`. Each
topic declares the dialogs it needs as a port. |
| `app/index.ts` `registerIpc()` | `app/ipc.ts` `registerIpc(ipcMain, {
services, transfer, dialogs })` | Calls each topic's registrar. Owns
`relay:status`, `debug:dump-ports` and `debug:reveal`. |
| `config:get/set/export/import-open/import-apply` | `config/ipc.ts`
`registerConfigIpc` | |
| `relay:start/stop`, `log:export`, `capture:toggle`, `replay:open` |
`dcs/ipc.ts` `registerDcsIpc` | |
| `device:connect/disconnect`, `serial:monitor` | `gateway/ipc.ts`
`registerGatewayIpc` | |
| `hid:availability`, and `hidAvailability()` | `hid/ipc.ts`
`registerHidIpc`, and `hid/hid-report.ts` | The function is moved
verbatim to the HID model (§1.2). |
| `nodes:*`, `node-mapping:*` (10) | `nodes/ipc.ts` `registerNodesIpc` |
|
| `cal:*` (10) | `hid/calibration/ipc.ts` `registerCalibrationIpc` | |
| `events:get/clear/export` | `events/ipc.ts` `registerEventsIpc` | |
| `app/index.ts` (289 lines) | `app/index.ts`: `export { start } from
'./bootstrap'` | |
| `main/index.ts`: `import './app'` | `import { start } from './app';
start()` | |
| `test-harness/services.ts` | the same file | `makeServices()` now
builds `AppServices`, as planned. No test that uses the harness changes.
|

**The handlers move verbatim. Only the receiver changes**, from
`session.x` to the `x` passed in `deps`. Channel names come from the
topic contracts (`CONFIG_CTRL`, `DCS_CTRL`, …). The one change inside a
handler is the dialog call. `await dialog.showSaveDialog({…})` followed
by `if (res.canceled || !res.filePath)` becomes `await dialogs.saveX()`
followed by `if (!filePath)`. The cancel check itself now lives in
`dialogs.ts`, unchanged. Arguments are still evaluated where they were:
`transfer.defaultFileName()` and `Date.now()` run before the dialog
opens, and `journal.exportTsv()` still runs before the events dialog.

**What the topic registrars take.** A topic can't import electron (P6)
or app (the topic rules), so `@shared/app` gains three types:
`CtrlChannel` (any CTRL value), `IpcListener` (ipcMain's listener shape)
and `IpcRegistrar` (`{ handle(channel, listener) }`). The real `ipcMain`
fits `IpcRegistrar` structurally.

`app/*` line counts: app-services 175, bootstrap 61, ipc 116, dialogs
81, window 45, debug-log 38, user-data 50, index 2.

## IPC registration order

Today's `ipcMain.handle` order interleaves topics: config, dcs, **app**,
gateway, hid, nodes, **gateway**, calibration, **dcs**, events, **app**,
**dcs**. Registering one topic after another would reorder it. So:

- The topics' registrars and app's own three handlers register into a
**collector**, a `Map` behind an `IpcRegistrar`.
- `app/ipc.ts` keeps **`IPC_ORDER`**, the 40 channels in today's order.
It then calls `ipcMain.handle` once per channel, in that order.
- **A channel missing from `IPC_ORDER` fails the typecheck** and names
the channel. `EveryChannel<typeof IPC_ORDER>` checks this. I tried it by
dropping `debug:reveal`: `TS2741 … '{ unordered: "debug:reveal"; }'`.
- `satisfies readonly CtrlChannel[]` rejects a misspelled channel.
- At startup, a second handler for the same channel throws, as `ipcMain`
itself does. So does a listed channel that has no handler. Neither can
happen in a correct build.

**Proof.** The boot probe now also prints the registration order (`order
…`), the handler count and any duplicate `handle`. Base and head print
the same 40-channel order:

```
order config:get,config:set,config:export,config:import-open,config:import-apply,relay:start,relay:stop,relay:status,device:connect,device:disconnect,hid:availability,nodes:refresh,nodes:get,node-mapping:set,node-mapping:adopt,node-mapping:forget,node-mapping:reset-node,node-mapping:reset,node-mapping:reset-preview,nodes:registry,node-mapping:hide,serial:monitor,cal:hello,cal:read,cal:session-open,cal:stream-select,cal:commit,cal:reset,cal:cache-read,cal:cache-store,cal:cache-drop,cal:session-close,log:export,events:get,events:clear,events:export,debug:dump-ports,debug:reveal,capture:toggle,replay:open
count 40
```

The probe can fail. Swapping `relay:start` and `relay:stop` in
`IPC_ORDER` gives a one-line diff on `order`.

## The tsconfig change

**The premise was off.** `tsconfig.json` set no `types`, so TypeScript
loaded every `@types/*` package, `@types/node` included (66 files). The
renderer's app code could already use `Buffer` or `node:fs` and
typecheck. I checked this at base with a probe file in
`src/renderer/src`. So moving the deep imports to `../nodes` typechecked
even without the split.

This PR makes the boundary real:

- **`tsconfig.json`** adds `"types": []`. It leaves out
`src/renderer/test`, `src/renderer/**/*.test.ts(x)`,
`src/shared/**/*.test.ts` and `src/test-fixtures`.
- With these settings, renderer app code that uses `Buffer` fails with
TS2591.
- The shared tests are still checked by `tsconfig.node.json`, as before.
  - `ipc.compat.test-d.ts` stays in `tsconfig.json`.
- **`tsconfig.test.json`** (new) extends `tsconfig.json`. It sets
`types: ["node"]`, so the files it checks get DOM and Node types. It
covers `src/renderer`, `src/shared` and `src/test-fixtures`, tests
included.
- **`npm run typecheck`** runs all three projects. vitest is unaffected.

## Deep imports removed

| From | Was | Now |
|---|---|---|
| `config/config-file.ts` | `../nodes/mapping-rules`
(`effectiveMapping`, `importMapping`, `sanitizeNodeMapping`) |
`../nodes` |
| `config/config-file.ts` | `../nodes/psu-limits` (`sanitizePsu`) |
`../nodes` |
| `config/config-file.ts` | `../hid/calibration/cache-rules`
(`sanitizeCache`) | `../hid/calibration` |
| `config/settings-schema.ts` | `../nodes/psu-limits` (`sanitizePsu`) |
`../nodes` |

`nodes/index.ts` now exports those four rules, and
`hid/calibration/index.ts` exports `sanitizeCache`. There are no cycles:
C2 stays at 0, at error. `.dependency-cruiser.severity.json` sets
`topicsMain` and `deepMain` to **error**.

## Lint counts

| | Before (f3cc40a) | After |
|---|---|---|
| `npm run lint` | 0 errors, 32 warnings (all P5, renderer) | 0 errors,
32 warnings (the same) |
| `npm run lint:deps` | 0 errors, 15 warnings: P4-legacy-rules 10,
T2-main 4, T2-renderer 1 | **0 errors, 11 warnings**: P4-legacy-rules
10, T2-renderer 1 |
| `npm run typecheck` | 2 projects, clean | 3 projects, clean |
| `npx vitest run` | 69 files, 906 tests | 69 files, 906 tests,
assertions unchanged |
| `npm run build` | ok | ok |

## Trace and boot proof

- **Side-effect trace (R2.3b harness, not committed).** It drives the
services with every edge faked: 30 scenarios.
- The head copy changes one line, the import: `const { AppServices:
Session } = await import('./app/app-services')`.
- Base (f3cc40a) and head output are **byte-identical**: sha1
`38a4e2b5…`, the same as R2.3b's head.
  - The output is 6,028 recorded lines, 1,933 of them emits.
- **Built-bundle boot probe** (R1.4a's `boot-probe.cjs`, plus the
registration-order dump). It ran with `SKYHAWK_USER_DATA` set and with
the dev default.
- Base and head output are **byte-identical**: `setPath`, the `app.on`
order (`window-all-closed`, `will-quit`, then `activate` inside
`whenReady`), the window's preload and renderer paths, the 40 channels
in registration order, `config:get`/`config:set`, `debug:dump-ports`,
`node-mapping:set` and the `will-quit` save.
- `config.json` and `node-mapping.json` are byte-identical. `debug.log`
differs only in its wall-clock timestamp.

## Not as briefed

The review moved `Emit` to the contract: one type, no topic copies
(commit `788ffc6`).


- **`IpcRegistrar`, `IpcListener` and `CtrlChannel` in `@shared/app`.**
The brief asked for an "ipcMain-like" parameter. This puts it in one
place rather than seven: a topic can't import electron or app.
`IpcListener` uses `any[]` for the arguments with an eslint-disable,
because electron's own listener type does.
- **The `Dialogs` port** resolves to a path or `undefined` rather than
electron's result object. `CONFIG_MAX_BYTES` lives in `dialogs.ts`, as
planned. The import's size check in `config/ipc.ts` reads it as
`dialogs.configMaxBytes`, because config can't import app.
- **ConfigTransfer is still built in `start()`**, as it was in
`index.ts`, and not in AppServices. The brief didn't move it. It is a
candidate for AppServices once it pushes (R3.3).

Refs #99
Tier-2 component tests, part 1 of 2: T17–T26 from the Phase 0
component-test plan. **Tests only.** Nothing under `src/renderer/src`,
`src/main` or `src/shared` changes. Every scenario goes in through
`emitScenario` at today's push and invoke level, so R3 can swap in its
new pushes inside the mock and leave these tests as they are.

**Totals:** 69 → 79 test files, 906 → 967 tests (+61). The 906 existing
tests are unchanged and pass. Lint and lint:deps show 0 errors (the
warnings are the same as before). Typecheck passes on all 3 projects.

## Harness additions (`src/renderer/test/`)

- **`skyhawk-mock.ts`**
  - New `Scenario` fields:
    - `stream` → `log:batch`
    - `serial` → `serial:traffic`
    - `calRaw` → `cal:raw`
    - `hidAvailability` → answers the `getHidAvailability` pull
- **`calDevice`**: a scripted Gateway calibration channel that answers
the `cal*` invokes the way main's `HidCalibrationService` does:
    - `offline` ("Gateway is not connected") while the port is closed.
- GET_CAL answers with what the device stores, and also pushes
`cal:data`.
    - COMMIT and RESET change what it stores.
- Optional `refuse` makes SESSION_OPEN, STREAM_SELECT, COMMIT or RESET
return a NACK or a timeout instead of an ack.
- The restore cache runs through main's own `cache-rules` (`boardFor`,
`regressedAxes`, `storeAxes`, `dropAxis`), keyed by the board serial and
compared against the last GET_CAL.
- When a port opens on a scripted device, the mock pushes `cal:data`, as
main does in `readCalibrationState`.
  - Without a `calDevice`, every existing default is unchanged.
- **`scenarios.ts`**
- `reported(id, over)`: a status row with main's `withFaultInfo`
applied.
- `pduTelemetry(id, pages)`: a PDU's pages, built by main's
`NodeTelemStore`.
  - Row builders: `logEvent`, `streamRow`, `serialFrame`.
- Calibration builders: `calSnapshot`, `calDevice`, `calCache`, and
`sweeps(...)` (RAW batches for N full sweeps, optionally with a rest
position).

## What each test pins

| # | File | Pins | Rows |
|---|---|---|---|
| T17 | `t17-cockpit-plan` | One group per placed PDU on its own
console, then that console's No power zone. A panel sits under its PDU
even when the PDU is on another console. "N PDUs" appears under a
console title. A hidden PDU's boards are drawn under No power zone and
come back when it reports. New boards are never placed, and a zone on a
PDU not added yet reads No power zone. A state change never reorders
tiles. A tile's label drops the console prefix, and its tooltip has the
full name. | B64, B10, X2 |
| T18 | `t18-node-panel-status` | Fault boxes: healthy (none), offline
(one box, whatever was raised last), not seen this session plus the
last-seen time, no status yet, degraded with the fault-table label,
unknown code "Fault N". Several faults stack in the order bus-off →
fault → overheat → error-passive. The status rows for each case. "Not in
registry" (C6). "updated N s ago" ticks on fake timers and changes to
"last report" once offline. With the link down the panel reads "last
known". | B70, B14, F11 |
| T19 | `t19-pdu-panel` | An offline PDU gets the fuse sentence only
when its boards dropped with it. "No readings received", with the
firmware hint only if it never had readings. Raised flags appear as one
box with their meanings. UNCAL appears only as a calibration row. Rail
rows show avg / min / A / peak A, a negative offset reads 0.00 A, and
the W hint is shown. NTC rows cover a value, a fault and unavailable.
"Feeds · N" lists the boards, and opening one switches panels. | B71 |
| T20 | `t20-cockpit-band-search` | Band counts: known boards plus
strays, unknown counted separately, new boards left out. 12 V and 5 V
amps, W, lowest 5 V rail, worst CAN with the bus-off board named first,
hottest PDU. Peak W holds when the draw falls. With the link down every
value is a dash. Search by name or #id is case-insensitive. Issues shows
only boards with an issue and combines with search. | B67, B69 |
| T21 | `t21-board-events` | Empty state. Only node rows, newest first,
one line each, with the count. "View all in Log →" opens Log › Events
filtered to Nodes. | B75 |
| T22 | `t22-log-events` | Source filter All / Nodes / Client. "Problems
only". Search over the source label and the event text. Raw changes the
column to "Message (raw)". Pause shows PAUSED and holds rows while the
tab count rises; Resume shows them. Clear calls `clearEvents`. The
Export footer, and no export when there are no events. | B78, B55 |
| T23 | `t23-log-stream` | Direction filter. Search by name or address.
Raw shows the address and bytes. Pause. Clear stays on the renderer
side. Export sends oldest-first TSV to `exportLog`, shows the footer,
and does nothing when the stream is empty. | B77 |
| T24 | `t24-serial-tab` | The off text. The toggle calls
`setSerialMonitor(true/false)`. Rows from `serial:traffic`, newest
first, as hex and ASCII, with `…+N` past 48 bytes. Stopping keeps the
rows; Clear empties them. | B79 |
| T25 | `t25-hid-tab` | Axes, hats and buttons from `hid:report`.
Uncatalogued or undeclared slots show a dash or n/a. Freshness reads
live / idle / quiet / no link, and a released port still reads live
(#74). CAL/RAW badges and the uncalibrated count. Calibrate is disabled
while the link is down, with the reason. The Review & restore banner,
which is absent when nothing is lost or the link is down. | B80 |
| T26 | `t26-calibration-dialog` | Opening runs SESSION_OPEN on the
first uncalibrated axis, and Write is disabled. STREAM_SELECT switches
axes. Cancel closes the session and re-reads. While capturing, the
capture and rest-position buttons are disabled. Three RAW sweeps produce
a draft, and Write sends COMMIT. Write, Cancel, ✕, the axis rail and
Re-capture stay disabled through the writing and verifying phases. After
that come the done state, the badge from the read-back, the cache store
and the notice. An invalid draft names the axis and keeps Write
disabled. When the link drops, the dialog pauses: Write and Delete are
disabled, axes switch locally with no STREAM_SELECT, and drafts are
kept. When the same board reports, the session reopens on the axis on
screen. An interrupted sweep restarts and the sweeps already accepted
are kept. The restore panel: Not now, the re-offer, and Restore writing
the cached values, after which the offer is gone. | B83, B82 |

## Where today's behaviour differs from the contract (pinned as today,
with a `// contract says …` comment)

1. **T17 / B64:** a long tile name "…" is CSS `text-overflow` only. The
DOM label is the whole short name, so the test pins that text.
2. **T18 / C6:** "Not in registry" is pinned, as C6 describes. No route
in the UI leads to a new panel group's own panel: it has no tile, no
Attention row and no Connection row. The box can only be reached on a
new PDU, through a placed board's Powered by (X2 R3).
3. **T20 / B69:** search and Issues dim tiles only through the CSS class
`tile2--dim`; no text or ARIA state changes. The test asserts that class
because it is the only thing to observe. This is the one class assertion
in the PR.
4. **T23 / B77:** Pause in Log › Stream drops `log:batch` rows that
arrive while paused (`store.ts` returns early). Log › Events holds its
rows instead. The test pins the drop.
5. **B77, not tested:** Autoscroll only toggles its own button state;
nothing in the Log view reads it.
6. **T22 / B78:** the `skyhawk-events-<n>.tsv` name is suggested in
main's save dialog and isn't visible to the renderer. The test pins the
footer built from the path main returns.
7. **T26 / B76:** the CalibrationDialog has no `role="dialog"` or
accessible name. The tests find it by its title text.

## Fail-check (break the behaviour, see red, `git checkout` the file,
tree clean)

| T | Behaviour broken | Result |
|---|---|---|
| T17 | `planLayout`: only `pduId === null` boards go to No power zone |
red (2) |
| T18 | `nodeFaultBoxes`: degraded box dropped | red (2) |
| T19 | `pduFaultBoxes`: fuse sentence on every offline PDU | red (1) |
| T20 | `healthBand`: strays left out of total | red (1) |
| T20 | `tileMatches`: `#id` not stripped | red (1) |
| T21 | BoardEvents lists client rows | red (1) |
| T22 | `filterEvents` ignores Problems only | red (1) |
| T23 | `exportLog` newest first | red (1) |
| T24 | `setSerialMonitor` never reaches main | red (1) |
| T25 | `freshness`: released port reads "no link" | red (1) |
| T26 | Write not disabled while paused | red (1) |
| T26 | Cancel not disabled while writing | red (1) |

Refs #99
Tier-2 component tests, part 2 of 2: T27–T35 from the Phase 0
component-test plan, plus contract row **B2** at the HUB seam. **Tests
only.** Nothing under `src/renderer/src`, `src/main` (outside
`*.test.ts`) or `src/shared` changes. Every renderer scenario goes in
through `emitScenario` at today's push and invoke level.

**Totals:** 79 → 88 test files, 967 → 1056 tests (+89). The 967 existing
tests are unchanged and pass. Lint and lint:deps show 0 errors (the
warnings are the same production-file warnings as before). Typecheck
passes on all 3 projects.

## Harness additions (`src/renderer/test/`)

- **`skyhawk-mock.ts`**: two new `Scenario` fields for the relay's
ticks: `gauges` → `telemetry:tick` and `stats` → `stats:tick`.
- **`scenarios.ts`**, each answering at main's level:
- `simTelemetry(fill)`: the five readouts from main's own DCS-BIOS
`Decoder`, after DCS exported the given fraction of each output's range.
  - `streamStats(over)`: one stats tick, idle by default.
- `bundledRegistry()`: what main answers `getNodeRegistry` with
(`NodeHub.registry()`).
- `resetPreview(file)`: what main answers `previewNodeReset` with for a
given node-mapping.json (`NodeHub.previewReset()`).

## What each test pins

| # | File | Pins | Rows |
|---|---|---|---|
| T27 | `t27-overview-hero-gauges` | Link tile: the value, sub-line and
colour for each device state × relay (relaying / stream stopped / "relay
waiting — resumes when it connects"). The Link and Cockpit tiles open
their tabs. Aircraft tile: NONE, "_ACFT_NAME · metadata" vs "inferred ·
address range", gold plus the A-4E-C-only banner when the aircraft is
unsupported. DCS Link tile: TCP / Multicast / Unicast with their
sub-lines. Gauges: five idle "—" readouts, then the tick's values. Fuel
is gold, and red under 25 %; the other ring colours are pinned too.
Stream Health from `stats:tick`; Errors red once > 0; the CMD line. |
B56, B57 |
| T28 | `t28-connection-relay-controls` | Device card: only the VID/PID
**target** before a board; identity, VID/PID, port and CDC + HID when
connected; Disconnect / Connect / Retry call main; the busy note; the
auto-connect-off note's Settings › Connection link; greyed out while
scanning. Relay controls: mode buttons save the mode and show their
hints; Replay's Load capture… and the loaded-file line; the Drive
Gateway toggle saves and blocks Start without a board; host, port and
transport saved as typed or picked; Record disabled unless relaying, its
hints and the running count; Start disabled when blocked, with the
waitnote; the waiting / ended waitnotes. | B61, B62 |
| T29 | `t29-settings-power-supply` | NX650 reads 54 A / 20 A / 650 W,
read-only, and typing into it changes nothing. Custom shows the saved
limits, editable, and saves a typed number. Custom keeps its values
across a switch to the preset and back. An emptied field saves no limit.
A non-number ("abc", "-5", "0", "20000", "1e3") is marked invalid, kept
on screen and never saved. | B87 |
| T30 | `t30-settings-reset` | Reset… is disabled when nothing differs
and no adopted board shows, and when the only adopted board is hidden
(X3). It is enabled when a board differs or an adopted board shows. The
row description. The review lists main's preview rows old → new, with
the adopted board under "Also forget adopted nodes". Reset sends
`resetNodeMapping({ forgetAdopted: false })`; ticking the option sends
`{ forgetAdopted: true }`, and the button label follows. With only an
adopted board, nothing can be reset until the option is ticked. A
refusal shows main's error and keeps the review open. Cancel calls
nothing. | B89, B24 |
| T31 | `t31-settings-connection-diagnostics` | Connection opens first
and shows the saved mode, host and port. Host and port are saved as
typed. The auto-connect toggle saves the setting both ways; the renderer
doesn't connect itself, main does. Record session calls `toggleCapture`
and shows the count. Diagnostics: the debug toggle saves `debugMode`;
Dump calls `dumpSerialPorts` and shows where the ports went; Reveal
calls `revealDebugLog`. | B86, B91 |
| T32 | `t32-settings-about` | Version `v<package version>`, app id,
licence. "NODE_IDS.md · 6 nodes: 3 PDUs, 2 panel groups, Gateway" with
the commit. The registry dialog lists every NODE_ID with the bridge as
the Gateway, and On the bus reads serial link / yes / offline / hidden /
—. It follows the next push. Unplugged, the Gateway reads "no device"
but CAN nodes still read "yes" (R5). | B92, X7 |
| T33 | `t33-dialog-focus` | One test file over a Settings review Dialog
and a Cockpit side-panel Sheet. Focus goes to the first control on open.
Tab goes through the controls and wraps, and Shift+Tab wraps back. Esc,
Close and a scrim click each close it, and focus returns to the opener
(Reset… / the tile). | B76 |
| T34 | `t34-overview-cockpit-agreement` | Integration in `App`: one
`nodes:model` push, then Overview, Cockpit and Connection. Each tab
shows the same offline and warning counts. The exception is a stray,
which only the Cockpit band counts (X1). Without the stray all three
agree exactly. The next push moves all three together. | X1, B58, B63,
B67 |
| T35 | `t35-settings-hidden-after-push` | Settings follows pushes
through its own `useNodeModel` copy. A hidden board that reports again
drops off, leaving the empty state and Un-hide all disabled. A board
hidden elsewhere appears. The last push wins. Only the deterministic
path is pinned; the C5 / F10 race is not. | B88, F10 |
| B2 | `src/main/nodes/node-hub.seam.test.ts` | HUB seam: adopt 41 on
today's bundle, then a "later client" whose NODE_IDS.md lists 41 starts
on the same node-mapping.json. 41 is still in your cockpit with your
name, console and PDU, online. It isn't "new on the bus". The adoption
is kept on disk, plus `lastSeen`. Off the bus it stays, offline. The
later bundle is a `vi.mock` of `@shared/firmware` whose `NODE_NAMES` is
today's plus 41; the other tests in the file see today's bundle. | B2 |

B2 had no seam test before: only the (C) `mapping-rules.test.ts`
"adopted, then in the registry" case.

## Where today's behaviour differs from the contract (pinned as today,
with a `// contract says …` comment)

1. **B2:** the contract says the model keeps `source: 'adopted'`. Today
main reports the board as `source: 'registry'`. It stays in your cockpit
with your settings. Because your name differs from the registry's, it is
`differs: true` and counts in `differingCount`. So Reset… now offers to
rename it to the registry name, which the C test also shows (`resetNode`
→ `Left_Oxy`).
2. **T28 / B61:** the contract says the "busy" note carries a Settings
link. Today the busy note has none. Only the
released-with-auto-connect-off note links to Settings › Connection.
3. **T31 vs B62:** Settings › Connection's **Record session** row is
enabled while the relay is stopped. The Connection tab's Record card is
disabled unless relaying. The two surfaces disagree.
4. **T27 / B56 (not a contract conflict, noted):** for Unicast, the DCS
Link tile's sub-line reads the multicast endpoint (`239.255.50.10:5010 ·
local`).
5. **T27 / B57 (noted):** the idle gauge placeholders carry different
units from the live readouts. Before the first tick IAS and Press Alt
read `% FS`; after it they read `kt` and `ft`.
6. **Known conflicts, pinned as planned:** X1 (T34; a stray isn't
counted on Overview or Connection), X3 (T30; Reset… disabled when the
only adopted board is hidden), R5 / X7 (T32; CAN rows read "yes" while
unplugged).
7. **T33:** the CalibrationDialog shares the focus handling but has no
`role="dialog"` or accessible name (known), so T33 covers the Dialog and
the Sheet only.

## Fail-check (break the behaviour, see red, `git checkout` the file,
tree clean)

| T | Behaviour broken | Result |
|---|---|---|
| T27 | `Overview`: fuel red under 20 % instead of 25 % | red (1) |
| T27 | `Overview`: Errors red only above 5 | red (1) |
| T28 | `startBlocked` always false | red (2) |
| T28 | Connection Record never disabled | red (1) |
| T29 | PSU preset fields not read-only | red (2) |
| T29 | a refused PSU value is saved anyway | red (1) |
| T30 | Reset… ignores adopted boards | red (2) |
| T30 | ResetDialog always sends `forgetAdopted: false` | red (2) |
| T31 | Dump button calls Reveal | red (1) |
| T32 | `registryOnBus`: no "hidden" | red (1) |
| T32 | `registryOnBus`: CAN "yes" only while the link is up (fixes R5)
| red (2) |
| T33 | `useModalFocus`: focus not returned to the opener | red (6) |
| T33 | `useModalFocus`: Tab doesn't wrap | red (2) |
| T34 | `nodesSummary`: healthy = online (warnings counted twice) | red
(2) |
| T34 | Overview counts new boards as strays (fixes X1) | red (2) |
| T35 | `useNodeModel` stops following `nodes:model` | red (3) |
| B2 | `inCockpit`: only `added` counts (an adopted entry stops counting
once it is in the registry) | red (1) |

After each run, `git status` was clean.

Refs #99
…121)

R3.1 of the structure rewrite (#101). The calibration workflow runs in
main. The dialog and the HID tab draw what main pushes, and send the
user's actions.

## What moved

| Before | After |
|---|---|
| `src/renderer/src/calibration-controller.ts` (`CalibrationController`,
`CalibrationApi`) | `src/main/hid/calibration/calibration-workflow.ts`
(`CalibrationWorkflow`, `CalibrationCommands`). Moved verbatim: same
methods and logic. Timers are injected (`WorkflowTimers`, default
`SYSTEM_TIMERS`). Its state types (`DraftAxis`, `WritePhase`,
`WriteProgress`, `WorkflowState`, which was the controller's
`CalibrationState`) moved to the contract. |
| `src/renderer/src/calibration-controller.test.ts` |
`src/main/hid/calibration/calibration-workflow.test.ts`. Ported 1:1: the
import paths changed, `CalibrationController` became
`CalibrationWorkflow`, and `CalibrationApi` became
`CalibrationCommands`. |
| `src/shared/hid/calibration/legacy-rules.ts` (+ test) |
`src/main/hid/calibration/axis-capture.ts` (+ test). Moved verbatim,
minus the `@deprecated` tags. The legacy file is deleted. |
| `CalibrationDialog.tsx`: `TRAVEL_BANDS`, `travelQuality`, the prompt's
step meaning and direction inference, and every gate |
`src/main/hid/calibration/calibration-view-model.ts` (`dialogState`).
Each derivation is verbatim. |
| `tabs/Hid.tsx`: badges, uncalibrated count, offer banner gate, offer
axis, Calibrate shown/enabled, default axis |
`calibration-view-model.ts` (`tabState`) |
| `useCalibration`: the controller's lifetime (built on HID-tab mount,
disposed on unmount), `cal:raw` → ingest, linkChanged before
deviceChanged, offer re-read per device report, the start sequence (open
→ restore panel → shown) and the close sequence (session close → hidden
→ re-read → offer); `store.ts`: the snapshot dropped whenever the link
isn't up; the dialog's `startRaw` anchor |
`src/main/hid/calibration/workflow-host.ts` (`CalibrationWorkflowHost`),
hosted by `HidCalibrationService` (`.workflow`). It keeps one view per
mounted HID tab (`cal:attach`). AppServices feeds it the link state from
`onStatus`, after `device:status` has gone out. |
| — | `src/renderer/src/calibration-view.ts`:
`toDisplay`/`fmt`/`fmtSpan`/`pct` and `promptText` (the prompt's words)
|

The renderer's calibration code is wording only. The two UI-local states
stay in the dialog: the two-click Delete arming and the 1.2 s "Captured"
beat.

## The workflow's lifetime: `cal:attach` (the lifecycle intent)

Before R3.1, the HID tab owned the controller: `useCalibration` built it
on mount and disposed it on unmount. `cal:attach(on)` replaces that
ownership. `Hid.tsx` sends it from a `useEffect` at the same two
moments.
- **`attach(true)`** builds a fresh `CalibrationWorkflow`. It then runs
what the mount's effects ran, in their order: `linkChanged`,
`deviceChanged`, and the restore-offer read. The offer starts empty
until that read lands, as the hook's state did.
- **`attach(false)`** calls `dispose()`. The ticker and the hold/notice
timers stop, and RAW is dropped while nothing is attached. As with the
renderer's controller, work already under way keeps running.
- **A renderer reload** never runs the unmount, and the old page's
controller died with the page.
- `relay:status` (a renderer's first pull) and a second `attach(true)`
without a detach both mark the old view gone: it is disposed, and its
pending commands never answer.
- So nothing the old page started reaches the device again, and the
rehydrate carries no dialog.

Without this, a dialog, its live reading and its pending timers would
have outlived a tab switch or a reload. That would be a behaviour
change.

## `CalibrationState` (`cal:workflow`)

```ts
interface CalibrationState {
  tab: {
    snapshot?: CalSnapshot                       // the device's report while the link is up (was store.cal)
    badges: Partial<Record<number, 'cal' | 'raw'>>
    uncalibrated: number
    offer?: { confirmedAt: string; axes: number[] }
    showOffer: boolean                           // offer && link up && an axis present
    offerAxis?: number                           // Review & restore opens here
    calibrate: { shown: boolean; enabled: boolean; axis?: number }
  }
  dialog?: {                                     // present while the dialog is shown
    workflow: WorkflowState                      // the workflow's own state, verbatim
    present: CalAxis[]; draft?: DraftAxis; stored?: CalAxis
    writing: boolean; invalid: { axis; reason } | null; showInvalid: boolean
    shown?: CaptureResult; travel: TravelQuality | null; selfCentring: boolean
    prompt: { step; holding; side: 'upper' | 'lower' | null; awaitingMovement } | null
    dirty: number[]; strip: boolean; stepIndex: 0 | 1; sweepsRemaining?: number
    holdAtStops: boolean; hold?: { remainingMs; progress }; sweeps: SweepCount
    convergence: { min; max; short }[]; restore: 'panel' | 'reoffer' | null; notice: boolean
    capture: { action: 'redo' | 'start'; disabled; recapture }
    disabled: { close; axisSwitch; rest; write }; remove: { shown; disabled }
  }
}
```

`CALIBRATION_IDLE` (contract) is the launch state the renderer starts
from. A main test pins the host's first state equal to it.

## Intents ↔ old methods

| Intent | Was |
|---|---|
| `cal:open`(axis, review?) | `useCalibration.start` → `controller.open`
+ `setRestoreOpen` + shown |
| `cal:close` | `useCalibration.close` → `controller.close` + hidden +
`calRead` + `calCacheRead` |
| `cal:select-axis`(axis) | `selectAxis` |
| `cal:start-capture` | `startCapture` |
| `cal:retry` | `retry` |
| `cal:set-self-centring`(on) | `setSelfCentring` |
| `cal:save` | `save` |
| `cal:restore` | `restore` |
| `cal:delete-axis`(idx) | `deleteAxis` |
| `cal:set-restore-open`(open) | `setRestoreOpen` |
| `cal:attach`(on) | lifecycle: `useCalibration`'s controller
construction on mount, `dispose()` on unmount |

The other public members are main-internal now:
- `ingest` takes RAW batches from the service's 50 ms flush;
- `linkChanged` and `deviceChanged` are driven by the host;
- `subscribe` and `snapshot` feed the push;
- `selfCentring`, `dirtyAxes` and `invalidAxis` are folded into the
state;
- `dispose` is called by `cal:attach(false)`.

The renderer calls the intents through `useStore().calibrate`.

## API guard diff (`ipc.compat.test-d.ts`, updated on purpose)

- **SkyhawkApi:**
- removed `calHello`, `calRead`, `calSessionOpen`, `calStreamSelect`,
`calCommit`, `calReset`, `calSessionClose`, `calCacheRead`,
`calCacheStore`, `calCacheDrop`;
- added `calOpen`, `calClose`, `calSelectAxis`, `calStartCapture`,
`calRetry`, `calSetSelfCentring`, `calSave`, `calRestore`,
`calDeleteAxis`, `calSetRestoreOpen`, `calAttach` (all `Promise<void>`).
- **CTRL:** the ten `cal:*` protocol channels are replaced by the ten
workflow channels and `cal:attach`.
- **PushChannels / IPC:** `cal:raw` is removed (main-internal) and
`cal:workflow` → `CalibrationState` is added. `cal:data` is unchanged
(still pushed; the renderer no longer listens).
- **RelayStatus** (now frozen in the guard too): `cal?: CalSnapshot` →
`cal?: CalibrationState`.
- **IPC_ORDER:** the eleven new channels are appended after
`replay:open`. Every pre-existing handler keeps its relative order.

## Mock

`skyhawk-mock.ts` hosts a real `CalibrationWorkflowHost`, on the test's
clock and timers.
- Its commands are the scripted-`CalDevice` `cal*` spies. They are
unchanged, and are now spies on the mock rather than on `SkyhawkApi`.
- The intents answer through the host, including `calAttach`, and
`getStatus` runs main's `relay:status` reload rule.
- `emitScenario` hands the link state to the host after `device:status`
and the report after `cal:data`, and RAW batches go to the host.

`getStatus` returns the host's state. No other test file changed.

## Proofs

- **Component tests:** T1–T35 are green, with no test-file diff. The
only change under `src/renderer/test/` is `skyhawk-mock.ts`.
- **Ported controller test:**
- 57 cases, same as before. The case-name diff is the describe prefix
only (`CalibrationController …` → `CalibrationWorkflow …`).
- Same expected values. The diff is imports, the class/interface names
and nothing else.
- **workflow-host.test.ts:** besides the 7 ported store cases, 5
lifetime cases:
  - nothing to act on and RAW dropped before mount;
  - a remount starts fresh;
  - an unmount stops the timers;
  - a second mount without an unmount leaves the old view behind;
- after a renderer load, the old view's pending re-read never reaches
the device.
- **store.test.ts calibration block:** the store keeps 5 tests, now on
`calibration.tab.snapshot` with the same expectations. The 7 that pinned
the drop-on-link-down rule moved with the rule to
`workflow-host.test.ts`, with the same cases and expectations.
- **Main trace** (r23c AppServices harness + 7 new dialog flows, 37
scenarios), base vs head:
- **Non-calibration and protocol scenarios (30):** byte-identical once
the `emit cal:workflow` lines (new) and `emit cal:raw` lines (now
main-internal) are removed.
- **Dialog flows**, driven at base by the old controller +
useCalibration emulation over the old `cal:*` handlers, and at head
through the intent handlers:
- the flows: capture → save → verify, restore, delete axis, port close
mid-write + resume + Cancel inside the held confirmation, link drop
mid-capture + resume, HID-tab unmount mid-capture and mid-write +
remount, renderer reload mid-write + new page;
- result: device traffic (serial writes, device replies, cache file
writes, final files) is byte-identical, and so is every other line
(timers included) apart from those pushes.
- **Render compare:** identical `innerHTML` base vs head in all 56
states:
  - **HID tab:** link down/up, offer.
  - **Restore:** panel, re-offer, writing, verifying, done, notice.
  - **Switch and delete:** axis switch, delete armed, erased.
- **Capture:** prompts incl. holding, awaiting confirmation and centre;
rejected + redo; draft; rest toggle.
  - **Link and write:** paused/resumed; the write phases; invalid draft.
  - **Refusals:** commit, session and stream.
  - **Lifetime:** a tab switch mid-dialog and back; a reload mid-dialog.
- **Boot probe (R2.3c):** the only diffs are channels, and `debug.log`
differs by timestamps only.
- Removed (10): `cal:hello`, `cal:read`, `cal:session-open`,
`cal:stream-select`, `cal:commit`, `cal:reset`, `cal:cache-read`,
`cal:cache-store`, `cal:cache-drop`, `cal:session-close`.
- Added at the end (11): `cal:open`, `cal:close`, `cal:select-axis`,
`cal:start-capture`, `cal:retry`, `cal:set-self-centring`, `cal:save`,
`cal:restore`, `cal:delete-axis`, `cal:set-restore-open`, `cal:attach`.
- Every other channel keeps its relative order; the count goes from 40
to 41.

## Checks

- `npm run typecheck` (3 projects): clean.
- `npm run lint`: 0 errors. The warnings fall from 32 to 31 (P5: −2
CalibrationDialog, +1 the store's one calibration accessor).
- `npm run lint:deps`: 0 errors, 9 warnings (down from 11).
P4-legacy-rules goes from 10 to 8 (CalibrationDialog and
calibration-controller are gone); T2-renderer stays at 1.
- `npx vitest run`: 89 files, 1064 tests.
- `npm run build`: OK.

## Left for R3.4

- `cal:data` is still pushed and still in the contract. The renderer no
longer listens: the report reaches it inside `cal:workflow`. Remove it
with the dead-code pass.

Refs #100
R3.2a of the structure rewrite (#101): the first half of R3.2. The
**Cockpit tab** reads a snapshot that main builds and pushes. The tab
keeps only presentation: the plan's geometry, wording, the search box
and its own view state. Overview, Connection, Settings, Add all, Un-hide
all and the `useNodeModel` delete are R3.2b.

## What moved

| Before (renderer) | After |
|---|---|
| `cockpit.ts` `freshReadings`, `nodeHealth`, `nodeViews` |
`main/nodes/cockpit-snapshot/views.ts` |
| `cockpit.ts` rail / status flag text, `pduAlertFlags`;
`cockpit-panels.ts` flag meanings, `pduFlagMeanings` |
`main/nodes/faults.ts` |
| `cockpit.ts` `attentionItems`, `affected`, `ATTENTION_SHOWN`,
`newCount`; `cockpit-adopt.ts` `IDENTIFY_WAIT_MS`, `strayNew`,
`strayFlag`; `Cockpit.tsx` strays and items only while live, the Gateway
item taking a shown slot, count / shown / more; `gateway-view.ts` the
Attention part | `cockpit-snapshot/attention.ts` (`cockpitAttention`,
`gatewayAttention`) |
| `cockpit.ts` `healthBand`, `totalWatts`, `canErrorsSub`,
`canErrorsWorst`; `Cockpit.tsx` REC-over-TEC; `store.ts` `setNodes` peak
W (`nodesPeakW`) | `cockpit-snapshot/band.ts` (`cockpitBand`),
`index.ts` (`sessionPeakW`) |
| `cockpit.ts` `consoleKey` and the grouping half of `planLayout` (who
is drawn, which zone, which console, outage per group, gold feeds, PDUs
per console); `Cockpit.tsx` the PDU group header's rules (OFFLINE
override, flag split, rail value and alert gates, "no readings") |
`cockpit-snapshot/plan-zones.ts` (`planZones`, `groupHead`) |
| `cockpit-panels.ts` `boardActions`, fault boxes, status / rail / board
rows, `powerZoneOptions`, `firstPduOn`; `SidePanels.tsx` the feeding
PDU, the other PDUs on the console, Feeds, the fed count passed to
Forget, `feedState`, the outage flag for the PDU box |
`cockpit-snapshot/panels.ts` (`boardPanels`, `zoneChoices`) |
| `cockpit-adopt.ts` `adoptQueue`, `adoptPduChoices`, `adoptability`,
`detectedAs`, `adoptEvidence`, `prefill`; `registrySpecs`'s default-PDU
rule | `cockpit-snapshot/adopt.ts` (`adoptRows`, `addAllPduId`) |
| `Cockpit.tsx` the views → strays → band → attention pipeline and its
stray `setTimeout` | `cockpit-snapshot/index.ts`
(`buildCockpitSnapshot`) + `main/nodes/cockpit-host.ts`
(`CockpitSnapshotHost`, hosted by NodeHub) |
| `cockpit-panels.ts` `CONSOLE_OPTIONS`, `consoleLabel` |
`shared/nodes/ipc.ts`: main's evidence wording and the renderer both
read it |

Each function moved verbatim. Two things are new composition:
`buildCockpitSnapshot` and the per-section wrappers (`cockpitBand`,
`cockpitAttention`, `boardPanels`, `zoneChoices`, `adoptRows`,
`groupHead`, `planZones`). Each wrapper runs the same expressions the
components ran.

The renderer keeps these view-model files:
- `node-view.ts`: `STATE_SYMBOL` and `tileMatches` (the search).
- `plan-layout.ts`: `planLayout` geometry over the pushed zones, plus
`tileWidth`, `shortName` and `outageHint`.
- `band-view.ts`: `headroom`, and `psuName` (R3.3).
- `panel-view.ts`: `reportAge` (the clock), `mappingPatch` (the unsaved
form), and the zone-table lookups.
- `adopt-view.ts`:
  - `adoptSpec` (the unsaved form);
  - `identState` and `nextSelection`;
  - `keptQueue` (the rows the open dialog keeps);
  - `registrySpecs` (Add all, which R3.2b moves);
  - the `adoptPduChoices` lookup over the pushed zones.

`cockpit.ts` keeps the copy that Overview and Connection still call
(`nodeViews`, `healthBand`, `attentionItems`) until R3.2b.
`cockpit-panels.ts` and `cockpit-adopt.ts` are deleted.

## `CockpitSnapshot` (`cockpit:snapshot`, new push)

```ts
interface CockpitSnapshot {
  rev: number                       // increases with every build
  live: boolean                     // isLinkUp when built
  views: NodeView[]                 // {entry, health {state, flag}, readings, noReadings}
  outages: PduOutage[]
  plan: { key: 'L' | 'C' | 'R'; zones: PlanZone[]; feeds: boolean[] }[]
                                    // PlanZone {key, pdu, head?, members, outage?}
                                    // head {off, flags[], noReadings, rail12V?, rail5V?}
  band: { band: HealthBand; canValue?: {counter, value}; canSub: string; canWorst?: string; peakW: number }
  attention: { gateway?: {flag, state}; items; shown; count; more; newCount }
  panels: Record<nodeId, BoardPanel>
                                    // BoardPanel {placed, faults, actions, status?, poweredBy?,
                                    //             rails?, board?, feeds?, sharedWith?}
  zones: { options: Record<console, Record<zone | '', {value, label}[]>>; first: Record<console, number | null> }
  adopt: AdoptRow[]
                                    // AdoptRow {view, kind?, badge, note?, evidence, goneEvidence,
                                    //           prefill, pending?, addAllPduId?}
}
```

Notes on the shape:
- `COCKPIT_IDLE` is the contract's launch value, and the renderer store
starts from it. A main test pins it equal to the builder's output for no
model.
- `buildCockpitSnapshot(input)` is pure, with `now` and `rev` injected.
It returns `{ snapshot, nextAt? }`, where `nextAt` is when the next
board still identifying becomes a stray.
- **API guard:** `PushChannels` / `IPC` gain `cockpit:snapshot` →
`CockpitSnapshot`. No intent or method changed, so IPC_ORDER and the
boot probe's channels are unchanged.

## When it is pushed (`CockpitSnapshotHost`)

The host mirrors what the renderer's store held and re-rendered on:

| Event | Push |
|---|---|
| `nodes:model` | just before it (`node-hub.test` pins `nodes:model` as
the last push of a mapping change) |
| `nodes:telemetry` | just before it |
| `device:status`, link state | after it (a new `onStatus` subscriber,
after calibration's) |
| `device:status`, Gateway health | after it (`rider.tick`) |
| a renderer load (`relay:status`) | built from main's current model and
telemetry, with the peak W starting over as the old store's did |
| the stray timer | when it fires |

The store state it mirrors: the last pushed model and telemetry, the
peak W raised on each push (and on a renderer's first pull, which
restarted it), and the stray timer.

`nodes:get` is not used for the rehydrate. Settings' `useNodeModel` also
pulls it, and a peak reset there would be a behaviour change.

## Stray timing

The renderer ran:

```ts
setTimeout(() => setNow(Date.now()), Math.max(0, nextAt - Date.now()) + 50)
```

The effect depended on `nextAt`. Its `now` moved only when that timer
fired, so a push in between was judged at the old time.

The host keeps the same three things:
- one timer per `nextAt`, cleared when `nextAt` changes, at `nextAt -
now + 50` (`STRAY_RECHECK_MS`);
- a strays clock that moves at the first build, at a renderer load and
at each timer fire;
- `nextAt` from the builder.

## X1–X8: the Cockpit's variant, co-located

Each variant is a separately named function, listed in
`cockpit-snapshot/index.ts`. Other surfaces keep their own copies until
R3.2b.

| X | Cockpit's variant (main) | The other surface's |
|---|---|---|
| X1 hidden in cockpit | PDU choices, Add all and the plan see the
plan's boards only: `addAllPduId`, `powerZoneOptions` / `firstPduOn`,
`planZones` | Settings `anyAdopted` (R3.2b) |
| X2 on the bus while unplugged | strays and items only while live:
`cockpitAttention` | Connection / registry rows (R3.2b) |
| X3 strays | counted online + warning: `cockpitBand`; New on the bus =
`isReportableNew` (`newCount`) | Overview `healthBand(views)` with no
strays (renderer copy, R3.2b) |
| X4 zone choices | `powerZoneOptions` / `firstPduOn` pushed as `zones`;
`adoptPduChoices` "(not added)" as `pending`; `addAllPduId` (D7);
`boardPanels` `poweredBy` from any source; `planZones` No power zone |
AdoptDialog clears `pduId` on a console change (form, unchanged) vs
NodePanel picks the first PDU |
| X5 Forget / Hide | `boardActions`: offline only |
`NodeHub.onlineRefusal`: present in the roster |
| X6 fault order | `nodeHealth` (views), `nodeFaultBoxes` /
`pduFaultBoxes` (panels), `adoptEvidence` (adopt) | |
| X7 fed boards | `boardPanels` feeds / fed count | `pduOutages`
fedCount |
| X8 registry wording | NOT IN REGISTRY (`nodeHealth`), "Not in
registry" (`nodeFaultBoxes`) | Settings registry rows (R3.2b) |

## Mock

`skyhawk-mock.ts` hosts a real `CockpitSnapshotHost` over the scenario,
on the test's clock and timers:
- `emitScenario` hands it each `nodes:model`, `nodes:telemetry` and
`device:status` push, in the same step as main;
- `getStatus` (main's relay:status) calls `rendererLoaded`, in a
microtask, so the page has subscribed first, as asynchronous IPC
guarantees;
- today's pushes are unchanged.

No other file under `src/renderer/test/` changed.

## Proofs

- **T1–T35:** green, with no test-file diff. The only change under
`src/renderer/test/` is `skyhawk-mock.ts`.
- **Ported helper tests:** every case kept its title and expected
values. Count before → after:

  | Before | Count | After |
  |---|---|---|
| `cockpit.test.ts` | 46 | main `cockpit-snapshot/cockpit.test.ts` 23 +
`node-view.test.ts` 3 + `plan-layout.test.ts` 14 + `band-view.test.ts` 6
|
| `cockpit-panels.test.ts` | 38 | main `panels.test.ts` 35 +
`panel-view.test.ts` 3 |
| `cockpit-adopt.test.ts` | 28 | main `adopt.test.ts` 17 +
`adopt-view.test.ts` 11 |
  | `store.test.ts` peak W | 2 | `cockpit-host.test.ts` |

  Every non-verbatim line is one of four things:
  - an import;
- `planLayout(views, outages)` → `layout(views, outages)`, which runs
main's `planZones` first;
  - the keptQueue row accessors (`r.view.entry`);
- `registrySpecs(queue, views, edits)` → `specs(...)` over main's
`adoptRows`.

The renderer `cockpit.test.ts` keeps 20 of its cases for the Overview /
Connection copy. The `phase0-probes.test.ts` Cockpit lines now call
main's functions, with the same matchers and values.
`cockpit-host.test.ts` adds 6 cases: renderer-load peak, idle = builder,
push points, the stray timer, one timer per `nextAt`, the strays' clock.
- **Render compare, base vs head:** every distinct
`document.body.innerHTML` recorded by a MutationObserver through all 35
component-test files plus 7 extra Cockpit flows.
- 277 tests and 1068 states (612 distinct), with identical sequences.
112 tests and 524 states touch the Cockpit.
- Two T9/T20 states show the wall-clock `hh:mm` of a real-timer
`linkDownAt`; these are normalised before comparing.
  - The extra flows cover:
    - link down with nothing known;
- a full cockpit: every panel kind and tab, every fault box (offline,
notSeen, unknown, bus-off, degraded + overheat + error-passive, rail /
NTC / TEMP / UNCAL flags, no readings with and without history), outage
+ hint, Feeds / Powered by, zone lists incl. "(other console)", search /
Issues / focus, Gateway warn / err / not responding, link down and back;
    - the stray before, at and after the 5 s grace;
    - peak W;
    - no complete roster;
- Adopt: every badge, edits, "(not added)", a hidden PDU, a power-cycle
(kept offline, then back), Later, a refused Add all.
- **Main trace (r31 harness, 37 scenarios):** every scenario is
byte-identical once the 229 `emit cockpit:snapshot` lines are removed.
The timer lines are unchanged, 1039 both sides.
- **Stray timing with fake timers:** renderer Attention transitions
stepped 1 ms at a time, base (the renderer's own timer) vs head (main's
host via the mock). Identical:
  - a board unidentified at mount turns stray at +5050;
  - boards pushed later turn stray at +11050 and +11550;
  - a push 20 ms after a board is due changes nothing until +16550.

NodeHub on its own, fed real `_NODE_STATUS` lines and stepped 1 ms at a
time, pushes the stray's snapshot at +5050.
- **Boot probe:** identical channels, order and count (41). `debug.log`
differs only in timestamps.

## Checks

- `npm run typecheck` (3 projects): clean.
- `npm run lint`: 0 errors, 31 warnings (base 31).
- `npm run lint:deps`: 0 errors, 8 warnings (base 9). `cockpit-panels`
and `cockpit-adopt` are gone (−2); `band-view.ts` → legacy-rules adds 1
(`psuName`, R3.3).
- `npx vitest run`: 96 files, 1090 tests.
- `npm run build`: OK.

## Left in the renderer, on purpose

- The registry defaults behind "Registry: … (NODE_IDS.md)"
(`useRegistryEntry`). Moving them removes the "…" frame shown while the
`getNodeRegistry` pull is in flight, and the render compare showed it.
- The Adopt dialog's session state, `keptQueue` and `dropped`: the rows
kept while it is open, and the boards that dropped since. The plan puts
these in NodeHub per dialog session, which needs intents. A kept row
that left the model shows its last pushed `pending` name and
`addAllPduId`.
- Add all's set and its sequence (R3.2b); PSU name and limits and
`linkDownAt` (R3.3).
- `gatewayView().attention` is no longer read: the Cockpit was its only
reader. Dead code for R3.4.

Refs #100
…gs (#123)

R3.2b of the structure rewrite (#101) is the second half of R3.2. The
**Overview**, **Connection** and **Settings** now read the snapshot main
builds and pushes (`cockpit:snapshot`), as the Cockpit tab has since
R3.2a. They keep only presentation: wording, layout and their own view
state. **Add all** and **Un-hide all** become NodeHub intents that run
today's loops.

Nothing changes for the user. The text, counts, order, enablement and
timing are all the same.

## What moved

| Before (renderer) | After |
|---|---|
| `cockpit.ts`: the Overview's and Connection's copy of `nodeViews`,
`nodeHealth`, `freshReadings`, `pduAlertFlags`, `attentionItems`,
`affected`, `ATTENTION_SHOWN`, `healthBand`, `totalWatts` | **deleted.**
It was identical to main's `cockpit-snapshot/views.ts`, `band.ts`,
`attention.ts` and `faults.ts`; the check was text identity, function by
function. |
| `overview.ts` `cockpitTile`, `BANNER_NAMES`, `attentionSummary` (with
`itemName` and `names`), `reportedKind`, `newDevices`; `Overview.tsx`
the views → band → items pipeline, the banner only while connected, and
the notice's live gate | `main/nodes/cockpit-snapshot/summaries.ts`
(`overviewSummary`) |
| `connection-summary.ts` `nodesSummary`; `Connection.tsx` its pipeline
| `summaries.ts` (`nodesSummary`); `connection-summary.ts` is deleted |
| `settings-view.ts` `registryName`, `registrySummary`, `registryOnBus`;
`CockpitSection.tsx` `differing`, `anyAdopted` and Reset…'s gate, the
hidden list; `AboutSection.tsx` the link and the model behind On the bus
| `main/nodes/cockpit-snapshot/registry-rows.ts` (`settingsNodes`) |
| `useNodeModel` (its own `nodes:get` pull and `nodes:model`
subscription, mounted 3×) | **deleted**; the subscription goes. The pull
stays as the loading gate (`useNodesPulled`, below). |
| `AdoptDialog.tsx` `addAll`: one `adoptNode` invoke per board |
`node-mapping:add-all` → `NodeHub.addAll`
(`main/nodes/mapping-sequence.ts` `addAllInTurn`) |
| `CockpitSection.tsx` `unhide(ids)`: one `hideNode(id, false)` invoke
per board (Un-hide and Un-hide all) | `node-mapping:unhide` →
`NodeHub.unhide` (`unhideInTurn`) |

Every function moved verbatim; the check was text identity, declaration
by declaration. The new composition is:
- `overviewSummary`;
- `settingsNodes`;
- the two loops. Each runs the same statements as the component it came
from.

The renderer keeps:
- `overview.ts` `newDevicesText`: the notice's wording;
- `settings-view.ts`: the reset, import and hidden-row wording;
- `adopt-view.ts` `registrySpecs`: the set Add all sends, because it
reads the unsaved forms.

## New snapshot sections

```ts
interface CockpitSnapshot {
  // … R3.2a's sections …
  overview: {
    tile: CockpitTile                           // {value, sub, symbol?, tone}
    banner: AttentionSummary | null             // {head, detail}; the renderer applies Settings' toggle
    newDevices: NewDevices | null               // {count, panels, pdus, registry}
  }
  nodesCard: NodesSummary                       // {known, healthy, warn, off, unknown, pdus, watts,
                                                //  rows: SummaryRow[], more, footer}
  settings: {
    differing: number
    canReset: boolean
    hidden: HiddenNode[]
    registry: { summary: string; rows: RegistryRow[] }
                                                // RegistryRow {nodeId, name, console,
                                                //   onBus, unloaded}: OnBus = {text, dot}
  }
}
```

- `CockpitSnapshotInput` gains `registry?`, and `CockpitSource` gains
`registry?()`. NodeHub answers it with `this.registry()`, the same
source as `nodes:registry`; the mock answers with the scenario's
registry.
- `COCKPIT_IDLE` gains the sections for "no model, link down". The host
test that pins the idle snapshot against the builder still passes,
unchanged.
- `unloaded` is the On the bus value the registry dialog shows before
its own pull lands: `registryOnBus(id, undefined, live)`.

## The variants, co-located, not unified (X1–X8 option a)

| X | This surface's variant (main) | The Cockpit's |
|---|---|---|
| X1 in cockpit | Settings `anyAdopted` = an adopted board on the plan.
A hidden adopted board is missed, so Reset… stays greyed
(`settingsNodes`). | The plan's boards (R3.2a) |
| X2 on the bus | The Overview's banner and notice only while the link
is up. Connected nodes reads the last model whatever the link. The
registry's CAN rows ignore the link; only the Gateway row follows it
(`registryOnBus`). | Items and strays only while live
(`cockpitAttention`) |
| X3 strays | The Overview and Connection count none:
`healthBand(views)` and `attentionItems(views, outages)` with no strays.
The notice is `isReportableNew` (`newDevices`). | Counted as online +
warning (`cockpitBand`) |
| X4 zone | A Connected nodes row names its zone's PDU wherever that PDU
is, else "no power zone" (`nodesSummary`). | `poweredBy`, `zoneChoices`
(R3.2a) |
| X8 wording | The registry's bridge row reads "Gateway"
(`registryName`). | NOT IN REGISTRY / "Not in registry" |

The full list is in `cockpit-snapshot/index.ts`, `summaries.ts` and
`registry-rows.ts`.

## Add all and Un-hide all

`node-mapping:add-all`(bulk: `AdoptRequest[]`) and
`node-mapping:unhide`(ids) both return `NodeBatchResult`:

```ts
type NodeBatchResult =
  | { ok: true; done: number[] }
  | { ok: false; done: number[]; nodeId: number; error: string }
```

The loop is the renderer's, statement for statement:

```ts
for (const b of bulk) {
  const r = await adopt(b.nodeId, b.spec)        // NodeHub.adopt: saves, pushes cockpit:snapshot + nodes:model
  if (r && !r.ok) return { ok: false, done: added, nodeId: b.nodeId, error: r.error }
  added.push(b.nodeId)
}
return { ok: true, done: added }
```

- **Un-hide** is the same loop over `hide(id, false)`.
- **Order, stops and results:** the boards go in the given order, each
awaited before the next. The loop stops at the first refusal, and the
boards after it are not tried.
- **Progress:** each board pushes the model as it lands, the same steps
the per-board invokes made, so the dialog draws the same intermediate
rows.
- **Renderer side:**
- Add all: `setDone(done ∪ result.done)`, then on a refusal `NODE_ID n:
error`. It keeps the stale-closure `done`, as before.
  - Un-hide: `setError(undefined)`, then the refusal's error.
- Single Un-hide goes through the same intent with one id, as it went
through the same loop before.
- **Known defects:** the Add-all race and Un-hide all stopping at the
first failure are kept (fix track).
- **API guard:** `CTRL`, `SkyhawkApi` (`addAllNodes`, `unhideNodes`) and
the frozen copy in `ipc.compat.test-d.ts` are updated on purpose. Both
channels are appended to `IPC_ORDER`.

## Pulls kept for a visible reason

- **Settings `nodes:get`.** Each section (Cockpit, Hidden boards, the
registry dialog) pulled the model on mount and showed nothing until it
landed. During that frame:
  - Reset… is disabled and reads "Every node matches";
  - Hidden boards shows "No hidden boards";
  - the registry rows read "—".

The render compare shows these frames, so `useNodesPulled` keeps them.
It sends the same pull per mount, and the section's data shows once the
reply lands, or once a newer `nodes:model` push reaches the store, as
the old subscription did. What is shown is the snapshot's section.
- **About `nodes:registry`.** It is kept as before: it gates the row and
gives the dialog's file, commit and date.
- **ResetDialog** still re-previews on each `nodes:model` push. Its
trigger is now the store's `nodeModel`, which changes exactly on those
pushes once Settings is open.

## Proofs

- **T1–T35:** green, with no test-file diff. The only change under
`src/renderer/test/` is `skyhawk-mock.ts`:
  - its CockpitSnapshotHost source answers the scenario's registry;
- `addAllNodes` and `unhideNodes` run main's `addAllInTurn` /
`unhideInTurn` over the mock's own `adoptNode` / `hideNode` spies, so
T11 and T12 still see and refuse each board's call.
- **Ported helper tests:** each case keeps its title, body and expected
values. Only the imports changed.

  | Before | Count | After |
  |---|---|---|
| `overview.test.ts` | 22 | main
`cockpit-snapshot/overview-summary.test.ts` 20 + renderer
`overview.test.ts` 2 (`newDevicesText`) |
| `connection-summary.test.ts` | 10 | main
`cockpit-snapshot/connection-summary.test.ts` 9 + renderer
`node-view.test.ts` 1 (the case that also reads `STATE_SYMBOL`) |
| `settings-view.test.ts` | 8 | main
`cockpit-snapshot/registry-rows.test.ts` 3 + renderer
`settings-view.test.ts` 5 |
| `cockpit.test.ts` (renderer copy) | 20 | deleted: each case was
textually identical, header included, to one already in main's
`cockpit-snapshot/cockpit.test.ts` (R3.2a) |

- The renderer `phase0-probes.test.ts` D1 probe now calls main's
functions, with the same matchers and values.
- `plan-layout.test.ts` takes `RAIL_FLAG` from `@shared/firmware`
(`PDU_RAIL_FLAGS`, the object legacy-rules re-exported).
- **New tests (13):**
- NodeHub Add all / Un-hide all, with per-board pushes and the stop (2);
  - `mapping-sequence` order, stop and no-answer (4);
  - `overviewSummary` (2);
  - `settingsNodes` (4);
  - the host's registry source (1).
- **Render compare, base vs head:** a MutationObserver records every
distinct `document.body.innerHTML` through all 35 component-test files,
R3.2a's 7 extra flows and 9 new ones.
- **Overall:** 286 tests and 1158 states (657 distinct). Every sequence
is identical.
- **The surfaces this PR touches:** 181 tests and 648 states render the
Overview, Connection, Settings, the App or the Adopt dialog.
  - **The new flows cover:**
- Overview: the tile, banner and notice in each state (scanning, no
roster, unknown, healthy, warnings only, offline + outage + bus-off +
not seen, PDU flags, Gateway err / warn, new devices registry /
unregistered / mix / gone, link down and back, the toggle off);
- Connection: the nodes card through the same states, Refresh, error and
no-device;
- Settings › About: the registry dialog through pushes and the link,
re-mounted, and the bundled registry opened while unplugged;
- Hidden boards: Un-hide all with a push after the first board and a
refusal at the second, single Un-hide, Un-hide all again;
- Reset…: through pushes, the review re-previewing on pushes, a refused
apply, then apply;
- Add all with a push after the first board and a refusal at the second
(the dialog draws the added board as "not reporting right now" between
them), then the rest with pushes;
- link down and back across Overview, Connection and Settings in the
App.
- **Main trace (the r31 harness, 40 scenarios = 37 + 3 new):** every
scenario is identical once the new snapshot sections are removed from
the `cockpit:snapshot` payloads. That covers every emit, file write,
serial write, timer, debug-log line and return value. The snapshots are
at the same positions (249). The three new scenarios are:
- `add-all`: a refusal mid-way, the rest, an empty set, then link down;
  - `unhide-all`: a refusal mid-way, one alone, then the rest;
  - `sequences-read-only`.

Base drives them through the old per-board `node-mapping:adopt` /
`node-mapping:hide` invokes in the renderer's loop; head drives them
through one intent. Their device and file traffic is identical.
- **Stray timing (R3.2a's harness):** unchanged:
  - the renderer side: +5050, +11050, +11550 and +16550;
  - NodeHub on its own: the stray's snapshot at +5050.
- **Boot probe:** the only change is the 2 new channels,
`node-mapping:add-all` and `node-mapping:unhide`. They are appended at
the end of the order (41 → 43). `config.json` and `node-mapping.json`
are identical; `debug.log` differs only in timestamps.

## Checks

- `npm run typecheck` (3 projects): clean.
- `npm run lint`: 0 errors, 31 warnings (base 31).
- `npm run lint:deps`: 0 errors, 6 warnings (base 8). The `overview.ts`
→ legacy-rules and `cockpit.ts` → legacy-rules warnings are gone.
- `npx vitest run`: 98 files, 1083 tests. Base was 96 / 1090: −20
duplicate cases and +13 new.
- `npm run build`: OK.

## legacy-rules importers left in the renderer

`shared/nodes/legacy-rules`: only the PSU ones, which are R3.3:
- `band-view.ts` (`PSU_PRESETS`);
- `Cockpit.tsx` (`effectivePsuLimits`);
- `CockpitSection.tsx` (`parsePsuLimit`, `PSU_PRESETS`).

`shared/events/legacy-rules` (out of scope): `log-view.ts` (`isProblem`)
and `Log.tsx` (`SEVERITY_GLYPH`).

## Notes

- **C5 race:** a Settings pull reply that lands after a newer push no
longer overwrites it, because the rows come from the pushed snapshot.
The mock answers pulls at once, so no flow can reach this race and none
was changed by it; T35 pins only the deterministic path.
- **Registry source:** the registry rows come from main's registry, not
the dialog's pull. Main's registry is bundled and never changes at
runtime, so the two agree. A test that swaps the scenario's registry
mid-session would see the summary follow at the next push.
- **Leftover comment:** T35's header still names `useNodeModel`. Test
files are untouched by rule.
- **Store fields:** the store keeps `nodeModel` (the review's re-preview
trigger and the pulled-gate) and `nodeTelemetry` (now read only by
`store.test.ts`) for R3.4's store split.

Refs #100
…ain (#124)

R3.3 of the structure rewrite (#101). The rules the renderer still
worked out for the link, the relay, the HID tab and the settings move to
their main owners, which push the result. The renderer keeps the
wording, the layout, what is typed, and one piece of pending view state:
the Relaying it shows at once when Start or Stop is clicked.

Nothing changes for the user. The text, enablement, colours and order
are all the same, and so are the gaps listed below.

## New push fields, per channel

| Channel | Payload | What's new |
|---|---|---|
| `device:status` | `DeviceStatusPush` = `DeviceStatus` +
`DeviceLinkFacts` | `linkUp`, `boardPresent`, `busy`, `linkDownAt?`,
`serialMonitor` (additive). Also carried by the rehydrate bundle's
`device`. |
| `hid:report` | `HidReport` = `HidSnapshot` + `HidReportFacts` | `band`
(`live` / `idle` / `quiet`), `present` (axis slots declared),
`axisLive[]` (additive) |
| `telemetry:tick` | `TelemetryReadout[]` | each readout gains `tone`
(`fuel-low` / `fuel` / `secondary` / `primary`) |
| **NEW** `relay:facts` | `RelayFacts` | `running` and `stopped` sides,
each with {`waiting`, `tip`, `startBlocked?` (`bridge` / `replay`),
`overviewLink`, `canRecord` {connection, settings}}; `stream?`,
`deviceState`, `endpoint` {pill, overview}, `recording` {on, events?},
`replay?` {file, events, durationMs}. The rehydrate bundle carries it as
`relayFacts`. |
| **NEW** `config:changed` | `ConfigChanged` = {`config`, `psu`:
`PsuView` {name, limits, preset}, `source`: `edit` / `import`} | pushed
after every settings change |

New intents: `config:edit` (`editConfig(ConfigEdit)`: a form's change,
with the port as typed) and `relay:toggle` (`toggleRelay(relaying)`).
`config:get` now answers `ConfigView` {config, psu}. Both channels go at
the end of `IPC_ORDER`. `SectionDiff` gains `note?`.

The API guard (`ipc.compat.test-d.ts`) is updated on purpose, with a
dated note for each change. New contract idle values, each pinned to its
main builder by a test: `DEVICE_IDLE`, `RELAY_FACTS_IDLE`,
`TELEMETRY_IDLE`, `AIRCRAFT_NONE`, `PSU_VIEW_DEFAULT`. `isLinkUp` leaves
the gateway contract, and main imports it from the gateway topic.

## Old → new

Every rule moved verbatim, its comment with it. Where one renderer
function both decided and worded (`relayTip`, `relayNote`,
`startBlockedReason`, `gaugeColor`, `freshness`), main keeps the
branches and pushes which one applies; the renderer keeps the strings
and colours.

| Before (renderer) | After (main) |
|---|---|
| store.ts `device:status` handler: `isLinkUp`, the `linkDownAt` stamp |
`gateway/link-state.ts` (`isLinkUp`, `LinkDownClock`, `linkFacts`);
GatewayConnection pushes the result |
| device-view.ts `isBusy`; freshness.ts `BOARD_PRESENT` |
`gateway/link-state.ts` (`isBusy`, `isBoardPresent`) |
| store.ts optimistic `serialMonitor` | SerialTrafficMonitor's page
flag; setting it re-pushes `device:status` |
| freshness.ts `FRESH_MS` / `RECENT_MS` / bands; Hid.tsx present axes
and `axisLive` | `hid/hid-facts.ts`; HidService pushes the result |
| device-view.ts `waitingForDevice` / `waitingForDcs`, `startBlocked`,
`relayWaiting`, `relayTip` / `relayNote` branches, `startBlockedReason`
/ `Callout` mode choice | `dcs/relay-rules.ts`; DcsRelay pushes
`relay:facts` |
| RelayPill.tsx / Overview.tsx endpoint labels; Overview.tsx Link
sub-line; Connection.tsx / ConnectionSection.tsx Record gating |
`dcs/relay-rules.ts` (`pillEndpoint`, `overviewEndpoint`,
`overviewLink`, `canRecord`) |
| store.ts `toggleRelay` chain (Stop, or save then Start) |
`DcsRelay.toggle` (`relay:toggle`). The renderer keeps the pending flag
and its rollback. |
| store.ts recording / replay file from the intent replies |
`DcsRelay.showCapture` / `showReplay`, from the same replies
(`captureView`, `replayView`) |
| store.ts `IDLE_TELEMETRY`; Overview.tsx `gaugeColor` |
`dcs/sim-telemetry-decoder.ts` (`idleTelemetry`, `gaugeTone`) |
| store.ts `buildConfig` (port fallback, read-modify-write) |
`config/settings-form.ts`; `SettingsService.edit` (`config:edit`) |
| ConfigurationSection.tsx rebuilding the store from `r.config` |
`config:changed` with `source: 'import'` (`SettingsService.imported`,
called by ConfigTransfer) |
| band-view.ts `psuName`; legacy `PSU_PRESETS`, `effectivePsuLimits` |
`nodes/psu-limits.ts` (`resolvePsu`), carried by `config:changed` /
`config:get` |
| ImportDialog.tsx `CAL_NOTE` and when it shows |
`config/config-file.ts` `withSectionNotes`, applied in
`ConfigTransfer.preview` |
| store.ts initial settings, device, aircraft and telemetry literals |
`configState(DEFAULT_CONFIG)`, `DEVICE_IDLE`, `AIRCRAFT_NONE`,
`TELEMETRY_IDLE` |

## The co-located variants (X9, X10, X12)

| X | Variants, side by side in their main owner |
|---|---|
| X9 endpoint label | `pillEndpoint`: TCP → host:port as typed, loopback
→ `239.255.50.10:5010`, unicast → `unicast listen`. `overviewEndpoint`:
TCP → host:port as typed + ` · remote`, otherwise `239.255.50.10:5010 ·
local`, for Unicast too. |
| X10 telemetry spec | `IDLE_TELEMETRY` (the old store's placeholders:
IAS and Press Alt in `% FS`, Press Alt keyed `D_ALT_NEEDLE`) next to the
live `TELEMETRY` (`kt`, `ALT_FT` / `ft`). `gaugeTone` keys on the id, so
the idle Press Alt keeps its plain ring and the live one is a secondary
ring, as before. |
| X12 Record | `canRecord(relaying)`: Connection card `connection:
relaying`; Settings › Connection `settings: true` |

## How each gap is kept

- **N1, reload loses page state.** Main now holds what the page held,
and resets it when a renderer loads (`relay:status` →
`AppServices.rendererLoaded`):
  - `linkDownAt` (`LinkDownClock.pageLoaded`);
- the serial monitor's flag, while the monitor itself keeps running and
its traffic keeps coming;
  - the recording and the loaded capture.

Aircraft and telemetry still start from the idle constants and are not
in the bundle. Peak W was already handled in R3.2. The render proof
reloads while relaying, recording, monitoring and with the link down,
and again in Replay with a capture loaded. Both runs render the same
states.
- **N2, Monitor says "relay waiting".** `overviewLink(relaying, linkUp)`
keeps the mode-blind rule.
- **N3, Bridge-only device wait.** `waitingForDevice` is moved verbatim.
Driven Replay stays blocked but never "waiting for the Gateway".
- **N5, per keystroke, junk → 7778.** Each keystroke is still saved,
through `config:edit`. `buildConfig` keeps `Number(port) || 7778`. The
fields keep showing what was typed: an edit's own `config:changed`
(`source: 'edit'`) doesn't touch them. Only an import resets them, as
its answer did, even an import that changed no settings.
- **N7, HID availability fetched once.** The pull stays, for hats and
buttons. Axes use main's catalogue, which is the same bundled table the
pull answered from, fixed per build.
- **N8, PSU fields stale after an import.** The typed text is still the
form's own state, seeded once per mount.
- **Silent revert on a refused Start.** The renderer still takes back
its pending Relaying when the answer is `!ok`, and shows no reason.

## legacy-rules

- **`shared/events/legacy-rules.ts`: deleted.**
  - `SEVERITY_GLYPH` is a glyph table.
- `isProblem` is the filter behind the Events view's "Problems only"
toggle. It chooses which rows the view lists from the severity main gave
them, and decides nothing about the events.

Both are presentation, so both move to the renderer's `log-view.ts`. The
`isProblem` case moves from main's events test to `log-view.test.ts`.
- **`shared/nodes/legacy-rules.ts`: the PSU entries go** (`PSU_PRESETS`
and `effectivePsuLimits` to main; `psuName` came from band-view.ts). Two
things stay, which the brief did not expect:
- `parsePsuLimit` (with `isLimit` and `PSU_LIMIT_MAX`) stays, and so
does its one renderer caller, CockpitSection.tsx. The PSU form's invalid
outline is judged on every keystroke against the text typed since that
form mounted. A verdict pushed from main would land one IPC hop later.
Main also can't know about the mount, which re-seeds the text (a saved
1e-7 re-seeds as `1e-7`, which reads invalid).
- The file is not empty. `RAIL_FLAG`, `PDU_FLAG`, `displayCurrentMa`,
`nodeFaultTag`, `reportedIdentity`, `isAdoptableKind` and
`isReportableNew` have no renderer caller left; only main uses them.
Moving them is R3.4's "legacy-rules move to `main/<topic>/`".

## Where this differs from the brief

1. **`relay:facts` is a new channel, not new fields on `relay:state`.**
There were two reasons:
- Today any `relay:state` push resets the pending Relaying (store.ts).
The facts must be re-pushed when the link, the settings, a recording or
a capture changes. On `relay:state`, that re-push would take back a
Start that main hasn't answered yet.
- `app-services.test.ts` 'relay state (#69)' asserts `relay:state`
payloads and `status().relay` with `toEqual`. New fields would change
those assertions.

`relay:state` is byte-identical. Each fact comes for both sides of the
on/off, so a pending Start or Stop reads the side it shows.
2. **`config:get` answers `ConfigView`, not `AppConfig`.** The power
supply view is then there in the same reply as the settings, as
`effectivePsuLimits(psu)` was. `config:changed` carries `source`, so an
edit's echo leaves what is typed alone (N5).
3. **The endpoint label is a template.** Main picks the label; for TCP
it says "the host and port as typed", and the renderer fills them in
from its fields. The pill has always shown the port as typed
(`127.0.0.1:77a`), while the saved port is 7778.
4. **HidService re-pushes `hid:report` in two cases.** It does so when
calibration's `cal:workflow` carries a new device snapshot, and when a
renderer loads. The live axes follow the snapshot, as the tab
re-rendered on that push.
5. **Timing in the app, not in tests.** Facts that depend on a form's
own change (the mode, Drive Gateway, the transport, the PSU), and the
serial monitor's flag, now come back one IPC round trip after the click
instead of in the same render. The mock pushes them synchronously, as
main does inside the handler, so the render sequences are identical.
Start's rollback lands one round trip sooner: the save and the Start run
in main back to back.

## Proofs

- **T1–T35: no test-file diff.** The only changes under
`src/renderer/test/` are:
- `skyhawk-mock.ts`, which builds each push with main's builders
(`linkFacts` / `LinkDownClock`, `hidFacts`, `relayFacts`,
`editedConfig`, `resolvePsu`) and hosts their page state. `toggleRelay`
runs DcsRelay's sequence over the `setConfig` / `startRelay` /
`stopRelay` spies. The capture, replay and import answers feed main's
views through a wrapper that keeps them drivable spies.
- `scenarios.ts`, whose `importPreview` applies `withSectionNotes`, as
`ConfigTransfer.preview` does.
- **Ported helper tests.** Each case keeps its title, body and values.
Adapters in each file's header compose main's rule with the renderer's
wording.

  | File | Before | After |
  |---|---|---|
  | `device-view.test.ts` | 18 | 18 |
  | `freshness.test.ts` | 5 | 5 |
| `store.test.ts` | 16 | 16 (the fake main builds pushes with main's
builders) |
  | `gateway-view.test.ts` | 5 | 5 (untouched) |
| `band-view.test.ts` | 6 | 5 (`psuName` → `psu-limits.test.ts`, 4 → 6
with `resolvePsu`) |
| `events.test.ts` | 27 | 26 (`isProblem` → `log-view.test.ts`, 11 → 12)
|

- **New tests (30):**
  - `link-state` (5)
  - `hid-facts` (3)
  - `relay-rules` (9)
  - decoder tones and the X10 idle pin (2)
  - `settings-edit` (5)
  - `resolvePsu` (1)
  - `pushed-rules.seam` (5)
- **Render compare, base vs head.**
- **Overall:** 300 tests and 1735 states (931 distinct) across all 35
component-test files, R3.2a's and R3.2b's flows, and 14 new flows. Every
sequence is identical.
- **Surfaces this PR touches:** 211 tests and 1376 states render the
title bar, Overview, Connection, HID, Serial, Settings or the Cockpit
band.
  - **The new flows cover:**
- the device link in every `DeviceState` (busy / other errors, Gateway
health) across the pill, popover, Connection card and Link tile;
    - auto-connect off and its Settings link;
- the relay in each mode × link state, through every stream state,
blocked callouts and a refused Start;
- a pending Start while the link, a recording and the transport change;
    - Record on both surfaces;
    - gauges idle → live → fuel alert → kept after Stop;
- HID freshness across fake time, the board coming and going, and
calibration arriving with no report yet;
- PSU preset / custom with the Cockpit band, including invalid and
re-seeded text;
- host and port as typed (junk too) with the pill and the Overview
following;
    - an import with and without settings, and a failed one;
    - the serial monitor;
    - two renderer reloads (N1).
- **Main trace.** The r31/r32b harness ran 40 scenarios plus 5 new ones
(`r33-link-facts`, `-relay-toggle`, `-config-edit`, `-import`,
`-capture-replay`). Base drives the renderer's old sequences; head
drives the new intents.
- **Filtered:** with the new fields stripped and the new channels
dropped (330 `relay:facts`, 46 `config:changed`), every scenario is
identical: the same emits, file writes, serial writes, timers, debug-log
lines and return values.
- **Inserted:** the only insertions are re-pushes, 10 `device:status`
(one per serial-monitor toggle) and 69 `hid:report` (calibration
snapshot changes and renderer loads).
- **Files:** config.json and node-mapping.json are written byte for byte
the same.
- **Boot probe.**
- **Intents:** `config:edit` and `relay:toggle` are appended (43 → 45).
  - **Pushes:** `config:changed` and `relay:facts` are new.
- **Files:** `config.json` and `node-mapping.json` are identical;
`debug.log` differs only in timestamps.

## Checks

- `npm run typecheck` (3 projects): clean.
- `npm run lint`: 0 errors, 29 warnings, all P5 direct `window.skyhawk`
(base 31: two fewer references in store.ts).
- `npm run lint:deps`: 0 errors, 2 warnings (base 6). Gone: both events
legacy warnings, and the nodes legacy warnings from Cockpit.tsx and
band-view.ts. Left: `CockpitSection.tsx → shared/nodes/legacy-rules.ts`
(`parsePsuLimit`, above) and `Overview.tsx → assets/logo.png`.
- `npx vitest run`: 103 files, 1113 tests (base 98 / 1083: +30 new,
ported cases net 0).
- `npm run build`: OK.

## Left for R3.4 (outside this brief's list)

- `deviceView`'s state → button / action table (§4.3, §4.7).
- `gatewayView`'s link gate and the not-responding title (§4.7).
- Connection's Refresh gate (`canRefresh`, §4.3) and the Record card
hidden in Replay (§4.3); both still read pushed facts or the mode in the
renderer.
- The renderer's transport aliases (§4.9).
- PSU `{...psu, mode}` and `parsePsuLimit` (§4.10, above).
- ConfigurationSection's export / import answer handling and
ImportDialog's "Import failed" fallback (§4.10).
- The remaining `shared/nodes/legacy-rules.ts` entries.
- The store fields `relayStream` and `hidAgeMs`, now read only by
`store.test.ts`.

Refs #100
R3.4a of the structure rewrite (#101): the decisions R3.3 left in the
renderer move to their main owners, which push the result. The renderer
keeps the wording. `shared/nodes/legacy-rules.ts` is deleted.

Nothing changes for the user. Text, enablement and order are the same,
and the known gaps (N1–N9, the parked list, the Relay pill's silent
revert) are kept as they are.

## Old → new

Each rule moved verbatim, with its comment.

| Before (renderer) | After (main) | Pushed as |
|---|---|---|
| device-view.ts `deviceView`: the state → button / action table,
disabled while scanning | `gateway/link-state.ts` `linkAction`
(GatewayConnection) | `device:status` `action` |
| gateway-view.ts: the Gateway hidden while the link is down |
`gateway/gateway-health.ts` `shownHealth` / `shownStatus`, applied by
GatewayConnection | `device:status` `gateway`, left out while down |
| gateway-view.ts: `kind === 'not-responding'` → popover title, not a
Health row | `gatewayStatus` marks it | `GatewayStatus.aboutLink` |
| Connection.tsx Refresh `disabled={!linkUp}` | NodeHub's own roster
guard, moved verbatim to `nodes/node-status-wire.ts`
`canRequestRoster(attached, state)`; `NodeHub.canRefresh()`;
`requestNodes` uses it | `device:status` `canRefresh`
(GatewayConnection's `canRefresh` dep, like `health`) |
| Connection.tsx Record card `sourceMode !== 'replay'` |
`dcs/relay-rules.ts` `recordShown(mode)`, beside `canRecord` |
`relay:facts` `recordShown` {connection, settings} |
| Connection.tsx Capture card `sourceMode === 'replay'` (its mirror) |
`dcs/relay-rules.ts` `captureShown(mode)`, beside `recordShown` |
`relay:facts` `captureShown`, in the same push |
| ConfigurationSection.tsx: reading the export reply from which field is
set | `ConfigTransfer.exportTo` (the config:export handler's body,
moved) | reply `ConfigExportResult.outcome` |
| CockpitSection.tsx PSU switch `{ ...psu, mode }` |
`config/settings-form.ts` `editedConfig` (SettingsService) | intent
`config:edit` `{ psuMode }`; the setting comes back on `config:changed`
|
| CockpitSection.tsx `onType`: refuse / delete / set one limit, `{
...psu, custom }` | `config/settings-form.ts` `withPsuLimit` +
`savedEdit`; `SettingsService.edit` saves and pushes nothing for a
refused limit | intent `config:edit` `{ psuLimit: { key, text } }` |

The renderer keeps "Connect / Disconnect / Retry" (`linkButton`), "Saved
{file}", the cards themselves, the popover's title and Health row, and
the PSU field's typed text. The field's per-keystroke invalid outline
still uses the contract's `parsePsuLimit`: that validates unsaved input.

### Record (X12-style variants)

Settings › Connection's Record row always shows. It differs from the
Connection card, so it gets its own variant: `recordShown` = `{
connection: mode !== 'replay', settings: true }`. ConnectionSection
reads `settings`. The value follows the mode, not the on/off, so it sits
at the top of `RelayFacts`, not in each side.

### Items with nothing to move

- **ConfigurationSection's import-open handling.** The canceled / error
/ ready verdict already comes from main (`ConfigImportOpen.status`). The
section only switches on it.
- **ImportDialog's `'Import failed'`.** `ConfigImportApplyResult`'s
failure always has an `error: string`: by type, and on all five
`ConfigTransfer.apply` returns. So `r?.error ?? 'Import failed'` only
fires when there is no answer (`window.skyhawk` absent). Main can't
decide that, so the fallback stays as wording. Both cases have render
flows.
- **Transport aliases** (`TRANSPORT_TO_IPC`, Connection's `TRANSPORTS`,
Overview's TCP / Multicast / Unicast). They are view-model names only, a
1:1 rename, so they stay. `configState`'s `?? 'tcp'` can't be reached,
because main's `sanitizeConfig` only lets the three `DcsTransport`
values through. It is left as it is.

## New push fields

| Channel / reply | Field |
|---|---|
| `device:status` (and `relay:status`'s `device`) | `action: LinkAction`
{`offer`: connect / disconnect / retry, `intent`: connect / disconnect,
`disabled?`}, `canRefresh: boolean`; `gateway` absent while the link is
down |
| `GatewayStatus` (on `device:status`) | `aboutLink?: true` (not
responding) |
| `relay:facts` (and `relay:status`'s `relayFacts`) | `recordShown: {
connection, settings }`, `captureShown: boolean` |
| `config:export` reply | `ConfigExportResult` = `{ outcome: 'saved',
path }` / `{ outcome: 'canceled' }` / `{ outcome: 'failed', error }` |
| `config:edit` intent | `ConfigEdit.psuMode?`, `ConfigEdit.psuLimit?: {
key, text }` |

No channel, intent or method was added, removed or renamed (45 intents).
The API guard (`ipc.compat.test-d.ts`) gets a dated R3.4a note.
`DEVICE_IDLE` and `RELAY_FACTS_IDLE` gain the new fields and are still
pinned to their builders.

## Where legacy-rules went

The file is deleted. The `P4-legacy-rules` depcruise rule, its `LEGACY`
path and the `legacyRules` severity key go with it.

| Entry | Now |
|---|---|
| `RAIL_FLAG`, `PDU_FLAG` | Pure aliases, so callers import
`PDU_RAIL_FLAGS as RAIL_FLAG` / `PDU_STATUS_FLAGS as PDU_FLAG` from
`@shared/firmware` (as plan-layout.test already did) |
| `displayCurrentMa` | `main/nodes/node-telem.ts` |
| `nodeFaultTag` | `main/nodes/faults.ts` |
| `reportedIdentity`, `isAdoptableKind` | `main/nodes/node-kind.ts` |
| `isReportableNew` | `main/nodes/node-model.ts` |
| `parsePsuLimit`, `isLimit`, `PSU_LIMIT_MAX` | the nodes contract
`shared/nodes/ipc.ts`, next to the PSU types. A comment says why: main
sanitizes with them, and the renderer marks a field invalid per
keystroke from the typed text |

## Timing in the app (not in tests)

As in R3.3, a fact that follows a form's own change arrives one IPC
round trip after the click:

- **The Capture and Record cards.** Both now come from the same
`relay:facts` push, so they flip together. During the round trip the
previous mode's cards stay on screen; they are never both visible.
- **The PSU segment and read-only fields after a mode click.** The
store's `psu` comes from every `config:changed`, edits included, since
main switches the mode and sets the limits.
- **The PSU limit field never moves.** Its text is the form's own state.

The sync mock pushes inside the handler, so those render sequences are
identical. An async-hop proof (below) holds main's answer back to show
the in-between state.

## Proofs

- **T1–T35: no test-file diff.** The only file changed under
`src/renderer/test/` is `skyhawk-mock.ts`. It builds `device:status`
with main's `shownStatus`, `linkFacts` (now `action`) and
`canRequestRoster`. A form's edit goes through `savedEdit`, so a refused
limit saves and pushes nothing. `exportConfig`'s default answer is `{
outcome: 'canceled' }`.
- **Ported helper tests.** Same cases, same values. The header adapters
compose main's rule with the renderer's wording.

  | File | Before | After |
  |---|---|---|
| `device-view.test.ts` | 18 | 18 (`button` =
`linkButton(linkAction(state))`) |
| `gateway-view.test.ts` | 5 | 5 (`gatewayView(g, linkUp, port)` =
`viewOf(shownHealth(g, linkUp), port)`) |
| node-kind, node-model, node-telem, node-status-wire, psu-limits,
panels, cockpit, events | unchanged | only their imports changed |

- **New tests (14).**
  - link-state: the action table, plus `canRefresh` (2)
  - gateway-health: `aboutLink`, plus `shownHealth` / `shownStatus` (2)
  - `canRequestRoster` (1)
  - `recordShown`, plus `captureShown` as its mirror (2)
  - PSU mode switch, plus `withPsuLimit` / `savedEdit` (2)
  - a refused limit saves and pushes nothing (1)
  - `ConfigTransfer.exportTo`: saved / cancelled / failed (1)
- `pushed-rules.seam`: action and Refresh through the link states;
Record and Capture across modes; the PSU switch and typed limits over
config:edit (3)
- **Render compare, base vs head.** 307 tests and 1965 states (1067
distinct): all 35 component-test files, the R3.2a / R3.2b / R3.3 flows,
and 7 new flows (230 states). Every sequence is identical. After each
click the flows write what reached main into the page (connect /
disconnect / refresh / export / import / apply calls, and every settings
save), so the clicks are compared too. The new flows cover:
- every `DeviceState`, with the card's and the popover's button clicked;
- Gateway not responding, and health pushed while the link is down
(reconnecting, error, disconnected);
  - Refresh in every state;
- Record across modes, stopped and relaying, and Settings › Connection;
  - export saved / cancelled / failed;
  - import open refused, apply refused twice, applied, cancelled;
  - PSU preset ↔ custom, with typing, invalid text, and an import.
- **Async-hop proof (base and head).** Two flows hold every
`config:edit` answer back until an explicit hop, so the state between a
click and main's push is on screen. A MutationObserver checks every DOM
state.
- **Source mode** (Replay / Monitor / Replay / Bridge / Replay /
Monitor): the Capture and Record cards are never both shown, at base or
at head. Before this fix, head (8c41811) failed here with both cards
showing during the hop. At head, the 6 held states show the previous
mode's cards, and each flips when main answers.
- **PSU limit** (type a valid value, then an invalid one, delete it,
type two keys with both answers held): the field shows exactly what was
typed in every state. Base and head reach the same 8 DOM states in the
same order, with the same saves.
- **Main trace.** The R3.3 harness ran 45 scenarios plus 3 new ones:
- `r34a-psu-mode`: mode switches, and limits typed valid, invalid, empty
and junk;
  - `r34a-refresh-action`;
  - `r34a-export`, through the real config:export handler.

With the new fields stripped, all 48 scenarios (11,794 lines) are
identical: the same emits, file writes, serial writes, timers, debug-log
lines and return values. Nothing is inserted or dropped; a refused limit
writes and pushes nothing on either side. Fields stripped:
`device:status.action` and `.canRefresh` (230 each), `gateway.aboutLink`
(5), `relay:facts.recordShown` and `.captureShown` (387 each),
`config:export.outcome` (4).
- **Boot probe.** No difference in either run, the dev userData or
`SKYHAWK_USER_DATA`. The channel list is the same 45 intents, in the
same order, with the same pushes. `config.json` and `node-mapping.json`
are byte-identical.

## Checks

- `npm run typecheck` (3 projects): clean.
- `npm run lint`: 0 errors, 29 warnings, all P5 (unchanged; store.ts's
two `editConfig` callers share one `window.skyhawk` reference).
- `npm run lint:deps`: 0 errors, 1 warning, the expected one:
`Overview.tsx → assets/logo.png` (base: 2; the `CockpitSection.tsx →
legacy-rules` warning is gone).
- `npx vitest run`: 103 files, 1127 tests (base 1113; +14 new).
- `npm run build`: OK.

## Left for R3.4c

- Main has a second copy of the link gate:
`cockpit-snapshot/attention.ts` `gatewayAttention` keeps its own
`!linkUp || !g`. It could consume `shownHealth`.

Refs #100
…#126)

R3.4b of the structure rewrite (#101). The renderer gets its topic
folders, one zustand store per topic, and a single IPC adapter. The
renderer boundary rules go to error.

Nothing changes for the user. Every component test and every render flow
reaches the same DOM states in the same order, and main is untouched.

Three commits:
1. `git mv` only.
2. The edits.
3. The lint flips.

## Old → new

Moved files keep their content. The edits are imports, store hooks and
the cuts listed in the table.

| Before (`src/renderer/src/`) | After |
|---|---|
| `App.tsx` | `app/App.tsx` (+ `app/index.ts`; `main.tsx` imports
`./app`) |
| `store.ts` | split: see the next section. `initBridge` →
`app/ipc-adapter.ts` |
| `assets/logo.png` | `app/assets/logo.png` (clears the T2 warning) |
| `useModalFocus.ts` | `app/kit/use-modal-focus.ts` |
| `tabs/settings/{Dialog,Row}.tsx` | `app/kit/{Dialog,Row}.tsx` |
| `Sheet` (in SidePanels), `Chevron` (SidePanels), `CogIcon` (Cockpit) |
`app/kit/Sheet.tsx`, `app/kit/icons.tsx` (`Chevron`, `Cog`), cut
verbatim |
| `tabs/Overview.tsx`, `overview.ts` | `app/overview/Overview.tsx`,
`app/overview/overview-view.ts` |
| `tabs/Connection.tsx` | `app/connection/Connection.tsx`. It composes
`gateway/DeviceCard.tsx`, `nodes/NodesCard.tsx` and
`dcs/RelayControls.tsx` (`CaptureCard`, `RecordCard`, `RelayStart`),
each cut verbatim |
| `tabs/Log.tsx` | `app/log/Log.tsx`. It keeps the toolbar, the head row
and the footer, and renders `events/EventsView.tsx` /
`dcs/StreamView.tsx` (the row lists, cut verbatim) inside the same
`.log__rows` element |
| `tabs/Hid.tsx` | `hid/HidPanel.tsx` + `app/hid/HidTab.tsx`. HidTab
holds the calibration store, the `attach` effect and the dialog, and
hands them to the panel |
| `tabs/Settings.tsx`,
`tabs/settings/{About,Cockpit,Connection,Diagnostics}Section.tsx`,
`ResetDialog.tsx`, `useNodesPulled.ts` | `app/settings/…` |
| `tabs/settings/settings-view.ts` | `app/settings/settings-view.ts`
(reset and hidden rows) + `config/import-view.ts` (`importSubtitle`,
`importRowKey`, `importDoneText`) |
| `tabs/settings/{ConfigurationSection,ImportDialog}.tsx` | `config/…` |
| `DevicePopover.tsx`, `gateway-view.ts`, `tabs/Serial.tsx` |
`gateway/…`. Serial's hex/ASCII `render` → `gateway/serial-view.ts` |
| `device-view.ts` | `gateway/link-view.ts` (`deviceView`, `linkButton`)
+ `dcs/relay-view.ts` (`relaySide`, `endpointText`, `relayTip`,
`relayNote`, `startBlocked*`) |
| `RelayPill.tsx` | `dcs/RelayPill.tsx` |
| `rawHex`, `LogRow`, `DirFilter` (store.ts) | `dcs/stream-view.ts` |
| `tabs/Cockpit.tsx`, `tabs/cockpit/{AdoptDialog,SidePanels,Dot}.tsx`,
`node-view`, `plan-layout`, `panel-view`, `adopt-view`, `band-view` |
`nodes/…` |
| `freshness.ts`, `AXIS_LABELS` + `HAT_DIRS` (store.ts) |
`hid/hid-view.ts` |
| `CalibrationDialog.tsx`, `calibration-view.ts` | `hid/calibration/…` |
| `tabs/cockpit/BoardEvents.tsx`, `log-view.ts` |
`events/BoardEvents.tsx`, `events/events-view.ts`. `LogView` and
`EventSourceFilter` → the nav store |
| (new) | `app/titlebar/{DevicePopover,RelayPill}.tsx`,
`app/cockpit/Cockpit.tsx`: app wrappers that hand a topic view its
settings (below) |

Test files moved with their modules: `store.test.ts` →
`app/ipc-adapter.test.ts`, and each `*-view.test.ts` sits next to its
module.

## Store slices → topic stores

Each store is cut verbatim from `store.ts`. Its fields, initial values,
intent bodies and comments are unchanged. Each one exports
`use<Topic>Store`, `bind<Topic>(api)` and `<topic>FromMain`, the
handlers the adapter calls.

| Store | State | Intents |
|---|---|---|
| `app/kit/nav-store.ts` `useNav` | `tab`, `logView`, `eventSource`,
`cockpitOpen`, `cockpitAdopt`, `devicePopOpen` | `set`, `showEvents`,
`openInCockpit`, `openAdopt` |
| `gateway/store.ts` | the `device:status` fields (`linkState`),
`serialMonitor`, `serialLog` | `connectDevice`, `disconnectDevice`,
`setSerialMonitor`, `clearSerial` |
| `dcs/store.ts` | `relaying`, `relayStream`, `relayFacts`, `aircraft`,
telemetry, stats, `log`, `autoscroll`, `rawMode`, `dirFilter`, `search`
| `set`, `toggle` (autoscroll / rawMode), `toggleRelay`,
`toggleCapture`, `openReplay`, `clearLog`, `exportLog` |
| `nodes/store.ts` | `nodeModel`, `nodeTelemetry`, `cockpit` |
`refreshNodes`, plus the views' former direct calls: `pullNodes`,
`getNodeRegistry`, `setNodeMapping`, `adoptNode`, `addAllNodes`,
`resetNode`, `forgetNode`, `hideNode`, `unhideNodes`,
`previewNodeReset`, `resetNodeMapping` |
| `hid/store.ts` | axes, buttons, hats, rate, age, band, present,
`axisLive`, `availHats`, `availButtons` | none (no `bindHid`) |
| `hid/calibration/store.ts` | `calibration` | `calibrate`
(`CalibrationIntents`) |
| `events/store.ts` | `events`, `eventsPauseMark`, `logPaused`,
`problemsOnly` | `set`, `togglePause` (the `logPaused` branch of the old
`toggle`), `clearEvents`, `exportEvents` |
| `config/store.ts` | the form fields (`configState`), `psu`, `psuView`,
`debugMode`, `debugDump` | `setConfigField`, `setPsuMode`,
`setPsuLimit`, `dumpSerialPorts`, `revealDebugLog`, `exportConfig`,
`openConfigImport`, `applyConfigImport` |

**Every multi-field update stays one update.**
- The Log tab's pause lives with the events pause mark, because
`toggle('logPaused')` set both in one update and `clearEvents` reads it.
- `showEvents`, `openInCockpit` and `openAdopt` set nav fields only.

## The adapter

`app/ipc-adapter.ts` is the only file that reads `window.skyhawk`.
- At load it binds every store through one accessor, `() =>
window.skyhawk`, which is read at each call. That matches the old
`window.skyhawk?.x()` at call time, including outside Electron.
- `initBridge` is the old one, with each pull and push handed to its
slice, in the same order:
- **Pulls:** `getHidAvailability` (hid) → `getNodes` (nodes) →
`getConfig` (config) → `getEvents` (events) → `getStatus`
- **Subscriptions:** `hid:report` → `device:status` → `relay:state` →
`relay:facts` → `cal:workflow` → `aircraft:changed` → `nodes:model` →
`nodes:telemetry` → `cockpit:snapshot` → `config:changed` →
`serial:traffic` → `telemetry:tick` → `stats:tick` → `events:batch` →
`log:batch`

**Cross-slice cases.** There are two, and both keep the old timing:
1. **The rehydrate bundle (`getStatus`).** It used to be one update. It
is now three, one after another in the same callback: dcs (`relaying`,
`relayStream`, `relayFacts`), then gateway (`linkState(st.device)`),
then calibration (`st.cal ?? CALIBRATION_IDLE`). Nothing runs between
them, so React renders them in one batch. The reload flows below show
identical states.
2. **`log:batch` (dcs).** It reads `useEventsStore.getState().logPaused`
at the moment the batch lands (dcs → events is an allowed direction). It
used to read `get().logPaused`.

## App composition

Topic views stay in their topic, and the app hands each one the other
topics' values as props.

| App component | Composes |
|---|---|
| `App` | nav `tab`, config `sourceMode`; title bar; tabs |
| `titlebar/DevicePopover` | gateway `DevicePopover` + config
`autoConnect` |
| `titlebar/RelayPill` | dcs `RelayPill` + config `autoConnect`, `host`,
`port` |
| `overview/Overview` | config, dcs, gateway (`gatewayView`), nodes
(`Dot`, snapshot), nav |
| `connection/Connection` | gateway `DeviceCard` (+ `autoConnect`),
nodes `NodesCard` (+ `autoConnect`), config source-mode and DCS-link
cards, dcs `CaptureCard` (+ Drive Gateway value and switch),
`RecordCard`, `RelayStart` |
| `cockpit/Cockpit` | nodes `CockpitTab` + config `psuView` |
| `log/Log` | nav (sub-tab, source), dcs `StreamView` + stream filters,
events `EventsView` + `filterEvents` + pause |
| `hid/HidTab` | hid `HidPanel` + hid/calibration (state, `attach`
effect, `CalibrationDialog`, `open`) |
| `settings/*` | config, dcs (Record row), nodes (hidden, reset,
registry); `ConfigurationSection` (config) + hid `AXIS_LABELS` |

## Lint, before → after

| Check | Before (4d13321) | After |
|---|---|---|
| `npm run lint` | 0 errors, 29 warnings (all P5) | **0 errors, 0
warnings**; P5 is `error` |
| `npm run lint:deps` | 0 errors, 1 warning (T2 `Overview.tsx →
assets/logo.png`); 181 modules, 561 deps | **0 errors, 0 warnings**; 213
modules, 667 deps |
| `.dependency-cruiser.severity.json` | `kitLeaf`, `topicsRenderer`,
`deepRenderer` = warn | **all 13 keys `error`** |

The rules were checked to fail. Temporary probe files (deleted again)
each produced an error:
- gateway → dcs: `topic-renderer-gateway`
- hid → hid/calibration: `topic-renderer-hid`
- nodes → app: `topic-renderer-nodes`
- app → `gateway/store.ts`: `T2-renderer-no-deep-topic-import`
- kit → gateway: `L-kit-leaf`
- `window.skyhawk` in nodes: P5 ESLint error

The CI step "Boundary lint (warn-only …)" is now "Boundary lint", and
the depcruise header comment is updated.

## Proofs

- **T1–T35 and the renderer unit tests: green, with unchanged
assertions.**
  - The 35 component tests: import paths only.
  - The view-model tests: import paths only.
  - The harness:
- `store-reset.ts` resets every topic store and imports the adapter, so
the intents are bound;
    - `render.ts` calls the adapter's `initBridge`.
- `app/ipc-adapter.test.ts` (was `store.test.ts`): only its
`freshStore()` helper changed. It now imports the adapter and the
stores, and returns a merged `getState()` view with `toggle` routed
(`logPaused` → events `togglePause`). Its cases and assertions are
unchanged.
- **Render compare, base (4d13321) vs head.** 313 tests and 2050 DOM
states (1119 distinct). Every sequence is identical.
  - Coverage:
    - all 35 component-test files;
    - the R3.2a / R3.2b / R3.3 / R3.4a render flows;
- the R3.4a async-hop proof, whose per-step log is also byte-identical;
- one new R3.4b flow (4 tests, 63 states): Log Stream ↔ Events with rows
in both, Pause across views, Clear while paused, Raw, search; cross-tab
targets; reloads whose rehydrate spans relay, link and calibration; HID
reports streaming under an open calibration dialog.
- One normalization: the logo `src` in a test run is the source path,
which moved. In the build, the asset is the same file,
`logo-Cq53l2FN.png`.
- **Main trace.** The R3.4a harness ran 48 scenarios (11,842 lines),
byte-identical. `git diff 4d13321 -- src/main src/shared src/preload
src/test-fixtures` is empty.
- **Build.** `out/main` and `out/preload` are byte-identical to base.
The renderer CSS and logo asset are unchanged; only the JS bundle
differs.
- **Boot probe.** Both runs, dev userData and `SKYHAWK_USER_DATA`, are
identical:
  - 45 intents, in the same order;
  - the same pushes;
  - the same files written.
- **Checks:**
  - `npm run typecheck` (3 projects): clean;
  - lint and lint:deps as above;
  - `npx vitest run`: 103 files, 1127 tests;
  - `npm run build`: OK.

## Not as in the plan (§1.4 / §2.2)

- `dcs` exports three relay-control components (`CaptureCard`,
`RecordCard`, `RelayStart`), not one `RelayControls`. The Connection
column puts config cards between them.
- `nodes` also exports `Dot`, which the Overview uses.
- `hid` has no `bindHid`: it has no intents.
- No `nodes-card-view.ts`. The nodes card has no wording left to move
(main pushes it).
- Three small app wrappers (`app/titlebar/*`, `app/cockpit/Cockpit.tsx`)
hand settings to gateway, dcs and nodes views, which may not read
config. Component tests import them, so the render lines stay the same.

Refs #100
R3.4c of the structure rewrite (#101). Before the rewrite the renderer
worked out its decisions synchronously from one push. Main now decides,
and real IPC is asynchronous, so the renderer could draw frames that the
original app never showed. This PR makes those frames consistent again.
Settled states do not change.

Two commits:
1. `refactor(dcs)`: the source mode and transport are shown from
`relay:facts` (F1, F2).
2. `refactor(app)`: each main turn's pushes go out as one `push:batch`
message (F3, F4, F5).

## The audit

An async-hop audit replayed 94 user actions. It held every renderer→main
invoke for one hop, then delivered main's pushes one IPC message at a
time. It compared the DOM after each step with BASE (2598104, before the
rewrite) and with HEAD (4d13321). It found two kinds of mixed frame,
where part of the screen has updated and a related part has not.

- **F1, source mode switch.** The segment, the hint and the title bar's
SOURCE come from the form's optimistic `sourceMode` and flip at once.
The Capture/Record cards, Start with its waitnote, and the pill's
blocked "!" and tooltip come from `relay:facts` and lag one hop. With no
device, Monitor→Bridge briefly showed Start enabled, and a click there
sent a Start that main refused.
- **F2, transport radio while relaying.** The radio moved at once, but
the pill tooltip still named the old endpoint.
- **F3, link change.** `AppServices`' onStatus fan-out sends
`device:status`, `cal:workflow`, `cockpit:snapshot` and `relay:facts` as
four messages. Frames between them showed, for example, the link down
while the Cockpit still read live.
- **F4.** `cal:workflow` arrived, then `hid:report`. One frame in
between showed CAL/RAW badges with "0 of 8 slots".
- **F5, Restore.** Two `cal:workflow` pushes in one tick. A restored
*draft* with Write enabled was visible before "Writing…".

## F1 / F2: shown from the facts main judged

`RelayFacts` gains `mode` and `transport`, the settings `relayFacts()`
was judged against. These now read them instead of the form's local
value:
- the Source Mode segment and its hint;
- the title bar's `SOURCE <b>`;
- the Transport radios;
- the Overview's DCS Link name (TCP / Multicast / Unicast).

Each of these flips in the same push as the cards, Start and the pill. A
click shows its result once main has taken it. A Start can no longer go
out for the old mode.

`SourceMode` and `DcsTransport` are now declared in the dcs contract,
and config re-exports them, so the facts can name them without a
contract cycle. `RELAY_FACTS_IDLE` carries the defaults (`monitor`,
`tcp-to-host`).

Settings › Connection's read-only "Source mode" row still reads the form
value. It cannot change while it is on screen.

## F3–F5: one message per main turn

- **Flush point.** `batchPushes(send)` (`main/app/push-batch.ts`) is the
`Emit` that `window.ts` hands to AppServices. The first push of a turn
starts a batch and queues a microtask. Every push made during the rest
of that synchronous turn joins the batch. The microtask sends it with
`webContents.send('push:batch', batch)`.
- The flush adds no timer-visible delay: the batch is out before any
timer or I/O callback.
- It stays ahead of the reply the turn's handler returns.
`ipcMain.handle` replies from a continuation that is queued only after
the handler returns.
  - `setImmediate` was rejected: it would let replies overtake pushes.
- **Ordering.** A `PushBatch` is an ordered list of `{ channel, payload
}` (`PushItem`). Main's order and payloads are kept as given, and
nothing is merged or dropped. A channel pushed twice appears twice, and
the later push wins exactly as before.
- **Preload.** The preload registers one `ipcRenderer.on('push:batch')`
listener. `SkyhawkApi.on(channel, cb)` is unchanged: it is still the
per-channel subscription, now served from that one listener.
- `deliverPushes` (shared/app) hands each item to its channel's
subscribers in order, all within the one IPC event.
- A subscriber that throws costs only its own push. The rest of the
batch is still delivered, and the error is rethrown at the end.
- **Adapter.** `initBridge`'s `api.on(...)` calls stay as the routes to
each `<topic>FromMain`. Because a whole batch reaches those routes in
one task, React 18 commits the turn once. Only the adapter's comment
changed.
- **F5.** Batching alone covers it. The two `cal:workflow` pushes still
both go out, in one batch, and the second wins in one commit. The
audit's in-hop (d) frame is gone, so `workflow.restore()` was left
outside the host's `batch()`.
- **Contract and guard.** These are new: `PUSH_BATCH`, `PushItem`,
`PushBatch` and `deliverPushes`. `ipc.compat.test-d.ts` now pins
`'push:batch'` and the batch's shape. `SkyhawkApi`, `PushChannels`,
`IPC`, `CTRL` and `RelayStatus` still equal the frozen copy.
- **Mock.** `skyhawk-mock.ts` collects pushes the same way and delivers
them through `deliverPushes`.
- A mock "turn" (`emitScenario`, an intent's answer, `calRead`, a host's
timer firing) delivers its batch as it ends, inside its `act`.
  - Any other push is delivered in a microtask, as main's flush is.
- `getStatus`'s pushes join one batch ahead of its answer, as main's
`relay:status` handler sends them.
- The calibration and cockpit hosts get timers whose callbacks are
turns.

## Audit, before → after (94 cases)

The audit-hop harness was ported to the R3.4b paths, and its HEAD
capture now records one message per `push:batch`. BASE was re-run in
both modes. Every recorded frame is byte-identical to the audit's run,
so the "already at BASE" classifications still hold.

**The frame between the click and the hop (HEAD):**

| Class | Audit (4d13321) | This PR |
|---|---|---|
| (a) before state | 42 (11 new latency) | 53 (22 new latency: the 11
accepted + the 11 F1/F2 cases below) |
| (b) after state | 34 | 30 |
| (c) mix | 8 (**7 new**) | 1: `pill-callout-connect`, already at BASE |
| (d) never at BASE | 10 (0 new) | 10 (0 new; all identical at
BASE-with-hop) |

The 11 F1/F2 cases that now wait one hop as a benign (a):
- the 6 source-mode switches and Transport Loopback while relaying,
which were (c);
- Bridge→Monitor and Transport TCP / Loopback / Unicast while stopped,
which were (b).

**Between pushes (in-hop frames):**

| | Audit | This PR |
|---|---|---|
| (c)/(d) frames | 29 | 9 |
| new (not at BASE) | **14, in 8 cases** (F3, F4, F5) | **0** |

The 9 that remain all appear identically at BASE-with-hop: the busy
state of a mapping call (Adopt, Save, Reset to default), the per-board
loop of Add all and Un-hide all, Reset apply, Select axis and the
refused Start.

The click-window MutationObserver (c) frames of the 7 F1/F2 cases are
gone. HEAD's before and after states equal BASE's in 92/94 cases, the
same two harness artifacts as in the audit.

## Proofs

- **Render compare** (`r34b` render scripts: every component test plus
the R3.2a–R3.4b flows, fbcd181 vs this head). Of 313 tests, 312 have
identical state sequences.
- The one that differs is the R3.4a async flow "source mode: the Capture
and Record cards are never both shown". It loses 6 intermediate states:
exactly the F1 mixes, with the segment moved and the cards not.
- Its remaining sequence is the base sequence with those states removed,
in order, and its final state is equal.
  - In total, 2,050 states become 2,044. No settled state changed.
- **Main trace** (r34a harness, 48 scenarios, with `push:batch` recorded
as sent and expanded into its items).
- Expanded batches give 4,702 emits, identical in order and payload to
the same head with an unbatched emit. Every non-emit side effect
(serial, DCS, timers, files, logs) is identical too.
- Against fbcd181, the differences are `relay:facts`' new `mode` /
`transport` fields, plus one extra `relay:facts` push. That push is in
`commands-stopped-running`: Bridge→Monitor while stopped with the link
up. No other fact changed there, so it was not pushed before. The mode
now has to reach the segment.
- There were 3,435 batches, 350 of them carrying more than one push (the
largest carried 59).
- With replies modelled as `ipcMain.handle` sends them (one continuation
later): no reply is delayed behind a push it used to precede. 25 replies
now come before pushes made after their handler returned. Each is a
value the harness returns synchronously, followed by a separate scripted
action in the same run; under Electron those would be separate messages.
- **Boot probe** (built bundle, dev userData and `SKYHAWK_USER_DATA`).
The intents are identical (45, same order) and the files are identical.
- The wire is the one difference: per-channel sends become `push:batch`.
Its items carry the same channels as base: dev `cockpit:snapshot,
config:changed, device:status, hid:report, relay:facts, relay:state`;
env adds `nodes:model`.
- The probe waits one `setImmediate` before reading what was sent,
because `will-quit`'s `relay:state` now leaves in the final microtask.
- **T1–T35** pass with unchanged assertions. Only the mock changed under
`src/renderer/test/`.
- **Checks:**
  - typecheck (3 projects) clean;
  - lint 0/0;
  - lint:deps: no violations;
- vitest: 104 files, 1,135 tests, which is the baseline 1,127 plus
`push-batch.test.ts` (7) and one relay-rules case;
  - build ok.

Known gaps are unchanged: N1–N9, the parked items, and the Relay pill's
local pending state with its silent revert.

Refs #100
R3.4d, the last PR of the structure rewrite. It deletes dead code,
merges two leftover duplicates, applies the plan's RENAME rows, rewrites
the pixel-geometry plan-layout tests, fixes stale comments and adds
`ARCHITECTURE.md`. Behaviour is unchanged: no UI change and no wording
change. The one wire change is that two dead pushes are removed:
`cal:data` and `nodes:telemetry`.

Refs #100

## 1. Dead code

Each item was checked with grep before it was deleted.

| Removed | Evidence |
|---|---|
| The `cal:data` push: its contract entry (`CalibrationPushChannels`,
`CALIBRATION_PUSH.calData`), main's `emit('cal:data')` in
`HidCalibrationService.calRead`, the mock's push, and the API guard's
entry. | The renderer subscribes only `cal:workflow` (`ipc-adapter.ts`);
no file under `src/renderer/src` names `cal:data`, and the preload has
no per-channel code. `calRead` still hands the report to the workflow.
In the mock, `cal` stays a scenario part with no push (`PUSHES.cal:
null`), and `host.reported` runs at the same point. |
| The `nodes:telemetry` push: the contract entry (`NodesPushChannels`,
`NODES_PUSH.nodesTelemetry`), NodeHub's `emit('nodes:telemetry')`, the
mock's push, and the API guard's entry. | After the route above went, no
renderer file subscribes it. NodeHub still takes the PDU pages on the
same 1 s throttle, hands them to the Cockpit snapshot host
(`telemetryPushed`, unchanged) and pushes the model after them;
`nodes:get` still answers them (`NodeTelemSnapshot` stays:
`NodeSnapshot.telemetry` uses it). In the mock, `telemetry` stays a
scenario part with no push (`PUSHES.telemetry: null`) and still feeds
the real cockpit host at the same point. |
| `gatewayView().attention` | No reader: `Overview.tsx` reads `.alert`
and `DevicePopover.tsx` reads pill/title/meta/health. The Cockpit's item
comes from `cockpit:snapshot`. |
| Store fields `nodeTelemetry`, `relayStream`, `hidAgeMs`, with their
writers | A per-field grep of every `*State` interface: these three are
the only fields that nothing in `src/renderer/src` reads outside the
store (`private_*` seq counters excluded). Their only readers were
`ipc-adapter.test.ts` cases. `nodeModel` is kept: ResetDialog and
`useNodesPulled` use it. With `nodeTelemetry` gone, the adapter's
`nodes:telemetry` route did nothing, so it was removed; the push itself
goes in the next row. |
| `formatCommand`, `cameOnline`, `decodeAxis`, `Recorder.reset`,
`isDebugEnabled` (plan §3.5) | No production caller; each name appears
only at its definition and in its own test. `Recorder.reset` and
`isDebugEnabled` had no tests. The trace harness mocks `isDebugEnabled`
in a `vi.mock` factory, which doesn't need the export. |
| Other unused exports | Found with **knip 6.41.0** via `npx` (no
dependency added; knip 5 can't load TypeScript 6). Run with all source
files as the project and tests as entries. **Deleted** (no reference
anywhere, tests included): the codec's `encodeGetCal`,
`encodeSessionClose`, `encodeKeepalive`, `encodeStreamSelect` (`CalLink`
builds frames with `request(CAL_TYPE.X, …)`) and `CAL_MAX_PAYLOAD`; the
`ImportDialog` and `AdoptDialog` re-exports from their topic doors.
**Un-exported** (used only in their own file): 32 symbols, plus one test
re-export (`scenarios.ts` `nodeModel`), across `config-file`,
`settings-service`, `relay-rules`, `stream-log`, `cache-rules`,
`calibration-view-model`, `calibration-workflow`, `codec`,
`cockpit-snapshot/*`, `node-status-wire`, `node-telem`, `power-zone`,
`registry`, `dcs-relay`, `serial-bridge`, `plan-layout` and the test
harnesses. |

Left on purpose, and listed by knip after this PR:

- shared contract exports (`PSU_LIMIT_MAX`, the calibration and
telemetry sub-types);
- generated `CalTypeName`;
- the topic doors' type re-exports (`<Topic>State`,
`JsonFile`/`JsonPolicy`/`JsonRead`, `MappingFileIO`, `CacheFileIO`,
`GatewayConnectionDeps`, `SerialRow`, `StreamLine`). They are types
only, and every door follows the same pattern.
- `installSkyhawkMock` is a false positive: the vitest `setupFiles`
entry uses it.

Production-dead but used by tests, so **not deleted** (outside the
plan's five):

- `ACFT_NAME` and `SUPPORTED_NODE_PROTO` / `SUPPORTED_TELEM_PROTO`: pins
in `reference.test.ts`;
- `idleTelemetry`: pinned equal to the contract's `TELEMETRY_IDLE`;
- `encodeHello` / `encodeSessionOpen`: codec wire vectors;
- `relayWaiting`: `link-view.test.ts`.

## 2. Duplicates

- **`gatewayAttention` uses `shownHealth`.** `shownHealth(g, up)` is `up
? g : undefined`, so `!shownHealth(g, up)` equals `!up || !g` for every
input. A scratch test, not committed, ran the old gate (verbatim)
against the new one through `cockpitAttention`. It covered 13 statuses
(undefined, plus ok, warn and err from `gatewayStatus` over healthy,
bus-off, overheat, error-passive, fault and kind-0 rows, each with and
without not-responding) × link up/down, so 26 cases, all equal.
`gateway/index.ts` now exports `shownHealth`; nodes → gateway is an
allowed direction.
- **`isObject` → `isPlainObject`.** The bodies are character-identical
(`typeof v === 'object' && v !== null && !Array.isArray(v)`).
`config-file.ts` imports it from `nodes` (the plan's MERGE row into
`mapping-rules.ts`); config → nodes is allowed.

## 3. Renames (types only, no channel string changed)

From the plan's RENAME rows:

| Old | New | Where |
|---|---|---|
| `LogRow` | `StreamRow` | contract `shared/dcs/ipc.ts` (`log:batch`) |
| `CaptureState` (DCS) | `RecordState` | contract `shared/dcs/ipc.ts`
(`capture:toggle`); calibration's own `CaptureState` is untouched |
| `Transport` | `TransportId` | `renderer/src/config/store.ts` |
| `LogRow` | `StreamLine` | `renderer/src/dcs/stream-view.ts` |
| `SectionId` | `SettingsSectionId` |
`renderer/src/app/settings/Settings.tsx`; config's contract `SectionId`
is untouched |

**API guard, removed channels.** The diff, on purpose:
- `OldPushChannels`: `'cal:data': CalSnapshot` and `'nodes:telemetry':
NodeTelemSnapshot[]` removed;
- `OldIPC`: `readonly calData: 'cal:data'` and `readonly nodesTelemetry:
'nodes:telemetry'` removed;
- the now-unused `CalSnapshot` and `NodeTelemSnapshot` imports removed;
- an R3.4d paragraph in the header says why.

`OldSkyhawkApi.on` and `OldPushBatch` follow `OldPushChannels`, so
neither channel can be subscribed or batched. `OldCTRL` and the intents
are unchanged.

**API guard, renames.** The frozen copy now pins the two contract shapes
under their old names: `OldLogRow` and `OldCaptureState`, used in
`log:batch` and `toggleCapture`. A new `RenamedShapesUnchanged` asserts
`Equal<OldLogRow, StreamRow>` and `Equal<OldCaptureState, RecordState>`.
To check the guard can fail, a field was added to `RecordState` as a
scratch mutation: typecheck failed on both assertions.

## 4. Brittle tests

**`plan-layout.test.ts`.** The cases that asserted pixel constants now
assert the same facts without them:

- **Tile width.** Was 99 / 172 / 206. Now: a short name gets the column
width; a longer one is wider than a column and narrower than the cap,
and grows with its name; a very long one is capped.
- **Flow and wrap.** Was x/y/w tuples. Now: rows by membership and order
(`Engine` | `ARC51_UHF_Radio` | `Fuel, Trim` | `Gear`); the flow order
is the members' order; each row starts at the group's left edge; tiles
run left to right, a gap apart; column tiles share one width and the
long name is wider; every tile stays inside the group.
- **Stage growth.** Was `> 600` and `psu.y + 52`. Now: the stage is
taller than a one-board stage, the PSU sits under the side box, and the
stage ends the same distance under the PSU.

Scratch mutations of `plan-layout.ts` showed the new assertions still
catch:

- no wrap;
- a fixed width;
- overlapping tiles;
- no gap.

They no longer catch a change to the PSU box's 52 px.

**Kept with its geometry:** "a board changing state never moves a tile".
There the geometry is the behaviour, and it compares two layouts with
each other, not with constants.

**Whole-sentence banner tests:** left as they are.

**Duplicate matrices: none deleted.** The rule is that a duplicate goes
only when an identical case stays at the owner. None qualified:

- **Identify matrix.** `node-kind.test` is the PROTO owner and
`node-hub.test` the HUB owner. The other copies each pin a fact the
owners don't:
- `node-model.test` "identifies by …": the model wiring, including the
telemetry-only PDU;
- `overview-summary.test` `reportedKind`: it reads `identified`, not the
display kind;
  - `cockpit.test` `newCount`: a non-new board isn't counted;
  - `adopt.test` `adoptability`: badges and notes.
- **Input validation ×2.** `mapping-rules.test` refuses `[]` and `[{…}]`
(the `Array.isArray` branch), five on/off values and four reset shapes.
`node-hub.test` checks one value each and the `{ok:false}` conversion.
- **Import cluster ×3.** `node-hub.test` ("keeps … added and last
seen"), `mapping-rules.test` `importMapping` and `config-file.test`
`diffImport` are the PART 1D "pin wrong or soon-to-change" rows (F19),
so they are untouched. The "never hides a board on the bus" copies test
different functions: the hub's disk result, `importMapping`'s output and
the preview rows.

## 5. Comments and docs

- Comments that described current code through `Session`, `DeviceLink`,
`useNodeModel`, old renderer files, or old `shared/*` model paths now
name the current owner, or just the phase. That covers `cal-link`,
`node-hub`, `app-services`, `stream-log`, the harness, `relay-rules`,
`summaries`, the `cockpit-snapshot` headers, `link-state`, `hid-facts`,
`shared/gateway/ipc.ts` and others.
- Comment-only test edits:
  - T35's header (it now names `useNodesPulled` and the nodes store);
  - port-provenance headers;
- `relay-rules.test` (it pointed to `device-view.test.ts`, which is
gone).
- `scripts/sync-a4ec.ts` doc strings now point to
`main/nodes/node-status-wire.ts`, `main/nodes/node-telem.ts` and
`main/hid/calibration/codec.ts`. `npm run sync` (pinned a78dd90276)
rewrites `node-status.generated.ts` and `axis-cal.generated.ts`, and
only their comments change. With comments stripped, all eight generated
files are identical to before, and a second sync is a no-op.
- `PRD.md` §9's repo layout now shows the topic folders, the
boundary-lint files and the real workflows.

## 6. `ARCHITECTURE.md`

A new contributor guide at the repo root, linked from `README.md`. It
covers:

- the process wall and the IPC contract, with the API guard;
- the topics, with `index.ts` as the only door, and the
allowed-directions table;
- the roles (model, service, view-model, view), with one example each;
- the owner per topic;
- how a screen gets its data;
- `push:batch`;
- firmware values from `npm run sync`;
- the settings store and the injected ports;
- where each piece of a new feature goes;
- the boundary lint and its severity file;
- the test layers.

## Proofs

- **Tests.** T1–T35 pass with their assertions unchanged; the only diff
under `src/renderer/test/t*` is T35's header comment. vitest goes from
104 files and 1,135 tests to 104 files and 1,131 tests. Deleted cases:
1. `bios-protocol.test` "formatCommand › builds an identifier + arg
line": dead code.
2. `node-model.test` "cameOnline › lists only nodes that were not
present…": dead code.
3. `hid-report.test` "decodeAxis sign-extends a 16-bit field": dead
code.
4. `ipc-adapter.test` "carries the report age, not just the rate": dead
field `hidAgeMs`. The rate and the age's band are pinned by T25.

  Assertion lines removed from cases that stay:
  - `gateway-view.test`: three `v.attention` lines;
- `ipc-adapter.test`: three `relayStream` lines and one `nodeTelemetry`
line.

  Four titles were reworded to drop the removed fields.

Re-expressed, not deleted (titles unchanged): the three
`app-services.test` cases that counted `nodes:telemetry` pushes.
- "feeds _NODE_TELEM to the node layer…": the pages are what `nodes:get`
answers (node 3, PDU, live). The throttle is the number of
`cockpit:snapshot` pushes per take of the pages: none for a second frame
inside the second, then exactly one more take after it.
- "keeps node pushes flowing after the relay stops": the pages are taken
and a Cockpit snapshot goes out.
- "stops node pushes while unplugged": no Cockpit snapshot after the
close for a late chunk.

All three also pass unchanged on base 8462a6c. A scratch mutation
(throttle 1000 → 0 ms) fails the first one.
- **Render compare** (the r34c scripts, 8462a6c vs head: every component
test plus the R3.2a–R3.4b flows): **313 tests, all 313 sequences
identical**, 2,044 states on both sides. The async log is identical.
- **Main trace** (the r34a harness, 48 scenarios, batches expanded).
Compared with base:
- **Pushes:** 51 `cal:data` and 43 `nodes:telemetry` emits fewer. The
other 4,608 emits are identical in order and payload.
- **Other side effects:** all 7,142 lines (serial, DCS, timers, files,
logs, replies) are identical.
- **Batches:** 3,435 on both sides, with the same pushes in each. The 51
batches that carried `cal:data` and the 43 that carried
`nodes:telemetry` are one item shorter. No batch became empty: each
`nodes:telemetry` shared its turn with the `cockpit:snapshot` before it
and the `nodes:model` after it.
- **Batch timing:** `nodes:telemetry` moved no batch. Two batches
(dialog-close-mid-write and dialog-link-drop-mid-capture, one each) now
flush after the fake device's SESSION_OPEN ack instead of before it.
`cal:data` was the first push of that turn, so it queued the flush
microtask before the session-open write. The first push is now
`cal:workflow`, which comes after the write. The harness's fake device
answers in a microtask; a real port answers on an I/O event, so the
order can't change in the app.
- **Boot probe** (built bundles, dev userData and `SKYHAWK_USER_DATA`):
identical. The boot sequence has no device, so it never pushed
`cal:data` or `nodes:telemetry`.
- **Checks:**
  - typecheck (3 projects) clean;
  - lint 0/0;
  - lint:deps: no violations (214 modules);
  - vitest: 1,131 passed;
  - `npm run build` ok;
  - `npm run sync` gives the same generated values.
- **Other push channels with no renderer listener:** none. All 14
remaining channels in the contracts' `*PushChannels` are subscribed in
`ipc-adapter.ts`, and every store field they set has a production
reader.
Comment thread src/main/hid/calibration/workflow-host.ts Fixed
Comment thread src/main/hid/calibration/workflow-host.ts Fixed
Comment thread src/main/hid/calibration/workflow-host.ts Fixed
Comment thread src/main/hid/calibration/workflow-host.ts Fixed
…check on it (#130)

Addresses the four code-quality "missing await" notes on #129
(`workflow-host.ts` selectAxis / save / restore / deleteAxis).

They were false positives: `this.view?.workflow.x() ??
Promise.resolve()` applies `??` to the optional-chain result
(`undefined` when no view), not to the promise. This branches on the
view instead, so the scan reads it correctly. The returned promise is
the same object as before when a view exists, and a resolved one when it
doesn't, so there's no behaviour or timing change.

Checks: typecheck (3 projects), lint, and 1131/1131 tests pass locally.

Refs #100
@mottihoresh
mottihoresh merged commit 44b70ee into main Oct 17, 2026
4 checks passed
mottihoresh added a commit that referenced this pull request Oct 27, 2026
Bring #169's main side in line with ARCHITECTURE.md (the #129 rewrite):

- One owner per rule: the link-health model (reset reason, first fault,
  self-recovery) moves to main/gateway/gateway-diag.ts. hid/calibration keeps
  only the wire (decodeDiag, GET_DIAG) and hands each read to
  GatewayConnection, as NodeHub feeds the Gateway's health.
- Main pushes finished state: DeviceStatus gains linkHealth, carried on
  device:status (and so in the reload bundle) while the link is up, dropped
  when it goes.
- Intents act, pushes answer: device:diag (which returned the record) becomes
  device:diag-refresh (refreshGatewayDiag → void), registered in gateway/ipc.ts
  with the reader injected by app-services.
- A real seam test through makeServices() replaces the service-level one.

Refs #169
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant