The Android port, built by an autonomous loop against the live backend. This page rebuilds from the repo itself — the feature table below is the tracker the agent writes to, and the feed is every commit it has made.
| # | Feature | Status | What was observed |
|---|---|---|---|
| 0 | App installs, launches, renders on device | verified on device | 2026-08-18. Performing Streamed Install -> Success on the A36, am start brings up MainActivity, dumpsys confirms ResumedActivity: ai.trepo.android/.MainActivity, pid alive, zero entries in the crash buffer. Screenshot from the handset shows the design system rendering correctly: cream #F5E9D8 ground, the hard offset shadow with no blur under both the card and the button, heavy uppercase wordmark. It reads as Trepo rather than as stock Material, which was the point of porting the idiom rather than the pixels. Re-verified 2026-08-20 after the tab-bar capsule landed: installs and launches clean on the A36, crash buffer empty across three tab taps. Note for the next run - the runbook's «device address» refused the connection; the handset answered on USB (RFCY322W52W) and on «device address». |
| 1 | Phone auth (send + verify code) | verified on device | Flow verified against production by direct HTTP on 2026-08-17: send-code → {"message":"Code sent successfully"} (200), verify-code → 200 returning user_id «account id» owner «id»", and a JWT. The documented path is wrong, see Spec corrections. App code stores user_id as the owner and never owner_id. On device 2026-08-18: tapped Send code, the code field appeared and the button flipped to Verify (so the round trip landed); entered «code»`, tapped Verify, and the app moved to the kitchen screen. No crash. |
| 2 | Kitchen tab: 2-column category card grid | in progress | GET /kitchen/{user_id} returns 200 with no auth header, shape {owner, items, count}. Sections render in enum order; rows keyed by _id, never index. Items whose category is outside the enum get their own "Unrecognised category" section rather than being dropped silently. On device 2026-08-18: three items added by text rendered under three correct section headers - PRODUCE / DAIRY & EGGS / PANTRY - in enum order, each in the hard-shadow card. Tapping Remove deleted the row and the server agreed (count 4 -> 3). No crash in the buffer. This row was signed off on 2026-08-18 and was BROKEN on 2026-08-21 by an ordinary add - see B6. The verification above was honest when written; what it could not cover is a wire shape this account had not yet produced. Re-driven 2026-08-21 after the fix: two items render under PREPARED & OTHER with correct quantity labels. |
| 2a | ↳ rename an item by hand | verified on device | iOS change #18: every field on an item was editable except its name, and the only route to it was the AI "fix this item" correction - a re-identification, not a rename, which is why users reported it as not saving. PUT /kitchen/{owner}/{item_id} has always accepted product_name. Shipped in 9b9280e. Probed against production first, and the probe found a trap: the server does NOT validate the name. {"product_name":""} returns 200 "Item updated successfully" and the item reads back with product_name: null - the name is destroyed and the row renders "Unnamed item" with no route back except another rename. A 200 gives the client nothing to catch, so blank is refused in the dialog *and* in renameKitchenItem. The optimistic update also needed a pending overlay, not just a local edit: settleEnrichment re-reads the whole kitchen several times after every add, and that server list still carries the OLD name until the PUT commits, so a plain optimistic write would snap back under the user's finger and flip forward seconds later - the B4 failure mode again. pendingRenames is keyed by item id, applied in acceptServerItems, which all six server-sourced load paths now route through so none can miss it; it is retired only if the in-flight name is still the pending one, so a second rename queued behind the first is not reverted, and it is cleared on sign-out. Driven on device 2026-08-20 against a deliberately adversarial fixture - two rows both named "Dill Pickles" - so a rename that matched by name instead of id would be visible. Tapped Rename on the first: the dialog is Trepo white with the card radius, prefilled, and Save is disabled on an unchanged name. Cleared the field and the guard fired - red border, "An item needs a name.", Save muted - and tapping Save while blank was inert, the dialog stayed open. Typed Grandmas Spears and saved: the first row changed and the second stayed "Dill Pickles", and the server agreed on readback (c4653c2a… -> 'Grandmas Spears', e87512a6… -> 'Dill Pickles'). Empty crash buffer. Both fixture items deleted afterwards, account back to count 0. |
| 3 | Check-in by photo → review → commit | verified on device | Whole flow written and driven on device 2026-08-18: tapped Check in by photo, granted the camera, captured, watched the analysing state, and landed on Review. The photo was of a dark surface so the analysis returned 0 candidates, which exercised the empty path: a yellow note reading "Nothing recognisable in that photo. Try again, closer.", the commit button correctly reading Add 0 to kitchen and disabled, and no crash. Populated path now verified on device too, 2026-08-18, using the photo picker rather than a live capture: picked a 12 line receipt, all 12 rows rendered with names, counts, category and storage chips, tapped Add 12 to kitchen, and the server confirmed count 12 with every category a valid enum value. Button said 12, 12 landed - no sign of the 20-vs-18 gap on this path. Fridge path verified separately 2026-08-18 with a fridge interior photo in bulk_inventory_deep: 6 candidates rendered (Maple Syrup, Tub of Spread, three condiment bottles, a tube of paste), all with valid enum categories. Then the include/exclude guard was tested deliberately: unticking one row moved the button from Add 6 to kitchen to Add 5 to kitchen, committing wrote exactly 5, and the excluded row was correctly withheld from the server. That is the 20-vs-18 shape probed directly and not reproduced. |
| 3a | ↳ category dropdown on each review row | verified on device | Options derive from KitchenCategory.entries. Went further: ReviewItem.category is typed as the enum, non-nullable, so the review screen is structurally incapable of sending an out-of-enum category. That is the bug that cost iOS a 36 row backfill; a type is a stronger guarantee than a well-behaved picker. Analysis returned leftovers for a takeout container, which is a valid enum value. |
| 3b | ↳ submit pinned above the keyboard | verified on device | Same imePadding() container as the text-add bar. The button label counts selected, the same list that becomes the payload, so the count and the commit body cannot disagree by construction - the cheapest possible way to never reproduce the 20-vs-18 shape. |
| 4 | Check-in by text | verified on device | POST /kitchen/{owner}/text-add verified against production: "eggs, oat milk, bananas" → 3 rows, correctly categorised dairy_eggs / pantry / produce, with storage inferred fridge / null / pantry. Surfaced as a permanent bar at the bottom of the kitchen screen, not buried in a menu. On device 2026-08-18: typed oat milk, bananas, cheddar cheese into the pinned bar, tapped Add, and all three landed correctly categorised. The bar stayed above the keyboard throughout. |
| 5 | Shopping list: add / check off / delete | verified on device | UNBLOCKED 2026-08-19, and the block was never going to lift by probing harder. The shopping list is not on the grocery host and it is not REST. It lives on the auth host - POST https://1zc0nh8x48....«aws host»/v1/list - and every action is one POST to that single path with an operation field in the body. That is why nine REST-shaped probes on {grocery} all 404'd: right idea, wrong host and wrong protocol shape. Found by reading the iOS client (TrepoAPIService.swift:12 base + :106 fetchItems), which is the source of truth the brief points at, then verified live against production rather than trusted: view / add / set_action / update_item / remove all round-tripped on the App Review account. Full contract in Spec corrections below. On device 2026-08-19: added oat milk, sourdough bread and bananas through the app's own add bar and watched all three render in hard-shadow cards with the iOS checkbox geometry; tapped the sourdough checkbox and it filled red with a tick, struck the label through, dimmed the row to 55% and dropped the header from 3 to buy to 2 to buy - the server independently read back CHECKED. Tapped the x on bananas and it left both the screen and the server. CLEAR CHECKED, which only renders while something is actually checked, removed sourdough and then removed itself. Zero entries in the crash buffer. Account left empty. Design-swept 2026-08-20: the header title was 28sp against iOS's 44 and the CLEAR CHECKED chip was missing its leading check glyph; both corrected in 5ab00ce/1df2f34 and re-driven on the handset. The row stays *verified on device* - the behaviour was never wrong. Both of this row's old caveats are now closed: the suggestions chip row shipped in 4ac308e, and the store *sections* - poster headers, the persisted colour rotation, collapse and a per-group composer - in f5aa551/9e02cb8, both verified on device. What is still NOT built and NOT covered here: drag-to-reorder, group rename, and Add New Group. See the store-groups sweep below. |
| 5a | ↳ hold-to-speak mic on the list | in progress | Shipped d701d2d (transport) + 34aa794 (gesture + UI). This was a genuinely missing feature, not a design gap - iOS has put a red mic beside the list composer since ContentView.swift:390 and Android had no mic on this screen at all. It is NOT Thyme's mic: two voice features on two protocols (see the 2026-08-20 spec correction). Thyme streams NDJSON off ivwu7ls6p8; this is one POST {auth}/v1/voice, Content-Type: application/octet-stream, x-owner-id + x-device-id, whole WAV as the body. The backend executes the command before it answers, which decides the client contract: child_results[0].body came back carrying an already-minted itemUUID, so after this returns the correct move is REFETCH, never an optimistic add - one utterance can also be several commands, or a remove. Verified live against production 2026-08-21 on the App Review account: a 46KB 16kHz mono WAV saying "add bananas to my list" → 200, asr_text: "Add bananas to my list.", text: "Okay, added Bananas on your list.", row created; the row was then deleted, account left clean. On device 2026-08-21: the mic renders at iOS's geometry (42x42, radius 12, red, 2pt border) beside the composer; first press raised the RECORD_AUDIO prompt *in context*; a quick tap was correctly read as a mis-tap and popped Hold to speak rather than sending anything; a 2.5s hold armed the recorder, greyed the tile to the processing fill, POSTed the captured audio to production and came back with "Could not transcribe audio" on screen - the server's own sentence, which is the one deliberate improvement on iOS here (iOS throws a generic "Failed to process voice command" and drops that wording). Clean crash buffer. What is honestly NOT device-verified: the speech-recognised success leg. The handset is remote, so nobody could speak at it; that leg is proved by the production HTTP run above, not by voice, and this row says so rather than claiming a run that did not happen. Also not ported: iOS's full-screen recording overlay and its waveform - the recording state is currently only the tile's own colour change. |
| # | Feature | Status | What was observed |
|---|---|---|---|
| 6 | Saved recipes list + save by link | verified on device | Shipped in 7a9450f; the row said not started until 2026-08-20, which was a bookkeeping failure, not a build one. Seen on device 2026-08-20: the Cook tab renders 12 saved in hard-shadow cards with RECIPE badges, and save-by-link is the pinned bottom bar. b457c57 later fixed two real semantics bugs here (a 202 carrying an id is not a save; a GET that omits a recipe is not a delete). |
| 7 | Custom tags: create + apply to a recipe | verified on device | many-to-many, orthogonal to meal_category. Shipped in 7a9450f. Seen on device 2026-08-20: the filter row renders All plus four real tags, and selecting one filters the list. |
| 7a | ↳ long-press a tag to RENAME | verified on device | 409 on duplicate name MUST be shown, not silent. Shipped in 7a9450f; the 409 path was exercised on device 2026-08-20 and it was broken - see B4. Fixed in c153bb3 and re-driven: opened the tag sheet on Homemade Garlic Bread, long-pressed MikeTest, renamed it to MikeTwo (an existing tag), and the server's own words - "A category with that name already exists" - rendered in a yellow note inside the sheet, directly above the tag rows. The name reverted to MikeTest in both the sheet list and the filter row, so the optimistic rename rolled back by id. Zero entries in the crash buffer. Nothing persisted, so no cleanup was owed. |
| 7b | ↳ long-press a tag to DELETE | verified on device | confirm with the recipe count; cascade keeps recipes. deleteTag exists and shipped in 7a9450f. Driven on device 2026-08-20, against the leftover MikeTest tag which the API showed carrying 2 assignments. Long-pressing the chip opens MikeTest / "Rename this tag, or remove it from your recipes." / Delete | Rename; Delete opens a second, counted confirm reading exactly Delete "MikeTest"? This removes the tag from 2 recipes. The recipes themselves are kept. - the count is real, not boilerplate, and it matched the two assignments GET /recipe-categories/{owner} had returned seconds earlier. On confirming, the chip left the filter strip without a refresh, the header still read 12 saved (cascade kept every recipe, as the copy promises), and the server agreed: the category was gone and assignments came back {}. Screenshot: docs/evidence/tag-delete-confirm-2026-08-20.png. Cleaning up a prior run's test tag was the fixture, so no production tag was destroyed to get this. |
| 8b | Edit a saved recipe (title / ingredients / steps) | verified on device | Missing from this table entirely until 2026-08-21, and missing from the app. iOS SavedRecipeDetailView carries a whole edit mode - startEditing/saveEdits (SavedRecipesView.swift:784-:830) and three edit cards (:971-:1046) - behind an Edit toolbar button (:766). Android had delete and tag-assign on a saved recipe and no way to correct one, so a bad extraction was permanent: a recipe saved with a mangled ingredient stayed mangled forever. Transport shipped de9cc35, UI 65a3ec0. Probed against production before a line was written, and the probe set the signature twice: the request field is steps and the response field is instructions (there is no steps key on the response at all), and omitted fields are preserved while present ones replace wholesale - a PUT of only title left ingredients and steps intact, a PUT of {"ingredients": []} emptied them. So the arguments are nullable rather than defaulting to emptyList(); a caller that cannot tell "leave alone" from "delete every row" wipes a recipe by accident. Both written up under Spec corrections. Two deliberate divergences from the iOS source. (1) Rows are keyed by a stable id, not by index. iOS uses ForEach(editIngredients.indices, id: \.self) (:987), which is the classic identity bug - deleting row 2 of 5 shifts rows 3-5 into identities that already own focus and text state, so the delete reads on screen as *the wrong row's text changing*. EditRow carries an id, so a delete is a delete. (2) The save is not optimistic, unlike every other recipe mutation here. The PUT returns a re-derived recipe - availability, ingredient_matches and the substitution fields are all recomputed against the new ingredient list - so painting the typed text first would show new ingredients beside a stale availability badge until the round trip landed. The row is replaced by id with the server's copy. That second call was confirmed by the device run rather than assumed: deleting one ingredient took the badge from HAVE 7/8 - CAN SUBSTITUTE to HAVE 6/7 - CAN SUBSTITUTE in the same frame the sheet returned to read mode, which is exactly the value an optimistic paint would have had wrong for a second. Driven on the handset 2026-08-21: opened Banana Bread, tapped EDIT, and the sheet became EDIT RECIPE with CANCEL/SAVE in the header and TITLE / INGREDIENTS / STEPS as cream hard-shadow cards, red bullets and red minus-circles - no Material default anywhere. Appended MikeEdit to the title, deleted the 1 teaspoon baking soda row (the remaining six rows shifted up with their own text intact - the id-keying working), tapped SAVE. Sheet returned to read mode showing the new title; GET /saved-recipes/{owner} confirmed title: "Banana Bread MikeEdit", 7 ingredients not 8, and no baking soda. Zero entries in the crash buffer. Restored afterwards with a PUT of the captured original - title, ingredients and instructions all compared equal to the pre-run capture. |
| 8 | Multi-select delete via long-press | built, not yet driven | pre-select the long-pressed row - MainActivity.kt:472 does exactly this. Shipped in 7a9450f. Not exercised on device. |
| 8a | ↳ ownership gate in household scope | built, not yet driven | dim rows you do not own; keyed on savedBy. Shipped in 7a9450f. Unexercised and probably unexercisable here - the App Review account looks like a household of one, so the dimmed state may not be reachable. See the open question dated 2026-08-19. |
| # | Feature | Status | What was observed |
|---|---|---|---|
| 9 | Week view + add meal to a slot | verified on device | Household-scoped per change #17. Built in 62d1186. Driven on device 2026-08-20: the Cook tab's THIS WEEK section renders AUG 16 - 22 with a card per day, four slots each (BREAKFAST/LUNCH/DINNER/SNACK), a red TODAY badge on Thu 20, and the list auto-scrolls to today while the earlier days stay reachable by scrolling up. The </> buttons move the window a week at a time and refetch. Attribution is implemented - entries where added_by_me is false render added_by_name, falling back to "Household" because that field is nullable even for other people's entries - and delete is not gated on the creator. Attribution rendering verified on device 2026-08-20: the App Review account was temporarily moved into household 11586 alongside test fixture TestCompanion («account id») and a seeded entry "Partner Pasta Night" (Fri 2026-08-21, DINNER) was created by that companion. The server returned added_by_me=false, added_by_name="TestCompanion", and the handset rendered the entry card with the byline exactly "Added by TestCompanion" in teal beneath the title - NOT the null-fallback "Household". Screenshot: docs/evidence/attribution-byline-verified-2026-08-20.png. The shared-household state was torn down immediately afterwards (entry deleted, account left the household - both calendars verified empty). Leaving assigned the App Review account a fresh household id: «id» -> 11586 -> 73846. That is expected and harmless because everything keys off the user_id UUID «account id», which never changes. |
| 10 | Free-text entry in a slot | verified on device | source_type: manual. Driven on device 2026-08-20: tapped + on Tue 18 breakfast, got a BREAKFAST · TUE 18 dialog reading "What are you eating?", typed Oatmeal and Berries, tapped Add, and the entry rendered in the slot. Then navigated a week forward and back to force a refetch and it came back from the server, so it persisted rather than only appearing optimistically. Deleted afterwards; account left clean. |
| 11 | Generate shopping list from the week | verified on device | MealListPlanner is a port of iOS SavedRecipesView.swift:863 addIngredientsToList, including the three skip rules and the store = <recipe title> field reuse, with two divergences documented in the file (duplicates collapsed across the week; kitchen check is exact-name rather than personalization, which errs toward over-adding). Both paths driven on device 2026-08-20: with the week's ingredients already listed, ADD MISSING TO LIST reported "You already have everything for this week." - an explicit message, not a silent no-op. Cleared the list and re-ran: "Added 2 items to your list - skipped 4 you have or already listed.", and the List tab showed both rows grouped under the LEMON CHICKEN TRAYBAKE header, confirming the store-field reuse renders as intended. Zero entries in the crash buffer across the whole session. |
| # | Feature | Status | What was observed |
|---|---|---|---|
| 12 | Dish history, grouped by day + collapse | in progress | Collapse ported 2026-08-20 (e9ae100) - the row read plain *verified* while Android was rendering every group and iOS collapses to one. See the Health tab sweep below. Feed transport unchanged: GET {grocery}/dishes/{owner} -> 200 {owner, dishes:[...]}, same host as the kitchen. iOS also tries /dishes?owner= and swallows a 404 into an empty list; the first form answered, so Android calls it alone rather than dressing an outage as an empty log. On device 2026-08-20: the tab fetched, parsed the account's one real dish (Fried egg, 2026-07-02) and rendered the empty state - but the correct empty state: "Nothing logged in the last 7 days." with "1 older dish is on file." underneath. That distinction is not cosmetic. iOS filters the history to seven days, so an account whose only dish is older shows nothing at all, which is indistinguishable from a broken fetch. Scrolls cleanly, zero entries in the crash buffer. The dish row itself is unexercised - no dish exists inside the window on this account and nothing may be written to production to manufacture one, so the row layout is built to the iOS source and not yet seen. |
| 13 | Daily macro rings | verified on device | DailyReference denominators ported exactly (2000 / 150 / 250 / 70). On device 2026-08-20: the full-width calories card renders 0 CALORIES / consumed today with an empty flame ring, and three equal macro cards read 0g PROTEIN / 0g CARBS / 0g FAT, each with its own ring - all in the hard-shadow idiom. Zero is the true total for today on this account, and it is reached by summing the *local* day, not the UTC one. The ring fill is clamped to 1.0 and guarded against a non-finite fill, so an over-target day reads as a full ring rather than a second lap or a crashed draw. |
| 14 | Log a dish from the tab | verified on device | iOS puts a LOG A DISH CTA above the nutrition section. The CTA is now drawn and wired - this row read pipeline built, button not drawn until 2026-08-20, a bookkeeping failure and not a build one, the same way item 15 did. Verified by reading the code: DishLog.kt:185 draws the button (shadow fixed in a526f84), MainActivity.kt:1142 routes it into CaptureScreen(dishMode = true), onCapturedDish calls vm.logDish, and that hands off to DishUploadSession.start (MainActivity.kt:195), whose pending cards surface through vm.pendingDishes into the list at MainActivity.kt:1090. Drawn in a163ac6. The transport underneath shipped earlier in ca9d635: createDishPresign / uploadDishImage / pollDishResult on TrepoApi, plus DishUploadModels.kt, ported from iOS UploadAPIService and DishSessionManager.swift. The flow is POST {grocery}/presign with type=dish -> plain PUT of the JPEG to the returned S3 URL -> poll GET {grocery}/dish/result until is_terminal. Worth knowing: this is the app's first presign. Android's grocery check-in goes inline-base64 via /identify-async, so no part of the upload path was already there to reuse. Three ported guards: DishAnalysis is hand-parsed rather than @Serializable, because the Swift parser takes a number as int or double and ingredients/allergens as an array or a string holding one - and the int/double case is already MEASURED on this backend (calories came back 90.0, see DishRow), so strictness would land a dish the user waited on as blank; a 403/404 on the S3 PUT throws UploadNotRetryable rather than ApiMessage, because those mean the presign expired and retrying a dead URL only delays an honest error; and pollDishResult is one poll, not the loop, so the caller honours the server's poll_after_ms (this endpoint is shared with ~12,600 live iOS users) and can show the fast pass the moment it lands instead of blocking on final. The pending-dish card exists too: DishUploadSession carries a PendingStatus of UPLOADING/ANALYZING/FAST_READY/FINAL_READY/FAILED, so the card can show the server's fast pass the moment it lands rather than blocking on final, and dismiss(id) clears a row by id, never by an index captured across the suspend point (RELIABILITY R3). The compression trap this row used to warn about is closed: CaptureScreen now takes a dishMode flag and a single deliver lambda decides what a photo becomes, so the dish leg gets ImagePrep.jpegForDish (raw JPEG from 2000px, matching iOS compressForUpload) and cannot silently inherit the check-in path's 1600px base64. Still not verified on device: the whole flow writes a real dish to production and this backend is shared with ~12,600 live iOS users, so exercising it needs a deliberate write-then-delete pass, not a casual one. Built and wired to the iOS source; not yet seen end to end. | Driven end to end on the handset 2026-08-20, first time this row has been exercised rather than read. Tapped LOG A DISH on the Dish Log tab, got the dish-mode camera (it says PHOTOGRAPH YOUR PLATE, not the check-in copy), picked a photo, and landed back on the tab immediately with a pending card reading Analyzing your dish… / Estimating portion size… over a progress ring at 39%. About forty seconds later the card was gone and the real row was in RECENTLY UPLOADED / TODAY: "Chinese takeout noodles", 1:39 PM, 550 calories, 22g / 74g / 18g, with the emoji thumbnail. The daily rings recomputed at the same moment, 190 -> 740 cal, 6 -> 28g protein, 35 -> 109g carbs, 3 -> 21g fat, i.e. exactly the dish's macros added to the day. Ran a second dish afterwards on the fixed build (430 cal, 620 total) to confirm it repeats. So presign -> PUT -> poll -> feed all work against production from Android. Evidence: docs/evidence/dish-log-pending-card-2026-08-20.png, dish-log-result-2026-08-20.png. One real bug came out of driving it, now fixed in 7acca74: deleting a just-logged dish resurrected its pending card as a ghost stuck at 95% reading "Results will appear here shortly", because visible() only *hid* a settled pending row for as long as the feed carried the real dish — delete the dish and the hide condition flips back. refreshDishes now calls DishUploadSession.reconcile, which retires the pending copy for good once the feed has taken over. Re-verified on device: after deleting, the list goes straight from RECENTLY UPLOADED to TODAY with no ghost (docs/evidence/dish-delete-no-ghost-2026-08-20.png). |
| 15 | Dish detail / correction / delete | in progress | Shipped in 5ed5c19; the row read not started until 2026-08-20, a bookkeeping failure and not a build one. Tapping a dish row opens the detail sheet iOS reaches from health_dish_tap (MainTabView.swift:16806), with the correction and delete actions under it. Three ported guards, each a bug avoided: recharacterize returns the corrected dish under item without echoing _id, so it is parsed with the sent id as fallbackId rather than dropped; the corrected row keeps its original createdAt, because a server date that moved would relocate the dish in the grouped list or push it out of the seven-day window, which reads to a user as "my correction deleted my dish"; and delete restores by id, never by an index captured across the suspend point (RELIABILITY R3). deleteDish also stopped throwing a bare IllegalStateException, which had made the server's own error text unreachable. Layout measured on device 2026-08-20 - that is how the nav-bar overlap (3d8ee41) and the red Cancel button (51b8f40) were both caught. The round-trips are deliberately unexercised: the sheet only opens from a row inside the seven-day window, this account's one real dish sits outside it, and manufacturing or deleting production dishes to exercise a destructive path is not a trade worth making. Built to the iOS source, not claimed as verified. | Detail and delete driven on device 2026-08-20. Tapping a dish row opens the detail sheet and it renders in full: emoji thumbnail, title, Thursday, Aug 20 at 1:39 PM, the four macro tiles (550 / 22g / 74g / 18g), an ANALYSIS block carrying the server's prose and serving line ("Chinese-style noodles in a white takeout box with chopsticks. / Serving: 1 small takeout box (about 2 cups)"), an ESTIMATED INGREDIENTS list, and the CORRECT THIS DISH / DELETE DISH actions. Delete works on a dish the app just logged: the confirm dialog reads "Delete this dish? This can't be undone.", and on confirming, the row leaves the list and the daily rings fall back by exactly that dish's macros (740 -> 190 cal). Delete is NOT reachable for older dishes — on a dish logged an hour earlier the server answers 404 Dish with id 24ecd4fe-… not found and the sheet shows that message rather than pretending the row went away. Confirmed with curl that this is server-side and not the client's id handling; logged under Backend asks. CORRECT THIS DISH driven on device 2026-08-20, so every action on this sheet has now been exercised. Tapping it opens the correction screen (sparkle badge, "Tell us what's wrong and we'll fix it.", a hint reading "e.g. This was a Caesar salad, not a garden salad"), SUBMIT CORRECTION is correctly disabled until text is entered, and typing "This was steel cut oats with raspberries, not blueberries" enables it. The round-trip itself is blocked server-side, not by the client: submitting returned 404 Dish with id 24ecd4fe-… not found - the SAME dead zone already logged for DELETE under Backend asks, now confirmed to cover recharacterize too. Reproduced outside the app with curl on BOTH dishes on this account, including the July Fried egg row that iOS itself created. This is not an Android id-handling bug: iOS parses _id from the same feed into backendId (DishAPIService.swift:131) and sends exactly that to recharacterize (MainTabView.swift:17055), so iOS hits it identically. What IS verified here is the client's failure path, and it behaves: the screen shows the server's own message in red, keeps the user's typed correction rather than clearing it, and leaves SUBMIT CORRECTION enabled so the attempt is retryable - it does not fake success or blank the sheet. Evidence: docs/evidence/dish-correct-404-2026-08-20.png. The happy path stays unproven until the backend dead zone is fixed, so this row remains *partly* verified. |
Each bar is one hour. The nightly gap is deliberate — the loop is scheduled 6am–11pm PT. Peak hour: 21 commits.
c64c8a1
Records what was already exact (the shared DetailCard/Bullet/NumberedStep helpers match iOS value for value) so the next sweep knows where NOT to look, the eight chrome gaps this pass closed, and two things a later pass would otherwise get wrong: the avatar disc was deleted deliberately, and the seal and handle are shipped but unphotographed because no card in today's feed carries a creator.
d74317e
iOS grounds ExploreRecipeDetailSheet in bgSecondary (MainTabView.swift:3085), and TrepoTheme.swift:35 defines bgSecondary AS cream - so the INGREDIENTS, STEPS and NOTES cards sit cream-on-cream and are read by their 2pt border and hard shadow, not by contrast against the page. Android grounded the dialog in cardBg (white), which turned every section card into a popping panel and inverted the figure/ground the Trepo idiom is built on.
Found by LOOKING at the sheet on the handset, not by reading the Swift: the white ground is only obviously wrong once the cream card is sitting on it.
edd0a23
Swept against iOS ExploreRecipeDetailSheet (MainTabView.swift:3066-3288). The section cards, bullets and numbered steps were already exact; every gap was in the sheet's own chrome, which is the part no shared helper covers.
- Order. iOS opens with the hero, then title+creator as one 6-spaced block. Android opened with the title, then the creator, then a small hero third. - The hero was the flattest thing on a screen built entirely out of hard shadows: 200dp/radius 14/no border vs iOS's 280pt, radius 18, 2pt stroke and the 4/4 shadow at 0.85. Now a TrepoCard, with the image inset 2dp and clipped a radius smaller so the child cannot paint over its own border. - Title 23 -> 26 with tracking -0.5. Fifth screen this sweep has found undersized against the same iOS poster scale. - Creator line: the red checkmark.seal.fill was missing, the handle was 13/bold instead of 14/heavy, and Android had invented a 28dp avatar disc the sheet does not have on iOS. The feed card needs an avatar to identify a creator mid-scroll; the sheet is already about one recipe. - Content padding 18 -> 20, block spacing 14 -> 20, notes 13/medium. - The X became the inline Recipe title + red Done, which is the two rows of nav chrome Android had collapsed into one.
715be0a
Row 8b was missing from the table entirely, which is how the gap survived - the milestone-2 table listed save, tag, filter and delete, so 'saved recipes' read as finished while a bad extraction was permanently uncorrectable.
Two spec corrections from probing the PUT before writing against it: the request field is 'steps' and the response field is 'instructions' (no 'steps' key comes back at all), and the update is partial - omitted fields are preserved, an empty array deletes every row. The second is worth the doc line because 'leave alone' and 'delete everything' are one Kotlin default apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
65a3ec0
iOS SavedRecipeDetailView has a full edit mode (SavedRecipesView.swift:502, :784-:830, edit cards at :971-:1046) and Android had none of it - a saved recipe could be tagged or deleted but never corrected, so a bad extraction was permanent.
Two deliberate divergences from the iOS source, both for reasons the iOS code shows:
- Rows are keyed by a stable id, not by index. iOS uses ForEach(editIngredients.indices, id: \.self) (:987); deleting row 2 of 5 shifts rows 3-5 into identities that already own focus and text state, so the delete reads on screen as the wrong row's text changing. EditRow carries an id so a delete is a delete. - The save is not optimistic. The PUT returns a re-derived recipe - availability, ingredient_matches and the substitution fields are all recomputed against the new ingredients - so painting the typed text first would show new ingredients beside a stale availability badge until the round trip landed. The row is replaced by id with the server's copy, which is self-consistent by construction.
A blank title is omitted from the PUT rather than sent as "", matching iOS: the endpoint preserves omitted fields, so clearing the box by accident keeps the old title instead of erasing it.
Not yet driven on the handset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
de9cc35
Ported from iOS SavedRecipesAPIService.updateRecipe (:244). Android had delete and category-assign on a saved recipe but no plain edit, so the whole iOS edit mode - title, ingredients, steps - had no transport under it.
Probed against production before writing, and the probe changed the signature twice:
- The request field is steps; the response field is instructions.
There is no steps key on the response at all. Write one name, read
another - the same shape already documented for the shopping list's
action/checked.
- Omitted fields are preserved and present ones replace wholesale. A PUT
of only title left ingredients and steps intact; a PUT of
{"ingredients": []} emptied them. So the arguments are nullable
rather than defaulting to emptyList(): null means leave alone, empty
means delete every row, and a caller that cannot tell those apart wipes
a recipe by accident.
Test account restored to its exact pre-probe state (title, ingredients and instructions all compared equal after the restoring PUT).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cde1d93
The row read 'manager ported, carousel not built' while ui/Tutorial.kt (644 lines) was already wired into MainActivity and shipping. STEP 1 of the loop exists to catch exactly this - a stale row costs a run, because the next one picks its work from this table and would have rebuilt it.
Row is now 'verified on device' against a real first-launch pass on the handset: all six steps, the already-granted notifications path, and the completeWithExplore exit read back out of shared_prefs. The two exits that were NOT driven are named as not driven.
20a3fb2
The invite step's phone mockup is where a still screenshot of the invite screen will composite in, but it drew Icons.Filled.Movie under the words "Screenshot coming soon" - a clapperboard promising a video that is never coming. Seen on device driving the carousel end to end. The three video steps keep Movie, because there the glyph is true.
Also lands the evidence for this run's device pass.
fda312b
Reading a 1080x2340 capture costs ~3,400 tokens; a design sweep over a dozen screens spends ~40k on images alone, which is what has been blowing the build loop's turn output cap and killing runs after the work is done. 1280 tall costs ~1,000 tokens and stays fully legible.
Downscale runs AFTER the byte-for-byte check, since that check is what proves the chunked pull didn't shorten the PNG.
db0acaf
The overlay reported its own dismissal from a LaunchedEffect keyed on
manager.isVisible. But MainActivity composes the overlay under
if (tutorial.isVisible), so the instant finish() clears that flag the
overlay leaves the composition and the effect is cancelled - it never gets to
re-run with the new key. onFinished therefore never fired for any exit.
The damage was invisible for the skip path, whose route is a no-op, and real for the other two: running the carousel to the end did not open the bulk camera, and "Explore hot recipes" dropped the user on the Kitchen tab. Seen on device - the completion flag persisted true while the tab never moved.
Move the observer up to MainActivity, outside the conditional, where the
dismissal cannot take it down. The overlay loses its onFinished parameter
entirely rather than keeping a callback nothing can invoke. The first pass
consumes a NONE before the tutorial has ever shown, which is correctly inert.
388572e
The invite step's primary CTA opens the share sheet without advancing, so "Skip for now" is the only way forward - and at the 0.44h fixed media height that link sat below the fold on the handset. The longest subhead in the carousel (four lines) plus a fixed mockup left nothing for the bottom bar.
Weight the invite mockup like the video steps so it scales down instead of pushing the CTA off screen, and confine the trailing weighted Spacer to the two steps whose media really is a fixed height. The notifications banner keeps its 0.28h: weighting it would centre a small banner in a tall empty area, away from the copy it explains.
a417328
Seen on device: the Continue button rendered as a sliver at the bottom edge on the three video steps. iOS sizes that media area h - 205, where 205 is a reserve hand-tuned to its own text metrics; in Nunito the caption wraps to two lines and the Android CTA is taller, so the reserve is about 40dp short and the column overflowed. A constant tuned against one platform's font cannot be ported as a constant, so the video area is weighted and takes what is actually left - which is what the 205 was approximating. The trailing spacer drops its weight on those steps, or the two weights would split the slack and halve the video.
0485e4f
The carousel had no caller, so it could not run. Root now owns a TutorialManager and calls startIfNeeded keyed on vm.ownerId - not once-per-composition: on a cold install the owner is null for the whole sign-in screen, so a run-once effect would fire before there was a user and never again after there was one. The completion flag makes the restore path a no-op for anyone who has already seen it.
The overlay is the last child of Root's Box so it covers the tab bar and the Thyme pill, matching iOS's fullScreenCover. Its three exits are routed here because only Root can reach them: finishing opens the bulk camera, the "hottest recipes" CTA selects Cook/EXPLORE, and skipping stays put.
One interaction worth naming: Root already asks for POST_NOTIFICATIONS in context when a check-in starts, and the tutorial's notifications step asks too. Both go through markAsked, and the tutorial runs first, so a new user gets the priming step and the Capture path then correctly does not ask again.
d666e5a
TutorialManager.kt shipped last run with no caller, so a brand new Android account still landed on a cold Home tab while iOS showed six full-screen steps. This is TutorialView.swift's carousel: crossfaded grounds, the paper dot grid, the back/dots/skip bar, the copy block and video caption with its red highlight phrase, and the three-variant bottom bar.
The demo .mov clips and the TutorialInvite screenshot are iOS bundle resources that are not in this repo, so rather than invent onboarding animations this ports iOS's OWN missing-asset card (TutorialView.swift:508, "Demo video coming soon") at the clip's 1080x1350 aspect - a shipped iOS code path, not a guess. The phone mockup gets the same fill inside the real 9:19.5 shell. PhoneMockup's tap-cycle is deliberately left out: it animates between screenshots, and with no screenshots it would be motion pretending to be content.
The notifications step keeps iOS's two different forwards - the primary CTA raises the permission dialog and advances only from the callback, "Not now" advances without ever prompting - and marks asked first, or Profile's card would keep offering a button Android will silently no-op. Profile's invite() goes internal and is reused rather than copied.
Not yet wired to a caller or run on device; that is the next slice.
cf40472
Half the onboarding carousel is video and none of it is in this repo. The clips are iOS bundle resources, and transparent .mov is not a format Android decodes regardless, so this is a transcode request as much as an asset one. Filed as an ask rather than solved locally: three invented animations teaching the app's core loop is not a port.
b5df830
Ports TutorialManager.swift plus the step content at the foot of TutorialView.swift. Step order, transitions, the persisted seen-flag, and every string - kicker, headline, subhead, caption, highlight - copied from the Swift rather than rewritten, including its hard line breaks, which are the designer's and not the layout's.
One deliberate divergence. iOS carries two independent published booleans, shouldOpenBulkCameraOnComplete and shouldOpenExploreOnComplete, and the caller reads them in order. There are three outcomes; a pair of booleans can represent four, and the ordering at MainTabView.swift:158 silently resolves the impossible one instead of preventing it. They are a single TutorialExit here.
That exit is the part worth reading the Swift twice for. advance() past the last step is not a no-op - it is the completion path, and it opens the bulk camera. So finishing the tutorial and skipping it are different endings, and completeWithExplore is a third. Three exits wearing one coat.
Not on device: there is no view yet. Two things the carousel will need and does not have - the three demo .mov clips are iOS bundle resources that were never checked in here, so it needs those assets or a still fallback.
The completed-flag key starts at v1, not iOS's v10. Nobody on Android has seen versions one through nine, and inheriting the counter would assert a history this app does not have.
2c1a689
Two corrections and one find.
The correction: last run's note said ShelfLifeTile was unreconciled. It was not - it has had a row since 2026-08-20, with an evidence screenshot. The error came from counting filename mentions, and that row describes the tile by what it draws rather than by its Kotlin filename, so grep scored it as missing. A filename census finds files with no row. It does not find features with no row, and it invents gaps for any row written in product language.
Run the same census over the iOS source and it earns its keep. Rank the Swift by size against its Android counterpart and the largest file with no counterpart is TutorialView.swift (943 lines) plus TutorialManager.swift (155): the six-step onboarding carousel every new iOS user sees on first launch. Android has never built it, and this table has never listed it.
It was not invisible, which is the part worth recording. It has been named here the whole time - inside the Profile row, as a parenthetical reason for skipping tutorialSection: 'Android never built the tutorial it replays.' Same failure mode as the meal plan yesterday, where the gap sat in a source comment. Twice in two days is a pattern, so the rule is explicit now: a missing feature noticed while porting something else gets its own row in the same commit, never a clause in someone else's.
bb15c0a
iOS wraps the whole timeline LazyVStack in a radius-14 white rect with a 2pt off-black stroke, inset 20 (MainTabView.swift:7112). Android drew the rows bare on cream, so the timeline was the one kitchen surface with no container - the values all matched and the screen still did not look like iOS.
Drawn per row rather than once, because a background behind the LazyColumn paints the whole viewport (a two-item timeline would trail off the screen) and a single Column inside one item{} would give up laziness on a 200-row kitchen. Middle rows push their rounded rect a corner-diameter past the top and bottom so clipToBounds leaves only the straight side edges; the seams disappear. Fill behind, stroke on top, so the 1dp row divider cannot paint over the border.
Also bumps the bottom scroll room 24 -> 80 (iOS's own Spacer(minLength: 80)): the 48dp FAB sits 20 off the bottom and was permanently covering the last row.
eb4fd2e
Records the Explore creator-header sweep against MainTabView.swift:2790, and the
one general lesson in it: post_timestamp had been on the Android model since the
model was written and the view simply never read it. Probing the API would never
have found that - only reading the iOS view against ours does.
Also files two things the next run needs. The ellipsis glyph is deliberately not
ported (dead chrome on iOS, and the likes count in that slot is real data), so a
later pass does not read the difference as a miss. And a uiautomator dump of a
LazyColumn returns nodes in layout order, so the header printed after a title
belongs to the NEXT card - reading it the obvious way made the timeAgo ladder look
off by a rung when it was correct.
The Later-milestones Explore row now says what is and is not verified: the header is proved present by the dump, but the gradient fill and the red seal have no text node and this screen cannot be screenshotted, so their colour is unseen.
255620e
The Explore feed card was faithful from the hero image down and wrong above it. Three facts iOS puts in the creator header were missing:
- The avatar was a flat deepBlue disc. iOS seeds a two-stop diagonal gradient from
the sum of the handle's unicode scalars (exploreCreatorGradient,
MainTabView.swift:3607), so a creator keeps one colour across every card in the
feed. Flat fill threw that recognition away and made a scrolling feed of
identical circles.
- No verified seal. iOS draws a red checkmark.seal.fill at 11pt beside the
handle (:2814).
- No provenance line. iOS draws timeAgo under the handle, and its fallback is
the literal word "Instagram" rather than blank - every card is attributed
whether or not the post carries a date. post_timestamp was already on the
model and simply unread.
The seed walks by code point, not by Char, so an emoji handle picks the palette Swift would pick instead of summing two surrogate halves. Negative elapsed time (clock skew, future-dated post) falls back to plain "Instagram" rather than printing "-3h ago".
Deliberately NOT ported: the trailing ellipsis glyph. It is an Image with no
Button and no action on iOS. The likes count already in that slot is real data
from the same row, which beats dead chrome.
d0af23d
The meal plan was found dead by hand. If one section can be declared and never composed, others can, and the next run has no way to know which - so the check is now written down and run across the whole iOS tree.
Twenty-six some View declarations have exactly one reference in the source,
their own. None of them has an Android counterpart: each was grepped against
app/src/main/java and found nowhere, so nothing already shipped is built on
dead reference code. The list is recorded as the set not to pick up.
Also recorded are the two things the grep cannot see, because both applied to the meal plan and either would have let it through: a live declaration inside an unreachable parent (HomeView, never constructed), and a reachable section whose loader is never called (loadMealPlan, no call site). Follow the chain to a tab root and check the loader too.
Five of the twenty-six are called out as false positives rather than left to be rediscovered - makeBody is ButtonStyle conformance and the trepo* extensions are invoked as .trepoCard() style modifiers.
e608d10
The previous run built the meal plan transport and left the view as the next task. Checking reachability before writing that view is what stopped it.
mealPlanSectionNew is declared at MainTabView.swift:15798 and never placed:
HealthView's body composes logDishButton, nutritionSection, recentDishesSection
and stops. loadMealPlan() has no call site at all - refreshAll() fans out to
metrics and the dish feed only - so the plan is never fetched either. The one
other reader, HomeView.swift:582, sits in a view whose initializer HomeView()
appears nowhere in the source.
So no live iOS user has ever seen a meal plan. Building the Android view would be Android inventing a screen, not matching one, against a generator no client exercises and with no reference behaviour to check against. Same call as the Gemini Live row.
The transport stays - it is measured against production and the double-encoded
plan string is worth keeping - but the row now says STOP rather than "no view
yet", so the next run does not pick this up as the obvious next slice.
The lesson, now twice: a section that compiles is not a section that ships. Check the body, not the declaration.
593aac6
Adds the AI meal plan to the feature table, a spec-drift line for the
double-encoded plan / 202-poll / focus-persistence facts, and a note on how
the sweep found it.
Worth recording separately from the code: this was found by looking for UI files
with NO row in PARITY rather than by re-reading the rows that have one. Three had
none. MealCalendar.kt's header comment had named the missing feature explicitly,
and it stayed invisible because a source comment is not where anyone looks to
choose the next task. KitchenTimeline.kt and ShelfLifeTile.kt are still
unreconciled and are named as the next check so this does not have to be
rediscovered.
4e9f3ab
plan that would have broken itThe AI meal plan is a feature Android has never had and PARITY has never listed - a
whole section of iOS's HealthView (MainTabView.swift:15798, mealPlanSectionNew)
with no Android counterpart and no row saying so. It is NOT the meal calendar: the
calendar is what the household types in and can delete, the plan is read-only and
written server-side off a focus sentence. MealCalendar.kt's own header already
drew that distinction; nothing had acted on it.
This is the transport half only - models plus the two verbs - so the view lands on a contract that was measured rather than guessed.
What measuring changed. GET /meal-plan/{owner} on the App Review account returned
"plan": "[]" - the array arrives as a JSON *string*. A generated deserializer typed
List<...> throws on that, which would have turned the ordinary never-generated-a-plan
case into a parse failure and shown an error card to every new user. iOS defends
against the same double-encoding in parsePlanSlots/parseStringArray, so it is the
contract on both clients, not an Android quirk. explanation_title and
explanation_paragraph also came back as literal nulls, not absent keys.
The POST answers 202 with status: "regenerating" and writes the plan later, so this
is a poll and the API doc says so. A blank focus is omitted from the body rather than
sent as "": the server persists whatever focus it is given, so sending an empty one
would erase a goal the user set earlier while they were only asking for a re-roll.
Verified on production - a posted focus came back on the next GET.
Two behaviours ported deliberately rather than inherited by accident. An unrecognised
status degrades to UNKNOWN instead of throwing, because it only picks which card to
draw and a readable plan should survive a word we do not know. A slot whose meal_type
does not parse is DROPPED, matching iOS's guard ... else { continue } - defaulting
it would file a dinner under breakfast, which is worse than the slot not appearing.
MealSlot is reused rather than adding a second four-case enum beside it.
fc68bbb
Opening the keyboard on the List tab emptied the tab. The header band, the suggestions card and the imePadding'd composer are all intrinsically sized siblings, so the rows' weight(1f) was the only thing that could absorb the IME - and there was less than nothing left to absorb it with. Measured on the handset at 1080x2340: the viewport drops to ~440dp against ~484dp of fixed content, so all three list rows disappeared, the composer being typed into was clipped to a ~10px sliver, and ADD TO LIST went off-screen entirely. The user taps a field and loses both their list and the button that submits.
The fix is also the parity correction: ContentView.swift:673 puts suggestionsRow INSIDE shoppingListView's ScrollView, as its first child. Android had read it as a fixed header. Moved into the LazyColumn as item "suggestions", so the card is what yields when the viewport shrinks.
SuggestionsRow takes its horizontal inset from the caller now, since it renders in two containers with different gutters. Inside the list it takes 0 and inherits the 20dp contentPadding, which also lines it up with the store cards - it used to sit 4dp proud of them.
74c6c42
The transport already existed - renameShoppingItem, operation update_item - but nothing called it, so product_name was the one field on a shopping row you could not fix without deleting and re-adding it. iOS turns the name into a TextField when you tap it (ShoppingItemRow.startEditing).
The optimistic write needs the same pendingRenames overlay the kitchen needed (item 2a): iOS's updateItem calls refreshItems() the moment the write returns, and three other list mutations re-read the whole list too, all still carrying the OLD name until the write commits. Keyed by itemUUID, applied in a single acceptListRows funnel that all four server-sourced load paths now route through, retired only if the in-flight name is still the pending one, and cleared on sign-out.
Blank is refused in the field and again in renameListItem. The kitchen's PUT answers 200 to an empty product_name and then reads back null; this is a different handler and was deliberately NOT probed, because the probe is itself the destructive act on a production row.
db70a8a
Row 5a is a new capability, not a design correction: iOS has had a mic beside the list composer all along and Android had none. Recorded with the leg that is NOT device-proved called out explicitly - the handset is remote so nobody could speak at it, and the speech-success path rests on the production HTTP run, not on a voice run. Saying that is the point of the row.
Also files {auth}/v1/voice as a spec correction. It is the third voice surface in this app and the spec documents none of the three together; the two facts that decide client design are that it executes the command before replying (so refetch, never optimistic add) and that a 400 is routine rather than a fault.
34aa794
Ports the mic from iOS addItemSection (ContentView.swift:390) with its gesture intact. The press does not start the recorder - it arms a 500ms timer that does, and a release before that fires is treated as a mis-tap and answers 'Hold to speak' instead of sending half a second of silence the server would 400. Writing this as an onClick would have looked equivalent and behaved nothing like it.
Two refusals carried over verbatim from stopVoiceRecording (:1199): no audio, and under MIN_MILLIS. Neither reaches the network.
No optimistic row on the way back, deliberately: {auth}/voice executes the command before it answers, so the row already exists with a UUID we do not hold, and one utterance can be several commands or a remove. Refetch is the only correct move. The assistant's own sentence is shown above the composer, because when the command was a REMOVE a row simply vanishing is otherwise unexplained.
One deliberate divergence: the mic sits beside a bottom-docked composer rather than a top-anchored one, so it clears the keyboard like the rest of that bar.
d701d2d
The shopping list's mic is not Thyme's. Thyme streams NDJSON off ivwu7ls6p8; this is one octet-stream POST to {auth}/v1/voice that transcribes, decides, and *writes the row server-side* before answering. Verified live on the App Review account: 'add bananas to my list' came back 200 with the itemUUID already minted in child_results, so the client contract after this returns is RELOAD, not add - which is what iOS does too.
A 400 is a normal outcome here, not a fault: silence answers 400 {"error":"Could not transcribe audio"}. iOS swallows that sentence behind a generic string; we surface the server's own wording, because 'could not transcribe' tells the user to speak again and the generic one does not.
eb8fae8
Ten design passes started inside an app that was already signed in, so the one screen a new user sees first had never been looked at. Documents what the sweep found (including the pre-filled App Review number, which was a live defect and not a design gap), the two omissions and what they cost today, and the device run that verified each state - including driving Sign In from the keyboard's Done key rather than the button, since ImeAction is the path most likely to silently no-op.
Backend ask added: Android is phone-only until an endpoint accepts a Google identity token, so every Apple-signed-in household is unreachable from the port.
5ff5873
The sign-in screen was a development stub. The worst of it was one line: the phone field was seeded with «test number», the App Review account, so any real user reaching this screen was handed Apple's test number - and a tap on Send Code would have sent an SMS to a number that is not theirs.
The rest shared nothing with iOS but the word TREPO. iOS draws two STEPS where the number field is replaced by the code field; Android stacked both and left the phone box live underneath. No +1 prefix, no digit mask, no length check, no resend, no way back to the number after a typo - that last one was a dead end only a force-quit escaped, since codeSent has a private setter.
Ported from LoginView.swift with its own numbers: 42/black/tracking-2 wordmark, step-swapped subtitle, the three-tier (...) ...-.... mask, accent 2dp strokes in red and deepBlue (not the offBlack ring TrepoCard puts on everything else, which is why StrokedBlock is drawn locally rather than adding a nullable border param to the app's most-used primitive), hard 4,4 @0.85 shadows, Send Code dead at 0.4 until ten digits, resend + change number, and the version string top-right.
Two omissions named in the source rather than left to look accidental: Sign in with Apple (the Android equivalent is Google Sign-In and needs a backend that accepts a Google identity token - filed as a backend ask) and the Sign Up link (destination screen does not exist on Android; a link to nothing is worse).
Not yet on device - verifying next.
41227bf
Records the review-screen section split, both deviations (offBlack digits on iOS's exact hues, and copy that names the control Android actually has), and what the device run did not prove: the fridge photo came back 6 of 6 low confidence, so the green IDENTIFIED ITEMS header was correctly suppressed and has still never been rendered on a screen.
2772fe2
Android drew one flat list, so a low-confidence row sat wherever the scan happened to put it - fourth of twenty, between two rows the model was sure about. iOS (BulkCheckInReviewView.swift:243-285) splits the same items into IDENTIFIED ITEMS and NEEDS REVIEW under accented headers, which is the whole point of the screen: the uncertain rows are collected where a human will actually look at them instead of being scattered through the certain ones.
partition() keeps scan order inside each group, so a row only ever moves across the boundary its own needsReview flag puts it on. Rows stay keyed by localId, never by index.
Two deliberate deviations, both recorded in the code: - the count capsule draws its digits in offBlack, not iOS's white. White on #00C853 and on #FF9500 is ~2.2:1 - the same invisible-label class as B7. - the explainer under NEEDS REVIEW names include/exclude, because Android has no confirm/dismiss pair to point at.
68d6671
Caught on the handset - PANTRY was rendering pale yellow on cream and could barely be read. My own doing: the previous commit swapped iOS's labelColor for bgColor to escape white-on-cream, which fixes six categories and breaks the two whose fill is light.
The fix is to stop picking a colour and derive one against the ground that is actually there. Where iOS's labelColor says off-black, the fill is light, so the ink is off-black here too; otherwise the fill is dark enough to be the ink. The split is read off iOS's own table rather than eyeballed, so it cannot drift from the tiles.
Same lesson as B7, third time now: port how a token is derived under its context, not the value it happens to hold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
eaf2dac
Wires the timeline in behind iOS's toggle (MainTabView.swift:7499): a 48dp off-black circle bottom-trailing, icon swapping clock <-> grid, hidden while the kitchen is empty because a toggle between two views of nothing is not a choice worth offering.
Two numbers are deliberately not iOS's. Its bottom padding of 100 clears its own tab bar; here the FAB lives inside the content box with the add-item bar below it, so 20 is the same visual gap and 100 would park it a third of the way up. And the FAB's shadow is its own 3pt/40%, lighter than the house 4pt/85%, because it floats over content rather than sitting on the ground - so it is drawn inline rather than borrowed from TrepoCard.
The header subtitle now swaps with the shape, as iOS's does: "TAP A CATEGORY" is a lie once the grid is gone. While in there, its type went to iOS's 12sp/ExtraBold/1.0 - it had been running 11sp Black at 0.3, which is the same weight-and-tracking collapse passes five and six were chasing, surviving in one string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c153973
PARITY twelfth pass item 4. The grid answers "what do I have"; this answers "what did I just buy, and what has been sitting here". Same rows, second ordering, so it takes the same list rather than fetching anything.
Ported from MainTabView.swift:7028 (view), :6773 (the five-band freshness traffic light), :6805 (the "MMM d" stamp), :7683 (the row).
Two deliberate deviations, both because iOS's own value does not survive the move:
1. iOS colours the category label with labelColor, which is white for
six of the eight categories - and the row sits on cream. Those six
labels are white on #F5E9D8 on iOS today, which is invisible.
labelColor was derived for text ON the coloured category tile; reusing
it off that tile drops the ground it was contrasted against. Android
uses bgColor - same per-category hue, read as ink rather than fill.
2. Undated rows sort last in both directions instead of being folded in.
iOS can sort on a non-optional Date because its parser substitutes
now when the wire has nothing. Ours is nullable on purpose, and both
ways of forcing it into the comparator state something false: epoch
claims 1970, now claims it just arrived.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cee92ed
The timeline view (PARITY's twelfth pass, item 4) orders the kitchen
chronologically and labels every row with its age. Android's KitchenItem
modelled neither - the wire's _createdDate was being dropped on parse,
so there was nothing to sort by and nothing to say.
Ports iOS's parse chain from KitchenAPIService.swift:388-435 rather than
inventing one. It is four branches because production has emitted all
four: ISO8601 with fractional seconds and an offset, plain ISO8601,
a naive yyyy-MM-ddTHH:mm:ss with no zone, and a bare date. The zoneless
forms are read as UTC, which is iOS's explicit assumption.
daysOld returns null rather than 0 for an unparseable row. A zero here would make every undated item claim to have arrived today, which is the kind of wrong that looks right.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7521e41
Records the Cook-tab sweep behind 870e757 with what was seen on the handset, plus two things worth carrying forward:
- author_name sometimes holds a URL. iOS renders it just as raw, so trimming it on Android alone would split this client from the 12,600 live ones over a cosmetic call. Logged as a Backend ask against ingestion instead. - the brief's adb port (34995) refuses; the device answers on :5555 and on USB. Noting it so the next run does not spend the same minutes rediscovering it.
870e757
Thirteenth design pass, Cook tab. iOS's recipeCard (SavedRecipesView.swift:258) carries three things Android's row dropped, and each one is a fact the card exists to convey rather than decoration:
- The yellow platform capsule opens with a glyph (:286). SF Symbols do not port by name, so each is mapped to the Material icon with the same silhouette. - The author line (:303) - who published the recipe. Distinct from the HOUSEHOLD pill, which is who in your house saved it; Android showed only the latter. - The trailing chevron (:315). Without it the row gave no sign it opened, which matters more here than on iOS because there is no swipe-back to undo a guess.
platformKey is hoisted onto the model because iOS switches on platform ?? sourceType in two places and the glyph must not drift from the label.
642c4e6
Records add-back-to-list as closed and verified on the handset, with the three design decisions behind it (brand travels, add is not a move, circular plus over iOS's hidden long-press).
Also records the two gaps honestly rather than rounding the row up. The settled green check was never photographed - screencap over Tailscale is slower than the 1.2s revert - so the proof the add worked is the List tab's, not the button's. And product_brand was never exercised: a text-added item has a null brand, so the populated path is written from the iOS source and unseen, and needs a barcode-scanned item to confirm.
And the layout bug the device found, with the part that matters: the row was already one long product name away from wrapping before the plus was added.
1ab4a15
Verified on device 2026-08-20, and the first device run is what caught this. With the plus added, the kitchen row held image + name + plus + Rename + Remove on one line. Rename and Remove are TextButtons that will not shrink below their text, so the weighted name column got what was left - about one character - and 'Greek Yogurt' wrapped down the card a letter at a time. It compiled, and it looked fine in the diff.
Worth naming: this was already marginal before the plus. Two text buttons plus a name was one long product name away from the same wrap, so the plus did not introduce the bug so much as spend the last of the slack. A row whose layout depends on the data being short is broken, it just has not met the data yet.
Now the name owns the top line with the plus beside it, and Rename / Remove sit right-aligned underneath. That also fixes the hierarchy: the name is what you read, add-to-list is what you do, rename and remove are the rare ones.
Evidence: docs/evidence/kitchen-row-crushed-name-2026-08-20.png is the broken frame, kitchen-add-to-list-buttons-2026-08-20.png the fixed one.
7e7f4a1
Ported from iOS addToShoppingList(_ item: KitchenItem) (MainTabView.swift:9226). The kitchen was a read-only surface: it could tell you the yogurt was down to one, and the only route to the shopping list was retyping it. That is the same gap the recipe sheets had before 3fe2540, on the screen users open most.
Three decisions worth recording.
Brand travels with it. A kitchen row knows it was Fage, not 'yogurt', and dropping that on the way to the list would hand the user a worse note than the app already had. addShoppingItem now takes product_brand and omits it when blank, the same rule store already followed - an empty string is a real value the backend would write.
It is an add, not a move. iOS leaves the item in the kitchen, and that is right: you list milk because it is running low, not because it is gone. Deleting the pantry row would make a re-buy destructive.
The affordance is the circular plus, not iOS's long-press context menu. A hidden long-press is undiscoverable sitting beside two text buttons that are already visible - and reusing IngredientAddToListButton rather than retyping a pill means 'circular plus means add to list' now holds on every surface, and one fix to the idle/spinner/check/cross cycle lands in all of them.
Not yet verified on device.
be41379
Records what the sweep MEASURED as well as what it changed. The three shopping list files diff clean against ContentView.swift on every container value, and writing those down is the point - the next sweep should not re-measure them.
The single divergence was absent rather than wrong: the trailing fade on the suggestion strip, fixed in 67ec07d. Carrying the lesson forward - a value-by- value diff cannot see a modifier Android has no line for, so the iOS side has to be read for things that are missing, not just for things that disagree.
Verified on device: added oat milk to make the strip render, saw Bananas and Cheese ramp into the yellow rather than hard-cut at the border, crash buffer empty, test item deleted.
67ec07d
The chip strip scrolls horizontally but nothing on screen said so - the last chip was cut dead against the panel edge, which reads as the end of the list. iOS overlays the scroll viewport's trailing edge with a 32pt ramp from clear to 0.9 of the panel's own yellow (ContentView.swift:522), so the strip fades into the card rather than stopping at it. Ported that.
matchParentSize rather than fillMaxHeight for the overlay: the Box is sized by the chip strip, and a fill modifier would resolve against incoming constraints instead, making the ramp the height of the whole panel rather than of the rows it fades. Takes no pointer input, matching allowsHitTesting(false) - it sits over a chip that must stay tappable.
d45c44b
The sweep cited placeholder hashes written before the commits existed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9e02cb8
Second half of the store-section port. iOS draws header + composer + rows as ONE card (ContentView.swift:1911) - cream, radius 20, a 2.5pt border heavier than the app-wide 2pt idiom, hard 4/4 shadow, clipped so the coloured header fills the top corners. Android had a floating banner with loose rows under it.
Rows inside a section are no longer LazyColumn items, so they are pinned with key(row.rowKey) to keep the RELIABILITY rule after the restructure.
One deliberate divergence, found by driving it: on iOS tapping + on a collapsed group flips the button to an x and nothing appears, because showAddField toggles independently of isExpanded while the field renders inside it. Android's + now un-collapses the section, so the control always does what it advertises.
Verified on device: two groups took distinct rotation slots from the persisted map, collapse hid the rows while the header kept its count, and an add through the Trader Joe's composer read back from the server as store='Trader Joe\'s'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f5aa551
The shopping list rendered store groups as plain grey caption labels in name order with 'Ungrouped' trailing. iOS draws each group as a full-bleed poster header (ContentView.swift:1676 StoreSectionView) with a persisted five-way colour rotation, a 44pt inverse icon tile, an item count, a collapse chevron and its own add composer - and it renders the UNGROUPED section FIRST (:679), not last.
The ordering was the part that mattered. A plain add from the composer bar carries no store, so on any list that had groups, the row a user just typed appeared below every group instead of at the top. The Android comment claimed this matched iOS; it did not.
Also ports StorePalette's persisted name->slot mapping (:1665) rather than deriving the colour from sorted position, which would repaint every existing group whenever a new one sorted ahead of it. Collapse state is tracked as the set of COLLAPSED stores, so a group that appears after first paint is open with no sync effect - iOS needs an onChange formUnion for exactly this case. Both collapse and composer state are hoisted out of the header because the sections are LazyColumn items and would lose it on scroll.
Not built here: drag-to-reorder, group rename, and Add New Group.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
b0742df
Records the sixth design pass. The finding worth carrying forward is methodological: per-screen sweeps find what someone thought to open, and a mechanical label-by-label diff against the Swift found both a weight idiom that is wrong in ~99 more places and a duplicated section card the third pass's dedup had missed on a screen it never visited. Also records what was NOT driven on the handset, so no row reads as verified that is not.
90e736a
Two findings from a mechanical label-by-label diff of every uppercase string
literal that exists on both sides (script matched Android Text("X") against
the same literal in the iOS source and compared size/weight/tracking).
1. Android has been collapsing iOS's two heaviest weights into one. iOS uses
weight: .heavy (W800) 355 times - MORE often than .black (279) - and
Android used FontWeight.Black 104 times against ExtraBold 5. The Nunito
ExtraBold instance was already cut and registered in Theme.kt, so the weight
existed and was simply never reached for. Fixed at the five sites the diff
confirmed against a cited Swift line: CLEAR (MainTabView:1506, 11/heavy/0.3),
ITEMS (:7555, 9/heavy/1.0), DELETE (:8795, 11/heavy/0.8), SEE ALL / SHOW LESS
(:16293, 10/heavy), SUGGESTED FOR YOU (ContentView:467, 9/heavy/1.2). Two of
those were also missing their tracking, so the weight error was hiding a
second one. ADD MISSING TO LIST was 13sp/0.4 against SavedRecipesView:608's
12/black/0.3.
2. DishLog kept a private DetailSectionCard - a FOURTH copy of the recipe
section card, the exact duplication the third design pass deduped everywhere
else. It drew 12sp untracked titles at corner 14 / padding 16 while iOS's
dishDetailSectionCard (MainTabView:17230) is byte-identical to the other
three: 14/black/tracking 1.0, padding 18, radius 18, shadow 4/4. Deleted;
ANALYSIS and ESTIMATED INGREDIENTS now delegate to DetailCard, so the dish
detail moves with the other three surfaces instead of drifting from them.
Not yet driven on the handset - device pass next.
8e664ba
Row said 'verified on device' and was mostly right - header, Account and Household all matched literal for literal. What it did not say is that the screen had no Delete Account, which Play requires of any app that creates an account. A cosmetic row was hiding a submission blocker.
Records the delete as BUILT BUT UNEXERCISED rather than verified: confirming it once destroys the App Review account every later run signs into, and there is no second account. Same call this table already made for the SEE ALL chip.
Also records the deliberate gradient divergence on the two action buttons, so the next sweep does not re-find it as drift.
6f43024
Design sweep of the Profile screen against ios-source/app/ProfileView.swift (932 lines). The header, Account and Household cards already matched; three things did not.
iOS wraps Log Out in an 'Account Actions' collapsible card alongside Delete Account (:688). Android had the Log Out button loose under the last card and no delete at all. That gap is not only cosmetic - Play policy requires an in-app route to account deletion for any app that creates an account, and this one creates one on the first verification code, so shipping without it is a review rejection waiting to happen.
DELETE {auth}/account, bearer-authenticated, ported from AuthService.swift:622. NOT exercised against the App Review account for the obvious reason - the call would destroy the account every later run depends on.
Order copied deliberately from iOS: the request completes first and only a success reaches signOut(). Clearing the session up front would strand a user whose delete failed - signed out of an account that still exists, holding no token to retry with.
Also ports founderNote (:756) verbatim, copy included; paraphrasing it would put a different voice in the founders' mouths on their own sign-off.
868c2a1
Records what the sweep actually found: the base palette matches iOS hex for hex and was never the gap - the drift was in how a colour is derived under state. Also reconciles the loop brief's stale 'remaining' list against the code; Thyme's mic and the local push half are both already shipped, so no row moved.
a5da3e9
Eight call sites disabled a button by swapping its hero fill for cardBorder (#D4C8B8) while keeping the white label on top. That is ~1.9:1 contrast, so the label is not dimmed, it is gone - the Kitchen screen's ADD TO KITCHEN button reads as a blank tan slab on device until you have typed, which is the one moment the user is deciding whether the control is worth using.
iOS never substitutes a neutral. A filled button keeps its own hero colour at 0.4 and keeps the white label (MainTabView.swift:8801, and the same line at 8786/10449/10471); an outline or white-ground button leaves the ground alone and tints the FOREGROUND instead (:8814 with its matching stroke at :8820). Both idioms are now helpers on TrepoColors and applied per-site by which kind of button it is - the Thyme mic is the white-ground case and was inverting to a tan circle with a white glyph, hiding the glyph the same way.
The tint is composited against the ground rather than left as alpha because TrepoCard draws the hard offset shadow BEHIND the fill, so a translucent fill would let the black slab show through the button face and turn it grey.
4c5fa42
Kitchen sweep item (5) was 'not yet measured'; it is now measured, built and driven on the handset, so the row carries the measurements instead of a placeholder.
Two corrections filed. HomeAPIService's private baseURL is the grocery host, not a sixth gateway - the wrong reading would have turned a view-only port into a transport one. And the items-to-watch GET is served from a cache that does not invalidate on a kitchen change, which matters because a stale shelf-life answer renders identically to a fresh one.
The row is deliberately not promoted past 'shipped': the content tile has never been seen, because nothing in the test kitchen is expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4a5d89a
Found by looking at the wire after driving the tile on the handset. The GET is served from a server-side cache that does NOT invalidate on a kitchen change: after adding twelve items through text-add, the endpoint still answered 'cached': true with 'updated_at': 2026-08-17T05:46:18 - three days stale. So the tile as committed an hour ago would have been reporting shelf life for a kitchen the user no longer has.
iOS already knows this and keeps a separate fire-and-forget refreshShelfLife() POST (HomeAPIService.swift:306). Android folds the two calls into one, because the POST RETURNS the recalculated payload rather than just accepting the trigger - so the fresh answer is already in hand and a follow-up GET would only be a chance to race it.
Whether to recalculate is computed, not remembered. Shelf life is a function of the kitchen contents, so 'the set of item ids changed' IS the trigger - no mutation call site can forget to flag itself, and settleEnrichment's several re-reads after every add cost nothing because they return the same ids.
Verified on device: launched the app on the A36 with 12 seeded items and watched updated_at move from 05:00:47 to 05:01:54 - the handset's own recalc. The tile renders the pale-coral empty state above the category grid ('Items nearing their shelf life will show up here over the week - check back!'), which is the correct answer for twelve items bought today. Crash buffer empty.
Evidence: docs/evidence/shelf-life-tile-empty-2026-08-20.png
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7f82d7a
Design-sweep item (5) from the Kitchen pass, which PARITY had recorded as 'not yet measured'. Measured now: iOS files this behind a HomeAPIService with its own baseURL constant, which reads like a sixth gateway, but the value is 7tn3gvwvh7 - the grocery host Android already talks to. Probed live against the App Review owner: 200 with {items_to_watch: [], recipe: null, cached: true, updated_at: ...}.
The tile has four states and the branching order matters: under 10 kitchen items it does not render at all, a first fetch draws a skeleton, a loaded-and-empty answer draws the educational line rather than a blank, and only then the headline. A refresh over an existing answer never flickers back to the skeleton.
itemsToWatch() returns empty on failure instead of throwing - deliberately, and matching iOS. This tile is an aside above the grid; an outage on the shelf-life service must cost the tile alone, not paint an error banner on the Kitchen tab.
Two decoding notes, both from B6 (one float quantity blanked the whole kitchen screen). LenientIntSerializer now falls through to Double, so a whole number sent as 90.0 rounds instead of failing the response. And quantity gets a lenient string reader: iOS reads it with 'as? String', which silently nils a numeric value, but kotlinx would fail the WHOLE payload over that one field.
The detail sheet is NOT in this commit - the content tile is an info surface here, with no tap affordance, so it cannot advertise an action that does nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
53558d7
The Explore row now cites the device run for the bookmark: 8 saved -> 9 on tap, back to 8 after cleanup, glyph filled red, crash buffer empty.
Two findings that will each save the next run real time:
- The Creators tab is dead code on IOS too. exploreTab is written and never read, creatorsContent has one occurrence in MainTabView.swift and it is its own declaration. The unreachable Android scaffolding therefore is not a half-finished port to go finish - building it would be Android inventing a surface the shipping iOS app does not show. - exec-out screencap truncates the frame on the Cook/Explore screens, returning a flat-white lower 60% (and once a 62-byte file) while the app renders fine. Use uiautomator dump to read state and to get tap bounds.
231290a
The Explore card was the one iOS surface with a live control Android had no answer for: iOS overlays a bookmark on the hero image and saves the recipe by its source URL (MainTabView.swift:2835 / bookmarkRecipe :2958). Android drew the card and the availability badge but nothing to save with, so the only way to get an Explore recipe into your own library was to copy the link out by hand.
Ported with three deliberate differences from the iOS card:
- The saved/saving pair lives in ExploreViewModel, not in the card. A Compose row is recycled on scroll, so card-local state would let a filled bookmark come back empty on the same recipe after scrolling away and back. Keyed by recipe id, so the revert still finds the right card after the feed pages or re-orders (R3). - A failure reports through a new saveNote, not through error: error only renders when the feed is empty, so a save failure written there would be silent on any populated feed (R5). - No bookmark is drawn at all when source_url is missing, since that is the only field the save endpoint takes and the control could never succeed.
Saving refreshes the saved-recipes tab, standing in for the SavedRecipeAdded notification iOS posts.
75cacaa
Row 2 read 'built, verified on device - but the SHAPE is wrong'. The shape is now iOS's, so the row is retitled to what the tab actually is and the sweep's items (3) colours and (1) cards are closed with a device run and evidence.
Also records what the slice deliberately did NOT build: iOS's category detail view edits expiration, storage, opened and quantity. Those never existed on Android, so the grid did not remove them - saying so keeps the next run from reading their absence as a regression the grid caused.
c00ceb8
Ports the iOS kitchen dashboard (MainTabView.swift:7198 grid, :7541 card) and the category colours it needs (:12821/:12834), which PARITY listed as items (3) then (1) of the Kitchen-shape gap.
WHY this is not a skin change: iOS's kitchen opens as a dashboard you drill into; Android's opened every category at once as a wall of rows. Same data, different product - and it is why the iOS subtitle reads "N ITEMS · TAP A CATEGORY", an instruction Android had no target for. That subtitle is now true rather than aspirational.
Two decisions worth recording: - SPICES gets off-black/cream, not one of the eight iOS colours. iOS's enum has eight cases and its colour switches are exhaustive with no default, so there is no value to copy for Android's ninth. Borrowing one would make Spices indistinguishable from the category it borrowed from and quietly settle a taxonomy question that is still open with the iOS side. - The out-of-enum rows keep their own tile. A dashboard is exactly where a silently-dropped category would hide, because there is no missing section header left to notice - and that is the bug that cost iOS a 36-row backfill.
Selection is a boxed CategorySelection, not a nullable KitchenCategory: null is already spent meaning the unrecognised tile, so nullability could not also mean "closed" without making that tile untappable.
dcb9652
iOS gates the counter line on !kitchenItems.isEmpty (MainTabView.swift:6827). Android did not, so a brand new user's first screen read KITCHEN / 0 items · tap to refresh / house glyph / No items in kitchen - the same fact three times, in the one place where the screen has to carry the product instead of the data.
Gated on vm.busy || vm.items.isNotEmpty() rather than just isNotEmpty so the REFRESHING… state survives: an empty list mid-load must never read as a settled empty kitchen. Verified on the A36 - installed, screenshotted the empty state, the line is gone and the poster and illustration are unchanged, crash buffer empty.
546f5ad
Row 2 claimed 'grouped by the 9 categories / verified on device'. Both halves were overstated. iOS's KitchenCategory (MainTabView.swift:12784) is EIGHT cases, and its bgColor/labelColor switches are exhaustive over those eight, so the enum cannot quietly hold more - Android invented a ninth, SPICES. And iOS's Kitchen tab is not a grouped list at all: it is a 2-column LazyVGrid of coloured category cards (:7198, :7541) that you tap to drill into, which is why its subtitle reads 'TAP A CATEGORY'. Android renders every category expanded at once, so the two apps' kitchens are different products, not different pixels.
Also logged as missing: the category colour palette (:12821), the timeline-view FAB and its whole chronological second view (:7499, :7028), the shelfLifeTile (:7863), and iOS hiding the counter line on an empty kitchen (:6827).
Recording the measurements before writing any code, because the taxonomy half is blocked - deleting SPICES from Android would strand any row already written with it, so it goes to the iOS side as a question rather than a unilateral fix.
3d3a7b0
Records the List tab empty state port, the cyan-that-is-red trap, the store- carrying chips, and the three spacing values deliberately cut below iOS's because Android's List tab spends ~230dp of chrome iOS does not.
12f9de5
Two corrections from driving the previous commit on the handset.
The suggestions row had been hoisted ABOVE the empty branch, on the reasoning that an empty list is when a user most needs ideas. That was a workaround for a missing screen. emptyStateView carries its own ideas affordance - the Quick start chips - so the row goes back inside the non-empty branch where shoppingListView keeps it. Stacked under the suggestions card the poster's two heading lines fell off the bottom and the screen showed a disc and nothing else.
Then three spacing values, each measured on the device rather than guessed: iOS's leading 100pt spacer is gone, its VStack spacing 20 is 12, and Quick start's top padding 20 is 4. Android's List tab also carries the composer, the full-width ADD TO LIST button and the floating tab bar - roughly 230dp iOS does not spend - and at iOS's numbers the second chip row sat half under the composer, which reads as a clipping bug, not as a scroll affordance. Every other value on the screen is iOS's.
The two store chips carry sentences; they split the width and wrap rather than running off the right edge as a plain Row would.
13d6671
Ported emptyStateView (ContentView.swift:845) and starterChip (:906): the
120pt tinted disc, the 50pt light cart glyph, "Your list is empty" / "Add items
to get started", and the four Quick start chips.
The gradient is RED, not cyan. The Swift says TrepoTheme.cyan -> cyanMuted,
but TrepoTheme.swift:26 is static let cyan = red // primary accent is now red
and :27 pins cyanMuted to #C62828. Reading the Swift literally would have put a
cyan disc on a red app. The names are a rename that never landed.
Two of the four chips add to a named store, so addListItem now carries the optional store the API already accepted - those chips are the only place in either app that demonstrates the store grouping the list already renders.
Quick start is suppressed while the real suggestions row has content, because Android hoisted that row above the empty branch and iOS never has to reconcile the two (on iOS suggestionsRow lives inside shoppingListView, so an empty list shows starters and nothing else). Two chip clusters would be an invention.
29c60b3
Two stale claims in one row. It said notificationsSection was deliberately skipped because 'push does not exist on Android'; the local half shipped in b16585f/65719ca and NotificationsCard has been in Profile.kt since. And the 'known cosmetic gap' for the avatar initial is fixed by 3a88768.
Cited to a device run that saw BOTH avatar branches: glyph on the pre-existing session, then H / Ham after a full sign-out and sign-in through the app.
3a88768
The avatar rendered the person-glyph fallback unconditionally and the header showed only a phone number, because verify-code's first_name was decoded into VerifyCodeResponse and then never persisted. iOS draws the initial in the avatar (ProfileView.swift:196) and the whole name at 28pt over the phone (:209).
Session now stores it (display only, nothing keys off it) and both call sites keep their fallback on purpose: a session persisted before this field existed has no name and no endpoint reads a user record back, so those users keep the glyph until they sign in again.
36c524b
The sixth-pass line said iOS 232 / Android 28; a fresh grep over reference/ios-source and app/src/main/java gives 260 / 40. The iOS number moved because earlier counts grepped a narrower tree - the same mistake the ninth pass caught in itself.
Writes up the check-in review screen, the first surface swept that is not reachable in one or two taps, and the method note for getting a fixture photo to the top of the Android picker.
8ea6ca5
Seventh pass measured iOS's primary capsules at 16/black tracking(0.5) and fixed the three it could see. This one sits on the check-in review screen and was missed: 15sp, no tracking, no glyph, and title-case copy.
BulkCheckInReviewView.swift:827-840 is the direct counterpart and gives all four values - checkmark.circle.fill at 18/heavy, HStack spacing 10, then ADD <n> ITEM(S) TO KITCHEN at 16/black tracking 0.5. The pluralised copy is iOS's own string, not invented here, so this is a true 1:1 port rather than the judgement call the kitchen/list composers needed.
The glyph is suppressed while busy so the label does not shift under the spinner text.
fda5984
Tenth design pass. The check-in review row was built to the right idiom but never measured against its Swift counterpart, and three values were off:
- productName was 15sp/Bold; iOS is 17/black rounded (BulkCheckInReviewView .swift:519). It read a full size smaller than the same row on iOS. - the brand/quantity sub-line was 12sp at default weight in textMuted; iOS is 12 semibold in textSecondary (:525). Muted-on-default made it recede much further than iOS intends. - the low-confidence badge said CHECK at 9sp with no tracking and no glyph. iOS's reviewPill (:763-778) is a warning triangle at 9/heavy plus REVIEW at 10/black tracking 0.3, HStack spacing 3, padding h8/v4 on yellow. Same class as the explore-open-button miss: the glyph is the part that carries meaning, and dropping it loses the warning, not just some decoration.
The category/store Chips were measured too and deliberately left alone - they are Android-only dropdown triggers, not iOS's display-only categoryPill, so lifting that pill's values onto them would be styling one control to look like an unrelated one.
d354e57
Every number in TabBar.kt was already right, which is exactly why it survived nine design passes - the file's own comment said the row was ported exactly and it was. The gap was the container: iOS floats a capsule, we drew a flat strip. Recording that, the four inventions on the capture button, the two divergences kept on purpose (in-flow rather than overlaid, faked ultraThinMaterial), and the device run. Also correcting row 0's device address - .80:34995 refused this run.
04bd985
MainTabView.swift:692 does not draw a bar across the bottom. It draws a capsule inset 16pt from each edge, translucent, stroked twice on hairlines and dropped on a soft shadow, with a second capsule sliding behind the selected tab on a spring and squishing as it lands (:810). Android had the row right - order, icons, 19/9pt sizes, the 7pt badge - and none of the container, which is the part you actually see. Frames are keyed by the tab enum rather than the row index because the 64pt camera gap makes visual order and child order disagree.
Also corrects the capture button: iOS :817 is a plain red 56 circle with a white bold plus raised 16pt. Ours was 58dp, bordered, hard-shadowed and wearing a camera glyph - four inventions on one control.
3582451
Recording this because it did not look like a tooling fault while it was happening - it looked like the app opening the wrong screens on tap. The pulled PNGs were earlier frames (status-bar clock reading 1:37 during a 8:53 run), so every coordinate derived from them was aimed at a control that had since moved.
exec-out streams the frame with no file on the device to go stale. Also noting the two-transport -s requirement and the left-gutter swipe, both of which cost time in the same run.
b55c86c
Row 12 read a plain 'verified on device' for dish history while Android was rendering every day-group and iOS collapses to the most recent one - the row was true about the grouping and silent about the control, which is exactly the kind of half-right row this reconcile step exists to catch.
Records what the device run actually showed and, deliberately, what it did not: the SEE ALL chip cannot be exercised on the App Review account because every dish a client can log lands on today, so there is only ever one day-group. It is filed as unexercised rather than claimed. Also notes the black dish thumbnail as an observation for the next run, not as a diagnosis - the image row is already CLOSED and telling an image-less dish from a failed load needs a second dish with a known photo.
e9ae100
Swept the Health tab against MainTabView.swift 15665-16320 and found one missing control plus the same scale drift the poster titles had.
Android rendered EVERY day-group unconditionally. iOS opens the feed collapsed to the most recent day (isDishHistoryExpanded / collapsedDishGroups, :16270 and :16339) with a SEE ALL / SHOW LESS chip, because the Health tab is read top-down for today's macros and an unbounded seven-day wall of cards pushes them off screen. Ported the collapse rule verbatim - first group alone if it has dishes, else the first two - as a function of the list rather than a remembered slice, so it cannot go stale against a refreshed feed.
The macro ring cards were uniformly undersized: ring 42 vs 46, icon 15 vs 18, vertical padding 14 vs 16, tracking 0.8 vs 1.0, and the shared ring track at 0.10 vs 0.12 alpha with the calories flame at 24 vs 26. Individually trivial, together the card read a size smaller than iOS - the same independent-literal drift that made four poster titles disagree.
Assistant hint gets its leading glyph (iOS wand.and.stars, :16314). Third time a missing glyph has turned up in this sweep, after CLEAR CHECKED and OPEN IN INSTAGRAM: the glyph is the part that says what the line points at.
adaf94f
Also records the miscount: greping ui/ instead of the whole source set hid four fields in MainActivity, two of them on the first screen a new user sees.
71be4fe
My own count of nine was wrong - it grepped ui/ and missed MainActivity, which held four more, two of them on the FIRST screen a new user sees. Sign-in was rendering phone number and 6-digit code as Material floating labels: the one screen where matching iOS matters most was the least ported.
The rename dialog loses Material's supportingText slot with the control, so the empty-name guard is a sibling Text now - same words, same trigger, drawn in the Trepo idiom. Zero OutlinedTextField call sites left in the tree.
ca67339
Zero Material text fields left in the tree. Two of these were still using the floating label, which iOS does not have anywhere - the prompt is a placeholder that clears on the first keystroke, so "Paste a recipe link" and "What are you eating?" were animating into a border gap that should not exist.
The composer's Modifier.weight(1f, fill = false) is carried across verbatim. It is load-bearing, not styling: it is what keeps the SAVE button on screen when the draft reaches four lines, which was found on device.
ab9ea51
Explore's is the one that needed the primitive to grow: iOS strokes that one search bar at 1.5 with a 2/2 hard shadow at 0.6 alpha and puts a 14pt bold magnifier inside the box (MainTabView.swift:2205-2249), so borderWidth, shadowOffset and a leading slot are parameters now rather than constants. Android had it at radius 14, a cardBorder hairline, no shadow, and Material's leading-icon slot.
0fac2a8
Only 4 of the 36 TextField sites in the iOS source take .roundedBorder. The other 32 hand-decorate the same shape - white fill, radius 12, offBlack stroke at lineWidth 2, 14/12 padding, 14pt medium rounded - and Android was drawing Material's OutlinedTextField at all nine of its call sites instead: a 1dp hairline, a floating label, and a focus colour that repaints the outline.
Built on BasicTextField rather than restyling the Material control, because the colors argument cannot remove the floating label or the indicator line, which are the two most visible differences. iOS has no floating label anywhere.
f9e57ea
Two things the code could not have told you, both caught by driving it:
The replacement path's COMMON case is a drop, not a swap. The fresh batch that comes back after an add is the same staple set minus what is now on the list, so every candidate is already on screen and there is nothing fresh to promote - the slot is removed and the row reflows. iOS does the same thing when its replacement is nil, so this is parity rather than a gap, but the row previously implied a swap was the normal outcome. It also makes rotateSuggestions dead against today's backend: the pool is 10 and the page is 10, so there is never a second window. Kept anyway - the server returning 30 is a config change, not a client one.
The cache-first paint was caught rather than inferred. Reopening onto List photographs the header still reading REFRESHING - the list had not come back - above a fully painted chip row restored from disk. That pair is the proof the no-TTL cache is doing its job; a screenshot taken a second later would have been consistent with a fast network instead.
Test row deleted, server reads back count 0, crash buffer empty.
4ac308e
The List tab has been a pure CRUD screen on Android while iOS opens it with a yellow panel of tappable ideas above the items. That gap was filed in PARITY as "a feature gap rather than a design one" and left; this closes it.
Ported from ContentView.swift:449 (suggestionsRow), :580 (suggestionChip) and HomeAPIService.swift:98 (fetchSuggestions), with the endpoint probed against production first rather than assumed: GET {grocery}/home/suggestions/{owner} returns 200 with ten staples for an empty kitchen, shaped {item_name, reason, confidence} and NO emoji key at all - so the emoji is genuinely absent most of the time, not merely nullable in the Swift.
Three things worth stating because they are decisions, not transcription:
- The suggestion has no server id. iOS mints a UUID per row in init because the row swaps a used chip for a fresh one IN PLACE, so a chip needs an identity that survives the swap. Here that is name+batch-ordinal, which also means the replacement path never keys on the index captured before its two suspend points - the pool can rotate underneath it. - Cache first, no TTL. The endpoint calls GPT and took ~2s on the probe, so a cold fetch leaves a hole on the screen a user opens to go shopping. iOS says in as many words that stale suggestions beat an empty row; that is carried, and the loading state only shows when there is nothing at all to paint. - The fetch never throws. iOS calls suggestions non-critical and returns [] on any non-200; a failed chip fetch must not paint an error over a working list. The 45s timeout is deliberate - copying iOS's literal 30 would have been a no-op against our 30s client, which would have read like a port and done nothing.
One deliberate deviation: the row sits ABOVE the empty-list branch rather than inside the scroll view. An empty list is exactly when a user needs ideas, and hiding the chips behind "Nothing on the list yet." would remove the one control that fixes the state they are looking at.
Chip taps route through addIngredientToList's shape, not addListItem, for the same reason the recipe sheet does: a chip owns its own progress and must not grey out the add bar. If the add fails the chip is re-armed rather than swapped - silently replacing it would claim the item is handled when it is not on the list.
Not on device yet.
9d098b6
Documents the first cross-cutting design miss the sweep has found (44 vs 28sp on four tabs), the overlay collision that raising it exposed, and the two shopping-list features the sweep turned up that Android has not built at all - the suggestions chip row and store create/reorder - filed rather than acted on because they are a new API surface, not a value to correct.
Row 5 keeps 'verified on device': the behaviour was never wrong, only the metrics were.
1df2f34
Raising the title to its iOS 44sp broke the Kitchen header: the Profile avatar, pinned to TopEnd of the enclosing Box and drawn OVER the tab content, landed on the N of KITCHEN. Caught on the handset, not in review - the same way the third Cook chip was caught rendering under the same button.
The Swift does not fit title and floating buttons on one line either; every poster header opens with .padding(.top, 60) and lets the buttons live in the band above it. So Kitchen, List and Health now reserve 58dp - the clearance CookSectionBar had already established for this exact overlay - which also puts all three titles on one baseline, so the wordmark no longer jumps ~46dp as you switch tabs. Cook is untouched: its title already sits below the section bar.
Verified on device. KITCHEN, LIST and HEALTH all render at poster scale, fully clear of the avatar, no clipping. CLEAR CHECKED renders with its check glyph left of the two-line label. logcat -b crash empty. Test row added, checked, cleared - account left clean.
5ab00ce
Design sweep against the iOS source, not the spec. KITCHEN, LIST, COOK and HEALTH are the same three modifiers in the Swift - size 44, weight black, tracking -1 (MainTabView.swift:6823, :895, :15669 and ContentView.swift:302). Android had drifted to 28sp on three of them and 40sp on the fourth, with no tracking anywhere, so the title read as a section heading rather than as the poster wordmark that opens each screen.
Fixed as one TrepoScreenTitle composable rather than four corrected literals, because four independent fontSize literals is exactly the shape that let them drift apart.
Also restores the leading checkmark.circle.fill on the CLEAR CHECKED chip (ContentView.swift:312, 12/heavy in an HStack(spacing: 5)). Same class of miss as the Explore OPEN IN INSTAGRAM button: the glyph is the part that says what the tap does.
8429727
The pass itself: iOS composes every empty screen (glyph + 16/medium title + a what-to-do line) and Android had one muted line, or on the Kitchen nothing at all. Records why the copy rule flipped from the seventh pass - these two screens are 1:1 ports of named Swift views, so the words come from the Swift - and states plainly that saved recipes is built but unverified, because seeing it would mean deleting eight real recipes.
b8f6d97
SavedRecipesView.swift:239-253 draws bookmark + 16/medium title + a muted 14 instruction; Android had both sentences squashed into one 14sp line with no glyph. This screen is a true 1:1 port of that view, so the body copy is lifted from the Swift rather than paraphrased - unlike the kitchen/list composers, which have no iOS counterpart and kept their own words in the seventh pass.
5729f55
iOS draws a composed empty state (icon + title + what-to-do line) on every screen that can be empty; Android had a single muted line on some and literally nothing on the Kitchen. TrepoEmptyState ports the measured iOS metric - VStack spacing 16, 40pt muted icon, 16/medium textSecondary title, 14 muted centred body - from MainTabView.swift:7010-7020 and SavedRecipesView.swift:239-253, with iconSize/spacing as parameters because the 'No recent activity' variant is the same idiom one size down.
Gated on !vm.busy so a first load in flight never reads as 'you have nothing'.
a6ac0f3
The brief says delete test data every run; earlier runs did not, and the residue was visible on the Cook tab as real saved rows - 14 saved, of which six were probes. Down to 8, confirmed on the handset.
Notes the judgement call rather than just the deletion: only names with an unmistakable test marker were removed. Banana Bread and Cinnamon Toast were left, because on a shared review account guessing wrong deletes real user data and a title cannot settle it. The fix is cleaning up in the run that creates the row, while it is still known which rows are yours.
d7da35a
Closes the uppercase flag the sixth pass deliberately left open. Records the part that was not in the flag: the metric was wrong too - 15sp/no tracking against iOS's 16/black/tracking(0.5) - so these buttons read narrower and softer than the same control on iOS, which the casing alone would not have fixed.
Writes down the negative result as well, because it is the load-bearing one: Profile's buttons are title case on iOS and Android already matched them, so the rule is the primary-action idiom rather than a blanket uppercase. A sweep would have broken three correct labels.
Evidence attached for all three buttons including the enabled red state.
cbab112
Sixth-pass sweep flagged this and deliberately did not act on it. Reading the Swift settles it: every full-width primary action button in the iOS app draws uppercase - SAVE TO KITCHEN (CameraLogView.swift:2522), ADD TO LIST (:923), SAVE RECIPE (MainTabView.swift:1554). Android's read Add to kitchen / Add to list / Save recipe, which is the one place the port picked Material's idiom over Trepo's.
Also ports the metrics, which were off by more than the casing. iOS primary capsules are 16/black with .tracking(0.5); Android's were 15sp with no tracking at all, so they read narrower and softer than the same button on iOS. RecipeButton is the direct counterpart of iOS saveButton and takes that button's own values instead - 14/black, tracking(0.3).
Deliberately NOT renamed to iOS's words where the screen has no iOS counterpart. The kitchen and list text composers are Android-only affordances; iOS's SAVE TO KITCHEN is its leftovers-note submit and its ADD TO LIST is in the discard flow. Copying a label across unrelated screens would be inventing product copy, so those two keep their own words and take only the casing and the metric.
Profile's buttons were checked in the same pass and are correct as they are: iOS ProfileView.swift renders Invite to Household (:438), Leave Household (:460) and Log Out (:703) in title case, and Android already matches. The uppercase rule is the primary-action idiom, not a blanket one.
2021cf1
be8ff8f
2a is the new work, driven against a two-identically-named-rows fixture so a name-matched rename could not pass as an id-matched one.
B6 matters more than 2a does. Row 2 has said 'verified on device' since 2026-08-18 and was broken today by an ordinary text-add, so its claim is now qualified rather than left to imply coverage it never had: the sign-off was honest, but it could not cover a wire shape the account had not yet produced.
9325bed
Found on the handset today, immediately after run() started logging causes.
The app showed "Could not load your kitchen." over an empty screen while
adb shell curl on the same device got a 200 from the same endpoint - so
it read as a network fault. It was not. The log said:
JSON input: ..."quantity_value": 1.0, "quantity_unit": "count"...
quantity_value was typed Int?, kotlinx refuses 1.0 for an Int, and the failure is not scoped to the row: KitchenResponse as a whole fails to deserialize, so ONE item with a float quantity empties the app's main screen. Reproduced by adding "one jar of dill pickles" through text-add, which is a completely ordinary add - this is not an exotic shape.
Now read through LooseQuantitySerializer, which takes a number in either literal form and also parses a string, since this backend already returns numbers-as-strings elsewhere. An unparseable value degrades to null so it costs its own label rather than the whole kitchen. The field is a Double now, so asQuantityLabel() keeps a whole number rendering as "1 count" and not "1.0 count" - verified on the handset, both rows read "1 count".
Worth noting the same int-or-double tolerance was already ported on the dish path (PARITY item 14, calories came back 90.0). Same backend habit, second sighting; CandidateItem.count is still a bare Int and is the next candidate, but it is a different endpoint and is not evidenced yet.
ad22d0a
Every kitchen/list failure funnels through run(), which replaced the
exception with a fixed sentence and dropped the cause on the floor. So the
app could say "Could not refresh." while adb shell curl got a 200 from
the same handset seconds later, with nothing anywhere to say which of the
two was lying.
Added while trying to verify item rename on device. It immediately paid for itself - the first line it printed identified a real parse bug that had been silently emptying the whole kitchen (fixed next commit).
9b9280e
iOS change #18: every field on a kitchen item was editable except its name. The only route to the name was the AI "fix this item" correction, which re-identifies the product rather than renaming it - which is why users reported it as not saving. PUT /kitchen/{owner}/{item_id} has always taken product_name; the client simply never sent it.
Two things here are not obvious:
Blank names are the client's problem. Probed against production on a throwaway item: {"product_name":""} returns 200 "Item updated successfully" and the item reads back with product_name null. The server will happily destroy a name, and a 200 gives the client nothing to catch, so Save is disabled on blank and the API call requires non-blank as well.
The optimistic rename needed a pending overlay, not just a local edit. settleEnrichment re-reads the whole kitchen several times after every add, and that server list still carries the OLD name until the PUT commits - so a plain optimistic update would visibly snap back and then flip forward seconds later, reading as a rejected edit the app changed its mind about. pendingRenames is keyed by item id and applied in acceptServerItems, which all six server-sourced load paths now go through so none can miss it. It is retired only if the in-flight name is still the one pending, so a second rename queued behind the first is not reverted to the server's copy, and it is cleared on sign-out so it cannot leak across accounts.
Failure restores that one row by id, and only if the row is still present and still shows the optimistic name, so a concurrent delete cannot resurrect it.
Not yet driven on the handset.
4c5f404
7b20e00
The loop brief says adb connect «device address». After the reboot this
pass ran, that address stopped answering ping. Tailscale does not survive a
reboot on this phone - Android will not re-establish a VPN without someone
tapping the consent dialog unless always-on VPN is set, and it is not - and
the wireless-debugging port it was paired on is reassigned every boot regardless.
Tried to bring it back by launching the Tailscale app over adb; it needs the consent tap, so it cannot be fixed from here.
Not blocking: USB works and is faster, and adb tcpip 5555 re-established a
LAN transport on a fixed port («device address») rather than a per-boot one.
Both are now written down in preference order, so a run that finds the brief's
address dead has somewhere to go instead of stopping. Flagged to Matt, since
restoring the Tailscale route needs a human tap.
6cf7d0d
Sixth design pass, plus the STEP 1 reconciliation.
Corrected, not just appended: this file previously called the screenshot failure a device display-buffer problem and told the next run to reboot the handset. The reboot was run and changed nothing. The section now carries the real cause (incompressible-data ceiling, with the zero-vs-urandom pair that isolates it) and points at scripts/screencap.sh. Leaving the old paragraph would have cost the next run the same twenty minutes.
ExploreDetail promoted to verified with the render actually looked at, and the container defect that looking found is written up next to it.
Two rows reconciled against the code:
- Thyme assistant: partly verified -> verified. Every sub-feature the row lists cites a device run and an evidence file, all six of which exist on disk, and the row's own body already ended "what is left of this row is nothing". The status line had simply never been moved.
- Recipe personalize: deliberately left at *partly*. It was not re-driven this run, and promoting it on the strength of the prose below it is the exact bookkeeping-over-reality error the other row shows. Also noted that the "what can I make" in its title matches nothing in either the iOS source or the Android tree - it is prose describing the availability matcher, not a screen, and should be renamed once someone confirms that.
Added a short "Verifying on the handset" section so the screenshot ceiling is findable without reading a paragraph about Explore.
262a0dd
With screenshots working again, the ExploreDetail verification the last pass could not finish got done - and the render immediately showed something the label-level check had missed.
The tracking itself is fine and is now verified on device: OPEN IN INSTAGRAM draws as a spaced 14/black eyebrow with visible letter gaps and no clipping inside a full-width capsule, which was the metric risk.
But putting the sheet next to MainTabView.swift:3240-3262 found three misses
in the container around it, and one of them is not subtle:
- iOS gives this button the hard-shadow idiom - radius 16, 2pt offBlack stroke,
shadow(radius: 0, x: 4, y: 4) at 0.85 opacity. Android drew a flat filled
rect. On a sheet where the STEPS card and the NOTES card both carry that
shadow, the button alone sat flat on the page.
- radius 16, not 14.
- iOS pairs the label with arrow.up.right.square.fill at 16/heavy in an
HStack(spacing: 8). Android had no icon, and the glyph is the part that says
the tap leaves the app - the words on their own read like another in-sheet
action next to the + buttons further up.
Drawn with TrepoCard filled offBlack rather than a hand-rolled drawBehind,
because that helper already solved the trap waiting here: a shadow drawn as a
sibling Box measures against the outer node, so a fillMaxWidth() button would
have thrown a black slab past its own right edge. Reusing it also means the one
place that defines the idiom stays the one place.
Worth naming how this was found. The label was measured, cited to a line number and committed a pass ago, and it was correct - the defect was in everything around it, and only looking at the rendered screen showed it. That is now the third time this sweep that the render found what the reading did not.
Verified on device: shadow block visible bottom-right matching the NOTES card
above it, icon 8dp left of the label, both centred, radius matched.
logcat -b crash empty. Nothing created, so nothing to clean up.
Evidence: docs/evidence/explore-open-button-2026-08-20.png, and the pre-fix
render at tracking-explore-detail-2026-08-20.png.
2fa6200
The 2026-08-20 tracking pass could not verify ExploreDetail because adb pull
of the screenshot died at "[ 3%]" and took the connection down with it. That
was written up as a device display-buffer fault with "reboot the handset" as
the next step. The reboot was tried this run and changed nothing, so the
diagnosis was wrong and worth correcting rather than retrying.
It is entropy, not the display and not the link. Two files of identical size pulled off the same device seconds apart:
dd if=/dev/zero bs=1024 count=2000 -> 2048000 bytes at 29.5 MB/s dd if=/dev/urandom bs=1024 count=2000 -> dies at [ 3%], ~61KB in
Same size, same path, same USB transport - only the bytes differ. A transfer
survives roughly 64KB of incompressible data and then the connection drops,
which is why the following command reports "device not found" and why this
looked intermittent. -Z and -z none do not help, so it is not adb's own
compression negotiation.
That explains the pattern exactly: flat cream screens pulled fine (163KB of mostly one colour) and every screen with a photo on it did not. Which is the worst possible failure shape for a design-parity sweep, because the screens it refuses to photograph are precisely the ones worth looking at.
So screencap.sh splits on the device, pulls 48KB at a time under the ceiling, and reassembles. Verified end to end: 42 chunks of /dev/urandom, md5 identical, and a real 1.8MB gradient screenshot in 4.7s.
A chunk that never lands aborts with an error instead of writing a short file. A truncated PNG still opens - it renders as a half-drawn screen - and one of those read as evidence is worse than no screenshot, which is the trap the previous run came close to falling into.
4294f0e
The gap row claimed 'no image loader at all, anywhere in this app'. Half of that was already false when written - Coil has been a dependency and three screens render AsyncImage - and half was real, the dish log. Rewritten to say which half was which, because a row that overstates a gap sends a later run rebuilding what exists.
Verified on device: both fixture dishes carry real S3 photos (curl'd to 200 image/jpeg first), the 88dp row thumbnail draws inside its 12dp frame, and the detail sheet draws the 220dp hero. Evidence committed.
The thumbnail is SOLID BLACK in the screenshot and that is correct - the fixture photo is a near-black frame from some earlier lens-covered capture. Downloaded and looked at the JPEG rather than assuming a loader failure, and wrote the reasoning into PARITY so nobody files a bug against it: a failed load would show the emoji underneath, so black proves a real decode.
Also corrected the DishLog header, which still said the LOG A DISH CTA is 'not drawn at all'. It has been drawn and wired since a163ac6 and I watched it render on the handset this run.
6e91268
PARITY carried a 'no image loading, anywhere in this app' gap. That was stale on the loader half - Coil is already a dependency and Explore, the Explore detail and the saved-recipe hero all use AsyncImage - but true for the dish log, which still drew an emoji tile where iOS draws the photo. Closing the half that was real.
Ported from iOS dishRow (MainTabView.swift:16355) and the detail hero (:16831). One DishPhoto composable serves both, sized by the caller.
iOS's four-rung fallback collapses to two here, deliberately: the two URL rungs were already collapsed upstream in Dish.from, and the local-capture rung has no Android equivalent (no DishSessionManager disk cache), which is written down in the composable's doc rather than left to be rediscovered.
Loading and failure are one state - the glyph paints under the image, the RecipeHero idiom - so an in-flight photo and a 404 both read as 'dish, no picture' and neither can flash an empty box.
a3d8ff0
Capture is verified on device with evidence - the tracked hint and the FRIDGE / RECEIPT chips still fit their pills, which was the real risk of adding tracking to a label inside a fixed capsule.
ExploreDetail is explicitly NOT marked verified. uiautomator confirms the button is laid out at [141,1879][939,2025], but screencap on the A36 degraded mid-session to truncated frames so nobody looked at the render. Crash buffer empty and the hierarchy intact, so it is the device, not the app. Written down with the fix for the next run (reboot the handset first) rather than rounded up to verified.
Also flagged, not fixed: iOS primary capsule buttons are uppercase where Android's are title case. That is copy, not a style value, so it needs a call rather than a unilateral edit.
92a9e3b
PARITY flagged that iOS applies .tracking() 232 times against Android's 25, and named Capture.kt and ExploreDetail.kt as having zero occurrences between them. Closing both.
ExploreDetail's OPEN IN INSTAGRAM button is a straight measurement off MainTabView.swift:3245-3246 - iOS draws that exact label at 14/black/tracking(0.3) and Android had it at 13 and solid, so the size was wrong too.
Capture has no iOS line to cite: CameraCaptureView.swift carries no Text at all (it is the AVFoundation preview layer) and CameraLogView's overlay has no uppercase hint. So the hint and the FRIDGE/RECEIPT chips take the modal value iOS uses at their exact size and weight - 11/black is tracked 1.0 at 11 sites, 10/black is tracked 0.5 at 7 - rather than a number picked to look right.
Checked and left alone: DetailCard already carries 14sp/Black/1.sp, which matches all four iOS section headers (RecipePersonalization.swift:265-266, SavedRecipesView.swift:944-945, MainTabView.swift:2707-2708). Since Thyme and ExploreDetail both delegate to it, that one row was already correct everywhere.
64c8884
Records the fourth design pass and, more usefully, the half of the tracking idiom the sweep had been missing: iOS tightens display sizes with NEGATIVE tracking, and Android had only ever set positive values. Also logs the two Thyme diffs left open deliberately - the mascot asset is absent from the exported source, so the leaf stands in rather than inventing art.
c019b48
The header was the one part of Thyme nobody had measured. iOS (VoiceAssistantView.swift:748-773) is a bordered circular xmark, a Spacer, then mascot + THYME/Your Kitchen Assistant pushed to the RIGHT edge. Android had a stock Material back arrow and a lone left-aligned 26sp THYME - wrong size, wrong position, and missing the subtitle entirely.
Also closes two of the tracking holes PARITY flagged: the recipe modal title is 26/black/-0.5 not 24/solid (iOS TIGHTENS display sizes), and FROM THYME is heavy/0.3 not black/solid.
ThymeMascot is an asset-catalog image absent from the exported source, so the leaf glyph stands in at the same 36pt box rather than inventing art.
1d169b2
Drove the saved-recipe sheet on the handset. INGREDIENTS and STEPS both render as cream cards with tracked offBlack titles, red dot bullets with the long ingredients hanging-indented, and red numbered discs on steps 1-6. NOTES stays yellow. Crash buffer empty; nothing was created, so there was no test data to delete.
The screenshot found one defect the port did not. iOS separates the ingredient text from the add-to-list + with Spacer(minLength: 0). On Android the text carries weight(1f) and eats the whole row, so "2 Tbsp chopped fresh parsley" rendered touching the button. Fixed with an 8dp gap and re-driven - it now wraps to two lines like its neighbours.
That is the second defect this run found by looking at the render rather than by testing the logic; the square press-ripple on the same button was the first.
PARITY records the third pass of the design sweep and the wider gap it exposes: iOS tracks 232 labels, Android 25, and unlike the typeface this cannot be fixed at the class level because Android's Text calls set fontSize inline and never resolve through a Typography role. It is a per-surface sweep from here.
ae9c93c
Continuing the design parity sweep. The section card is the busiest surface in the product - it draws INGREDIENTS / STEPS / NOTES / SUBSTITUTIONS on the saved-recipe sheet, the Explore sheet and Thyme's recipe card - and every value in it had been invented rather than measured.
iOS carries three byte-identical copies of the card (savedSectionCard SavedRecipesView.swift:1065, detailSectionCard MainTabView.swift:3269, recipeSectionCard MainTabView.swift:17899), all agreeing on: title 14pt black with tracking 1.0 in offBlack, spacing 12, padding 18, radius 18, border 2, shadow 4/4. Android had 11sp with no tracking in deepBlue, spacing 8, padding 14, radius 14, shadow 3. Not one of those matched, and the title colour was a different colour entirely.
Three more mismatches came out of reading the content, not the container:
- STEPS is cream on iOS in all five places it appears, never white. Android passed cardBg (#FFFFFF) in all three of its copies, so the one card that should read as paper read as a floating white slab on a cream app. - Ingredient bullets are a 6pt RED circle at 10pt gap. Android concatenated a literal "• " into the string, which is not red, is not 6pt, and wraps with the text - so a two-line ingredient hung its second line under the bullet. - Steps are numbered in a 22pt red circle with the digit in white heavy 12. Android rendered "1. step" as plain text. That is the single most visible difference between the two apps on this screen.
Tracking is the theme here and it is bigger than this commit: iOS applies .tracking() 232 times, overwhelmingly on small heavy/black uppercase labels (33 of them on size 11 heavy alone). Android used letterSpacing 25 times. The uppercase eyebrow label is a Trepo idiom and Android has been setting it solid.
Deduped while here: Thyme's RecipeSection was a byte-identical copy of DetailCard, so Android was one edit away from repeating iOS's own three-copy problem. It now delegates. NumberedStep and Bullet are shared the same way, so all three recipe surfaces move together.
e9f7950
The design-parity section had recorded the palette, the 4/4 hard shadow and the 2pt/radius-18 border as measured and matched, which made the sweep look nearly done. It was not: two thirds of iOS's sized text is SF Rounded and Android was drawing all of it in Roboto. Row now carries the counts that prove it (1013 of 1539), why Nunito over Quicksand/Varela Round (they cannot reach Black without Compose synthesising fake-bold), why the statics were cut with fontTools rather than downloaded, and the metric checks that were the actual risk - two-line recipe titles, filter chips and 9sp tab labels all hold with the wider face.
Also records that the two surviving FontFamily.Monospace call sites were checked against iOS rather than left by omission: ProfileView.swift:238/:249 and VoiceAssistantView.swift:1193 are monospaced there too.
55af3d6
Design parity sweep against ~/trepo-web-starter/reference/ios-source. The palette diffs clean - all eleven brand hexes in Theme.kt are byte-identical to TrepoTheme.swift - and PARITY already records the hard-shadow idiom (4/4 offset, radius 0, 0.85 alpha) and the 2pt border / radius 18 pairing as measured and matched. So the borders, the shadows and the colours were right, and the app still did not look like iOS. The reason is the one design value that cannot be copied out of the Swift, because it is not a value:
grep 'design: .rounded' -> 1013 grep '.system(size:' -> 1539
Two thirds of every sized piece of text in the iOS product is SF Rounded, and all three of TrepoTheme's typography helpers (posterTitle, sectionTitle, bodyLabel) pass it. Android has no rounded system family, so FontFamily.SansSerif resolves to Roboto and every screen was rendering the whole product in flat terminals. Theme.kt's own comment already claimed "rounded system font" above three SansSerif declarations - the intent was recorded, the font never existed.
Nunito (SIL OFL, licenses/Nunito-OFL.txt) is the substitute: closest free analogue to SF Rounded that actually reaches Black and ExtraBold, which this identity leans on far harder than most apps. Quicksand and Varela Round were rejected for exactly that - they stop at Bold or ship one weight, and Compose would have answered a request for Black by synthesising fake-bold on every poster title. Google Fonts ships Nunito only as a variable font now, so the five static instances were cut from Nunito[wght].ttf with fontTools rather than downloaded; instancing at prep time keeps FontVariation - API 26+ and sensitive to the Compose version - off the runtime path.
The second half of the fix is bigger than the font. Typography() named only three of the fifteen Material roles, and Text with no style argument resolves to bodyLarge, which was not one of them - so most text in the app was never covered by this object at all. The defaults are now taken from Material and re-emitted with the family swapped, so a role nobody has thought about is still Trepo-faced. Same lesson as the B5 colour-scheme note directly below it: fix the class, not the call sites.
Compiles. Not yet on the handset - screenshot verification next.
643268b
Reconciled first: the row's own deferral note claimed push was not started on Android, which the shipped TrepoNotifications contradicts. Recorded what was actually driven on the handset, including the ON_RESUME re-read, which is the only thing stopping the OFF branch from lying to a user who just fixed it in Settings.
3daae1a
iOS ProfileView.swift:521. The row was skipped in the original Profile port with a stated reason - push was not started on Android - and that reason is now stale: TrepoNotifications posts check-in alerts. Three states with iOS's copy and geometry; ON taps into system Settings, NOT SET goes through the POST_NOTIFICATIONS runtime permission (unreachable below API 33, where there is nothing to request), OFF draws the pink recovery box. Status re-reads on ON_RESUME because the OFF branch sends the user out to Settings and back.
602119f
Also records the rule the thumbnail miss taught: a fallback that renders correctly is indistinguishable from a feature that was never built. Every Cook row drew the fork glyph and it read as a load failure; the server had had the photos all along.
6a18c57
Found while verifying the hero: every row on Cook drew the fork glyph, and the reason was not a load failure - the thumbnail was never wired to image_url at all. The server had the photos the whole time (9 of the 12 saved recipes carry an image_url, and one fetches 200 image/jpeg 163kB), so the main recipe screen had been showing placeholder art over real data.
Same glyph-under-photo pattern as the hero and the Explore card, so all three recipe surfaces now degrade identically.
Verified on device: Cook shows real photos for garlic bread, white cake, banana bread and the burger, cropped square inside the 14dp corner with the border intact; 'Mike Bread Probe' (source TEXT, image_url null) still draws the fork in the same frame, which is both states in one screenshot. logcat -b crash empty. Evidence: docs/evidence/recipe-thumbnails-2026-08-20.png, recipe-thumbnail-fallback-2026-08-20.png
828040f
PARITY listed the hero as one of three things still missing from the saved recipe sheet. image_url was already decoded on SavedRecipe and Coil was already a dependency, so this is the view half only.
Loading and failure are one state, not two: the fork glyph paints under the image rather than branching, borrowed from the Explore card. A photo in flight and a photo that 404s both read as 'recipe, no picture yet', and neither can flash an empty box. iOS needs separate placeholder/failure closures for the same result.
Blank or absent image_url draws nothing - a text-saved recipe never had a photo, so a slab would be inventing a failure. 200dp not iOS's 280: this is a 640dp Dialog, not a full-screen NavigationView, and close lives in the title row here.
ca19af0
Was about to build the view this row called Next. Checked the entry point first: showGeminiLive is declared at MainTabView.swift:30, read at :147 and :298, and assigned true nowhere in the iOS source. No iOS user can reach GeminiLiveView, so porting it would be inventing a feature rather than matching one - no reference behaviour, nothing to diff against.
Transport + audio stay (they compile, cost nothing unreferenced). Row now says STOP, and it is a blocking question for Matt/iOS rather than a task. Found by reading the entry point, not the implementation.
e1e6cc5
Closes the last read-only ingredient list in the app. Corrects the recipe row, which said the Thyme card was 'not wired yet'. Records what was NOT seen (the 1.2s green check - screencap over Tailscale is slower than the revert) rather than implying the whole cycle was photographed.
cff73bd
The Thyme recipe card could open a full ingredients list and give the user no way to act on it - the only route from "Thyme just wrote me a recipe" to the shopping list was retyping the lines by hand. iOS puts its IngredientAddToListButton on exactly this list (MainTabView.swift:17786); Android rendered plain Text bullets.
Reuses Bullet from RecipeDetail.kt rather than retyping the button. The
two recipe sheets (saved recipes, Thyme) now share one affordance and one
idle/spinner/check/cross cycle, so they cannot drift; a copy would have
meant fixing every future bug twice. onAddIngredient threads
ThymeScreen -> Bubble -> RecipeTurn -> RecipeSheet from vm::addIngredientToList,
which reports to its caller instead of driving the List tab's own busy/error
state - a failure inside a chat sheet must not park an error banner on a tab
the user never opened.
Rows are keyed on the ingredient string inside Bullet, so a recomposition
cannot land one row's check on another.
3134c85
STEP 1 reconcile. The Explore row still read "transport + view model built, no UI" while ui/Explore.kt, the header pass and ui/ExploreDetail.kt had all shipped. Corrected by reading the code, then driving it on the handset.
Records what was actually seen: 26 recipes under UNDER 30 MIN matching the anonymous curl count, and a detail sheet scored against the live kitchen (4/7 on hand, In kitchen: Honey / Soy Sauce / Garlic). Also explains the apparent off-by-one - olive oil's neutral PANTRY dot is counted by the server and deliberately not claimed by the row, which is iOS's rule.
Keeps the row honest about what is still NOT built: creator shelves have no UI, so the LenientIntSerializer fix for the quoted "total_likes" remains unproven against the Kotlin decoder - the only endpoint returning that field is never called.
New finding logged: the feed serves HTML-escaped text and iOS has no decoder anywhere, so live iOS users are seeing raw & today.
d6a7ce0
Driving the Explore detail sheet on the handset showed an ingredient reading "1 lb medium uncooked shrimp, peeled & deveined" - the feed is scraped from creator captions and reaches the client still HTML-escaped.
iOS has no entity decoding anywhere in its source, so it renders the raw & too. This is therefore a deliberate improvement on iOS rather than a parity gap; it is safe to diverge on because the only observable difference is that an ampersand is now an ampersand.
Single-pass decoder, not a chain of replace() calls: whichever order those run in, "&lt;" decodes twice and turns prose into a tag. Unknown names and over-long bodies are left exactly as written, so ordinary "&" in text survives.
Applied at the transport edge (exploreRecipes / exploreSearch / exploreRecipeDetail) so every consumer - card preview, detail sheet, and the string handed to add-to-list - sees the same text, and a recipe saved out of Explore stores the decoded line. Also in IngredientMatch.label, since the matcher echoes the recipe's own ingredient line back and that path does not come through the Explore models.
77a112f
Until now tapping an Explore card did nothing. The feed could show you a recipe and then never give you the recipe - three ingredients under a photo was the whole of it. Ported from ExploreRecipeDetailSheet (MainTabView.swift:3066).
The design decision is that the sheet reads TWO sources, not one:
- the tapped row, which already carries title, hero, ingredients, steps and notes, so everything readable paints on the first frame; and - the ?owner=-scored detail, which lands later and only ever ADDS - per ingredient matches, substitutions, an availability bar.
Modelling it as a single nullable detail would have put a spinner in front of content the app was already holding, and would turn a scoring outage into an unreadable recipe. As built, a failed personalize is invisible: the recipe stays up and simply never grows badges.
Three more rules carried from the transport row:
- The list row's badge shows immediately, so opening a card that reads '4 of 6' does not lose its count for a beat while the detail call runs. - Empty kitchen (or signed out) skips the network entirely and resolves locally to 0 matched / everything to buy. Explore takes no identity; ?owner= is the only thing that scores it, so with no owner there is nothing to ask. - The in-flight detail is keyed by recipe id, not a boolean - close one card and open another mid-flight and the first answer is dropped rather than landing on the second recipe.
Field-by-field fallback rather than object-by-object, so a detail that comes back with an empty instructions array cannot blank steps the row already had.
DetailCard/Bullet/NumberedStep/IngredientMatchRow/SubstitutionsCard are made internal and reused from the saved-recipe sheet rather than copied, so the two recipe surfaces cannot drift into two roundings or two bullet styles.
Compiles; device verification next.
8090988
Two things the handset showed that review would not have.
The third CookSection chip rendered UNDERNEATH the floating Profile button - the overlay is pinned TopEnd and two chips used to fit beside it. The section bar now reserves 58dp of top inset rather than shrinking the chips, since a fourth section would collide again.
Coil paints nothing until a ~350kB thumbnail lands, so mid-scroll two cards held a blank 200dp slab that was a photo a second later - an empty box, not a loading one. The fork glyph is now drawn UNDER the image rather than as an else branch, which is the DESIGN_SYSTEM 'show pending, not zero' rule applied to an image.
Verified on device: EXPLORE opens the feed, '26 RECIPES' matches the probed Under 30 Minutes shelf count exactly, and thumbnails resolve.
d6c280c
The transport and view model have been sitting behind nothing since 7cdb0ca - six endpoints and a full paging/scoring state machine that no user could reach. This is the screen, plus the entry point that makes any of it real.
Explore is a third CookSection rather than iOS's card-on-a-hub (exploreCard, MainTabView.swift:1156): Android's Cook tab is already a section bar, and inventing a sheet for one screen would be a second navigation idiom for no gain.
Two rules from the transport row drive the layout, not the other way round:
- Explore takes NO identity - every read answered 200 to an anonymous curl - so the card is complete before scoring and the availability sticker is an overlay on it, never a gate on drawing it. A personalize failure leaves the feed up. - Null availability means 'not computed yet', never 'you have nothing'. In flight draws SCORING...; not yet asked draws nothing at all. A 0-of-N badge on an unscored recipe would be a confident lie about the user's kitchen.
Scoring is driven by what is VISIBLE, not by the fetch: a 26-row shelf against a 10-id batch would otherwise spend three round trips on cards nobody scrolled to. Rows are keyed by id - personalize rewrites in place and paging appends, so an index key would recycle one recipe's image onto another.
Coil is added for this. A visual browse rendered as a grid of fork glyphs is not the feature, and it also closes the 'TODO real image loading' left on the kitchen and saved-recipe rows.
Pill/AvailabilityPill are made internal rather than copied into Explore - the saved-recipe badge and the Explore badge must not drift into two roundings.
Not in this slice: creator shelves, the detail sheet, and the card's bookmark. Compiles; nothing driven on the handset yet.
c75e446
Row said 'UI not started' which is still true of the screen, but understated what exists: the state machine is built and compiles. Recorded what is left (chips, card, creator rows, detail sheet, entry point) so the next run does not re-derive it, and marked explicitly that none of it is on the handset.
3ff51e5
The transport landed in 7cdb0ca with nothing driving it. This is the state machine, ported from iOS ExplorePopup (MainTabView.swift:2060) - category browse, search, creator shelves, and the lazy personalize second pass.
Three rules the port had to carry rather than reinvent: - a generation counter, so a personalize answer that lands after the user switched category is dropped instead of badging the wrong feed; - pagination only in creator mode, because search does not paginate server-side and category browse asks for the whole shelf in one page; - scored vs pending as two sets, so an id the server skips goes back in the queue rather than shimmering forever.
Its own ViewModel, not more fields on TrepoViewModel: four independent failure modes should not share the kitchen's single busy/error pair.
99cacc2
Corrects a row that read "not started" with no notes at all. Records what the live probe found that the Swift alone would not have taught: one creator's total_likes arrives as a quoted string next to another's null in the same array, so a strict Int decoder loses all four creators.
States the limit plainly rather than rounding up: those were curl probes, so the models compile but no payload has been through the Kotlin decoder yet. Not claimed as verified, and the UI slice is where that gets proven.
7cdb0ca
Explore is the last unbuilt feature row and it is big, so this is the transport slice only - no UI, nothing wired into a tab yet.
Two things came out of probing the live host rather than porting blind:
- The creators feed sends total_likes as a JSON STRING for one creator and null for another IN THE SAME ARRAY ("1839041" vs null, measured 2026-08-20). Kotlinx throws on the quoted one, and because it is one array that would have taken all four creators down with it. iOS hit this first and works around it with a hand-written initialiser; LenientIntSerializer is the same fix, reusable, applied to every count field on the feed.
- The host takes no identity at all. owner is an optional query param that upgrades a public browse into a kitchen-scored one, so Explore can paint before sign-in and personalization is a droppable second pass. mergeAvailability() therefore keys by id and leaves unscored rows alone, so a partial personalize never wipes badges already on screen.
c41df88
Drove all three interaction paths on the handset.
Permission is asked in context on the mic tap, not at screen entry: with RECORD_AUDIO revoked the tap raised "Allow Trepo to record audio?", and Don't allow put the amber "Thyme needs microphone access to hear you." in the transcript with no recording started. Granting swapped the composer for the recording bar with 12 of 20 meter bars lit off actual room noise, timer at 0:02, and the platform's green mic indicator in the status bar. The lit meter is the point: a static "Recording..." label is equally consistent with a mic that never armed.
Sending restored the composer with the mic disabled mid-turn, put the voice placeholder bubble up, and the server answered "No speech detected." - the correct answer for a phone alone in a quiet room, and the end-to-end proof in itself: the backend accepted the audio/wav body, parsed it as WAV, ran ASR over it and returned a domain answer rather than a decode error. logcat -b crash empty. Test files removed from the device.
What the row does NOT claim: the meta-transcript fill is unexercised. It needs the server to hear real speech and the handset is remote in a silent room; playing a generated WAV out of its own speaker found no intent handler for a file:// audio URI. Written, not proven, and recorded that way.
Also corrected the Thyme row's "Still not built: voice/mic capture" line, which this run made false.
753c737
Ports iOS inputControls (VoiceAssistantView.swift:1186) and sendRecording (:1333).
Recording SWAPS the composer out rather than adding a button to it, which is what iOS does and is the honest affordance: while the mic is open the only two things available are throw the take away or send it.
The level meter is not decoration. It is 20 bars lit against the live RMS, using iOS's own Int(audioLevel * 20) threshold (:1296) so both platforms respond identically to the same voice - and it is the only thing on screen that distinguishes an open microphone from a "Recording..." label over a mic that never armed.
Both of iOS's refusals are carried, kept distinct: a stop that yields no audio at all says "No audio was recorded", a take under 0.6s says "Recording too short. Tap the mic and speak, then tap send." They go into the transcript as error bubbles, not toasts, matching iOS.
Two things this had to get right that iOS gets for free: - DisposableEffect cancels the take if the screen goes away. A leaked AudioRecord holds the microphone against the entire device, so the next app to ask for it fails, and the recording it is filling can no longer be sent by anyone. - RECORD_AUDIO is requested on the first mic tap, not at screen entry - same rule as the notification prompt. A permission dialog raised before the user has shown any interest in the microphone is the one that gets denied.
Builds. Device verification next.
ad31f5d
Ports iOS sendVoice (VoiceAssistantView.swift:362). streamThymeTurn is now parameterised by its event source and an onTranscript hook, so the voice and text paths share one streaming loop instead of forking it - the delta handling, the by-id bubble rule and the done/final_text precedence can only be defined once.
The non-obvious shape: the user bubble goes up EMPTY. The phone runs no speech-to-text; the request carrying the WAV is the same one that answers, so what the user said is unknown at send time and arrives as meta.transcript, which is written into the already-visible bubble by id. Showing nothing until the transcript lands would leave the user looking at a screen that does not acknowledge they just spoke.
Two corrections against the iOS source, both from reading it rather than
assuming:
- The zero-event sync fallback must NOT fire on the voice path. askThymeSync
takes a message string, which is empty here, so retrying would send Thyme a
blank question and get a confident answer to it. Text keeps the fallback.
- The mic is tap-to-record then tap-to-send, not hold-to-talk - iOS opens a
recording bar with cancel/Recording.../send that survives a finger lift - and
the short-take floor is 0.6s (minimumRecordingDuration, :709) with its own
distinct message. WavRecorder now returns null only for NO audio and leaves the
length check to the caller, so a dead mic and a fumbled tap stay
distinguishable. Added downsampledLevels (24 bars, 0.05 floor) per :187.
Compiles. Still no mic button - UI is the next slice.
7d4e8e6
Ports iOS VoiceRecorder.swift and VoiceAssistantStreamService.streamAudio (:96). Thyme's mic is push-to-talk, not a session: record a short utterance, POST the whole WAV to the SAME NDJSON endpoint the text path uses, get the same event stream back. The backend transcribes, so there is no separate STT call - the transcript of what the user said arrives as the meta event.
Because only the body and Content-Type differ, the NDJSON read loop is now shared (emitStream) rather than copied, so the terminal-event rule and the HTTP-failure shape are defined once for both entry points.
Three things are deliberately NOT copies of VoiceAudioIo: - MIC source, not VOICE_COMMUNICATION. VoiceAudioIo needs the voice path so the platform AEC arms against Gemini's own speech re-entering the open mic. Nothing plays during a push-to-talk take, so that setting would only hand the transcriber a duplex-tuned, noise-suppressed signal for no benefit. - A real 44-byte RIFF header, built little-endian explicitly. AudioRecord returns raw PCM; headerless bytes sent as audio/wav are a file the server cannot know the rate of. - Bounded buffering: capture self-stops at 60s (~1.9MB) rather than growing without limit if a press is never released, and takes under 300ms are discarded locally instead of costing a round trip to be told Thyme heard nothing.
The iOS meter curve is reproduced rather than invented (clamp dBFS to -60..0, normalise, pow 0.6) so the waveform has the shape users already see.
Compiles. Not on device yet - there is no mic button, that is the next slice.
35bfcb3
The row was one line hiding two unrelated features. The local completion notification is built and driven on the handset in both directions of the foreground guard; the remote/APNs half is not buildable without an FCM sender and a backend that can fan out to platform=android, so it is now an explicit Backend ask rather than an open TODO.
65719ca
dumpsys showed the posted record as sound=null defaults=0 - from API 26 the channel owns sound, so the builder default was dead code claiming to do something. The small icon was the platform sync-arrows glyph, which reads as 'something is syncing', not 'groceries ready'; replaced with a white-silhouette basket vector since Android keeps only the alpha channel of a small icon.
b16585f
Ports the half of iOS's notification story that does not need a backend: the local UNTimeIntervalNotificationTrigger both session managers fire when an analysis job finishes. The remote/APNs half needs an FCM sender and a backend that can fan out to platform=android; neither exists, so it stays a Backend ask.
The foreground guard is the feature, not a nicety - iOS bails on galleryIsPresented / app-is-active, so a check-in the user is watching must not also buzz. Tracked via ActivityLifecycleCallbacks rather than inferred, and returning to the app clears the banner however the user got back.
8b27d0a
The row said 'transport built'; the mic and speaker now exist too, so it says so. Logged why both ends declare the voice-communication path - iOS inherits AEC from .voiceChat, so nothing in GeminiLiveService.swift hints that getting it wrong makes the model interrupt itself.
4be7eb1
The other half of the Gemini Live feature - GeminiLiveService deliberately shipped the wire protocol alone, so nothing could actually speak or be heard. VoiceAudioIo is the AVAudioEngine half of the iOS service ported over: AudioRecord at 16kHz mono PCM16 feeding 100ms chunks to sendAudioChunk, and a streaming AudioTrack for response.audio.
Both ends declare the voice-communication path (VOICE_COMMUNICATION source, USAGE_VOICE_COMMUNICATION usage) because that is what arms the platform AEC. Without it the model's reply comes back in through the open mic, Gemini reads it as the user barging in, and it cuts its own turn off - iOS avoids the same trap via .voiceChat mode.
Two deviations from the Swift, both to remove moving parts: AudioRecord.read already blocks until it has samples, so the read loop replaces the lock plus 100ms Timer, same cadence on the wire; and playback writes go through a Channel drained by one coroutine, because AudioTrack.write blocks when the track is full and audio arrives on the WebSocket pump, which must never block.
No UI yet - that is the next slice. Committed on its own because the audio path is the part that fails silently and is worth reading in isolation.
63a1944
The Thyme row said voice was 'streamAudio, the same endpoint with an audio/wav body' and that is true - but it is only half the story, and the half it omits is the bigger one. iOS ships two unrelated voice features: Thyme's push-to-talk WAV upload, and Gemini Live, a realtime duplex session on a fifth host wired to the camera and the kitchen. Gemini Live was not in this table at all, so anyone reading it would have priced 'voice' at one slice.
Adds its row (transport shipped, no UI, honestly not verified on device since there is nothing to drive), and two undocumented-behaviour lines: the two-feature split, and the CloudFront trap where an h2 probe 404s a host that is live - which would read as a wrong path and is exactly the kind of thing that gets a working endpoint written off.
6df9c54
Voice on iOS is not speech-to-text into the chat box - it is a full realtime session against a fifth backend host (a CloudFront relay at d1rsowhe2r09pj.cloudfront.net/ws), with duplex audio, video frames and an inventory side-channel. Porting that in one go would be a 1300-line change that cannot be reviewed, so this lands the wire on its own: handshake, every inbound message type GeminiLiveService.swift handles, and the outbound senders.
Mic capture and playback are deliberately absent - the protocol is the part that fails silently, and it is worth landing where it can be read.
Two deviations from iOS, both deliberate: - iOS fires session.start right after resume() and relies on URLSessionWebSocket queueing sends until the handshake lands. Ktor's webSocketSession() suspends until the socket is open, so we send after it returns and the ordering is guaranteed rather than incidental. - iOS answers scan_requires_video by sending a frame itself. The camera belongs to the view here, so we surface NeedsVideoFrame and let the owner answer.
ad0ebdc
Row said 'built, not exercised on device'. Exercised it against the leftover MikeTest tag, which the API independently showed carrying 2 assignments, so the counted confirm could be checked against a known number rather than taken on trust. It read '2 recipes', the chip vanished without a refresh, '12 saved' was unchanged, and the server returned assignments {}. Cleans up a prior run's test tag in the process.
b3d1dd2
The press ripple was painting a square behind a round button, caught in the mid-flight screenshot rather than by a test. clickable over background(fill, CircleShape) has nothing clipping the indication.
PARITY records the slice as verified on device, including the two design decisions (own feedback rather than the List tab's banner, and a visible failure state where iOS has none) and the Thyme card as the remaining place this button belongs.
3fe2540
A recipe sheet that names three missing ingredients and offers no way to act on them makes the user retype them into the List tab by hand. This is the bridge from reading a recipe to shopping for it, ported from iOS IngredientAddToListButton (RecipePersonalization.swift:340).
Kept off the List tab's own busy/error state deliberately: addListItem drives a banner on a screen the user is not looking at. addIngredientToList reports to the caller, so the button owns the only visible feedback, and splices the created row in only when the list has actually been loaded.
One deviation from iOS: a failed add shows a red cross rather than silently snapping back to the plus, which on a flaky connection is indistinguishable from a tap that never registered.
c69d609
Records the finding that reframes this row: the gap was not just 'personalize not started', it was that a saved recipe's ingredients and steps had no screen at all - tap went to the tag sheet.
Two spec-drift lines added: the saved-recipes list already carries the full personalization payload inline (so /personalize is a refresh, not the source), and match_status is an open vocabulary that must not be modelled as a wire-level enum.
Honest about what was NOT seen: substitute/have rendering is written from the iOS source but the App Review kitchen is empty, so production only ever emitted pantry and missing.
68f21c0
Tapping a saved recipe opened the TAG sheet, so a saved recipe's own ingredients and steps were unreachable - the app could save a recipe and then never show it back. Tap now opens a detail sheet; tags moved to a button inside it.
The sheet renders personalization off the list row rather than fetching it. Measured against production: GET /saved-recipes/{owner} already returns availability, ingredient_matches, substitution_candidates, substitution_summary and substitution_status inline on every recipe (all 12 rows carried availability), so /personalize is a recompute-after- kitchen-change refresh, not the only source. No spinner, no second round trip.
match_status is typed String at the wire and narrowed in code: production emits only pantry/missing against an empty kitchen while iOS knows five values, so an enum at the wire would throw away a whole recipe the day the backend adds a sixth.
The open sheet is keyed by recipe id and re-resolved from the live list, so a refresh updates it instead of pinning a stale copy.
fab2042
The gap was filed as a one-Text AnnotatedString fix. Reading the iOS source first showed it was wider - the assistant bubble parses blocks and only then applies bold - so the row now records what was actually ported and, importantly, what was deliberately NOT (headings, italics, links, code: iOS has none).
Records both device screenshots, and pins two behaviours as intentional iOS parity so a later run does not 'fix' them: numbered lists render as dots, and an unterminated ** shows its asterisks mid-stream.
Also flags the two dishes on the App Review account as fixtures items 12 and 13 need, not leftovers - deleting them would cost the next run its baseline and cannot be undone.
7851468
The bubble drew Thyme's raw string, so a kitchen answer arrived as 'Your kitchen is empty right now.' - asterisks and all. iOS never had this bug because the assistant branch does not render the string: assistantContentView (VoiceAssistantView.swift:1366) parses it into title/header/bullet/paragraph blocks and applies inline bold per block.
Ported that, deliberately at iOS's scope rather than 'markdown' - bold, -/*/bullet lines, numbered lists, trailing-colon headers, short first-line title. No headings, italics, links or code, because iOS has none of them and inventing them is divergence dressed as an improvement.
Applied to the assistant bubble and the recipe intro only. User messages stay verbatim, matching iOS reaching assistantContentView from the assistant branch alone.
Unterminated ** renders plain, delimiters included - that is not an edge case here but a normal mid-stream frame, and it resolves to bold when the closing pair lands.
46523c7
Records the streaming slice as verified, with the mid-flight screenshot as the actual proof rather than an inference - the bubble caught holding a half-written recipe with no card, then the same bubble settled into a card reading '10 ingredients, 12 steps'.
Also corrects this row's own description twice over: the transport is NDJSON not SSE, and it lives on a Lambda Function URL rather than the API Gateway host the sync path uses. Both were wrong in the table before.
Separately logs a real gap seen while driving it: Thyme writes markdown and the plain bubble draws it literally ('empty right now'). It predates this change - recipe answers become cards and never show prose, so it took a non-recipe answer containing bold to surface it. Not fixed here because it wants its own before/after on device.
1c67454
The bubble now fills in as the model writes instead of sitting on 'Thyme is thinking...' for the whole turn, and the tool note appears at tool_start rather than after the tool returns - that note is the reason a long turn does not read as a hang.
Three judgement calls worth stating:
- Recipes are parsed only at 'done', never off the accumulated deltas. Mid-stream the text is a half-written recipe and the parser would flash a card claiming '3 ingredients, 1 step' that then rewrites itself.
- The fallback to the sync path fires ONLY when the stream produced zero events. Thyme has side effects (it checks items in, adds to the list), so re-asking a turn that already reached tool_start could double-apply them, and a double-added shopping list is worse than a failed answer. Zero events means nothing was ever carried. That is not a proof that nothing ran server-side and is not treated as one.
- An error arriving after text is already on screen KEEPS the text. The user watched those words arrive; blanking them reads as the app eating a real answer.
The sync path is kept rather than deleted because it is on a different host (API Gateway) to the stream (a Lambda Function URL), so one being unreachable does not imply the other is. Every bubble write resolves by id, per RELIABILITY R3 - a streaming loop is the easiest place in the app to carry an index across a suspend point by accident.
f08d3e3
Ports VoiceAssistantStreamService.swift, the half of Thyme that was listed as not built. Emits a Flow<ThymeStreamEvent> so the bubble can fill in as the model writes instead of sitting on 'thinking' for the whole turn.
Two things the port had to get right rather than copy:
- The name in PARITY was wrong. This is NDJSON, not SSE - bare JSON per line, no 'data:' prefix, no blank-line separator. A client written to the SSE shape would strip a prefix that is not there and parse nothing. Confirmed by probing production directly.
- iOS's timeoutInterval=90 is a BETWEEN-PACKETS timeout. Ktor's requestTimeoutMillis is a TOTAL one, so copying 90 into the obvious field would guillotine a healthy turn that simply had a lot to say. Mapped onto socketTimeoutMillis instead; total budget left infinite.
Lines are hand-parsed off JsonObject rather than @Serializable for the same reason DishAnalysis is: this payload is live in front of ~12,600 iOS users and free to grow fields (tool_start already carries an 'args' object nothing reads), and kotlinx fails the whole object on one unknown key. Unrecognised lines are skipped, never thrown on.
262bfaf
Household sharing moves from 'transport built, no UI' to verified: the screen shipped in 85058d2 gives it a caller, household 73846 renders with a copy affordance, LEAVE appears only when that id is non-empty, and the 4-6 digit guard fires before the destructive confirm rather than after it - typing 12 painted the field red and the Join dialog did not open.
Join and leave themselves were not executed. Both are destructive on the App Review account and would move it out of 73846 for no verification gain; the calls were already measured against production earlier today.
New undocumented contract row: the household id is write-once. verify-code and POST /household are the only two places it is ever handed out and there is no GET for it, so a client that does not persist it on those two occasions cannot get it back. Found by looking at a Profile screen that said 'No household assigned' while verify-code was returning 73846 for the same account.
85058d2
Android had no Profile screen at all, which is why the household join/leave transport shipped in f396b0f had no caller. On iOS the whole household feature lives inside ProfileView.swift, so 'household sharing' was never a self-contained slice - the screen was the blocker.
Ported: header, Account (household id + user id, both copyable), Household (join by 4-6 digit code, invite, leave) and Log Out, reached from a circular person button placed left of the Ask Thyme pill exactly as iOS orders them.
Not ported, each for a stated reason rather than by omission: notifications (the capability does not exist on Android yet), Halo pairing (a BLE provisioning milestone, not a settings row), tutorial replay (not built), delete account (irreversible, needs its own confirm ladder and error path).
Both household operations invalidate the shopping list rather than refetching it. Join REPLACES the caller's list server-side and leave mints a fresh empty household, so the rows in memory afterwards belong to a household the user is no longer in - rendering them for even one frame is showing someone else's list. On failure nothing is cleared, because the server did not touch anything.
householdId is now restored from Session on cold start. It arrives once, on verify-code, and no endpoint reads it back, so without the restore Profile would tell a user with a household that they have none and hide their LEAVE button.
3fc17ff
Recording the gap that makes household sharing bigger than its row suggested: iOS keeps the entire feature inside ProfileView, and Android has no Profile screen at all - no sign-out, no household code to share. That was never listed here, so this loop would have kept picking 'household sharing' as a small slice and rediscovering the same wall. Added it as its own row.
Also recording the measured contract (400 not 404, quoted id) and the two iOS branches that are dead against the real server, so nobody 'completes' the port by adding them back.
f396b0f
Ported from iOS AuthService (joinHousehold :715, leaveHousehold :831). POST {auth}/household with operation join|leave, returning the new household id.
Three things this turned up that were not obvious from the spec:
The endpoint hangs off /v1, NOT /v1/auth, so it needed AUTH_ROOT rather than reusing AUTH - appending to AUTH gives /v1/auth/household, which does not exist. Named the constant so the next endpoint on this host does not have to rediscover the level.
It is the first request in this app that needs a bearer token. Every other endpoint takes ownerId in the path and authenticates on nothing, so verify-code's token was being thrown away. Session now persists it (and the numeric household id, which Profile will need to display). Session.save takes both as nullable and only overwrites when non-null: a re-save that dropped the token would sign the user out of the household endpoint while leaving them signed into the app, a state with no UI and no way back.
errorMessage read 'error' alone, which is right for the grocery APIs but wrong here. Measured the real failure: a bad code answers 400 with {message, code} and no 'error' key at all, so the server's own text was being swallowed for a generic fallback. Now walks the iOS ladder message -> error -> code. Worth noting iOS's friendly INVALID_HOUSEHOLD copy is dead code, since the server always sends message and message wins - so it is not reproduced here.
new_household_id is hand-pulled rather than @Serializable so a numeric JSON value parses as readily as the quoted string the server actually sends; iOS casts as? String and would throw on that.
No UI yet - transport only, so it lands as a compiling unit.
59b8106
The Dish log summary still read 'logging a new dish is still absent - it needs the analysis pipeline', which item 14 has contradicted since the presign -> PUT -> poll -> feed flow was driven end to end on the handset. A stale summary row is worse than a missing one: this loop picks its next task from this table and the iOS side reads it to know where Android is, so that line was advertising work that is already shipped.
Backend asks said 'Nothing outstanding' while item 15 claimed the dish mutation 404 had been 'logged under Backend asks'. It never was - the table was empty. Logged now with the curl reproduction and the iOS call sites that prove it is not an Android id-handling bug.
1703c9f
Exercised the last unverified action on the dish detail sheet. The correction screen itself behaves - submit is gated until text is entered, and on failure it shows the server's own message, keeps the typed correction, and stays retryable rather than faking success or blanking the sheet.
The round-trip is blocked server-side. recharacterize 404s on both dishes on the account, reproduced with curl outside the app, including the July 'Fried egg' row that iOS created. That is the same dead zone already logged for DELETE, so the backend ask is widened from 'delete' to 'all dish mutations'.
Read the iOS source rather than guessing at whether this is our id handling: iOS parses _id from the same feed into backendId (DishAPIService.swift:131) and sends exactly that to both mutations, so ~12,600 live iOS users hit this identically - a dish older than roughly an hour can be neither corrected nor deleted. Recorded under Backend asks; not fixable from this side.
6c2dd77
204f997
iOS anchors its steps header with $, so it only matches a bare 'Steps:' on its own line. Three probes of /voice-ack today (garlic butter pasta, chicken stir fry, what-should-I-cook) all came back with ingredients on their own lines but the whole steps run INLINE after the colon - '- Steps: 1. Boil... 2. Melt...'. hasSteps was false every time, so a faithful port would have shipped cards that cannot appear. Added the inline case, and nothing else.
Splitting that run is sequence-aware rather than a plain split on \d+[.)], which would break 'Bake at 350. 10 minutes' in half and invent a step. A marker only counts if it carries the number expected next.
Verified on device: 'Garlic Butter Pasta' rendered as the red card reading '10 ingredients - 8 steps'; the sheet listed all 10 ingredients and steps split clean 1-8 with 'cook for 30 to 60 seconds' and 'Parmesan and 2 tablespoons' intact. Control turn 'What is in my kitchen right now?' still drew a plain bubble, so an ordinary answer is not swallowed into a card. logcat -b crash empty.
91406cb
app_output carries a message and tool actions and nothing else - when Thyme suggests something to cook, the entire recipe arrives as markdown inside message.text. iOS recovers structure from that text rather than asking the backend to change shape; this is that parser (VoiceAssistantView.swift:556) plus the red card and detail sheet it feeds.
The parser is deliberately hard to trigger: both an Ingredients: header and a steps header must be present, and any block that does not yield a title AND ingredients AND steps is discarded. It runs on every assistant turn, so a false hit would swallow 'your kitchen is empty' into a recipe card - a miss is much cheaper than that.
b03d8fd
Row moves not-started -> partly verified, with the two spec corrections the probe turned up (a fourth API host nothing documents; a toolTrace field the iOS Codable silently drops).
The evidence worth naming: the follow-up 'And what about my shopping list?' came back 'Your shopping list is ALSO empty right now'. Nothing in that message mentions the kitchen, so the word 'also' is the only proof available that session_id multi-turn memory is actually wired - a per-message session id would have answered it in isolation and looked identical otherwise.
Recorded what is NOT built - stream, mic, cards - so the row cannot be read as the whole feature.
00cf6d9
The assistant is reachable: a floating 'Ask Thyme' pill over the Kitchen, the way iOS floats it (MainTabView.swift:58), opening a chat that answers. Text and sync only - the SSE stream and the mic are additive and land later.
Four decisions worth the ink:
- Entry is an OVERLAY, not a tab. iOS reaches Thyme from whatever tab you were on and drops you back there; a fifth tab would throw that away. closeThyme() restores the tab you left, and the pill is hidden on the assistant itself. - ONE session id per app run, not per message. It is the entire multi-turn memory story server-side, so regenerating it per turn would make Thyme forget the previous sentence. Rotated on sign-out so a new account cannot inherit the last one's memory. - The pending bubble is keyed by a stable UUID and replaced in place, never addressed by index (R3). Concretely: an answer can take 60s, and anything the user does meanwhile shifts a captured position. A failure turns that bubble into an error, never removes it - vanishing reads as the app eating the question. - Thyme MUTATES state. It checks items in, adds to the list, logs dishes. So a successful turn refreshes the tab you came from - and only that one, because refetching all four per turn costs four requests for tabs nobody opened.
a8f47c3
Ported from VoiceAssistantService.swift and probed live before writing a line of UI. Three things the iOS Codable does not say, and one that would have bitten:
- Thyme lives on ivwu7ls6p8, a third API-Gateway host. Identity is a header (x-owner-id), not a body field or a token - omitting it does not 401, it cheerfully answers about nobody's kitchen, which is the worst kind of wrong. - The live payload carries a camelCase toolTrace that the iOS struct has no field for and JSONDecoder silently drops. iOS gets its tool pills from the SSE stream instead; the sync path is blind to them. Android models it, so the non-streaming slice can still say what Thyme actually did. - 30s is the wrong timeout. A trivial question answered in 3.4s on probe, but a turn that runs search_web_recipes runs far past that, so the default would fail exactly the answers worth waiting for. 90s, same reasoning as saveRecipe.
Text/sync path only for now - voice capture and the stream are a later slice.
d35b141
Logged two dishes from the handset against production: presign -> PUT -> poll -> feed all work, the pending card shows real progress, and the daily rings recompute by exactly the dish's macros. Item 15's detail sheet and delete are driven too. Also files a backend ask: DELETE /dishes 404s on dishes the feed still returns once they are more than about an hour old, reproduced outside the app with curl.
7acca74
visible() only hid a pending row while the feed carried the real dish, so deleting that dish flipped the condition back and the settled card returned as a ghost - seen on device at 95%, 'Results will appear here shortly', never clearing. refreshDishes now reconciles: once the feed has the row, the pending copy is dropped for good. An in-flight poll is unaffected, update() addresses rows by id and no-ops once the row is gone.
b26400a
Reconciling the table against the code before picking the next slice, as the loop is supposed to. Row 14 claimed the LOG A DISH button was not drawn; a163ac6 drew it and wired it through to DishUploadSession, and a526f84 fixed its shadow. Item 15 had already gone stale the same way, so this is a pattern worth naming rather than a one-off typo: the row is the thing the iOS side reads to know where Android is, and a false 'not built' invites someone to build it twice.
Also closes out the compression trap the row warned about - CaptureScreen grew a dishMode flag, so the dish leg takes jpegForDish (2000px raw JPEG) and can no longer inherit the check-in path's 1600px base64.
What is genuinely left on 14 is device verification, which writes a real dish to a backend shared with ~12,600 live iOS users and so needs a deliberate write-then-delete pass.
10a1325
Leaving the shared test household reassigns a new id («id» -> 11586 -> 73846). Recording it so a future run doesn't mistake the change for corruption - the user_id UUID is the stable key, not the household id.
34eb845
The added_by_name rendering (milestone 3, item 9) had only ever been built to the contract - the App Review account was a household of one, so no foreign entry existed to display and the byline was never seen.
Temporarily seeded a two-member household (App Review acct + the TestCompanion fixture) with a companion-created dinner entry, drove the Cook tab's THIS WEEK section to Fri Aug 21 on the handset, and saw the card render "Added by TestCompanion" - not the null-fallback "Household", which would have meant the name was being dropped.
Screenshot kept as evidence. The shared-household state is torn down in the same run; it must not persist, since that account is what Apple uses for review.
a526f84
Seen on device: the hard shadow rendered as a full-height dark block sitting ABOVE the red button rather than offset behind it. Cause was hand-rolling it as a separate Box - inside a LazyColumn item those stack vertically, so the "shadow" was laid out as its own row.
TrepoCard's own doc comment warns about exactly this class of bug and it draws the shadow with drawBehind from the card's node, which makes the shadow the same size and place as the thing casting it by construction. Reused it rather than fixing my version. It also supplies the 2dp off-black border, so the CTA now matches iOS logDishButton without duplicating any of it.
a163ac6
Item 14 has read 'pipeline built, button not drawn' since ca9d635 on the principle that a button that does nothing is worse than no button. The pieces under it now exist, so it is drawn.
Ported from iOS logDishButton (MainTabView.swift:15765): red fill, off-black 2px stroke, hard 4/4 shadow, above the nutrition section rather than below the feed - the numbers it fills in are the ones directly underneath it.
The in-flight row is the part that matters. It renders from createdAt, not from a counter in the composable, because the user is expected to leave this tab while the analysis runs and a counter that restarts on recomposition would show progress going backwards. Capped at 95%: it is an easing curve over elapsed time, not a measurement, and reaching 100% while the row still says "analyzing" is the one place the estimate would visibly lie.
Once the fast pass lands the row shows the actual calories and protein instead of another line of reassurance - that is what the fast pass is for. A failed dish gets a yellow card carrying the SERVER's own message, not a generic one: "you are offline" and "that upload link expired" want different reactions from the user, and it is dismissible so a dead row cannot wedge the feed.
DishCapture is its own destination rather than a flag on Capture, so backing out returns to the Dish Log tab instead of the check-in review flow.
Builds. Device verification next - not claiming this one until I have driven it.
2710991
The analysis takes tens of seconds and iOS lets the user leave the camera the moment the shutter fires, so the screen cannot simply await the round-trip. This is the state holder they come back to, ported from DishSessionManager.
Guards, each one a bug it would otherwise have:
- Rows are keyed by a CLIENT id, not the backend id. The backend id does not exist until the final pass returns, so a row keyed on it is unaddressable for most of its life. Every mutation goes through update(id){} - no index is held across a suspend point (R3). - A fresh presign PER upload attempt. Retrying the same short-lived URL after a slow failure is precisely the case most likely to have expired. 403/404 comes back as UploadNotRetryable and breaks the loop rather than burning two more attempts on a dead link. - A failed poll only fails the dish if NOTHING has landed. Once the fast pass has put numbers on screen, pulling them off because a later request timed out is strictly worse than leaving the partial result up. Same reason a later empty pass cannot overwrite numbers already showing. - Running out of poll attempts with numbers showing is not a failure either - it is a late final pass, and the feed refetch settles it. - poll_after_ms is the server's to set (shared endpoint, ~12,600 live iOS users), clamped to 0.5-10s so a malformed value cannot spin or stall the loop. - The settle refetch waits 3s like iOS: the analyser writes the row, and a refetch racing it returns without the dish, which reads as the dish being dropped. A FAILED dish refetches too - the row may have committed after the client lost the thread. - Pending rows the feed has caught up with are HIDDEN, not removed: the poll may still be running and deleting the row would strand it with nowhere to write.
Not a singleton - the activity ViewModel already outlives tab switches. iOS's disk persistence for cold starts is a separate slice, noted not half-built.
Compiles. The CTA that calls it is next.
a810af9
The upload pipeline landed in ca9d635 with a warning attached: CaptureScreen hands back base64 fitted to ImagePrep's 5.6MB INLINE budget from 1600px, and the dish leg PUTs raw JPEG to S3. Wiring the CTA to the existing callback would have compiled and run and quietly sent the analyser a worse photo than iOS sends it, which is the kind of bug nobody files - the dish just comes back wrong more often.
So the dish path gets its own preparation, ported from iOS CameraLogView.compressForUpload(image:maxDimension:2000):
- 2000px, not 1600. 1600 is the RECEIPT Lambda's own downscale target, so it costs nothing there. The dish analyser has no such step, so the same number is just a smaller photo handed to the model. - Raw bytes, not base64. base64 would PUT a 33% larger body that decodes to something no image parser wants. - Never returns null for size. fitToBudget gives up when even the floor won't fit, which is right for a batched body that would 413. iOS's dish path falls out of the loop and returns quality-20 bytes anyway. A soft photo beats an error the user can't act on, so jpegForDish returns null only when the bytes aren't a decodable image at all. - Keeps iOS's 2.8MB base64 ceiling even though nothing here base64s. The dish call site takes the default, so that ceiling is real on iOS and the analyser has only ever been fed images inside it. Measured on the number rather than by building the string, to avoid doubling peak memory on a 2000px capture.
CaptureScreen gains dishMode instead of a second camera screen - permission,
preview, flash and the picker are all identical. Both entry points now go
through one deliver, so the camera and the picker cannot drift into
preparing the photo differently. The fridge/receipt chips are hidden in dish
mode: a tap there would send the photo down a different pipeline than the
button the user pressed.
Compiles. Nothing calls dishMode yet - the CTA is the next commit.
69a376c
Also records the compression trap for whoever draws the button: CaptureScreen returns base64 fitted to ImagePrep's 5.6MB INLINE budget starting at 1600px, but the dish leg goes to S3 with no payload ceiling and iOS sends dishes at maxDimension 2000. Reusing fitToBudget would silently hand the analyser a worse photo than iOS does.
Checked rows 8-13 against the tree this run and they already match reality (milestone 3 is committed in 62d1186, the dish log in 5ed5c19) - no correction needed this time.
ca9d635
Item 14 read "not started - it needs the dish analysis pipeline, and a button that does nothing is worse than no button." That was the right call, so this builds the pipeline rather than the button. Ported from iOS UploadAPIService.createPresign / uploadImage / pollDishResult and the state machine in DishSessionManager.swift.
Three legs: POST {grocery}/presign with type=dish returns a job id and a presigned S3 URL, the JPEG goes up as a plain PUT, then GET {grocery}/dish/result is polled until terminal. Note this is the app's FIRST presign - Android's grocery check-in takes the inline-base64 route through /identify-async instead, so none of it was already sitting there to reuse.
Three things carried over deliberately, each of them a bug avoided:
- DishAnalysis is hand-parsed out of JsonObject, not @Serializable. The Swift parser accepts a number as int OR double, and accepts ingredients/allergens as an array OR a string holding a JSON array. We have already MEASURED the first case on this backend - the dish feed returned calories 90.0 (see DishRow). A strict model throws on the whole payload, so being strict here means a dish the user photographed and waited on arrives blank. - 403/404 on the S3 PUT throws UploadNotRetryable, not ApiMessage. Those mean the presign expired, and an expired URL does not un-expire; retrying burns the caller's attempts and delays an error the user is already waiting on. Every other non-200 stays retryable. - pollDishResult is ONE poll, not the loop. The loop belongs to the caller so it can honour the server's poll_after_ms rather than picking its own interval - this endpoint is shared with ~12,600 live iOS users - and so it can show the "fast" pass the moment it lands instead of blocking on "final".
Builds. No UI yet and nothing calls this, so the tree stays shippable; the button and the pending-dish card come next.
08839c8
The original B5 note said SavedRecipes.kt had 7 dialogs with 1 setting containerColor, and that MealCalendar did not set it. Both wrong: that file's one containerColor is on a ModalBottomSheet, so 6 dialogs set none, and MealCalendar does set it. The wrong count implied the wrong fix (six hand edits) which is why it is worth correcting rather than quietly overwriting.
Records what was actually seen on the handset for the tag menu, the rename dialog's focus border and caret, and the meal dialog that the corner change could only have regressed.
46af2ae
The theme set surface, so surfaces looked covered. Material3 1.2 split them
into a surfaceContainer* family and AlertDialog takes its container from
surfaceContainerHigh, which was still being generated from the baseline purple
tonal palette. Six of the eight dialogs passed no containerColor and came up
lavender on a cream app.
Fixed at the scheme rather than the call sites. Six edits would have fixed the six dialogs that exist and none written later; naming the roles means a new dialog is Trepo-coloured for being a dialog. Same leak was reaching outline (text-field borders), onSurfaceVariant (labels) and error, which was Material's maroon and not Trepo red.
Containers resolve to cardBg, matching the two dialogs that already set it by hand, so those keep the look already signed off on the handset. extraLarge pulled 28dp -> 18dp so a dialog shares the TrepoCard radius.
e0497e3
Seen on device while driving 7a. Same family as the red-Cancel bug: every Material default we accept brings a theme that is not ours. Queued rather than rushed, because it is 7+ surfaces and each deserves a look on the handset.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
16b3212
The 409 path was the thing 7a said was unproven, so it was worth driving rather than reasoning about: renaming MikeTest to MikeTwo on the handset now shows the server's own text inside the sheet and rolls the name back by id. Zero crashes, nothing persisted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c153bb3
PARITY flagged 7a with '409 on duplicate name MUST be shown, not silent' and listed it as built-but-unexercised. Reading the path rather than trusting it: the viewmodel is correct - it reverts the optimistic rename by id and puts the server's message in recipeError - but TagAssignSheet took no error parameter and drew no error surface. recipeError is rendered in exactly one place, the recipe screen, which is the screen this ModalBottomSheet is sitting on top of.
So renaming a tag to a name that already exists made the name snap back under the user's finger with the explanation drawn underneath the sheet, where it could not be seen. Not literally silent, which is worse than silent: the app looks like it rejected the edit for no reason.
Gives the sheet its own RecipeNote, and clears recipeError when the sheet opens so it cannot inherit a stale message from an unrelated operation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
29925c4
Second time this has happened, and the cost is specific: this loop picks its next task from that table, so a stale 'not started' invites rebuilding work that already exists. Recorded what was actually verified and, as importantly, what was not - the sheet's layout was measured on device, but the correction and delete round-trips were never exercised, because the sheet only opens from a row inside the seven-day window and this account's one real dish sits outside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
51b8f40
Material's default TextButton paints in the theme primary, which for us is the Trepo red used for destructive actions. So the confirm dialog offered two red buttons and the safe one carried the same visual weight as the one that deletes. Pin Cancel to offBlack.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3d8ee41
MEASURED ON DEVICE: at bottom = 40.dp the DELETE DISH card sat under the three-button navigation bar even at full scroll, so the most destructive action on the screen was the one you could not fully see.
Tried the correct fix first - systemBarsPadding() inside the dialog, then
again with decorFitsSystemWindows = false. Neither moved a pixel on device:
a Compose Dialog window does not receive system-bar insets here, so the
inset resolves to zero. Recording that because it will be true of every future
full-screen dialog in this app, not just this one.
So it clears the bar the way the tab screens already do, with a fixed pad.
5ed5c19
Tapping a dish row now opens the detail sheet iOS reaches from health_dish_tap
(MainTabView.swift:16806), with the same section order and the same two
destructive actions underneath it.
Three things worth stating, because each is a bug avoided rather than a preference:
- recharacterize returns the corrected dish under item WITHOUT echoing
_id. Parsing that on the feed's rules would drop the row we just corrected,
since Dish.from requires an id. iOS passes the id it sent back in as
backendId:; fallbackId is that, ported.
- The corrected row keeps its ORIGINAL createdAt. The server's date is not
trusted to be the same day, and if it moved, the dish would relocate in the
grouped list or fall out of the seven-day window entirely - which would read
to a user as "my correction deleted my dish".
- Delete is optimistic but restores BY ID on failure, never by a position
captured before the suspend point (spec/RELIABILITY.md, R3).
Delete confirms first: DELETE /dishes/{owner}/{id} is final and the photo
analysis that produced the row cannot be replayed from the client.
Also fixes deleteDish throwing a bare IllegalStateException via error()
instead of ApiMessage, which meant the server's own error text could never
reach the user.
35421e7
Recorded as part of finishing the slice, not as bookkeeping afterwards - the stale milestone-2 rows are the reason that rule exists.
Marks items 12 and 13 verified on device and says plainly what was NOT seen: the dish row layout is unexercised, because the account's only dish is outside the seven-day window and nothing may be written to production to manufacture one.
e53fcc2
Found by driving it, not by reading. The tab rendered the yellow banner "Could not load your dishes." against an endpoint curl had already returned 200 for. Production sends ingredients as a JSON *string* - "[\"egg\", \"cooking oil or butter\"]" - so a field typed List<String> throws, and kotlinx fails the whole object, so ONE mis-typed field on ONE row blanked the entire tab.
iOS tolerates both shapes (DishAPIService.parseStringArray) and now so does Android.
Worth noting what worked: the banner refused to render the empty state over a failed fetch, so this showed up as an error rather than as a plausible, silent "no dishes" - which is the RELIABILITY rule earning its keep.
cbc2548
Ported from iOS HealthView (MainTabView.swift:15608) rather than the spec: poster title, disclaimer, daily macro rings and the seven-day dish history grouped by day. The meal-plan and Kitchen-IQ blocks HealthView declares are NOT in its body, so their absence here is parity, not a gap.
Two things the live payload forced that the Swift parser only implies. Macros come back as doubles (calories 90.0, protein 6.3) and are nullable, so a model typed Int would throw and blank the whole tab. And _createdDate carries no timezone and no fractional seconds (2026-07-02T06:06:32), which the primary iOS formatter does not match at all - every row falls through to the UTC fallback, so UTC is the real behaviour. Reading it as local time would move a late dish into the wrong day and mis-total today.
Read-only for now: logging a dish needs the analysis pipeline, and a CTA that does nothing is worse than no CTA. No photo thumbnails either - this app still has no image loader - so rows fall back to the emoji iOS reserves for photo-less dishes.
f976d84
Drove items 9, 10 and 11 on the A36 against the App Review account:
- week view renders Aug 16-22 with four slots a day, a TODAY badge, working week arrows, and auto-scroll to today that still leaves earlier days reachable - free-text entry round-tripped: added "Oatmeal and Berries" to Tue 18 breakfast, then paged a week forward and back to force a refetch and it came back from the server, so it persisted rather than only looking like it did - list generation exercised BOTH ways. With the week already covered it said "You already have everything for this week" - an explicit message rather than a silent no-op. Cleared the list and re-ran: "Added 2 items - skipped 4 you have or already listed", grouped under the recipe title, which is the iOS store-field reuse rendering correctly.
Zero crash-buffer entries. All test data deleted afterwards - list, calendar entries, and a stray kitchen row left by an earlier run. Account is empty.
The PARITY edits are the bigger half of this commit. Every milestone 2 row
still read not started despite having shipped in 7a9450f, and the milestone
1 header still called items 3 and 5 not started when both were verified days
ago. That is not cosmetic: this loop picks its next task from that table, so a
stale row invites rebuilding work that already exists, and iOS reads it to
know where Android is.
Rows now say what the code actually does, and I split built from verified
on device rather than flattening them. Tag rename/delete, multi-select and
the ownership gate are in the code but I did not drive them this run, so they
say built. The meal calendar's added_by_name rendering is likewise built to
change #17 but unexercised - the App Review account looks like a household of
one, so no foreign entry exists to display. Claiming that verified would be
exactly the kind of overstatement the status vocabulary exists to prevent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
62d1186
Recovering work an earlier run wrote but crashed before committing. It compiles clean; device verification follows in this run.
The calendar is per-household, not per-user (iOS change #17), so entries carry attribution and delete is NOT gated on the creator - anyone in the household can remove anything. Entries added by someone else render their added_by_name, which is the whole point: two users told Matt this week that their meal plan does not sync with their partner's, and seeing "Anna added this" is what makes the shared plan legible.
added_by_name is nullable even for other people's entries, so it falls back to "Household" rather than showing an empty byline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aab5fd0
Two things found by driving the app rather than reading it.
Android had no session persistence at all. ownerId was a plain mutableStateOf and nothing else, so the identity died with the process and every cold start landed on the sign-in screen. Seen twice on device: once after a reinstall, and once on an ordinary relaunch of an app that had been installed for an hour. That is not just friction - re-auth means an SMS round trip, so it is a real per-launch cost, a deliverability risk, and the user cannot open the app at all until a text arrives. iOS does not do this; AuthService.checkAuthenticationStatus() restores the identity on launch and only shows LoginView when that fails.
Session stores the owner id and phone in app-private prefs. Deliberately NOT the Keystore: Android discards the JWT verify-code returns, so there is no credential here - the owner UUID is already a path parameter on unauthenticated endpoints. That reasoning is written in the file so whoever adds token auth knows the token does not belong there.
The part that did matter is backup. allowBackup was true with no rules, so the session file would have been restored onto a DIFFERENT handset, signing a second device into the account with no sign-in and no SMS. Both rule files now exclude that one file, and device-transfer too.
Restore fetches the kitchen after switching screens, not before, so a cold start is never held behind a network timeout. A failed restore fetch shows an error and keeps the session: "could not reach the server" and "you are not who you said you were" are different facts.
**The availability badge announced HAVE IT ALL for recipes it knew nothing about.** The old label returned it whenever missing_count == 0, which is also true of matched 0 / missing 0 - the shape a recipe has while extraction is still running or after it produced nothing. iOS guards that with totalCount > 0 and renders no badge, which is honest.
Matched the rest of the badge to iOS while there: can_make_with_subs was being ignored entirely, though it is a distinct state and the difference between the amber and blue badge; the text is matched/total and READY, not "2 OF 3" and "HAVE IT ALL"; and the three colours are restored, so "cook it now" no longer looks identical to "you are missing six things". Verified on device: the badges read 2/3, 2/5, 2/9 in the blue tier with the 1.5pt border and 2pt hard shadow.
Session restore verified on device: force-stop then relaunch went straight to KITCHEN, no sign-in screen and no SMS.
356e6bd
TrepoCard built its hard offset shadow as a sibling Box sized with matchParentSize(). That measures the shadow against the OUTER box, which callers stretch with fillMaxWidth(), while the bordered card measures against its own CONTENT. Any card whose content did not fill the width therefore rendered as a short pill with a full-width black slab sticking out to the right of it. The Cook error banner did exactly that on device.
Drawing the shadow from the card's own layout node makes the two sizes equal by construction, so the next short-content card cannot reintroduce it. Draw modifiers do not clip, so the 4dp overhang still spills outside the layout bounds exactly as before and nothing about measurement or spacing changes.
Also from driving it: the saved-recipe composer field could push the Save button off the bottom of the screen once the error banner was showing and the draft reached four lines, because a Column measures unweighted children first and the field took everything it asked for. weight(1f, fill = false) hands the button its height first and lets the field keep what is left, while fill = false stops the composer ballooning when the draft is short.
And the dialog confirm/dismiss buttons were inheriting the Material default accent rather than offBlack, which is the one place stock Material was still showing through the Trepo idiom.
b457c57
Two failures found on device that both end with a recipe on screen that does not exist.
1. POST /saved-recipes/{owner} answers 202 with a fully-shaped recipe object carrying an id and "status": "failed" - the extraction did not work. The list GET filters failed rows out, so that id never comes back. We were splicing it into the list, giving the user an undeletable ghost row, and isPending ("anything not ready") rendered a SAVING... pill on it that could never resolve. Failed is now terminal and separate from pending, the row is never spliced, and the draft SURVIVES so the user still has the text they need to retry.
2. The list GET is not read-your-writes consistent: it can legitimately omit a recipe saved a second ago. settleRecipes assigned the fetched list raw, which deleted the row the user had just watched appear. Not-yet-visible saves are now carried across in front until the server catches up.
Same commit because the poll exit condition depended on both: none {
isPending } is true on an EMPTY list, so an absent recipe read as
"ready" and stopped the poll on precisely the case that needed another
one. It now requires all ids to have actually landed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7a9450f
The Cook tab was a Coming Soon placeholder. This is the library: list, save by link or pasted text, user tags with rename and delete, multi-select delete, and the ownership gate.
Four things here came from measuring production rather than reading the spec, and each one is a bug if you assume the documented behaviour:
- POST /saved-recipes returns 202 with the recipe already in the body and NO job object. iOS's postSave treats a 202 without job.job_id as a failure, so on iOS a text save shows an error over a save that worked. Android accepts a recipe body on 200/201/202. - The saved row is not finished when it arrives: status "", no instructions, null meal_category, resolving to "ready" seconds later. Same shape as the B1 kitchen bug, so it gets the same bounded settle poll rather than a spinner that never resolves. - A recipe does NOT carry its own tag ids. The only source of assignments is the recipe-categories response, so the tag filter is a client-side join. Assuming otherwise gives you a filter that is always empty. - saved_by is present on READ and absent on WRITE, so a client that gates on the object it just created disowns its own new recipe. isMine() reads null as mine, matching iOS.
Reliability rules that shaped the code, not decoration: the delete restores only the rows that actually failed instead of replacing the list with an error state (the iOS bug hid every recipe the user still had); rows are keyed by id because a settle poll can replace the list mid-tap; and the save draft is cleared on success only, because the one failure that asks the user to paste the recipe text was also the one throwing away what they would have pasted.
Device verification of this slice is pending in this commit.
a6fb13d
Item 5 had been blocked since 08-18 because spec/API_CONTRACTS.md files the
shopping list under the grocery base. It is not there. It is on the AUTH host,
at POST /v1/list, and it is not REST at all - there is no GET, no path
parameter and no DELETE verb. Every action is one POST to that single path
with an operation field: view / add / set_action / update_item / remove.
That is why nine REST-shaped probes on the grocery host all returned 404. The block was never going to lift by probing harder, because both the host and the protocol shape were wrong. Reading the iOS client (TrepoAPIService.swift:12 and :106) found it in ten minutes. The brief says the app is the source of truth; this is the run where that stopped being a slogan. Every operation was then verified live against production rather than trusted from the source.
Two shapes in here are traps rather than details, so they are encoded in types rather than left to a caller to remember:
- A row has TWO identifiers and only one works for writes. Mutations key on itemUUID (household-wide, because the list is shared), never on the numeric id, which is not an error - it just never matches. itemUUID only ever arrives in the add response, so addShoppingItem splices in the returned row instead of appending a locally-built one. ShoppingItem .canMutate makes a row that lacks it visibly not-ready rather than giving it a checkbox that silently does nothing.
- The checked state is a STRING on read ("ADDED"/"CHECKED") and a BOOLEAN on
write (set_action's checked). Read one, write the other.
The list keeps its own busy/error state rather than sharing the kitchen's: shared state means a failed kitchen refresh paints an error over the shopping list, and two tabs quietly lie about each other. clearChecked deletes one call per row (there is no batch endpoint) and restores ONLY the rows that actually failed - reverting all of them would put back items the server had already deleted, which is worse than the failure it is reporting. Same shape the milestone 2 multi-select delete will need.
Verified on device on the A36, not just compiled: three items added through the app's own add bar, one checked off (red tick, strikethrough, row to 55%, header 3 -> 2, and the server independently reading back CHECKED), one deleted with the x, then CLEAR CHECKED - which only renders while something is checked - clearing the last one and removing itself. Zero entries in the crash buffer. Account left empty.
Also logs six spec corrections measured this run, the saved-recipes API shape ahead of milestone 2, and iOS change #17 (meal calendar is now per-household, so delete must not be gated on the creator).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
635b3c0
d09f053
0bef3ba
iOS change #15 reported receipt batches exceeding the ~6MB API Gateway gate, failing invisibly, 67 users over 107 rejections. Checking my own code, the Android bug was blunter: both the camera and picker paths base64-encoded raw bytes with NO compression at all. Every test passed only because the fixtures were ~120KB - a real capture off this handset would have 413'd on a user's first photo.
ImagePrep.fitToBudget steps the dimension down from 1600, then the JPEG quality, until the encoded string fits ~5.6MB. 1600 rather than 2000 because the receipt Lambda downscales to GEMINI_RECEIPT_MAX_DIMENSION=1600 anyway.
The budget is a parameter rather than a per-image constant, so a future multi-photo batch shares one allowance. Sharing is precisely what iOS got wrong, and a per-image signature is what makes it easy to get wrong again.
Failure returns null and the UI shows a visible note. Never drops a photo silently - that was the half of the iOS bug that hid it for weeks.
Verified on device with a deliberately incompressible 4000x3000 noise image: 16,677,677 bytes, 22,236,904 as base64, nearly 4x the gate. Shrunk, uploaded, analysed, normal empty result. No 413, no crash.
Cursor acked to #16.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
59a1130
941bd9c
8d1383f
35f858b
My message #5 to the iOS side claimed they may be rendering invisible rows from receipt check-ins. That was wrong and I have corrected it.
KitchenAPIService.mapCategory() does normalise on read - substring match on dairy/egg, meat/seafood/fish, produce/fruit/vegetable and so on, else .pantry - so 'Dairy' displays correctly in the iOS UI.
The real finding is narrower and more useful: mapCategory is called in exactly two places, KitchenAPIService.swift:501 and DiscardAPIService .swift:119, both on the READ path. BulkCheckInService takes category as a raw String? from the analysis dict at line 664 and sends it verbatim at line 711. iOS normalises on read and not on write.
So the out-of-enum values persist in the database and are only masked client-side on iOS. Anything without that read-path mapper - backend grouping, analytics, the web app, Android - sees an unknown category, and nothing on iOS ever surfaces it. One line fix at BulkCheckInService:711.
Sent as message #7. PARITY B2 rewritten with the precise mechanism.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
02f16c6
Matt asked for the Android app to match iOS. Rather than eyeball screenshots, worked from reference/ios-source in trepo-web-starter - 78 Swift files including TrepoTheme.swift and MainTabView.swift - since the app is the source of truth for behaviour.
Measuring confirmed the card idiom was already right: the 0.85 opacity, radius 0, offset (4,4) shadow appears 98 times and the 2pt offBlack border 206 times, with radius 18 the most common partner. Android matched all three.
Worth recording so nobody "fixes" it later: the TrepoCard ViewModifier in TrepoTheme.swift draws a plain white rect with no border or shadow. It is dead code; the real screens apply both inline.
Measuring also found a whole shell Android was missing: a 4 tab bar (List, Kitchen, Cook, Dish Log) with a floating red camera button in a 64pt centre gap. Ported with the exact TabBarButton values - icon 19, label 9, bold when selected, offBlack/textSecondary, 22pt icon row, 5pt padding, 7pt badge at (6,-3).
Removed the separate "Check in by photo" button: iOS puts that affordance in the centre camera button alone, so having both was an Android invention.
Verified on device: tab bar renders, selection state moves correctly between Kitchen and List, camera button opens capture, no crash.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
363f218
Fridge interior photo through bulk_inventory_deep on device: 6 candidates rendered with valid enum categories.
Then tested the include/exclude guard deliberately, since it is the direct defence against the iOS 20-vs-18 gap. Unticked one row, the button moved from "Add 6 to kitchen" to "Add 5 to kitchen", committing wrote exactly 5, and the excluded row was correctly withheld from the server. Probed, not reproduced.
Item 5 (shopping list) is BLOCKED, not started: the documented endpoints 404. GET {grocery}/list/{owner} returns 404, as does POST. Probed 7 path variants on grocery and 2 on jobs, all 404, while the same owner UUID works fine on GET /kitchen/{owner} - so it is not identity. Asked the iOS side as message #6 marked BLOCKING rather than guessing further; guessing at paths is what cost a day on analysis_mode.
Test data cleaned up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
db062e6
Adds the Android photo picker and a FRIDGE / RECEIPT toggle selecting bulk_inventory_deep vs receipt_inventory_deep. Picking is a real user need, and it is also what let me exercise this flow without a human at a fridge.
Which immediately found B2. Receipt analysis returns categories that are NOT in the 9-value kitchen enum: on a 12 line receipt, 6 of 12 came back as 'Dairy', 'Meat' or 'Bakery'. Dairy is not dairy_eggs, Meat is not meat_seafood, and Bakery has no kitchen equivalent at all - a separate vocabulary, not a casing near-miss.
bulk-commit stores what you send verbatim, so passing these through writes rows that exist in the database and are invisible in the UI. That is the failure that cost iOS a 36 row backfill, except at half of every receipt.
Fix: KitchenCategory.fromWire maps synonyms and falls back to prepared_other for anything unrecognised, so no row is dropped or corrupted.
Verified on device end to end: picked the receipt, 12 rows rendered, eggs and milk corrected from PREPARED & OTHER to DAIRY & EGGS, committed all 12, server confirmed count 12 with zero out-of-enum rows. Button said 12 and 12 landed. Test data cleaned up.
Item 3, 3a and 3b are now verified on device. Flagged to iOS on the relay.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
be82b2e
Milestone 1 item 3 built. Camera capture, the analysing state, the review screen with per-row category and storage pickers, and commit.
- R4 built in from the start: resolveFlashMode asks hasFlashUnit() and falls
back to OFF, and the flash control only renders when the hardware has one.
Android's failure mode differs from iOS here - it silently ignores an
unsupported mode rather than throwing, which is quieter but just as wrong.
- The category picker derives from KitchenCategory.entries, and ReviewItem
.category is the non-nullable enum, so an out-of-enum category cannot be
sent even by accident.
- Storage offers "Let Trepo decide" as the first option: null is a
legitimate and often better answer, since the backend infers a location and
a guess we make is one it cannot correct.
- The commit button counts selected, the exact list that becomes the
payload, so label and body cannot diverge - the 20-vs-18 shape, ruled out
by construction.
- The analysing state has a label and a Cancel, never a bare spinner.
Driven on device: granted the camera, captured, watched the analysing state, landed on Review. The photo was of a dark surface so 0 candidates came back, which exercised the empty path - "Nothing recognisable in that photo. Try again, closer.", commit disabled reading "Add 0 to kitchen", no crash.
Left as built, not verified on device: the populated review list has not
been seen rendering rows on the handset. That needs someone to point the
phone at actual food.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15103be
Milestone 1 item 3 in progress. Camera and review UI still to come, but the whole analysis path is now written and exercised end to end.
- identifyAsync posts the photo INLINE as base64. There is no upload step
and no presigned URL. Sends owner AND user_id AND device_id: omitting
owner silently disables kitchen-overlap matching rather than failing.
- awaitJob polls the JOBS host, not the grocery one the original doc named,
and is bounded so a stuck job surfaces a retry instead of an endless
spinner - the failure mode DESIGN_SYSTEM says caused two iOS outages.
- JobStatus maps the real lowercase vocabulary (pending -> completed);
DONE is never returned.
- bulkCommit sends owner + required source_job_id.
- ReviewItem.category is the non-nullable KitchenCategory enum, so the
review screen structurally cannot send an out-of-enum category. That is
the bug that cost iOS a 36 row backfill, and a type beats a careful picker.
Verified against production with a real fridge photo: 202 + job created,
polled to completed, candidate returned as leftovers (a valid enum value)
with confidence 0.80 and needs_review true.
Also logs the candidate shape: the list uses item_name, NOT
product_name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5f29852
text-add returns before the backend fills in product_image_url and storage_location, so a single fetch straight after the add left rows with a blank image slot and no storage label, permanently.
settleEnrichment re-fetches up to 5 times at 1.5s until no row is missing an image. Bounded on purpose: product_image_url is legitimately nullable for some products, so an "until complete" loop would spin forever on those. The header doubles as tap-to-refresh for that case, and reads REFRESHING… while busy so the pending state is visible rather than a silent gap.
Re-reads the whole list each pass rather than patching rows in place, so no index crosses the suspend point (RELIABILITY rule 1).
Verified on device: added three items, at 14:40 all three showed the placeholder, at 14:41 with no user action they showed their emoji and Fridge/Fridge/Pantry. Test data cleaned up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
382059f
The handoff asked Android to install the relay as a LaunchAgent because it "runs under nohup and dies on reboot". That is out of date. The relay already has ppid 1, and a pkill was followed by a new pid within seconds - launchd KeepAlive under the matttaylor account.
So installing a second agent would have made two KeepAlive jobs fight over port 8787 forever. Deleted the staged plist rather than leave that trap lying around; it also carried the relay token.
The reboot concern is real but is a different problem than assumed: no account has auto-login configured, so after an unattended reboot NO user agent returns, whichever account owns it. Only a system LaunchDaemon fixes that, and that needs sudo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d1e68c9
Drove the flows on the A36 against production with the App Review account. Signed in, watched three text-added items land under PRODUCE / DAIRY & EGGS / PANTRY in enum order, removed one and saw the server agree. No crashes. Items 0, 1, 2 and 4 are now verified on device; 3 and 5 not started.
Driving the UI paid for itself twice.
It found a real bug (B1): text-add returns before the backend fills in product_image_url and storage_location, and the app fetches the kitchen immediately after, so those rows render with a blank placeholder and never resolve - no refresh, no polling. Proved rather than assumed: the rows added first showed the placeholder, then a later add forced a re-fetch and the same rows showed their emoji and storage while the newest row showed the placeholder. Parsing is correct, the refresh is missing.
And it nearly produced a FALSE bug. A 3-item text-add looked like it dropped
2 items. It was my harness: adb shell input text truncates at the first
space unless escaped as %s, so the field only held "eggs,". Checked the
screenshot before reporting it. Exactly the simplified-harness trap
RELIABILITY.md warns about.
All test data cleaned up; kitchen back to 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
721a312
MCP registered and verified end to end - all 8 tools enumerate, relay
healthy, and a real message reached the iOS agent as #4. Notes the schema
gotcha: send_message takes text, not message.
The LaunchAgent is staged but deliberately not bootstrapped. Port 8787 is already held (confirmed by a bind attempt), and the running relay is owned by the matttaylor account while the console session belongs to mikehunt, which is precisely why it should move. kill returns operation not permitted and there is no sudo, so the handover needs someone with that account. Script, plist and the migrated message store are all in place; one launchctl bootstrap finishes it.
Also corrects the handoff's claim that nothing can be verified on device - the phone has been connected over Tailscale since this morning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6b98d38
With the mode answered (bulk_inventory_deep), ran the experiment end to end against production on the App Review account.
- Committed exactly 20 distinct items, read the kitchen back: 20 of 20 landed, none missing. - Duplicate collapsing is NOT the mechanism: 5 items with names identical to existing kitchen rows, plus 5 new, all created as 10 separate rows with duplicate_count 0. - Commit is idempotent per source_job_id, so a retry cannot double-write.
A clean 20 commits cleanly, so the gap is most likely client side - the payload carried 18 while the button counted 20. Given three stale-index crashes already came out of that same bulk review list, a stale index dropping rows from the payload is the first place to look.
Also logs a trap: on a repeat commit of the same source_job_id, persisted_count and created_count still read 20 while nothing is written. Only confirmed_item_count and the message are honest.
All 30 test rows deleted, kitchen back to 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
32836cf
60d622e
Galaxy A36 (SM-A366U), Android 15, over Tailscale at «device address». Streamed install succeeds, MainActivity resumes, crash buffer is empty, and the design system renders as intended on real hardware - cream ground, hard offset shadow with no blur, heavy uppercase type.
Only item 0 is marked "verified on device". Sign-in, kitchen list and text-add stay "built": the handset has a credential lock and a locked device cannot be driven, so I have not actually put a finger through those flows. Their APIs are verified separately by direct HTTP, which is a weaker claim and is labelled as one.
Adds docs/DEVICE_SETUP.md. Four separate things block ADB on a modern Samsung and each hides the next: Auto Blocker kills it on BOTH transports; USB is unusable from a LaunchAgent because macOS TCC denies IOKit and Automation with no prompt possible; the phone may sit on an unroutable subnet, so Tailscale rather than the LAN; and the pairing port exists only while the popup is on screen, with the popup and the main screen showing two different ports.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9f9f7f2
send_message / check_messages on the MCP now reach the other agent directly instead of leaving a note in the repo. Verified end to end: the Android MCP sent a blocking message, the iOS side read it and replied, and the reply came back through check_messages.
The relay is a small store-and-forward HTTP service on the always-on Mac mini, bound to the Tailscale interface only and requiring a shared token. It holds no credentials, touches no database and cannot deploy anything - the same safety posture as the rest of the MCP, because both apps share ONE production backend serving ~12,600 live users.
Running now under nohup. README documents the LaunchAgent install, which has to be done under the account owning the GUI session since a launchd user agent needs an active login.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQzSSEVY1gRUf1THeedCC2
432ce8b
analysis_mode must be exactly bulk_inventory_deep / receipt_inventory_deep; the five values Android tried fall back to product_analysis, which is why bulk-commit rejected every job it could create.
Also applies the corrections Android found by probing production, including two that were actively dangerous: GET {grocery}/job/{id} returns 200 with all-null fields forever rather than erroring, and job status is lowercase 'completed', never 'DONE' - both are hang-with-no-feedback traps.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQzSSEVY1gRUf1THeedCC2
b2b30c5
39eaa7b
Static docs go stale; this lets the agent query the real system. Six tools: read_spec, get_changes, probe_api, get_parity, report_progress, ask_ios_side. Zero dependencies, stdio JSON-RPC, Node 18+.
The safety model is the point. Both apps share ONE production backend serving ~12,600 live iOS users and there is no staging, so the server holds NO credentials (no DB password, no AWS key, no deploy path) and probe_api is GET-only against two allow-listed hosts. The worst an autonomous agent can do through it is read. It defaults to the App Review account rather than real user data.
report_progress and ask_ios_side commit and push on the agent's behalf, so progress and blocking questions reach the iOS side without manual git steps.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQzSSEVY1gRUf1THeedCC2
a161de2
Investigating the 20-vs-18 open question against production. Could not answer it - blocked on an undocumented mode - but the probing turned up four contract drifts, one of them nasty:
- GET {grocery}/job/{id} is the wrong host AND fails silently: HTTP 200
with an all-null body. The real endpoint is GET {jobs}/job/{id}. A client
polling the documented URL hangs forever with no error, which is the
exact spinner-never-resolves outage mode DESIGN_SYSTEM warns about.
- Job status is lowercase pending/completed, never the DONE/FAILED the doc
lists under "contracts that must not drift".
- bulk-commit takes owner, not owner_id, and requires an undocumented
source_job_id.
Logged the blocking question rather than guessing at a production write path: how does a client create a committable inventory (BULK) job? Every documented parameter is ignored and jobs come back product_analysis.
No test data left behind; kitchen is empty and every commit was rejected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7a32647
84a8d9f
First slice of PARITY.md milestone 1. Builds to a debug APK; nothing is verified on device yet because the handset still has USB debugging off.
- Compose project (AGP 8.7.3, Kotlin 2.0.21, minSdk 26/target 35, Ktor 3.0.1) - Design system ported as the idiom, not the pixels: hard offset shadow drawn as a second shape behind the card, since Compose elevation is the soft Material look Trepo deliberately is not - KitchenCategory as a closed enum; every picker derives from entries so it cannot drift from the backend the way the iOS aisle picker did - product_image_url modelled as three states, not two: production returns plain null as well as emoji: and URL - CrashReporter from day one (R1/R2), JVM exceptions only, native signals called out as uncovered rather than implied - delete() re-resolves by id after the suspend and restores only the failed row (R3, R5)
Three spec corrections logged in PARITY.md, the significant one being that the documented auth path 404s: the live route is /v1/auth/send-code, not /v1/send-code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4f64715
One file that says what Trepo is, the one hard rule (never deploy backend: both apps share ONE production backend serving ~12,600 live iOS users), the reading order, the test account, the parity target (1.12 build 96, now in App Store review), and the three things iOS learned expensively:
- ship crash reporting on day one (27 devices crashed unseen for weeks) - never carry a list index across a suspend point (caused 3 crashes in a day) - drive the UI before calling it done (3 bugs invisible to code review)
Also records the unexplained 20-vs-18 commit-count gap so a second sighting on Android would turn it into a backend question.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQzSSEVY1gRUf1THeedCC2
d88bcf2
f339483
Adds two spec docs so Android can start on milestone 2 without asking:
spec/SAVED_RECIPES.md - tags (rename/delete, all endpoints already live) and multi-select delete, including the MEASURED API behaviour (409 on duplicate rename, idempotent create/delete, 64-char truncation) so Android does not have to re-derive it, plus the three UI bugs iOS shipped into.
spec/RELIABILITY.md - the crash patterns. One class of bug (an index carried across a suspend point) caused three iOS crashes in a single day, and the first one hit 27 devices unseen because the app had no crash reporting at all. Ship crash reporting from day one.
PARITY.md gains milestones 2 and 3 and a cross-cutting reliability checklist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQzSSEVY1gRUf1THeedCC2
9a15c7e
cad9d72
fc56dd4
fac6fff
537ae24
6bf7725
cc5ee5e
b569ecc
bf234bd
The machine's location was never load-bearing - what matters is that it cannot see the iOS machine - and naming a place in a repo goes stale the moment the box moves. The agent is called Mike.
e4e4ba9
b2aef1f
53ea35d
This repo is the contract between the two machines. The iOS side appends to CHANGES.jsonl when something ships that Android needs to know about; the Android side reads it at the start of each session and records progress in PARITY.md.
Git rather than a service on purpose: it works offline, it diffs, it keeps history, and neither machine has to be awake when the other is.
The spec leads with the things that have already gone wrong on iOS, because those are the expensive ones to rediscover: the 9-value category enum that silently hides items when you write outside it, owner_id vs the numeric household id, nullable availability that must render as pending and not zero, and never leaving a spinner without an exit.
Milestone 1 is deliberately the core loop - auth, kitchen, photo check-in with the review screen, text add, shopping list. That is where the usage is (137,652 check-ins in 30 days) and it exercises most of the API surface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQzSSEVY1gRUf1THeedCC2