Compare commits
2 Commits
a5cd9b1d03
...
8a27b692b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a27b692b8 | ||
|
|
f5c0f0929d |
@@ -41,6 +41,17 @@ struct Bookmark: Codable, Identifiable, Sendable, Hashable {
|
|||||||
var domain: String {
|
var domain: String {
|
||||||
URL(string: url)?.host ?? url
|
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 {
|
struct BookmarkResponse: Codable, Sendable {
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ struct BookmarkListRow: View {
|
|||||||
.onTapGesture { onOpen() }
|
.onTapGesture { onOpen() }
|
||||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||||
.listRowSeparator(.visible)
|
.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) {
|
Button(role: .destructive) {
|
||||||
Task { await viewModel.delete(bookmark) }
|
Task { await viewModel.delete(bookmark) }
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ struct BookmarkRow: View {
|
|||||||
var onPodcast: (() -> Void)? = nil
|
var onPodcast: (() -> Void)? = nil
|
||||||
|
|
||||||
@State private var podcastTapCount = 0
|
@State private var podcastTapCount = 0
|
||||||
|
@State private var podcastCached = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -23,7 +24,7 @@ struct BookmarkRow: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(bookmark.displayTitle)
|
Text(bookmark.displayTitle)
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.headline)
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -33,7 +34,7 @@ struct BookmarkRow: View {
|
|||||||
Text("·")
|
Text("·")
|
||||||
Text(bookmark.dateAdded, style: .relative)
|
Text(bookmark.dateAdded, style: .relative)
|
||||||
}
|
}
|
||||||
.font(.system(size: 13))
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ struct BookmarkRow: View {
|
|||||||
onPodcast()
|
onPodcast()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
|
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
|
||||||
.font(.system(size: 20))
|
.font(.title3)
|
||||||
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
|
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -54,10 +55,10 @@ struct BookmarkRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let summary = bookmark.aiSummary {
|
if let excerpt = rowExcerpt {
|
||||||
Text(summary)
|
Text(excerpt.text)
|
||||||
.font(.system(size: 14))
|
.font(.subheadline)
|
||||||
.italic()
|
.italic(excerpt.isAI)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.padding(.leading, 44)
|
.padding(.leading, 44)
|
||||||
@@ -68,7 +69,7 @@ struct BookmarkRow: View {
|
|||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
ForEach(effectiveTags, id: \.self) { tag in
|
ForEach(effectiveTags, id: \.self) { tag in
|
||||||
Text(tag)
|
Text(tag)
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.caption.weight(.medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
.padding(.vertical, 3)
|
.padding(.vertical, 3)
|
||||||
@@ -97,10 +98,24 @@ struct BookmarkRow: View {
|
|||||||
.frame(height: 2)
|
.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 {
|
/// Excerpt shown under the title: prefer the AI summary (italic), else the
|
||||||
FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: bookmark.url).path)
|
/// 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] {
|
private var effectiveTags: [String] {
|
||||||
|
|||||||
161
docs/design-wwdc26.md
Normal file
161
docs/design-wwdc26.md
Normal 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 (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`.
|
||||||
Reference in New Issue
Block a user