Repository navigation
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, againstdocs/specs/2026-09-03-statusboard-client-design.md.Nothing is hand-written that the contract already describes.
docs/api/openapi.yamlgenerates the types, and
just checkfails on a diff.What is here
tokens.csswrites a hexThree 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
tabparameter, 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 tabsdifferent filters, and one
homeSearchblurred them, so a board could carry aphasetheAPI 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-schemeappliedthem 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
http://app.localhost:52156/verify?token=..., so theAPP_URLthat
bin/worktree-env.pyderives from the branch reaches the mail. Opening it signedin, Home showed the tracked GitHub row at Operational, and the nav's third slot became
Settings. Opening it again said no longer valid.
operational and untinted, each linking to its service.
config.js, answers 200 on a nested deep link, andcarries the CSP on all six paths.
just checkpasses 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.
/servicesand/componentsreach it, because every screen under them needs an id. Theroot carries a
notFoundComponentnow.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
mainfails the same waytoday.
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.lockmoved. Every pin inpyproject.tomlis 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 workin the plan for the full list and the reasons.appservice indeploy-pipeline/app-statusboard/compose.yml, which is a differentrepository. Until it exists, merging publishes
statusboard-app:edgeand nothingconsumes it.
just deploy, which recreates production.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-alpinehas not been scanned here before.
🤖 Generated with Claude Code