# ADR: The palette

Status: accepted, 2026-09-02, shipped as 1.0.0.21.

## In plain words

This page records where the site's colours come from: a small set of chosen colours (a palette), with every colour in the code taken from it. Where a shade was too light to read, it was deepened until it passed the contrast standard (WCAG AA), and a unit test fails the build if a text colour falls short.

What that is worth: a developer changes the look of the whole site in one file and learns at build time if a colour is unreadable, and the organization ships no text that fails the contrast bar.

## Context

The site had shipped in a navy and electric blue it inherited from the
take-home it grew out of, and by evening Steve had chosen its own: color
scheme 7 in Figma's website color schemes library, "Urban slate", five
colors in gray, brown and blue. Figma's line on it: "this color scheme
pulls from foggy cityscapes to evoke a sense of calm and sophistication.
The combination of light and dark shades creates a sense of depth and
contrast, while the overall color scheme maintains a serene and
professional aesthetic." Steve chose it and asked
for everything to match it.

The five, as Figma prints them: #E9E6E7 (a light warm gray), #5E5653 (a
dark brown-gray), #7B7F8A (a slate gray), #AB978C (a warm taupe) and
#6B7C98 (a slate blue).

## Decision

Every color token in the site comes from the five, and where a shade had
to move for readability, the record beside it says which of the five it
came from and why.

- **The ground is the light gray.** The page behind the cards is #E9E6E7
  as printed; cards and the sidebar are white so the depth Figma describes
  is real. Borders are the same gray a step darker.
- **Text is the brown-gray.** Body text is #5E5653 as printed; it measures
  7.2:1 on white and 5.8:1 on the ground, past the 4.5:1 that WCAG AA asks
  of normal text. Headings and the brand use a deeper cut of the same
  brown-gray, #3F3A37, for weight.
- **Muted text is the slate gray, deepened.** #7B7F8A as printed measures
  4.0:1 on white, short of AA for small text, so muted labels use #62666F
  (5.8:1 on white, 4.6:1 on the ground) and the faintest labels #6F737E
  (4.7:1 on white). The printed slate gray survives as the icon color in
  the sidebar, where 3:1 is the bar and it clears it.
- **Actions are the slate blue, deepened.** White text on #6B7C98 as
  printed measures 4.2:1, so buttons and links use #536786 (5.7:1 with
  white text on it, 5.7:1 as link text on white). A pale tint of the
  blue, #E4E9F1, sits behind hover and current rows, and the focus ring
  and the current row's edge are the deepened blue.
- **The taupe is the brand mark.** #AB978C measures 2.8:1 on white, which
  rules it out for text; it marks the lightning bolt in the sidebar and
  the header, and the favicon, the only warm note on the page, where
  contrast rules do not apply to a decoration.
- **Status colors stay semantic.** Green, amber and red on titles,
  condition grades and the live badge carry meaning a buyer reads at a
  glance, and they are the one part of the page deliberately outside the
  five. Shadows and dimmed backdrops are the brown-gray at low opacity.

## In the code

The palette, read from this build
([`src/styles/colors.css`](https://github.com/SteveStout/TheYard/blob/main/src/styles/colors.css)):

```css
/* Urban slate, the five colours as Figma prints them (ADR-016):
   #E9E6E7 light gray, #5E5653 brown-gray, #7B7F8A slate gray,
   #AB978C taupe, #6B7C98 slate blue. Two are kept as printed because other
   design tokens are built from them: the brown-gray tints every shadow and the
   scrim, and the slate gray is the third chart series and the rail's icons.
   White is written once too. */
--palette-white: #ffffff;
--palette-brown-gray: #5e5653;
--palette-slate-gray: #7b7f8a;
--color-bg: #e9e6e7; /* the light gray: the page ground where the ribbons are not drawn */
/* #region ribbon-ground */
/* The page's ground: green-grey on the left fading to white on the right,
   and the ribbons' own colours. The ribbons are drawn in code, never an
   image (ADR-081, the ribbon ground). */
--color-ground-left: #deeaed;
--color-ground-mid: #f3f7f6;
--color-ground-right: var(--palette-white);
--color-ribbon-gold: #c9a95c;
--color-ribbon-gold-soft: var(--color-gold-light);
--color-ribbon-teal: #308ca6;
--color-ribbon-teal-light: #64afc4;
--color-ribbon-green: #1d5463;
--color-ribbon-shine: #f0dc94;
--color-ribbon-shine-pale: #fff4cf;
--color-ribbon-shine-white: #fff8e0;
--color-ribbon-flare: #fffaf0;
--color-ribbon-spark: var(--color-gold-light);
--color-ribbon-star: #fff6d8;
--gradient-ground: linear-gradient(
  90deg,
  var(--color-ground-left) 0%,
  var(--color-ground-mid) 45%,
  var(--color-ground-right) 100%
);
--ribbon-tints:
  radial-gradient(
    60% 50% at 18% 30%,
    color-mix(in srgb, var(--color-ribbon-teal) 10%, transparent),
    transparent 70%
  ),
  radial-gradient(
    45% 40% at 30% 85%,
    color-mix(in srgb, var(--color-ribbon-shine) 22%, transparent),
    transparent 70%
  );
/* #endregion ribbon-ground */
--color-surface: var(--palette-white);
--color-surface-muted: #f4f2f3; /* the light gray, halfway to white */
--color-border: #d8d3d4; /* the light gray, one step darker */
--color-border-strong: #b6afb0;
/* Text greys. Each is measured against the thinnest glass over the
   darkest ribbon, not against plain white (ADR-016, ADR-083). */
--color-text: #524b48; /* body text: the brown-gray, one step deeper */
--color-text-muted: #4a4e57; /* every secondary word: the slate gray, deepened */
--color-text-faint: var(--color-text-muted); /* kept as a name; it is the muted grey (ADR-083) */
--color-heading: #3f3a37; /* the brown-gray, deepened for weight */

/* #region teal-and-gold */
/* The accent family: teal fills, dark green draws, gold trims (ADR-016, the
   teal and gold addendum). The number beside a colour is its WCAG contrast
   ratio; colors.test.ts measures every one, so a shade that fails fails the
   build. */
--color-accent: #12677f; /* teal: white on it 6.42; as text 6.42 on white, 5.18 on the page ground */
--color-accent-hover: #0f576b; /* white on it 8.09 */
--color-accent-soft: #ddebee; /* the teal, as a pale tint: the accent on it 5.25; heading text 9.19 */
--color-on-accent: var(--palette-white);
--color-green-dark: #0f4452; /* it draws: a card's left edge, a heading's rule; 10.67 on white; gold light on it 6.71 */
--color-teal-deep: #10495a; /* a plain tile's top, a vehicle's title, an upcoming auction's chip; white on it 9.89 */
--color-teal-header: #125568; /* the bottom of the header gradient and nothing else; white on it 8.32; gold light on it 5.23 */
--color-gold: #b8923f; /* the gold bar's mid tone: badge rings, the rail's group rules, the flat fallback under the bar; 2.91 on white, so never text and never data */
--color-gold-light: #e6cb7e; /* the gold bar's highlight, trim on the header: 6.71 on the dark green, 5.23 on the header teal */
/* The gold bar (to match the resume): dark at both ends, the
   highlight in the middle. A page title's underline and a stat tile's top
   rule are drawn as this bar with border-image; the flat --color-gold stays
   for the rings and the rail's group rules, and is what a browser without
   border-image draws. The two bar tones are only ever stops in the bar. */
--color-gold-bar-dark: #7d582e;
--color-gold-bar-shade: #ad8e57;
--gradient-gold: linear-gradient(
  90deg,
  var(--color-gold-bar-dark) 0%,
  var(--color-ribbon-gold) 30%,
  var(--color-gold-light) 50%,
  var(--color-gold-bar-shade) 80%,
  var(--color-gold-bar-dark) 100%
);
/* The lightning mark. It only ever sits on the header gradient. */
--color-brand-mark: var(--color-gold-light);
/* One gradient, top to bottom, for every header bar: the phone's header and
   the side rail's brand block. */
--gradient-header: linear-gradient(180deg, var(--color-green-dark), var(--color-teal-header));
/* #endregion teal-and-gold */

/* #region code-palette */
/* The colours code is shown in inside a document (ADR-074). Code sits on
   --color-surface-muted, so each colour is deepened until it clears WCAG AA
   on that ground, and colors.test.ts checks every one. */
--color-code-text: #332f2c; /* the code itself, darker than the prose because its ground is not white */
--color-code-keyword: #6d4a83; /* the slate blue, turned toward plum */
--color-code-type: #1f5c8b; /* the slate blue, deepened */
--color-code-string: #8a5a3c; /* the taupe, deepened until it reads as ink */
--color-code-comment: #25663a; /* green, which is where a reader looks for a comment */
--color-code-number: #96363b; /* brick, so a number is not a second green */
--color-code-meta: #6b5a1e; /* olive: attributes and the preprocessor */
/* #endregion code-palette */
/* The two data stores, whenever they are drawn against each other: slate
   blue for the relational store, brown for the document store. Marks, not
   text, so the floor is 3:1 on white (ADR-071). */
--color-store-sql: #4a6c96;
--color-store-cosmos: #8a6a4f;
/* A chart's series, in a fixed order that is never shuffled. A series is
   never a status colour, because a line drawn in the warning colour reads
   as an alarm (ADR-080). The first two are told apart by hue, so a chart
   that draws both always carries a legend. */
--color-series-1: var(--color-mark-teal);
--color-series-2: var(--color-mark-gold);
/* A third series, or a request the site turned away. */
--color-series-3: var(--palette-slate-gray);
/* Who the traffic was, on the Site activity card, stacked in this order:
   people, scanners and crawlers, the site's own reads. Chosen as a set that
   stays apart under colour blindness (ADR-071). Marks only: a legend names
   each band. */
--color-who-people: #1d90af;
--color-who-scanners: #b8800a;
--color-who-self: #5b78c2;
/* #region mark-vii */
/* The Admin tab's charts and gauges, drawn in one instrument style
   (ADR-083). A teal and a gold series, each at least 3:1 as a mark on
   white and on the page ground. The deep teal draws axes and ticks; the
   ribbon gold marks the end of a gauge's fill. Status colours stay for
   status. */
--color-mark-teal: #1f8ead;
--color-mark-gold: #9a7728;
--color-mark-axis: var(--color-teal-deep);
--color-mark-marker: var(--color-ribbon-gold);
--color-mark-track: color-mix(in srgb, var(--color-teal-deep) 15%, transparent);
--color-mark-bar-track: color-mix(in srgb, var(--color-teal-deep) 8%, transparent);
--color-mark-rule: color-mix(in srgb, var(--color-teal-deep) 14%, transparent);
--color-mark-tint: color-mix(in srgb, var(--color-teal-deep) 5%, transparent);
/* How solid a chart's fill is. Fills are see-through so the glass reads
   through them; the edge carries the full colour. A gauge's fill is the
   most solid, because white words sit inside it (colors.test.ts checks
   4.5 there). The wash is the page ground laid over today's part-day. */
--gauge-fill-opacity: 0.8;
--mark-bar-opacity: 0.6;
--mark-band-opacity: 0.55;
--mark-area-opacity: 0.12;
--mark-wash-opacity: 0.7;
--mark-minor-opacity: 0.45; /* a scale's minor graduation, read past rather than read */
--live-dim-opacity: 0.45; /* the live dot at the bottom of its breath */
--mark-weekend-opacity: 0.07; /* the weekend band on a daily chart, faint enough to read through */
/* #endregion mark-vii */
```
*Live from [`src/styles/colors.css`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/styles/colors.css#L35-L186), region `palette`, read from this build at 1d6b50b.*

The sidebar's share of it:

```css
/* The side rail (ADR-013): a white ground, the accent's tint behind the
   hovered and current rows, the accent for active icons. Every pair clears
   WCAG AA on both grounds; colors.test.ts checks them. */
--color-sheet-bg: var(--color-surface);
--color-sheet-bg-raised: var(--color-accent-soft);
--color-sheet-text: var(--color-text);
--color-sheet-text-muted: var(--color-text-muted);
--color-sheet-icon: var(--palette-slate-gray);
--color-sheet-icon-active: var(--color-accent);
--color-sheet-divider-strong: var(--color-border);
```
*Live from [`src/styles/colors.css`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/styles/colors.css#L208-L217), region `sheet-tokens`, read from this build at 1d6b50b.*

The proof is
[`src/styles/colors.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/styles/colors.test.ts),
which reads the tokens file and holds every text and ground pair above to
WCAG AA, so a shade that fails contrast fails the build.

## Consequences

- One file changes the look of everything, because every component reads
  the tokens and none carries a color of its own. The repaint touched the
  tokens, two dimmed backdrops, two placeholder gradients, the countdown
  chip on photos, and the favicon.
- Three of the five needed a deeper cut to clear AA for text. The printed
  values still appear where they can: the ground, the body text, the
  sidebar icons, the hover tint, the brand mark.
- A future palette is the same exercise: replace the values in the region
  above, run the unit test, ship.

## Where it sits

The palette is front end only: every colour is a design token in src/styles/colors.css, with sizes, type and effects in their own token files beside it, and colors.test.ts holds each text and ground pair to WCAG AA. SOLID speaks to classes more than stylesheets, though the four token files each have one reason to change, which is the single responsibility idea carried over to CSS. The cost was deepening three of the five printed colours, and every component giving up colours of its own. A brand guide handed down from outside, with fixed values that fail contrast, would force a different trade.

## Files

- [`src/styles/colors.css`](https://github.com/SteveStout/TheYard/blob/main/src/styles/colors.css): every color token (regions palette and
  sheet-tokens above).
- [`src/styles/colors.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/styles/colors.test.ts): the contrast proof.
- [`src/components/layout/SideNav/SideNav.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/components/layout/SideNav/SideNav.module.css),
  [`src/library/DocDialog.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/library/DocDialog.module.css),
  [`src/components/shared/AuctionCountdown/AuctionCountdown.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/components/shared/AuctionCountdown/AuctionCountdown.module.css),
  [`src/components/inventory/VehicleCard/VehicleCard.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/components/inventory/VehicleCard/VehicleCard.module.css),
  [`src/components/shared/VehicleImage/VehicleImage.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/components/shared/VehicleImage/VehicleImage.module.css): the five stylesheets
  that carried a color of their own before the repaint.
- [`src/app/App.module.css`](https://github.com/SteveStout/TheYard/blob/main/src/app/App.module.css) and [`src/components/layout/BrandMark/BrandMark.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/components/layout/BrandMark/BrandMark.tsx):
  the brand mark in the palette's taupe.
- [`index.html`](https://github.com/SteveStout/TheYard/blob/main/index.html): the favicon.
- [`src/styles/fonts.css`](https://github.com/SteveStout/TheYard/blob/main/src/styles/fonts.css) and [`src/assets/fonts`](https://github.com/SteveStout/TheYard/tree/main/src/assets/fonts): IBM Plex Sans since the addendum of 24 September, one variable file for its four weights (the four Poppins faces before it), served by the site itself since 1.0.0.140, with the font's licence beside it.
- [`tests/e2e/fonts.spec.ts`](https://github.com/SteveStout/TheYard/blob/main/tests/e2e/fonts.spec.ts): no request leaves for a font host, and IBM Plex Sans is the one face the page paints with.

## The look, from the live site

![The inventory in Urban slate: the light gray ground, white cards and rail, brown-gray text, the taupe brand mark](/api/docs/images/app-home.jpg#1280x800)

![A vehicle open: the slate-blue Place bid button and Back link on the same ground](/api/docs/images/app-vehicle.jpg#1280x800)

The measurements that hold the palette to WCAG AA, read from this build
([`src/styles/colors.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/styles/colors.test.ts)):

```ts
// Contrast is measured, not eyeballed. The test reads the real token sheets with
// ?raw and computes the WCAG ratio for every text and ground pair the site
// actually uses, so a palette change that fails AA fails the build instead of
// shipping (ADR-016).
describe('the site palette (ADR-016)', () => {
  it('body, muted and heading text clear AA on white and on the page ground', () => {
    // The ground's darkest stop too, since the ribbons (ADR: The glass look, the addendum on the ribbon ground).
    for (const ground of ['color-surface', 'color-bg', 'color-ground-left']) {
      expect(contrast(token('color-text'), token(ground))).toBeGreaterThanOrEqual(4.5);
      expect(contrast(token('color-text-muted'), token(ground))).toBeGreaterThanOrEqual(4.5);
      expect(contrast(token('color-heading'), token(ground))).toBeGreaterThanOrEqual(4.5);
    }
  });

  // This test used to allow the faint colour 3:1 on the page ground, on the
  // reasoning that faint labels sit on white "where they sit". The stylesheet
  // did not agree: AuctionCountdown paints an ended auction in the faint colour
  // at 13px on the page ground, which is normal text and needs 4.5, and axe
  // found it at 3.82 (ADR-042). The exemption was the defect, not the colour,
  // so it is gone: a token used for text clears AA on every ground this site
  // puts it on, and if a future label really is large text it can say so with
  // its own assertion rather than by lowering everybody's floor.
  it('faint labels clear AA on white and on the page ground, not 3:1 on either', () => {
    for (const ground of ['color-surface', 'color-bg', 'color-ground-left']) {
      expect(contrast(token('color-text-faint'), token(ground))).toBeGreaterThanOrEqual(4.5);
    }
  });

  // The two lines of the activity graph are graphics, not text, so WCAG
  // 1.4.11 asks 3:1 against what they are drawn on, and they are drawn on
  // white inside a card on the page ground (ADR: Site activity, and the line
  // an address does not cross).
  it('the two store colours clear 3:1 on white and on the page ground, and are told apart', () => {
    for (const ground of ['color-surface', 'color-bg']) {
      expect(contrast(token('color-store-sql'), token(ground))).toBeGreaterThanOrEqual(3);
      expect(contrast(token('color-store-cosmos'), token(ground))).toBeGreaterThanOrEqual(3);
    }
    expect(token('color-store-sql')).not.toBe(token('color-store-cosmos'));
  });

  // A chart's series are graphics too, and they are identities: the status
  // colours are for states, and a series that takes one reads as an alarm
  // (ADR: The Admin tab, as a product, the addendum on the traffic card in plain words).
  it('the series colours clear 3:1 on white and on the page ground, are told apart, and are no status colour', () => {
    const series = [token('color-series-1'), token('color-series-2'), token('color-series-3')];
    // The first two are the Mark VII teal and gold, told apart by hue (a legend names both).
    expect(series[0]).toBe(token('color-mark-teal'));
    expect(series[1]).toBe(token('color-mark-gold'));
    for (const line of series) {
      for (const ground of ['color-surface', 'color-bg']) {
        expect(contrast(line, token(ground))).toBeGreaterThanOrEqual(3);
      }
      for (const status of ['color-success', 'color-warning', 'color-danger', 'color-live']) {
        expect(line.toLowerCase()).not.toBe(token(status).toLowerCase());
      }
    }
    expect(new Set(series).size).toBe(3);
    // Teal and gold are near in lightness and far apart in hue, which is how they are told apart.
    const hue = (hex: string) => {
      const [r, g, b] = [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255);
      const max = Math.max(r, g, b);
      const min = Math.min(r, g, b);
      const turn =
        max === r
          ? ((g - b) / (max - min)) % 6
          : max === g
            ? (b - r) / (max - min) + 2
            : (r - g) / (max - min) + 4;
      return (turn * 60 + 360) % 360;
    };
    const apart = Math.abs(hue(series[0]) - hue(series[1]));
    expect(Math.min(apart, 360 - apart)).toBeGreaterThanOrEqual(90);
  });

  // #region teal-and-gold
  // Teal fills, dark green draws, gold trims (ADR-016, the addendum on teal,
  // dark green and gold). Every pairing the site makes with them, measured.
  it('white reads on every teal and on the dark green, which is what the header and the chips put it on', () => {
    for (const ground of [
      'color-accent',
      'color-accent-hover',
      'color-teal-deep',
      'color-teal-header',
      'color-green-dark',
    ]) {
      expect(contrast(token('color-on-accent'), token(ground))).toBeGreaterThanOrEqual(4.5);
    }
  });

  it('the deep teal reads as a title on white, and the accent as a link on the page ground', () => {
    expect(contrast(token('color-teal-deep'), token('color-surface'))).toBeGreaterThanOrEqual(4.5);
    expect(contrast(token('color-accent'), token('color-bg'))).toBeGreaterThanOrEqual(4.5);
  });

  it('gold light is safe as text on both ends of the header gradient, and the brand mark is that gold', () => {
    for (const end of ['color-green-dark', 'color-teal-header']) {
      expect(contrast(token('color-gold-light'), token(end))).toBeGreaterThanOrEqual(4.5);
      expect(contrast(token('color-header-text-muted'), token(end))).toBeGreaterThanOrEqual(4.5);
    }
    expect(token('color-brand-mark')).toBe(token('color-gold-light'));
    expect(token('color-header')).toBe(token('color-teal-header'));
  });

  it('gold is never text on white: it is under 3:1 there, which is why it is only ever trim', () => {
    // Not a floor to clear. A record of why the rule exists, held so that a
    // gold deepened until it could be text is noticed, because beside the
    // amber warning that gold reads as a warning.
    for (const gold of ['color-gold', 'color-gold-light']) {
      expect(contrast(token(gold), token('color-surface'))).toBeLessThan(3);
      expect(token(gold)).not.toBe(token('color-warning'));
    }
  });

  it('a plain tile is not the status green: the deep teal is told apart from it, and the accent is not', () => {
    // The accent teal sits 1.09 from the status green, so a plain tile wears the deep teal.
    expect(contrast(token('color-accent'), token('color-success'))).toBeLessThan(1.2);
    expect(contrast(token('color-teal-deep'), token('color-success'))).toBeGreaterThan(1.5);
  });
  // #endregion teal-and-gold

  // #region glass
  // Words are read against the WORST thing behind them, not against plain
  // white (ADR: The glass look): the darkest stroke of the watermark at its
  // strength, seen bare on the page ground, and seen through a glass panel.
  it('every text colour clears AA over the watermark at its worst, bare and through a panel', () => {
    const number = (name: string) => {
      const match = tokens.match(new RegExp(`--${name}:\\s*([0-9.]+)\\s*;`));
      if (!match) throw new Error(`the token sheets have no number for --${name}`);
      return Number(match[1]);
    };
    const glass = tokens.match(/--glass-bg:\s*rgba\(255, 255, 255, ([0-9.]+)\)/);
    if (!glass) throw new Error('the token sheets should state --glass-bg as white at a share');
    // The glass is transparent since the operator's look (0.42, from 0.66), and
    // thinner again since the tweaks pass (0.30); a phone's is a touch fuller, and
    // the desk's, the thinner, is the one held below.
    const phone = tokens.match(/--glass-bg-phone:\s*rgba\(255, 255, 255, ([0-9.]+)\)/);
    if (!phone)
      throw new Error('the token sheets should state --glass-bg-phone as white at a share');
    expect(Number(glass[1])).toBe(0.3);
    expect(Number(phone[1])).toBeGreaterThanOrEqual(Number(glass[1]));
    const rgb = (hex: string) => [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16));
    const hex = (parts: number[]) =>
      `#${parts.map((part) => Math.round(part).toString(16).padStart(2, '0')).join('')}`;
    const over = (top: string, bottom: string, share: number) =>
      hex(rgb(top).map((part, index) => part * share + rgb(bottom)[index] * (1 - share)));

    // The watermark's one ink is the rows' lighter teal; this measures the darker accent, the
    // rings' ink until they came out, so the margin the rings needed is kept.
    const stroke = over(token('color-accent'), token('color-bg'), number('watermark-opacity'));
    const throughPanel = over('#ffffff', stroke, Number(glass[1]));

    // On the bare ground the quiet colours are not used: muted and faint
    // words always sit inside a panel, and glass.spec.ts holds that against
    // the rendered page. Everything else that is ever a word on the ground is here.
    for (const name of [
      'color-heading',
      'color-text',
      'color-accent',
      'color-teal-deep',
      'color-success',
      'color-danger',
    ]) {
      expect(contrast(token(name), stroke)).toBeGreaterThanOrEqual(4.5);
    }
    for (const name of [
      'color-heading',
      'color-text',
      'color-text-muted',
      'color-text-faint',
      'color-accent',
      'color-teal-deep',
    ]) {
      expect(contrast(token(name), throughPanel)).toBeGreaterThanOrEqual(4.5);
    }
  });

  it('is frosted: what is behind a panel reads as colour, not as shapes (Steve, 2026-09-25)', () => {
    const frost = tokens.match(/--glass-filter:\s*blur\((\d+)px\)\s*saturate\(([0-9.]+)\)/);
    if (!frost)
      throw new Error('the token sheets should state --glass-filter as a blur and a saturation');
    // Deeper in the tweaks pass: 28 px and 1.6, where it was 20 px and 1.5.
    expect(Number(frost[1])).toBe(28);
    expect(Number(frost[2])).toBe(1.6);
    expect(tokens).toContain('inset 0 0 40px rgba(255, 255, 255, 0.18)');
  });

  // #region worst-case-pairs
  // The worst case the tweaks pass named (B1): ink over the 30 per cent glass
  // where the glass lies straight over a ribbon's teal stop and its gold stop,
  // blended at the glass's share, the stops read from the ribbon tokens. The
  // secondary ink darkened until both held 4.5 (#4d515a read 4.31 and 4.40);
  // the body ink joined the check in the self-review of 25 September, when it
  // read 3.88 there and deepened a step (#5e5653 to #524b48). One grey for
  // secondary text: the faint ink and the sidebar's muted ink are it.
  const rgb = (hex: string) => [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16));
  const hex = (parts: number[]) =>
    `#${parts.map((part) => Math.round(part).toString(16).padStart(2, '0')).join('')}`;
  const over = (top: number[], share: number, below: number[]) =>
    top.map((part, index) => part * share + below[index] * (1 - share));
  const white = [255, 255, 255];
  const share = (name: string) => {
    const match = tokens.match(new RegExp(`--${name}:\\s*rgba\\(255, 255, 255, ([0-9.]+)\\)`));
    if (!match) throw new Error(`the token sheets should state --${name} as white at a share`);
    return Number(match[1]);
  };
  const stops = () => [rgb(token('color-ribbon-teal-light')), rgb(token('color-ribbon-gold'))];

  it('every ink holds 4.5 over the 30 per cent glass on the ribbons teal and gold stops', () => {
    for (const stop of stops()) {
      const ground = hex(over(white, share('glass-bg'), stop));
      for (const name of ['color-text', 'color-text-muted', 'color-heading']) {
        expect(
          contrast(token(name), ground),
          `${name} on the glass over ${hex(stop)}`
        ).toBeGreaterThanOrEqual(4.5);
      }
    }
    expect(token('color-text-faint')).toBe(token('color-text-muted'));
    expect(token('color-sheet-text-muted')).toBe(token('color-text-muted'));
  });

  it('body text stays darker than the secondary grey, so the hierarchy reads the right way round', () => {
    expect(contrast(token('color-text'), '#ffffff')).toBeGreaterThan(
      contrast(token('color-text-muted'), '#ffffff')
    );
  });

  // A bar gauge's reading inside its deep teal fill: white on the fill at its
  // share, over the gauge's faint track, over the glass on either stop. The gold
  // fill carries no text at all (charts.test.ts): no ink clears 4.5 on it.
  it('white inside a deep teal gauge fill holds 4.5 over the glass on either stop', () => {
    const fill = tokens.match(/--gauge-fill-opacity:\s*([0-9.]+)\s*;/);
    if (!fill) throw new Error('the token sheets should state --gauge-fill-opacity');
    const track = tokens.match(
      /--color-mark-bar-track:\s*color-mix\(in srgb, var\(--color-teal-deep\) ([0-9.]+)%, transparent\)/
    );
    if (!track)
      throw new Error(
        'the token sheets should state --color-mark-bar-track as the deep teal at a share'
      );
    for (const stop of stops()) {
      const glass = over(white, share('glass-bg'), stop);
      const ground = over(rgb(token('color-teal-deep')), Number(track[1]) / 100, glass);
      const drawn = hex(over(rgb(token('color-mark-axis')), Number(fill[1]), ground));
      expect(contrast('#ffffff', drawn)).toBeGreaterThanOrEqual(4.5);
    }
  });

  // The document dialog (B1b): the sheet nearly clear, the reading panel frosted,
  // and body text on the reading panel over the same two stops holds 4.5.
  it('a document reads on a frosted panel inside a clear sheet', () => {
    expect(tokens).toContain('--dialog-sheet-filter: blur(6px);');
    expect(share('dialog-sheet-bg')).toBeLessThan(share('glass-bg'));
    for (const stop of stops()) {
      const ground = hex(over(white, share('dialog-page-bg'), stop));
      for (const name of ['color-text', 'color-text-muted', 'color-heading']) {
        expect(contrast(token(name), ground)).toBeGreaterThanOrEqual(4.5);
      }
    }
  });
  // #endregion worst-case-pairs

  it('a panel turns solid where a blur is not available or not wanted', () => {
    // Three fallbacks, each turning the panel's ground solid: no backdrop-filter,
    // a reader who asked for less transparency, and forced colours.
    const solid = (condition: RegExp) => {
      const at = tokens.search(condition);
      expect(at).toBeGreaterThan(-1);
      return tokens.slice(at, at + 420);
    };
    // Every see-through ground turns with the panels: a phone's fuller glass, a
    // document's sheet and its reading panels (the self-review of 25 September).
    const grounds = ['--glass-bg', '--glass-bg-phone', '--dialog-sheet-bg', '--dialog-page-bg'];
    const noBlur = solid(/@supports not \(\(backdrop-filter/);
    const lessGlass = solid(/@media \(prefers-reduced-transparency: reduce\)/);
    const forced = solid(/@media \(forced-colors: active\)/);
    for (const ground of grounds) {
      expect(noBlur).toContain(`${ground}: #ffffff;`);
      expect(lessGlass).toContain(`${ground}: #ffffff;`);
      expect(forced).toContain(`${ground}: Canvas;`);
    }
    for (const filter of ['--glass-filter', '--dialog-sheet-filter', '--dialog-page-filter']) {
      expect(lessGlass).toContain(`${filter}: none;`);
      expect(forced).toContain(`${filter}: none;`);
    }
  });
  // #endregion glass

  it('actions read both ways: white on the accent, and the accent as link text on white', () => {
    expect(contrast(token('color-on-accent'), token('color-accent'))).toBeGreaterThanOrEqual(4.5);
    expect(contrast(token('color-accent'), token('color-surface'))).toBeGreaterThanOrEqual(4.5);
    expect(contrast(token('color-accent'), token('color-accent-soft'))).toBeGreaterThanOrEqual(3);
  });

  it('the status colours clear AA on their own soft grounds and on white', () => {
    // The bid panel puts color-success on color-success-soft ("you are the
    // high bidder") and color-danger on color-danger-soft ("someone outbid
    // you"), and the card chips put white on the strong shade of each. Four
    // pairs, all normal text.
    for (const status of ['success', 'danger']) {
      expect(
        contrast(token(`color-${status}`), token(`color-${status}-soft`))
      ).toBeGreaterThanOrEqual(4.5);
      expect(contrast(token(`color-${status}`), token('color-surface'))).toBeGreaterThanOrEqual(
        4.5
      );
      expect(contrast(token('color-on-accent'), token(`color-${status}`))).toBeGreaterThanOrEqual(
        4.5
      );
    }
  });

  // #region composed-pairs
  // The two pairs axe found and this file did not (ADR-035). Neither is
  // exotic. They are pairs composed by a stylesheet rather than listed by a
  // person, which is the category an enumerated test cannot cover on its own.
  it('the pairs the stylesheet composes clear AA, not only the ones listed here', () => {
    // The "Reserve not met" chip: muted text on the neutral chip ground.
    expect(contrast(token('color-text-muted'), token('color-neutral-soft'))).toBeGreaterThanOrEqual(
      4.5
    );
    // The live countdown on a vehicle: success green on the page ground.
    expect(contrast(token('color-success'), token('color-bg'))).toBeGreaterThanOrEqual(4.5);
  });
  // #endregion composed-pairs

  it('the header text clears AA on the header', () => {
    expect(contrast(token('color-header-text'), token('color-header'))).toBeGreaterThanOrEqual(4.5);
    expect(
      contrast(token('color-header-text-muted'), token('color-header'))
    ).toBeGreaterThanOrEqual(4.5);
  });
});
```
*Live from [`src/styles/colors.test.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/styles/colors.test.ts#L40-L372), region `site-palette`, read from this build at 1d6b50b.*

## Addendum, 2026-09-17: the type is the site's own

Steve, 9/17: the fastest site at no extra cost. Type stays Poppins; where it
comes from changed. Until 1.0.0.140 the head of the page held a stylesheet
link to fonts.googleapis.com and preconnects to it and to fonts.gstatic.com,
and every cold visit paid for both: a DNS lookup and a TLS handshake to each
host, and a stylesheet the browser would not paint without. The measurement
that opened the performance lane (`mentor\logs\lane0917-963-perflane-measure.log`,
Lighthouse against the live site on 1.0.0.139, throttled phone profile) named
that stylesheet as the page's one render-blocking resource and put its cost
at 852 ms of an estimated 1,080 ms of savings; on a desktop Chromium with a
cold cache the four woff2 files and the stylesheet were five of the thirty
requests a first visit made.

The four latin woff2 files Google served, one per weight the tokens name,
are in `src/assets/fonts` now, fetched once as Chrome received them and
kept with the font's Open Font License beside them. `src/styles/fonts.css`
declares the four faces with the same `unicode-range` and the same
`font-display: swap` the Google stylesheet carried, so a glyph outside the
latin set falls back exactly as it did, and text is readable in the fallback
face until the file lands. Vite hashes the files under `/assets`, which puts
them under the year-long cache rule every bundle file already has (ADR: Cache
headers), and the edge keeps a copy the same way it keeps the script.

What was considered and not done, with the cost of each. A system font
stack costs nothing on the wire and changes the look of every page, and the
look was Steve's decision, not a performance budget's. `font-display:
optional` would stop the swap that shows up as layout shift on a slow first
visit, at the price of a first visit that never shows Poppins at all. A
`preload` for the two most used weights would take one hop off the critical
path, and it needs the hashed file names that only the build knows, which
means a plugin or a hand-typed name that goes stale on the next build; the
stylesheet is same-origin and first in the head, so the files are discovered
early enough without it. Poppins itself covers latin-ext and devanagari as
well as latin; those two subsets were never fetched by an English page and
are not carried. One place still names the Google stylesheet, and is left on
purpose: the diagram pages under `/diagrams` are HTML the server writes with
no build step to hash a file for them (ADR: Every diagram opens on its own page), and they are not
the page a visitor lands on.

`fonts.spec.ts` holds it: no request leaves the page for a Google host, and
after `document.fonts.ready` Poppins at the body weight is a loaded face.
The before and after, measured on the live site, are on the Performance
page.

## Addendum, 2026-09-21: the series colours

Two tokens were added, `--color-series-1` and `--color-series-2`, for a chart's lines when the lines
are not the two stores. The Admin tab's traffic card had drawn its slow-requests line in the warning
colour and its typical line in the success colour, and Steve read the first as an alarm while
scanning the page (ADR: The Admin tab, as a product, the addendum on the traffic card in plain
words). A series is an identity, so it needs a colour that means nothing else on the page: not a
status colour, and not a store's, because the two store colours mean the two stores on every
comparison.

No colour came onto the page with them. The first holds the accent's value, `#536786`, and the
second the heading's, `#3f3a37`, both from the five this record chose. They are tokens of their own
so that a change of palette changes two values in `src/styles/tokens.css` and touches no chart.

Measured, the way the rest of the sheet is, as graphics against the 3:1 that WCAG 1.4.11 asks:

| Token | Value | On white | On the page ground `#e9e6e7` |
| --- | --- | --- | --- |
| `--color-series-1` | `#536786` | 5.75 | 4.64 |
| `--color-series-2` | `#3f3a37` | 11.22 | 9.05 |

`tokens.test.ts` holds both figures at 3:1 or better on both grounds, that the two are told apart,
and that neither is the value of a status token.

## Addendum, 2026-09-21 (1.0.0.169): teal, dark green and gold

Steve liked the five colours this record chose but found them a little bland. He gave two
reference pictures and one condition, that the white and light grey background stay, and the palette that came out of them is an accent and not a repaint: **teal fills, dark green
draws, gold trims, and the grounds and the text colours are exactly as they were.**

Every value was read off the pixels of his pictures and not off their captions, because the first
picture's printed codes do not match its own swatches: the box labelled `#68875A` is `#0A3021`. One
value is not from a picture. The sampled deep teal was too close to the dark green for a gradient
between them to be seen on a bar fifty pixels tall, so the bottom of the header gradient is one step
bluer, `#03505a`.

| Token | Was | Is | Measured |
| --- | --- | --- | --- |
| `--color-accent` | `#536786` | `#006360` | white on it 7.11; as text 7.11 on white, 5.73 on the page ground |
| `--color-accent-hover` | `#465a78` | `#004f4d` | white on it 9.44 |
| `--color-accent-soft` | `#e4e9f1` | `#e0ecee` | the accent on it 5.89; heading text on it 9.30 |
| `--color-green-dark` | new | `#0a3021` | 14.41 on white, 11.62 on the page ground; white on it 14.41 |
| `--color-teal-deep` | new | `#024345` | 11.10 on white; white on it 11.10; 1.70 from the status green |
| `--color-teal-header` | new | `#03505a` | white on it 9.13; gold light on it 5.06 |
| `--color-gold` | new | `#d4aa3a` | 2.19 on white, so never text and never data |
| `--color-gold-light` | new | `#dcbf57` | 7.98 on the dark green, 5.06 on the header teal; 1.81 on white |
| `--color-brand-mark` | `#ab978c` | `#dcbf57` | the gold light, on the header gradient only |
| `--color-header` | `#ffffff` | `#03505a` | the lighter end of the gradient, which is the worse of the two for white text |
| `--color-header-text` | `#3f3a37` | `#ffffff` | 9.13 on the header teal, 14.41 on the dark green |
| `--color-header-text-muted` | `#62666f` | `#cfe3e6` | 6.87 on the header teal, 10.83 on the dark green |
| `--color-series-1` | `#536786` | `#0a3021` | 14.41 on white |
| `--color-series-2` | `#3f3a37` | `#188f8d` | 3.92 on white, 3.16 on the page ground, 3.67 against series 1; never text |
| `--color-series-3` | new | `#7b7f8a` | 4.00 on white, 3.23 on the page ground |
| `--color-sheet-bg-raised` | `#e4e9f1` | `#e0ecee` | sheet text on it 5.94, muted sheet text 4.77 |
| `--color-sheet-icon-active`, `--color-sheet-focus` | `#536786` | `#006360` | 7.11 on white, 5.89 on the raised row |

**What each is for, and where it must not go.**

- **Teal** is the accent everywhere the slate blue was: pressed buttons, links, toggles, focus rings,
  the store switch, the chosen row of the rail. It is never a tile's top, because it sits 1.09 from
  the status green and would read as "fine". A plain tile wears the deep teal, and only a healthy
  tile is green.
- **Dark green draws**: a card's left edge where the edge is deliberate, the rule under a section
  heading, the first series on a chart, the top of the header gradient. Not every hairline, which
  stay the neutral border.
- **Gold is trim**: the lightning mark, the rule under the header, the site name on the header, the
  underline of a page's title, a short tick at the start of a section's rule, a ring round the
  chosen button. On white it reads about 2 to 1, so it is never text and never data, never a tile's
  top and never a line on a chart, and beside the amber "worth a look" it would read as a warning.
- **The header is one gradient**, `--gradient-header`, top to bottom from the dark green to the
  header teal, because left to right could not be seen. It is the phone's header and the rail's
  brand block. White text is 14.41 at its top and 9.13 at its bottom.
- **The series order is fixed**: dark green, bright teal, neutral grey, so a colour means the same
  place on every chart. 1.0.0.168 gave the series tokens of their own for exactly this; this
  ship changed their values and touched no chart.

**The status colours did not change and are reserved.** They mean a state and always carry a word beside
them; a series never takes one (ADR: The Admin tab, as a product, the addendum on the traffic
card in plain words).

**The two store colours stay for now**, slate blue and the deepened taupe, on the comparison cards,
the proof's bars and the visitors graph. They mean the two stores in five drawings under
`docs/images` and in the records that show them, and moving them on the page and not in the
drawings would be a half move. They move together with the drawings, in a ship of their own. The
link preview, `docs/images/og.mjs`, moved in this one, because it is seen before the site is.

**Raw colours that left the components on the way**: the countdown chip over a photograph was three
hex codes on a see-through brown and is now solid, in the colour of what it says (live the status
green, to come the deep teal, ended the heading colour), and the grey gradient behind a photograph
that has not loaded is the muted surface token.

`tokens.test.ts` holds every figure in the table that is a floor: white on each teal and on the dark
green, the gold light and the muted header text on both ends of the gradient, the three series on
both grounds and apart from each other, and that neither gold could pass as text on white.

## Addendum, 2026-09-21 (1.0.0.170): the style section, and rules that hold themselves

The day the new colours shipped, Steve asked for a new section on style holding the colour swatches
and the colour style document, with these rules always followed.

Two halves. The first is a page a person reads. The second is what makes "always followed" true, and
it is tests, because a rule on a page is followed until the first session that does not read the
page.

**The page.** A new section in the sidebar, Style, between Diagrams and Hosting, with one document:
[Colour and style](https://theyard.stevenstout.biz/?doc=color-style), `docs/style/COLOR-STYLE.md`. One
entry a colour, with its job, where it must not go and its measured contrast; then the grounds, the
status colours, the chart order, the glass look and the rules.

**The swatches are drawn from the token sheet and are never a picture.** A picture of swatches is
wrong the day a token changes. A `swatches` fence in a served document lists tokens, one a line,
and the renderer turns the fence into a sheet (`src/lib/swatches.ts`): each chip is painted with the
token ITSELF, `var(--token)`, so it is whatever the page is running on, and beside it are the value
the sheet states and its contrast on white and on the page grey, computed from the sheet's text by
the same arithmetic the token test uses (`src/lib/contrast.ts`, which both now import). The header
gradient is a wide sample of its one token. A token a fence names and the sheet does not define is
drawn as a gap that says so. It costs the inventory nothing: the sheet's text rides in the chunk
that already carries the markdown renderer, which is fetched when the first document is opened.

| Option for the swatches | Why it was or was not taken |
| --- | --- |
| A picture, regenerated by a script | Stale between regenerations, and a second artefact to keep true |
| An endpoint that reads `tokens.css` and serves the colours | C# and a request for something the page already has |
| **A fence the renderer draws from the sheet** | No endpoint, no picture, no request; the chip is the token |

**Two tokens were added so that no component carries a raw colour.** `--color-warning-border`,
`#f5d48a`, is the hairline round a notice on the warning's soft ground, which had been a hex code in
one stylesheet; it is a mark and not text. `--scrim` is what dims the page behind a dialog and the
drawer, the brown-gray at 0.45, which had been two `rgba(` values a twentieth apart in two
stylesheets and is one now. A white in the error boundary's stylesheet became `--color-on-accent`.

**The rules, held by the gate** (`api/TheYard.Tests/StyleRulesTests.cs`, each a row in the table of
rules a change has to pass). Each fails with a sentence that says what to do.

1. No raw colour, hex, `rgb(` or `hsl(`, in any stylesheet or component outside the token sheet.
   The allow-list is empty, and an exception goes on it with its reason.
2. Every hex on the Colour and style page is the value of a token, and every colour token is on the
   page. This is the test that keeps the page true.
3. Every contrast figure the page states is the figure the tokens give, to the hundredth, and clears
   the bar its pairing needs, 4.5 for text and 3.0 for marks; a figure on the page that no pairing
   gives fails too, because it is a figure nobody checks. `tokens.test.ts` still measures every
   pairing the site makes.
4. No series token has a status colour's value, and a chart's line takes a status tone only for
   server errors.
5. The gold tokens are used only by the header, the brand mark and the named trim, by file, with
   what they trim there; never a chart's line, a tile or a ring.
6. `--gradient-header` is defined once, in the token sheet, and every header bar that paints the
   header colour paints the gradient over it.
7. Nothing that holds a word or an image is faded, and a quiet word is never on the bare ground:
   those are held against the rendered page by `glass.spec.ts`, and the seventh test here holds that
   they still are.

## Addendum, 2026-09-24: the one face is IBM Plex Sans

Steve compared Poppins with IBM Plex Sans and two others on one page, every view of the site set in each, and chose Plex as the best of them for phones and tablets. It replaces Poppins everywhere and there is no second face. Google Fonts' own stylesheet, asked for 400, 500, 600 and 700 the way the Poppins files were fetched, names one variable latin file for all four (45,712 bytes): four static cuts of it would weigh 86 KB against Poppins' 31 KB, so the variable file is cut on the build machine to the four weights, the latin range and the layout features running text uses instead (kerning, the ligatures and the marks; the fraction forms go, which nothing on the site asks for), one file of 29,144 bytes, kept in `src/assets/fonts` with the font's licence (the SIL Open Font License, IBM's copyright and its reserved name), declared once in `fonts.css` for weights 400 to 700 with the same latin range, preloaded from the head as 1.0.3.1 set up, and named first in `--font-sans`; the first screen carries 2.3 KB less type than it did. Plex sits a little smaller on the em than Poppins, so every size was read again in the pictures after the swap rather than raised on a guess. **Readings take the one face with tabular figures**, set once on the body so a column of numbers lines up without a rule per component; the monospaced face that the Admin tab's small labels, the chart readouts, the index chips, a vehicle's VIN and the footer's commit used is gone from all of them. **Code keeps a monospaced face and only code**: a document's samples, the SQL the Admin tab shows and an error's stack take `--font-code`, because code reads like code (ADR: Code that reads like code). `StyleRulesTests` holds it: the token names Plex first, no sheet or component names Poppins outside a comment, and the code token goes only to code. The palette does not change.

## Addendum, 2026-09-25 (1.0.3.23): one secondary grey

The muted and faint text colours, `--color-text-muted` and `--color-text-faint`, and the rail's muted text were three slate greys a step apart. From 1.0.3.23 they are one, `#4a4e57`: the glass went to 30 per cent white in the tweaks pass (ADR: The tweaks pass), and over that glass, where it lies straight over a ribbon's teal stop, the grey the pass first proposed read 4.31. The grey went darker until the worst case held 4.5; the glass did not go back. The style page and `tokens.test.ts` carry the figures.

## Addendum, 2026-09-25 (1.0.3.26): body text a step deeper

The body ink, the printed brown-gray `#5e5653`, read 3.88 over the 30 per cent glass where it lies over a ribbon's teal stop, and 3.97 over the gold one. The secondary grey had been deepened for exactly that case on 25 September and the body ink had not, which left the body text lighter than the secondary text. It is `#524b48` from 1.0.3.26, the same hue a step darker: 4.63 and 4.72 over the two stops, 8.54 on white and 6.89 on the page ground, and darker than `#4a4e57` again. `tokens.test.ts` now reads every ink, body, secondary and heading, over both stops, and holds the body ink darker than the secondary. The API reference and the diagram page keep the printed colour: they are read on white, where it reads 7.16.

## Addendum, 2026-09-28 (1.0.3.34): the tokens in four files named for what they control

Steve asked that a developer find any style setting from the file name alone. `src/styles/tokens.css` held every token in one 440-line sheet, so "where is the drawer's width?" meant searching it. From 1.0.3.34 the same 221 tokens live in four files, each named for what it controls, loaded in this order by `main.tsx`:

- `colors.css`: the palette (with the ribbon ground, teal and gold, the code theme, the stores, the series, who came, the chart marks), the status and header colours, the side rail's colours and the scrim. It keeps the three-tier header.
- `sizes.css`: spacing, reserved heights, the controls, rail widths, corners, the pill's padding, the page and drawer widths, layers, and the phone's control heights.
- `typography.css`: the families, text sizes, weights, the title's tracking and chart text.
- `effects.css`: shadows, the glass and its fallbacks, the panel look, the gold trims, strengths and motion.

Whole regions moved intact, and no value, variable name, selector or class name changed: the sorted list of names before and after is identical, and so is the sorted list of declarations. Code that reads the tokens as text (the swatches and the contrast tests) reads the four joined, from `src/lib/styleSheet.ts`, and StyleRulesTests reads the same four; the glass-fallbacks check reads `effects.css`, and the check for tabular figures reads `base.css`. This reverses the choice in ADR-083's 1.0.3.32 addendum to keep one token sheet for the tests' sake: the tests now read four files as one, so the split costs them nothing. `tokens.test.ts` is `colors.test.ts`. What these records say about `tokens.css` above is history; their links and live samples point at the new files.

## Addendum, 2026-09-29: the Style section becomes four pages

The Style section was one page, Colour and style. It is four now, in the sidebar in this order: the **Style guide** (the landing: three tiles that open the sub-pages in the same window, the standing rules, a glossary of twelve styling terms, and the readouts the build counts), **Colour and style** (the swatches, the glass, the sizes, how the sheet is built, what holds it), **Background and ribbon** (the ground, the ribbons and a live strip of them, the watermark, why it stands still, the glass over it, the Chrome bug) and **UI architecture** (the layers, where a change goes, one job per file). The ground's and the ribbons' swatches moved from Colour and style to Background and ribbon, and `StyleRulesTests` reads the two as one set: every colour design token is on one of them, and every hex and every contrast figure on either is checked.

**"Design token", never a bare "token".** On a site with a Built with AI section, a bare "token" reads as the AI kind. Each page defines the term on first use (a named CSS custom property holding one design decision), and the comments in the four design token files say it too. `StyleSectionTests` holds the four pages and the four files to it, and holds the pages to no em dash.

**Every number on the pages is counted by the build.** A page writes a live placeholder, two braces around `live:` and the name of a measure, and the API replaces it at request time (`LiveCounts.cs`): the design tokens, the facts in a test class, the lines in a file, the files that carry the three-line header, the entries in the ribbons' arrays. The count is made on the server because the browser cannot read a C# file or list a folder. The design token count is the number of distinct names across the four files, since a name declared again inside a media query is still one design token; the mock's 221 counted declarations.

```csharp
/// <summary>The four design token files, in the order main.tsx loads them.</summary>
public static readonly string[] DesignTokenFiles = ["src/styles/colors.css", "src/styles/sizes.css", "src/styles/typography.css", "src/styles/effects.css"];

/// <summary>The two folders whose code carries the header, as FileHeaderTests reads them; every stylesheet under src carries it too.</summary>
public static readonly string[] HeaderFolders = ["src/app", "src/library"];

/// <summary>
/// Replaces every live placeholder in <paramref name="markdown"/> with the
/// measure read from <paramref name="repoRoot"/>. A measure that returns
/// several lines (the fact names) is meant to sit alone on a line inside a
/// fence, and replaces that line.
/// </summary>
public static string Expand(string markdown, string repoRoot) =>
    Placeholder().Replace(markdown, match => Measure(match.Groups["measure"].Value.Trim(), repoRoot));

/// <summary>One measure, as text.</summary>
public static string Measure(string measure, string repoRoot)
{
    string[] words = measure.Split(' ', StringSplitOptions.RemoveEmptyEntries);
    string kind = words.Length > 0 ? words[0] : "";
    string? path = words.Length > 1 ? words[1] : null;
    try
    {
        return kind switch
        {
            "design-tokens" when path is null => Number(DesignTokenFiles.SelectMany(file => TokenNames(repoRoot, file)).Distinct(StringComparer.Ordinal).Count()),
            "design-tokens" when DesignTokenFiles.Contains(path) => Number(TokenNames(repoRoot, path!).Distinct(StringComparer.Ordinal).Count()),
            "design-token-files" => Number(DesignTokenFiles.Count(file => File.Exists(Full(repoRoot, file)))),
            "facts" when Readable(path) => Number(FactAttribute().Matches(File.ReadAllText(Full(repoRoot, path!))).Count),
            "fact-names" when Readable(path) => string.Join("\n", FactName().Matches(File.ReadAllText(Full(repoRoot, path!))).Select(match => match.Groups["name"].Value)),
            "lines" when Readable(path) => Number(File.ReadAllLines(Full(repoRoot, path!)).Length),
            "sheets" => Number(Sheets(repoRoot).Count),
            "headers" => $"{Number(HeaderScope(repoRoot).Count(file => HasHeader(file)))} of {Number(HeaderScope(repoRoot).Count)}",
            "over-300" => Number(HeaderScope(repoRoot).Count(file => File.ReadAllLines(file).Length > 300)),
            "array" when path is not null => Number(ArrayLength(repoRoot, "src/lib/ribbons.ts", path)),
            "documents" => Number(DocumentationCatalog.Files.Count),
            "live-blocks" => Number(LiveBlocks(repoRoot).Sum()),
            "live-documents" => Number(LiveBlocks(repoRoot).Count(blocks => blocks > 0)),
            _ => $"(no live measure named '{measure}')",
        };
    }
    catch (IOException)
    {
        return $"(the file for '{measure}' is not in this build)";
    }
}
```
*Live from [`api/TheYard.Api/LiveCounts.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Api/LiveCounts.cs#L21-L66), region `live-counts`, read from this build at 1d6b50b.*

The tiles, the readouts, the glossary and the ribbon strip are fences, the way the swatches are (`src/library/styleBlocks.ts`), and `src/library/useLiveBlocks.tsx` mounts the ribbons and the tile icons into them after the document is drawn. Every second-level heading now carries an id from its words, so the glossary's "See" links land on the section a term is used in.

**1.0.3.43: the blocks are kept.** The live read after 1.0.3.42 found the ribbon strip empty on both sites, and one read of the Style guide with a tile missing its icon. The first version mounted the blocks when the document changed and let them go in the effect's cleanup, and a render in between could leave an element with no root. The blocks are now drawn once per element, on every render that finds one not yet drawn, and a root is let go only when its element has left the page. `style-section.spec.ts` holds the three icons, a tile opening its page in the same window, and the strip's drawing.

## Addendum, 2026-09-29 (1.0.3.44): palette F, and the gold becomes a bar

Steve, 29 September: the blues move toward palette F, hue 193, blue with enough green in it to soften the step to the gold; the gold becomes a gold bar, cut from the swatch he sent (dark `#7d582e`, highlight `#e6cb7e`). Both match the resume he made his main one the same day, which is teal `#125a68` with gold `#b8923f` rules; the header teal lands a hair from the resume's teal. Colour values only, plus the bar: the grounds, the ribbons' shapes, the glass, the face and the layout are unchanged, and so is every floor and every pairing in `StyleRulesTests`.

| Token | Was | Is | Measured |
| --- | --- | --- | --- |
| `--color-accent` | `#006360` | `#12677f` | white on it 6.42; as text 6.42 on white, 5.18 on the page ground; 1.01 from the status green |
| `--color-accent-hover` | `#004f4d` | `#0f576b` | white on it 8.09 |
| `--color-accent-soft` | `#e0ecee` | `#ddebee` | the accent on it 5.25; heading text on it 9.19 |
| `--color-green-dark` | `#0a3021` | `#0f4452` | 10.67 on white, 8.60 on the page ground; gold light on it 6.71 |
| `--color-teal-deep` | `#024345` | `#10495a` | white on it 9.89; 1.52 from the status green |
| `--color-teal-header` | `#03505a` | `#125568` | white on it 8.32; gold light on it 5.23 |
| `--color-ribbon-teal` | `#2a8c8c` | `#308ca6` | a ribbon stop, no text on it |
| `--color-ribbon-teal-light` | `#5fb3a8` | `#64afc4` | body text on the thinnest glass over it 4.62, the secondary grey 4.51 |
| `--color-ribbon-green` | `#1f4d3f` | `#1d5463` | a ribbon stop |
| `--color-ground-left` | `#dcebe7` | `#deeaed` | the ground's left end |
| `--color-mark-teal` | `#13928b` | `#1f8ead` | 3.80 on white, 3.06 on the page ground; more than 150 degrees of hue from the gold mark |
| `--color-who-people` | `#0a8f85` | `#1d90af` | 3.71 on white |
| `--color-header-text-muted` | `#cfe3e6` | `#d1e1e6` | 6.19 on the header teal, 7.94 on the dark green |
| `--color-store-sql` | `#536786` | `#4a6c96` | 5.42 on white, 4.37 on the page ground |
| `--color-gold` | `#d4aa3a` | `#b8923f` | the bar's mid tone; 2.91 on white, so still never text |
| `--color-gold-light` | `#dcbf57` | `#e6cb7e` | the bar's highlight; 1.59 on white |
| `--color-ribbon-gold` | `#c9a24a` | `#c9a95c` | the bar's stop at 30 per cent; body text on the thinnest glass over it 4.94, the secondary grey 4.82 |
| `--color-ribbon-gold-soft` | `#e8cf8a` | `var(--color-gold-light)` | the same value as the gold light, so written as it |
| `--color-ribbon-shine` | `#f3d98a` | `#f0dc94` | a ribbon stop |
| `--color-ribbon-spark` | `#f1d27e` | `var(--color-gold-light)` | the same value as the gold light, so written as it |
| `--color-mark-gold` | `#a57c1d` | `#9a7728` | 4.16 on white, 3.36 on the page ground |
| `--color-gold-bar-dark` | new | `#7d582e` | the bar's two ends, a stop in the gradient only |
| `--color-gold-bar-shade` | new | `#ad8e57` | the bar's stop at 80 per cent, in the gradient only |
| `--gradient-gold` | new | the bar, left to right | dark, ribbon gold, gold light, shade, dark |

**Three values moved from the brief, each to pass a test that was already there.** The brief's accent `#126981` read 4.42 against the watermark's stroke, under the 4.5 `colors.test.ts` holds a link to there, so it went down a step in its own hue until it cleared: `#12677f`, 4.51. The brief's deep teal `#104f60` sat 1.40 from the status green, under the 1.5 that keeps a plain tile from reading as a healthy one, so it went down to `#10495a`, 1.52. The brief's ribbon gold `#ad8e57` put the body text at 4.06 and the secondary grey at 3.96 on the thinnest glass over it; darkening could not fix that, so the ribbon gold is the bar's own stop at 30 per cent, `#c9a95c`, and the brief's value stays in the bar as its shade. No floor was lowered and no pairing removed; the header teal and the mark teal are exactly the brief's.

**The bar is drawn, not painted.** Every element that drew `--rule-gold` as a bottom border (the landing, Inventory, Admin and Account titles, a document's title and the Author page's) takes `border-image: var(--gradient-gold) 1`, so the rule is the bar. A stat tile's top rule takes the same bar on its top edge only (`border-image: var(--gradient-gold) 1 / var(--rule-width) 0 0 0`), which means the tile's hairline on the other three sides is no longer drawn and the bar's ends are square under the rounded corners. The flat `--color-gold` stays for the rings, the rail's group rules and a browser without `border-image`. Two tokens are new beside the gradient because the sheet writes no hex inside a value (`StyleRulesTests`, a value written once): the bar's dark ends and its shade.

**Where else the old values lived.** The API's two inline pages (the diagram page and the API document's theme), the browser's theme colour and the tab icon in `index.html`, the preview card (`og.svg`, `public/og.png`), and the drawings whose style blocks named a palette colour (the UI architecture, the infrastructure, the data flow, the ERD, the two sites and SQL against Cosmos DB, each redrawn). The resume the site serves is the 29 September build, by hash.
