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. |
| 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 list, grouped by the 9 categories | verified on device | 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. |
| 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. |
| # | 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. |
| 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 | verified on device | 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: 17 commits.
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