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>
ROOT CAUSE (confirmed by instrumentation):
1. viewportHeight was 0 — background GeometryReader onAppear fired
before ScrollView layout, never updated. Visibility ratio was
always 0.00, so wasVisible was never populated.
2. cumulativeDown was 0 — PreferenceKey + onPreferenceChange on the
zero-height anchor never delivered scroll offset updates.
3. Both tracking mechanisms were dead. Only per-row onChange fired.
FIX: Removed dead PreferenceKey scroll tracker and dead viewport
background GeometryReader. All tracking now lives in the per-row
GeometryReader onChange(of: frame.minY), which the logs confirmed
fires reliably:
- Scroll direction: computed from delta between current and previous
minY for each entry (stored in lastKnownMinY dictionary)
- Cumulative scroll: accumulated from positive deltas (>2pt filter)
- Activation: requires cumulative downward scroll > threshold
- Visibility: computed using UIScreen.main.bounds.height (reliable,
doesn't depend on layout timing)
- Mark condition: trackingActive + moving down + unread + was visible
+ maxY < 0 (fully above viewport)
Navigation protection preserved: onAppear resets trackingActive,
cumulativeDown, and lastKnownMinY.
Removed debug instrumentation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Temporary logging to identify which guard condition prevents marking.
Logs visibility ratio, scroll state, and failure reasons for entries
that are above viewport but not being marked. Will remove after fix.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE: The exact crossing condition (oldMaxY >= 0 && newMaxY < 0)
required onChange to fire at the exact moment maxY crosses zero. But
LazyVStack recycles views when they scroll off-screen, destroying the
GeometryReader before the crossing event is delivered. The entry goes
from maxY=15 to being recycled — onChange never sees maxY go negative.
FIX: Replace exact crossing with position check (newMaxY < 0). The
entry just needs to be fully above the viewport. The other 5 guards
prevent false positives:
1. trackingActive (scrolled past threshold)
2. isScrollingDown
3. !entry.isRead
4. !markedByScroll (dedup)
5. wasVisible (was >=50% visible)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Madiha sees only Home + Fitness tabs (same as web dashboard).
Reader tab, pre-warm, and data pre-fetch all skipped for her login.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Visibility requirement (new condition 5):
- Tracks max visible ratio per entry via GeometryReader
- Entry must have been >=50% visible at some point to qualify
- Prevents marking entries that were never genuinely seen
- Uses wasVisible set, populated by onChange(of: minY)
2. Dynamic activation threshold:
- max(100pt, 20% of viewport height)
- Taller screens (iPad) require proportionally more scroll
- Measured via background GeometryReader on ScrollView
3. Stabilized scroll direction:
- Ignores micro deltas <2pt (was 1pt)
- Filters layout noise, rubber-banding, and momentum artifacts
Existing protections preserved:
- trackingActive reset on onAppear (navigation protection)
- downward-only marking
- crossing detection (oldMaxY >= 0, newMaxY < 0)
- markedByScroll dedup set
6 conditions must ALL be true to mark an entry:
1. trackingActive (scrolled past threshold)
2. isScrollingDown
3. !entry.isRead
4. !markedByScroll.contains(id)
5. wasVisible.contains(id) — was >=50% visible
6. bottom edge crossed above viewport
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Uses GeometryReader + coordinate space to track entry positions —
NOT onAppear/onDisappear.
How it works:
1. ScrollView has a named coordinate space ("readerScroll")
2. Invisible anchor at top measures scroll offset via PreferenceKey
3. Each entry row has a background GeometryReader that tracks its
frame in the scroll coordinate space
4. onChange(of: maxY) detects when an entry's bottom edge crosses
above the viewport top (oldMaxY >= 0 → newMaxY < 0)
5. Entry is marked read only when ALL conditions are true:
- trackingActive (user scrolled down >100pt)
- isScrollingDown (current direction is down)
- entry is unread
- entry hasn't been marked by scroll already
- entry's bottom edge just crossed above viewport
Navigation protection:
- onAppear resets trackingActive = false and cumulativeDown = 0
- When returning from an article, tracking is suspended
- User must scroll down 100pt before tracking reactivates
- This prevents all visible entries from being marked read on
navigation back (they were already below viewport, not crossing)
- Scrolling up never marks anything (isScrollingDown = false)
State updates are local-first (immediate) with background API sync.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Empty HTML warmup only spun up the WebContent process. The first
real article still paid ~3s for CSS parsing, font measurement,
layout engine init, and compositing pipeline startup.
Now ArticleRenderer.init loads the full ArticleHTMLBuilder template
with sample content covering all styled elements (paragraphs,
headings, blockquotes, code blocks). WebKit performs real rendering
during app launch while the user is on Home/Fitness. By the time
they open the first article, CSS is parsed, fonts are measured,
and the pipeline is warm — the first article renders as fast as
subsequent ones.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The local entries mutation now runs synchronously at the top of
.task, before any Task {} or await:
1. vm.entries[idx].status = "read" ← synchronous, immediate
2. currentEntry syncs from mutated array ← immediate
3. Task { api.markEntries + getCounters } ← background
4. await getEntry ← content fetch, merge preserves local status
Previously markAsRead was wrapped in Task {} (fire-and-forget),
which SCHEDULED the mutation but didn't execute it until the main
actor yielded. Line 2 read stale state because the mutation hadn't
run yet.
Now the @Observable array mutation happens before any async work,
so the ForEach row re-renders on the same run loop — the list
shows "read" even during the push animation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Toolbar:
- Removed star, read/unread, and ellipsis menu
- Single "Save to Brain" button (brain icon, turns green when saved)
Open original article:
- Title in HTML header is now a tappable link (when URL exists)
- Subtle ↗ icon after title indicates external link
- Tap opens in Safari via existing WKNavigationDelegate link handler
- No accidental triggers: styled as text link, not a button
- Active state dims to 0.6 opacity for tap feedback
- Dark mode: title link inherits text color (not accent)
No floating buttons added. No architecture changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
API deprecated since iOS 15. All WKWebViews already share one
WebContent process. Our singleton WKWebView + shared config is
sufficient.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- HTML template now wraps article content in <div id="article-body">
- Content upgrade JS targets only #article-body.innerHTML, leaving
header, CSS, and outer document structure untouched
- Returns 'ok'/'no-container' status for reliable fallback detection
- extractArticleBody() parses the #article-body content from HTML
- escapeForJS() separated into its own method
- Full reload fallback if container not found or JS fails
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. Explicit WKProcessPool — static shared instance assigned to
WKWebViewConfiguration. Prevents any future divergence even
though iOS 15+ shares by default.
2. Scroll-preserving content upgrade — when articleContent updates
(partial → full), uses JavaScript DOM replacement instead of
loadHTMLString. Captures window.scrollY before swap, restores
after. No visible flash or scroll jump. Falls back to full
reload if JS replacement fails.
3. No unnecessary reloads — coordinator tracks lastHTML. Only
loads if content actually changed. First article open = full
page load (lastHTML is nil). Content upgrade = DOM swap
(lastHTML exists, new content is different).
4. Clean separation — isUpgrade flag distinguishes first load
from content upgrade. First load uses loadHTMLString (needs
full <html> document). Upgrade uses innerHTML replacement
(preserves scroll, CSS, page state).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Changes:
1. Article opens INSTANTLY — articleContent initialized from entry's
existing content in init(), not after API fetch. WebView renders
immediately with whatever we have. Full content swaps in silently
when getEntry returns (only if longer than current).
2. markAsRead is fire-and-forget — wrapped in detached Task inside
.task, does not block the content display chain. Toolbar syncs
from vm.entries immediately after.
3. CSS template pre-built as static string in ArticleHTMLBuilder.
Avoids rebuilding ~2KB of CSS on every article open. HTML builder
is a stateless enum with a single static method.
4. Removed isContentReady flag — no longer needed since content is
available from init. Spinner only shows if entry truly has no
content at all (rare edge case).
Flow is now:
tap → ArticleView created with entry.articleHTML →
WebView loads immediately → user can scroll →
background: markAsRead fires, getEntry fetches full content →
if full content is better, WebView updates silently
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
## 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>