The Panel
How the field terminal and the watch stop being documents and become an instrument.
This is normative for interface work. It does not add a milestone — it is how build items 2
and 3 in ../CLAUDE.md get done, which are the Field Terminal status screen
and the screens after it. Sequence and gates are at the bottom.
The diagnosis, measured
Taken in Chromium at 390×727 against the built artifact, not the source:
| Screen | Words | Of which prose | Longest paragraph | Screens tall | Controls |
|---|---|---|---|---|---|
| Watch | 302 | 291 · 96% | 57 words | 1.9 | 4 |
| Status | 150 | 121 · 81% | 39 words | 1.4 | 15 |
| Distress | 62 | 56 · 90% | 32 words | 1.1 | 3 |
Source is worse, because most of it is behind state: terminal/+page.svelte carries 1,003
words across 39 paragraphs and 15 sections, and watch/+page.svelte carries 968 across 35.
Distress is 90% prose. That is the screen a person opens when they are in trouble.
Why it happened, and why it is not a paint problem
terminal/screen.css is 66 lines and styles exactly this: header, h1, h2, section,
section p, form, label, select, textarea, .note, .cost, .error.
It is a document stylesheet. It offers paragraph furniture and nothing else — no slot, no
readout, no state chip, no panel. So every screen writes paragraphs, because paragraphs are
what the design system knows how to make. There are two shared components in the entire
application (FieldRow, RecordSummary) against 20 terminal routes and 435 lines of
per-page CSS.
The DOM is a document. That is why it reads as a website regardless of what colour it is painted, and it is why this cannot be fixed by theming.
The honesty argument, which is the real one
An unread caveat is not a caveat. A 21-word disclosure at 2am on the fiftieth night is skipped, so the prose that exists to keep this system honest is what prevents it from being honest. Discipline and honesty are the same fix here, not a trade against each other.
The register: bridge watch, not chain of command
research/lore.md refuses rank, clearance levels and command hierarchy
outright — watch is a post, not a rank. So the reference cannot be an army.
It is a ship's bridge, which positioning.md already says: "On a ship's
bridge, Navigation and Communications are separate stations. NavCom fuses them into one post."
Bridge watch discipline is terse, procedural, acknowledged, and requires no rank to stand
competently. That is the only register this project's ethics permit, and it happens to be
the one that produces the feel we want.
A bridge at night is quiet, dim and unremarkable, right up until it is not. Discipline is what makes the quiet legible.
Doctrine
Nine rules. Normative.
- Nomenclature, not narration. Every state gets a name, not a description. The panel says
DARK. It does not say "no watch is on station right now, and Distress will page nobody." - Five words for state. Any readout fits in five words or fewer in a fixed slot. If it will not fit, it is not a state — it is an explanation, and explanations live one layer down.
- Readout and reason. Two layers, permanently. The readout is terse and always visible;
the reason is the existing prose, kept word for word, one tap away, marked
WHY. Rule 3 is not optional. IfWHYis ever cut for space, the doctrine has become the thing it was built to prevent. - Fixed slots. The same fact in the same place on every screen, every time. Nothing
reflows, nothing reorders, nothing appears only sometimes — an absent value shows its slot
with
—in it. Spatial memory is the entire performance gain. - One lit action. Fifteen controls of equal weight is a menu. A panel has one action lit and the rest recessed to a rail.
- Silence is a readout. An empty board reads
NO CONTACT— a positive statement that nothing has been heard, which differs from nothing being wrong and differs again from the app being broken. - The alarm channel is sealed. One colour, one weight, one motion, reserved for
DISTRESSand for a watch state that is lying about itself. Overdue is amber and rises; it never alarms. - Data type, not prose type. Condensed for labels, mono with tabular numerals for
anything compared or counted down, a text face only inside
WHY. - Nothing decorative survives. If a rule, graduation, glow or movement does not encode a real quantity, it is deleted. This is the directory's own rule pointed at the interface.
The motion test
If the static version is equally true, the motion is decoration.
The clean case is a countdown: a window with forty seconds left is stale the instant it is painted, so the moving render is the honest one and the number is the lie. Everything that moves must name what the static version gets wrong.
The layer that does not exist
This is the whole engineering content of the redesign. Screens are not rewritten one by one — the missing component layer is built, and then screens are converted onto it.
Tokens (lib/terminal/tokens.css, extend)
Already good: --t-ground: #0B0E12, --t-raised, --t-sunk, and semantic state colours
--t-station, --t-oncall, --t-auto, --t-dark.
| Change | Why |
|---|---|
Retire --t-ink: #F2F5F8 as the default |
Near-white is what low signature exists to remove. Warm bone default, white only in document mode |
Add --t-alarm, reserved |
Rule 7 needs a colour nothing else may use. --t-dark currently doubles as a state colour and a warning |
Add --sig-* overrides |
The low-signature set, applied at the .terminal root |
Add --f-cond |
Condensed labels. See the typography decision below |
Components (lib/components/, new)
| Component | Does |
|---|---|
Panel.svelte |
The shell: header strip, slot region, one lit action. Rules 4 and 5 |
Slot.svelte |
One KEY / VALUE pair, fixed width, — when absent |
Readout.svelte |
The value: state name, tone, optional sub-line. Rules 1, 2, 8 |
Why.svelte |
The disclosure. Takes the existing prose as a slot and changes none of it |
Action.svelte |
The single lit control, plus hold variant for thresholds |
Window.svelte |
Depleting bar seeded from a real RESPONSE_WINDOW |
Elapsed.svelte |
Climbing bar with no end mark. Distress only |
Heartbeat.svelte |
The one pulse for unresolved and still retrying |
Board.svelte |
The floor: rows by time, transform-only reorder |
Stylesheet (lib/terminal/panel.css, new; screen.css shrinks)
screen.css keeps form controls and typography. Everything document-shaped
(section p, .cost, .note) is deprecated as new-screen vocabulary and removed per screen
as each is converted.
Typography decision — needs a human
Rule 8 wants a condensed face for labels and tabular numerals for data. The terminal currently ships no webfont at all (system stack), and the worst page is at 66% of the 220 KB script budget with a 260 KB page total.
Three options, and this is a decision rather than a recommendation:
- System stack only. Zero bytes.
font-variant-numeric: tabular-numsworks in system fonts; a true condensed does not. Labels get letter-spacing instead of condensation - One variable font, subset. ~18–25 KB woff2 for Latin, self-hosted. Buys real condensed labels and instrument character. It is a first webfont on a surface that has none
- Condensed only, subset to uppercase + digits. ~8–12 KB. Labels and readouts only; body
and
WHYstay system
Default if nobody decides: system stack, because the budget is a real target and the doctrine survives without a webfont.
The migration mechanism
Converting prose to readouts risks losing facts. The safety net already exists and it was
measured: 9 test files assert on prose the redesign rewrites — page nobody,
still works offline, ladder ends there, nobody is on call, call first,
listing below is unchanged, goes out under your callsign, and others.
So the migration procedure is:
- Convert a screen onto the components
- Run the suite. Every red test names a fact that must survive
- Make it green by putting that fact in the readout or in
WHY— never by editing the assertion to match the new copy - Only when a fact is genuinely obsolete does an assertion change, and that gets its own commit with the reason
A test edited to match new copy is the redesign losing information silently. That is the one failure mode this plan can produce that would be worse than shipping nothing, and rule 3 plus this procedure are what prevent it.
Phases and gates
Each phase is shippable. Nothing here is a big-bang rewrite, and at every point the terminal works.
P0 — The layer · gate for everything else
Tokens extended, panel.css written, the nine components built with their own tests.
Gate: Panel, Slot, Readout and Why exist and are used by at least one real screen.
A component nobody uses is not built — see verification.md.
The gate lapsed in P1, and was not noticed until P3. Converting the status screen, I hand-rolled
<section class="nc-panel">instead of usingPanel, because the screen neededdata-*markers the component did not forward.Panelthen sat in the tree, imported by nothing, while the build order recorded its gate as met.It is the exact failure this gate exists to catch, and passing a gate once does not keep it passed.
Panelnow spreads...restso a screen can mark it, and renders its label as an<h2>— a screen made entirely of panels with no headings is one a screen reader cannot navigate. Both status and watch use it.
P1 — The shell · STATION + NIGHTHAND
terminal/+layout.svelte gains the fixed panel header. /terminal/ becomes the three-post
panel: Alone, Out, On Watch — same geometry, same slot order, one lit action, values and
lit control changing per post.
The hard part is Alone: fully lit, fully capable, watch line as a fact rather than an error. The default is Alone and it is not a degraded state.
Gate: status renders in all three posts with no <p> outside a Why, and the 15 controls
are down to one lit plus a rail.
Done. Posts are No callsign · Ready · Out. Holding the watch is not a post here because this screen does not know about the watch key — it is the watch screen's state, and inventing it would be worse than naming what exists. Measured at 390×727: zero
<p>outside aWhyin every post, 21–33 words read without opening anything, and the per-page stylesheet down from 38 rules to 3.
Whygained anopenprop, and that is a correction to this doctrine rather than an exception to it. Rule 3 puts the reason one layer down; it does not say the reason may be hidden when the reason is the message. An operator with no watch must be told plainly that this is a normal way to work — the story test calls it "the sentence this project cares most about getting right for her" — and a tap is enough to lose it for exactly the person who needed it. The same holds for being shown Dark without being told why, a failure this project has already fixed once. Terse is the default; it is not the rule when brevity costs somebody the point.
P2 — WHY everywhere · withdrawn, and here is why
The plan was to move every prose block on every terminal screen inside a Why, verbatim, as a
purely mechanical phase. It was attempted, measured, and abandoned on evidence.
The transform ran across 18 screens and produced 96 disclosures, keyed on .cost and
.note — the classes that look like explanatory asides. Then the suite ran: 31 browser tests
went red, every one of them asserting that a now-hidden sentence must be visible.
The reason is in one line of screen.css:
.terminal .cost { color: var(--t-faint); font-size: .93rem; }
.cost is a typographic class, not a semantic one. It means muted, slightly smaller, and
this codebase uses it for genuine asides, for primary state (<p class="cost" data-never-backed-up> — "You have not made one on this phone"), for unsent-and-retrying errors,
and for instructions. Nothing distinguishes them in the markup, so nothing mechanical can
separate what may be hidden from what may not.
That is not a flaw in the screens. It follows from the house style this project is built on — the claim and its limit in one breath — which puts the load-bearing sentence and the aside in the same paragraph class on purpose.
A separate pass before the readouts also found six disclosures that had swallowed an instruction, including the Distress screen's "You have to press send — a web app cannot do that for you." Behind a tap, that one lets an operator believe help was summoned when it was not.
What replaces it
Prose relocation is not a phase. It is a per-screen judgement, made in P3 alongside the readouts, with three outcomes per sentence:
| The sentence is | Where it goes |
|---|---|
| State — what is true right now | A readout, in a slot |
| An instruction, or a thing that will not happen unless you act | Visible prose, unwrapped |
| A genuine aside — the reason behind a state | Why, closed |
And the rule the six near-misses produced, which is P1's lesson generalised:
Prose that tells the operator to do something, or that a thing they expect to happen will not happen, stays visible. Terse is the default. It is not the rule when brevity costs somebody the point.
The transform itself is kept — it is useful for finding candidates — but it proposes rather than decides.
P3 — The readouts
Convert status, watch, distress, sign-on, standing. The rewrites are drafted:
WATCH — DARK, DISTRESS — NO ADDRESSEE, BOARD — NO CONTACT,
STAND DOWN — NOT SENT / STILL ADVERTISED, GATE — NOT VOUCHED.
Gate: re-measure. Target is under 40 words and one screen tall for status and distress,
with every removed word present in a Why.
P4 — Motion that carries state
Window, Elapsed, Heartbeat, Board reorder.
Elapsed is the safety-critical one: distress: null in RESPONSE_WINDOW means Distress
has no window, so it must never get a depleting bar. A bar that empties implies the signal
resolves itself, and a Distress that appears to resolve itself is the silent failure
invariant 2 forbids. It climbs, with no end mark.
Gate: prefers-reduced-motion has a real static equivalent for every moving readout,
tested — not "animations off".
Done, and the gate earned itself. A window bar carries its quantity in movement; with animation disabled it is painted once and never advances, so on its own it would state the size of the window and nothing about how much is left. There is now a branch: the bar for everyone else, and the number —
90s left of 120s— exactly where the movement is missing.Heartbeatdifferentiates by shape rather than motion or colour, a hollow ring while it is trying and a filled dot when it is done, which also fixes it for anybody who cannot separate amber from green.Two things nearly shipped wrong.
animation: nc-drain linear forwardswith an unset duration resolves to0sand fills to its end state, so a bar that lost its duration rendered a response window as fully run out — a false claim in the direction that makes a watch believe somebody is late. It is gated ondata-runnow, and the painted fraction is the floor.And
test.use({ reducedMotion: 'reduce' })never reached the page —matchMedia('(prefers-reduced-motion: reduce)').matchesstayed false — so the whole file would have measured the ordinary branch while claiming to measure the other. It usesemulateMedia, and asserts the emulation is on before asserting anything else.
P5 — Low signature
No white anywhere, minimum viable luminance, amber-dominant, with document mode one tap away. Brightness is a tactical property: every operator using this is outdoors at night and none of them have a control for it.
Gate: contrast checked for low vision in both modes; document mode reachable from every screen; the choice persists in the accruing tier.
P6 — The moments
Handover (read-back arms the threshold), Inked (coming home and being counted, as a
logbook line sealed into the existing hash chain), Present (the credential screen laid out
for the other person to read at arm's length), Ladder (escalation walking its real
paging → contact → exhausted states, including failing out loud).
Gate: no ceremony outside taking the watch and standing down. A Distress control is never
gated behind a sequence, not for one second.
Done, and one concept was rejected while building it. The roster proposed the read-back arming the control after a staged reveal. That is ceremony with no mechanism under it — a delay whose only job is to make an act feel weighty — and gating on animation would have left the control dead under
prefers-reduced-motion. What shipped is a read-back that is simply there, above a hold you can abandon by letting go.
Inkedclaims less than the concept did. It was written as the night's line "sealed into the hash chain with an inclusion proof." Inclusion proofs have not shipped —log.tssays an operator reading the log is trusting the watch's account — so the close of the night shows how long, where, and "nobody was watching, and it still counts", and claims nothing about provability.And it found a real defect. All three hold controls — Distress, wipe, take the watch — completed from inside a
requestAnimationFrameloop. rAF is throttled hard, and paused outright, in a backgrounded or power-saving page, so a hold that needs frames to complete can fail on a phone in low power mode — the phone this is written for, and the control it would fail on first is the one somebody holds when they are in trouble. The fill is animation now; the firing is a timer that does not care whether anything was painted. Surfaced by a test that failed only under parallel load, which had every marker of a flake and was not one.
P7 — Haptic
Three patterns: sent, acknowledged, distress armed. There is currently not one vibrate call
in the codebase.
Gate: fires only in direct response to a tap the operator made. Never on arrival of anything, or it has become a notification.
Budgets, enforceable
| Rule | Enforcement |
|---|---|
transform and opacity only |
Lintable. Nothing animates width, top or height — this is what holds 60fps on the floor device |
| No JS timers for continuous motion | Countdowns and pulses are CSS keyframes seeded once from a real timestamp |
prefers-reduced-motion is a branch |
Every moving readout has a static equivalent carrying the same fact |
| Nothing animates on arrival | An operational tool opens into a situation. Motion always means something changed |
| Motion budget under 3 KB | All CSS. None of it spends from the 220 KB script budget |
| The alarm channel stays sealed | One colour, one motion, DISTRESS and a lying watch state only |
Refused
Everything already in ../CLAUDE.md's anti-patterns, plus, specific to this
work: boot sequences, radar sweeps, holographic chrome, rank and clearance, counts and totals,
notification badges, decorative telemetry, easing flourishes on navigation, skeleton shimmer,
and motion on directory values — a field that animates draws the eye to whichever record
changed last, when the eye should go to whichever record is freshest. That is decoration
working against the display rules.
What conversion actually turns out to be
Eight screens in, audited sentence by sentence, the ratio holds: roughly one paragraph in ten is a convertible aside. Everything else is load-bearing, and the screens that look worst by paragraph count need the least change.
| Screen | Prose | What converted |
|---|---|---|
| Distress | 90% | Nothing. Reclassified to P4/P6 — its prose is instruction |
| Watch | 96% | The 218-word preamble, the state, the empty board |
| Directory | 21 <p> |
Three: unsent corrections, snapshot age, empty state |
| Setup | 17 <p> |
Two confirmed states and one aside |
| Peers | 18 <p> |
Two state readouts. Two Why wrappers drafted and reverted |
The reverted pair on peers is the clearest case. "They are told you are doing it" is a
consent fact that decides whether somebody takes watching on, and "ignoring sends nothing" is
a thing that will not happen. Both looked like asides and neither is one.
So the value of converting a screen is almost never in moving text. It is in the state
readouts — CALL FIRST on a cache older than a week, NOTHING YET on an empty directory,
a Heartbeat on a half-finished pairing — and in what the panel makes glanceable. A screen
whose prose survives intact has still been converted correctly.
Risks
- Terse becomes curt-and-wrong.
CALL FIRSTwithout its reason is a shorter way to be unhelpful. Rule 3 is the mitigation and it is not negotiable - Jargon without a legend. A controlled vocabulary must be taught once, in one place an operator can read on a bus — not discovered through tooltips. A term nobody was taught is jargon, and jargon is authority without accountability
- Terseness eating provenance. "Wren, in person · 3 days ago" is not fluff to compress into a freshness dot. A recognised name is the thing a number can never be
- Friction creep. Ceremony belongs to two acts. Every additional one is a tax on somebody standing in the cold
P9, decided: when low signature is the default
Neither always on nor always off survives contact with who opens this, and the tempting escape is closed by the terminal's own commitments.
Not on a timer. tokens.css gives the terminal a single visual world "because a terminal
that changed appearance with the phone's theme would be a terminal you could not learn by
muscle memory." A screen that redecorates on a clock — one this app already has a documented
Dark reason for distrusting — is worse than either fixed default. Ambient sensing is not
reliably available, and inferring where an operator is standing is the guessing invariant 3
forbids.
The failure modes are asymmetric, and that sets the direction.
| Default | If it is wrong | Cost | Do they see it? |
|---|---|---|---|
| Off | A bright phone on a dark street | Night vision gone; brightest object for a hundred metres | No — and they may never find the control |
| On | A dim amber screen indoors | A moment of "is this broken" | Yes — one tap, and the control says Document |
This project picks the safe answer over the true one everywhere else. It should here.
But not from the first screen. A newcomer must never meet something that reads as degraded — the whole Alone position — so always-on fails a different test than always-off does.
So the default follows the one thing the operator has declared: a callsign. Somebody who only wants the directory never needs one, and the terminal's directory works without it. Choosing a callsign is a deliberate statement of I intend to work as an operator, and operators work outdoors at night. That is not a guess about the world; it is honouring something a person said.
Two refinements matter more than the default:
prefers-contrast: moreoutranks it. Somebody who asked their whole device for more contrast has already answered this, and dimming them would override a stated need with a default. It is the one signal here that is neither inference nor proxy- It is explained where it happens. The setup screen says it at the moment it changes. An operator who finds a dim screen and no explanation reasonably concludes the app is broken, which is the failure the Alone position exists to avoid
Decisions needed from a human
- Typography — system stack, one subset variable font, or condensed-only. Default is system stack
Low signature default— decided: on once there is a callsign, not before. See below- Legend placement — one screen that teaches the vocabulary, and where it lives without becoming onboarding, which is banned