Compare commits

..

2 Commits

Author SHA1 Message Date
Krishna Kumar
8a27b692b8 feat(BookmarkRow): show a content excerpt under each bookmark title
All checks were successful
CI / build-and-deploy (push) Successful in 30s
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>
2026-06-16 14:20:28 -05:00
Krishna Kumar
f5c0f0929d feat(BookmarkRow): adopt Dynamic Type, move cache stat off render path, safer delete
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>
2026-06-16 00:47:31 -05:00
4 changed files with 200 additions and 11 deletions

View File

@@ -41,6 +41,17 @@ struct Bookmark: Codable, Identifiable, Sendable, Hashable {
var domain: String {
URL(string: url)?.host ?? url
}
/// A short content snippet for list previews: the page's scraped meta
/// description, falling back to the user's own note. nil when neither exists.
var contentExcerpt: String? {
for candidate in [websiteDescription, description] {
if let c = candidate?.trimmingCharacters(in: .whitespacesAndNewlines), !c.isEmpty {
return c
}
}
return nil
}
}
struct BookmarkResponse: Codable, Sendable {

View File

@@ -23,7 +23,9 @@ struct BookmarkListRow: View {
.onTapGesture { onOpen() }
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
// Delete is destructive and not undoable require an explicit tap on the
// revealed button rather than letting a single full swipe delete instantly.
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {

View File

@@ -6,6 +6,7 @@ struct BookmarkRow: View {
var onPodcast: (() -> Void)? = nil
@State private var podcastTapCount = 0
@State private var podcastCached = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -23,7 +24,7 @@ struct BookmarkRow: View {
VStack(alignment: .leading, spacing: 4) {
Text(bookmark.displayTitle)
.font(.system(size: 17, weight: .semibold))
.font(.headline)
.foregroundStyle(.primary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
@@ -33,7 +34,7 @@ struct BookmarkRow: View {
Text("·")
Text(bookmark.dateAdded, style: .relative)
}
.font(.system(size: 13))
.font(.footnote)
.foregroundStyle(.secondary)
}
@@ -45,7 +46,7 @@ struct BookmarkRow: View {
onPodcast()
} label: {
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
.font(.system(size: 20))
.font(.title3)
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
}
.buttonStyle(.plain)
@@ -54,10 +55,10 @@ struct BookmarkRow: View {
}
}
if let summary = bookmark.aiSummary {
Text(summary)
.font(.system(size: 14))
.italic()
if let excerpt = rowExcerpt {
Text(excerpt.text)
.font(.subheadline)
.italic(excerpt.isAI)
.foregroundStyle(.secondary)
.lineLimit(2)
.padding(.leading, 44)
@@ -68,7 +69,7 @@ struct BookmarkRow: View {
HStack(spacing: 6) {
ForEach(effectiveTags, id: \.self) { tag in
Text(tag)
.font(.system(size: 12, weight: .medium))
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
@@ -97,10 +98,24 @@ struct BookmarkRow: View {
.frame(height: 2)
}
}
.task(id: bookmark.url) {
// Stat the podcast cache off the render path: once per appearance
// (and when the URL changes), not on every `body` recomputation.
let path = ClaudeService.cachedPodcastURL(for: bookmark.url).path
podcastCached = await Task.detached { FileManager.default.fileExists(atPath: path) }.value
}
}
private var podcastCached: Bool {
FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: bookmark.url).path)
/// Excerpt shown under the title: prefer the AI summary (italic), else the
/// page's scraped description / user note. nil hides the line entirely.
private var rowExcerpt: (text: String, isAI: Bool)? {
if let s = bookmark.aiSummary?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty {
return (s, true)
}
if let e = bookmark.contentExcerpt {
return (e, false)
}
return nil
}
private var effectiveTags: [String] {

161
docs/design-wwdc26.md Normal file
View File

@@ -0,0 +1,161 @@
# Marks — WWDC26 Design Brief
Source: WWDC26 session transcripts (`~/master/scratch/claude-setup/wwdc26/transcripts`).
Sessions digested: Principles of Great Design, Communicate Your Brand Identity on iOS,
What's New in SwiftUI, Dive into Lazy Stacks and Scrolling, Compose Advanced Graphics
Effects, Design Intuitive Search Experiences, WidgetKit Foundations, Create UI Prototypes
Using Agents in Xcode.
Everything below is mapped to Marks (SwiftUI bookmarks / read-later app, iOS 26 target,
with a home-screen widget and a share extension).
---
## The mental model: two layers (iOS 26 Liquid Glass)
- **UI layer** — tab bar, toolbars, search field → keep **native**. Don't restyle them.
- **Content layer** — the bookmark rows → **this is the canvas.** All editorial brand lives here.
The editorial-row direction is correct: it's the sanctioned place for personality. Corollary:
**move any header/brand color into the scroll content**, not onto the toolbar. The Liquid Glass
bar then picks up the tint dynamically as rows scroll under it, and content goes edge-to-edge
instead of being letterboxed by a colored bar.
> "Think of your app as two distinct layers: the UI layer … and the content layer … the content
> layer is the best opportunity to express your brand identity."
---
## Bookmark list (highest leverage)
- **Row hierarchy** answers three questions at a glance: *what do I look at, what can I tap, how?*
Title most prominent; favicon + domain + reading-time/date as the "context that makes it
simpler" (a bare title is *less* usable, not more). Build hierarchy with **order, spacing,
contrast** — not by stripping.
- **No truncation under Dynamic Type — wrap to multiple lines.** Mandatory to test if shipping
any custom font (free only with system fonts). New York (system serif) gives an editorial feel
with Dynamic Type for free.
- **Swipe actions = forgiveness, not confirmation.** Undo over alerts; consistent positions;
standard metaphors only (no creative-liberty trash/archive icons).
- **Tint = meaning only:** unread/synced status, selection, primary save. Everything else neutral.
- **Wordmark only on the main list, fade on scroll** (NYT Cooking pattern). "People don't need to
be reminded which app they're using."
> "When your hierarchy is strong, the most important item on the screen is always the most obvious one."
---
## SwiftUI wins (two are nearly free on iOS 26 once built with Xcode 27)
1. **`AsyncImage` now HTTP-caches by default** → favicons/thumbnails survive scroll-away/back.
Biggest free win for an image-heavy list. Add a sized `URLCache` via `.asyncImageURLSession(_:)`
if loading many.
2. **`@State` is now a lazy macro** → row/view models stop re-allocating on every parent update
(back-ports to iOS 17+). *One source-break to grep for:* a `@State` var with both a default
value **and** an `init` assignment now errors.
3. `toolbarMinimizeBehavior(.onScrollDown, for: .navigationBar)` to reclaim reading space.
4. Drag-to-reorder via `reorderable`/`reorderContainer`; swipe actions work outside `List` via
`swipeActionsContainer` if rows move to `LazyVStack`.
### Scroll-perf traps that bite editorial lists specifically
- **Don't put conditional/dynamic content in `ForEach` leaf rows** (`if env { Row() }`) — keeps
off-screen rows alive and re-evaluating. **Filter at the data level** (SwiftData `#Predicate`
on `@Query`).
- **Configure rows in their initializer, not `onAppear`** (and `onAppear` isn't guaranteed in lazy
stacks — fine for paging, wrong for setup).
- Scroll-driven effects: use **scale/opacity**, never a transform that moves the row's frame, or
lazy rows vanish early.
- Read **`onScrollTargetVisibilityChange`** (visibility), not content-offset, for
"jump to top/unread" buttons.
---
## Search
- Put search in the **bottom toolbar** (most reachable; animates up over the keyboard) unless a
sheet blocks the bottom. `.searchable(text:placement:prompt:)`.
- Add a **scope bar** (`.searchScopes`) for All / Unread / tag; optionally layer **search tokens**
on top — but pair tokens with the visible scope bar (tokens alone aren't discoverable).
- **`ContentUnavailableView.search(text:)`** for no-results, echoing the query so typos are obvious.
---
## Widget (MarksWidget)
- **`AppIntentConfiguration`** so users pick a tag/collection (12 params, sensible default, don't
force config). Reuse the existing app group (`group.com.magicive.marks`).
- Reload policy **`.never`** + call **`WidgetCenter.shared.reloadTimelines(ofKind:)` when the app
*and the share extension* background after a save** — content changes on save, not on a clock.
- **`.widgetAccentedRenderingMode(.fullColor)` on favicons/thumbnails** — otherwise they render as
white rectangles in tinted/clear Home Screen modes. Test full-color / tinted / clear.
- Carry the app's editorial palette for instant recognizability; consider iOS 27's new
`.systemExtraLargePortrait` for a richer list. Deep-link rows with `.widgetURL`.
---
## Prototyping workflow
The "create UI prototypes with agents in Xcode" flow fits well: ask for *many named SwiftUI
previews* of BookmarkListRow variants against **realistic sample data + edge cases** (long titles,
missing thumbnails, hundreds of rows), then remix the ones you like. Output is real native SwiftUI
you keep. Best targets: BookmarkListRow layouts, the search results + empty states, the row→detail
transition, and the widget's families.
---
## Cross-cutting principles (Principles of Great Design)
- **Agency / forgiveness:** undo over confirmation for destructive actions; only interrupt for
genuinely big mistakes (bulk delete).
- **Familiarity:** standard symbols/metaphors; things that look the same behave the same.
- **Simplicity = removing friction**, not minimalism. "You'll know you've arrived at simplicity
when you have exactly enough."
- **Craft = materials:** good fonts, adaptive colors, crisp icons, responsive animation. Watch the
anti-patterns: jittery scrolling, misaligned icons, layout breaking on rotation, taps with no
immediate response — these are the trust-killers in a scrolling list.
- **Dark Mode is non-negotiable.**
- **Delight is the result of getting everything else right** — pick the target emotion (calm,
editorial, focused) and reinforce it everywhere.
---
## Suggested punch-list order
1. `AsyncImage` caching + `.widgetAccentedRenderingMode(.fullColor)` (free correctness/perf).
2. Audit `@State` view models for the lazy-init source-break before bumping SDK.
3. Move search to bottom toolbar + scope bar + `ContentUnavailableView.search`.
4. Row Dynamic Type wrapping audit; confirm no conditional content in `ForEach` leaves.
5. Widget → `AppIntentConfiguration` + `.never` reload + reload-on-save from app & share extension.
---
## Codebase audit (current vs. brief)
### Already compliant
- Native UI layer; brand lives in rows; no toolbar color letterboxing (`BookmarksView.swift`).
- Standard swipe metaphors — trash/archivebox/pencil (`BookmarkListRow.swift`).
- Dark-mode-safe: semantic colors throughout.
- `ContentUnavailableView.search(text:)` already used (`SearchView.swift`).
- Deep links via `Link(destination: .marksBookmark(...))` (`RecentBookmarksWidget.swift`).
- Reload-on-save already wired (`WidgetDataStore.saveBookmarks``reloadTimelines`).
- `onAppear` paging — the sanctioned lazy-list use (`BookmarksView.swift`).
### Fixed (this pass)
- **P1 Dynamic Type** — `BookmarkRow` converted from fixed `.system(size:)` points to text
styles: title `.headline`, domain/date `.footnote`, summary `.subheadline`, tags
`.caption`, podcast glyph `.title3`. Title already wraps (`lineLimit(2)` + `fixedSize`).
- **P2 disk I/O in `body`** — `podcastCached` was `FileManager.fileExists` evaluated during
render (per row, per scroll frame). Moved to `@State` populated by a `.task(id: bookmark.url)`
that stats off the main actor — once per appearance, not per render.
- **P3 unrecoverable delete** — destructive trailing swipe changed to `allowsFullSwipe: false`
so a single fling no longer deletes instantly; requires tapping the revealed button.
### Still open (optional)
- Favicon `URLCache` sizing via `.asyncImageURLSession(_:)` (iOS 26 already caches by default).
- True **undo** for delete (snackbar + soft-delete) — current fix only removes the accidental
full-swipe; an explicit tap still deletes immediately.
- Widget fonts still use fixed sizes (left as-is — widgets clamp Dynamic Type; lower value).
- Search scope bar (`.searchScopes`) for All / Unread / tag.
- Widget `AppIntentConfiguration`, more families incl. `.systemExtraLargePortrait`, interactive
"mark read" `AppIntent`.