# ADR: The glass look

Status: accepted, 2026-09-21. Asked for over one morning: keep the colour scheme but make it less
bland, and make the interface more modern, with a still from a film's heads-up display as a mood and
nothing more. The specification: slightly transparent surfaces over a soft watermark, with every image
and every line of text fully opaque. The site has to look good, because people arrive from LinkedIn
posts and are meant to stay and read.

## In plain words

This page sets the site's look: panels you can partly see through (glass) over one faint drawing behind the page (a watermark), while everything that is read, from words to photographs, stays fully solid. Text colours are measured against the darkest thing that can sit behind them, and tests hold both rules.

What that is worth: a developer gets a look built from shared style values (tokens) with tests that fail when a word is faded or too faint, and the organization's site looks finished to a visitor with ten seconds on a phone.

## Context

The reader this record is for is not an engineer at a desk. It is a reviewer who tapped a link in a post, most likely on a phone, inside an in-app browser, with about ten
seconds. That reader was landing on a white header and a store switch over a column of eight
filters. Nothing on the first screen said what the site is or who built it. Nor did it say where
the resume is, and not one vehicle was in it. The site was correct and it was plain, and to that
reader plain reads as unfinished.

The colours are their own decision and are in the palette's record (ADR: The palette, the addendum
on teal, dark green and gold). This record is the surfaces: what a panel is and what is behind it,
then what the first screen says.

## What was considered

Three ways of taking the heads-up idea were drawn as mock-ups on the site's own Admin tab before any
code was written, and Steve chose with all three in front of him.

| Option | What it is | Why it was or was not taken |
| --- | --- | --- |
| A light readout | White panels on the light grey ground, corner brackets on every panel, ring gauges, small capitals | The brackets are decoration on every panel of every page, and on a phone they are noise at the corners of a card 160 pixels wide |
| A dark deck | The tiles and the charts on dark glass panels, the rest of the page light | He had said of the mock-ups before it, "I loved the background of white and light grey." A dark band across a light page is two sites |
| **Glass, with a watermark** | Panels slightly see-through over one soft drawing fixed behind the page; every word, number and photograph fully solid | His words, nearly verbatim, and the only one of the three whose whole effect survives at 375 pixels |

Nothing was taken from the film still: no layout, no element, no name. What is kept from the genre
is what helps somebody with ten seconds, and each piece had to earn its place against that reader.

| Piece | Kept | Why |
| --- | --- | --- |
| A ring beside a number | On three tiles | A ring is a share of a known whole: checks passing, pages up, memory against its limit. It says "all of them" faster than "5 of 5" does |
| A ring on milliseconds or request units | No | There is no whole. A ring drawn for looks is a gauge that measures nothing |
| Small spaced capitals over a tile | Yes | The question over a tile is a label on an instrument, read after the number and not before it |
| A fine grid and a readout on a chart | Yes | A line with a number under the pointer is a chart somebody can read a value off |
| An index chip on a chart's title | Yes | Three charts on one card, and "the second one" is how people talk about them |
| A status line in the header | No | On a desk this site has no header: the rail carries the brand and the version, and the footer carries the commit. A third place for the same two facts is decoration |
| Animation of any kind | No | It costs frames on the phone this is for, and says nothing |

## Decision

**Panels are glass, the page has one soft watermark behind it, and everything that is read is
solid.**

**The watermark** is ONE inline SVG (`src/components/layout/Background/Watermark.tsx`), fixed behind
the page's content at a tenth of the strength of its inks: concentric rings in the teal, dotted rows
in the lighter teal, and the site's own lightning mark in gold. It is a drawing and not a picture,
so it costs no request. It does not move, so it costs no frame. It holds no words and no image,
which is why it may be faint. The side rail is solid and sits over it. A printed page gets no
watermark at all, and neither does a page in forced colours or one that asked for less transparency.

**A panel** is white at about two thirds. It has a hairline teal border and a soft teal shadow, and
its radius is ten pixels. The see-through is in the BACKGROUND COLOUR and never in an `opacity` on
the container, because an opacity fades everything inside it: the rule is that a panel's words,
numbers and photographs are always at full strength, and `tests/e2e/glass.spec.ts` holds it against
the rendered page by finding every element drawn at less than full strength and failing if one of
them holds a word or an image. Two disabled buttons had said "not yet" with an opacity of 0.6; they
say it with the colours of a thing that is not pressable now.

**The blur is spent where it is cheap.** A backdrop blur is paid for by the pixel on a phone. The
tiles, the small cards, the window buttons, the filter bar and the intro strip carry it. The Admin
tab's wide cards, which are the large ones, are see-through without it. The hundred vehicle cards on
the inventory are solid white and carry neither: a photograph sits in each and there are a hundred
of them. The full-height side rail is solid.

**Three fallbacks, in one place.** The panel's ground is one token, `--glass-bg`. Where
`backdrop-filter` is not supported, where the reader asked for reduced transparency, and in forced
colours, that token is solid. `src/styles/tokens.test.ts` holds that the three blocks are there.

**Words are measured against the worst thing behind them**, not against plain white. The darkest ink
in the watermark is the teal of its rings, and at a tenth of its strength on the page ground it is
`#d2d9da`. Seen through a panel that is `#f0f2f2`, and every text colour the site has clears AA on
it: the faint colour, which is the weakest, reads 5.2. On the BARE ground it is closer: the heading
reads 7.84, the body text 5.01, the accent 4.97, the status green 4.55 and the status red 4.52, all
over 4.5, and the muted and faint colours read 4.21 and 4.08, which is not. So the rule is that **the
two quiet colours are only ever used inside a panel**. The token test holds the arithmetic, and the
browser suite holds the rule against the rendered inventory, a vehicle's page and the Admin tab by
walking up from every quiet word to find the ground it sits on. The words that were on the bare
ground in a quiet colour, a page's blurb, the caption beside the window buttons, the footer and a
vehicle's subtitle among them, are in the body colour now. No text token's value changed.

The first drawing of the watermark had its dotted rows in the dark green, and the body text on the
bare ground read 4.81 against them with the status red at 4.34. The rows moved to the lighter teal
and the measurement is the reason.

## The first screen

Looked at before it was changed, at 375 pixels, on the live site: a header, a store switch with two
lines of explanation, the word "Inventory" and the first five of eight filters. So the inventory
now opens with **an intro strip**: one sentence, "Steven Stout's working demo: a used-vehicle
auction site on .NET and React, 100,000 vehicles, live on Azure, with its decisions written down",
and three ways on: the resume, how it is built, and the live Admin tab. It is a glass panel over the
inventory and never on top of it, every target in it is 44 pixels tall, it has a name a screen reader
can skip past, and once dismissed it stays dismissed in that browser. What it says is in
`src/lib/intro.ts` and is tested there. The resume is linked and never touched.

The phone suite holds the first screen: the brand, the sentence and the resume link are all inside
the first 812 pixels without a scroll, every target in the strip and the hamburger over it is at
least 44 pixels, the title of the inventory is under the strip and not behind it, and a dismissal
survives a reload. The store switch's two halves are 44 pixels tall on a phone as well.

**The link preview** is the first impression before the first screen: on a post, `public/og.png` is
seen before the site is. It is drawn by `docs/images/og.mjs` on the header's own gradient with the
gold mark and the name at a size that reads when the card is four hundred pixels wide, and one line
saying what this is. The version and the count of records on it are still read from the repository,
and the tests that hold them are unchanged.

## What it cost on the wire

From the build's own output before and after, the precheck's build of this version against
1.0.0.168's: the stylesheet went from 55.07 kB to 61.02 kB, 9.41 kB to 10.50 kB compressed, and the
script from 359.63 kB to 365.96 kB, 106.12 kB to 108.17 kB compressed. That is about three
kilobytes more on the wire for a first visit, and it is the watermark's drawing, the intro strip,
the rings and the readout. No dependency was added; no image is requested; no font was added; and
nothing on the page moves. The blur is the cost that does not show in a byte count, which is why it
is spent only on the small panels.

## What is not done, and is said here

**The writing on a chart is still small on a phone.** A chart is a 720 wide drawing, and in a 320
wide card its labels, its unit and its readout are drawn at under half size. The readout is a desk
feature in practice. This was already the next thing to fix on the Admin tab (ADR: The Admin tab, as
a product, the addendum on the look) and it still is.

**The drawings under `docs/images` keep the old palette for now**, and so do the two store colours,
for one reason: the drawings are where slate blue and brown mean "the relational store" and "the
document store", in five pictures and the records that quote them. Moving the store colours on the
page and not in the drawings would be the half move the palette's record rules out. They move
together, in their own ship.

## Addendum, 2026-09-21 (1.0.0.170): every surface, not only the Admin tab's

An hour after 1.0.0.169 went live, looking at the inventory, Steve asked for the inventory and the
tabs to be styled the same way as Admin, and noted the background and the transparency were
missing. He was right, and the cause was a decision in this record. To save a phone the cost of a
blur, 1.0.0.169 left the hundred vehicle cards solid white, and with them the panels of a vehicle's
page, the account tab's card and the store bar, which nobody had decided at all. A page of solid
white cards over a watermark is a page with no watermark: the drawing was only visible in the
gutters, and the inventory read as the old site with a new header.

The cost that mattered was the blur, not the see-through. So the see-through went everywhere and the
blur stayed where it is cheap:

- **A vehicle card is glass without the blur**, the way the Admin tab's wide cards are. Its ground
  is white at two thirds, its border the hairline teal, its photograph solid.
- **A vehicle's page, the bid panel, the account tab's card, the store bar** and the two notices an
  empty or failed inventory shows are glass with the blur: there are few of them on a page.
- **The Account tab's title takes the gold underline** and its Back button is glass, as the Admin
  tab's are, because two tabs that look different are two applications.
- The side rail stays solid, and the documents' dialog stays solid white: it is for reading.

The worst-case arithmetic does not change. A word inside a card without a blur has the same colour
behind it at worst as one inside a card with one, and `glass.spec.ts` now holds that a vehicle card's
ground is see-through and the card itself is at full strength.

## Addendum, 2026-09-22 (1.0.2.0): every document on the Author page's panels

On reading the library after the Author page was rebuilt, Steve pointed out that the documentation was not formatted like the Author section, with its background and formatting. He was right, and the difference was not a style anybody chose. The Author page had a layout of its own, `layoutAuthor()`, that put its words on glass panels over the ribbon ground; every other document, the decision records among them, was a single column of markdown on the dialog's white.

**Decision: every document takes the same panels, and the same ground.** `src/lib/docLayout.ts` is twenty lines: the title and whatever comes before the first second-level heading open the page, and each second-level heading after it starts a panel that runs to the next one, which is the shape every document in this repository already has. The dialog carries its own copy of the ribbons for every document and not only for the author's. The panels carry the words so nothing is ever read off the drawing. The panel rule itself is now one rule for both, so a change to the Author page's panels is a change to the library's.

What it does not do: it does not reflow a document or move a word, and it renames no heading. A document with no second-level heading is one panel, and a heading inside a code sample is escaped markup by the time this sees it, so it opens nothing.

**1.0.2.2, measured after it shipped:** a phone could still slide a document sideways, and the panels were not the cause. At 375 the README's dialog body was 375 wide and scrolled to 460, pushed by the long unbroken tokens inline code carries in prose and in lists, a file path 396 px wide in a 306 px column; code blocks were innocent, because a block keeps its sideways scroll inside its own box. A word that cannot fit now breaks (`overflow-wrap: anywhere`, which also shrinks the minimum a grid column measures), a table too wide for a phone scrolls inside itself, and `mobile.spec.ts` holds four documents to a dialog that never scrolls sideways.

## Addendum, 2026-09-23 (1.0.3.9): one consistent experience, from tokens

On 2026-09-23 Steve set polish, a consistent user experience, second only to performance. So every view was read at 390, 1280 and 1440 in Chrome and WebKit before anything changed, the computed facts and not the pictures: headings, panels, buttons and pills, the focus ring on the first Tab, icons, empty states, the wording of every action and the back path. The panels were already one rule (radius, border, shadow, glass). The rest was not: three focus rings (the site's accent ring, the browser's default on the skip link, and offsets from minus two to four pixels across thirty-three rules); button heights of 28, 30, 34, 37, 39, 41, 44 and 48 for the same kinds of control, the vehicle's back button a text link where Admin's and Account's are pills; inputs on two border colours and two radii; the vehicle's title the one page heading at semibold; small icons at 14 and 16 px with strokes of 1.6, 1.8 and 2 depending on the file; badges at medium beside status pills at semibold; and "Back to inventory" on Admin and Account when they were opened from the landing page, where Back goes home.

Each is a token in `tokens.css` now (the `controls` region): one focus ring with one offset and one inset for a control clipped by its box, and the gold ring where the ground is dark; a pill height, a control height and a large one, each the thumb's target on a phone; a pill weight, a control weight and a badge weight; an input border and radius; a title weight and line; two icon sizes and one stroke, which `src/lib/icons.ts` carries for the components that draw their own SVG. The back button's label is the entry's own record of where it came from. `StyleRulesTests` rule eight holds every sheet to the ring, the offset, the heights and the title weight, `tokens.test.ts` holds the values, `icons.test.ts` holds that no component writes an icon's number itself, and `landing.spec.ts` holds the back path.

## Addendum, 2026-09-24: the operator's look

On the drawings for the Admin tab, Steve asked for something more futuristic, in the manner of Iron Man's displays, and in the same breath for the colour scheme, the header and the page background to stay as they are. So the treatment lives on the panels, not the page. **A panel is glass with a top rule and two corner brackets**: the dark green rule on panels and cards and the gold one on stat tiles (the landing tiles' borders, which he named), the brackets in the rule's colour at the top left and bottom right, bent round the panel's radius (his words: "rounded with no square corners"), 2 px at 18 px on a card, 14 on a rail and 10 on a tile. **A reading is small spaced capitals with tabular figures**, a label and its value on one line with a hairline under it (`Readout`). **A number that is a share of a whole is a ring** (`Ring`): a track, the value's arc from twelve o'clock, a second series inside it in gold, the number always in words inside or beside it so the arc is never the only carrier, filled from nothing on its first paint over 400 ms and not at all under reduced motion. **Every control is the site's pill group** (the store toggle's shape, `.op-seg`): a rounded border round the segments, the chosen one filled with the accent. All of it is tokens (the operator-look region of the token sheet: the rule, the bracket sizes and stroke, the readout's size, tracking and weight, the ring's three colours and its fill time, every colour one the palette already had) and one shared sheet, `src/styles/operator.css`, applied by class name, so no component draws its own bracket or ring. `operator.test.ts` holds the sheet to the tokens and the tokens to the palette. The Admin tab takes it first, with the workbench (ADR: The Admin tab, as a product, the addendum on the workbench); the rest of the site follows, a view at a time.

## Addendum, 2026-09-25 (1.0.3.20): the frost, and the ribbons back in Chrome

Steve sent two pictures of frosted glass as the model for the transparency alone: panels you can see through, where what is behind reads as soft colour and light, never as shapes, with a bright rim on the edge. So the glass keeps its share of white (42 per cent, 50 on a phone; every contrast figure above stands on that share) and its blur goes from 7 px to 20 px with the saturation from 1.2 to 1.5, and the shadow gains a light inner rim. The blur is still spent only where it was: a vehicle card and the Admin tab's wide cards stay glass without it. `tokens.test.ts` holds the frost (a blur of 16 px or more, a saturation over one) beside the three fallbacks that turn a panel solid.

The same day he asked for the original background image back. The ribbons had been blank in Chrome on 1.0.3.17 and on 1.0.3.19 alike: the page's drawing and the document dialog's copy named their gradients and filters alike, a reference finds the first element with the name, and that one was inside the closed dialog, which Chrome does not paint from; WebKit drew them, which is why the pictures in WebKit showed ribbons and the ones in Chrome did not. Each copy names its own now, and `coverage.spec` holds every page the site lists to one use of an id and every reference inside a drawing to an element of its own drawing.

## Where it sits

This decision lives in the front end alone: the glass tokens in src/styles/effects.css, the Watermark component, and the Ring and Readout components in src/components/shared. Every panel and its fallbacks read one --glass-bg token, so the glass changes in one place. It cost backdrop blur paid by the pixel on phones, which is why the hundred vehicle cards and the wide Admin cards go without it, plus a browser test that hunts for any faded word. Measured frame drops on low-end phones, or readers asking for a dark theme, would reopen it.

## Files

- [`src/styles/effects.css`](https://github.com/SteveStout/TheYard/blob/main/src/styles/effects.css): the glass tokens and their three fallbacks, beside the palette.
- [`src/styles/colors.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/styles/colors.test.ts): every text colour against the watermark at its worst, bare and through a panel, and the fallbacks.
- [`src/components/layout/Background/Watermark.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/layout/Background/Watermark.tsx) and [`src/components/layout/Background/Watermark.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/components/layout/Background/Watermark.module.css): the one drawing behind the page.
- [`src/components/layout/IntroStrip/IntroStrip.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/layout/IntroStrip/IntroStrip.tsx) and [`src/lib/intro.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/intro.ts): the first screen's sentence and its three links.
- [`src/lib/statTiles.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/statTiles.ts): which tiles carry a ring, and the ring as a stroke.
- [`src/lib/machineChart.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/machineChart.ts): the readout's arithmetic and its words, and the fine grid.
- [`src/styles/panels.css`](https://github.com/SteveStout/TheYard/blob/main/src/styles/panels.css) and [`src/styles/panels.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/styles/panels.test.ts): the operator's look as one shared sheet, and what holds it to the tokens.
- [`src/components/shared/Ring/Ring.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/shared/Ring/Ring.tsx), [`src/components/shared/Readout/Readout.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/shared/Readout/Readout.tsx) and [`src/lib/ring.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/ring.ts): the ring and the readout, one component each.
- [`src/components/admin/AdminPanel/AdminPanel.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/AdminPanel/AdminPanel.tsx) and [`src/components/admin/AdminPanel/AdminPanel.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/components/admin/AdminPanel/AdminPanel.module.css): the tiles, the rings, the readout and the panels of the Admin tab.
- [`tests/e2e/glass.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/glass.spec.ts) and [`tests/e2e/glass.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/glass.ts): nothing that is read is faded, quiet words are never on the bare ground, and the watermark is one drawing with no request behind it.
- [`tests/e2e/mobile.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/mobile.spec.ts): the first screen on a phone.
- [`docs/images/og.mjs`](https://github.com/SteveStout/TheYard/blob/main/docs/images/og.mjs): the link preview, in the new look.

```css
/* Panels are frosted glass over the ribbons (ADR-081). The see-through part
   is the background colour only, never an opacity on the panel, so the
   words, numbers and photos inside are always fully solid. Where blur is
   not available or not wanted, the fallbacks below turn every panel solid. */
/* #region glass-design-tokens */
--glass-bg: rgba(255, 255, 255, 0.3);
/* A little more white behind a phone's smaller type. */
--glass-bg-phone: rgba(255, 255, 255, 0.38);
/* The frost: what is behind a panel reads as soft colour, never as shapes,
   and a light rim lifts the edge (ADR-083). */
--glass-filter: blur(28px) saturate(1.6);
--glass-border: color-mix(in srgb, var(--color-teal-deep) 18%, transparent);
/* #endregion glass-design-tokens */
--glass-shadow:
  0 10px 30px color-mix(in srgb, var(--color-teal-deep) 12%, transparent),
  inset 0 1px 0 rgba(255, 255, 255, 0.9), inset 0 0 0 1px rgba(255, 255, 255, 0.55),
  inset 0 0 40px rgba(255, 255, 255, 0.18);
/* A faint 24 px grid inside every glass panel, under its words. */
--glass-grid-line: color-mix(in srgb, var(--color-teal-deep) 3.5%, transparent);
--glass-grid-size: 24px;
/* A document's dialog: a nearly clear sheet, with the frost on the reading
   panel inside it. */
--dialog-sheet-bg: rgba(255, 255, 255, 0.1);
--dialog-sheet-filter: blur(6px);
--dialog-page-bg: rgba(255, 255, 255, 0.78);
--dialog-page-filter: blur(28px) saturate(1.5);
--watermark-opacity: 0.1;
--radius-glass: 10px;
/* The frame on every Author page photo: a deep teal border, a gold hairline
   outside it and a soft teal shadow. One design token, so every photo changes
   together (ADR-013). */
--frame-photo-border: 3px solid var(--color-teal-deep);
--frame-photo-shadow:
  0 0 0 1.5px var(--color-gold),
  0 8px 22px color-mix(in srgb, var(--color-teal-deep) 22%, transparent);
```
*Live from [`src/styles/effects.css`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/styles/effects.css#L23-L57), region `glass`, read from this build at 1d6b50b.*

```tsx
import styles from './Watermark.module.css';

/**
 * The soft watermark behind the page (ADR: The glass look): ONE inline SVG,
 * at the top of the page behind everything, about a tenth as strong as the ink it is drawn in:
 * dotted rows in the lighter teal and nothing else. No concentric rings and no
 * large lightning mark: over the ribbon ground and through the thinner glass, a
 * mark's straight edges and its notch read as a pale box behind every page.
 * It is a drawing and not a picture, so it costs no
 * request, and it does not move, so it costs no frame. It holds no words and
 * no image, which is why it may be faint: everything that is read is solid.
 *
 * The rows are made here and not typed out, so the drawing is
 * eleven lines of arithmetic rather than forty of coordinates.
 */
const ROWS = Array.from({ length: 20 }, (_, index) => 40 + index * 44);

export function Watermark() {
  return (
    <svg
      className={styles.watermark}
      viewBox="0 0 1040 900"
      preserveAspectRatio="xMidYMid slice"
      aria-hidden="true"
      focusable="false"
      data-testid="watermark"
    >
      <g className={styles.rows}>
        {ROWS.map((y) => (
          <path key={y} d={`M0 ${y}H1040`} strokeDasharray="1 11" />
        ))}
      </g>
    </svg>
  );
}
```
*Live from [`src/components/layout/Background/Watermark.tsx`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/components/layout/Background/Watermark.tsx#L1-L35), region `*`, read from this build at 1d6b50b.*

```ts
/**
 * The colour rules, all in one place. Amber means "worth a look". Red means
 * "someone should be looking". A failing check or a page that is down is red.
 * A slow 95th percentile (p95: 95 of every 100 requests were faster), memory
 * past 80% of its limit, or a refused request is amber. The thresholds are
 * this site's own, set from what it measures on a quiet day, and open to change;
 * the lines themselves live in src/lib/ranges.ts, one table for the tab.
 */
export const SLOW_P95_MS = RANGES.p95.amber;
/** A 95th percentile at or above this many milliseconds turns the speed tile red. */
export const VERY_SLOW_P95_MS = RANGES.p95.red;
/**
 * The speed tile only takes a colour when the hour has at least this many warm
 * requests. With fewer, the 95th percentile is really just the one slowest
 * request, so the tile stays plain and shows the request count instead.
 */
export const QUIET_BELOW_REQUESTS = 20;

/**
 * The tiles whose number is a share of a known whole. Only these reserve room
 * for a ring, so the other tiles keep their full width for text on a phone.
 */
const RINGED = new Set(['health', 'pages', 'memory']);
/** The share of its limit at which memory turns the tile amber. */
export const MEMORY_WARN_SHARE = RANGES.memory.amber;
/** The share of its limit at which memory turns the tile red. */
export const MEMORY_BAD_SHARE = RANGES.memory.red;

/** Builds every tile, in display order, from the readings the tab holds. */
export function tilesFrom(readings: TileReadings): StatTile[] {
  const { health, pages, traffic, memory, charged, errors, visitorsToday, sparks } = readings;
  const tiles: Omit<StatTile, 'ringed'>[] = [];

  if (health === null) {
    tiles.push(waiting('version', 'up', 'Version'), waiting('health', 'up', 'Health'));
  } else {
    const passing = health.checks.filter((check) => check.status === 'pass').length;
    tiles.push({
      key: 'version',
      question: 'up',
      label: 'Version',
      value: health.version,
      detail: `${health.commit}, up ${uptimeWords(health.uptime_seconds)}`,
      tone: 'plain',
    });
    tiles.push({
      key: 'health',
      question: 'up',
      label: 'Health',
      value: health.status === 'healthy' ? 'Healthy' : health.status,
      detail: `${passing} of ${health.checks.length} checks pass`,
      tone:
        passing === health.checks.length ? 'good' : health.status === 'healthy' ? 'warn' : 'bad',
      ring: ringOf(passing, health.checks.length, `${passing}/${health.checks.length}`),
    });
  }

  tiles.push(
    pages === null
      ? waiting('pages', 'up', 'Pages')
      : {
          key: 'pages',
          question: 'up',
          label: 'Pages',
          value: `${pages.up} of ${pages.checked}`,
          detail:
            pages.up === pages.checked
              ? 'every address it serves is up'
              : `${pages.checked - pages.up} down`,
          tone: pages.checked === 0 ? 'warn' : pages.up === pages.checked ? 'good' : 'bad',
          ring: ringOf(
            pages.up,
            pages.checked,
            `${Math.floor((pages.up / Math.max(1, pages.checked)) * 100)}%`
          ),
        }
  );

  // Speed, as a visitor felt it. The median (p50) is the big number and the 95th
  // percentile goes in the line under it, both over the warm requests. Below
  // QUIET_BELOW_REQUESTS the hour is too quiet to colour, and the tile says so.
  const busy = traffic !== null && traffic.warm_requests >= QUIET_BELOW_REQUESTS;
  const percentiles = (t: NonNullable<TileReadings['traffic']>) =>
    t.p50_ms === null || t.p95_ms === null
      ? ''
      : `typical ${millisecondsWords(t.p50_ms)}, 95th ${millisecondsWords(t.p95_ms)}`;
  tiles.push(
    traffic === null
      ? waiting('speed', 'fast', 'Typical answer')
      : {
          key: 'speed',
          question: 'fast',
          label: 'Typical answer',
          // With no median to show, the hour is "quiet", unless the only requests
          // came during the cold start, in which case the process is "warming".
          value:
            busy && traffic.p50_ms !== null
              ? millisecondsWords(traffic.p50_ms)
              : traffic.warm_requests === 0 && traffic.requests > 0 && traffic.cold_start_label
                ? 'warming'
                : 'quiet',
          ...speedLine(traffic, busy, percentiles(traffic)),
          tone:
            !busy || traffic.p95_ms === null
              ? 'plain'
              : traffic.p95_ms >= VERY_SLOW_P95_MS
                ? 'bad'
                : traffic.p95_ms >= SLOW_P95_MS
                  ? 'warn'
                  : 'good',
          spark: sparks?.speed,
        }
  );

  if (memory === null || memory.limit_mb <= 0) {
    tiles.push(waiting('memory', 'cost', 'Memory'));
  } else {
    const share = memory.working_set_mb / memory.limit_mb;
    tiles.push({
      key: 'memory',
      question: 'cost',
      label: 'Memory',
      value: `${Math.round(share * 100)}%`,
      detail: `${formatNumber(Math.round(memory.working_set_mb))} of ${formatNumber(Math.round(memory.limit_mb))} MB`,
      tone: share >= MEMORY_BAD_SHARE ? 'bad' : share >= MEMORY_WARN_SHARE ? 'warn' : 'good',
      spark: sparks?.memory,
      ring: ringOf(memory.working_set_mb, memory.limit_mb, `${Math.round(share * 100)}%`),
    });
  }

  tiles.push(
    charged === null
      ? waiting('charged', 'cost', 'Request units')
      : charged === 'none'
        ? {
            key: 'charged',
            question: 'cost',
            label: 'Request units',
            value: 'none',
            detail: 'no document store is answering here',
            tone: 'plain',
          }
        : {
            key: 'charged',
            question: 'cost',
            label: 'Request units',
            value: `${Math.round(charged.request_units * 10) / 10}`,
            // This is a total, not a rate, so it is never coloured against the free
            // allowance per second. The machines card compares the busiest minute.
            // A total says the stretch it covers, which the server measures.
            ...(charged.span_minutes === null || charged.span_minutes === undefined
              ? {
                  detail: `RU in memory; ${formatNumber(charged.free_per_second)} a second is free`,
                }
              : {
                  detail: `RU over the last ${formatNumber(charged.span_minutes)} min`,
                  more: `; the free tier includes ${formatNumber(charged.free_per_second)} a second`,
                }),
            tone: 'plain',
            spark: sparks?.charged,
          }
  );

  // Errors: two counts over two different windows, so each is named with its
  // own. The big number is the error list the tile links to, the server's and
  // the browser's most recent errors; the 5xx answers are the traffic's
  // stretch. Before the list is read, the big number is the 5xx count, and the
  // line says so. Any error at all is red.
  if (errors === null && traffic === null) {
    tiles.push(waiting('errors', 'broke', 'Errors'));
  } else {
    const failing = traffic?.server_errors ?? 0;
    const reported = errors ?? 0;
    const stretch = ringStretch(traffic?.ring_minutes);
    const clean = failing === 0 && reported === 0;
    tiles.push({
      key: 'errors',
      question: 'broke',
      label: 'Errors',
      value: `${errors === null ? failing : reported}`,
      detail: clean
        ? 'no 5xx answered and none listed'
        : errors === null
          ? `${failing} answered 5xx`
          : `${reported} listed, ${failing} answered 5xx`,
      more:
        (errors === null ? '' : '; the list is the most recent from the server and the browser') +
        (traffic === null ? '' : `${errors === null ? ';' : ','} the 5xx are from the ${stretch}`),
      tone: clean ? 'good' : 'bad',
      spark: sparks?.errors,
    });
  }

  tiles.push(
    visitorsToday === null
      ? waiting('visitors', 'fast', 'Visitors today')
      : {
          key: 'visitors',
          question: 'fast',
          label: 'Visitors today',
          value: `${visitorsToday}`,
          detail: 'since midnight UTC, bots left out',
          more: '; counted by a daily hash, the day the server keeps',
          tone: 'plain',
        }
  );

  return tiles.map((tile) => ({ ...tile, ringed: RINGED.has(tile.key) }));
}
```
*Live from [`src/lib/statTiles/tileRules.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/statTiles/tileRules.ts#L59-L267), region `tile-rules`, read from this build at 1d6b50b.*

## Addendum, 2026-09-21 (1.0.0.175): the ribbon ground

Steve saw a background injected into the live site in the browser pane and chose it: a
ground that runs left to right from a soft green-grey to white, and teal and gold ribbons
sweeping in an S-curve down the left of the content, with pale gold highlight strands, two soft
star flares and twinkling gold sparks. It replaces the flat grey behind the panels and nothing
else. The panels, the words, the pictures, the header, the rail and the watermark are unchanged,
and the watermark stays in front of the ribbons.

**It is code, never an image.** The ground is one token, `--gradient-ground`, on the body. The
ribbons are one inline SVG in `Ribbons.tsx`, mounted once in the app shell beside the watermark. It
is fixed in place and hidden from assistive technology, and the pointer passes through it. Their
numbers are the approved drawing's, in `src/lib/ribbons.ts`; their colours are eleven tokens, set on
the gradients' stops by the stylesheet, and with the ground's four they are on the style page under
Background. They start at the rail's right edge: the rail's width token when it is docked, its
collapsed width when it is collapsed, and the screen's edge when it is the drawer, read from the
shell's own `data-rail` state rather than from a second copy of its breakpoint.

**It is cheap to draw, and a test holds each part of that.** The whole drawing drifts on one
transform with `will-change`; inside it only opacity moves, on the sparks and the two flares.
Nothing that moves carries a blur: the ribbons and the highlight strands keep their two soft
glows and never move on their own, the flares pulse on a group whose glow sits on the group
inside it, and the sparks glow through a radial gradient. A reader who asked for less motion
gets none. Markup, data and style are under twelve kilobytes of source, about four compressed,
and they fetch nothing. `ribbons.test.ts` holds those rules against the files, `tokens.test.ts`
holds every text colour against the ground's darkest stop as well as the page grey, and the
browser suite finds one drawing behind the inventory, the Admin tab and a document, at the rail's
edge on a desk and the screen's edge on a phone, still under reduced motion.


## Addendum, 2026-09-21: the ribbons stand still

Steve asked for no ribbon movement at all, the ribbons centred with CSS alone, for a minimal site that looks good. Measured the evening before in Chrome with its GPU, a minute of the front page spent 6.9 s of main-thread work in every 20 s with the ribbons moving and 0.6 s with them still. The drift, the twinkle and the pulse are gone, with their keyframes and `will-change`; the flares and sparks are drawn in the one SVG with the ribbons, since nothing in it changes; the sparks keep the opacity they had for a reader who asked for less motion. The drawing is placed and centred in the content area by the stylesheet alone, from the shell's `data-rail` state. `ribbons.test.ts` now holds that nothing in the sheet or the component moves, and the browser suite finds no animation on a desk or a phone. The paragraph above on what moves describes the drawing as it was approved on 21 September and is kept as the record.

## Addendum, 2026-09-22 (1.0.1.0): showcase

The intro sentence in `src/lib/intro.ts` now reads "showcase" where it read "working demo", at Steve's request. The quotations of the old sentence above keep the words of the day they were written.

## Addendum, 2026-09-25 (1.0.3.23): thinner glass, deeper frost, and a document on a clear sheet

Two sentences above stopped being true with the tweaks pass (ADR: The tweaks pass). The glass is no longer 42 per cent white: it is 30 per cent, 38 on a phone, frosted at 28 px with a saturation of 1.6 and an inner glow, and the contrast figures above that were measured on 42 now stand on a secondary grey deepened to `#4a4e57`, held by `tokens.test.ts` at the new share over the ribbons' brightest stops. And a document on a phone is no longer read on white: every document, at every width, opens on a clear sheet (a tenth white, a 6 px blur, nothing dimmed behind it) with its words on frosted reading panels at 78 per cent, which on a phone run edge to edge. The dialog's own copy of the ribbons went with the white, since the page's own drawing now reads through the sheet. The inventory no longer carries the intro strip; the landing page carries its sentence, and `src/components/layout/IntroStrip/IntroStrip.tsx` stays in the repository, unrendered, as the file this record names.

## Addendum, 2026-09-25 (1.0.3.28): the watermark without its lightning mark

On the live site at a desk, Steve reported an odd white box in the background, on every page. The box was the watermark's lightning mark: a gold bolt a third of the screen tall at a tenth of its strength, whose straight edge and notch read as a pale box over the ribbon ground, more so since the glass thinned to 30 per cent in the tweaks pass. It is gone from 1.0.3.28, and the watermark is its dotted rows alone, the way the rings went on 21 September. `glass.spec` holds the watermark to lines, no filled shape. The lightning mark is still the site's own mark on the header, where it was always read.

## Addendum, 2026-09-28 (1.0.3.34): operator.css is panels.css

The shared sheet that draws every panel, card, tile, rail and pill group is `src/styles/panels.css`, and its test `panels.test.ts`, named for what they draw (ADR-016, the addendum on the four token files). Nothing in either changed but the name; the operator-look tokens it draws from are in `effects.css`.

## Addendum, 2026-09-29 (1.0.3.42): the ground is part of the page

Steve said he had never liked the way the background moved. Nothing in the drawing moved; the layer did. The ribbons and the watermark were pinned to the window (`position: fixed`, one large viewport tall), so a reader who scrolled watched the page slide over a still picture. Both are pinned to the page now (`position: absolute` at the top of the page, still one large viewport tall), so they scroll away with the words in front of them like a letterhead, and the gradient ground on the body carries on below. The page column starts at the rail, so the layer's left edge is moved back by the rail's width while the rail is docked or collapsed, and the drawing's own rail offsets are as they were: at the top of any page the picture is where it was. `contain: strict` and the one paint stay.

```css
.layer {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  /* The large viewport, not the screen as it is this moment. On a phone the address
     bar comes and goes as the reader scrolls; with inset: 0 the layer's height followed
     it (664 px to 750 px, measured at 390 px wide), and a drawing that fills
     its box by slicing was scaled by 13% every time: the ribbons danced with nothing
     animated. 100vh first for a browser without lvh, which on a phone is the same
     large height. */
  height: 100vh;
  height: 100lvh;
  z-index: var(--layer-ground);
  overflow: hidden;
  pointer-events: none;
  background: var(--ribbon-tints);
  /* Its own layer, painted once. */
  contain: strict;
  transform: translateZ(0);
}

/* The page column starts at the rail; the layer, at the window's edge. */
:global([data-rail='open']) .layer {
  left: calc(-1 * var(--rail-width));
}

:global([data-rail='collapsed']) .layer {
  left: calc(-1 * var(--rail-width-collapsed));
}
```
*Live from [`src/components/layout/Background/Ribbons.module.css`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/components/layout/Background/Ribbons.module.css#L10-L39), region `ground-layer`, read from this build at 1d6b50b.*

`ribbons.test.ts` holds that the layer is pinned to the page and never the window. The browser suite scrolls the front page 600 pixels at a desk and a phone width and reads the ribbons, the watermark and the words: all three move by the scroll, to the pixel. Two assertions in `glass.spec.ts` that read the ribbons' and the watermark's position as `fixed` read `absolute` now; that is this change, and no other assertion moved.

One consequence to watch: a document opened after the reader has scrolled down stands on the gradient ground alone, since the ribbons are above the screen. The Background and ribbon page draws a second copy of the drawing in a strip, with ids of its own, which is also the standing proof of the Chrome fix of 1.0.3.20.

## Addendum, 2026-10-17: the sentence keeps only what stays true

The first screen's sentence named a count of vehicles. The band under it now reads the site live (ADR: One reading for the first screen), so the sentence keeps only the words that are as true a year from now as today: built and run by one engineer, in the open, on .NET and React, live on Azure, every decision written down, the bill on the page. A test holds it to carrying no figure at all, so the next person to write a number into it has to answer the test first.
