## Bug 1: First open didn't mark as read
ROOT CAUSE: Race condition. markAsRead set local status="read",
then getEntry returned the server's status="unread" (API sync
hadn't completed yet) and overwrote the local mutation.
FIX:
- markAsRead runs FIRST, before getEntry (was concurrent before)
- After getEntry, merge server response but PRESERVE local
status/starred (which may differ from server due to race)
- currentEntry syncs from vm.entries after markAsRead, ensuring
the toolbar reflects the correct state
## Bug 2: Long articles freeze before scrollable
ROOT CAUSE: WKWebView.scrollView.isScrollEnabled = false, embedded
inside SwiftUI ScrollView with .frame(height: webViewHeight).
For a 15000px article, WebKit had to render the entire document,
JavaScript measured document.body.scrollHeight, SwiftUI relaid out
the 15000px frame — all blocking before scroll became responsive.
FIX:
- WKWebView now handles its own scrolling (isScrollEnabled = true)
- Removed SwiftUI ScrollView wrapper around article
- Removed contentHeight binding and height measurement JavaScript
- Removed the Coordinator's didFinish height evaluation
- Article header (title, feed, time) moved into the HTML document
so it scrolls naturally with the content
- WKWebView fills available space, scrolls natively via WebKit's
compositor thread — immediate scroll response
Both fixes preserve the shared WKWebView architecture.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root-cause investigation identified 5 architectural issues. This commit
fixes all of them with structural changes, not patches.
## 1. Persistent ArticleRenderer (fixes first-article freeze)
BEFORE: Every article tap created a new WKWebView with a new
WKWebViewConfiguration and a new WKProcessPool. Each spawned a
WebContent process (~3s). The "warmer" used a different config,
warming a different process — useless.
AFTER: Single ArticleRenderer singleton owns one WKWebView with
one shared WKProcessPool + WKWebViewConfiguration. Created at app
launch via `_ = ArticleRenderer.shared` in ContentView.task.
ArticleWebView wraps the shared WKWebView in a container UIView.
SwiftUI owns the container lifecycle, not the WKWebView's.
Zero process launches after first warm-up.
## 2. Stable Reader layout (fixes tab jitter)
BEFORE: Sub-tabs and feed chips were conditionally rendered
(`if !vm.isLoading || !vm.entries.isEmpty`). When loading finished,
~80px of UI appeared suddenly, causing layout shift that rippled
to the tab bar.
AFTER: Sub-tabs and feed chip bar ALWAYS render. Feed chip bar has
fixed height (44px). No conditional wrappers in the layout hierarchy.
Content area shows LoadingView during fetch. Chrome never changes shape.
## 3. Local-first state updates (fixes mark-read lag)
BEFORE: markAsRead made 3 sequential API calls (mark, re-fetch entry,
re-fetch counters). toggleRead and toggleStar did the same. Each
action had 3 network round-trips before UI updated.
AFTER: Mutate local entries array immediately (status/starred are
now var). API sync happens in background via Task.detached. UI updates
instantly. Counter refresh happens async.
## 4. Atomic list replacement (fixes empty flash)
BEFORE: loadEntries(reset:true) set `entries = []` then
`entries = newList`. Two mutations = empty state flash + full
LazyVStack teardown/rebuild.
AFTER: Never clear entries. Fetch completes, then single atomic
`entries = newList`. SwiftUI diffs by Identifiable.id — only
changed rows update.
## 5. Reserved thumbnail space (fixes card layout jump)
BEFORE: AsyncImage default case was EmptyView() (0px). When image
loaded, 180px appeared. Cards jumped.
AFTER: Default case renders a placeholder Rectangle at 180px.
Card height is stable from first render.
## Additional: Pre-load moved off TabView
`.task { await readerVM.loadInitial() }` moved from TabView
(caused observable mutations during TabView body evaluation,
contributing to tab bar jitter) to the outer ZStack.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
onDisappear fires when scrolling in BOTH directions and when
navigating, making it impossible to reliably detect scroll direction.
Reverted to simple behavior: articles only mark as read when you
tap into them (handled in ArticleView). Will revisit mark-on-scroll
with a proper ScrollViewReader approach later.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
loadInitial() had guard !isLoading but isLoading defaults to true,
so it returned immediately without loading. Replaced with hasLoadedOnce
flag to prevent double-loading without blocking the first call.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- FitnessDraft properties changed from let to var (mutable)
- New EditableDraftCard with Edit/Done toggle:
- Editable food name
- Editable macros (calories, protein, carbs, fat, sugar, fiber)
- Meal type picker (dropdown menu)
- Editable quantity
- Edited values flow through draftToDict → apply endpoint
- No backend changes needed — purely iOS UI
- Default view is read-only (same as before), tap Edit to modify
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ReaderViewModel starts with isLoading=true (shows spinner, not "No articles")
- MainTabView owns ReaderViewModel and pre-fetches in background on launch
- Sub-tabs and feed chips hidden during initial load (no tiny squished layout)
- VStack fills full screen with frame(maxWidth/maxHeight: .infinity)
- WebKit warmer triggers when Reader tab appears
- By the time user taps Reader, data is already loaded
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
iOS:
- PhotosPicker with maxSelectionCount: 5
- Horizontal scroll preview strip with individual remove buttons
- Sends "images" array instead of single "image"
Server:
- Gateway accepts both "image" (single, backwards compat) and
"images" (array) fields
- Uploads each as separate Gitea issue attachment
Also closed Gitea issues #11, #12, #13, #14.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reader mark-as-read:
- Track visible entry IDs with onAppear/onDisappear
- Only mark as read when entry disappears AND other entries are still
visible (meaning user is scrolling, not navigating to an article)
- Prevents the bug where opening an article marked all visible entries
Goals (#11):
- Added .scrollDismissesKeyboard(.interactively) for drag-to-dismiss
- Added tap-to-dismiss keyboard on background
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- AppearanceManager with UserDefaults persistence
- Three modes: System (follows iOS), Light, Dark
- Toggle in Home screen profile menu under "Appearance"
- Applied via .preferredColorScheme at app root
- Persists across app launches
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Uses .onDisappear instead of .onAppear — entries are marked as read
only when they scroll past the top of the viewport, not when the
list first renders. Same behavior as proper RSS readers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
.onAppear fires for all visible rows when the list renders, marking
everything as read immediately. Removed — entries only mark as read
when you tap into the article (handled in ArticleView).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dark mode:
- All colors in Color+Extensions now adaptive (UIColor with traits)
- Warm dark palette: dark brown canvas, brighter gold accent, warm cards
- Article HTML CSS supports prefers-color-scheme: dark
- Meal/macro colors unchanged (vivid on both themes)
Reader fixes:
- .contentShape(Rectangle()) on cards/rows — fixes tap target issues
where small cards couldn't be clicked
- Context menu moved from card/row to the NavigationLink wrapper
so it doesn't interfere with taps
- Mark as read on scroll via .onAppear on each entry
- Cards no longer pass vm (cleaner, context menu handled at list level)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
First WKWebView creation in a session is slow (~2-3s) because iOS
lazily initializes the WebKit rendering engine. WebKitWarmer creates
a hidden 1x1 WKWebView with empty HTML on Reader tab load, forcing
the engine to initialize. Subsequent article opens are instant.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server: add slim query param (default true) to entries list endpoint,
returning EntrySlimOut without content/full_content HTML — cuts payload
size dramatically for list views. Single entry endpoint still returns
full content.
iOS: ArticleView now fetches full entry content on demand when opened
instead of relying on list data. Shows loading indicator while fetching.
Mark-as-read is fire-and-forget to avoid blocking the view.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server-side (dashboard + iOS + any client):
- Added thumbnail column to reader_entries
- Worker extracts from media:thumbnail, media:content, enclosures, HTML img
- API returns thumbnail in EntryOut with & decoding
- Backfilled 260 existing entries
iOS:
- Prefers API thumbnail, falls back to client-side extraction
- Decodes HTML entities in URLs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ArticleWebView: remove dangerous intrinsicContentSize override, use
height binding measured via JS after render
- ReaderViewModel: add @MainActor, replace didSet filter with explicit
applyFilter() to avoid property observer reentrancy
- Thumbnail extraction: use precompiled NSRegularExpression, limit search
to first 2000 chars, skip placeholder when no image found
- Card view: only show thumbnail when image exists (no placeholder)
- Feedback: add guard against double-tap, @MainActor on Task
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Card view: large thumbnail from article content, feed name, title, author, reading time
- List view: compact rows with mini thumbnail on right
- Toggle button in toolbar (grid/list icon)
- Thumbnail extracted from first <img> in article HTML
- Card view is default, warm Atelier styling preserved
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New third tab in the iOS app with:
- ReaderModels matching Reader API response shapes
- ReaderAPI with all endpoints (entries, feeds, categories, counters)
- ReaderViewModel with filters (Unread/Starred/All), pagination, feed management
- ReaderTabView with sub-tabs and feed filter chips
- EntryListView with infinite scroll, context menus, read/unread state
- ArticleView with WKWebView HTML rendering, star/read toggles, Save to Brain
- ArticleWebView (UIViewRepresentable WKWebView wrapper)
- FeedManagementSheet with add/delete/refresh feeds, categories
- Warm Atelier design consistent with existing app
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reverts all Liquid Glass styling back to the original warm beige/brown
Atelier design system. Deployment target back to iOS 17.0.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
iOS:
- Photo picker in feedback sheet (screenshot/photo attachment)
- Image sent as base64, uploaded to Gitea issue as attachment
Web:
- Feedback button in sidebar rail
- Modal with text area + send
- Auto-labels same as iOS
Gateway:
- Multipart image upload to Gitea issue assets API
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
F10020 was used for both Products and Feedback groups.
Xcode resolved it as Products, so FeedbackView path was wrong.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Goals PUT returns partial JSON, use putVoid + reload
- Home shows 'Hi, Yusuf' instead of 'Home'
- EmptyResponse type for void-like endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Sugar + Fiber bars in TodayView macro summary card
2. Goals page: editable text fields + Save button (PUT /api/fitness/goals)
3. AI Chat: keyboard dismisses on send + tap chat area to dismiss
4. Entry detail: edit quantity (stepper) + meal type picker + Save
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- DragGesture with minimumDistance:15 as highPriorityGesture
- Tap only navigates when not swiping
- Tapping while swiped closes the delete button
- Hidden NavigationLink for programmatic navigation
- Reverted FitnessTabView back to page-style TabView
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replaced .tabViewStyle(.page) with Group/switch.
Tab switching only via top buttons, no horizontal page swipe.
This prevents swipe-to-delete from accidentally switching tabs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Button outside ScrollView using safeAreaInset(edge: .bottom)
- No more scroll gesture eating the first tap
- scrollDismissesKeyboard(.immediately) for keyboard handling
- Swipe to delete on meal entries
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Custom SwipeToDeleteRow with drag gesture
- Swipe left reveals red trash button
- Tap still opens EntryDetailView with delete option
- Both paths call the same onDelete callback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Exact replica of ReactFlux confetti:
- 200 tiny rectangles scattered across full screen width
- Drop from above the screen, fall down with gravity
- Gentle horizontal drift + 3D rotation (tumbling effect)
- 10 vivid colors matching ReactFlux palette
- Staggered delays (0-0.4s) for natural rain effect
- 1.5-2.5s fall duration, fade at 80%
- No overlay/checkmark — just pure confetti rain
- Haptic feedback on trigger
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>