Sprint M4 — the app gets a face
Last sprint ended on a blank. The phone had reported that “Ranger les tamias” — the biweekly chore that had been living a double life for months — still didn’t render, then retracted the report seventeen minutes later because the phone wasn’t running what everyone thought it was. A publish is not an install. The sprint closed pending one relaunch and one screenshot.
The relaunch happened. The screenshot said yes. Recurring events render on my phone, expanded server-side, with the client doing no date math at all — and the Settings row that counts how many occurrence sets have actually arrived meant the confirmation took one frame instead of three hypotheses. That was M4’s first hour. It is not what the sprint turned out to be about.
Forty-four PRs merged over the window (August 9–22), fourteen production over-the-air updates, 283 tests across 25 suites — up from nine suites when the sprint opened. A fourth agent joined the bridge. And the app, for the first time since it existed, started looking like a thing rather than a scaffold.
The app had no face, and I hadn’t noticed
Domi Mobile shipped its first screen in July wearing Tailwind’s defaults: an indigo accent, cool grey borders, black text, white surfaces. It was fine in the way that a wireframe is fine. You stop seeing it.
This sprint a design agent — the fourth on the bridge, alongside backend, mobile and CI — published an actual system: a warm palette called Cabinet, tobacco-brown accent on cream, with a real dark counterpart rather than the two dark overrides the app had been limping along with; a second family called Foyer for later; Newsreader for titles and JetBrains Mono for the numbers that live in columns; and a rule I like enough to repeat, which is that one number per screen may be a display moment and every number that sits in a column stays monospaced, because columns have to align.
The palette arrived as a JSON contract rather than a screenshot, which turned out to matter twice. Both theme files are now generated from it and a test fails if either drifts — the app had been hand-keeping the same colour list in two places with a comment asking politely that they be kept in sync, which is the exact defect class this project has spent a month removing everywhere else. And when the design agent later doubted whether my copy had diverged from theirs, the answer was a checksum rather than a conversation.
Halfway through the sprint they went further and moved the whole design system into its own git repository, retiring the copies that had been living in a shared folder on my Mac. Their reasoning, which they attributed back to the mobile agent: the artefacts were in three non-git locations, which is precisely the hand-kept mirror we’d been deleting, and they had built a fourth without noticing. Catching that on your own work is harder than catching it on someone else’s.
Typefaces, over the air
Adding fonts to a React Native app is normally a native change: new build, TestFlight, a day of latency at best. So the interesting part of shipping the typefaces was the ninety seconds it took.
Rather than reason about whether the font module was compiled into the binary on my phone, the agent went and read the lockfile at the exact commit that produced the build I’m running, found the module pinned there as a transitive dependency, and concluded that font files are just assets and assets ship over the air. Then it proved it end to end by checking that three TrueType files actually appeared in the export bundle. My phone changed its face without a build.
On the way it found a trap worth writing down: the token generator rewrites the theme block wholesale, so any font definition written inside that block would have vanished silently the next time anyone regenerated. No error, no failing test — just a typeface quietly reverting to the system default a fortnight later, long after anyone would connect it to the cause. The fonts live in their own block now, and the generator refuses to run if it can’t tell which block belongs to it.
Then the arithmetic
The restyle shipped. It looked right. And then the agent audited the screens against the new tokens and found that in dark mode, the app’s primary buttons were white text on a light tan background at 2.21:1 contrast — against an accessibility floor of 4.5:1 for body text. Sign in. Unlock. Add. Save. Today. The chat send button. The list button. Every selected chip.
Nothing failed. No test broke, nothing looked wrong in a simulator, and I hadn’t seen it because I use light mode. The old palette’s primary was a dark indigo, so white-on-primary had always been correct; the new palette inverts in dark mode — the accent goes light, as warm dark palettes do — and the hundred-odd places that said “white text here” became wrong all at once, silently, in a mode I never open.
The same audit found every placeholder in the app sitting at 2.50:1, in a cool grey left over from the old theme, on warm cream. Two hundred and fifty-one hard-coded colour classes went with them.
I keep relearning the same lesson from restyles: changing a palette is not a visual change, it’s a contract change, and the contract was being kept in 251 hand-written places. The fix was mechanical. What I actually wanted was the test that now fails on all six of the patterns that caused it, so the next person to copy an old line finds out in CI rather than in a screenshot of a dark-mode phone.
And there’s a timing detail that makes it worse before it makes it better: dark mode had just become something a person chooses on purpose. Which is the other half of this sprint.
The appearance stack, in one evening
Before this sprint the app had no colour-scheme override at all. A hundred and fifteen dark-mode variants in the styles and not one line of code that could set the scheme, so it silently followed iOS no matter what the web app’s Settings said. The design agent found that by reading the source rather than asking me, which is why it got fixed before anyone reported it.
Getting it right took four questions to the backend agent before a line was written, and two of the answers changed the design. It’s stored per user, not per household — my switching to dark must not flip my wife’s phone — which meant the field belongs on the user record and rides on the identity endpoint, not on the household settings blob the mobile agent had built against first. And it’s two dimensions rather than one: a palette family and a light/dark mode, because “dark” and “cabinet” are not answers to the same question and collapsing them makes a third palette a migration.
Then the backend built the write endpoint, and three more questions shaped it while it was still being written: whether a partial update leaves the other field alone (it does — otherwise toggling dark would have silently reset your palette), which field a rejection names, and whether the token needs a write scope. The control on the phone applies optimistically and reverts if the server refuses, because a toggle that quietly keeps a value your account doesn’t hold is the same mistake as showing queued work as synced.
Asking before building beat asking after, four times in two weeks. That’s the whole methodology, and it’s cheaper than it looks.
I sent four screenshots in French
The design agent had specified a set of French layout contracts and flagged them as unverified rather than unstarted — possibly zero work, possibly real defects, unknowable without looking at a device. So I switched my phone to French and sent four screenshots.
Four defects, none visible in English. Settings rows where the label ran straight into its own value with no gap and the value was clipped at the screen edge, because French labels are longer and neither side of the row could give ground. The month grid painting on top of the following week — a day with five events overflowed its row, and the number 25 ended up sitting on an eight o’clock appointment belonging to the week above. The CSS capitalize rule doing exactly what it says, which is uppercase every word, so the header read “Vendredi 21 Août” when French keeps months lowercase. And a sentence ending in “19 nov..” because the abbreviated month brought its own full stop to a template that already had one.
The month-grid one had a real cause rather than a typo: the number of events a cell would draw was a fixed guess rather than arithmetic against the row height, so at the zoom level where each chip carries a title and a time, five of them simply didn’t fit. It now derives from the measured height, reserves room for the “+2 more” line, and clips as a backstop, because a row painting onto the next week reads as corruption rather than as “there’s more here.”
This is the part of building bilingually that nothing automates. The defects aren’t in the translation. They’re in the space the translation needs.
Everything else the phone found
Notes were losing every line break on save — every recipe I’d stored, flattened into one paragraph, silently, for however long. The add-item box on a shopping list sat half-hidden behind the keyboard, from a hard-coded offset that was simply the wrong number on my phone; it moved to the top of the screen, which fixes both that and the fact that it was hard to see, and happens to match how the list is sorted so a new item lands directly under where you typed it. And the back button on every screen pushed from a tab read (tabs) — a raw internal route name, which would have gone straight into the App Store screenshots.
Six defects this sprint were found by me using the app or looking at a picture of it. None of them failed a test. I don’t take that as evidence the tests are bad; I take it as evidence that there’s a class of defect that only exists where real data, a real screen size, a real language and a real pair of eyes intersect, and the only defence is keeping the loop from I noticed something to it’s fixed on my phone short enough that noticing stays cheap. Median this sprint: under twenty minutes, fourteen times.
The name changed three times in one day
A small story that shows how the multi-agent setup actually behaves under pressure.
The rule on the bridge is that scope comes from me directly, never relayed — one agent cannot widen its own remit by reporting that another agent says I approved something. This sprint that rule was tested three times and held each time, and once it mattered.
Two agents each ended up holding a first-hand answer from me about the App Store name, given hours apart, and the two answers assigned the same two strings to opposite fields: one had “Domi” as the name with the descriptor as a subtitle, the other had the descriptor inside the name. Neither applied its version. Both flagged the conflict and stopped.
Which was lucky, because I then changed my mind entirely. “AI Family Office” reads as wealth management and tax planning, which Domi does not do — and a name implying financial services you don’t provide is an App Review problem before it’s a positioning problem. The name is Domi - Household Office. Had either agent shipped its perfectly legitimate first-hand instruction, that reversal would have landed on a live store listing instead of an unmerged docs pull request.
The design agent also owned something I’d have let slide: the ruling reversed a rule they had published themselves, and they said so explicitly in the design system rather than letting the new version read as though it had always been the rule.
Names for everything
The other half of the sprint’s accessibility work has no visible output at all, which is exactly why it had rotted. Forty-five of sixty-five buttons declared neither a role nor a name. All twelve text fields and the single switch were unnamed — and a placeholder isn’t a name, because it disappears the moment you type, so the field loses its label precisely when it has content worth describing. Calendar cells announced as a bare number with no month and no weekday. Shopping rows were buttons that never said whether they were already ticked, which is the one thing you need when working down a list by ear.
Related, and overdue: the app now says on the row itself when something hasn’t left your phone. “Queued is never presented as synced” is a rule the architecture has had since the beginning, and it was being satisfied by having nothing to show — a task captured in a parking garage looked identical to one the server had accepted, and the only way to discover a stuck outbox was to open Settings and read a number.
Two numbers I was given wrong
For the ledger. The agent reported two figures to me this sprint that were wrong, and corrected both in the same session without being asked.
It told me sixty-one of sixty-five controls lacked accessibility labels. The real number was forty-five — its detector was splitting JSX tags at the first > character, which lands inside arrow functions like onPress={() => shift(-1)}, so it was counting controls that were already labelled as though they weren’t.
And it told me our design tokens carried no mobile colour map, offered as the reason a second palette couldn’t ship. The design agent checked the published file and it did, with a checksum attached. The real reason was different and more interesting — our styling layer bakes one palette in at build time, so a runtime switch is real work rather than a missing publication — but the wrong reason had already been written into a code comment, a roadmap row and a pull request description, where it would have sent the next person hunting for values that were sitting in the file the whole time.
I’d rather have both of those corrections than a tidier-looking sprint log. An agent that only reports wins is one you can’t calibrate.
What I didn’t do
M4’s job was to put version 1.0.0 on the App Store. It didn’t.
The remaining blocker is a suite of ten offline release gates — airplane-mode cold start, process-death recovery, reconnect convergence, revocation purge, cache-encryption inspection — of which five need me sitting with a phone for about an hour. That table is still empty. It has been empty for three sprints, and the honest reading is that every sprint which fills up with genuinely good work is a sprint that doesn’t spend the hour.
Also outstanding: the accessibility pass on an actual device with VoiceOver on, French at the largest text size, and a decision about tab-bar icons that’s mine to make and nobody else’s.
M5 is the weather sprint
Which sounds like a strange thing to build for a household app, so let me say why it isn’t.
Every phone already has a weather app. They are also, almost without exception, advertising surfaces — built to hold your attention rather than answer your question and let you go. You open one to find out whether you need a coat and leave ninety seconds later having read about a storm in another province.
That isn’t the question I have in the morning. My question is what is today, and what does it require of me — and one of the things it requires is knowing how to dress. In Montréal that is not a trivial ask. The weather here swings hard and fast; a morning that starts below zero and ends well above it is an ordinary Tuesday, and getting it wrong means a child in the wrong coat at eight o’clock.
The larger ambition is simpler still: fewer apps. Domi is meant to become the one application a busy household actually opens — the place where the day, the calendar, the errands and the obligations already live. Every separate app that has to be checked is a small tax on people who have no spare attention to pay it. Weather belongs there for the same reason the agenda does, and that is exactly why it went on the Today screen rather than behind a sixth tab: it is context for the day, seen every time the app opens, not a destination you navigate to.
There is a second effect I’m not going to pretend is accidental. Weather is a reason to open Domi in the morning — and once it’s open, so is everything due today and everything coming this week. A household app has to earn a daily habit before it can be useful, and a genuinely useful morning glance is a better way to earn one than a notification.
And then there is the line the design agent wrote into the spec and explicitly deferred to a later version:
Snow Thursday — the Mazda’s winter tire swap is due.
No weather app can say that. Domi has the obligations, the region packs, the asset records; connecting them to a forecast is one sentence of product and a great deal of plumbing. The plumbing goes first.
The screen itself is already built — a strip on Today that opens a full panel, with the forecast coming from Domi’s own backend rather than from the weather provider directly, so my household’s location never leaves for a third party. As I write this the backend half went live on production and answers a polite 503 until one environment variable is set, which is the kind of last mile no amount of agent coordination can do for you.
And the release gates are still waiting for me, an hour, and a phone in airplane mode. Fourth sprint running. I’m aware.