# ADR: The React configuration, explained for a new developer

Status: accepted, 2026-09-02, shipped as 1.0.0.24. Written at Steve's
request for a developer new to React and Vite, or new to this codebase,
who opens the root of the repository and wants to know what every
configuration file is for and why the source is laid out the way it is.

## In plain words

This page walks every configuration file at the root of the front end, in the order a request meets them, and says why each one is there. There is no router and no state library, and the page says what does those jobs instead.

What that is worth: a developer new to React or to this code learns the setup in one read instead of by trial and error, and the organization spends less senior time onboarding each new hire.

## Context

The root of the repository holds a package.json with a page of scripts
(eleven when this was written, seventeen today) and
three runtime dependencies, an index.html, a vite.config.ts, three
tsconfig files, and a playwright.config.ts. Under `src/` there is no
router, no state library and no CSS framework. A newcomer used to larger
React projects will look for those and wonder what replaced them. This
record walks the files in the order a request meets them, from the
manifest to the build in the container, with the why beside each. The
samples are the files, read from this build.

## The walk

### package.json: few scripts, fewer dependencies

`npm start` runs the API and the Vite dev server together (`concurrently
-k` kills both when either exits, and `vite --open` opens the browser).
`npm run build` is `tsc -b && vite build` because the two tools split the
job: Vite strips types without checking them, so the compiler runs first
and emits nothing (`noEmit`, below), and a type error stops the build.
`npm test` is Vitest and `npm run test:e2e` is Playwright; the two are
separated further down.

There are three runtime dependencies. `react` and `react-dom` are the
framework; `marked` renders the markdown the API serves for every document
in the sidebar. Everything else is a dev dependency, which is why the
production bundle is small and why adding a package is a decision, not a
reflex.

```json
{
  "name": "theyard",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "start": "concurrently -k -n api,web -c blue,green \"npm run api\" \"vite --open\"",
    "dev": "vite",
    "api": "dotnet run --project api/TheYard.Api",
    "docker": "docker build -t theyard:local . && docker run --rm -d -p 8080:8080 --name theyard theyard:local",
    "docker:stop": "docker stop theyard",
    "test:api": "dotnet test api --filter Store!=cosmos",
    "build": "tsc -b && vite build",
    "build:render": "vite build --config render/vite.config.ts",
    "render": "node render/server.mjs",
    "preview": "vite preview",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:e2e": "playwright test",
    "test:all": "node scripts/test-all.mjs",
    "test:all:full": "npm run test:api && npm test",
    "images:resize": "node scripts/resize_photos.mjs",
    "images:author": "node scripts/author_photos.mjs",
    "lint": "oxlint --deny-warnings",
    "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"render/**/*.{ts,mjs}\" \"tests/**/*.ts\" \"*.ts\" \"scripts/**/*.mjs\"",
    "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"render/**/*.{ts,mjs}\" \"tests/**/*.ts\" \"*.ts\" \"scripts/**/*.mjs\"",
    "format:api": "dotnet format api/TheYard.slnx",
    "format:api:check": "dotnet format api/TheYard.slnx --verify-no-changes"
  },
  "dependencies": {
    "highlight.js": "^11.12.0",
    "marked": "^18.0.14",
    "react": "^19.3.0",
    "react-dom": "^19.3.0"
  },
  "devDependencies": {
    "@axe-core/playwright": "^4.13.0",
    "@playwright/test": "^1.63.0",
    "@types/react": "^19.3.0",
    "@types/react-dom": "^19.3.0",
    "@vitejs/plugin-react": "^6.1.1",
    "concurrently": "^10.0.5",
    "oxlint": "^1.86.0",
    "prettier": "^3.9.9",
    "sharp": "^0.35.5",
    "typescript": "^7.0.2",
    "vite": "^8.3.2",
    "vitest": "^5.0.3"
  }
}
```
*Live from [`package.json`](https://github.com/SteveStout/TheYard/blob/1d6b50b/package.json#L1-L50), region `*`, read from this build at 1d6b50b.*

### index.html is the entry point

Vite starts from HTML, not from a JavaScript file. The one script tag
loads `/src/main.tsx` as a module; in development the browser fetches the
source files one by one and Vite transforms them on the way, and in a
build Vite rewrites the tag to point at the hashed bundle (which is what
makes the year-long cache rule in ADR: Cache headers safe). The favicon is
an inline SVG data address in the brand colors, so it costs no request.
The two preconnect lines warm the connection to Google Fonts before the
Poppins stylesheet is asked for. The viewport line is what makes the phone
layouts in ADR: The phone header possible at all.

```xml
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <!--
      The API reads the first page makes, started from here (ADR: The landing
      page rendered at build time, server rendering as the goal).

      Without this, each read waits until the script below has arrived and
      run, so the landing page's figures arrive a whole bundle late. A preload
      hint starts each one while the script is still on its way, and the
      fetch that asks for it later is handed the answer already here.

      A hint is used only when it matches its fetch exactly; anything else and
      the browser asks twice. as="fetch" with crossOrigin "anonymous" is a cors
      request with same-origin credentials and no extra headers, which is what
      every one of these fetch calls sends. tests/e2e/preload.spec.ts holds
      that: each address asked for once, and no hint the browser could not use.

      Four reads every view makes on its first load. Two more only the landing
      page makes, so they are hinted only when the address carries no query,
      which is the landing page and nothing else; a hint nobody uses is a
      wasted request and a warning in the reader's console.

      It is a script rather than link tags because two of the six depend on
      the address. It sits above the stylesheet on purpose: a script below a
      stylesheet waits for it, and this one must not wait for anything.
    -->
    <!-- #region api-preload -->
    <script>
      (function () {
        var reads = ['/api/stores', '/api/auth/me', '/api/bids', '/api/version'];
        if (location.search === '') reads.push('/api/health', '/api/tests/summary', '/api/recommendations');
        // The build draws the landing page into #root (the note beside it,
        // below). Any other address opens on another view, so that drawing is
        // kept off the screen there until the app replaces it (src/app/mount.tsx).
        else {
          var hide = document.createElement('style');
          hide.textContent = '#root[data-drawn="landing"] { display: none; }';
          document.head.appendChild(hide);
        }
        for (var i = 0; i < reads.length; i++) {
          var hint = document.createElement('link');
          hint.rel = 'preload';
          hint.as = 'fetch';
          hint.crossOrigin = 'anonymous';
          hint.href = reads[i];
          document.head.appendChild(hint);
        }
      })();
    </script>
    <!-- #endregion api-preload -->
    <link
      rel="icon"
      href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230f4452'/%3E%3Cpath d='M13 5 8 18h5l-2 9 10-14h-6l4-8z' fill='%23e6cb7e'/%3E%3C/svg%3E"
    />
    <!--
      No third-party stylesheet and no preconnect. The type is the site's
      own since 1.0.0.140: one IBM Plex Sans file under /assets for its four
      weights (four Poppins files until 1.0.3.18), declared in
      src/styles/fonts.css and hashed like every other bundle file (ADR: The
      palette, addendum). The Google Fonts link that stood here was the one
      render-blocking resource on the page, 852 ms of it on a throttled phone.
    -->
    <title>TheYard, a used-vehicle auction platform</title>

    <!--
      What an automated reader gets.

      Everything below this line exists because the first thing that visits a
      link is rarely a person. A crawler, a chat client unfurling a URL, an
      applicant tracking system and a search engine all read the head and
      nothing else, and until now the head held a title and a font. A page that
      says nothing about itself to those readers is invisible to the ones that
      matter most for a portfolio.

      The description is one sentence and under 160 characters, because that is
      what gets shown rather than truncated. The Open Graph image is absolute:
      a relative og:image is silently dropped by most unfurlers.

      No record count in this file. It carried a typed one from 1.0.0.58 to
      1.0.0.113 and was twenty-five records stale by the end, because this file
      is static and the image is built without the docs folder, so nothing here
      can read the catalogue. The number lives on the generated preview card,
      which reads it; the words here make no claim a count could contradict,
      and the public-face test now reads this file too (ADR: The public face).
    -->
    <meta
      name="description"
      content="A used-vehicle auction platform in .NET 10 and React: 100,000 listings, live bidding, two stores, and every decision record served from inside the app."
    />
    <link rel="canonical" href="https://theyard.stevenstout.biz/" />
    <meta name="author" content="Steven Stout" />
    <meta name="theme-color" content="#0f4452" />

    <meta property="og:type" content="website" />
    <meta property="og:site_name" content="TheYard" />
    <meta property="og:url" content="https://theyard.stevenstout.biz/" />
    <meta property="og:title" content="TheYard, a used-vehicle auction platform" />
    <meta
      property="og:description"
      content="100,000 listings, live bidding, two stores side by side, and every decision record served from inside the running app. .NET 10 and React on Azure."
    />
    <meta property="og:image" content="https://theyard.stevenstout.biz/og.png" />
    <meta property="og:image:width" content="1200" />
    <meta property="og:image:height" content="630" />
    <meta
      property="og:image:alt"
      content="TheYard: a used-vehicle auction platform in .NET 10 and React, by Steven Stout"
    />

    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:title" content="TheYard, a used-vehicle auction platform" />
    <meta
      name="twitter:description"
      content="100,000 listings, live bidding, two stores side by side, and every decision record served from inside the running app."
    />
    <meta name="twitter:image" content="https://theyard.stevenstout.biz/og.png" />

    <!--
      Structured data, as SoftwareSourceCode rather than the WebSite most sites
      declare. This is a portfolio piece: what a reader wants to know is who
      wrote it, in what, and where the source is, and there is a schema that
      says exactly that. Claiming to be an Organization or a Product would be
      more impressive and less true.

      Since 1.0.3.47 the author is a Person node of his own, the same one
      /about carries (api/TheYard.Api/AboutPage.cs): his name, the resume's
      title line, the two profiles that are him, and his city. Nothing else
      about him goes here, and never a street.
    -->
    <!-- #region structured-data -->
    <script type="application/ld+json">
      {
        "@context": "https://schema.org",
        "@graph": [
          {
            "@type": "Person",
            "@id": "https://theyard.stevenstout.biz/about#steven-stout",
            "name": "Steven Stout",
            "jobTitle": "Lead / Staff .NET Engineer",
            "url": "https://theyard.stevenstout.biz/",
            "sameAs": [
              "https://www.linkedin.com/in/stevenwstout/",
              "https://github.com/SteveStout/TheYard"
            ],
            "address": {
              "@type": "PostalAddress",
              "addressLocality": "Saint Charles",
              "addressRegion": "Missouri",
              "addressCountry": "US"
            }
          },
          {
            "@type": "SoftwareSourceCode",
            "name": "TheYard",
            "description": "A used-vehicle auction platform: browse 100,000 listings, inspect a vehicle, and bid against a simulated room.",
            "url": "https://theyard.stevenstout.biz/",
            "codeRepository": "https://github.com/SteveStout/TheYard",
            "programmingLanguage": ["C#", "TypeScript"],
            "runtimePlatform": [".NET 10", "React 19", "Azure App Service", "Azure SQL Database", "Azure Cosmos DB"],
            "license": "https://github.com/SteveStout/TheYard/blob/main/LICENSE",
            "author": { "@id": "https://theyard.stevenstout.biz/about#steven-stout" }
          }
        ]
      }
    </script>
    <!-- #endregion structured-data -->
    <!-- #region search-console-slot -->
    <!--
      Search Console's HTML-tag check. The token is a GitHub repository variable,
      GOOGLE_SITE_VERIFICATION, public by design, passed to the image build, and
      the Dockerfile writes the meta tag in place of the line below only when it
      is set; unset, the line stays a comment and nothing is claimed.
    -->
    <!-- google-site-verification -->
    <!-- #endregion search-console-slot -->
  </head>
  <body>
    <!--
      The frontend build writes the landing page into this element and marks it
      data-drawn (ADR: The landing page rendered at build time, and the Vite
      config's prerender step), so the bare address paints before any script has
      run. Left empty here, so the development server draws everything in the
      browser as before.
    -->
    <!-- #region root -->
    <div id="root"></div>
    <!-- #endregion root -->
    <!--
      For a reader that does not run JavaScript, which is most AI tools and some
      crawlers: who built this, what it is, and where the words are, as plain HTML.
      Every link is an address the site serves without the app.
    -->
    <noscript>
      <main>
        <h1>TheYard, by Steven Stout</h1>
        <p>
          A used-vehicle auction platform that Steven Stout, a Lead / Staff .NET engineer, built and
          runs on Azure: 100,000 vehicles, live bidding, and the same build on Azure SQL Database and
          on Azure Cosmos DB, in .NET 10 and React.
        </p>
        <ul>
          <li><a href="/about">About Steven Stout</a></li>
          <li><a href="/api/docs/readme">The README</a></li>
          <li><a href="/api/docs/architecture">Architecture</a></li>
          <li><a href="/api/docs/performance">Performance</a></li>
          <li><a href="/api/reference">The API reference</a></li>
          <li><a href="/llms.txt">An index of every document, for AI tools</a></li>
          <li><a href="https://github.com/SteveStout/TheYard">The code on GitHub</a></li>
        </ul>
      </main>
    </noscript>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
```
*Live from [`index.html`](https://github.com/SteveStout/TheYard/blob/1d6b50b/index.html#L1-L217), region `*`, read from this build at 1d6b50b.*

### main.tsx: the lines that start React

`main.tsx` reads like `Program.cs`: one line per part, in order, with the file that holds the
details named beside it. It loads the stylesheets, keeps the operator's key before the address bar
is tidied, starts reporting the crashes an error boundary never sees, and draws the site.

```tsx
// The entry point for the site. It reads like a table of contents: each line starts one part, in
// order, and the comment beside it names the file that holds the details. Program.cs does the
// same for the API.
import './styles/globalStyles';
import { mountTheYard } from './app/mount';
import { reportUncaughtErrors } from './app/reportUncaughtErrors';
import { captureAdminKey } from './lib/adminKey';

// #region bootstrap
// styles/globalStyles.ts: the site-wide stylesheets, loaded by the import above, in order
captureAdminKey(); // lib/adminKey.ts: keep the operator's key before the address bar is tidied
reportUncaughtErrors(); // app/reportUncaughtErrors.ts: report crashes an error boundary never sees
mountTheYard('root'); // app/mount.tsx: draw the site into <div id="root"> in index.html
// #endregion bootstrap
```
*Live from [`src/main.tsx`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/main.tsx#L1-L14), region `*`, read from this build at 1d6b50b.*

The stylesheets load in one fixed order, from `styles/globalStyles.ts`: the font first, then the
four design token files, so every variable exists before a component's styles use it, then
`base.css`, the page's defaults, and the two shared looks.

```ts
// The order matters. fonts.css declares the face before anything asks for it. colors, sizes,
// typography and effects define every design value as a variable, each file named for what it
// controls. base.css sets the page's defaults from those variables. panels.css and
// code-highlight.css draw shared looks in the same variables, so they come after them.
// drawn-page.css shows the right frame while a page drawn at build time is taken over.
import './fonts.css';
import './colors.css';
import './sizes.css';
import './typography.css';
import './effects.css';
import './base.css';
import './panels.css';
import './code-highlight.css';
import './drawn-page.css';
```
*Live from [`src/styles/globalStyles.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/styles/globalStyles.ts#L8-L21), region `load-order`, read from this build at 1d6b50b.*

`app/mount.tsx` draws the site. `createRoot` is the React 18 and 19 way to mount; the older
`ReactDOM.render` is gone. `StrictMode` costs nothing in production; in development it mounts,
unmounts and remounts every component once, so every effect's setup runs twice, which flushes out
effects that leak a timer or a listener. The throw on a missing `#root` turns a blank page into a
message.

```tsx
/**
 * Finds the element index.html holds for the site and puts the site in it.
 *
 * Two kinds of page arrive already drawn. The rendering service draws the page
 * for any address and writes what it read beside it (ADR: A rendering service
 * beside the API); React takes that over with hydrateRoot from the same answers.
 * The build draws the landing page into index.html (ADR: The landing page
 * rendered at build time); on the bare address React takes that over the same
 * way. Every other address on a page the service did not draw opens on another
 * view, so the drawn landing page is not what it wants; index.html keeps it
 * hidden there, and this clears it and draws the view from nothing with
 * createRoot, as the site always did.
 * @param elementId the id of that element, "root" in index.html
 */
export function mountTheYard(elementId: string): void {
  // A missing element would leave a blank page with no reason given; throwing names the problem.
  const root = document.getElementById(elementId);
  if (!root) throw new Error(`Missing #${elementId} element`);

  const drawn = root.getAttribute('data-drawn');
  root.removeAttribute('data-drawn');
  const firstLoad = drawn === 'server' ? readFirstLoad() : null;
  if (firstLoad && firstLoad.search === window.location.search.replace(/^\?/, '')) {
    hydrateRoot(root, <TheYard firstLoad={firstLoad} />);
    return;
  }
  if (drawn === 'landing' && window.location.search === '') {
    hydrateRoot(root, <TheYard />);
    return;
  }
  root.replaceChildren();
  createRoot(root).render(<TheYard />);
}
```
*Live from [`src/app/mount.tsx`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/mount.tsx#L12-L44), region `mount`, read from this build at 1d6b50b.*

### vite.config.ts: the dev server and the proxy

`plugins: [react()]` gives the JSX transform and Fast Refresh, which swaps
an edited component into the running page without losing its state. The
proxy is the part that matters for a newcomer: the browser asks the Vite
server for `/api/...` and Vite forwards it to the .NET API on port 5210,
so the page and the API share one origin and the API needs no CORS
configuration at all. The preview server gets the same proxy or `npm run
preview` breaks. The `watch.ignored` line keeps Vite's file watcher out of
the .NET build output, where locked files crash it on Windows.

```ts
// The .NET API (api/) owns /api: data and vehicle photos. Proxying keeps the
// browser same-origin, so the API needs no CORS configuration. The preview
// server needs the same proxy or `npm run preview` breaks.
const apiProxy = {
  '/api': 'http://localhost:5210',
  // The page about him is the API's too (api/TheYard.Api/AboutPage.cs), outside /api.
  '/about': 'http://localhost:5210',
  // And the resume at its own address (ResumeDoor.cs), exactly /resume with or without its query.
  '^/resume(\\?.*)?$': 'http://localhost:5210',
};

export default defineConfig({
  plugins: [react(), preloadTheFonts(), drawTheLandingPage()],
  server: {
    proxy: apiProxy,
    // Keep Vite's file watcher out of the .NET build output, because dotnet holds
    // locks on those files, which crashes the watcher on Windows (EBUSY).
    watch: {
      ignored: ['**/api/**'],
    },
  },
  preview: {
    proxy: apiProxy,
  },
```
*Live from [`vite.config.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/vite.config.ts#L144-L167), region `dev-server`, read from this build at 1d6b50b.*

Vitest reads its settings from the same file, under `test`. The include
pattern keeps it to the unit tests, and the one CSS entry lets
`tokens.test.ts` read the palette file raw to measure its contrast.

```ts
// Vitest reads its settings from the same file as the dev server, which is
// why there is no vitest.config.ts. The include pattern keeps it to the unit
// tests, and the one CSS entry exists because Vitest blanks CSS imports it
// is not told to process, which would leave the palette test with nothing to
// measure.
test: {
  // Unit tests only; tests/e2e belongs to Playwright. render/ holds the rendering service's own.
  include: ['src/**/*.test.ts', 'render/**/*.test.ts'],
  // allowOnly is deliberately not set. Vitest already defaults it to
  // !process.env.CI, which is the rule this project wants: a committed
  // `it.only` turns a suite into one test and still reports green, so CI
  // refuses one while a developer debugging a single test can focus it.
  // Writing the default out would need @types/node in a project that has
  // never needed it, for one boolean. What is checked instead is that
  // nothing here turns it off (ADR: Broken windows, and the rule that
  // answers them).
  // Vitest blanks CSS imports it is not told to process. These are read as
  // text: the four token sheets (through src/lib/styleSheet.ts, for the
  // contrast tests and the swatches), the panels sheet (panels.test.ts), the
  // ribbon sheet (ribbons.test.ts) and every component sheet (icons.test.ts,
  // for a stroke written as a number of its own).
  css: {
    include: [
      /(colors|sizes|typography|effects)\.css\?raw$/,
      /panels\.css\?raw$/,
      /Ribbons\.module\.css\?raw$/,
      /(components|library)\/.+\.module\.css\?raw$/,
    ],
  },
},
```
*Live from [`vite.config.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/vite.config.ts#L170-L199), region `unit-tests`, read from this build at 1d6b50b.*

### Three tsconfig files, and why not one

The root `tsconfig.json` compiles nothing itself (`files: []`); it points
at two projects, and `tsc -b` builds both. Editors use the same references
to pick the right settings for whichever file is open.

```json
{
  "files": [],
  "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
```
*Live from [`tsconfig.json`](https://github.com/SteveStout/TheYard/blob/1d6b50b/tsconfig.json#L1-L4), region `*`, read from this build at 1d6b50b.*

`tsconfig.app.json` is for the browser code under `src/`. Read its options
in groups. `target` and `lib` say which JavaScript and which browser APIs
the code may assume (`DOM.Iterable` is what lets a `NodeList` be spread).
`module: ESNext` with `moduleResolution: bundler` tells the compiler that
Vite, not Node, will resolve imports, which allows extensionless paths;
`resolveJsonModule` lets a JSON file be imported as typed data.
`allowImportingTsExtensions` permits `./x.ts` in an import and requires
`noEmit`, which is true anyway because Vite emits.
`verbatimModuleSyntax` forces `import type` for types, so Vite can drop
type-only imports file by file without a whole-program view.
`jsx: react-jsx` is the automatic runtime: no `import React` at the top
of every component. `strict` plus the `noUnused` pair keep dead code from
accumulating, and `noUncheckedSideEffectImports` makes a bare
`import './styles/tokens.css'` an error when the file does not exist,
which used to fail silently. `tsBuildInfoFile` keeps the incremental state
under `node_modules/.tmp`, out of the tree.

```json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "resolveJsonModule": true,

    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src", "render"],
  "exclude": ["render/vite.config.ts"]
}
```
*Live from [`tsconfig.app.json`](https://github.com/SteveStout/TheYard/blob/1d6b50b/tsconfig.app.json#L1-L26), region `*`, read from this build at 1d6b50b.*

`tsconfig.node.json` exists because `vite.config.ts` runs in Node during
the build, not in a browser: it gets a newer language target and no `DOM`
library, so a stray `window` in the config is a compile error.

```json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
    "target": "ES2023",
    "lib": ["ES2023"],
    "module": "ESNext",
    "skipLibCheck": true,

    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,

    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["vite.config.ts", "render/vite.config.ts"]
}
```
*Live from [`tsconfig.node.json`](https://github.com/SteveStout/TheYard/blob/1d6b50b/tsconfig.node.json#L1-L22), region `*`, read from this build at 1d6b50b.*

One more file belongs here. `src/vite-env.d.ts` is a single reference to
Vite's client types, and it is what teaches the compiler that
`import styles from './X.module.css'` yields an object, that `?raw` on an
import yields a string, and what `import.meta.env` holds.

### Styling: CSS modules over a token sheet

Every component that has styles has a `Name.module.css` beside it. Vite
hashes the class names, so `styles.card` in one component can never
collide with `.card` in another and nobody has to invent a naming scheme.
The colors, spacing and type all come from `src/styles/tokens.css` as
custom properties, and a test measures every text and ground pair against
WCAG AA (ADR: The palette). A CSS framework would add a dependency to
solve problems this app does not have: one palette, one font, and a
component count you can hold in your head.

### No router: the address bar is the state

Everything the visitor is looking at is in the URL: the filters and sort
as GET parameters, `?vehicle={id}` for a detail page, `?view=admin` for
the Admin tab. Two functions in `inventory.ts` translate between the
filter model and the query string, using the same parameter names the API
takes, so the address bar mirrors the request.

```ts
/**
 * The browser-facing filter serialization: the same names the API takes, so
 * the address bar mirrors the API request. Used for both the URL bar and the
 * actual fetch; see data.ts.
 */
export function filtersToSearchParams(
  filters: InventoryFilters,
  sort: SortKey = 'ending-soonest'
): URLSearchParams {
  const params = new URLSearchParams();
  const query = filters.query.trim();
  if (query) params.set('q', query);
  if (filters.make) params.set('make', filters.make);
  if (filters.bodyStyle) params.set('body_style', filters.bodyStyle);
  if (filters.titleStatus) params.set('title_status', filters.titleStatus);
  if (filters.province) params.set('province', filters.province);
  if (filters.status) params.set('status', filters.status);
  if (filters.minCondition !== null) params.set('min_condition', String(filters.minCondition));
  if (filters.priceMin !== null) params.set('price_min', String(filters.priceMin));
  if (filters.priceMax !== null) params.set('price_max', String(filters.priceMax));
  if (sort !== 'ending-soonest') params.set('sort', sort);
  return params;
}

/**
 * Whether an address opens the inventory rather than the landing page (the
 * landing page is home). An address that already meant the
 * inventory keeps meaning it, so every link shared before the landing page
 * existed still lands where it did: any filter or sort, an open vehicle, or
 * ?view=inventory itself.
 */
export function opensInventory(params: URLSearchParams): boolean {
  if (params.get('view') === 'inventory' || params.has('vehicle')) return true;
  const { filters, sort } = filtersFromSearchParams(params);
  return filtersToSearchParams(filters, sort).toString() !== '';
}

/** Restores filter state from URL parameters, discarding anything invalid. */
export function filtersFromSearchParams(params: URLSearchParams): {
  filters: InventoryFilters;
  sort: SortKey;
} {
  const bound = (name: string): number | null => {
    const raw = params.get(name);
    if (raw === null || raw.trim() === '') return null;
    const value = Number(raw);
    return Number.isFinite(value) && value >= 0 ? value : null;
  };
  const status = params.get('status');
  const sort = params.get('sort');
  return {
    filters: {
      query: params.get('q') ?? '',
      make: params.get('make') ?? '',
      bodyStyle: params.get('body_style') ?? '',
      titleStatus: params.get('title_status') ?? '',
      province: params.get('province') ?? '',
      status: status === 'live' || status === 'upcoming' || status === 'ended' ? status : '',
      minCondition: bound('min_condition'),
      priceMin: bound('price_min'),
      priceMax: bound('price_max'),
    },
    sort: SORT_OPTIONS.some((option) => option.value === sort)
      ? (sort as SortKey)
      : 'ending-soonest',
  };
}
```
*Live from [`src/lib/inventory.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/inventory.ts#L47-L113), region `url-state`, read from this build at 1d6b50b.*

`App.tsx` keeps the URL current with `replaceState`, so typing in a filter
does not pile up history entries. Opening a tile is different: it pushes
an entry so the browser's Back button closes the detail page, and a
deep-linked visit that has no list entry behind it swaps the URL in place
instead.

```ts
// Mirror the current view into the address bar: the filter GET parameters
// plus ?vehicle={id} when a detail page is open. replaceState here, because
// typing shouldn't pile up history entries; opening a tile pushes its own
// entry (openVehicle, in useNavigation.ts) so the browser's Back button
// closes the detail view.
// A vehicle the rendering service already read is no longer pending: it is on the page.
const deepLinkPending = useRef(first.vehicleId !== null && !firstLoad?.vehicle);
useEffect(() => {
  if (deepLinkPending.current) return;
  // The landing page carries no filters: they belong to the inventory, and an
  // address with one in it opens the inventory (opensInventory).
  const onLanding = !inventoryOpen && !selectedVehicle && !adminOpen && !accountOpen;
  const params = onLanding ? new URLSearchParams() : filtersToSearchParams(filters, sort);
  if (selectedVehicle) params.set('vehicle', selectedVehicle.id);
  const listOnly = inventoryOpen && !selectedVehicle && !adminOpen && !accountOpen;
  if (listOnly && !opensInventory(params)) params.set('view', 'inventory');
  if (adminOpen) {
    params.set('view', 'admin');
    // The Overview is the card ?view=admin opens, so it is the one card the address leaves unnamed.
    if (adminCard !== DEFAULT_CARD) params.set('card', adminCard);
    if (adminPin !== null) params.set('pin', adminPin);
  }
  if (accountOpen) params.set('view', 'account');
  if (openDocKey) params.set('doc', docSlug(openDocKey));
  const query = params.toString();
  window.history.replaceState(
    window.history.state,
    '',
    query ? `?${query}` : window.location.pathname
  );
}, [
  filters,
  sort,
  selectedVehicle,
  adminOpen,
  adminCard,
  adminPin,
  accountOpen,
  openDocKey,
  inventoryOpen,
]);
```
*Live from [`src/app/hooks/useAddressBar.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/hooks/useAddressBar.ts#L80-L120), region `url-mirror`, read from this build at 1d6b50b.*

```ts
// Open the detail at the top; restore the list scroll position on back.
const listScrollY = useRef(0);
const openVehicle = (vehicle: Vehicle) => {
  listScrollY.current = window.scrollY;
  // Real GET navigation: push a history entry so the browser's Back works.
  const params = filtersToSearchParams(filters, sort);
  params.set('vehicle', vehicle.id);
  address.push({ viaTile: true }, params);
  setInventoryOpen(true);
  setSelectedVehicle(vehicle);
};
/** From the account page's bid list: open that vehicle's detail view. */
const openVehicleById = (vehicleId: string) => {
  void fetchVehicleById(vehicleId).then((vehicle) => {
    if (!vehicle) return;
    setAccountOpen(false);
    openVehicle(vehicle);
  });
};
/** What the entry that opened this view recorded, read when the view draws its back button. */
const backTo = (): BackTo => (address.entry()?.from === 'home' ? 'home' : 'inventory');
const backToInventory = () => {
  // If we pushed this entry, going back keeps history clean; a deep-linked
  // visit has no list entry behind it, so just swap the URL in place.
  if (address.entry()?.viaTile) {
    address.stepBack();
    return;
  }
  setSelectedVehicle(null);
  setInventoryOpen(true);
  address.replaceQuery(filtersToSearchParams(filters, sort));
};
```
*Live from [`src/app/hooks/useNavigation.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/hooks/useNavigation.ts#L74-L105), region `history`, read from this build at 1d6b50b.*

Back and Forward re-read the whole view from the URL, which is the only
place it lives.

```ts
// Browser Back/Forward: re-read the whole view from the URL. The listener is
// registered once, so it reads the open vehicle through selectedIdRef.
const selectedIdRef = useRef<string | null>(null);
useEffect(() => {
  selectedIdRef.current = selectedVehicle?.id ?? null;
}, [selectedVehicle]);
useEffect(() => {
  const onPopState = () => {
    const params = new URLSearchParams(window.location.search);
    const restored = filtersFromSearchParams(params);
    setFilters(restored.filters);
    setSort(restored.sort);
    setAdminOpen(params.get('view') === 'admin');
    const card = cardFromAddress(params.get('card'));
    setAdminCard(card.slug);
    setAdminPin(pinFromAddress(params.get('pin')));
    setCardAsked(card.known ? null : card.asked);
    setAccountOpen(params.get('view') === 'account');
    setInventoryOpen(opensInventory(params));
    setOpenDocKey(docKeyForSlug(params.get('doc')));
    const vehicleId = params.get('vehicle');
    if (!vehicleId) {
      setSelectedVehicle(null);
      return;
    }
    if (vehicleId === selectedIdRef.current) return;
    fetchVehicleById(vehicleId)
      .then((vehicle) => setSelectedVehicle(vehicle))
      .catch(() => setSelectedVehicle(null));
  };
  window.addEventListener('popstate', onPopState);
  return () => window.removeEventListener('popstate', onPopState);
}, []);
```
*Live from [`src/app/hooks/useAddressBar.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/hooks/useAddressBar.ts#L150-L182), region `back-forward`, read from this build at 1d6b50b.*

A router would add a dependency to do what these functions do, and it
would still need this logic for the parameters. The visitor's own
settings are the exception: the collapsed rail is a preference, not a
view, so it lives in `localStorage` (ADR: The sidebar).

### data.ts: one seam to the API

Every `fetch` for the inventory is in one file; the account calls, the
store list, the Admin tab, the error boundary, the document viewer and the
build stamp each read their own endpoint, because a cache and a debounce
built for the listing would be the wrong tool for a health check or a
sign-in. `fetchVehicles` keys a small
cache by the query string (five minutes, thirty entries, the oldest
evicted first), takes an `AbortSignal` so an effect's cleanup can cancel a
request the visitor has already typed past, and `forceRefresh` bypasses
the cache for the retry buttons and the status interval. Bids and the
reset clear the cache because they change what the server would answer.
Every request also carries the buyer's local midnight as `anchor_ms`,
which the auction schedule depends on; the URL bar leaves that out because
it is clock plumbing, not user state.

```ts
// The one function every list view goes through. Three things share this seam:
// a cache keyed by the exact query string and the UTC day (a filter
// combination revisited renders instantly, and nothing cached before the
// server re-seeds at UTC midnight is served after it), an AbortSignal so an
// effect's cleanup can cancel a request the visitor has already typed past,
// and forceRefresh for the paths that must not read a cache (retry buttons,
// the periodic status refresh).
export interface FetchVehiclesOptions {
  sort?: SortKey;
  offset?: number;
  signal?: AbortSignal;
  /** Skip the cache and overwrite it with a fresh response. */
  forceRefresh?: boolean;
}

export async function fetchVehicles(
  filters?: InventoryFilters,
  { sort, offset = 0, signal, forceRefresh = false }: FetchVehiclesOptions = {}
): Promise<VehiclePage> {
  const query = queryString(filters, sort, offset);
  const key = cacheKey(query);

  if (!forceRefresh) {
    const hit = cachedPage(key);
    if (hit) {
      return hit;
    }
  }

  const response = await fetch(`/api/vehicles${query ? `?${query}` : ''}`, { signal });
  if (!response.ok) {
    throw new Error(
      await problemDetail(response, `The inventory API responded with ${response.status}`)
    );
  }
  const page = (await response.json()) as VehiclePage;

  // Re-inserting moves the key to the back, so eviction drops the stalest.
  queryCache.delete(key);
  queryCache.set(key, { at: Date.now(), page });
  if (queryCache.size > CACHE_MAX_ENTRIES) {
    const oldest = queryCache.keys().next().value;
    if (oldest !== undefined) queryCache.delete(oldest);
  }
  return page;
}
```
*Live from [`src/lib/data.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/lib/data.ts#L140-L185), region `fetch-vehicles`, read from this build at 1d6b50b.*

### lib, hooks, components

`src/lib` is plain TypeScript with no React import: the auction schedule,
the formatting, the filter model, the API seam. That is why the unit tests
run in milliseconds and need no browser. `src/hooks` holds React state
that more than one component needs: `useNow` is one clock at the app root
so every countdown ticks together, `useBids` is the buyer's standing,
`useMediaQuery` is the phone breakpoint. `src/components` render; each
takes props and owns a stylesheet. `App.tsx` composes them and owns the
three things that must be in one place: the fetch effect, the URL mirror,
and the selected vehicle.

### Two test runners, on purpose

Vitest runs `src/**/*.test.ts`: pure functions, no DOM, in the CI job
before the build. Playwright runs `tests/e2e` in a real Chrome against
both servers, which its config starts itself, so `npm run test:e2e` needs
nothing running beforehand. The CI job installs that Chrome with
`npx playwright install --with-deps chrome`.

```ts
// Playwright starts both servers itself, so `npm run test:e2e` needs nothing
// running first, and reuses ones already up, so it also works mid-development.
// Each server is considered ready when a real URL answers, not after a sleep.
webServer: [
  {
    // The ship gate has just built the API, so it says so and the server
    // starts without building it again, ten seconds twice per gate
    // (ADR: The five-minute gate). Everywhere else the build is wanted.
    command: process.env.YARD_API_PREBUILT
      ? 'dotnet run --no-build --project api/TheYard.Api'
      : 'npm run api',
    // The simulated room waits twenty seconds before answering a bid
    // (ADR-027). Zero here so the outbid test watches a lead change hands
    // instead of watching a clock. Nothing else sets this.
    // And a key for the visitor rows on the Admin tab, so the browser
    // suite can prove the table exists behind it and nowhere else
    // (ADR: Site activity, and the line an address does not cross).
    env: {
      Market__GraceSeconds: '0',
      Admin__Key: 'e2e-admin-key',
      Admin__VisitorRows: 'true',
      Logs__DrainSeconds: '2',
    },
    url: 'http://localhost:5210/api/facets',
    reuseExistingServer: true,
    timeout: 120_000,
  },
  {
    // The ship gate has just run vite build, so it serves that bundle with
    // vite preview: one script and one sheet per page instead of Vite
    // compiling and serving the module graph file by file to every page the
    // suite opens (ADR: The five-minute gate). Everywhere else, the dev server.
    command: process.env.YARD_WEB_PREVIEW
      ? 'npx vite preview --port 5173 --strictPort'
      : 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: true,
    timeout: 120_000,
  },
  // The rendering service (ADR: A rendering service beside the API), when its build is made
  // (npm run build:render) and asked for: in front of the built site above, which stands in for
  // the API it reads and for the edge, so a page it draws is taken over by the bundle that site
  // serves. drawn.spec.ts opens it on 5220 and skips those pages when it is not running.
  ...(process.env.YARD_RENDER
    ? [
        {
          command: 'node render/server.mjs',
          env: {
            PORT: '5220',
            YARD_SITES: 'sql=http://localhost:5173',
            YARD_PASS_ORIGIN: 'http://localhost:5173',
            // The suite's machine runs four browsers, the API and the site at once, and a list read
            // there can pass the live deadline; the pages here are drawn with every read in.
            YARD_DEADLINE_MS: '20000',
          },
          url: 'http://localhost:5220/readyz',
          reuseExistingServer: true,
          timeout: 120_000,
        },
      ]
    : []),
],
```
*Live from [`playwright.config.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/playwright.config.ts#L70-L131), region `web-servers`, read from this build at 1d6b50b.*

### The same build, in the container

The Dockerfile's first stage runs the same `npm ci` and `npm run build` in
Node 22 and hands the `dist` folder to the API image as `wwwroot`, where
the API serves it with the SPA fallback (ADR: Program.cs, explained). The
three tsconfig files are copied in because `tsc -b` needs them; a build
that passed locally and fails in the image usually means a file the stage
never received.

```dockerfile
# Stage 1: build the frontend in a Node environment.
FROM mirror.gcr.io/library/node:24-alpine AS frontend-build
# This gives us a standard Node toolchain for Vite and keeps the build dependencies isolated from the final runtime image.
WORKDIR /src

# Copy only the package manifests first so dependency restore can be cached independently from the app source.
COPY package*.json ./
# `npm ci` is deterministic because it uses the existing lockfile and is the correct choice for container builds.
RUN npm ci

# Copy the frontend configuration and source after dependencies are restored so a source-only change does not invalidate the restore cache.
COPY index.html ./
COPY tsconfig.json ./
COPY tsconfig.app.json ./
COPY tsconfig.node.json ./
COPY vite.config.ts ./
COPY src ./src
# Vite copies everything in public/ to the root of the built site untouched, so
# this is where robots.txt, sitemap.xml and the link preview image come from.
# It is easy to leave out, and leaving it out fails in the one place nobody
# looks: the built site loses those files while every local check stays green,
# because locally the folder is simply there (ADR: The second manifest).
COPY public ./public

# Build the production bundle that will be served by the ASP.NET host.
RUN npm run build

# #region stamp-the-build
# Two things the built site learns only here. The sitemap's /about entry takes
# the day this image was built as its lastmod, in place of the placeholder
# public/sitemap.xml carries; APP_VERSION is declared first so every version
# runs this step again rather than keeping a cached day. And Search Console's
# HTML-tag check: GOOGLE_SITE_VERIFICATION is a repository variable, public by
# design, and when it is set its meta tag replaces the comment index.html keeps
# for it. A token is letters, digits, underscores and hyphens and nothing else,
# so anything else stops the build rather than reaching the page.
ARG APP_VERSION=dev
ARG GOOGLE_SITE_VERIFICATION=
RUN set -e; \
    sed -i "s/__BUILD_DATE__/$(date -u +%Y-%m-%d)/" dist/sitemap.xml; \
    if grep -q "__BUILD_DATE__" dist/sitemap.xml; then echo "the sitemap kept its placeholder" >&2; exit 1; fi; \
    if [ -n "${GOOGLE_SITE_VERIFICATION}" ]; then \
      case "${GOOGLE_SITE_VERIFICATION}" in *[!A-Za-z0-9_-]*) echo "GOOGLE_SITE_VERIFICATION holds a character no token has" >&2; exit 1;; esac; \
      sed -i "s|<!-- google-site-verification -->|<meta name=\"google-site-verification\" content=\"${GOOGLE_SITE_VERIFICATION}\" />|" dist/index.html; \
      grep -q "name=\"google-site-verification\"" dist/index.html; \
    fi; \
    echo "stamped: $(grep -o '<lastmod>[^<]*</lastmod>' dist/sitemap.xml) for ${APP_VERSION}"
# #endregion stamp-the-build
```
*Live from [`Dockerfile`](https://github.com/SteveStout/TheYard/blob/1d6b50b/Dockerfile#L4-L51), region `frontend-build`, read from this build at 1d6b50b.*

## What to change when

- **A new component:** `Name.tsx` and `Name.module.css` in
  `src/components`, colors from the tokens, props in and nothing fetched.
- **A new API call for the inventory:** one function in `data.ts`, so it
  shares the cache, the debounce and the abort signal; a call for another
  concern lives in that concern's file under `src/lib`, never in a component.
- **A new piece of view state:** if it describes what the visitor is
  looking at, put it in the URL through `filtersToSearchParams` and its
  reader; if it is the visitor's own setting, `localStorage`.
- **A new dependency:** ask whether a file in `lib` would do. The three
  runtime dependencies are three on purpose.
- **A new compiler option:** `tsconfig.app.json` for browser code,
  `tsconfig.node.json` for the config; CI runs `tsc -b` over both before
  anything is built.

## Addendum, 2026-09-28 (1.0.3.36): two files split by job

Until 1.0.3.35 two files did most of the frontend's work, and a newcomer had to read
all of either one to find the part they came for. `src/App.tsx` was 1,100 lines doing
seven jobs: it read and wrote the address bar, fetched the vehicle list, opened and
closed every view, knew who was signed in, placed bids, docked the rail, and drew the
header and the footer. `src/components/docs/DocsMenu/DocsMenu.tsx` was 1,308 lines and
held no menu at all (the rail draws the menu): a list of 106 document names typed by
hand, every document as data, what each sidebar section holds, a document's address,
and the window a document opens in.

They are two folders now. Every file in them does one job, its name says which, and
its first three lines say it again.

### src/app: the app shell

| File | What it does |
| --- | --- |
| `App.tsx` | Joins the pieces and picks which view shows. It holds no logic of its own. |
| `Shell.tsx` | The frame every page sits in: the skip link, the rail, the store bar, main, the footer. |
| `Header.tsx` | The header a phone shows: the bolt, The Yard, Reset bids, the resume, the menu button. |
| `Footer.tsx` | The version line. |
| `InventoryView.tsx` | The inventory page: the filter bar, the grid, Load more, and the loading and unreachable notices. |
| `hooks/useAddressBar.ts` | Holds the open view and keeps the address bar in step with it, both ways. The only code that writes the address bar. |
| `hooks/useNavigation.ts` | Opens and closes each view, moves focus, and says the change to a screen reader. |
| `hooks/useInventory.ts` | The vehicle list: fetched only when shown, filtered, paged, retried, with this visitor's bids on it. |
| `hooks/useListingRefresh.ts` | Asks for the list again when an auction starts or ends, never while nobody can see it. |
| `hooks/useOpenVehicle.ts` | The vehicle on screen: keeps its price current, places a bid, buys it now. |
| `hooks/useAccount.ts` | Who is signed in, asked of the API. |
| `hooks/useRail.ts` | Docked, collapsed or a drawer, and remembering it. |
| `hooks/useRunningBuild.ts` | Which build is running, asked of the API once; the rail and the footer both show it. |

A hook is named for what it gives back, so `App.tsx` reads top to bottom like a table
of contents: one line per hook with a comment saying what it holds, then the frame
with one view inside it.

```tsx
const address = useAddressBar(); // which view the address names; the only code that writes it
const { account, changeAccount } = useAccount(); // who is signed in
const inventory = useInventory(address, account.email); // the vehicle list, with this visitor's bids on it
const vehicle = useOpenVehicle(address, inventory); // the vehicle on screen
const rail = useRail(); // the sidebar: docked, collapsed, or a drawer
const go = useNavigation(address, inventory.loadState); // open and close views, move focus, announce
const build = useRunningBuild(); // the version line, asked of the API
const now = useNow(1000, useFirstLoad()?.nowMs); // the clock every countdown reads
const inBrowser = useInTheBrowser(); // Admin and the account page draw only here, never on a server
```
*Live from [`src/app/App.tsx`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/App.tsx#L38-L46), region `table-of-contents`, read from this build at 1d6b50b.*

### src/library: the documents the site serves

| File | What it does |
| --- | --- |
| `records.ts` | Every decision record, numbered, as data. |
| `pages.ts` | Every other document: the overviews, the Bicep file, the changelog, the Author page. |
| `documents.ts` | Joins the two into the one list every document is looked up in. |
| `sections.ts` | What each sidebar section holds. The sections' order and icons stay in `src/lib/siteMap.ts`. |
| `addresses.ts` | A document's address (`?doc=slug`) and back again. |
| `DocDialog.tsx` | The window a document opens in: it asks the API, renders, lays out the panels, and copies the link. |

The hand-typed list of names is gone. `DocKey` is worked out by TypeScript from the
two lists, so a document cannot be named in code without existing, and adding one is
one entry in `records.ts` or `pages.ts`:

```ts
/**
 * The name of a document in code, such as 'adrLockout' or 'readme'. It is
 * worked out from the two lists rather than typed out a second time, so a
 * document cannot be named without existing, and adding one to either list
 * is the whole of adding its name.
 */
export type DocKey = keyof typeof RECORDS | keyof typeof PAGES;

/** A name in both lists would be one document hiding the other, so this line stops compiling if there is one. */
export const NO_NAME_IN_BOTH: [Extract<keyof typeof RECORDS, keyof typeof PAGES>] extends [never]
  ? true
  : never = true;
```
*Live from [`src/library/documents.ts`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/library/documents.ts#L22-L33), region `doc-key`, read from this build at 1d6b50b.*

The folder is `library`, not `docs`, because the repository root already has `docs/`
for the markdown, and two folders called docs would send a new developer to the wrong
one.

### The header every file opens with

```ts
/**
 * Does:      Holds the open view (the page, the vehicle, the Admin card, the document, the filters and sort)
 *            and keeps the address bar in step with it, both ways: the first-load reads, deep links, Back and Forward.
 * Does not:  Decide when a view opens (useNavigation.ts does), fetch the vehicle list, or draw anything.
 * Used by:   App.tsx, useNavigation.ts, useInventory.ts, useOpenVehicle.ts.
 */
```

"Does not" is there because the question a reader brings is usually "is it in here?",
and the fastest answer is the file saying where it went instead. `FileHeaderTests`
fails any file in either folder that ships without the three lines, reads "Used by"
against the files that really import it, so the line cannot go stale, and holds every
file to 300 lines except the ones it names with why: `records.ts`, which is data, and
the two stylesheets that moved whole (ADR: The rules a change has to pass lists it).

### What did not change

Nothing a visitor sees: the same pages, the same addresses, the same Back and Forward.
Every region moved with its code under the same name, so every record above that shows
one shows the same lines from its new file. Every existing test passes with only paths
changed. The lazy chunks keep their names (`AdminPanel`, `markdown`, `author`), because
each lazy import still names the same file.

Two lines read differently, because the linter reads small files more closely than it
read the large one. The rail now closes its drawer while it renders, the moment the
window crosses the docking line, instead of in an effect after the paint; and moving
focus to `<main>` finds it by its id, the same id the skip link names, instead of
through a ref handed down the tree.

Where the plan had six hooks there are seven, and one more file on each side: the list
refresh has a file of its own because the list's hook would have been 350 lines with
it, the running build has one because the rail and the footer both show it,
`InventoryView.tsx` keeps the list's markup out of `App.tsx`, and `documents.ts` is
where the two lists are joined.

### Where a change goes now

- **A new view:** its flag in `useAddressBar.ts`, its open and close in
  `useNavigation.ts`, one branch in `App.tsx`.
- **Something the whole app knows:** a hook in `src/app/hooks`, named for what it
  gives back, with the header.
- **A new document:** the markdown in `docs/`, its slug in `DocumentationCatalog.cs`, its entry
  in `records.ts` or `pages.ts`, and its row in `sections.ts`.

## Addendum, 2026-10-02: the records in three runs

`records.ts` was the one file the 300-line rule let through, because it is data. It is now the list
that joins three runs of records in number order, `src/library/decisionRecords/records001to030.ts`,
`records031to060.ts` and `records061to087.ts`, each under the line, so no file is exempt for being
data any more. A new record goes at the end of the last run. A type check in `records.ts` fails the
compile if one key appears in two runs, and `DocumentationCatalogTests` reads every run, so a run
that `records.ts` does not join fails the build too.

## Where it sits

The React configuration is the front end, layered: components render, hooks hold state, and src/lib is plain TypeScript that imports nothing from React, so dependencies point inward the same way the server's onion does. One job per file under src/app and src/library is single responsibility, held by FileHeaderTests with a 300-line cap; data.ts, the one seam for inventory fetches, keeps components from knowing how the API is called. The cost is more small files, each carrying a three-line header that a test checks against its real importers. A site with many pages of its own would earn a router and retire the address-bar-as-state functions.

## Files

- [`package.json`](https://github.com/SteveStout/TheYard/blob/main/package.json): the scripts and the dependencies.
- [`index.html`](https://github.com/SteveStout/TheYard/blob/main/index.html) and [`src/main.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/main.tsx): the entry point, one line per part.
- [`src/styles/globalStyles.ts`](https://github.com/SteveStout/TheYard/blob/main/src/styles/globalStyles.ts) and [`src/app/mount.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/app/mount.tsx): the stylesheets in order, and the mount.
- [`vite.config.ts`](https://github.com/SteveStout/TheYard/blob/main/vite.config.ts): the plugin, the proxy, the watcher, and the Vitest settings.
- [`tsconfig.json`](https://github.com/SteveStout/TheYard/blob/main/tsconfig.json), [`tsconfig.app.json`](https://github.com/SteveStout/TheYard/blob/main/tsconfig.app.json), [`tsconfig.node.json`](https://github.com/SteveStout/TheYard/blob/main/tsconfig.node.json), [`src/vite-env.d.ts`](https://github.com/SteveStout/TheYard/blob/main/src/vite-env.d.ts): the compiler, split by where the code runs.
- [`src/styles/colors.css`](https://github.com/SteveStout/TheYard/blob/main/src/styles/colors.css) and [`src/styles/colors.test.ts`](https://github.com/SteveStout/TheYard/blob/main/src/styles/colors.test.ts): the palette and its contrast test.
- [`src/lib/inventory.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/inventory.ts), [`src/lib/data.ts`](https://github.com/SteveStout/TheYard/blob/main/src/lib/data.ts), [`src/app/hooks/useAddressBar.ts`](https://github.com/SteveStout/TheYard/blob/main/src/app/hooks/useAddressBar.ts): the URL as state and the one seam to the API.
- [`src/app/App.tsx`](https://github.com/SteveStout/TheYard/blob/main/src/app/App.tsx): the table of contents.
- [`src/library/documents.ts`](https://github.com/SteveStout/TheYard/blob/main/src/library/documents.ts): the documents, joined, and the names worked out from them.
- [`api/TheYard.Tests/FileHeaderTests.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Tests/FileHeaderTests.cs): the header rule.
- [`src/hooks/useNow.ts`](https://github.com/SteveStout/TheYard/blob/main/src/hooks/useNow.ts), [`src/hooks/useBids.ts`](https://github.com/SteveStout/TheYard/blob/main/src/hooks/useBids.ts), [`src/hooks/useMediaQuery.ts`](https://github.com/SteveStout/TheYard/blob/main/src/hooks/useMediaQuery.ts): the shared state.
- [`playwright.config.ts`](https://github.com/SteveStout/TheYard/blob/main/playwright.config.ts) and [`.github/workflows/ci.yml`](https://github.com/SteveStout/TheYard/blob/main/.github/workflows/ci.yml): the two runners and the jobs that run them.
- [`Dockerfile`](https://github.com/SteveStout/TheYard/blob/main/Dockerfile): the build stage that repeats `npm run build` in the image.
- [`api/TheYard.Api/LiveSamples.cs`](https://github.com/SteveStout/TheYard/blob/main/api/TheYard.Api/LiveSamples.cs): the whole-file samples this record uses for the files that cannot carry a region marker (ADR: Live code samples, second addendum).

## Addendum, 2026-09-29 (1.0.3.42): the stylesheets follow

The split of 28 September moved two stylesheets whole and named them as allowed past the 300-line cap. They were the last two files in those folders with more than one job, and they are split now, by the same rule as the code: one job per file, the name says the job, the three-line header.

| The sheet | Before | After |
| --- | --- | --- |
| `src/library/DocDialog.module.css` | 828 lines, the window, the prose, the swatches, the reading panels and the Author page | `DocDialog.module.css` (the window), `DocProse.module.css`, `DocSwatches.module.css`, `DocPanels.module.css`, `AuthorPage.module.css` |
| `src/app/App.module.css` | 425 lines, the frame and the inventory page | `App.module.css` (the frame), `InventoryView.module.css` |

Every rule moved byte for byte; only the headers are new. Each document sheet scopes its rules under its own `.prose`, so the element that holds a document carries all of them (`DocDialog.tsx`, the `PROSE` line), and the reading panel's frost is scoped under the window as well, so the window carries that sheet's `.dialog`. The CSS modules keep the order the one sheet had, because `DocDialog.tsx` imports them in that order.

**The header on every stylesheet.** `FileHeaderTests` reads every `.css` under `src` beside the code in `src/app` and `src/library`: the header, "Used by" against the files that really import each sheet, and the 300-line cap. Forty sheets under `src/components` and `src/styles` got the header in this version. The two "moved whole" allowances are gone. Four component sheets are over the line (`Landing`, `SideNav`, `AdminPanel`, `VehicleDetail`); each holds one component's look and is named in the test with that job and the lane that will split it with its component. A live fence may quote a file's header with `region=header`, since the header comes before anything a marker could wrap.

```csharp
/// <summary>The two folders the rule holds for code and stylesheets alike, relative to the repository root.</summary>
private static readonly string[] Folders = ["src/app", "src/library"];

/// <summary>What a file in them can be: code, and the stylesheets beside it.</summary>
private static readonly string[] Kinds = [".ts", ".tsx", ".css"];

/// <summary>And every stylesheet anywhere under this folder: a sheet grows a job at a time the way code does.</summary>
private const string Sheets = "src";

/// <summary>The three labels, in the order a header gives them.</summary>
private static readonly string[] Labels = ["Does:", "Does not:", "Used by:"];

/// <summary>
/// Past three hundred lines, each with why. One job per file keeps a file
/// short. The four component sheets named here each hold one
/// component's look, shared by that component and the parts split out of
/// it, and are over the line because the look is one piece; each comes
/// off this list when its sheet is split by job.
/// </summary>
private static readonly Dictionary<string, string> LongAllowed = new(StringComparer.Ordinal)
{
    ["src/components/landing/Landing/Landing.module.css"] = "the landing page's one look (hero, featured tiles, section tiles); split with Landing.tsx in the component split lane",
    ["src/components/layout/SideNav/SideNav.module.css"] = "the side rail's two shapes, one set of rows: one sheet shared by SideNav.tsx and the rows split out of it",
    ["src/components/admin/AdminPanel/AdminPanel.module.css"] = "the Admin workbench's frame: one sheet shared by AdminPanel.tsx and the parts split out of it",
    ["src/components/vehicle/VehicleDetail/VehicleDetail.module.css"] = "a vehicle's own page; split with VehicleDetail.tsx in the component split lane",
};
```
*Live from [`api/TheYard.Tests/FileHeaderTests.cs`](https://github.com/SteveStout/TheYard/blob/1d6b50b/api/TheYard.Tests/FileHeaderTests.cs#L19-L44), region `the-header-rule`, read from this build at 1d6b50b.*

The next lane is the components that have the disease the split cured in `src/app`: `AdminPanel.tsx`, `ActivityCard.tsx` and `SideNav.tsx`, and their sheets with them.

## Addendum, 2026-10-03: Node 24

The front end is built on Node 24 in the Docker image and in CI where this record says Node 22 (ADR: Technology versions).

## Addendum, 2026-10-06 (1.0.3.85): the documents' look, written down

How a served document gets its look was spread across three files: `src/lib/markdown.ts` (the renderer, the section ids and the fences), `src/lib/docLayout.ts` (the panels and the status reading) and `src/library/styleBlocks.ts` (the Style section's fences), with the glass itself in the stylesheets. None of them changed. A new page under Style, How the documents are styled, explains them in one place and shows the code from each file live (ADR: Live code samples, the addendum on the page that shows them), so a developer reading this record has a page to start from before opening the files.

## Addendum, 2026-10-07 (1.0.3.88): the landing page arrives drawn, and the mount takes it over

`app/mount.tsx` no longer always draws the site from nothing. The frontend build now draws the landing page to HTML and writes it into `#root` (ADR: The landing page rendered at build time, server rendering as the goal), so on the bare address the mount calls `hydrateRoot`, which draws the same tree again, finds the same markup and attaches to it; every other address clears that drawing and calls `createRoot` as before. The tree both draws start from moved into `app/TheYard.tsx`, so the build and the browser cannot draw different sites. Two things the first draw used to ask the browser for at once, which side of the docking line the window is on and whether the rail was left collapsed, are read through `useSyncExternalStore` in `useRail.ts`, whose third argument is the answer the build gave: React uses it while taking the page over and asks the browser straight after, which is what keeps the takeover free of hydration errors.

```tsx
/**
 * Finds the element index.html holds for the site and puts the site in it.
 *
 * Two kinds of page arrive already drawn. The rendering service draws the page
 * for any address and writes what it read beside it (ADR: A rendering service
 * beside the API); React takes that over with hydrateRoot from the same answers.
 * The build draws the landing page into index.html (ADR: The landing page
 * rendered at build time); on the bare address React takes that over the same
 * way. Every other address on a page the service did not draw opens on another
 * view, so the drawn landing page is not what it wants; index.html keeps it
 * hidden there, and this clears it and draws the view from nothing with
 * createRoot, as the site always did.
 * @param elementId the id of that element, "root" in index.html
 */
export function mountTheYard(elementId: string): void {
  // A missing element would leave a blank page with no reason given; throwing names the problem.
  const root = document.getElementById(elementId);
  if (!root) throw new Error(`Missing #${elementId} element`);

  const drawn = root.getAttribute('data-drawn');
  root.removeAttribute('data-drawn');
  const firstLoad = drawn === 'server' ? readFirstLoad() : null;
  if (firstLoad && firstLoad.search === window.location.search.replace(/^\?/, '')) {
    hydrateRoot(root, <TheYard firstLoad={firstLoad} />);
    return;
  }
  if (drawn === 'landing' && window.location.search === '') {
    hydrateRoot(root, <TheYard />);
    return;
  }
  root.replaceChildren();
  createRoot(root).render(<TheYard />);
}
```
*Live from [`src/app/mount.tsx`](https://github.com/SteveStout/TheYard/blob/1d6b50b/src/app/mount.tsx#L12-L44), region `mount`, read from this build at 1d6b50b.*

## Addendum, 2026-10-07 (1.0.3.90): what a component owes a page that arrives drawn

Since 1.0.3.88 the landing page is drawn twice from the same tree: once by the build, with no window, and once by the browser, which must produce the same markup before React will take the page over. That puts one rule on every component the landing page renders. Anything it reads from the browser on its first draw (the window's width, local storage, the address, the time) is read through `useSyncExternalStore` with a third argument that gives the build's answer, as `useRail.ts` does, or inside an effect after the takeover; read directly, the two draws disagree and React throws the drawn page away and draws it again, which costs more than never drawing it at all. `tests/e2e/drawn.spec.ts` holds the result at phone and desk sizes: no hydration error, and the node the build drew is the node the visitor keeps.

The takeover has a price of its own, measured after the roll: blocking time on the Azure SQL site's phone reading rose from 110 to 255 ms at the median, the main-thread work of attaching React to a page that is already there (ADR: The landing page rendered at build time, server rendering as the goal, the addendum on the numbers after). A smaller landing tree, or leaving parts of it to draw after the takeover, is where that comes back from.
