Phase 7 — The redesign — ships as 1.4
Status: sketched, not planned in full. This file holds the shape of the phase and the decisions that
block it; the per-screen worklist is deliberately not written yet, because it cannot be written before
the visual language exists. The boxes that do exist are in DOD.md §6. Finished phases live in
PLAN.md, which is 3 000 lines of record and is not needed to build this one.
Prerequisite: Phase 6 ships first. It adds a Support screen; designing it in the old language and then again in the new one is the same screen drawn twice.
Nothing here blocks on Phase 5’s open evidence. No schema, no alarm, no permission, no dependency — the same reason Phase 6 is safe to build while the overnight run and Play’s count are outstanding.
Decisions it leans on: ADR-0001 (never infer a problem from missing data), ADR-0013 (every user-visible string is a resource), ADR-0020 (all image writes go through the media helper), ADR-0026 (what the copy may never say).
What ships
The same app, easier to use. Every feature stays, every route stays, every table stays. What changes is the look: palette, type scale, spacing rhythm, how a list row and a card are drawn, what an empty screen says, and how much of a screen you must read before the useful thing is visible.
25 routes today, 26 once Phase 6’s Support screen lands — five tabs (Home, Weight,
Observations, CareAndMeds, More) and the editors, galleries, viewers and setup screens behind them.
The scope constraint is what makes this tractable: no schema change, no new nav key, no new dependency, no migration. The existing test suite stays valid from the first commit to the last, so the phase ships screen by screen rather than as one long-lived branch, and every step is independently verifiable on the device.
The decision that blocks everything else
theme/Theme.kt ships dynamicColor: Boolean = true. On Android 12+ Binky takes its palette from the
user’s wallpaper, so LightColorScheme is only ever seen on API 26–30. A brand palette designed
today would be invisible to most of the people who install the app.
That is a fork, not a detail, and the whole phase inherits whichever way it goes:
- Keep Material You. The redesign becomes layout, hierarchy, density and spacing; colour is used
through tonal roles (
primaryContainer,surfaceVariant) and Binky looks different on every phone. - Own a brand. Dynamic colour goes off, or becomes a Settings toggle defaulting off, and
theme/Color.ktstops beingandroid create’sPurple40.
The recommendation is the second. A listing with no reviews and no installs gets more from a recognisable app than from wallpaper harmony, and the current state is neither — the theme is still the scaffold’s, so there is nothing to preserve. A toggle keeps Material You available for anyone who wants it, at the cost of one preference.
Decide this before any mockup exists. Every other visual choice is downstream of it.
Decided, 2026-08-06: own a brand — ADR-0027
Dynamic colour defaults off; Material You stays available as a Settings toggle. The argument that settled it was not the one written above, and it is worth recording which one did.
It is ADR-0012 that forces this, not brand preference. That ADR’s first rule buys the whole redesign:
colours come from MaterialTheme, never literals — the visual pass then edits one file. With
dynamicColor = true, on Android 12+ nothing reads that file. The visual pass could pick a palette, wire
it in, build, install, and see no change at all, with no error to explain why. Turning dynamic colour off
is what makes ADR-0012’s promise true on a device anyone owns.
Two consequences land on this phase directly. A full scheme is owed, not three roles — the current
lightColorScheme(primary, secondary, tertiary) leaves surface, background, outline and every
on*/*Container at M3’s baseline, which is itself purple and which most users will now actually see.
And the toggle needs a label and help line as resources in both locales (ADR-0013), which is the first
answer to this file’s “does any string change?” question: yes, at least two.
Why it runs before Phase 8, not after
Phase 8 is 631 translatable resources × 7 new languages ≈ 4 400 strings, each read by a native speaker before its language ships. A redesign aimed at user-friendliness rewrites copy by definition — the section header nobody understood, the empty state that explained nothing, the button whose label was the reason people tapped the wrong thing.
Ship the languages first and every one of those rewrites costs seven re-translations and seven re-reads. The ordering is not a preference; it is the difference between drafting a string set once and drafting it twice.
(If the phase turns out to be strictly pixels — not one string changed — the constraint relaxes. Do not assume that: assume copy moves, and let it be a pleasant surprise if it does not.)
What the redesign may not change
“Same functionality” is easy to say and easy to lose one screen at a time. These are the rules the new look inherits, all of them already load-bearing somewhere:
- Weight changes are always shown in grams, whatever the display-unit preference —
−0.04 kghides the signal that−40 gmakes obvious. A prettier weight card that switches to kilograms is a regression, not a redesign. - The weight chart plots real timestamps, not list index. Weighings are irregular; an evenly spaced chart is a better-looking lie.
- Missing media renders as a placeholder, never a crash. A restored backup may legitimately lack photos, and a new photo grid must survive that on day one.
- All image writes still go through the media helper (ADR-0020). A redesigned gallery that loads full-resolution bitmaps to look sharper blows up in memory.
- Nothing infers a health problem from missing data (ADR-0001), and no screen says missed or overdue outside Phase 4’s care reminders (ADR-0026). New empty states are exactly where this rule gets broken — “no observations” must not become “nothing to worry about”.
- Every user-visible string is a resource, in both locales (ADR-0013). A redesign that hardcodes
English is a Phase 8 bug planted a phase early, and
PolishTranslationTestis what catches it. - Health features observe; they never advise.
Where the design happens
Claude Design (claude.ai/design) — beta, included in the Pro subscription, drawing on the same
usage pool as everything else. It renders HTML/CSS, not Compose: nothing comes back as shippable
code, and the translation to Modifier chains and MaterialTheme.colorScheme roles is done by hand.
So it is a mockup surface, not a codegen path, and it is used for the part where iterating is expensive in Compose and cheap in HTML:
- the visual language — palette, type scale, spacing rhythm, list-row and card treatment, empty states, the shape of a primary action;
- two hero screens,
HomeandWeight, drawn until they are right.
Then stop. The remaining 24 routes are applied in Compose directly, against the language those three artefacts fix. Mocking a screen in HTML that will be hand-written in Kotlin anyway is work paid for twice — and the phone, not the browser, is where “easier to use” is actually judged.
The “before” set is an input. Every screen captured before anything changes: it is what the design work is a response to, and afterwards it is the only way to answer “is this better?” with something other than an opinion.
scripts/screenshots.py is what takes it, and again at the gate — same scenes, same cells, so the two sets
are comparable by construction rather than by care. It imports edge-to-edge.py’s 61 scenes rather than
copying them: those tap sequences are the expensive asset in this repo, and two drifting copies would both
go on producing screenshots, just of the wrong screens.
Its matrix is theme × locale where edge-to-edge.py’s is rotation × navigation mode, and it drops
the inset arithmetic that is the other script’s whole point. Portrait + gesture only, deliberately: the
gate below re-runs the orientation matrix in full, and shooting one design in four orientations teaches
nothing about the design. The first set is light + dark, English, 61 scenes each.
Output stays out of the repo — docs/edge-to-edge/ holds two PNGs, the pair that illustrated one
finding, and that is the convention: the full run is evidence to look at, not to commit, and only the shots
that make a point get checked in.
Where the design actually lives — read this before starting a sub-phase
Project “Binky mobile app design”, 748bb56e-50eb-4b44-9a9e-7b6af513d47e:
https://claude.ai/design/p/748bb56e-50eb-4b44-9a9e-7b6af513d47e
A session reads it with the DesignSync tool, passing projectId explicitly. list_projects
returns an empty list — it filters to design-system projects and this is an ordinary one. That is not
evidence the project is missing; pass the id and get_project/list_files/get_file all work.
| File | What it is |
|---|---|
github.md |
Read this first. A screen map: every mockup against the repo files it lands in, plus a Not yet drawn list. Small, and it saves opening the big file to find out what exists. |
Binky Design Language.dc.html |
All the mockups. See the truncation trap below. |
support.js |
The generated dc-runtime canvas renderer. No design content — never read it. |
uploads/before/{light,dark}/ |
The before set, re-uploaded into the project. |
The trap: get_file caps at 256 KiB and truncates with no error. Binky Design Language.dc.html is
over the cap, so it comes back cut mid-attribute and looks complete. Check for a closing </x-dc>; if it
is absent, the file is partial. The document is ordered newest turn first (t7 → t1), so the cut
eats the oldest turn — which is turn 1, the design language and the two hero screens, i.e. exactly the
part a visual pass needs. Export the file to disk from the browser, or split it in the project, rather than
reasoning about what is in it. (A read on 2026-08-08 lost the tail of 1c onward and wrongly concluded
Weight had never been drawn; github.md says it was.)
Structure, for reading it a piece at a time instead of whole: <section class="dv-turn" id="tN"> per turn,
<div class="dv-opt" id="NX"> per mockup (1a, 1b, 4c2, …). Split on the opt divs and open one.
Solved on 2026-08-09 — export the project and slice it locally. The exported
Binky Design Language.dc.html is 295 007 bytes, so get_file’s 262 144-byte cap cuts it 733
bytes into 1c — which is exactly the 2026-08-08 symptom, and puts 1d (starts at 270 361) and
1e (282 279) permanently out of reach of that tool. There is no read that gets them; the export is
the only path.
Once exported, never read the file whole — it is ~72k tokens and almost all of it is markup.
Slice one dv-opt and strip the tags: that is ~1.5k tokens per frame, and the stripped form keeps
the colour and size hints that carry the spec. The byte offsets of every mockup come from one
grep -bo 'class="dv-opt" id="[0-9a-z]*"', and diffing a light frame against its dark twin is
how to read a dark variant for almost nothing — it is a pure role swap, so the diff is the
information.
Two things in the project that the brief did not ask for
- The calendar (
7a/7b) is out of scope. The doc itself calls it “the one thing in this pass that is not in the app today”. This phase adds no route and no nav key — park it for a later phase. - The colours in the mockups are hand-picked, and the doc says they are not. Section 1 claims all the
roles are “generated from these by Material’s tonal palette builder. Nothing below is hand-picked.” They
are not on a tonal grid: the stated
P40is tone 42.2 at chroma 42 against a seed of chroma 32, and the neutral family runs hue 94.6°/89.5°/88.9° and then 48.2° foronSurface. A generated palette holds hue constant per family and lands on exact tones. So take the four seeds and generate — do not transcribe mockup hexes.scripts/gen_scheme.pyis that generator (withscripts/hct.py, a CAM16/HCT port); it emitstheme/Color.ktwhole, and it is verified against Material’s published baseline scheme. Most roles land within dE 1.3 of the mockups;primary,outlineVariantandonPrimaryContainerdiffer visibly, and that is the accepted cost of a scheme whose contrast holds in both themes. Edit the seeds in that script and re-run; never hand-editColor.kt.
Order of work
- Capture the before set —
scripts/screenshots.py, on the Xiaomi. ✅ 2026-08-06. -
Settle dynamic colour. Nothing else starts first. ✅ 2026-08-06 — ADR-0027, see above.
(These two are listed in this order and
DOD.md§6 says the colour decision blocks everything. Both are right: the capture is a photograph of what already ships, so it depends on no decision and is the one task that can run alongside one. Nothing that changes a pixel starts before step 2.) - Fix the visual language in Claude Design, plus
HomeandWeight. ✅ 2026-08-08 — see the project above; it went well past the two hero screens, so most routes now have a drawing to work against. -
Theme commit first —
Color.kt,Type.ktandTheme.ktstop being the scaffold’s. One commit, and the whole app moves at once; every screen after it is an adjustment rather than a reinvention. ✅ 2026-08-08.Color.ktgenerated from the seeds, both schemes in full, 22 contrast checks passing in light and dark.Type.ktcarries the scale; Nunito is a new bundled asset (res/font/, OFL indocs/licenses/) — not a Gradle dependency, so ADR-0009’s quarantine is untouched.Spacing.ktadds the six steps.dynamicColornow defaults off, which is the half of ADR-0027 that ships.✅ 2026-08-09 — the Settings toggle landed, so ADR-0027 is whole: dynamic colour is now off by default rather than unavailable.
material_youinAppPreferences.kt, a switch row on Settings hidden below Android 12 (no wallpaper palette to take, and a control that provably does nothing is the furniture ADR-0013 warned about), two strings in both locales.One structural thing came out of it, and it is worth knowing before touching the theme again. The preference has to be read in front of ADR-0007’s gate:
BinkyThemewraps the schema-mismatch screen as well as the app, andBinkyApplication.containeris thelazythat is the wipe guard — readingcontainer.preferencesfromMainActivitywould have forced the gate open from inside the screen standing in front of it. So the oneAppPreferencesnow lives on the application and is handed to the container. Anything else the theme ever needs to know follows the same path.Two traps found doing it, both silent:
FontVariation.Settingsis ignored for resource fonts. Nunito ships only as a variable font whose default instance is ExtraLight 200, so the app rendered thinner than before with no error anywhere. Pin thewghtaxis in an XML font resource instead —res/font/nunito_bold.xmlis the pattern.- Dynamic colour was hiding wrong role choices. The trend flag card was
errorContainer; with the brand on it became an alarm-red panel, against ADR-0026 and against the design’s own apricot marker. NowtertiaryContainer, and no screen referenceserrorContainerat all — the right end state for an app with no emergencies. Expect more of these as screens land: roles picked when nobody could see the result.
- Screen by screen, tab by tab, starting with the two that were mocked.
- Re-capture and compare, same routes, same locales.
The rewrite checkpoint — every route to the new language
Step 5 of the order of work, expanded. The theme commit moved the whole app at once; each item below is an adjustment against a drawing, not a reinvention, and each is its own commit that builds and installs.
Commit rule for the whole sweep: feat: and fix: only, never feat!: — see the 1.4 decision below.
Mockup ids are the dv-opt ids in the design project (1b, 4c2, …); see Where the design actually
lives for how to open one without loading the whole file.
| Route | Mockups | Notes |
|---|---|---|
Home (bunny selected) |
1b / 1c |
✅ 2026-08-09. Hero. Dark is not a tint — the flag card climbs to surfaceContainerHigh |
Home under All bunnies |
4a / 4b |
✅ 2026-08-09. The flag stays inside the bunny it belongs to |
Home, no bunnies |
4c / 4c2 |
✅ 2026-08-09 — code only, not yet seen on the device: the phone holds the sample fluffle, and emptying it to look at one screen would take the Doze run’s seed with it (DOD.md §1) |
| Bunny switcher | 4d |
✅ 2026-08-09. Four items; Archived deliberately absent |
Weight + chart |
1d / 1e |
✅ 2026-08-09. Hero. The row lost its buttons, so deleting moved to the editor; the chart’s points became rings filled with the card |
| Record a weighing | 6e / 6f |
✅ 2026-08-09. The only oversized input in the app, and the last-five line is adopted. Building it found a shipped bug on the screen in front of it |
| New course | 3e |
✅ 2026-08-09. Same six fields, same words — and Save moved to the app bar, which is the drawing’s own recommendation against its own frame |
| Record a dose | 3f / 3g |
✅ 2026-08-09. Stays a dialog, and fixes the rules for every dialog — they live in ui/common/Dialogs.kt |
| Trend flag card | 1b and every card that nests it |
✅ 2026-08-09, and it moved twice. errorContainer → tertiaryContainer → a quiet surfaceContainer card with a 10dp apricot dot. The drawings are explicit: “the flag card is the same surface as every other card — apricot arrives as a 10dp dot”. A whole panel of colour asserts an urgency the sentence inside it disclaims. Apricot as a fill survives in one place only, the active watch row |
Observations |
2a / 2b |
✅ 2026-08-09. Four decisions, all of which generalise — the scope chip, the dense fact block, the tray’s own subheading, and hay for what the owner recorded |
| Record an observation | 2c / 2d |
✅ 2026-08-09. Fixes the form rules for every editor — they live in ui/common/Forms.kt |
CareAndMeds |
3a / 3b |
✅ 2026-08-09. Today’s doses, then the courses that generate them. The largest redraw so far: four sections of 64dp rows, three deletes moved, and the delivery caveat rewritten |
CareAndMeds, no bunnies |
3c / 3d |
✅ 2026-08-09. One sentence in a card the size of a row, and nothing else |
| Vets + vet editor | 5a / 5b |
✅ 2026-08-09. One grouped card where two vets used to fill two thirds of the screen, and the fifth delete to leave a list row. The editor has no drawing and changes no string |
| Bunny editor | 4e |
✅ 2026-08-09. Same eight fields, same order, same words. Settles one of the four open decisions: the field-absent states already ship |
| Archived bunnies | 4f / 4g / 4h |
✅ 2026-08-09. The one list that keeps its buttons — Open leads to a read-only bunny, so there is nowhere for Delete to move to. 4f’s change is the weights |
More |
6a / 6b |
✅ 2026-08-09. Same six destinations, same copy. Six headings with paragraphs become six 64dp rows in one card |
| Backup & restore | 6c / 6d |
✅ 2026-08-09. Six section rules become the header rhythm, and the automatic-backup status takes the apricot dot — which settles the stale-backup marker |
Settings |
9a / 9b |
✅ 2026-08-09. Built by hand while still undrawn, then checked against 9a when it arrived — the structure held. The drawing corrected two things: the current language moves into the row’s trailing slot, and the two debug blocks group under one Debug builds only header. 9a predates the Material You row and does not show it; kept |
Support |
9c / 9d |
✅ 2026-08-09. One filled button per screen, and it is the one that matters most — now app-wide. First card to hold a chevron row and a fact row side by side, which cost the idiom SingleLineRowHeight |
| Chart empty states | 8a / 8b |
✅ 2026-08-11. Four states, not one. The app’s only artwork: a 240×96 grid with one ring, drawn on the two single-point states and deliberately not on the other two — an empty grid says nothing the sentence has not, and starts to read as a chart that failed to load. The ring is the plot’s own marker at the plot’s own size |
| Watch expiry prompt | 8c / 8d / 8e |
✅ 2026-08-11. 8d is the case with no live flag, “where silence must not read as good news” — ADR-0001 drawn, and it already shipped correct. The redraw is BinkyDialog, the nested flag, and a quiet Close it. The seed cannot show 8c — it pairs the flag with the running watch on purpose |
| Documents list | 10a / 10a2 |
✅ 2026-08-11. Three changes, none of them this screen’s own decision: rows group into one card, each gains a chevron, the thumbnail rounds. The empty state moves in place, which cost MessageCard an optional title |
| One document | 10b / 10b2 |
✅ 2026-08-11. The one route where “put it in a card” is the wrong answer — the page stays full-bleed, and gets surfaceVariant under it so a page that does not fill the frame has a ground rather than a gap. Delete document drops to onSurfaceVariant inside the menu |
| Photos | 10c / 10c2 |
✅ 2026-08-11. The one screen that goes edge to edge, and the drawing says so — the grid keeps its 2dp bleed while everything the app says stays in the 16dp gutter |
| One photo | 10d / 10d2 |
✅ 2026-08-11. The caption line is the only thing drawn: a missing caption stays onSurfaceVariant and reads Add one, so it cannot pass for a caption that failed to load |
| Setup, steps 1-3 | 10e–10g / 10e2–10g2 |
✅ 2026-08-11. The wizard is the one place with no app bar, no switcher and no nav, so the step counter is the only orientation on screen — tracked out to read as a position, not a heading — and the title takes headlineMedium, the display face the wizard alone spends. Step 2’s two horizontal rules become the header rhythm and its four loose paragraphs become one card. Step 3 answers two filled buttons on one screen with containment rather than demotion |
| Reminders opt-in | 10h / 10h2 |
✅ 2026-08-11. Every drawn state already shipped — blocked lines in onSurfaceVariant not error, the button swapping to Open notification settings, the battery card absent when nothing will be delivered. What it was missing was a name: the block opened mid-explanation. The drawing’s sheet framing is not adopted — see below |
| Schema mismatch | 10i / 10i2, 10j / 10j2 |
✅ 2026-08-11. Two variants, one screen: the path and the two sentences about the copy group into one card, and the path gains a container of its own because a file path wrapping mid-name on a plain background is unreadable and this one has to be typed somewhere else. The single deliberate difference is emphasis — share is filled in release, where it is the only action, and outlined in debug, where the continue outranks it |
| One course | 10k / 10k2 |
✅ 2026-08-11. The facts and the three actions become one card, with End the course below a hairline rather than a gap — a rule separates two kinds of action, where a gap would separate two subjects. The dose history goes from three stacked cards to one grouped card of rows |
| One reminder | 10l / 10l2 |
✅ 2026-08-11, and it is 10k again on purpose: a course and a reminder are the same shape. What differs is what sits below the hairline — the calendar hand-off, the one action here that is about the repeat rather than this occurrence. Adds the sweep’s only new mark: an overdue reminder takes the [CautionDot] |
| Reminder editor | 10m / 10m2 |
✅ 2026-08-11. Kind, name and interval are one card — one answer in three parts, and the unit chips belong beside the number they inflect. The read-back line sits outside every card, last before the fold. Cost the idiom ChangeableValueRow.stacked |
| Visit editor | 10n / 10n2 |
✅ 2026-08-11. Three cards by kind of thing: two value rows, three typed fields, then the paperwork under its own header with both ways in beneath a hairline. Cost SingleLineField/NoteField an enabled, which is ADR-0004’s archived scope |
The drawings are complete as of 2026-08-10, and four of them are routes this table never listed.
A turn-10 export closes the undrawn half of the sweep: 10a–10n with dark twins 10a2–10n2, 72
dv-opt frames in the file, and github.md ending “Nothing. All 46 frames exist in light and dark.”
Everything above now has a mockup in both themes. Three things are worth carrying:
- The four routes with no row — one course, one reminder, the reminder editor, the visit editor —
were not an oversight in the export but in this table. Three of them were edited by the Care &
Meds redraw, which moved a delete onto each, which is exactly how they stayed invisible: a file
the sweep touches reads as a file the sweep did. Check the table against
ui/, not against itself. - The dark twin is
10a2, not10b. Turn 10 follows4c/4c2, not the1b/1cletter-pair convention of the earlier turns. A script that assumes “the next id is the dark one” pairs the wrong frames here. - The export was replaced nine minutes after it was downloaded — a 418 KB copy carrying turn 10
in light only became a 482 KB one with the dark twins, and every byte offset moved (
9a: 73032 → 136728). Re-grep -boafter any re-download; a slice offset does not survive one.github.md’s closing line is the cheapest freshness check there is.
Two things in the last batch were declined, and both for the same reason: a drawing can describe behaviour it is not the drawing’s place to change.
10hframes the opt-in as a modal sheet over a dimmed Care & Meds, and the app hosts it inline at the foot of that screen. Every drawn decision on the frame — the blocked lines inonSurfaceVariantrather thanerror, the button swapping to Open notification settings, the battery card absent because nothing is going to be delivered anyway — already shipped correct. What is left is a sheet, which is a behaviour change with a trigger to design, not a redraw; and the inline foot of Care & Meds is the point of use ADR-0006 asks for. What the frame did win is the title: the block used to open on a paragraph explaining what reminders are for, and a block that opens mid-explanation cannot say what it is. It takes aSectionHeaderrather than the sheet’s title, because here it is the last section of a scrolling screen. No new string — the wizard’s own step title already says Reminders.10fputs the wizard’s gutter at 20dp and gives the reason as “so the cards line up with every other card in the app”. Every other card in the app is at 16dp, andSpacinghas no 20. The reason is right and the number is the mockup’s own rounding, so steps 2 and 3 takeSpacing.baseand the reason comes true. Step 1 keepsSpacing.section: it is the one step with no card on it, so it has nothing to line up with, and 24dp is the right inset for a screen that is only type.
All six were seen on the phone, and the wizard turned out to be both reachable and recoverable.
edge-to-edge.py already carried wipe(), reset_to_seeded() and scenes for all three steps, so
the seed a wipe takes is put back by the same run — what made the earlier “code-only” call right was
the 5 Aug run that wiped with nothing to restore from, and that gap is closed. Check §1 is not
armed first. fake_schema_mismatch() covers 10i without touching the database; 10j needed a
throwaway destructiveMigrationAllowed() patch, reverted after.
The pass found one real defect, and it is the exact rule 10g exists to answer. pm clear
revokes POST_NOTIFICATIONS, so the honest first-run step 3 is the blocked state — which 10g
never draws, because it draws the armed one. There, Turn reminders on rendered as a bare filled
button directly above Finish setup: two filled buttons on one surface. The fix is the frame’s own
answer applied to a state it did not arrange — the blocked ask takes the same apricot surface as the
battery ask, and its button becomes the filled button of that card. A frame that draws one state
has said nothing about the others, and this is the third time the sweep has paid for that (2a,
4e, now 10g) — the first where the state it skipped was the common one rather than the rare one.
ui/common/Surfaces.kt is where the idiom lives, and every remaining row above depends on it.
The mockups draw the same four things screen after screen — SectionHeader, GroupedCard, FactRow,
RowDivider — so they are written once rather than re-derived per route. It also holds CardRadius
(20dp), NestedCardRadius (16dp, for a card inside a card, which is how dark stays legible where a
tint would carry no meaning) and FabClearance. That last one fixes a bug older than the redesign:
Scaffold pads content for the bars it owns but not for the FAB floating over it, so Home’s
Delete button sat underneath it in the before set. Any scrolling route with a FAB owes it.
GroupedCardItem was added building Weight, and every long list owes it. A grouped card holding
a whole history is a single LazyColumn item, so every row composes whether or not it is on screen —
which is the one thing LazyColumn exists to avoid, and Bijou’s seeded history is already 39 rows. It
draws the same card the other way round: each row carries the surface itself and only the two at the
ends round their outer corners. Observations, Photos, Documents and the dose history all want it.
Build an undrawn route anyway — the structure held when the drawing arrived. Settings was built
against Surfaces.kt and Forms.kt alone, and 9a landed a few hours later agreeing with all of it:
the two cards, the rows, the chip pair, the header rhythm. What it corrected was content placement,
not shape — where the current language sits, and that one header can serve two blocks. The idiom is
now strong enough to predict a drawing, which is the strongest evidence the sweep has produced that
Surfaces.kt was worth writing before the routes rather than after them.
SingleLineRowHeight (56dp), added by 9c. A ListRow with no subtitle is not a 64dp row with a
line missing, and a FactRow next to one is not a 48dp row in a column of facts. Support’s last card
is the first to hold both — Privacy policy opens something so it chevrons, Version does not so it
carries a value — and two different floors in one card read as a rendering fault. ListRow derives it
from subtitle == null; FactRow cannot know, so the caller passes Modifier.heightIn(min = …).
SwitchRow titles at titleMedium, and Settings is why. The row was written on 6c, where it is
the only row in its card, so nothing ever stood next to it; Settings puts a switch row between two
ListRows and M3’s bodyLarge list headline against ListRow’s titleMedium reads as a rendering
fault rather than a distinction. Same size, one step of weight — and the whole point of the undrawn
half of the sweep is that it keeps finding these, because a component only proves its type scale
against its siblings, which the drawn routes never arranged.
Three places where the drawings and the shipped app disagree, all resolved against the drawing. Worth knowing before reading a mockup as gospel, and consistent with the palette lesson above:
- The mockups pad card interiors and row insets at 20px;
Spacing.kthas no such step and its own rule is “no screen invents a seventh”. Built atSpacing.base(16dp). - The mockups set card titles in Nunito 700 at 17px;
Type.ktdeliberately confines Nunito to display and headline, andtitleMediumis the default family. Built astitleMedium. 1ddates the chart’s x-axis “Jul 15”, with no year. The axis keeps its short localized date (7/13/26) instead: the selector offers All, a history can span years, and a month and day with no year would be ambiguous in exactly the range the drawing never had to show.2bputs Observations’ cards onsurfaceContainerLow, one step below every other route’s. Built onsurfaceContainerlike the rest. The note’s actual argument is a relative one — “not up to High … the cards are the content, so they sit quietly” — and in the generated schemesurfaceContaineralready is the quiet level;Highis what it rejects, and nothing here takes it. Weight is a list of cards on the same tab bar one tap away, and two sibling lists on different container levels reads as a rendering fault rather than a decision.2asets the attribute rows 2dp apart. Built at [Spacing.hair] (4dp): the app is committed to a 4dp grid and the difference is a rounding error at this size.3adraws a ”+” FAB on Care & Meds. Not built: ADR-0015 puts the global “+” on Home and Observations only, because it logs an observation and nothing on this tab is one. A drawing may not quietly add a route-level action an ADR placed somewhere else. (Consequently this route needs noFabClearance— nothing floats over its last row.)3aomits the vet visits section entirely, andgithub.mdmaps5a/5bto the vet directory rather than to visits. Visits are per bunny (ADR-0017) and cannot leave this tab, so the section is built by hand in the same idiom, below routine care and above the caveat.3eputs each field’s question underneath its own box as help text, leaving only the example inside it. Built the other way round, withFieldLabelabove: a question below its own answer reads as a footnote about what you just typed, and Forms.kt’s “help belongs to the control above it” was written for footnotes. The amount keeps its real footnote underneath, which is what the rule is for, and both examples stay in the boxes as drawn.10kgives the filled button 52dp and the outlined one beside it 44dp. Neither is taken. 52dp isRecordButtonHeight, which is for the action a tab exists for standing alone between two sections — its own doc says “in a row of peers a default-sized button would be right”, and this is that row. 44dp is the step9calready declined. What carries the weight is the fill, which is9c’s app-wide rule.10mand10ndraw the date button as “Pick a date”. It reads Change, as every other value row in the app does: the label stacked above it already says what changes, andrecorded_at_pick_date(“Change the date”) stays as what a screen reader hears. A new string would cost both locales to say what the row already says (ADR-0013).3eomits the reminder switch, and it should not have: it draws a course with two times set, which is exactly the state in which the switch shows (ADR-0003 hides it only when there are no times). Built inside the Times of day card below a divider — “remind me at these times” is a sentence about the times in that card and about nothing else on the screen.3ashows a hay tick on an answered dose and never draws a skipped one. A tick beside “Skipped” would read as “yes, given”, so skipped takes a neutral bar in the same hay circle — drawn as aBoxrather than an icon, because Compose’s core icon set has no minus andmaterial-icons-extendedis deliberately not a dependency (ADR-0009’s neighbouring argument).5akeeps Edit and Delete on every vet row and argues in its own note only about their relative weight — “Edit is primary and Delete is onSurfaceVariant … they were equal-weight blue peers before”. Built with neither: the row carries a chevron and deleting lives on the editor. That is the drawing’s own argument carried through — the strongest way to stop Delete being Edit’s peer is to take it off the row — and it is1d’s rule, which by this point had already moved five deletes and would otherwise have left the vet directory as the one list in the app that destroys a record from a list row. The whole sweep is now one grammar: a row that is only telling you something opens.3eputs each field’s question under its own box;4edoes the same to Name and then draws Colour and markings the other way round in the same card. The label goes above in both cases, which is the rule3ealready settled — worth recording only because the drawing broke it inconsistently within one frame, so neither reading could be taken as deliberate.
Observations added three things to Surfaces.kt, and every one of them was forced by a drawing
whose reasoning generalises past this route:
TagChip— the read-only chip.2ais explicit that “symptoms are hay chips, not apricot: apricot stays reserved for what the app raises; a symptom is something you recorded”, which is ADR-0001’s line drawn in colour. Not one of M3’sChips: those are all controls, carrying a click, a ripple and a selected state a tag has no use for.denseis the smaller one that rides beside a card title.DenseFactRow—FactRow’s 28dp twin, with no divider, and the distinction is about meaning rather than size: “dividers separate rows that are independent of each other; these are not.” Four droppings facts from one moment are one answer in four parts. Photos, the dose history and the vet record all have blocks shaped like this.RecordButtonHeight/RecordButtonRadius— 52dp and fully rounded, moved out ofWeightScreenwhen Log a healthy day turned out to want exactly the same button. Two routes is where a private constant stops being private.
The scope of an entry became a chip — 2a’s first decision, “‘Observed together with Nugget’
becomes a hay chip in the card’s header row rather than a sentence, so the scope of what follows is
legible before you read any of it.” It kept ADR-0008’s three cases intact and only shortened the
copy: observation_observed_together → observation_with (“With %1$s”), the lone survivor’s
un-named “Observed together” unchanged, and a solo entry showing no chip at all on its own
bunny’s timeline — the screen already is her — but her name under All bunnies, where the chip is
the only thing that says whose entry it is.
The tray facts got their own subheading, 2a’s third decision, and it needed two strings
rather than the drawing’s one: “Shared — the litter tray” is a claim, and a solo observation’s tray
is nobody else’s, so it reads plain “The litter tray” there.
⚠️ One layout bug, found on the device and not in the drawing. The mockups only draw entries with attribute rows, so nothing in them shows a bunny whose entire share of an entry is “Looked for symptoms, saw none” — which the sample data has two of. The gap belongs between two parts of a block, and emitting it after an empty one left that sentence sitting the same 16dp from its own heading as from the next bunny’s, belonging to neither. Which of the three parts exist is now worked out before any of them is drawn, because a part cannot answer “am I first?” from inside itself. The same shape will recur on every card built from optional blocks — Care and the vet record next.
Care & Meds added four more things to Surfaces.kt, and the first of them is the one every
remaining list route wants:
ListRow—FactRow’s opposite number. A fact row is a label and a value the owner reads; a list row is an object they act on, at 64dp with a title, one fact line and a trailing slot. It is what3a’s “five changes” note is mostly about: a dose was a whole card with an internal divider and two text buttons, so three filled the screen, and eight fit now.Chevron— the trailing mark of a row that opens something, decorative to a screen reader because the row’s own text is what it announces.CautionDot— the 10dp apricot marker, lifted out ofTrendFlagUion its second caller.CaveatCard— dot, title, one paragraph, at most one action, onGroupedCard’s raised level.GroupedCard’s parameter was renamednested→raisedwith it: the level says this card is not like the others, and a card inside a card was only its first reason. Spend it once per screen —3bis explicit that it is “the only thing on this route at surfaceContainerHigh”.
The row grammar 3a fixes, and it generalises past this route. A row that is asking you
something carries the answer; a row that is only telling you something carries a chevron. The
drawing states it for doses — Given / Skipped inline, collapsing to a hay marker once answered —
and the same rule is what decides that a care reminder keeps Done only while it is actually
due. The drawings never show a due reminder, so this had to be derived rather than read; it is the
same class of gap as 2a’s optional blocks.
Three deletes moved off the list, and the destination did not already have one. 1d’s rule
applied three times: a course, a reminder and a visit are all deleted from their own screen now, not
from a 64dp row. Where Weight’s editor could simply absorb it, none of these three could —
MedicationCourseScreen and CareReminderScreen delete doses and completions, which are
sub-records, and VisitEditorScreen had no delete at all. Each needed the dialog, a
confirmingDelete flag and three methods; CareViewModel lost all three sets in exchange, along
with PendingCourseDelete. Check the destination before assuming the move is free.
The delivery line became one caveat card at the bottom, and the armed state stopped rendering.
DoseDeliveryLine.kt is now ReminderCaveats.kt, and it renders exactly one caveat, ranked:
doses channel muted → exact alarms denied → battery policy. The states are not independent — one
switch silences both channels, one battery policy delays both — so stacking every true statement
showed the same fix twice under two titles. Taking a fix reveals the next on resume, which the
LifecycleResumeEffect was already doing for its own reasons. The armed sentence is gone
entirely, which is the rule the care line already followed: a line confirming that a working app
works is reassurance an owner learns to skip, and then skips the one that matters. doses_state_armed
went with it, and action_open — the string DOD §2’s needle trap was named after — is now unused and
deleted.
ui/common/Forms.kt is the same bet for editors, and it came out of 2c. FormSection (a
[SectionHeader] over a [GroupedCard]), FieldLabel, HelpText, ErrorText, ChipRow, FormChip
and NoteField — the four rules the drawing writes out, written once:
- chips wrap, they never scroll sideways. The before set cut More than usual and Strung together off the right edge, so the two answers most worth recording were the two hardest to find;
- a section is a card, replacing the hairline rules the old forms drew on the background;
- help text belongs to the control above it at
Spacing.tight, not to the field below; - free text is an outlined box with a placeholder, no floating label.
minLines = 3rather than the drawing’s literal 88dp: a fixed height centres the caret in an empty box, where three lines of room start the text at the top and grow.
Chips are 36dp, which is M3’s 32dp default overridden deliberately — a wrapping grid of chips is the primary control on these screens, not a filter bar above a list. Compose still expands the touch target to 48dp underneath.
3e added four more to Forms.kt, and between them they finish the editor kit:
SingleLineField— [NoteField] one line tall, same 14dp box, same no-floating-label rule. The bunny editor and the vet editor are made of these.ChangeableValueRow— moved out ofRecordedAtField, where it had been private, and given an optional label. Two dates in one card need naming (Starts, Ends); a date and a time do not, which is whyRecordedAtFieldstill passes none. Its screen-reader trick is the load-bearing part: three buttons on one card all read Change and still announce which is which.SwitchRow— a setting, its help line and the switch, with the whole row toggleable and theSwitchitself takingonCheckedChange = null. One semantics node, so a reader announces the setting once instead of a paragraph followed by an unlabelled control.RemovableChip— the third chip, and the three now divide cleanly by what they are for: [FormChip] is an answer you choose, [TagChip] a fact you read, this one an entry you remove. Hay-filled like the tag rather than outlined like an unchosen option, because a time on this list is something the owner put there. The whole chip removes, not just the ✕ — a 12dp target inside a 36dp one is a miss waiting to happen.
Save moved into the app bar, and that was a decision rather than a redraw. 3e draws the filled
button at the foot of the scroll and then argues against it in its own notes — “the rule worth
adopting is the observation form’s, since the bottom button drifts off-screen on a long form.
Changing it is a decision for you, not something to slip in.” Taken, deliberately: back arrow plus a
Save text button in the bar, matching 2c. Every editor left in the sweep now has one chrome to
copy — 6e, 4e, 5b and the archive screens included.
ui/common/Dialogs.kt is the third file of the idiom, and 3f’s notes are explicit that it is
decided once for all of them: 28dp radius, 24dp padding, the title at headlineSmall with the
subject beneath it, actions bottom-right with the confirming one last. Almost all of that is M3’s own
AlertDialog default, which is why BinkyDialog is a thin wrapper and not a re-implementation.
The one thing M3 does not give is the level, and it goes in opposite directions in the two themes.
Light steps down to surfaceContainerLow — the scrim already separates the dialog, so it need not
shout as well — and dark steps up to surfaceContainerHigh, because it has to lift off 50% black
and lighter is the only direction available. One constant cannot say that, which is why there are two.
That forced LocalCardSurface, and it is the first CompositionLocal in the app. A
[GroupedCard] inside a dialog must sit one step above the dialog, or in dark it renders darker
than the thing holding it — 3g calls the pair “the only two-level nesting in the app”. Which level
that is cannot be known by the card, and the alternative was a colour parameter threaded through
GroupedCard, FormSection and RecordedAtField, only the innermost of which uses it. The
pickers are deliberately left alone: DatePickerDialog and the time picker are M3 components with
their own container contract, and re-colouring the frame round a picker that still paints itself
would only make the two disagree.
The retrofit finished on 2026-08-11, and it needed its own commit because it was never a route.
3f decided this file governs every dialog, but the conversions rode along with whichever route
happened to be open — four on 10a–10d, more on the Care details — so when the rewrite table went
all-✅ there were still eleven M3 AlertDialogs left, on no row of it. They are the three weight
prompts (delete, and ADR-0021’s two collisions), the observation delete, TrendFlagDialog,
StartWatchDialog, CompleteCareDialog, ChooseBunnyDialog, and ADR-0004’s two ceremonies in
BunnyDialogs.kt. What remains is three sites and all three are the exemption above:
RecordedAtField and MedicationCourseEditorScreen each frame an M3 TimePicker, and
SearchablePicker is a picker of the same kind. The last carries a comment saying so, because an
AlertDialog that survives a sweep looks like one the sweep missed.
⚠️ BunnyDialogs.kt hid from the count for the whole sweep, and the filter was mine. Every
inventory was grep -v "Dialogs.kt", meant to spare ui/common/Dialogs.kt — and it spared
BunnyDialogs.kt too, by substring, so ADR-0004’s archive and delete never appeared in a total. The
count only came right when the final check listed the paths instead of counting them. Exclude a
path, never a basename, and print what is left rather than how many.
Two of the eleven were not frame swaps, and both cost nothing new. CompleteCareDialog is
RecordDoseDialog’s twin once you see it — the same three questions — so the reminder’s name became
the dialog’s subject rather than a line of body text, the date took RecordedAtField’s section
shape, and the note took its placeholder as its label. ChooseBunnyDialog’s bunny names were bare
clickable Texts at 12dp; they are ListRows in a GroupedCard now, which is what every other list
in the app is made of. Neither needed a parameter added to the idiom — the first batch of the sweep
that cost the idiom nothing at all.
The subject line is real data or it is absent. 3f adds “Metacam · 0.3 ml · for the 8:00 PM
dose” because with two doses a day the course name alone does not say which one you are answering
for. The name and the amount reuse MedicationsSection’s courseTitle, so the row you tapped and
the dialog it opened cannot name the course two different ways. The slot clause only appears
where a slot exists — on the edit path, from dose.scheduledTime — and never on the ad-hoc one,
where by definition no slot is being answered (ADR-0002).
5a and 4e between them finished the kit rather than extending it, which is the first sign the
idiom has settled: two whole routes cost four small additions and no new concept.
MessageCardmoved out ofCareAndMedsScreenintoSurfaces.kt—3c’s one-sentence card, wanted unchanged by an empty vet directory. It is not Care’sEmptySection: that is one section of a populated screen saying it holds nothing, and it stays plain text precisely so it cannot be mistaken for a row.ListRowHeightbecame public. A vet is up to four lines at three weights, so the row is drawn by hand and borrows only the floor — a name-only entry still matches every other row in the app.ChangeableValueRowgrewactionLabelandonClear. Change is the wrong verb beside Not known, which is why4ewrites “Set a birthday” out in full; and clearing needs a second button that reads a bare Clear while announcing which field it empties — the samecontentDescriptiontrick the row already played for its first button, and for the same reason.SingleLineFieldgrewkeyboardOptions, andNoteField’s placeholder became optional. A placeholder is an example, so it is left out where there is no useful one: a vet’s notes are whatever that owner wants to remember, and a specimen would imply the field expects a kind of answer.
Neither route needed a new section idea, and the vet editor changed no string at all — its four
labels read as well above a box as they did inside one. The bunny editor added two,
bunny_editor_section_details and a generic action_clear.
Check the set state on a screen whose sample data is all absent, which is the inverse of the
trap 2a found and just as easy to ship. 4e draws a bunny with no birthday, no breed and no
colour, and the seeded Bijou has none of the three either — so both the drawing and the ordinary
capture show only the empty half of a card whose whole point is the pair of states. Driving one of
each by hand is what proved the row does not crowd at label · value · Change · Clear, and that the
approximate switch lands between Birthday and Breed with a divider each side rather than after both.
⚠️ 6e found a shipped bug on the screen in front of it, and the way it was found is the point.
Opening a weigh-in reminder raised Correct this completion with Delete this completion?
stacked on top, immediately and every time, and cancelling brought them back — the screen was
unusable. CareEventRow.id is null on every weight-derived row (a weighing is not a completion and
has no event to name), and the state resolved its dialog flags with
rows.firstOrNull { it.id == open.event }, so the ordinary no-dialog state matched the first
row whose id was also null. One reminder type, because only a weigh-in’s history holds those rows.
Two lessons, both cheap and both general:
- A scene that reaches a route through another one is testing both. This was reachable by any
owner tapping their weigh-in reminder, and it survived a phase of device passes because
care-reminderopens a reminder that is not a weigh-in — so the one screen with null-id rows was never the one under the camera. Route coverage is not type coverage. - A deterministic scene failure is evidence, not noise. It failed in all four cells, which was read first as a driver flake and rewarded a driver “fix” that changed nothing. Reproducing it with a single raw tap, and then with the row scrolled to a different position, is what ruled the driver out in two runs. Rule out the harness by making the harness irrelevant, not by adjusting it.
RecordedAtField moved with it, so the weight form inherits the treatment before 6e redraws the
rest of it: a titled card of two rows with an inset divider, and buttons reading just Change — the
value each sits beside already says which. The old “Change the date” / “Change the time” survive as
their contentDescriptions, because a screen reader has no value in view to disambiguate them.
2c was worth doing early even though it is not a hero screen — done 2026-08-09, and this is why:
its own label says the form rules get fixed there, and every other editor inherits them, so doing it
after the editors would have meant doing the editors twice.
6a / 6c / 4f are the last three drawn routes, and they cost the idiom one parameter between
them — which is the second sign it has settled, after 5a/4e. What they did add is three rules
about when the rules do not apply:
Morehas no high surface at all, and6bsays so out loud: “a navigation route has nothing to raise”. The one-raised-card budget is a ceiling, not a quota — a screen may spend none of it.Backupspends it on the section the owner cannot control, which is6d’s reading and the clearest statement yet of what the level means: automatic backup is a mechanism Binky can neither switch on nor check, and everything below it is work the owner can actually do. The selected scope row and the photo warning take that samesurfaceContainerHighas a fill inside a card. That is not a second claim to be the exception — the budget is about cards standing on the background — and6dcalls it “one mechanism, reused” rather than a tint ofprimary, which is what stops the chosen scope needing colour recognition to be legible. (It carriestitleMediumas well as the fill, so the two say the same thing.)Archived bunniesis the one list that stays separate cards, and4fstates the exception itself: “a block with three actions of its own is not a row”. It is also the one list that keeps its buttons rather than following1d— Open leads to a read-only bunny (ADR-0004), so the destination offers no actions by definition and there is nowhere for Delete to move to.4ftherefore fixes the weights instead: Unarchive isOpen’s peer because it asks nothing and only restores, and Delete drops toonSurfaceVariant.
⚠️ Two defects came off the device that the code could not show, and both were compounding
padding. The reminder card put a section gap on top of SwitchRow’s own 12dp inside a 64dp
floor, and the paragraph, the switch and the state line read as three unrelated things; and the
screen ends in a footnote rather than a card, so Spacing.tight at the bottom of the scroll put
the last sentence under the gesture bar. Both are now written out explicitly. A component that
carries its own insets and a container that adds spacing are two mechanisms that compound, and the
sum is only visible on a phone.
⚠️ The scope picker’s divider was asymmetric around the selected band — drawn below the fill and not above it, because the first rule only skipped it on a selected row. The fill’s own edge is the seam; a rule along one side of it reads as a rendering fault. Skipped on both sides now.
❓ 4f says “Lived with Marzipan” and the app says “Lives with Nugget”, and the drawing is
arguably right. housematesLabel is shared with Home and the bunny editor, where the present tense
is correct, so adopting the past tense here means a second string in both locales. It is a copy
decision rather than a layout one — an archived bunny may have been rehomed or died, and a present
tense about a bunny who is neither living there nor listed anywhere else is at best stale. Left
unchanged, and listed here rather than taken, because this phase does not rewrite copy on its own
authority.
ListRow gained enabled, and it is deliberately not the same as having no onClick. A row that
is asking a question is also unclickable and must not dim — that is the row grammar 3a fixed. This
is the third case, which only More has: a row that would open something if it could, where the title
drops to onSurfaceVariant and the subtitle says why. The chevron goes with the tap, so an inert row
carries none rather than promising a screen that is not coming.
BackupScopePicker draws rows now, not a card, because on 6c those three rows share their card
with the photo warning and the Export button. The setup wizard, which shares the picker (ADR-0006),
supplies its own — one line, and the only change this batch made outside its three routes.
ℹ️ No scene needle broke on any of the three, and the reason refines the rule rather than
contradicting it. Every needle into these routes reaches into content: settings, backup,
archived, vets, photos, documents and support all tap a More row by its title, and
more’s own six rows are now identical 64dp merged nodes — precisely the tie-breaking hazard Care &
Meds hit. They survive because 6a changes no string, and because no two subtitles contain
another row’s title. A content needle survives a redraw that is structural only; what breaks one
is a redraw that moves or rewords the text it names.
New functionality the designs introduce
This phase’s scope is same functionality, new looks, so each of these is a decision, not a task. They are listed because a screen redrawn from a mockup will otherwise absorb them silently.
- A calendar route (
7a/7b). The doc concedes it: “this is a new route, which your original brief ruled out”. Defer — a new nav key is out of scope by definition, and it wants its own phase. The last-five line on Record a weighing(6e). Adopted 2026-08-09, and it is the only one of the four that was. It is a guard rather than a feature, which is what earns it a place in a phase scoped to appearance:2310and23100look equally plausible in an empty box, and a digit too many silently poisons the very series ADR-0001’s flag then reports on. It reads only weighings the route already loads and excludes the row being edited. Printed oldest first, ending on the most recent reading — the drawing’s order, and the one that reads, since the eye finishes on the number the new one will follow.A stale-backup marker(6c: “the status line gets the apricot marker”, andgithub.md: “the same marker badges a stale backup”). Settled 2026-08-09 building6c, and like4eit turned out not to be new functionality. The staleness rule this line said “does not exist yet” does exist:AutoBackupStatus.Recorded.staleis a fortnight,backup_auto_staleis its own shipped sentence, andstaleis already what decides whether Open Android backup settings appears at all. So the marker adds no rule, no threshold and no copy — it marks the two states the screen was already going to act on (NeverRecorded, andRecordedpast a fortnight), which is exactly theactionableflag that was already there. A fresh recorded backup gets no dot: nothing is being raised, and a permanent marker beside a working net is the reassurance an owner learns to skip, which isReminderCaveats’ argument about the armed state made a second time.Field-absent states in the bunny editor(4e: “birthday — not known”, “breed — not set”). Settled 2026-08-09 building4e, and it was never new functionality. The check this line asked for came back the good way:bunny_birthdate_none= Not known andbunny_breed_none= Not set have shipped with exactly those two words since ADR-0016, and the distinction they draw is real — a birthday is a fact about the rabbit that nobody may know, a breed is a field on a form that nobody has filled in. The drawing was reading the app back to itself.Chips wrap rather than scroll sideways (. Settled 2026-08-12, and the claim is exactly half right — the half that matters being the one it gets wrong. Not checked is a real, tappable, default-selected chip; it is not a stored value. No vocabulary has a2c), and “not checked” is a real value selected by defaultNOT_CHECKEDentry, the column is nullable, andnullis “not checked” (ADR-0001) —ObservationEntity.kt’s own words are “absence with two spellings is absence nobody can query”.NullableChoiceFieldwritesnullwhen the chip is tapped and lights it onselected == null, so the UI asserts precisely what the column means. The drawing was reading the app back to itself for the fourth time this phase (after4e,6cand8d), and the reason it still needed checking is that a chip lit by default is indistinguishable, on a frame, from a value the row stores. No code and no string changed. With this the four new-functionality decisions are closed: one adopted (the last-five line), three that were never new functionality.
Two Weight surfaces are deliberately not drawn, and both need the language applied by hand:
- The chart’s empty state.
1aspecifies it — centred, pinned to 220dp so switching range never makes the list below jump, naming the date of the most recent weighing rather than claiming nothing exists — but only as a spec swatch, never on a real frame. - The watch-expiry sheet.
The inventory is now complete. It was built from the mockups that survived the 256 KiB truncation
plus github.md’s summary; the casualties were 1d and 1e, the two Weight frames, because
turn 1 sits last in the byte stream. Both were read on 2026-08-09 from the export, and the answer
is that they introduce nothing beyond this list — “first” on the oldest row, the · from a visit
suffix and the 39 weighings count are all presentation of data the route already had. So the four
decisions above are the whole of it, and the only one still owed by a built screen is the last-five
line, which lands with Record a weighing (6e).
What 1d did decide, and it is a placement rather than a feature: the history row has nowhere to
put a Delete, so deleting moved to the editor. That is not a loss of functionality and it is not a
new one — but it is the shape every other list-plus-editor pair in the sweep should now follow.
The comparison — what the after set actually says
Done 2026-08-13 against the English after set (62 light + 62 dark), reading 12 route pairs in light:
home, weight, care, observations, more, support, backup, documents, settings,
medication-course, setup-bunny, setup-reminders. Light only, and on structure, density and copy —
never hue: the before set was shot while dynamicColor = true, so its colours are that day’s wallpaper.
The gains are mostly affordance, not decoration, which is not what a “redesign” is usually expected to buy and is the most useful thing this comparison found.
MoreandDocumentsare the clearest pair. Both were content on a bare background with nothing saying a row could be tapped — six headings with paragraphs, three document rows split by edge-to-edge rules. Both are now one grouped card, inset dividers, a chevron per row.Morechanges no string at all, so the entire improvement is that its rows finally look like the objects they are.Care & Medsis the largest single gain. Before, the delivery caveat sat at the top with its own outlined button and ate the first third of the screen; below it fitted two dose cards and one course. After: two doses, three courses, Add a course, the vet caveat and Routine care already begun — about three times the content, with the caveat demoted to one sentence far down. This route on its own justifiesListRowand the 64dp floor.- The weight chart gained its data back. The before chart draws a bare line with no point markers, so a reading is indistinguishable from a vertex — on a chart whose whole point is that weighings are irregular and plotted against real time. The rings fix exactly that.
- Home’s flag card is ADR-0026 made visible. It was a salmon panel shouting while its own text said “an observation about the numbers, not a diagnosis”. The quiet card and the 10dp dot end that contradiction; the panel-and-copy disagreement is obvious once the two shots are side by side.
medication-coursemoved a whole action onto the screen and still got denser: three dose cards with inline Edit/Delete became four-plus rows in one grouped card, and Delete — moved off the Care list by1d’s rule — fits without costing a row.setup-remindersis the one pair that photographs a defect being fixed. The before shot is the exact state10gnever drew: Turn reminders on and Finish setup as two identical filled buttons stacked on one surface. The after shows containment — the blocked ask in an apricot card, its button the filled button of that card. The before set had been carrying the evidence for that bug since 2026-08-06, and nobody looked until the route was rebuilt. A before set is a bug report nobody has read yet.
The honest cost: the record-reading screens got less dense. This runs against the phase’s own aim and is worth stating plainly rather than filing under “breathing room”.
Observationsfits one entry where the before fit one and a half; the fact rows sit ~35% further apart, and the tray subheading and symptom chips add height. The chips are more scannable — but this is a timeline, and a timeline that shows less per screen is a real trade.Backupreaches Export a backup at the fold where the before reached Where exports go.Homepushes Edit / Archive / Delete below the fold; all three were visible before.
On Care the same idiom bought three times the content, so this is the grouped card’s cost showing up where there is less structure to win back — not a fault in it. It is the one dimension on which the redesign is not uniformly an improvement.
✅ FabClearance is proven, and the scene that proves it now exists. Shot 2026-08-13 in both themes:
at the end of Home’s scroll all three of Edit, Archive and Delete stand clear of the FAB, with
the scroll’s own bottom padding holding them off the navigation bar. The bug the constant was written for
does not survive the redesign — which the after set could not have said an hour earlier, because the frame
did not exist. What follows is the finding as it stood; it is kept because the gap is the lesson, not
the outcome.
⚠️ The set could not prove FabClearance on the one route it was written for. Surfaces.kt records that
Scaffold pads for the bars it owns but not for the FAB, and that Home’s Delete sat underneath it in
the before set — yet the scene table has home, home-all-bunnies, home-empty and no home-bottom,
so no frame in either set shows the end of Home’s scroll. The after home shot has the FAB floating over
the About Bijou card with the three buttons below the fold, which is the state the fix is about and not a
view of the fix. home-bottom is added to SCENES on care-reminder-editor-bottom’s rule — a route
whose last row can end up under something owes a -bottom, or every future gate re-litigates it from
scratch.
ℹ️ Three differences that are not the redesign, recorded so the next reader does not chase them: every
date moves (Jul 23 → Jul 30, Aug 4 → Aug 11, Aug 6 → Aug 13) because the seed is relative to
capture day; Support reads 1.2.0 (218) before and 1.3.0 (265) after; and Settings gains the
Material You row, which did not exist when the before set was taken.
ℹ️ medication-course’s before shot is the real course, not the system Settings mis-capture. DOD §2
records that trap for edge-to-edge.py’s matrix run; screenshots.py’s before set is unaffected, so this
pair is usable evidence.
Gate
- All 26 routes visited on the device, against the before set. In English only — the Polish pass
moved to Phase 8 on 2026-08-13, and the reason is that it turned out to be a translation tool
rather than a capture.
--locale plswitches the app and then every scene fails at its first tap, because the needles inedge-to-edge.py’s table are English string literals; resolving them through resource names is Phase 8’s copy-length canary, and building it here would mean building it for one locale and generalising it to nine. What this phase gives up by moving it is Polish copy-length overflow in the redrawn layouts — a longer string clipping or wrapping wrongly — which is already a gate line inphase-8.mdalongside the languages that make it worst. What it does not give up is string parity: ADR-0013 andPolishTranslationTesthold that, and both locales are level (DOD.md§6). The full write-up isDOD.md§7. - 4f’s edge-to-edge matrix re-run in full — both orientations, both navigation modes. A visual overhaul is precisely the change that matrix exists to catch, and it is the one gate here that cannot be argued down.
PolishTranslationTestgreen — a redesign that adds strings adds them in two languages.spotlessApply,assembleDebug,testat each checkpoint;lintat the gate, holding at 0 errors and 0 warnings. NoconnectedAndroidTestis owed: no schema change and no media path.- The Play screenshots owed from Phase 5 are taken after this, not before — otherwise they are taken
twice, and the first set is obsolete before the count clears. See
DOD.md§4.
Open questions
-
1.4 or 2.0?Decided 2026-08-08: this ships as 1.4. Nothing about the data, the schema or the backup format breaks — only the appearance — and a major bump should mean something a user has to act on. A restored backup, an existing install and every migration behave identically before and after, so 2.0 would be telling them something untrue in order to sound impressive.The consequence is a commit rule, and it is easy to break by accident.
release-pleasederives the version from commit subjects, so onefeat!:anywhere in this phase cuts 2.0 — no matter what this file says. A redesign is exactly the work where a!feels earned in the moment, on the commit that replaces a screen wholesale. It is not earned: nothing downstream of that commit has to change. Usefeat:andfix:, and keep the breaking-change marker for something that actually breaks. - Does any string change? Assume yes (see Phase 8’s ordering). Worth answering properly once the hero screens exist, because a “no” would let Phase 8 start in parallel.
- How is “more user friendly” judged? Today the answer is one person’s eye. That is acceptable for a free app with no installs, and it should be stated rather than dressed up as a method.
Closed — 2026-08-13
Ships as 1.4. Every route in the sweep redrawn against the drawings, the dialog retrofit finished, the
244-scene edge-to-edge matrix re-run with no confirmed defect in the redesign, lint at 0 errors and 0
warnings, PolishTranslationTest green with both locales level, and the before/after comparison read and
written up above. PLAN.md’s status list is ticked and DOD.md §6 is emptied.
What the phase actually cost in copy, measured across the whole sweep (61abe63^ → the last route
commit): 29 names added (28 strings and one plurals), 9 removed, 5 reworded in place, 648 →
668 entries — nearly all of it naming rather than new words, and every one of it present in both locales.
Three things carried out of the phase rather than finished in it, each with a home:
- The Polish after set → Phase 8 (
DOD.md§7). It was never a capture problem:--locale plswitches the app and then every scene fails at its first tap, because the needles are English string literals. Resolving them through resource names is a translation tool and that phase’s copy-length canary. What this phase gives up by moving it is Polish copy-length overflow against the redrawn layouts, which is now a Phase 8 gate line beside the languages that make it worst. - The
4fcopy question — “Lived with Marzipan” versus the app’s “Lives with Nugget”. Left unchanged deliberately:housematesLabelis shared with Home and the bunny editor, so the past tense costs a second string in both locales, and this phase does not rewrite copy on its own authority. - The calendar route (
7a/7b) — deferred by definition, since a new nav key is out of scope.
The one methodological result worth carrying into every later phase. setup-reminders’ before shot
contains the two-stacked-filled-buttons defect in plain sight, captured 2026-08-06 and not noticed until
the route was rebuilt in August. And home-bottom did not exist, so the after set could not answer the
one question FabClearance was written to answer. A before set is a bug report nobody has read yet, and
a scene that does not exist is a question nobody can be asked. Both are cheap to fix and neither is
found by looking at code.