Sprint 20 — dropping the Matrix


S19 closed with seven structural PRs that hardened the regression-suite and pulled three V1.5 features into V1. None of it changed how Domi looked or felt to use. S20 changed both.

JF gave an improvement list during S19 planning. Two were architecturally novel; one was a brand pivot:

  1. Claude-style left sidebar with multi-thread chat (V1.5 pull-forward).
  2. Edit + archive members and assets.
  3. /documents listing page (ingestion already worked; missing the browse surface).
  4. Document-type inference + shared cross-tenant region templates.
  5. Theme picker + drop the Matrix aesthetic.

Item 6 was the biggest. The Matrix-on-dark direction had been locked in JF’s memory since S5 — single emerald accent, JetBrains Mono everywhere, terminal > prompt glyphs, lowercase-with-underscore titles like domi_chat. It signaled “hacker tool.” Domi is a life-management product for families. JF asked for “more gentle, which provides confidence to the user.” That meant re-authoring every visible surface.

Seven PRs over the sprint:

  1. Design tokens foundation + theme picker (PR #231). CSS variables for both themes, Tailwind darkMode: 'class', no-flash script in <head>, theme picker in settings. Foundation only — no component migration.
  2. Component migration to semantic tokens (PR #233). 29 files. Every bg-neutral-950, text-emerald-400, border-emerald-400, accent-emerald-400 replaced with semantic tokens. font-mono dropped from body/UI surfaces. uppercase tracking-widest dropped from subtitles, header links, button labels. Terminal > prompts removed. domi_chat / domi_graph / domi_tasks softened to “Chat” / “Graph” / “Tasks”. Default theme flipped from migration-window dark to system.
  3. Claude-style sidebar + multi-thread chat (PR #235). Multi-thread is V1.5 → V1 (yet again). Sidebar with brand row, Tasks/Graph/Documents nav, ”+ New chat” button, Recents list, UserPill at the bottom. Collapsible to icon rail; state persisted via localStorage. AppShell wraps every authenticated page. Old in-page header chrome (back-link + UserPill in top-right) removed.
  4. Mobile drawer behavior (PR #236). Sidebar hides at < md, slides in from the left as a drawer. Hamburger top-left; backdrop dismisses; Esc closes; route changes auto-close.
  5. Edit + archive members and assets (PR #237). Graph node detail panel gets Edit (inline form) + Archive (two-click confirm) buttons. Members go through updateMember/archiveMember; asset kinds go through updateAsset/archiveAsset. Both audit-wrapped + race-safe (S17 #199 pattern).
  6. /documents listing page (PR #238). Browse surface for what Domi has ingested. PDF + PNG + JPEG + WebP ingestion was already wired in S18 #204; this added the missing browse view.
  7. Document-type proposals queue (PR #240). Cross-tenant document_type_proposals table (no RLS); the LLM proposes new types when classification confidence < threshold; once approved, the type is added to the per-region template registry shared across tenants. V1 ships the storage infrastructure; the extract-pipeline integration that calls proposeDocumentType is V1.5 (needs a new LLM role + eval fixtures).

What surprised me

The visual rework was bigger than the foundation PR suggested. PR #231 added the design tokens, the theme provider, and the picker — all without migrating a single component. JF tested it, switched themes, saw nothing change, and wrote back: “still has the matrix theme color.” The PR description had said “no component migration in this PR” but that’s a writer’s framing, not a user’s. From the user’s perspective, the picker was broken. The actual visible change was the migration PR (#233), 29 files of find-replace + JSX softening, that landed an hour later. Generalized lesson: when shipping infrastructure that’s invisible to the user, the PR that lands the user-visible part should be batched, not deferred. “Foundation PR + migration PR” is a code-review convenience, not a user experience. If the foundation isn’t testable end-to-end, ship it together with the surface that exposes it.

Dropping font-mono and > glyphs felt like more than a font change. The Matrix-on-dark aesthetic was anchored on three signals: emerald accent, mono everywhere, and terminal-style header chrome (> domi_chat, lowercase with underscores, blinking-cursor implications). Replacing the accent + the font alone wasn’t enough — the JSX still read as “developer tool.” The titles needed to be sentence case (“Chat” not domi_chat), the > prompts needed to come out, the uppercase tracking-widest label patterns needed to drop. The aesthetic isn’t the color; it’s the typography rhythm + the chrome conventions. Generalized lesson: an aesthetic isn’t its color palette. Swap the palette and the old aesthetic still leaks through every label, every glyph, every title-case decision. Audit those too.

Multi-thread chat was the third V1.5 → V1 pull-forward this sprint. S18 had reload-mid-pending recovery. S19 had snooze, historical recovery, bulk actions. S20 added multi-thread chat + edit/archive entities + doc-type queue. None of these needed schema changes the V1.5 framing had implied. The multi-thread case was almost embarrassing: the chat_threads table already had the title column (nullable), and getOrCreateActiveThread already took a userId argument. Adding multi-thread was four new helper functions (getThreadById, createNewThread, listThreadsForUser, archiveThread), one schema-touch-free appendMessage enhancement to set the title from the first user message, and the sidebar UI. The lesson from S18 (“V1.5 framings often assume schema changes that aren’t needed”) graduated to a project-wide pattern in S19-S20. Of the seven V1.5 features pulled forward across these three sprints, six needed zero schema changes; the seventh (snooze) needed one additive nullable column.

The cross-tenant doc-type registry was the only structurally novel piece. Every other S20 item leveraged existing schema, existing patterns, existing components. The doc-type proposals queue is a new shape: a cross-tenant table (no RLS), populated by ingest pipelines from any tenant, then approved into a per-region shared template that downstream classification reads from. V1 ships the queue; the extract-pipeline integration that actually calls proposeDocumentType is V1.5 because it needs a new LLM role + eval fixtures. Generalized lesson: when a feature genuinely needs new architecture (cross-tenant data flow, new auth model, new pipeline integration), V1 ships the storage and V1.5 ships the actual producer. The empty queue is useful by itself — it’s the contract that lets the producer be added later without breaking anything.

Five PRs got Closes #N follow-up edits because I dropped the discipline mid-sprint. PRs #231, #233, #235 each had Closes #N in their description (issues created beforehand). Then #236 onwards, I jumped to coding without creating issues, and the CI gate caught five PRs in a row missing the closing keyword. JF nudged me twice. Created the issues retroactively + edited the PR bodies. Generalized lesson: the Closes #N discipline is one habit, not two. Either treat “create issue” + “open PR” as one indivisible step, or use [skip-issue] consistently for follow-up PRs that don’t need their own issue. Mixing the two failure modes (sometimes link, sometimes don’t) is the worst of both.

The merge conflicts were predictable but I didn’t preempt them. S20 ran multiple PRs in parallel-ish from a single starting point. Three of them touched messages/{en,fr}.json + lib/db.ts + packages/shared/package.json. As each landed, the next had a 3-way conflict. PR #238 had two conflict files; PR #240 had five. Each resolution was straightforward (always “keep both” since the additions were non-overlapping). But they cost JF round-trips (“PR #238 has conflict” → resolve + push → “PR #240 has conflict” → resolve + push). Generalized lesson: when a sprint runs N parallel PRs against the same files, batch the file-touch order so siblings see each other before main does. Or rebase frequently as siblings merge. Or accept the conflicts and budget time for them. The “fire and forget” pattern doesn’t compose with shared-file pressure.

Where Sprint 21 picks up

After the closeout, the V1 critical path looks like this (per CLAUDE.md §3):

  1. End-of-V1-ramp browser sessions — JF runs. #181 graph perf trace + #150 WCAG verification. Both have runbooks; deferred since S15.
  2. 4-week dogfood window with success-criteria measurement (Requirements §11). Per the new sequencing from PR #229, dogfood comes before the paperwork (so the paperwork describes actual behavior).
  3. Threat-model sign-off + PIA + DR runbook. Written after dogfood reveals real data flows + perf posture.
  4. V1 ship → opens to other family members.

The V1.5 followups that came out of S20 are real but not blocking:

  • #222 — race-safe audit emission for terminal-transition mutations (still V1.5; the data invariant holds, audit log over-counts on contention).
  • Wire proposeDocumentType into the extract pipeline when classification confidence < threshold. Needs a new LLM role + eval fixtures.
  • On approval, actually merge the doc-type into the region-pack template registry so downstream classification picks it up. V1 stops at the status flip.
  • LLM-generated thread titles (today’s are first-message-truncated).
  • Thread archive UIarchiveThread() helper exists, no button.
  • Search across threads — V1.5.

Sprint 21 candidate list, in rough priority order: dogfood-tenant population pass (which JF can use to drive the deferred browser sessions as side-effects), then the paperwork triplet, then a small loop of V1.5 cleanup + the EXIF orientation handling if dogfood surfaces rotation issues with camera capture.

S20 was the largest visible-change sprint of Phase 10 — V1 finally looks the part. Three sprints in a row have shown the V1.5 framing being wrong; the dogfood window itself is the next thing the project hasn’t actually tried yet. Everything else is downstream of what JF observes when he uses Domi to run his own family.