From f5c0f0929df247b761cfa9a1fa0b0974ad0999bd Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Tue, 16 Jun 2026 00:47:31 -0500 Subject: [PATCH] feat(BookmarkRow): adopt Dynamic Type, move cache stat off render path, safer delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Marks/Views/BookmarkListRow.swift | 4 +- Marks/Views/BookmarkRow.swift | 21 ++-- docs/design-wwdc26.md | 161 ++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 docs/design-wwdc26.md diff --git a/Marks/Views/BookmarkListRow.swift b/Marks/Views/BookmarkListRow.swift index 03150c3..92941ce 100644 --- a/Marks/Views/BookmarkListRow.swift +++ b/Marks/Views/BookmarkListRow.swift @@ -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: { diff --git a/Marks/Views/BookmarkRow.swift b/Marks/Views/BookmarkRow.swift index 82c73d4..2c595e5 100644 --- a/Marks/Views/BookmarkRow.swift +++ b/Marks/Views/BookmarkRow.swift @@ -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) @@ -56,7 +57,7 @@ struct BookmarkRow: View { if let summary = bookmark.aiSummary { Text(summary) - .font(.system(size: 14)) + .font(.subheadline) .italic() .foregroundStyle(.secondary) .lineLimit(2) @@ -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,12 @@ struct BookmarkRow: View { .frame(height: 2) } } - } - - private var podcastCached: Bool { - FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: bookmark.url).path) + .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 effectiveTags: [String] { diff --git a/docs/design-wwdc26.md b/docs/design-wwdc26.md new file mode 100644 index 0000000..c38bec9 --- /dev/null +++ b/docs/design-wwdc26.md @@ -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 (1–2 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`.