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>
8.6 KiB
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)
AsyncImagenow HTTP-caches by default → favicons/thumbnails survive scroll-away/back. Biggest free win for an image-heavy list. Add a sizedURLCachevia.asyncImageURLSession(_:)if loading many.@Stateis 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@Statevar with both a default value and aninitassignment now errors.toolbarMinimizeBehavior(.onScrollDown, for: .navigationBar)to reclaim reading space.- Drag-to-reorder via
reorderable/reorderContainer; swipe actions work outsideListviaswipeActionsContainerif rows move toLazyVStack.
Scroll-perf traps that bite editorial lists specifically
- Don't put conditional/dynamic content in
ForEachleaf rows (if env { Row() }) — keeps off-screen rows alive and re-evaluating. Filter at the data level (SwiftData#Predicateon@Query). - Configure rows in their initializer, not
onAppear(andonAppearisn'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)
AppIntentConfigurationso 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+ callWidgetCenter.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
.systemExtraLargePortraitfor 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
AsyncImagecaching +.widgetAccentedRenderingMode(.fullColor)(free correctness/perf).- Audit
@Stateview models for the lazy-init source-break before bumping SDK. - Move search to bottom toolbar + scope bar +
ContentUnavailableView.search. - Row Dynamic Type wrapping audit; confirm no conditional content in
ForEachleaves. - Widget →
AppIntentConfiguration+.neverreload + 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). onAppearpaging — the sanctioned lazy-list use (BookmarksView.swift).
Fixed (this pass)
- P1 Dynamic Type —
BookmarkRowconverted 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—podcastCachedwasFileManager.fileExistsevaluated during render (per row, per scroll frame). Moved to@Statepopulated 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: falseso a single fling no longer deletes instantly; requires tapping the revealed button.
Still open (optional)
- Favicon
URLCachesizing 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.