Pill tabs (Quick Add / AI Chat) stay visible and tappable.
Pages are also swipeable. Pills sync with swipe position.
Quick Add shown first, swipe left for AI Chat.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Widget background tap → platform://fitness → opens Fitness tab
Widget + button tap → platform://add-food → opens Fitness + food assistant
Small widget: + button in bottom-right corner (emerald green)
Medium widget: + button in bottom-right corner
Lock screen widgets: single tap → fitness (no room for + button)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Widget: .widgetURL(platform://add-food) on all widget sizes
App: .onOpenURL handles platform://add-food → switches to Fitness
tab and opens the food assistant sheet
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Session expires: widget gets 401 → clears stale cookie from
App Group → stops retrying with bad auth → shows cached data
until user opens app and re-authenticates
2. Account switch: login() now calls clearWidgetAuth() BEFORE
syncCookieToWidget() — clears previous user's cached calories
before writing new user's cookie. No brief display of wrong data.
3. Logout: already correct — clearWidgetAuth removes cookie +
cached data, widget shows 0/2000
4. Minimum data: only session cookie + 2 cached numbers + timestamp
in App Group. No passwords, no user IDs, no PII.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Widget:
- Fetches /api/fitness/entries/totals and /api/fitness/goals/for-date
directly from gateway using shared session cookie
- Falls back to cached data in App Group UserDefaults if network fails
- Refreshes every 15 minutes via WidgetKit timeline
- Each phone shows the logged-in user's own data
Auth sharing:
- AuthManager.syncCookieToWidget() copies the session cookie to
App Group UserDefaults on login and auth check
- Widget reads cookie and makes authenticated API calls
- Logout clears widget auth + cached data
Data in App Group (group.com.quadjourney.platform):
- widget_sessionCookie: auth token for API calls
- widget_totalCalories: cached fallback
- widget_calorieGoal: cached fallback
- widget_lastUpdate: cache timestamp
HomeViewModel also writes cache on each loadTodayData() as fallback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Widget displays:
- systemSmall: calorie ring + "X left" text
- systemMedium: ring + "Calories" / "X of Y" / "X remaining"
- accessoryCircular: gauge ring for lock screen
- accessoryInline: "🔥 845 / 2000 cal" text for lock screen
- accessoryRectangular: linear gauge + calorie count
Data flow: main app writes totalCalories + calorieGoal to
UserDefaults on each loadTodayData(), then calls
WidgetCenter.shared.reloadAllTimelines(). Widget reads on
15-minute refresh cycle.
Note: currently uses standard UserDefaults (same app container).
For production, migrate to App Group UserDefaults so widget
process can read the data. Requires Xcode App Group setup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tab bar action button cycles through speeds on each tap:
- ▶ (play) → tap → Slow (1.5x, hare icon)
- Slow → tap → Med (1.75x, walk icon)
- Med → tap → Fast (2.0x, bolt icon)
- Fast → tap → back to Slow
Touch feed to stop → icon returns to ▶
Removed speed controls from Reader toolbar — all speed control
is now in the single tab bar button. Clean, no overlays.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. .navigationBarTitleDisplayMode(.inline) — title stays at top
2. .background(Color.canvas) — restores warm cream background
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reader now shows only unread entries. Removed:
- Unread/Starred/All sub-tab selector
- Feed filter chip bar (categories)
- All related state (selectedSubTab) and helpers
Glass nav bar shows: "Reader" title + "74 unread" subtitle
Trailing toolbar: grid/list toggle + ellipsis menu
Auto-scroll: speed controls in leading toolbar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removed .navigationBarHidden(true) and all custom header layout.
Now uses standard iOS 26 navigation APIs that get Liquid Glass free:
- .navigationTitle("Reader") + .navigationSubtitle("74 unread")
- ToolbarItemGroup for sub-tabs (Unread/Starred/All) on leading
- ToolbarSpacer between groups
- ToolbarItemGroup for grid/list + menu on trailing
- Feed filter chips in .bottomBar toolbar
- When auto-scrolling: toolbar swaps to speed controls
The glass nav bar is translucent — content scrolls underneath.
Collapses to inline glass pill on scroll (system behavior).
No custom backgrounds, no custom layout — all system-managed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Uses .safeAreaBar(edge: .bottom) on the Reader content — the iOS 26
API that places content in the collapsed tab bar area, same position
as the Now Playing mini bar. Speed controls [ - ] 1.50x [ + ] appear
in the glass bar when auto-scrolling.
Removed the floating speed pill overlay from ContentView.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Speed pill and feedback button were in separate VStacks with
independent absolute padding, causing misalignment. Now they share
one VStack with .padding(.bottom, 70) at the container level.
The speed pill sits directly above the tab bar area, positioned
relative to the same anchor as all other bottom controls.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Speed pill was floating mid-screen (padding.bottom 90). Moved to
bottom (padding.bottom 8) to sit just above the tab bar area.
Switched from ultraThinMaterial to regularMaterial for better
Liquid Glass look with more opacity and stronger blur.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE (confirmed by instrumentation):
Every markRead during auto-scroll mutated entries[idx].status,
causing SwiftUI to re-render the row (bold→regular, dot removed).
This changed card height, causing contentSize jumps of 10-128pt
per entry — visible as jitter.
FIX: During auto-scroll, collect entry IDs in deferredReadIDs
instead of mutating entries array. When auto-scroll stops,
flushDeferredReads() applies all status changes at once and
sends a single batched API call.
Manual scroll still marks immediately (no deferral needed since
the user controls the scroll position).
Removed all debug instrumentation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Calling scrollViewWillBeginDragging/DidEndDragging on the delegate
didn't trigger tabBarMinimizeBehavior — iOS 26 likely tracks actual
touch events, not delegate calls. Reverted to avoid side effects.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
tabBarMinimizeBehavior needs scrollViewWillBeginDragging to recognize
a scroll session. Now ScrollViewDriver calls it on the original
delegate when auto-scroll starts, and scrollViewDidEndDragging +
scrollViewDidEndDecelerating when it stops.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bug: when action tab (value=3) was tapped, selectedTab was already 3
by the time handleActionTap ran. The check 'if selectedTab == 2'
always failed, falling through to food assistant.
Fix: use onChange(of: selectedTab) oldValue to capture which tab the
user was on BEFORE tapping the action button. Pass that to
handleActionTap(from:). If from Reader (2), toggle auto-scroll.
If from Home/Fitness, open food assistant.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Single separated circle in tab bar (like Photos search icon):
- Home/Fitness: shows + icon, taps opens food assistant
- Reader idle: shows play icon, taps starts auto-scroll
- Reader playing: shows pause icon, taps stops auto-scroll
Icon updates dynamically via computed actionIcon property.
handleActionTap() routes the tap based on selectedTab.
After action, selectedTab returns to the previous tab (doesn't
stay on the invisible "action" tab).
Speed controls still appear as glass capsule overlay when playing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removed Tab(role: .search) — it was always visible on all tabs.
Now the floating action button changes based on selected tab:
- Home/Fitness: FAB (+) → opens food assistant (same as before)
- Reader: Play/Pause circle → toggles auto-scroll
- Idle: play.fill icon, warm accent background
- Playing: pause.fill icon, red background
- Tapping toggles between play/pause
Speed controls appear as glass capsule above the FAB when playing.
Auto-scroll stops when switching away from Reader tab.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Migrated to new Tab API (iOS 18+). The auto-scroll play button
uses Tab(role: .search) with systemImage: "play.fill" — this gives
the separated circular placement on the trailing side of the tab
bar, identical to the Photos app search icon.
Tapping the play icon:
- Switches to Reader tab
- Starts auto-scroll
When playing: a Liquid Glass speed control capsule appears above
the tab bar with [ - ] 1.00x [ + ] [ stop ].
Removed the old floating glass pill implementation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Moved auto-scroll control from ReaderTabView to MainTabView so it
sits at the tab bar level, trailing side — matching the iOS Photos
search icon placement.
Idle: 48px glass circle with play icon (bottom-right, tab bar row)
Playing: expands to capsule with [ - ] 1.00x [ + ] [ stop ]
Spring animation between states.
Grid/list toggle and ellipsis menu moved inline to the sub-tab
header row (next to Unread/Starred/All) so they're always visible
without needing a toolbar.
ReaderTabView now receives isAutoScrolling and scrollSpeed as
bindings from MainTabView.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. FAB hidden when selectedTab == 2 (Reader) — no plus button
on Reader since it's for food logging
2. Auto-scroll now notifies original UIScrollViewDelegate via
scrollViewDidScroll after each contentOffset change — this
triggers tabBarMinimizeBehavior so the tab bar collapses
during auto-scroll just like manual scrolling
3. Glass control bar positioned bottom-right (like Photos search
icon) instead of bottom-center
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Floating glass pill above the tab bar with .ultraThinMaterial:
Idle state: [ ▶ ] [ grid/list ] [ ⋯ ]
- Play: starts auto-scroll
- Grid/list: toggles card/list view
- Ellipsis menu: mark all read, refresh, manage feeds, add feed
Playing state: [ - ] 1.00x [ + ] | [ ■ ]
- Speed adjustment in 0.25 increments (0.25x–3.0x)
- Stop button (red)
- Animated spring transition between states
Removed .navigationBarHidden(true) toolbar items — all controls
now in the glass bar. Nav bar stays hidden (no title needed).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The GPU warmup set webView.alpha = 0 to keep it invisible while
attached to the window. makeUIView reparents it to the article
container but never restored alpha — articles rendered invisibly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
GPU process can exit due to idle timeout if user waits before opening
Reader. reWarmIfNeeded() checks elapsed time since last warm — if >60s,
re-attaches WKWebView to window and loads minimal HTML to restart the
GPU process. Called on ReaderTabView.onAppear.
No timers, no keep-alive loops. Just a timestamp check on tab appear.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The GPU process was exiting due to idle timeout between warmup and
first article open. Now the WKWebView stays attached to the window
(alpha=0, invisible) until first article use. makeUIView reparents
it to the article container via removeFromSuperview + addSubview.
Also: removed all debug logging (warmup, article open, WebView timing).
Confirmed by instrumentation:
- GPU process launches during warmup (1.3s, background)
- First article open: 22ms total, WebView finish: 3ms
- No user-visible freeze
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
THEORY: WKWebView at frame .zero or detached from window skips GPU
compositor init. First real display triggers GPU process launch (~3s).
FIX: Create WKWebView at screen bounds, attach to key window (alpha=0)
during warmup. WebKit launches GPU process while user is on Home tab.
Remove from window after 2s (GPU process stays alive).
Also: ensureAttachedToWindow() fallback if init runs before window
exists. Called from ContentView.task where window is guaranteed.
Added 1x1 transparent GIF in warmup HTML to force image decoder init.
Kept all debug logging for verification.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE: Articles from sites like onemileatatime.com serve WebP
images with .jpeg extensions. WebKit attempts JPEG decode, fails,
retries as WebP — the retry loop blocks the compositor thread during
scroll, causing ~1s freezes per image.
FIX 1 — Image attributes (ArticleHTMLBuilder.optimizeImages):
Regex injects loading="lazy" decoding="async" on all <img> tags
that don't already have loading= set. This tells WebKit to:
- decode images off the main/compositor thread (decoding=async)
- only decode when approaching viewport (loading=lazy)
FIX 2 — CSS max-height:
img { max-height: 600px; object-fit: contain; }
Limits decoded image buffer size. A 1200x900 image still displays
at full width but WebKit doesn't need to composite oversized tiles.
Both fixes are content-rendering optimizations only — no changes
to WKWebView architecture, scrolling model, or layout system.
Also marked Atmos Rewards articles as unread for testing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LOG EVIDENCE:
1. notVisible: wasVisible never set for entries already on screen
at list load. Removed wasVisible guard — trackingActive (100pt
scroll) is sufficient protection.
2. aboveVP: maxY never goes below 0. LazyVStack destroys views at
~maxY=0. Changed threshold from maxY<0 to maxY<30 (nearly off).
3. notDown flickering: per-entry deltas are ~1pt, causing direction
to flip between down/not-down on every callback. Made direction
sticky: scrollingDown stays true until 30pt of cumulative upward
scroll is detected. Prevents jitter from sub-pixel noise.
Removed debug logging.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EVIDENCE (from xcode.txt):
- down=false ALWAYS: per-entry deltas are ~1-2pt per callback,
but filter required >2pt. Every delta was rejected.
- cumDown stuck at 129: threshold was max(100, 956*0.2) = 191.
With most deltas rejected, cumulative barely grew.
FIXES:
1. Delta filter: >2pt → >0.5pt for direction detection.
Cumulative accumulation accepts any delta >0 (no filter).
Per-entry callbacks deliver small deltas — filtering at 2pt
discarded virtually all genuine scroll events.
2. Threshold: removed 20% viewport scaling, fixed at 100pt.
The scaling made sense for a global offset tracker (large
deltas), not per-entry tracking (small deltas).
Removed debug logging.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bug: lastKnownMinY[entryId] was set to newMinY on line 83, then
read back on line 97 to check direction. Since it was just set to
newMinY, the check (lastKnownMinY[entryId] <= newMinY) was always
true, making isMovingUp always true, so the guard always failed.
Fix: Read prevMinY BEFORE writing newMinY. Compute isScrollingDown
from the delta between prev and new. Use that boolean in the guard.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>