Sprint 5.5 — the polish round
Sprint 5 closed earlier tonight as “the cash-out” — the chat surface made every backend feature reachable in a sentence of natural language, and the eval graded it five-for-five. By the time I’d published the post and was about to call it a day, I opened the deployed site in a fresh browser to take screenshots, and the gap was obvious. The chat worked, but the product didn’t quite feel like one yet. The empty state was a wall of text. The header had no avatar, no logout, no sense of “who am I logged in as.” The signin form on a machine without OS dark mode rendered gray-on-white. Tab focus rings were the browser default, half of which disappeared into the matrix-on-dark theme.
So Sprint 5.5 happened. A six-hour mini-sprint, 2026-05-06 evening into night, one calendar day. Nine PRs. Driven by Domi - User Flow and Navigation v0.1.md, a spec doc I’d written mid-Sprint-5 to capture the design intent for the chat surface in case I had to pause and pick it up later — which then sat as a checklist of everything I knew was missing.
What shipped
- Centered empty-state greeting.
<emoji> Good afternoon, JFrather than the previous wall of “I’m still being wired up to your data” placeholder text. Time-of-day band resolved client-side viauseEffectto avoid SSR hydration mismatch on the locale switch (locale change triggers an unmount-remount; the time-of-day computation can’t be server-side because the seed for “afternoon” depends on the tenant’s timezone — which is hardcodedCA-QCfor now, M6’s job to make per-tenant). - User pill + menu in the chat header. Avatar circle (initials), display name, caret. Click → menu opens with the email address as a non-interactive header, a Language submenu (English / Français with checkmark on the active locale), and a Logout button. Settings is intentionally absent until M6 has a route to send it to — don’t ship a 404 link.
- Session-expiry banner. When a magic-link session lapses mid-conversation, instead of “the chat just stopped working,” a role=“alert” banner appears at the top of the chat with a “Sign back in” link. The unsent input the user was typing is preserved across the redirect via a separate localStorage key (
domi:chat:pending-input:v1), restored to the textarea when they land back on/[locale]/chat. - A11y / focus pass. Shared
FOCUS_RINGconstant —focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950— applied to every interactive element on the chat and signin surfaces.focus-visiblematches keyboard focus only, not click; tab through with the keyboard and you see the emerald ring; click and you don’t. Decorative>glyphs in headers are nowaria-hidden. - Check-your-inbox panel after magic-link send. Replaced the inline “Magic link sent” toast with a focused panel showing the bolded sent-to email, a Resend button gated by a 60-second client countdown (
Resend in 47s), and a “Use a different email” back-link. The countdown is the load-bearing UX — it stops the “I clicked Send a bunch of times because the email didn’t show up immediately” pattern. Server-side per-email rate limiting is M6’s job; Resend’s API throttles at the provider layer in the meantime. - Reserved chips slot below the input pill.
min-h-[2.5rem]placeholder where the empty-state quick-action chips will land in M6. The spec is explicit that V1 reserves the layout slot but doesn’t commit to specific copy until M6, so I shipped the slot and a comment block describing the recommended exploration set. This is the smallest PR of the sprint by far and was on purpose: the visual rhythm of the empty state needs to be right now so M6 just drops chips into a styled slot.
What I had to fix mid-sprint
The chat messages container was gray-on-gray on a screenshot with the OS in light mode. Root cause: bg-neutral-950/60 (60% opacity) over a bg-white-default body let the lighter layer bleed through, and the body was light because globals.css had a prefers-color-scheme: dark override that only kicked in for users with OS dark mode enabled. Two-stage fix: PR #71 made the messages container solid (defense in depth), PR #72 made the body always dark — body { @apply bg-neutral-950 } independent of OS preference, and color-scheme: dark on :root so native form controls and scrollbars match. Domi is matrix-on-dark always. The OS-prefers-light path was a copy-paste from the Astro starter that I’d never noticed because my own machine is in dark mode.
The signin form asked for email and household name in one step. Per the user-flow spec §3.3, the initial signin is supposed to be email-only — household name is collected after authentication via the post-signin onboarding bounce. Sprint 5 had taken the shortcut of combining both in one form because it conveniently fit the “JF is the only user, JF has only one household” V1 dogfood timeline. Aligning now means removing the household input from the new_user mode and deleting PENDING_HOUSEHOLD_COOKIE plus its three setter/reader/clearer helpers as dead code — the cookie’s only purpose was the shortcut path, and the post-signin onboarding form (which always existed for the no-cookie case) is now the canonical household-creation entrypoint.
Forcing the body to neutral-950 exposed that every card and pill was also neutral-950. Borders made cards visible but they didn’t read as elevated surfaces — they looked like outlines. PR #73 introduced an explicit elevation map: body neutral-950 (deepest), cards / pills / menus neutral-900 (raised), inputs nested inside cards back to neutral-950 (recessed wells). Three steps, not two. Worth keeping in mind for any future surface — flat single-tone surfaces read as outlines on a matrix-on-dark theme.
What surprised me
Specs that name the gap make polish work feel like a checklist. I’d written User Flow and Navigation v0.1 mid-Sprint-5 partly out of self-defense — if I had to context-switch and pick chat back up later, I wanted the design intent committed somewhere. What it ended up doing was making Sprint 5.5 trivially scopable. Six issues, one per spec section, all small enough that the entire mini-sprint fit in one calendar day. The doc-then-PR rhythm is something I’d resisted because writing specs feels like overhead when you’re the only engineer; this sprint flipped my read on it. The spec is the thing that lets me close the loop on “what would ‘done’ look like” without re-deriving it every time I open the file.
The push-after-merge habit is recurring. Sprint 5’s PR #57 → PR #58 was the first time I noticed it: I pushed follow-up commits to a branch that had been merged minutes earlier, and the commits sat on an orphan branch waiting to be cherry-picked onto a fresh one. Sprint 5.5 caught the same pattern twice — once when the contrast fix went onto the just-merged chips-slot branch, once when the elevation fix went onto the just-merged force-dark branch. The fix is procedural (“verify the PR is still open before pushing follow-ups”), but the habit hasn’t internalized yet. Adding it to the post-merge mental checklist along with “remember to update the testing report.”
The third elevation step matters. I’d been thinking of dark-theme elevation as a two-step thing (page bg, surface bg). Forcing the body to a true matrix neutral-950 made the third step visible: inputs nested inside surfaces want a tone that contrasts with their surface, not with the page. With body=950 and card=900, inputs at 950 read as recessed wells inside cards rather than as flat panels — and the visual hierarchy reinforces what the form is (a card containing controls). Cheap insight, cheap fix, big improvement. The kind of thing you can’t pre-design without seeing the wrong version first.
Where Sprint 6 picks up
M6 — settings + persistence. The plan hasn’t changed: chat_sessions + chat_messages schemas to retire the localStorage mirror, the settings IA per Domi - Settings IA v0.1.md, real per-tenant region resolution to replace the hardcoded CA-QC (and unblock the timezone-aware greeting), and the SECURITY DEFINER user→tenant lookup function that’s been a carry-forward since Sprint 2. Plumbing, not a cash-out.
The mini-sprint pattern I’d like to keep, though. Sprint 5.5 was scoped narrow enough — “match the user-flow spec and that’s it” — that the constraint did the prioritization for me. Six hours, nine PRs, no scope creep. If I find myself needing to do another spec-driven polish round between the next two milestones, I’d insert another half-sprint without hesitation.