# Screens round 4 — lane state file

> Branch `work/screens-round4`, worktree `channels-manager-screens`. Every line
> here is true as of **2026-08-05**. Rewrite in place, never append.
> Mockups serve from `design/` (NOT `design/tiles/`): see the serve line below.

## Git position, exactly

**Everything this lane built or drew is merged into `main`** — `origin/main..HEAD`
is empty (checked 2026-08-05). Prod runs `4638156`, a LATER lane's deploy, and
this lane's work was re-verified alive in that rebuilt bundle rather than assumed
to have survived it: `cm-navpanel`, `cm-rail__avatar`, `cm-account-menu`,
`cm-btn__label`, `cm-dash__health` and the health foot line's own sentence all
present; the retired header's classes 0 hits.

⚠ **`main` moves several times a day.** Two pushes this session were refused
because it had advanced underneath — which is the mechanism working. Merge it in
and re-run the gates; never force.

⚠ **MERGE `origin/main` in, never rebase.** This branch carries published merge
commits, so a rebase replays ~70 commits and conflicts through every one. It was
tried at the start of this session and aborted on the first conflict. Worktrees
also share one `.git`, and force-push is ruled out entirely (operator,
2026-08-03). To publish a desynchronised tip: `git merge -s ours
origin/work/screens-round4`, then push.

⚠ **`index.lock` kept appearing as a 0-byte crash artifact** in this worktree's
own git dir, four times this session, with zero `git` processes alive each time.
Deleting it is correct in that case and was reported each time
(`PARALLEL-SESSIONS.md` § 4). Never delete it while a git process is running.

## What shipped this session — all deployed and verified

Deploying is **fast-forward `work/screens-round4` → `main` by push**, no
checkout in the hub, then pull + build + `scripts/publish-web.sh` on the VPS.
Everything below was **web-only**; `channels-api` was NOT restarted, on purpose
— the merge also carried another lane's api work and restarting it would have
shipped their backend change without their verification.

1. **The two-level nav** (`4eb6a47` + `caa87d0`), built last session, is
   **LIVE**. Served-bundle proof: `cm-navpanel`, `cm-rail__avatar` and
   `cm-account-menu` present; `cm-header__crumb`, `cm-header__right`,
   `cm-navgroup__label`, `cm-drawer__logout`, `cm-drawer__who` all **0 hits**.
2. **The account popover was looked at**, closing the one thing nobody had seen.
   A real browser CAN drive React — the old harness could not only because of
   `--virtual-time-budget`. Use the chrome-devtools MCP, not the screenshot
   recipe, when a click has to reach a handler.
3. **Two keyboard defects the review gate found, fixed** (`03c7942`): Escape
   from inside the account menu dropped focus on `<body>`; Tab could walk out
   of the open menu into the page behind it. **Both reproduced live before
   being touched.** The drawer now says `aria-modal` and the content behind it
   is `inert` — the platform's own answer, not a hand-rolled trap.
4. **The two Inbox audit findings** (`2126665`). Measured at 768: the contact's
   name box was **0px wide** and the name was simply not on the screen; it is
   now 154px and the header's buttons drop their words below `--cm-bp-rail`,
   each leaving an `aria-label` behind.
5. **The Dashboard health foot line** (`a6915d7`) — the last thing
   `dashboard.html` drew that the build never had.

## The Inbox audit finding that DIED, and why it matters

**Audit finding 4 (the panes do not fit their ruled breakpoints) is void.** Its
arithmetic assumed the OLD 216px sidebar. `AREAS` gives Inbox no `screens`, so
it draws **no sub-nav panel** and the shell there is the 56px rail alone:
56 + 324 + 340 + 320 = **1040**, inside `--cm-bp-rail` 1180. Measured per
element at 1179, 900 and 768: **zero overflowing elements at every width.**

The lesson is the general one: **an audit's arithmetic expires when the chrome
moves.** Re-measure before fixing anything the round-4 audit lists.

## The health foot line, as built

Reads **`GET /health` directly** — one component, one fetch, no API change.
Three things about it a rebuild must not undo:

- **The path is `/health`, NOT `/api/health`.** The controller is mounted
  outside the `/api` prefix. Verified against prod: `/health` → 200 with
  `{"status":"ok","db":true,"redis":true,"worker":true}`, `/api/health` → 404.
  It is `@Public()`, so it is the one read on the screen that needs no token.
- **`worker` counts as broken here, though `/health`'s own `status` excludes
  it.** A dead worker is not an api-process failure, but it IS a thing behind
  the scenes that stopped, which is the only claim the line makes.
- **No line renders until the first answer lands**, so the route pins — which
  hold every fetch pending — never see a claim about prod on an unread screen.

It was built on the operator's instruction to execute the almost-ready items,
and **that instruction is now ledgered as the ruling on round-3 fork 2**
(`docs/DECISIONS.md` § Design & UI; the superseded 2026-08-04 wording is in
`docs/archive/DECISIONS-history.md`). The reversal is scoped: the round-6b lock
no longer holds against a foot **line**, and still holds against a health
**tile**.

## The five round-4 sheets, authored this session

`design/tiles/sweep-phase1-round-4/`: **`agent-overview.html`**,
**`contacts.html`**, **`settings.html`**, **`knowledge-doc.html`**, plus
**`analytics-definition.html`**. One hand,
never fanned out, each drawn on the shell that shipped today, each opening with
**the direction it is drawn against** so the direction can be rejected before
the pixels are argued about. Every one was rendered and looked at before it was
committed, and two had real defects that only looking could catch.

**Serve them:** `python -m http.server 8140 --bind 127.0.0.1 --directory design`,
then `http://127.0.0.1:8140/tiles/sweep-phase1-round-4/<name>.html`. Serve
`design/`, not `design/tiles/` — every sheet loads the logo from `../../assets/`.

**`activity` was NOT drawn, and there is nothing owed.** Checked rather than
assumed: the activity-posts lane's `activity.html` already wires "the filter
menu and every facet, chips, sorting" AND the detail panel, measured at seven
widths with zero em dashes and a clean console (their `ANNOTATIONS.md` § Demo).
That supersedes **both** held items on this lane's list —
`activity-filter-bar` and `activity-detail-panel`. Drawing a rival sheet is the
parallel-authoring incoherence the 2026-07-30 ruling bans.

**`analytics` was drawn as the DEFINITION ROUND, not as a proposed screen.**
The exclusion is that the operator owns "what to show and how", so asserting an
answer would repeat the round-3 mistake. `analytics-definition.html` instead
inventories the six questions the payload can already answer, offers ONE
arrangement explicitly labelled as something to argue with, and ends in four
specific questions. The inventory survives even if the drawn screen is struck.

## Forks the sheets raise — each needs one word

| # | sheet | fork |
|---|---|---|
| 1 | agent-overview | **The channel gate cards are gone from it**, because the Dashboard already owns that concept. Keep them here and thin the Dashboard instead? |
| 2 | contacts | **Segments stop being a tab and become saved views.** Struck if a segment will ever carry more than a filter. |
| 3 | contacts | Unrecorded consent: **"Not asked" or "Unknown"**. |
| 4 | settings | Does a connection row say **"Working"** (a claim needing a real check) or "Active"? |
| 5 | settings | **Only the n8n row carries a what-breaks sentence.** Every row or none? |
| 6 | knowledge-doc | Chunk boundaries **read-only**, or hand-editable? |
| 7 | knowledge-doc | The **KB rewrite-from-zero is still open**; if it changes what a document IS, redraw this sheet after it. |
| 8 | analytics | **Which of the six questions stay?** Drawn: 1, 2, 3, 5. Delivery left to Home, campaigns cut. |
| 9 | analytics | **Is cost per lead the headline?** The only figure joining money to an outcome, and the most fragile — it divides by `leadsCaptured`. |
| 10 | analytics | **Does Analytics own "per situation"?** `bySituation` is in the payload, undrawn, and arguably Behaviour's. |
| 11 | analytics | **Is the time range control furniture?** 30 / 90 / this year drawn; cut it if nobody looks past 30 days. |

## Open — each names who unblocks it

| # | item | who unblocks |
|---|---|---|
| 1 | ~~The health foot line~~ **BUILT, LIVE and LEDGERED.** Fork 2 is struck in, scoped to a foot line rather than a tile. Nothing owed. | closed |
| 2 | **Two copy changes made without being asked**, both to avoid a lie: the add-card's "Channels are set up in Settings.", and the breakage row omitting the provider's raw `429`. Revert either on one word. | **operator** |
| 3 | **A stuck campaign renders in two places, and that may now be correct.** The split moved the copies into surfaces with different lifecycles, which is what the 2026-07-31 ruling asked for. Whether the bell should suppress an alert already on the canvas is a fresh question. | **operator** |
| 4 | **The Dashboard still cannot show a dead database.** `red.health` and `red.moderation.failsafe` fire as alerts with no computed row, so they hide behind the bell. Needs a `BreakageRow` kind and a producer. Unchanged. | this lane or backend |
| 5 | ~~Which Inbox pane gives way~~ **VOID** — see above. The panes fit. | closed |
| 6 | **Uppercase, RECOUNTED today** (the old figure was stale): **52 occurrences across 34 files**, not 35, plus 3 CSS rules. `Settings.tsx` 3, `ApiDocs.tsx` 3, `CampaignDetail.tsx` 4, `TemplateStatsPanel.tsx` 4. The shell's panel groups are already sentence case. Whether the rest earns it is judgment `craft.md` leaves open. | **operator** |
| 7 | **Facebook Messenger** — ingestion, adapter, sender. Not a gate flip. Until then the card says "Not built", never "Off". | **operator** to schedule |
| 8 | **The canary state has no home on the channel card.** WhatsApp's Messages row reads its gate state and renders "1 test number", which probably covers it. | **operator** to confirm |
| 9 | **Two naming changes made unasked**: "Comments moderations" → singular, and the social Messages row → "Messages reply". Both ship today. | **operator** |
| 10 | **IG username and FB Page name are stored nowhere**, so `handle` is null on both social cards. Proposal: persist them when the connection is tested. Boarded in `scope-board.md` § Intake. | **operator** to approve |

## Still not seen against the real API

The Dashboard has now been rendered against **prod's real gates and event
counts**, read from the live DB: `web:chat` live with **zero messages ever**, so
the website card reads "Nothing has ever arrived" with an empty sparkline — the
honest state, not a crash. What is still nobody's: **an authenticated load on
prod**, which needs the operator's own login. The five headline tiles are the
only part never seen with real arithmetic behind them.

## Next, in build order

1–4. ~~Dashboard, delete `apps/api/src/home/`, the website channel, the nav~~
**all DONE and LIVE.**
5. **The four sheets above are drawn and waiting on a reaction.** Nothing is
   built from a sheet until the operator rules its direction and its forks.
   Build order after that: agent-overview → contacts → settings →
   knowledge-doc. `activity` belongs to the activity-posts lane; `analytics`
   waits on the definition round.

## How this lane works, learned the hard way

- **A green suite says nothing about how a screen looks, and neither does a
  document.** Every sheet this session had a defect that only rendering caught:
  a clipped frame, and a `<span>` that put a name and a phone number on one
  line. That is the same species as the audit's findings 1 and 2 — **a span
  ignores layout until it is told to be a block.**
- **An audit's arithmetic expires when the chrome moves.** Finding 4 died the
  day the sidebar became a rail; re-measure before fixing.
- **A wrong fixture and a real defect look identical.** Three times this session
  a screen "crashed" on a fixture whose shape was wrong. Read the producer.
- **A scoped `vitest run src/<dir>` is NOT green.** Run the whole package.
- **The full suite has one load-sensitive flake**: `CsvBroadcast.test.tsx`'s
  variable-mapping test passes alone at **4343ms against a 5000ms budget** and
  timed out at 9442ms under full-suite load. It passed on the clean re-run.
  Flagged, not fixed — one slow machine away from red.
- **`pm2 restart` must run as `sopserver1`, never root** (`CLAUDE.md`, corrected
  today by another lane): root's `pm2 list` is empty, so a root restart exits 0
  and restarts nothing. **Health is not a deploy oracle either** — it answers
  `ok` from the old code. The oracle is a grep of the SERVED bundle.
- **Never `git stash` on what looks like a clean tree.** It restores a
  stranger's stash from another branch.
- **`pnpm` is not on PATH in this worktree; `corepack pnpm` is.**
- **`packages/shared` moved three times under this lane in one session.** After
  every merge: rebuild `@channels/shared` BEFORE typecheck or tests, or the
  failures are phantom.
- **A real browser is needed for a click that must reach a handler.** The
  headless `--virtual-time-budget` harness renders faithfully but cannot drive
  React, which is why the account popover was called unlookable for a day. Use
  the chrome-devtools MCP; details and its two traps are in the screenshot-recipe
  memory.
