phase: 31_gateway_route_readability

**Phase 31 verification complete — all 5 tasks already in `complete/`; this was the final verification pass.**

Verified / changed
- Re-ran every phase-31 artifact live and green: `verify-31-page-only.py` 6 PASS/0 FAIL; browser pass `31-route-readability.cjs` **64 PASS / 0 FAIL** (exit 0); `27-task02-mobile-audit.cjs` **371 PASS / 0 FAIL / 0 FINDING** (exit 0); rebuilt `data/logs/31-route-readability.out` as the 4-part transcript.
- Added the 4 degradation-arm screenshots the criterion asked for but the pass lacked (`31-gateway-{unconfigured,unreachable,no-nginx,sockets-quiet}.png`) — 12 frames now, one per arm.
- Grep criteria: no gateway connector rule uses `border-strong`; no verdict token in the gateway CSS section or `page-gateway.js` beyond the `alerts.py`-owned `.warn`; exactly 4 `addEventListener` calls (none for the switch); `routeInConfig()` returns `true`; **no `.py` under `src/dashboards/` changed**.

Tests / lint (all 100% green)
- `uv run pytest` → **1796 passed, 16 skipped**; `--cov` → **98.94%** (floor 94); `ruff check src tests agent` → All checks passed; `pyright` → 0 errors; `node --check` clean; `bash .agents/validate.sh` → **exit 0**.
- `uv run pytest -m live --live` → **12 passed / 0 failed**; `./scripts/dev-server.sh status` → all **10 sources ok**.

Completion criteria: colour (33/33 rows every segment `--gate-on`, dark `rgb(63,185,80)` + light `rgb(26,127,55)`, zero grey dashes) ✔ · inbound-first rows + filter (33/33 line 1 = `out.selectors`, line 2 = `→ host:port · kind`; `stackexpected` → **6** rows, not the overview's "five" — the capture declares 6 such routes; tallies byte-identical `10/33/16/0`, zero requests) ✔ · trunk motion truth table + `null` arm prints `n/a` never `0` + reduced-motion stills 43 paths and every knob ✔ · screenshots/transcripts ✔ · payload page-only ✔ (literal byte-diff of a live socket table can never pass; answered by the no-`.py`-diff + shape-equality proof, as recorded in the transcript) · no behaviour change elsewhere ✔.

Docs: fixed 6 inconsistencies — two identifiers split across line breaks (`test_the_pipe_animation_stills_in_both_states_under_reduced_motion`, `32_gateway_resolved_target_liveness`), stale "sixteen routes" count, stale "four machines/hosts" in README **and** AGENTS (fleet is three), "three fields"→"three field types", gap-14 headline wording; updated the pass's screenshot list.

Deviations: `uv pip list --outdated` shows one advisory (`starlette 1.9.0 → 1.10.0`) — not upgraded, this phase moved no dependency (rule 8 / DoD apply "if deps moved"). `TODO.md` carries two new operator disk requests, left untouched.

Next pending phase: `32_gateway_resolved_target_liveness`.
This commit is contained in:
2026-10-25 05:32:30 -04:00
parent e366f599f3
commit 25fd74b7c5
52 changed files with 3434 additions and 181 deletions
+216 -57
View File
@@ -29,7 +29,7 @@ file — plus an overview page that says which of the five you need:
| `/disks` | Are any of my physical disks failing, and which one — health, temp, hours, wear | the same agents, `smartctl -i -A -H` per whole drive (never a partition) |
| `/llm` | Tokens/sec, active sessions, queue depth, KV pressure, latency percentiles | gufo `/metrics` + `/slots`, vLLM `/metrics` |
| `/ups` | Battery health, runtime left, power events, battery-test buttons | NUT `upsd` (our own wire-protocol client; 3051 here, not 3493) |
| `/gateway` | Which domains come in on which port, and where each one goes — **two connection tables, one per proxy config file** (`nginx`, then `haproxy`), a trunk per bound port with its configured endpoints branching off it, each line carrying the config's own word for the route and its live socket count | the same agent (1.2.0) on the one host `GATEWAY_HOST` names: its `haproxy` + `nginx` config sections joined to its `/proc/net/tcp{,6}` sockets |
| `/gateway` | Which domains come in on which port, and where each one goes — **two connection tables, one per proxy config file** (`nginx`, then `haproxy`), a trunk per bound port with its configured routes branching off it, each line reading the config's own **inbound** word (`server_name`, `map` key, `backend` name) over the counterpart it routes to, plus its live socket count, with one filter box above the map that hides the routes whose own labels do not contain what was typed | the same agent (1.2.0) on the one host `GATEWAY_HOST` names: its `haproxy` + `nginx` config sections joined to its `/proc/net/tcp{,6}` sockets |
History is a first-class feature, not a sparkline afterthought: every number worth graphing
is stored, rolled up (raw → 1m → 1h), and served from `/api/*/history`.
@@ -373,8 +373,8 @@ Responsibilities, one line each:
30 s, `GATEWAY_TIMEOUT_SECONDS` 10 s, health and events under the dashboard name `gateway` — but
**one** job rather than one per host (the `nut` shape), dialling the `HOST_TARGETS` entry
`GATEWAY_HOST` names with its `label` set to that host's name, because the page is about the single
ingress and a record per machine would ask four hosts a question only one can answer. It exists
only when that name is a target: an unconfigured source has no task that could look red
ingress and a record per machine would ask every host a question only one of them can answer. It exists only
when that name is a target: an unconfigured source has no task that could look red
(invariant 3), and it is handed no `RateEstimator` because a count is not a rate (rule 2).
The job list is built once (`app.build_hub()`) because it is wired twice — production and
the injected-store test path — and two hand-written copies drifting is how a source ends up
@@ -1616,7 +1616,7 @@ uv run python scripts/dev-certs-export.py --url http://127.0.0.1:8090 \
# GET /api/certs/<name>/bundle and writes
# data/certs/<name>.bundle at 0600 — mints nothing
uv run pytest # hermetic: 1791 passed + 16 skipped (12 live,
uv run pytest # hermetic: 1796 passed + 16 skipped (12 live,
# 4 store_perf), no network
uv run pytest --cov # coverage gate (98.9%; floor 94%)
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
@@ -2158,7 +2158,8 @@ charge float jitters every `NUT_INTERVAL_SECONDS`, and rebuilding the `<svg>` on
an accessible name for a difference no eye can see.
**The `/gateway` page is the config's own tree, and a line's only claim is a count** (phase 29;
joined differently by phase 30 task 02; laid out as trunk→leaf panels by phase 30 task 04): **exactly
joined differently by phase 30 task 02; laid out as trunk→leaf panels by phase 30 task 04; made
readable — whole-row colour, inbound-first rows, a moving trunk, a filter — by phase 31): **exactly
two panels**, one per config file, in the order `nginx` then `haproxy` (`GATEWAY_CARD_KEYS`), each
shipping `hidden` and appearing on the poll that has rows for it — a gateway with no nginx in it
spends no panel saying so, and a section that did not answer at all is the page's one `why` line
@@ -2169,10 +2170,37 @@ the left-over of a rule the join stopped applying.
Inside a panel the rows are **groups** (`groupsFor()` → `renderGroups()` → `renderTrunk()` +
`renderLeaves()`): a *trunk* is one port that file binds (`:<port>`, its live count, up to
`MAX_CLIENTS` client addresses, and its `server_name` count with the whole list on `title`), and the
*leaves* hanging off it are the endpoints that port names, each with the config's own word for the
route above it (a haproxy `server` name, a vhost's `server_name`, a stream `map` key) and its own
`metric`. The page draws what the config declares and nothing else: the deleted `0`-padding
`MAX_CLIENTS` client addresses, its `server_name` count with the whole list on `title`, and — since
phase 31 task 03 — a **short `.pipe.pipe-trunk` of its own**, appended through the DOM as the trunk's
last child because an `<svg>` needs `createElementNS` and cannot come out of `groupElement()`'s static
`innerHTML` template), and the *leaves* hanging off it are the routes that port's config declares,
each with its own `metric`. That stub is the port's live count drawn as a line: `renderTrunk` puts
`live` on it when `listeners[].connections >= 1`, `still` when it is `0` **or** when it is `null` —
never `live` for an unknown, and never a `0` in `.pipe-count` for one either — and `on` always, because
`listeners[]` contains only ports a config names, so the row existing *is* the existence fact. Its
claim is **the port and never a leaf**: `trunkAria()` writes
`:4443 — 9 connections are open on this port right now` / `… no connections are open …` /
`… connections on this port are unknown: the socket table did not answer` into both `aria-label` and
`title` — the latter with `setAttribute`, because `title` is a reflected IDL attribute of
`HTMLElement` and **not** of `SVGElement`, so `svg.title = …` is a JavaScript property no tooltip ever
reads (task 05's browser pass found the served code doing exactly that) — and it indexes nothing but
`listeners[].connections`: no `endpointText`, no `out.*`, no client address, because attributing one
port's sockets to one of the routes hanging under it is a claim `sources/gateway.py` does not make.
`margin-inline-start: auto` on `.pipe-trunk` is measured, not polish: the trunk wraps, and
`.pipe-count`'s own auto margin flushes only the item that *starts* a
line, so without it a two-line trunk (`:80` on the live ingress) dropped the dash alone at the far
left — ink stranded on the opposite side of the trunk from the spine.
**A leaf reads inbound first** (phase 31 task 02, correcting phase 30's rank): line 1 is
`.pipe-name` and carries the config's word for **who reaches** the endpoint — a vhost's
`server_name`, a stream `map` key (the literal `default` verbatim), or a haproxy `backend` name —
from `inboundText()`, which joins **every** `out.selectors` entry, falls back to `out.kind`, and
never to `out.host`; line 2 is `.pipe-end` and carries `counterpartText()`'s
`→ host:port · kind`, the counterpart it flows to. Both lines are mono so a fan-out of eighteen rows reads
down its columns, `.pipe-name` stays the primary line of its row for the trunk too (a trunk prints
its port there), and neither helper cuts a string: CSS clips, and `leafTitle()` / `leafAria()` carry
**both ends whole** (gap 13). Phase 30 had the two reversed, which buried the domain the operator
types into a browser under the address they never type. The page draws what the config declares and
nothing else: the deleted `0`-padding
(listeners with no connection, endpoints no line reaches) is gone because a group *is* a listener and
an unrendered endpoint has no row to sit in. **A trunk whose config names no target is a fact and not
a gap in the drawing** — this fleet's nginx `:80` is a `return 301`, so its leaf list renders the
@@ -2181,7 +2209,8 @@ for it; `sources/gateway.py` refuses to warn about that shape for the same reaso
on the port, and rows on the connection's own `id`, in per-panel `Map`s (`state.groups`,
`state.rows`):
built once, patched afterwards, reordered with `appendChild` of the existing node and pruned once per
panel by id, because rebuilding a 17-row fan-out every 30 s re-announces all of it to assistive tech
panel by id, because rebuilding a fan-out the size of this ingress's `:443` every 30 s re-announces
all of it to assistive tech
for a difference of one connection (the `/llm` slot-grid rule). `pruneCards()` wiping a panel's whole
map is the only bulk clear, for the case where the family itself left the payload.
@@ -2191,20 +2220,44 @@ quiet prints `n/a` (`setTxt`'s own word), while a **leaf** whose endpoint the co
**name** prints an em dash (`NO_COUNT`) whose `title` says `connections not attributed — the config
writes a name and nothing here resolves names`. That is the normal look of 31 of this ingress's 33
lines, and a column of `n/a` would be twelve identical words competing with the page's one footnote.
The footer's second paragraph is the same sentence in the operator's terms rather than the join's —
*a dash means the endpoint is written in the config as a name; the page matches sockets by address and
resolves nothing, so this route is drawn but not counted* — because a column of dashes needs one
explanation on the page and not thirty-two on the rows, and because it is a **name** that is the reason
and not a fault. `32_gateway_resolved_target_liveness` is what removes the reason; until then the dash
stays unexplained-looking by design rather than borrowing the trunk's `n/a` word.
The line itself is hand-rolled SVG (`role="img"` + an `aria-label` naming the inbound port, the
endpoint, the selector and the count) and it carries **two independent class pairs** (phase 30 task
05), because the operator's green and the socket count are two different facts:
endpoint, the selector and the count) and the row carries **two independent class pairs** (phase 30
task 05, moved onto the `<li>` by phase 31 task 01), because the operator's green and the socket count
are two different facts:
* `live` / `still` — "≥ 1 ESTABLISHED socket on this pipe right now" — decides **motion only**. One
`pipe-flow` keyframe whose sole property is `stroke-dashoffset`; the live rule declares no `stroke`,
so a busy line and a resting line are the same colour and motion can never become a verdict.
* `on` / `off` — "a proxy config declares this route" — decides **stroke only**, through the sheet's
own existence pair `--gate-on` / `--gate-off` (declared once in `:root` as a substitution of the
theme colours, so both themes follow and no gateway rule names `--ok`). `routeInConfig()` answers
`true` unconditionally, which is honest rather than a stub: `connections[]` **is** the config's
declaration list, so `off` is the styled seam the write phase will fill and never a second state in
use. The base `.pipe-path` declares no `stroke` at all, which is what makes the state class the only
way a colour can arrive.
* `live` / `still` — "≥ 1 ESTABLISHED socket on this pipe right now" — decides **motion only**, and it
stays on the `<svg>` alone. One `pipe-flow` keyframe whose sole property is `stroke-dashoffset`; the
live rule declares no `stroke`, so a busy line and a resting line are the same colour and motion can
never become a verdict. Motion may not spread: the spine segment between two rows is **shared** by
both leaves, so a dash travelling down it could not be attributed to one route, and animating it
would put a per-route claim on a per-list structure. There are **two** moving pipes and they read two
different payload paths (phase 31 task 03): a leaf's run from `connections[].connections` and a
trunk's stub from `listeners[].connections`. One `.pipe` component serves both — `buildPipe(trunk)`
adds `pipe-trunk` and nothing else — which is also why the file's single
`@media (prefers-reduced-motion: reduce)` block stills the stub without naming it: the stilling rules
are descendant selectors (`.pipe.live .pipe-path`), and
`test_the_pipe_animation_stills_in_both_states_under_reduced_motion` pins that no `.pipe-trunk` rule
may style `animation`/`transition` itself and quietly step outside that block.
* `on` / `off` — "a proxy config declares this route" — decides **colour for the whole row**, through
the sheet's own existence pair `--gate-on` / `--gate-off` (declared once in `:root` as a substitution
of the theme colours, so both themes follow and no gateway rule names `--ok`). `renderLeaf` toggles
it on the `<li>` **and** the `<svg>` off the same value in the same pass, and four rules carry it to
the two spine halves and the tick, so a route is green from its trunk to its endpoint — phase 30
coloured `.pipe-path` only and the operator read that as two thirds of every route being inactive
(`test_the_whole_route_takes_one_existence_class` pins the row class, the four connector rules and
that no gateway rule names the structure token any more). `routeInConfig()` answers `true`
unconditionally, which is honest rather than a stub: `connections[]` **is** the config's declaration
list, so `off` is the styled seam the write phase will fill and never a second state in use. Colour
still only ever *arrives* from a state class: the base `.pipe-path` declares no `stroke` and the base
`.gate-link::before` / `::after` / `.gate-elbow` rules declare their border `transparent` — an SVG's
default stroke is `none` for free, a border's default colour is `currentColor`, so "a row built and
never rendered draws nothing" has to be written down for a border.
So the page is **green because the route exists**, never because a number is good: `severity.py` has
no `gateway.*` row, the payload carries no `severity` at any depth, and
@@ -2217,18 +2270,23 @@ one of them off) and coloured from the same existence pair, carrying a full acce
(`endpoint, reached by <selector>, in the nginx config, on`) because a colour cannot say that to a
screen reader. It is `aria-disabled="true"` — **never** the `disabled` attribute, which would take the
reading out of the a11y tree — with a `title` naming the missing write path, no listener (the file's
only `addEventListener` calls are the charts `toggle` and `DOMContentLoaded`) and no `:hover` or
`cursor: pointer` anywhere in the map, because invariant 1 means no browser can disable a route:
`addEventListener` calls are exactly four — the charts' `toggle`, `DOMContentLoaded`, and the filter's
`input`/`keydown` — and `test_the_switch_is_built_and_explicitly_unwired` pins that list as an
allow-list whose missing member is the point: **no `click` is bound anywhere on this page**) and no
`:hover` or `cursor: pointer` anywhere in the map, because invariant 1 means no browser can disable a
route:
`haproxy.cfg` and the nginx `server` block are the only switches on this fleet. The one `transition`
is the knob's, and the reduced-motion block names it beside both pipe states.
The branch is four CSS rules and no JavaScript: each `.gate-link` carries its own upper and lower half
of the spine at the gutter's left edge (`::before` / `::after`, each reaching half a leaf gap past its
row so consecutive halves meet), the first and last rows drop theirs so the spine starts at the first
branch and stops at the last with no mask, no measured height and no background-coloured eraser —
which is also why it stays right on the poll that adds a leaf — and `.gate-group.solo`, set by the
script from the group's own leaf count, takes the vertical away so a one-route port runs **straight
across** (every haproxy frontend here). `.gate-group { column-gap: 0 }` is what makes the trunk's
The branch is four CSS rules of geometry plus four of colour, and no JavaScript: each `.gate-link`
carries its own upper and lower half of the spine at the gutter's left edge (`::before` / `::after`,
each reaching half a leaf gap past its row so consecutive halves meet), the first and last rows drop
theirs so the spine starts at the first branch and stops at the last with no mask, no measured height
and no background-coloured eraser — which is also why it stays right on the poll that adds a leaf — and
`.gate-group.solo`, set by the script from the group's own leaf count, takes the vertical away so a
one-route port runs **straight across** (every haproxy frontend here). The ≤640px block restates the
reaching half's geometry and **no colour**, because a per-width copy of the existence pair is how a
pair drifts from its theme. `.gate-group { column-gap: 0 }` is what makes the trunk's
right edge and the spine the same x, so each tick starts *at* its trunk, and at ≤640px the stacked
trunk is reconnected by bringing the first row's upper half back at `top: calc(var(--gate-row-gap) * -1)`.
@@ -2250,9 +2308,80 @@ and the legend names pipes because the panel heading already says which config f
degradation arm uses the payload's own words — the `why` sentence for a pre-1.2.0/`--no-proxy` agent or
a silent section, the `unreachable` dim for a host that has never answered, and `#gate-empty` (naming
`GATEWAY_HOST` and agent **1.2.0**) for `404 {configured: false}` — and a failed poll re-draws nothing,
so the last topology stays on screen. The footer is **three** paragraphs and no more: why a line is
drawn and what its motion means; why a **name** endpoint carries no number; and that the switch is not
wired. The layout is three load-bearing declarations (`minmax(0, 9.5rem) minmax(0, 1fr)` per group so
so the last topology stays on screen. The footer is **four** paragraphs and no more: why a line is
drawn and what its motion means; why a **name** endpoint carries no number; that the switch is not
wired; and what the filter box does to the map and what it leaves alone — the sentence is there because
the field is the one control that could be read as a query, and it is not one.
**`#gate-filter` is an eye, not a query** (phase 31 task 04). 33 routes in config order is not a
lookup, and the operator's question is always about one domain, so one field above the panels hides the
rows whose **own payload text** does not contain what was typed: `in.port`, `out.host`, `out.name`,
`out.kind` and every `out.selectors` entry for a route (`haystack()`), `:${port}` plus
`listeners[].domains` and `.clients` for a trunk (`trunkHaystack()`). Nothing else is readable and
nothing else is matched — no DNS, no re-ranking, no rule about `.reeselink.com`, and no page-level
split by domain suffix, because "`reeseapps.com` vs `stackexpected.com`" is a fact neither config file
states and grouping by it would be inventing a taxonomy the ingress never declared (rule 2); the
operator types the suffix and the page hides the rest. A **trunk** that matches keeps its whole
fan-out: typing `4443` asks for the port, and its routes are the answer. Visibility is applied inside
the patch pass (`renderLeaves` sets `li.hidden` by index into `group.connections`, `renderGroups` sets
`el.hidden` and the panel's `[data-k="no-match"]`), so a poll cannot undo the filter, a hidden row
keeps its identity and its `<svg>`, and only the *config* removing a route ever deletes a node. The
repaint is `renderPanels(state.snapshot, state.row)` — **no** `fetchJSON` and no `refresh()` on the
filter path, which is what the type of the feature is (`refresh()` would add a history pair per
keystroke and change nothing), `Escape` clears the field because a filter you cannot leave with the
keyboard is a trap on a page whose other shortcuts are all keys, `#gate-filter-count` reads
`N of M routes` (leaf routes, both panels, and hidden while the field is empty), and
`renderHeader()`/`renderWarnings()` hold no reference to `state.filter` at all: `ports`,
`connections`, `live` and `warnings` describe the ingress, not the operator's current view of it, and
the warnings panel is a different claim rather than a shorter version of this one. The field is the
page's own markup wearing the sheet's shared field skin (`input[type="search"]` joined the
`input[type="text"], input[type="password"]` rule — the only three field types the app has), it is
`autocomplete="off" spellcheck="false"` with an `aria-label`, `.gate-filter` caps it at `18rem` on a
desktop and goes `width: 100%` at ≤640px, and it declares no `:hover`, no `cursor` and no
`transition` because the connection map owns one interactive-looking thing (the unwired knob) and this
is not it. `data/logs/31-task04-filter.cjs` measured it on the live ingress (transcript
`31-task04-filter.out`, screenshots `31-task04-filter-{stackexpected,no-match,cleared,360}.png`):
`stackexpected` → 6 of 33 rows with `nginx:443` and `haproxy:2000` the only trunks standing, a client
address → that port's whole fan-out, `no-such-domain-anywhere` → every group hidden with both panels
saying `no route matches "…"` and neither the tallies (`10 / 33 / 12 / 0`, byte-identical either side
of every keystroke) nor the warnings panel moving, **zero** requests across five keystroke states, the
same filtered view after a real 30 s poll as before it, and 0 px of sideways document scroll at 360 px
where the field is 320 px wide. `34 PASS / 0 FAIL`.
Phase 31's own pass measured the whole map rather than its parts, and is the pin that the three
readability changes landed together: `data/logs/31-route-readability.cjs` (transcript
`31-route-readability.out`, which opens with `data/logs/verify-31-page-only.py`'s payload proof,
screenshots `31-gateway-*.png`, twelve frames — 1440, 360, light, filtered, no-match, trunk-live,
phone, and one per degradation arm: predates, unconfigured, unreachable, no-nginx, sockets-quiet) —
signs in and reads **computed** colour, not classes — every segment of all 33 rows (both spine
halves, the tick, the run, the knob) computes to `--gate-on`, `rgb(63, 185, 80)` in dark and `rgb(26, 127, 55)` after the real
theme toggle, and **no dashed border anywhere inside `.gate-map` computes `--border-strong`**
(`rgb(51, 59, 77)` dark / `rgb(195, 202, 214)` light — the grey plumbing the operator read as
inactivity), with **0** boxes in the map carrying a verdict class in either theme. The motion truth
table comes out of the DOM against the payload's own numbers: every trunk the kernel reported ≥ 1 for
animated, every one it reported 0 for drew a motionless line, and each `0 live` string corresponded to
a port the payload carried 0 for — a reading of traffic, never a stored fact. A second, deterministic
arm settles the direction of the claim: with `:443` handed 7 sockets and **every** leaf's count set to
`null`, the trunk animated and 0 of its 18 leaves did, so a port's number provably does not leak into
its fan-out. The arm where the socket table
answers nothing draws **ten motionless `n/a` stubs** reading `connections on this port are unknown: the
socket table did not answer`, with **no** `0 live` ink anywhere, every leaf keeping its `—`, the header's
`live` tally `n/a` — and no panel taking the shared `stale` class, because the configs *did* answer. It
then holds that the **leaf** keeps its own claim: with a trunk live and the leaf under it at `—`, the
trunk animates and that leaf does not, because a port's sockets are not one route's. The filter pass is
zero requests across five keystroke states with the four tallies byte-identical either side of every
keystroke and `Escape` clearing the same way; `prefers-reduced-motion: reduce` stills all 43 paths (10 of
them trunk stubs, through the block's descendant selectors) and every knob; the degradation arms each
show their own sentence (`404 {configured: false}`, a `supported: false` agent, a silent socket section,
a never-answered host); and the document scrolls **0 px** sideways at 1440 px and at 360 px with both
charts open and the widest fan-out on screen. It also found, and fixed, the one defect of the phase that no class-based check could see:
`svg.title = aria` assigned a JavaScript property and produced **no tooltip**, because `title` is a
reflected IDL attribute of `HTMLElement` and not of `SVGElement` — the two map tooltips are
`setAttribute('title', …)` calls now, which is what the browser can show and what
`tests/test_api.py` demands. Its mobile-audit sibling is `31-task05-mobile-audit.out` (the
`27-task02-mobile-audit.cjs` sweep, 371 checks, 0 failures, 0 findings, exit 0).
The layout is three load-bearing declarations (`minmax(0, 9.5rem) minmax(0, 1fr)` per group so
a fan-out lines its leaves up instead of re-flowing them to the widest target, `minmax(0, …)` on every
track so a 40-character hostname ellipsises instead of growing the card — phase 27's rule — and one
column with the trunk above its leaves at ≤640 px). Task 04 measured that layout with
@@ -3145,9 +3274,10 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
ordinary rotation hygiene, not a missing step.
13. **`/gateway`'s long endpoint labels are ellipsised on a phone — a column-width question, not a
lost fact** (found by phase 29 task 06's live pass, **closed at the desktop width by phase 30
task 04's trunk→leaf row and re-measured on 2026-10-23 by task 06's pass**;
`data/logs/30-connection-map.out` is the current measurement, `data/logs/30-task04-panels.out`,
lost fact, and the filter is what a clipped label used to block** (found by phase 29 task 06's
live pass, **closed at the desktop width by phase 30 task 04's trunk→leaf row and re-measured
after phase 31 re-ranked the row on 2026-10-26**; `data/logs/31-route-readability.out` is the
current measurement, `data/logs/30-connection-map.out`, `data/logs/30-task04-panels.out`,
`data/logs/29-task06-live.out` and `data/logs/29-task06-card-haproxy.png` the history).
**Phase 30 task 04 closed the desktop half by changing the row, not the font.** The two
@@ -3160,26 +3290,35 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
and not a bug: the longest target here is 42 characters
(`reeseapps-brain-of-code.reeselink.com:443`) and it fits.
The arm that remains is the **phone**, and it got narrower before it got better: task 05's
**switch** moved into the leaf row and a group stacks to one column at 360 px, so the same labels
now clip at **31 of 33** targets and **22 of 33** selectors in a **139 px** column (this phase's
re-run; 27 / 13 in a 177 px column when task 04 measured it, the difference being the switch
pill's 34 px and the tick's 26 px taken off the label). Nothing is lost —
`renderLeaf()` puts the endpoint *and* the config's own selector on the row as `title` and
`leafAria()` carries the whole endpoint on the line, which is what
`tests/test_api.py::test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` pins, alongside
the declarations that decide *how much* it clips (`min-width: 0` on `.gate-target`,
`minmax(0, 1fr)` on the leaf track, `max-content` on `.pipe-name`, `flex-wrap` on the trunk) so a
future edit cannot squeeze a label back to the seven characters phase 29 measured. In order of cost:
(a) at ≤640 px let the leaf row put the count on the target's second line instead of its own
column, which buys roughly two more characters per label and is a stylesheet-only change;
(b) accept it — the fan-out is read as a set, the part that fits is the selector, and the number
the row is about is the count. Whoever picks this up re-runs
`data/logs/30-connection-map.cjs` (task 06's pass, which supersedes
`data/logs/30-task04-panels.cjs`): its two `FINDING` lines are the counts at both widths, and the
run **fails** if any line ever stops carrying its full text — 33 of 33 rows carried a `title`
naming their whole endpoint at 1440 px and at 360 px as this phase closed, which is the assertion
the gap is actually about.
**Phase 31 kept the desktop half closed and moved the phone's clipping to where it belongs.** Its
pass (`data/logs/31-route-readability.cjs`, both widths, both themes) reads **0 of 33** inbound
lines and **0 of 33** counterpart lines ellipsised at 1440 px in the same **361 px** leaf column,
with the longest inbound word 35 characters and the longest counterpart line 50 — the row got a
second line and a `→ … · kind` wrapper and still fits. At **360 px** a group stacks and the column
is **139 px**, so the two lines now clip at **19 of 33** inbound and **33 of 33** counterpart.
That is the intended direction of the trade: phase 31 task 02 made line 1 the config's own short
word for *who reaches* the endpoint (`server_name`, `map` key, `backend` name) and demoted the long
`host:port` to line 2, so what still fits is the part an operator reads and what clips is the part
they read once. Nothing is lost: `renderLeaf()` puts **both** ends whole on the row's `title` and
`leafAria()`/`leafTitle()` carry them on the line, which is what
`tests/test_api.py::test_a_clipped_pipe_label_keeps_its_full_text_on_the_element` pins, and the
browser pass measured 33 of 33 rows carrying their whole pair at both widths — as a **painted**
tooltip now, because that pass found `svg.title = aria` had been assigning a property
`SVGElement` does not reflect and showing nothing, and the fix (`setAttribute`) is what makes
"nothing is lost" true in a browser rather than only in the DOM. The declarations that decide *how
much* clips stay pinned (`min-width: 0` on `.gate-target`, `minmax(0, 1fr)` on the leaf track,
`max-content` on `.pipe-name`, `flex-wrap` on the trunk) so a future edit cannot squeeze a label
back to the seven characters phase 29 measured. What changed the gap's *cost* is task 04's filter:
the reason a clipped label mattered was that a long target could not be **found**, and typing
`brain` now hides 31 of 33 rows without one request, matching whole text CSS never showed. In order
of cost for what is left: (a) at ≤640 px let the leaf row put the count on the counterpart's second
line instead of its own column, which buys roughly two more characters per label and is a
stylesheet-only change; (b) accept it — the fan-out is read as a set, the part that fits is the
inbound word, and the number the row is about is the count. Whoever picks this up re-runs
`data/logs/31-route-readability.cjs` (which supersedes `data/logs/30-connection-map.cjs`): its two
`FINDING` lines are the counts at both widths, and the run **fails** if any row ever stops carrying
its full pair — 33 of 33 at 1440 px and at 360 px as this phase closed, which is the assertion the
gap is actually about.
How it got here, kept because the two rejected fixes are the reason the current shape looks the
way it does: at 1440 px phase 29's outbound name showed **seven characters** (`reeseapps-…`) for a
@@ -3191,6 +3330,26 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
declarations and removed the reason they were needed: with the trunk in its own track, the leaf's
name is the widest thing in a column that is now wide enough for it.
14. **A route whose endpoint the config writes as a *name* is drawn and never counted —
`32_gateway_resolved_target_liveness` is the phase that removes the reason** (recorded by phase
31 task 05, which wrote no design for it). Today 31 of this ingress's 33 routes end in a
`server_name`/`map` target and so carry an em dash where a live count would go, because
`sources/gateway.py` matches sockets by
address and resolves nothing (rule 2 by choice, and pinned as an absence). The follow-on phase is
the one that makes such a target answer with its liveness and its live connections anyway — the
agent reporting resolved targets on the box that owns the config, which is an `AGENT_VERSION` bump
to **1.3.0** and therefore an agent + source + pin change, not a page change. Its acceptance line
is that a dash disappears from `/gateway` for a reason and not by hiding the column.
15. **`/gateway`'s switches are knobs with no write behind them — `33_gateway_route_switching` is the
phase that decides whether they ever get one** (recorded by phase 31 task 05, no design written).
Enabling or disabling a route is a second gated upstream write path in front of the proxy configs,
which **amends invariant 1** (today `POST /api/ups/command` is the app's only upstream write, and
the route enumeration in `tests/test_api.py` is the pin that says so) and needs the operator's
sign-off in `.agents/PLAN.md` **before** it is written as a phase. Until that decision exists the
knobs stay `aria-disabled`, unbound, and explained in the footer; the `PATCH`-shaped endpoint, the
config writer and the rollback are that phase's work and none of it is here.
## Locked decisions (and why)
- **SQLite, not Prometheus/Grafana.** Handful of boxes, 1–10s resolution, one file the