Files
linkding-ios/docs/design-wwdc26.md
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

162 lines
8.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.