Surface a bit of the page content in the list without fetching/parsing pages
ourselves — linkding already returns the scraped metadata in the bookmark
payload. Add Bookmark.contentExcerpt (website_description, falling back to the
user's note) and render it under the title: AI summary takes priority and stays
italic, otherwise the scraped excerpt shows. Rows with neither degrade to the
existing favicon + title layout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apply WWDC26 design-brief fixes to the bookmark list reading surface:
- Dynamic Type: replace fixed .system(size:) points with text styles
(title .headline, domain/date .footnote, summary .subheadline, tags
.caption, podcast glyph .title3). Rows now scale and wrap with the
user's text-size setting instead of staying fixed.
- Perf: podcastCached was a FileManager.fileExists stat evaluated inside
body — i.e. per row, per scroll frame. Move it to @State populated by a
.task(id:) that stats off the main actor, once per appearance.
- Forgiveness: destructive trailing swipe is now allowsFullSwipe: false so
a single fling can't permanently delete; requires tapping the button.
Adds docs/design-wwdc26.md (consolidated brief + codebase audit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace Button wrapper with onTapGesture so swipe gestures don't compete
with the row tap recognizer and accidentally open bookmarks.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Meta line .tertiary → .secondary (more readable domain/date line)
- Tag font 11pt → 12pt
- Reading progress bar: full-width, green, pinned to row bottom edge via overlay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Save serverConfig to App Group on every app launch so ShareExtension
can always find credentials without requiring a disconnect/reconnect flow
- Add MarksWidget target with RandomBookmarkWidget (random saved bookmark,
reloads hourly) using file-based App Group storage to avoid CFPreferences issues
- Extract BookmarkListRow as shared component with editorial typography tweaks
(17pt semibold title, 13pt domain, relative date, 32×32 favicon, italic AI summary,
20pt podcast icon, 16pt horizontal insets)
- Add MarksWidget and ShareExtension Xcode schemes for direct run/debug
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Mini player shows as soon as generation starts (not just when audio ready)
- Idempotency: tapping headphones again during generation is a no-op
- Playback resets to position 0 on completion (standard player behavior)
- Stop button in full player visible during generation
- PodcastLibraryView lists all generated podcasts
- Sparkles menu gains Podcasts entry
- Fix AnalyticsService Sendable error (serialize before Task.detached boundary)
AnalyticsService.track() is fire-and-forget — posts to POST /v1/marks/events
which records a Langfuse trace. No external SDK, no Sentry on iOS side.
Events tracked:
- podcast.started (url, cached)
- podcast.completed (url, durationSec) — fires at 97% playback
- podcast.failed (url, error)
- search.semantic (query, resultCount)
- enrich.completed (count)
- collections.generated (collectionCount)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- MiniPlayerView: persistent bar above tab bar, shows title/progress,
play/pause and close buttons, slides in when audio starts
- PodcastPlayerViewModel lifted to BookmarksViewModel so it persists
across sheet dismissals — audio keeps playing in background
- Headphone button on every bookmark row (filled = cached, outline = not)
- start() is idempotent: re-tapping same article resumes without restart
- stop() saves playback position before teardown (PodcastProgress)
- setupPlayer restores saved position on next open
- BrowserView keeps its own local VM (stopOnDismiss: true)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without a UI delegate, WKWebView hands target="_blank" and window.open()
links to the system (Safari). createWebViewWith now loads them in the
existing webview instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PodcastIndex.swift stores {articleUrl, filename, title, createdAt} in
App Support/podcasts/index.json. Updated on every successful download.
Enables building a podcast library view later.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Cache generated MP3 to App Support/podcasts/<hash>.mp3 — re-opening
the same article plays instantly with zero backend calls
- AVAudioSession .playback/.spokenAudio + .allowBluetoothA2DP — routes
audio to AirPods and Bluetooth headsets
- MPRemoteCommandCenter play/pause/seek — AirPods controls + lock screen
- MPNowPlayingInfoCenter — title and scrubber on lock screen
- UIBackgroundModes: audio in Info.plist — playback continues when backgrounded
- Auto-play when player is ready
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UIDevice.current is MainActor-isolated; replaced with a stable UUID
persisted in UserDefaults to avoid the async access.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Device auto-registers on first launch using hardcoded backend URL
and anon key (MarksAuth.swift). 90-day JWT stored in UserDefaults,
refreshed transparently. No settings fields needed — ClaudeService
is always active and non-optional throughout the app.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- BrowserState: inject scroll-tracking JS after page load, report position
via WKScriptMessageHandler (weak ref to avoid retain cycle)
- BrowserView: thin blue progress bar at top of viewport; saves progress
to UserDefaults on dismiss; restores scroll position on revisit
- BookmarkRow: 2px progress bar below tags for partially-read articles
- BookmarksView: loads ReadingProgress.all() on appear, refreshes when
browser sheet dismisses
- ReadingProgress: static helper for UserDefaults-backed [url: Double] store;
removes entry when article is fully read (≥99%)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tapping a bookmark now opens an in-app WKWebView instead of bouncing to
Safari. Reader mode injects JS to extract article content and rerender
with clean serif typography and dark-mode support. Bottom toolbar has
back/forward, reader toggle, share, and open-in-Safari escape hatch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The list row shows effectiveTags which merges real linkding tagNames with
AI-enriched aiTags. If a bookmark has only AI tags, the edit form was
appearing empty because it only seeded from tagNames. Now falls back to
aiTags when tagNames is empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Edit bookmark: full PATCH via swipe action or long-press context menu;
EditBookmarkView covers URL, title, description, tags, and unread toggle
- Offline cache: bookmarks persisted to ApplicationSupport JSON and shown
instantly on launch; cache is per-server, skipped when unread filter is on
- Unread filter: toolbar toggle sends ?unread=true to the API; title updates
to "Unread"; no stale-cache flash when toggling
- Extract normalizedURL and parsedTags into String+Helpers, removing three
copies of URL normalization and two of tag parsing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Complete ground-up rewrite in SwiftUI targeting iOS 26. Drops the Flutter/Dart
codebase entirely in favour of a lean native app with no third-party dependencies.
Features shipped:
- Bookmark list with pagination, pull-to-refresh, swipe delete/archive
- Add bookmark form + iOS share extension (zero-tap save from any app)
- Tags tab — all tags sorted by count, tap to browse filtered bookmarks
- Native search tab (Tab role: .search) with instant client-side filtering
- AI enrichment via OpenRouter (google/gemini-2.0-flash-lite-001): auto-summary
and tag generation, semantic search, smart collections
- Settings: linkding server config, OpenRouter API key
- App Groups for credential sharing between main app and share extension
- Swift 6 strict concurrency throughout (@Observable, @MainActor)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>