Previously, in Part 1, we photographed the template look, made the AI propose four real directions, and — after a human veto — landed on porcelain & petrol. Now the rebuild starts from the bottom: this part ships the tokens every later part will spend, gets dark mode almost for free, and deletes Bootstrap without breaking a single page.

The prompt: four clauses, four jobs

Here it is, verbatim from the commit in the repo (tag polish-02):

"Direction is 'Calm Practice: porcelain & petrol' (cool porcelain bg,
petrol teal primary, coral accent, Atkinson Hyperlegible). Build the
foundation: (1) a token-based app.css — every color/size/radius a custom
property, dark mode by redefining tokens under prefers-color-scheme,
never per-component; (2) DELETE Bootstrap but keep its class vocabulary —
grep which classes the app actually uses and reimplement exactly that
subset on our tokens, so every page compiles unchanged; (3) replace the
sidebar with a calm top bar; (4) the kiosk and board are APPLIANCES —
give them a chrome-free layout with no navigation at all."

Four numbered clauses, and they are the four sections of this post. Notice what the prompt does not say: "make it beautiful." Part 1 spent its budget deciding what beautiful means here; this prompt only spends decisions that are already made. That's the rhythm the whole season runs on — taste up front, execution to order.

Model pick: Opus, high effort, for the token system — naming and pricing a design system is structure-and-taste work, and bad token names are forever. The vocabulary CSS that followed went to Sonnet: forty small, precisely specified classes is exactly the shape of work a cheaper model does perfectly.

Tokens: components spend, never invent

The stylesheet opens with its own constitution — this comment ships in app.css, not in a wiki nobody reads:

/* ============================================================
   ClinicLive design system — "Calm Practice: porcelain & petrol"

   Rules of the house:
   - Every color, size and radius is a token. Components never
     invent values; they spend tokens.
   - Light and dark are the same system: dark redefines tokens,
     components don't change.
   - We keep Bootstrap's class VOCABULARY (btn, form-control,
     alert…) but the implementation is ours — the markup kept
     compiling the day Bootstrap left.
   ============================================================ */

"Components spend tokens, never invent values" is the sentence doing the most work. A component that invents #ddd once will invent it twice, slightly differently, and dark mode dies by a thousand hardcodes. Here's the real block, abbreviated:

:root {
    /* surfaces */
    --bg: #F5F8F8;            /* porcelain */
    --surface: #FFFFFF;
    --line: #DCE5E5;

    /* text */
    --ink: #182A2D;
    --muted: #5A6E71;

    /* brand */
    --primary: #16696F;       /* petrol teal */
    --on-primary: #FFFFFF;
    --primary-soft: #E3EFEF;

    /* meaning */
    --ok: #3E8460;
    --warn: #C96F4A;          /* soft coral/clay */
    --danger: #B4473C;

    /* type */
    --font: "Atkinson Hyperlegible", system-ui, sans-serif;

    /* shape & depth */
    --radius: 10px;
    --radius-lg: 16px;
    /* …spacing scale, shadows, mono stack… */
}

@media (prefers-color-scheme: dark) {
    :root {
        --bg: #101B1D;
        --surface: #182527;
        --ink: #E4EDEC;
        --primary: #4FB3B8;
        --on-primary: #0C2224;
        /* …every token re-priced; no component touched… */
    }
}

And that second block is dark mode. Not a feature, a re-pricing: the same system with different numbers, delivered under prefers-color-scheme, and every component that spends var(--bg) follows along without knowing anything happened. Note --on-primary flipping to dark ink — in dark mode the primary lightens to #4FB3B8, so the text on it must darken to keep contrast. That's the kind of pairing tokens make thinkable and hardcodes make hopeless. One deliberate exception is coming: the waiting-room board will pin its own palette in Part 6, because a TV on a wall doesn't follow anyone's OS theme.

Killing Bootstrap, keeping its vocabulary

Here's the centerpiece. The usual redesign failure mode is the flag day: rip out the old framework, and every page is broken until every page is restyled — so the redesign stalls at eighty percent forever. The trick that avoids it: the markup keeps speaking Bootstrap; only the implementation changes. Grep the components for the classes actually in use, dedupe, and you get the app's true vocabulary — the inventory came back with about forty names: btn, btn-primary, form-control, form-floating, alert, badge, table, and a modest pile of utilities. Then reimplement exactly that subset on the tokens:

/* Bootstrap's names, our implementation */
.btn-primary   { background: var(--primary); color: var(--on-primary); box-shadow: var(--shadow); }
.alert-success { background: var(--ok-soft); border-color: var(--ok); }
.form-control:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); }

Forty classes instead of Bootstrap's thousands, every one of them spending tokens, and every page compiled the day Bootstrap left — no flag day, no eighty-percent purgatory. To be clear, this isn't a Bootstrap diss; the vocabulary is genuinely good, which is why it's worth keeping. It's a migration strategy: the old names are scaffolding, and as the season refines each surface, purpose-built classes (slot-btn, ticket, kiosk-code) will quietly replace them where it matters.

The shell: a top bar and two appliances

The sidebar was the most template thing about the template — seven links of chrome for an app whose public visitors need exactly two (book, cancel) and whose staff need a sign-in. It became a calm top bar: brand left, links right, sticky, done. But the better decision is clause four. The kiosk and the board aren't pages a user browses — they're appliances bolted to a counter and a wall. They now render through a layout with no navigation at all, and its header comment is the design rationale:

@* Chrome-free layout for "appliance" surfaces: the check-in kiosk and the
   waiting-room board. A TV on a wall does not need a nav bar — but it is
   still a document: <main> keeps the landmark for assistive tech. *@
@inherits LayoutComponentBase

<main class="appliance">
    @Body
</main>

And notice what this does to Part 1's embarrassing bug: the TV's 404ing Register link isn't fixed by deleting a link — it's fixed by an architecture in which the board cannot have links. Season one learned this exact lesson with the registration page itself: delete the surface, not the link. Same move, one level up.

Before: the waiting-room board wearing the template's purple gradient sidebar with Home, Book, Register and Login links; the board content is small black text on white. Before
After: no navigation at all — 'Waiting room' centered on a porcelain background, a 'Now serving' label above 'Maria G.' in very large dark lettering, an Up next card listing David C. and Emma W., and a small green 'live' dot at the bottom. After
Same page, same data. Part 2's win is structural: nothing left to wear. It's still a light web page reading like a document — the real signage treatment (a permanent dark palette, vmin sizing, a clock) is Part 6's job. That little "live" dot survives for now; it dies there too.

What the screenshots caught

The after-shoot ran the moment the foundation compiled, and it earned its keep three times before any human review. Catch one is sitting in the home-page shot, in plain sight:

The home page after Part 2: a white top bar with the ClinicLive brand, Book and Cancel links, and Staff sign-in on the right; on the porcelain page, the heading 'Welcome to ClinicLive' sits inside an unintended teal focus ring, above intro copy, a filled petrol 'Book an appointment' button and an outlined 'Cancel a booking' button.
The new home page — with catch number one included: look at the teal box hugging the heading. Every page title in the app was wearing one.

Why would an h1 have a focus ring? Because the Blazor template's FocusOnNavigate moves focus to the heading on every navigation — genuinely good behavior, it's how screen-reader users learn the page changed — and our shiny new global :focus-visible rule dutifully outlined anything focused. Both rules correct; the combination absurd. The fix scopes the ring to elements where focus means action, and it ships with its story attached:

/* Ring only where focus is an ACTION. The template's FocusOnNavigate moves
   focus to the h1 on every navigation (good for screen readers) — a global
   ring would draw a teal box around every page title. Screenshot caught it. */
:is(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
    outline: 3px solid var(--primary);
    outline-offset: 2px;
}
h1:focus { outline: none; }

Catch two was better. With the template CSS deleted, every page — every single one — greeted visitors with a yellow bar reading "An unhandled error has occurred." No error had occurred. The template's stylesheet had been quietly carrying #blazor-error-ui { display: none } all along: the banner is always in the markup, hidden by CSS, un-hidden by Blazor's JavaScript when a real error happens. Delete the stylesheet, inherit the job it was doing. Our replacement keeps the contract and documents it:

/* The template's stylesheet quietly carried `#blazor-error-ui { display:none }`.
   Delete the template CSS and the "hidden" error banner greets every visitor —
   found by screenshot, of course. Blazor's JS un-hides it on a real error. */
#blazor-error-ui {
    display: none;
    position: fixed; bottom: 0; left: 0; right: 0;
    background: var(--warn-soft);
    border-top: 2px solid var(--warn);
}

Catch three: at 375px the elegant top bar wrapped into three awkward rows. The fix tightens instead of stacking, behind one small breakpoint:

/* small screens: the bar tightens instead of stacking */
@media (max-width: 640px) {
    .topbar-inner { gap: var(--s-2); padding: var(--s-1) var(--s-3); }
    .topnav a, .topnav .linklike { padding: var(--s-2); font-size: 0.9rem; }
}

What the AI got wrong: all three catches were in CSS the AI had just written and would happily have declared finished — and none of them are visible in the code. The focus ring was two correct rules colliding; the error banner was a job the deleted stylesheet had never advertised it was doing; the three-row bar was just arithmetic nobody ran at 375. The lesson of the part: the invisible parts of a template are still load-bearing. When you delete a template's CSS, you inherit every quiet job it was doing — and only a photograph tells you what those were.

The meter: ≈ $1.10 on season two's running meter. The token system and the forty-class vocabulary rebuild were most of it; the three screenshot fixes were one-liners that cost more to photograph than to fix — which is the correct ratio.

Checkpoint: git checkout polish-02 in the repo: the Bootstrap stylesheet is gone and every page still compiles; flip your OS between light and dark and watch the whole app re-price itself; /kiosk and /board render chrome-free — no Register link left to 404. The season-one test suite is still 9-for-9: a redesign that never touched behavior.

Three bugs in this part alone were found by photographs, not by reading code — and so far the "loop" is just a folder of PNGs and good intentions. Time to make it a tool: one command, every surface at its natural device size, state seeded so the shots have life in them — Part 3: The screenshot loop: letting the AI see its own work.