Hedronite · Curriculum · UIUX · Frontend · Sat 2026-11-07

A status page that never turns green after gave_up A static export in Bun and TypeScript

Five contracts in, five pages out: only the clean success goes green; gave_up, drift_failed, recovering and a damaged log never do, and a forged contract stops the build.

Lesson Class: Curriculum · F-UIUX · rung A
Author: Evan Eugene Pincham
Tested with: Bun 1.3 (lab image oven/bun:1.3-debian, v1.3.14)
Lab: lab/ · sh check.sh (8 checks) · no network, no browser
Source: WCAG 2.2 SC 4.1.3 · WAI-ARIA APG Alert · MDN aria-live · Bun File I/O · Lustre element · DOP-C02 v1.6 · CKA v1.37
Copy, Never Recompute
The aria-live attribute on the banner is the contract's aria_live, word for word; the page does not pick its own urgency.
Green Is Stricter
Green needs healthy, can_act and zero unreadable lines; the page may withhold green from the contract, never add it.
Disagreement Stops the Build
A contract that says healthy after drift_failed, or counts more unreadable lines than it read, is a build error, not a page.
A status banner that can be painted green over a failure teaches its readers to stop reading it; the build refuses the paint.

Honesty line. The lab is a static-page generator over fixture files. It runs on CPU only. It opens no browser and runs no assistive technology, so no screen reader ever announces anything. What is real: the parser that refuses contradictory contracts, the rule that nothing is green after a terminal failure, the build-time disabled button, the aria-live attribute copied into the HTML, the unreadable-line counts, and the exit codes. What is simulated: the screen reader that would announce the banner, the live service behind the Promote form, and any cloud dashboard.

1. Concept

A service that heals itself restarts, retries, rolls back, or gives up. A person (or a screen reader on that person's behalf) learns which of those happened from a status surface. If that surface can show green when the service has given up, the loop has failed at its last step. The service did the right thing and reported it wrongly. The monitoring chapter of Site Reliability Engineering separates symptoms (what is broken, as the user sees it) from causes (why it broke). A status banner is a symptom surface: it is the one place a reader learns what is broken without reading the log, so it has to be the part of the system that is hardest to fool.

This lesson builds the last step: a status page generated from a small, strict contract and nothing else.

The contract

A command-line tool, uistatus derive, reads an operator log. The log is JSONL, one JSON object per line, each with at least an event key. For every physical line it either parses a readable event or counts the line as unreadable (blank, not JSON, truncated, wrong shape, or not UTF-8). It then maps the last readable event to this block:

state: healthy|recovering|degraded|gave_up
banner: ok|warn|error
headline: <one short sentence>
can_act: true|false
aria_live: off|polite|assertive
last_event: <event or none>
lines_read: N
lines_unreadable: M

The mapping is a fixed table:

last_eventstatebannercan_actaria_live
completed, promotedhealthyoktrueoff
restarting, resumed, backoff_retry, candidate_started, killed, checkpointed, job_started, artifact_writtenrecoveringwarnfalsepolite
rolled_backdegradederrorfalseassertive
gave_up, job_failed, drift_failedgave_uperrorfalseassertive
(no readable events)degradedwarnfalsepolite

lines_read counts every physical line, readable or not, so lines_read - lines_unreadable is the number of readable events.

The hard rule: **state is never healthy when the last readable event is a terminal failure** (gave_up, job_failed, drift_failed, rolled_back).

What the page is allowed to do

The page consumes the contract and may only be stricter than it. Four design decisions follow.

  1. Refuse a contradictory contract at build time. A contract is rejected, and the build fails, if a field is missing or duplicated, an enum value is unknown, a count is not a whole number, or state disagrees with what last_event demands. It is also rejected if banner, can_act or aria_live disagree with state, if lines_unreadable is greater than lines_read, or if a last event exists but zero lines were readable. A hand-edited file that says "healthy" over a drift_failed event never reaches a page.
  2. Green is stricter than healthy. The page is green only if the state is healthy, can_act is true, and lines_unreadable is 0. A contract can say "healthy" while four of seven lines were unreadable. The page then shows a warning, because a status derived from lines nobody could read is not a status you can trust.
  3. **Copy aria-live, don't recompute it.** The banner's aria-live value comes straight from the contract. The role is alert when it is assertive and status otherwise. The banner's class is never ok unless the page is green.
  4. Decide the Promote button at build time. It is a plain <button type="submit"> with the disabled attribute unless the page is green.

Why there is no backend and no client JavaScript

This is a content page. It reads contract files once, at build time, and writes HTML. A Bun static export fits: no server process, no runtime, no script shipped to the reader. The Promote control is a plain HTML form, and its enabled or disabled state is decided during the build, so no script has to flip it.

There is one limit. A static page is only as fresh as its last build. In a real deployment, something must rebuild it whenever the log changes. And a disabled attribute is a courtesy to the reader, not a security control. Whatever handles POST /promote must re-check the same gate (a command that exits 0 only if the contract says can_act is true) before it does anything. The lab does not include that handler.

Why accessibility is part of the contract

The W3C's guidance on status messages (WCAG 2.2, success criterion 4.1.3) defines a status message as a change in content that is not a change of context. It provides information on success or results, a waiting state, progress, or errors. Such messages must be programmatically determinable, so assistive technology can present them without taking focus. For warnings and errors, the guidance points to role=alert or live regions.

The ARIA Authoring Practices alert pattern describes an alert as a brief, important message that attracts the user's attention without interrupting their task. MDN says aria-live="polite" notifies without interrupting, and assertive presents immediately, possibly clearing the speech queue. It warns against assertive "unless the interruption is imperative," and off means no announcement unless the element is focused. The contract's mapping follows that spectrum. Recovery is polite news. A give-up or a rollback is imperative. A healthy state is silent.

This also connects to the monitoring and log-analysis skills in task statement 4.2 of the AWS DevOps Engineer Professional exam guide, which lists building dashboards and visualizations over logs and metrics. The status contract is a local stand-in for one dashboard panel that must not greenwash a failed log. The Kubernetes administrator curriculum's Troubleshooting domain lists "manage and evaluate container output streams". The same habit applies here: derive state from the stream, and keep lines you could not read visible instead of silently dropping them.

2. Worked example

All of the output below comes from the lab's gate run, not from a hand-written approximation.

Step 1: the build

bun build.ts fixtures dist reads every *.out contract in a directory and writes one HTML page each. The reference build printed:

damaged.out -> dist/damaged.html state=healthy green=false aria-live=off promote=disabled lines=3+4
drift.out -> dist/drift.html state=gave_up green=false aria-live=assertive promote=disabled lines=3+0
gaveup.out -> dist/gaveup.html state=gave_up green=false aria-live=assertive promote=disabled lines=4+0
recovering.out -> dist/recovering.html state=recovering green=false aria-live=polite promote=disabled lines=3+0
success.out -> dist/success.html state=healthy green=true aria-live=off promote=enabled lines=2+0
built 5 pages

Only success is green. The lines=3+4 on the damaged line means 3 readable plus 4 unreadable lines.

Step 2: how the banner is built

The core of the page logic is one function:

function banner(c: Contract): string {
  const green = isGreen(c);
  const cls = green ? "ok" : c.banner === "ok" ? "warn" : c.banner; // never paint ok unless green
  const role = c.ariaLive === "assertive" ? "alert" : "status";
  const extra = !green && c.state === "healthy"
    ? ` Not green: ${c.linesUnreadable} of ${c.linesRead} log lines could not be read.`
    : "";
  return `<p class="banner ${cls}" role="${role}" aria-live="${c.ariaLive}" data-state="${c.state}">${esc(c.headline)}${extra}</p>`;
}

Read it as three small policies:

Here are three of the banners the reference build actually wrote: the clean success, the give-up, and the damaged log. They are copied from the exported pages, not retyped.

<p class="banner ok" role="status" aria-live="off" data-state="healthy">Service reports ready (last event: promoted).</p>
<p class="banner error" role="alert" aria-live="assertive" data-state="gave_up">Service gave up (last event: gave_up).</p>
<p class="banner warn" role="status" aria-live="off" data-state="healthy">Service reports ready (last event: completed). Not green: 4 of 7 log lines could not be read.</p>

Look at the third line closely. The contract for the damaged log said banner: ok, and the page refused: the class is warn and the banner says why. The data-state is still healthy, because the page reports what the contract said; it only withholds the colour. The gate confirms it: damaged page: banner=warn green=false.

Step 3: the button

The button is one line of the page template in build.ts. The disabled attribute is written or left out at build time:

<form method="post" action="/promote"><button type="submit"${act ? "" : " disabled"}>Promote</button></form>

act is isGreen(c). In the reference run success is promote=enabled, and every other page is promote=disabled; the give-up page carries <button type="submit" disabled>Promote</button>. Note that the damaged page is disabled even though its contract says can_act: true. That is decision 2 again: the page may take permission away, never hand it out.

Step 4: the forged contract

A sixth fixture, forged, was edited by hand. It says state: healthy, banner: ok, can_act: true and aria_live: off, but its last_event is drift_failed. It lives in a separate rejected/ directory, and building that directory must fail:

$ bun build.ts rejected dist-rejected
build error: forged.out: last_event drift_failed means state gave_up, contract says healthy
build failed: 1 contract(s) disagree with themselves

The build exits non-zero and writes no forged.html. The page generator re-derives what the state must be from last_event and compares. The contract is therefore checked on the consumer side as well as the producer side: a status file that was edited after it left the producer, or a producer with a bug, cannot paint the page green. Note what this does not catch. A forger who edits last_event to promoted as well produces a contract that agrees with itself, and the page will build it green. Catching that needs the log itself, or a sealed log, not a better page.

Step 5: the known gap

On the damaged page, the banner turns warn, but aria-live stays off, because the page copies the contract's value. The gate shows this: damaged: aria-live page=off contract=off. The contract's own aria_live: off is the producer's decision, and it is wrong for this case. A sighted reader sees an amber banner. A screen-reader user is not told.

The page could override the value, but that would defeat decision 3, where the page never invents urgency the contract did not state. The fix belongs upstream, in the contract's rules or in the policy that decides when unreadable lines make a status non-green. That is a stretch task below.

3. Lab

Run the lab with sh check.sh from a copy of reference/ (or your edited starter/) with check.sh beside it. It needs Bun and nothing else: no network, no browser.

The lab has a reference/ tree and a starter/ tree with the same files (status.ts, build.ts, status.css, package.json). In the starter, isGreen always returns true, the page hard-codes class="banner ok", aria-live="off", an enabled Promote button and no counts, and parseContract never throws. A script, check.sh, writes the fixtures and prints one RESULT line per check, eight in all. bun run build is shorthand for bun build.ts fixtures dist.

The fixtures are real uistatus derive output over operator logs:

The gate ran inside an oven/bun:1.3-debian container with --pull=never --network=none, so nothing needed the network. The reference tree passes and the starter fails:

RESULT static_build PASS
RESULT success_page_green PASS
RESULT never_green_after_failure PASS
RESULT aria_live_matches_contract PASS
RESULT promote_disabled_unless_green PASS
RESULT unreadable_lines_withhold_green PASS
RESULT forged_contract_fails_build PASS
RESULT disagreeing_numbers_fail_build PASS

That is 8 of 8 for the reference. The starter, run against the same checker, printed:

RESULT static_build PASS
RESULT success_page_green PASS
RESULT never_green_after_failure FAIL
RESULT aria_live_matches_contract FAIL
RESULT promote_disabled_unless_green FAIL
RESULT unreadable_lines_withhold_green FAIL
RESULT forged_contract_fails_build FAIL
RESULT disagreeing_numbers_fail_build FAIL

So the starter passes 2 and fails 6. The two passes are not evidence of correctness: a page that is always green is accidentally right for a success log. The failure lines show the symptoms:

gaveup page: state=gave_up green=true
drift page: state=gave_up green=true
...
gaveup: aria-live page=off contract=assertive
...
gaveup: promote not disabled
...
damaged page: banner=ok green=true
...
forged contract: build exited 0
...
gave_up with banner ok: did NOT throw

The gate's verdict line was VERDICT: GATE PASS: the reference passes and the starter fails, which is what a useful lab needs.

Your tasks

  1. **Fix isGreen.** Make it return true only when the state is healthy, can_act is true, and lines_unreadable is 0. Re-run the checker. never_green_after_failure and unreadable_lines_withhold_green need the page fixes too, so expect some checks to stay red.
  2. Make the page honest. Replace the hard-coded banner with the three-policy function from the worked example. Copy aria-live, derive role, and never write class ok unless green. Add the line counts to the page, with the readable, unreadable and total figures that the lines field shows. Make Promote conditional.
  3. Make the parser strict. Follow the TODO markers in the starter parseContract. Check each of the following, and watch disagreeing_numbers_fail_build and forged_contract_fails_build:

- exactly one line per field; - enum membership; - whole-number counts; - state against last_event; - banner, can_act and aria_live against state; - lines_unreadable not greater than lines_read; - a last event requires at least one readable line.

  1. Add one new refusal. Write a contract fixture of your own that the parser should reject. Examples: can_act: true on a recovering state, or last_event: none with state: healthy. Show the build error it produces.
  2. Stretch: close the known gap. On the damaged page, aria-live stays off while the banner is warn. Decide where the fix belongs. Options: the tool that derives the contract (make unreadable lines lower the state or raise the urgency), or an explicit policy value passed to the page. Write down why the page should not silently override a field it was handed. Then implement your choice and show the damaged page's new aria-live value in the gate output.

Check yourself

Question 1
The contract says state: healthy, can_act: true, lines_read: 7, lines_unreadable: 4. Is the page green, and is Promote enabled?
Answer neither. Green needs healthy, can_act true and zero unreadable lines. The banner is warn with "Not green: 4 of 7 log lines could not be read.", and Promote carries disabled.
Question 2
Why does the page copy aria-live from the contract instead of choosing its own value?
Answer so one place decides urgency. If the page recomputed it, the page and the contract could disagree, and the gate's aria_live_matches_contract check would have nothing to hold either side to.
Question 3
A contract says state: healthy and last_event: drift_failed. What happens, and why is that better than a red banner?
Answer parseContract throws and the build exits non-zero with no page for it. A red banner would still publish a file that contradicts itself; a build error makes someone look at the producer.
Question 4
The starter passes success_page_green. Why is that not evidence the starter works?
Answer the starter paints every page green, and the success page happens to be the one page that should be green. A check that an always-green page passes proves nothing on its own; the failure checks are what catch it.
Question 5
Promote is disabled in the HTML. Is that enough to stop a promotion while the service is recovering?
Answer no. disabled only changes what the page offers. Whatever handles the form post must run the same gate again before acting; this lab does not include that handler.

4. Other paths

Two other ways to look at the same page: the same banner logic in Gleam with Lustre, and the reasons this rung stays away from a server or client-side page.

Gleam with Lustre (hypothetical sketch, not run). The Lustre documentation for lustre/element says the library can be used as an HTML templating library without its runtime, by passing elements to functions such as to_string_tree or to_document_string. The documented text turns a Gleam string into a text node, and none renders nothing, which is useful for conditional rendering. That fits the same static-export idea: build elements, render to a string at build time, write the file. A sketch of the banner and button logic follows. It has not been compiled or run, and function names outside the cited passages (attribute.class, attribute.attribute, attribute.disabled, html.p and the rest) are from memory and need checking against the Lustre version you use.

import gleam/bool
import lustre/attribute
import lustre/element.{type Element}
import lustre/element/html

// Strings for brevity; real code would use custom types for state, banner and live.
pub type Contract {
  Contract(
    banner: String,
    headline: String,
    aria_live: String,
    green: Bool,
  )
}

pub fn banner(c: Contract) -> Element(msg) {
  let class = case c.green, c.banner {
    True, _ -> "ok"
    False, "ok" -> "warn"
    False, other -> other
  }
  let role = case c.aria_live {
    "assertive" -> "alert"
    _ -> "status"
  }
  html.p(
    [
      attribute.class("banner " <> class),
      attribute.attribute("role", role),
      attribute.attribute("aria-live", c.aria_live),
    ],
    [element.text(c.headline)],
  )
}

pub fn promote(green: Bool) -> Element(msg) {
  html.form([], [
    html.button(
      [attribute.type_("submit"), attribute.disabled(bool.negate(green))],
      [element.text("Promote")],
    ),
  ])
}

The shape matches the TypeScript: one function decides the class, a second derives the role, and a third decides the button state. Rendering would go through element.to_document_string. The parser (the part that refuses contradictory contracts) is the larger piece of work and is not sketched here.

Why not a server-rendered or client-side page? A server framework would add a process to keep healthy, which is circular for a page whose job is to report on health. Client JavaScript would add a place where the banner could be recomputed and drift from the contract. For this rung, build-time rendering keeps the number of moving parts, and therefore the number of places to lie, as low as possible.

5. Citations