Skip to content

The client shell: a deployed SPA you can sign into, that shows your board - #10

Merged
thalida merged 24 commits into
mainfrom
app
Oct 24, 2026
Merged

thalida merged 24 commits into
mainfrom
app

Conversation

@thalida

@thalida thalida commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

The client shell: a deployed SPA you can sign into, that shows your board. Implements
docs/plans/2026-09-04-statusboard-client-shell.md, tasks 1 to 10, against
docs/specs/2026-09-03-statusboard-client-design.md.

Nothing is hand-written that the contract already describes. docs/api/openapi.yaml
generates the types, and just check fails on a diff.

What is here

Build pnpm, Vite, React, TypeScript, Biome, Vitest, MSW
Palette 26 tokens, and no rule outside tokens.css writes a hex
Types generated by Orval from the contract, committed, drift-gated
Auth one place attaches the token and rotates it
Routes fifteen, a folder per screen, filters validated per tab
Shell bottom pill on a phone, a bordered bar on a desktop
Row state as a tint and a coloured edge, never a dot
Home your board signed in, one row per service signed out
Sign-in a magic link, spent exactly once
Image nginx, configured at start, scanned by Trivy, published on a green main

Three decisions that changed the approved shape

Each was the maintainer's call during review, and the spec is amended to match.

A tab is a path; a filter is a search parameter. Spec §4 made every tab a tab
parameter, reasoning that a tabbed screen shares a header while its body swaps. Links over
one shared header do exactly that, so the reason did not hold. Home's two tabs are / and
/updates. The split also tightened the schemas: §4's own table gives Home's tabs
different filters, and one homeSearch blurred them, so a board could carry a phase the
API refuses.

There is no dark theme. The decks draw the app in light only. The dark values were
derived by swapping twelve tokens, were never drawn, and prefers-color-scheme applied
them from the reader's own system. Gone, with the four lifted hues that served only them,
and with Theme in Settings. A test holds it out.

Every screen is a folder. Dots and directories are the same nesting to TanStack's
generator, so this is house style. The route ids are unchanged: 30 generated id and path
lines, identical before and after.

Read back against the real thing, not only mocks

  • A magic link printed as http://app.localhost:52156/verify?token=..., so the APP_URL
    that bin/worktree-env.py derives from the branch reaches the mail. Opening it signed
    in, Home showed the tracked GitHub row at Operational, and the nav's third slot became
    Settings. Opening it again said no longer valid.
  • Signed out, Home rendered three live rows: Twilio degraded and tinted, GitHub and OpenAI
    operational and untinted, each linking to its service.
  • The image serves the page, writes config.js, answers 200 on a nested deep link, and
    carries the CSP on all six paths.

just check passes whole: 638 API tests at 96.66% coverage, ruff, the docs check, prose,
22 bin/ tests, 49 client tests, no codegen drift, both images building.

Two things found while building

Neither was in the plan.

  • A URL no route matches was the words "Not Found", unstyled, with no way back.
    /services and /components reach it, because every screen under them needs an id. The
    root carries a notFoundComponent now.
  • Nothing declared a font. The decks specify Outfit and DM Mono; the app rendered in
    the browser default. No task owns typography, so the type scale and row densities are
    still unmatched. Recorded in the plan.

It also carries an API security fix

Not client work, and kept here deliberately rather than split out.

The first CI run failed on the API image: 13 Trivy findings, 11 high and 2 critical, all
fixed upstream. They are new advisories rather than new code, so main fails the same way
today.

Package Was Now Why
PyJWT 2.13.0 2.15.1 CVE-2026-102268, critical: authentication bypass through an improper public key. CVE-2026-102266, high: the same through an empty HMAC key. Four more.
certifi 2026.7.22 2026.10.23 GHSA-6q48-6c7w-9gw2, critical: it bundled the compromised emSign roots.
djangorestframework 3.18.0 3.18.3 CVE-2026-114136, high: denial of service. Two more.
urllib3 2.7.0 2.8.0 CVE-2026-97687 and CVE-2026-97689.

PyJWT is the one to read twice. Sign-in is a magic link and the session is
djangorestframework-simplejwt, so PyJWT verifies every request this API authenticates.
An authentication bypass sits in that path, and it was live before this branch existed.

Only api/uv.lock moved. Every pin in pyproject.toml is a floor these already satisfied.
The suite passes on them, and the rebuilt image scans clean.

Open before the client is actually live

See ## Open work in the plan for the full list and the reasons.

  • The app service in deploy-pipeline/app-statusboard/compose.yml, which is a different
    repository. Until it exists, merging publishes statusboard-app:edge and nothing
    consumes it.
  • just deploy, which recreates production.
  • The phone layouts. The desktop header was read back and corrected; nobody has looked at
    390px, because jsdom computes no layout and this repo installs no browser driver.

Worth watching on this run

The client job is new, so this is the first CI run that installs on a clean runner, builds
the client image and scans it. Trivy exits 1 on HIGH or CRITICAL, and nginx:1.27-alpine
has not been scanned here before.

🤖 Generated with Claude Code

thalida and others added 24 commits September 4, 2026 19:03
Ten tasks, ending with a deployed app you can sign into that shows
your board. Scaffold, the 26 tokens, generation from the contract, the
token store, validated search parameters, the shell, the row, Home,
the magic link, and the image with its pipeline.

The screens that read those components are a second plan. Writing them
first would mean guessing props, which is what needed correcting most
in the API plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3FXXWHwTQ89ytonD2sZaT
A Vite SPA on its own origin. bin/worktree-env.py already reserves the
port and points the API's CORS at it, so a worktree serves both halves
without touching either config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT9JAX8MR1cMy39T6kr826
tsconfig.json maps `@/` to `./src`.
The type checker honors that mapping.
Vite's bundler does not honor it.
An import through `@/` would pass typecheck.
The same import would fail when Vite built it.
Every screen imports through `@/` from here on.
An unresolved alias would have broken every one of them.

The router plugin builds a route tree from route files.
This task has no route files.
The plugin had nothing to build.
It failed on every run instead.
The plugin belongs in Task 5.
Task 5 adds routes and `__root.tsx`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT9JAX8MR1cMy39T6kr826
Both decks carry an identical core block, and this is the third copy.
The app layer is built from it, so a theme swaps twelve values rather
than every rule. Dark is derived: the decks render the app in light.

The css import needs vite's ambient types. The token test reads
node:fs, which needs node's types too. tsconfig lists both, and the
app gains @types/node as a dependency.

Tailwind's theme block is invalid CSS to a strict parser. A
directory-scoped biome config turns on Tailwind syntax for the styles
folder, and leaves the root config untouched.

App.tsx now carries bg-app-ground and text-app-ink. Tailwind only
emits a class it sees used, and the token test never renders a screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT9JAX8MR1cMy39T6kr826
Dark set --app-nav and --app-card both to u-900, so the nav pill and
the card beneath it read as one flat surface. Light keeps them apart,
one step down at u-800 against a u-0 card. The nav floats over the
content on a phone, so it has to hold that contrast in dark too. Both
dark blocks move --app-nav to u-800, so System and an explicit dark
choice render the same nav.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT9JAX8MR1cMy39T6kr826
drf-spectacular generates the contract from the code, and a contract
test holds the two together. A hand-written type here would be a third
answer to one question. CI regenerates and fails on a diff.

Two spec quirks needed a workaround rather than a contract edit. A
handful of parameter descriptions carry an unescaped comma inside
flow-style YAML, which OpenAPI validation rejects, so validation runs
disabled. EventRef is an object schema marked nullable, which Orval
turns into invalid TypeScript, so an in-memory spec transform rewrites
it to the OpenAPI 3.1 nullable form before generation.

bin/tests/test_merge_gate.py gains a test that fails until Task 10
adds the client's checks to CI. That failure is left in place on
purpose, as the reminder Task 10 clears.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT9JAX8MR1cMy39T6kr826
Eight descriptions used flow-style YAML with an unescaped comma, which
splits the value into a null-valued key and truncates the sentence.
Component.is_overall silently lost "carrying the provider's top-level
indicator. Always first, parent is null.", and every ends_at silently
lost "or when the window closes." Quoting is the whole fix, so those
sentences now reach the generated client whole.

EventRef also declared `nullable: true`, a 3.0 keyword this 3.1
document already dropped everywhere else. Orval read it as valid and
emitted an interface unioned with null, which is not legal TypeScript.
Its two lines now use the 3.1 form, `type: [object, "null"]`.

Both fixes used to live in orval.config.ts: a spec transformer for
EventRef, and disabled validation for the descriptions. Neither
belonged there. A transformer is a second definition of the contract,
and disabled validation hides the next spec bug behind a green build.
The contract is now the one place either fact is recorded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT9JAX8MR1cMy39T6kr826
The access token stays in memory and the refresh token survives a
reload. A 401 mid-session refreshes once and retries, so a rotating
token does not bounce a signed-in reader to the sign-in page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT9JAX8MR1cMy39T6kr826
statusboard.refresh contains the letter "a", so the first auth test's
substring assertion against a single-character "a" token failed no
matter what the key was named. The key was not the bug: it is a
reasonable, namespaced name, and AGENTS.md rules out choosing one for
the letters it lacks. Gave the test distinctive token values instead,
so the assertion means what it claims rather than depending on the
key's spelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PT9JAX8MR1cMy39T6kr826
`just app` ran the client alone, but nothing ran the server alone.
`just api` does now. `dev` calls both recipes rather than repeating
their commands, so each command lives in one place.

The README's table listed neither, and said `dev` runs the server
and poller. It has run the client too since VITE_API_URL arrived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every filter is server-side, so every filter is a search parameter. A
value the API would refuse is dropped in one schema rather than by
each screen, and an unknown tab falls back instead of erroring.

Three departures from the plan, each with a reason.

The plan gitignores routeTree.gen.ts. `build` runs `tsc -b` before
`vite build`, so the type checker reads the route tree before the
plugin writes it. A fresh clone failed with eleven errors. The file
is committed instead, which is what src/api already does.

The plan gives /settings and /discover/add a tab schema. Neither
screen has tabs. A URL could then set a tab the screen does not own.
Both render a plain stub.

Step 8 reads four URLs back by hand. The same four are a test, so a
schema that starts throwing is caught by `just check`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three destinations, and the third is Sign in or Settings. Signed out
and signed in are the same screens, so nothing else moves. Icons are
Lucide throughout: one nav cannot carry two weights.

The plan's test queries the document synchronously. A router renders
its tree after it loads, so each query read an empty body. Every
query here awaits instead.

The plan lists __root.tsx as modified. Shell takes a title per
screen, so the root cannot supply one. Task 8 wraps Home instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Home, Discover and a service render one card. It carries its state as
a tint and a coloured edge rather than a dot, and its only third line
is a parent. The severity word comes from /meta/, so the row spells
no state.

The plan names severity 3's tone "none". Tailwind reads `border-none`
as no border, so the tinted edge vanished. Severity 3 is Unknown and
the palette already has that token. The tone is "unknown".

The plan's row test renders no label but asserts on "Degraded". The
label is the caller's, read from /meta/, so each render passes one.

The same test reads durations off the real clock. Its fixture aged
past "2 hrs" weeks ago. The clock is frozen, and left advancing so
the async queries still resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed in it is your board. Signed out it is one row per service,
which is the same list narrowed to the rollups. The severity word
comes from /meta/, so a label change needs no client release.

The plan renders Home on its own. A screen reads its search
parameters from its own route, so `tab` was undefined and Home
rendered the Updates branch in every case. One test then passed on
the nav's three links while no row existed at all. Tests render the
route tree at a path instead, through a `renderRoute` helper.

The plan asserts on "Degraded" against Orval's mock, which fakes the
label map. Both handlers are explicit now, and the word asserted on
is one no client could spell.

Three routing tests read tab names off `/`. Home renders a board
there now, so they moved to the service stub, which still renders
its tab and reaches no API.

Read back against the running API: three rows, labels from /meta/,
and no tint on the operational two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nav was static on a desktop and rendered after <main>, so the
three destinations sat at the foot of the page. The desktop deck puts
them in a bordered top bar beside the brand and the refresh stamp.
Header holds the nav now. The phone pill is fixed, so its place in
the document costs nothing there.

A destination is an outlined pill on a desktop and fills while it is
the screen you are on, which is the deck's one meaning for a filled
shape.

A Shell test holds the nav inside the banner. Static on a desktop
means the document order is the screen order, and nothing caught
that.

APP_URL was mine to hardcode per worktree. bin/worktree-env.py
already derives this host for CORS and CSRF, so it derives APP_URL
and APP_MAGIC_LINK_PATH too. Every branch mails a link to the client
it actually serves. A value in the environment still wins, which is
how a branch points at a client the script cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spec §4 made every tab a `tab` search parameter, reasoning that a
tabbed screen is one page whose header is shared and whose body
swaps. A layout of links over one shared header does exactly that,
so the reason did not hold. A tab is a place you can send someone.
A filter is state you carry while you are there.

Home's two tabs are `/` and `/updates`. They are the only pair not
under a screen of their own, because Home is the root.

The split tightened the schemas. Spec §4's own table gives Home's
two tabs different filters, and one `homeSearch` blurred them: a
board carrying `phase` would send the API a parameter it refuses.
Each route now validates its own tab's filters. Discover loses the
one-value tab the plan gave it, which the spec never had.

An unset filter is dropped rather than set to undefined. A key set
to undefined is still a key, and the router then asks every link to
carry a search object.

Routes are grouped in a folder per screen. Dots and slashes are the
same nesting to the generator, and the route ids are unchanged: 30
generated id and path lines, identical before and after the move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dots and directories are the same nesting to the generator, so this
is house style rather than correctness. A folder per screen reads as
a sitemap, and each screen has somewhere to keep its test and its
own components. verify's test already sits beside its route.

An index route's generated id gains a trailing slash. Both forms are
valid Link targets, so no link changed.

`/` keeps a flat index.tsx. The root has no folder to live in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
TanStack's fallback is the text "Not Found" with no header and no
nav, which strands the reader. `/services` and `/components` reach
it: every screen under them needs an id, so the parent path names no
page. So does a typo, and so does a tab one screen owns on another.

The screen sits on the root, so one covers them all, and it wears
the shell because the way back is the nav.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing declared a font-family, so every screen rendered in the
browser's default. The decks use Outfit for the UI and DM Mono for
every number: a duration, a count, the refresh stamp. Both are
Tailwind families now, so `font-mono` is DM Mono wherever a number
appears, and the stamp joins the durations in it.

A test holds the families in the stylesheet and the fetch in the
page. A family named in CSS and absent from the page falls back in
silence.

No plan task owns typography. Tasks 1 to 10 cover the build, the
palette, the types, auth, the routes, the shell, the row, Home,
sign-in and the image. The second half covers screens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The decks draw the app in light only. The dark values were derived
by swapping twelve tokens and were never drawn, and the
prefers-color-scheme block applied them from the reader's system. So
anybody on a dark machine was reading a theme nobody had approved.

Gone: the data-theme block, the media block, and the four lifted
hues, which existed for type on dark and nothing else. Spec §2 drops
the theme from client state and Settings drops the Theme row.

A test holds it out, so the next palette change cannot bring it
back. Dark is a design task before it is a code one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The API builds the link from APP_URL, so /verify is not ours to name.
The token is single use, so the page posts it exactly once: an effect
guard rather than a dependency array, because StrictMode invokes the
effect twice.

The generated hooks are named for their method and path, so the
plan's useAuthMagicLinkCreate and useAuthVerifyCreate are really
usePostAuthMagicLink and usePostAuthVerify.

The mutation sits in a ref. It is a new object every render, and the
effect would re-run on each one.

The test harness renders inside StrictMode now, which is what the
guard is for. Without it the single-spend test passed by not
exercising the case.

Read back against the running API, with EMAIL_USE_CONSOLE=1. The
link printed as http://app.localhost:52156/verify?token=..., so the
derived APP_URL reaches the mail. Opening it signed in, Home showed
the tracked GitHub row at Operational, and the nav's third slot
became Settings. Opening it again said no longer valid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One image per component, built from its own directory. The entrypoint
writes config.js from the environment, so one image answers anywhere
and it is the image CI proved. A green main publishes :edge, so a
deploy during development needs no version bump.

nginx expands no variable in a config file, so the server block is a
template the official image renders with envsubst. It substitutes the
names it finds in the environment, which leaves nginx's own $uri
alone.

The security headers are a second rendered file, included by every
location. add_header does not merge: a location carrying one of its
own drops every header inherited from the server. The cache rules
each set one, so the CSP was absent from exactly the responses that
carry the app. Read back on all six paths before and after.

`expires` writes its own Cache-Control, so pairing it with an
add_header served /assets/ two of them. The add_header carries the
age now.

Trivy scans both images. The action takes one image-ref, so the
client gets its own step.

The merge gate passes again. Task 3 left it failing on purpose,
because `just check` ran client checks that CI did not.

Steps 8 and 9 are not done here. The compose file lives in
deploy-pipeline, its own checkout, and step 9 deploys production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every step of every task is ticked, so progress lives in the plan
rather than only in the commit messages. Three stay unticked and say
why on the line: two are not this repository's or not an agent's to
run, one read back a dark ground that no longer exists.

An Open work section carries the rest to whoever is next: the compose
file in deploy-pipeline, `just deploy`, and the phone layouts nobody
has looked at. Beside it, where the shape changed after approval, and
the two things now in the code that no task owns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trivy failed the build on the API image: 13 findings, 11 high and 2
critical, every one of them fixed upstream. They are new advisories
rather than new code, so main fails the same way.

PyJWT 2.13.0 to 2.15.1 is the one that matters here. CVE-2026-102268
is an authentication bypass through an improper public key, and
CVE-2026-102266 another through an empty HMAC key. The API signs in
with djangorestframework-simplejwt, so PyJWT verifies every request
this app authenticates.

certifi 2026.7.22 to 2026.10.23 drops the compromised emSign roots
it still bundled. djangorestframework 3.18.0 to 3.18.3 fixes a
denial of service, and urllib3 2.7.0 to 2.8.0 two more findings.

Only the lock moved. Every pin in pyproject.toml is a floor these
already satisfied.

The suite passes on them, 638 tests, and the rebuilt image scans
clean at high and critical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thalida
thalida merged commit 1854654 into main Oct 24, 2026
2 checks passed
@thalida
thalida deleted the app branch October 24, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant