Skip to content

Give the panel a persistent status footer - #215

Merged
Jason-Vaughan merged 6 commits into
mainfrom
feat/gui-status-footer
Sep 8, 2026
Merged

Give the panel a persistent status footer#215
Jason-Vaughan merged 6 commits into
mainfrom
feat/gui-status-footer

Conversation

@Jason-Vaughan

Copy link
Copy Markdown
Owner

Closes #188. Train 3, Chunk 02.

What

A persistent status footer beneath both panel views, carrying the two signals that qualify every figure the panel shows: the placeholder-pricing caveat and the measurement-health findings.

Both rendered inside the Settings stats card, which #166's Chat/Settings split put one navigation click from the view the panel opens on. Both say the numbers you are looking at may not mean what you think. Same wording, same refresh points (load, after a run, after a pricing save) — a move, not a copy: the footer is visible on Settings too, so duplicating would show every caveat twice to the reader most likely to be reading it.

Frontend only. No new endpoint, no new view function, no new control, no new dependency.

Why the failure states are the interesting part

The bar is hidden when the store answered and had nothing to report — so the strip only ever appears when something is wrong, rather than becoming chrome the reader learns to skip. That forced a third state, because an empty bar and an unreadable store looked identical.

Closing that properly meant fixing something older and wider than this chunk. getJSON never checked r.ok. This server answers a failed read view with a well-formed {"error": …} body at HTTP 500, and fetch does not reject on 500 — so an error response parsed and every consumer read it as data: the stats card as zeroes, the roster as an empty table, and the new footer as "nothing to report", hiding itself over a store it could not read.

Fixed at getJSON rather than in loadStats alone. The other three consumers land in catch branches they already had, so each gains an honest failure state instead of a silent empty one — and the 404 door needs no server bug at all, only a path regression, which matters because Chunk 03 reshapes this payload and Chunk 04 adds a route.

The server's error body is now read and logged rather than reduced to a bare status code: server.py overrides log_message to silence per-request stderr, so that body is the only rendering of the cause anywhere in the system.

docs/design/boundaries.md records that HTTP status is now part of the GUI endpoint contract — a read view that wants to report a problem in its payload must answer 200 and use a field, as health does.

Descoped, deliberately

#188 also proposes configurable field selection plus tasks-routed, spend-avoided and the billing-gate state. Not built here. They need a home for display preferences, which #188's own scope note flags as deserving thought rather than reflex. Recorded as descoped in the build plan, the CHANGELOG and the chunk entry — not dropped.

Test plan

  • make test805 passed, 17 skipped; ruff and mypy clean (the test target runs lint first).
  • Every new assertion was mutation-checked. Twenty mutations across four passes; all caught. Four assertions failed that check first and were rewritten: two assertIns matched their call commented out, and two compared positions across a two-branch function where there is always an earlier match to find. The ordering rule is now asserted as a statement sequence.
  • Wire verified end-to-end against a real server on a leased ad-hoc port: a panel launched with a deliberately damaged totals.json returned the health finding over /api/stats, and the served page carries the footer and all its call sites.
  • Two PAIRS rows added to tests/test_gui_contrast.py — the module cannot see a new pairing of two existing tokens, only a new token.

What the tests do not cover, stated because the alternative is a false sense of coverage: the pixels. The panel binds loopback only (tests/test_bind_address.py), and the build host is not the operator's, so showing it in a browser would have meant breaking a ratified security norm. VRF-008 carries six steps, including forcing the 500 door and a screen-reader check.

Review

  • Chunk review rev-20260908T074223Z-e8368b1f — 2 blocking, 8 warning, 12 note. Both blocking findings were real: the 500-renders-as-healthy case above, and ### Car 0N: headings that the record parser cannot see, which meant chunk 02's deliverable check silently never ran.
  • verify-resolutions rev-20260908T080034Z-ff175903 — 0/0, all ten confirmed resolved against the files rather than the diff.
  • Cumulative rev-20260908T080745Z-b89c2e9b — 0 blocking, 2 warning, 14 note. Both warnings actioned; the notes are dispositioned or recorded in the handoff.
  • Self-review before the first round caught two layout defects no test could see: box-sizing: border-box silently narrowing the content column by 40px, and the bar's text aligning with nothing on a wide window.

A claim I had to retract: I wrote into both CHANGELOG.md and ARCHITECTURE.md that an unreadable store could not render as a healthy one, while that was false for the 500 case. Guarded one door, then documented the invariant. Both are corrected, and learnings.md gained two new members of the mutation-survivor family this produced.

Note for the reviewer

prawduct-hook check-change-log-entry reports no-entry structurally on this repo — .prawduct/ is gitignored by owner ruling, so git cannot see .prawduct/change-log.md. Both entries were verified by hand: the private one, and the tracked CHANGELOG.md entry under [Unreleased] / ### Added (minor-tier — user-visible new chrome, not ### Internal). Tracked as #180.

The Design Overview Artifact was republished in the same turn per the parity rule: its degradation ladder claimed the panel "degrades to a finding rather than to silence", which was false for this exact case until this branch.

Splitting the panel into Chat and Settings (#166) moved two signals behind a
navigation click that should never need one: the placeholder-pricing caveat
and the measurement-health findings. Both say the numbers you are looking at
may not mean what you think, and both rendered only inside the Settings stats
card. They now sit in chrome the centre pane scrolls within, visible from
every view — same wording, same refresh points, a move rather than a copy.
The footer is visible on the Settings view too, so leaving the originals in
place would have shown every caveat twice to the reader most likely to be
reading them.

A failed /api/stats renders as "status unavailable", never as an empty bar.
The bar is hidden when the store answered and had nothing to report, so
silence would otherwise be indistinguishable from a clean bill of health —
the defect the migrated-log detector exists to avoid, one surface out. Grey
rather than amber: "could not ask" is a different claim from "asked, and it
is bad".

Sticky inside .main rather than fixed. Fixed would cover the sidebar and
force the body to reserve space for a bar that is usually absent; a new
scroll container would disturb the sidebar's height:100vh sticky, which the
stylesheet's own comment records as deliberate. `.statusbar[hidden]` is not
redundant: [hidden] is a UA rule and the author `display: flex` outranks it,
so without the override the bar cannot hide — the exact trap the .view[hidden]
comment predicted, arriving as predicted.

Seven tests pin the contract and all seven were mutation-checked. Two started
weak: an assertIn for the render call matched a commented-out one, so both
are now anchored to the start of a line. Two PAIRS rows are added because the
contrast module cannot see a new pairing of two existing tokens, only a new
token.

#188 also proposes configurable extra figures — tasks routed, spend avoided,
the billing gate. Those are not built here: they need a home for display
preferences, which #188's own scope note flags as deserving thought. The
descope is recorded in the plan rather than left silent.

Visual verification is queued as VRF-008. It could not be done from this
session: the panel binds loopback only by a tested security norm, and the
build host is not the operator's.
Two layout defects from the previous commit, both found by re-reading the
diff against the case next door rather than the case that motivated it.

The content column got 40px narrower. Moving the padding from `.main` to
`.wrap` looked like a pure relocation, but `box-sizing: border-box` is global,
so padding on `.wrap` comes out of its max-width instead of adding to it.
960 - 2x20 restores the 920 the column had. A footer must not narrow the page
behind it.

The bar's text did not line up with anything. `.statusbar` spanned the pane
and padded 20px, while `.wrap` is a centred 960 column — so on a wide window
the items sat at the pane's left edge and the cards they qualify sat hundreds
of pixels to the right. The strip now carries only the background, the rule
and the `hidden` state; an inner `.statusbar-inner` carries the same box as
`.wrap`. Two independent max-widths that only look right while they agree, so
a test asserts they agree, and another asserts the text is written into the
inner column — writing to the strip's innerHTML replaces the column element
and loses the alignment permanently, which the width check cannot see.

The `.statusbar[hidden]` override stays and its comment is corrected: the
layout rules moved to the inner column, so the strip declares no `display`
today. The guard is what keeps adding one safe, which is not the same claim
as the one the old comment made.
The media query re-insets `.wrap` to 16px and left the status bar's inner
column at 20px, so the alignment the previous commit established held on a
wide window and was 4px out on a phone. Only the horizontal value has to
agree; a strip is shorter than a page by design.
Critic rev-20260908T074223Z-e8368b1f: 2 blocking, 8 warning, 12 note. Every
blocking and warning finding is fixed here; the notes that wanted no action
are dispositioned in the evidence store.

R-1/R-16 — a 500 rendered as a healthy store, which is the exact case this
footer was built to prevent. `getJSON` never checked `r.ok`, and this server
answers a failed read view with a well-formed {"error": ...} body; `fetch`
does not reject on 500, so the body parsed and every consumer read it as data
— the stats card as zeroes, the roster as an empty table, the footer as
nothing to report, hiding itself. Fixed at `getJSON` rather than in
`loadStats` alone: the other three consumers land in catch branches they
already have, so they gain an honest failure state instead of a silent empty
one, and the 404 door needs no server bug at all. I had written the opposite
claim into both CHANGELOG.md and ARCHITECTURE.md; both are corrected.

R-17 — the live region was populated before being unhidden, so the one
transition `role="status"` exists for was the one that would not announce.
Both branches now unhide first. Asserted as a statement SEQUENCE, because
with two branches a positional check always finds an earlier unhide and
passes against a reversed branch — the first version of that test did.

R-7/R-19 — `renderStatusBar` took ready-to-insert HTML for two items that are
pure text, putting the escaping obligation on every future caller for no
capability. It takes plain text; `statusItem` escapes, once.

R-18 — the catch asserted a cause it cannot know and dropped the only witness
to the real one. It now says what the code knows and `console.error`s the
rest.

R-4 — nothing pinned the show/hide toggle at all: drop either line and the
caveats never appear on any view with the suite still green.

R-2/R-8 — the plan headed chunk 01 `### Chunk 01:` and the rest `### Car 0N:`,
which the record parser cannot see; chunk 02's deliverable check never ran and
chunk 01's section swallowed the other three to end-of-file. One term per
level now, with the Coordinator's larger unit qualified where meant.

R-5/R-20 — what chunk 02 was accepted against is recorded in Status rather
than backfilled into its section as criteria, which would grade the code
against itself; its decisions are recorded in the artifact the tooling reads.
Chunks 03 and 04 get a Done-when that gates them on having acceptance
criteria BEFORE code, rather than invented ones now.

R-9/R-10/R-11/R-12 — doc drift, three pieces of it mine: a test comment whose
premise this work falsified, two comments narrating the diff rather than the
code, a plan overview still promising a header the train stopped building, and
deliverable lines naming an `app.js` the no-build-step constraint forbids.

Suite 803 green, lint clean. Every new assertion was mutation-checked; four
failed that check first and were rewritten.
Closing observations from verify-resolutions rev-20260908T080034Z-ff175903
(0 blocking, 0 findings), taken here because this commit was being made
anyway rather than bought with another round.

`statusItem` escaped its text and interpolated its class attribute raw. No
data flow today — both call sites pass a literal or nothing — but the whole
point of moving the escape to the sink was that the safe form is the only
form the signature offers, and one of two interpolations does not achieve
that. Pinned, so it is not an untested rider.

The toggle-sequence helper sliced from `renderStatusBar` to a neighbour it
named, so inserting any function between the two would have widened the slice
and the sequence it asserts. It now ends at whatever top-level function comes
next. Verified by inserting a decoy that sets `bar.hidden` between them: the
slice holds.

Not taken: pinning the narrow-viewport inset. It is correct, VRF-008 step 3
covers it by eye, and the axis that drifts under maintenance is max-width,
which is already pinned — parsing a media-query block to assert 4px is
disproportionate to a value nothing computes from.

Suite 803 green, lint clean. Chunk 02 is ticked in the build plan.
Cumulative review rev-20260908T080745Z-b89c2e9b: 0 blocking, 2 warning,
14 note — nothing gated. These are the ones worth acting on; the rest are
recorded in the handoff.

R-10 — a 500's cause was discarded at the only place it is rendered. The
server composes {"error": str(exc)} and overrides log_message to silence its
own per-request stderr, so that body is the whole system's only account of
why a read failed, and `getJSON` threw on the status alone. VRF-008's
corrupt-pricing step walked straight into it: the operator would have read a
bare 500 while the parse error sat unread in the response. It is read and
logged now, and the two catches this branch newly routes 500s into log their
error instead of binding and dropping it.

R-9/R-11 — the same invariant's other remaining door. A 200 whose body is not
a stats payload left every key undefined, so the findings list was empty and
the bar hid: "asked, and all clear" over a response the panel could not read.
It now takes the could-not-read path. Chunk 03 reshapes that payload, which is
what makes it a guard rather than a comment.

R-4 — the content column's geometry was declared twice and pinned once, so
changing `.wrap`'s inset misaligned the footer with the suite green. Hoisted
to `--content-max` / `--content-inset`; both columns read them, and the test
now forbids a literal in either rather than policing two copies for equality.
`load_palette` skips non-colour tokens, so the contrast guards are unaffected.

R-14 — `test_the_failure_path_leaves_a_trace` used a bare assertIn and passed
against the line commented out, one line from assertions this same class
anchors. That is the shape this session added to learnings.md two hours ago.

R-13 — `docs/design/boundaries.md` and its private mirror stated the GUI
contract as "JSON shapes" and said nothing about status, which this branch
made load-bearing: a read view that wants to report a problem IN its payload
must answer 200 and use a field, as `health` does. Non-2xx now means "this
response is not data". The Design Overview Artifact is republished in the same
turn, per the parity rule — its degradation ladder claimed the panel "degrades
to a finding rather than to silence", which was false for this exact case
until now.

R-7/R-8 — README never mentioned the footer; the plan stated chunk 02's state
twice and the two disagreed.

Not taken: R-3/R-6, whether a screen reader announces a region unhidden and
filled in one synchronous task. Empirically reader-dependent, already VRF-008
step 6, and the fix if it reports silence is structural rather than a tweak.

Suite 805 green, lint clean. Six mutations run against the new guards, all
caught.
@Jason-Vaughan
Jason-Vaughan merged commit 498c8b4 into main Sep 8, 2026
5 checks passed
@Jason-Vaughan
Jason-Vaughan deleted the feat/gui-status-footer branch September 8, 2026 18:06
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.

[feature] Persistent status footer the centre pane scrolls within

1 participant