Library design: cards + redesigned list on the bookmarks screen, tag picker sheet #9

Merged
admin merged 11 commits from feat/library-browse-prototype into master 2026-07-30 04:03:03 +00:00
6 changed files with 279 additions and 98 deletions
Showing only changes of commit 3c7a783b6c - Show all commits
+4
View File
@@ -14,6 +14,7 @@
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; }; 1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; };
1F1CB72BBCFFB33B6533D5C9 /* LibraryListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E093C878E702891C64D21FD5 /* LibraryListRow.swift */; };
212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; }; 212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; }; 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; }; 337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; };
@@ -180,6 +181,7 @@
D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; }; D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; };
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; }; DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; };
E093C878E702891C64D21FD5 /* LibraryListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListRow.swift; sourceTree = "<group>"; };
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = "<group>"; }; E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = "<group>"; };
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSpotlightIndexer.swift; sourceTree = "<group>"; }; F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSpotlightIndexer.swift; sourceTree = "<group>"; };
@@ -252,6 +254,7 @@
children = ( children = (
1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */, 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */,
AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */, AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */,
E093C878E702891C64D21FD5 /* LibraryListRow.swift */,
); );
path = Library; path = Library;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -588,6 +591,7 @@
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */, EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */, 55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */,
4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */, 4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */,
1F1CB72BBCFFB33B6533D5C9 /* LibraryListRow.swift in Sources */,
50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */, 50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */,
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
212F713DCC289C48087B79AE /* Log.swift in Sources */, 212F713DCC289C48087B79AE /* Log.swift in Sources */,
+29 -6
View File
@@ -3,9 +3,16 @@ import SwiftUI
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView. /// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView.
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet. /// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet.
struct BookmarkListRow: View { struct BookmarkListRow: View {
/// Which visual language the row speaks. `.library` is the paper design
/// used by the bookmarks screen; `.classic` is the system-styled row the
/// Tags and Search screens still use, so redesigning one doesn't silently
/// restyle the others.
enum Style { case classic, library }
let bookmark: Bookmark let bookmark: Bookmark
let viewModel: BookmarksViewModel let viewModel: BookmarksViewModel
var readingProgress: Double = 0 var readingProgress: Double = 0
var style: Style = .classic
let onOpen: () -> Void let onOpen: () -> Void
var onEdit: (() -> Void)? = nil var onEdit: (() -> Void)? = nil
@@ -14,15 +21,13 @@ struct BookmarkListRow: View {
@State private var episodePickerBookmark: Bookmark? @State private var episodePickerBookmark: Bookmark?
var body: some View { var body: some View {
BookmarkRow( row
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { onOpen() } .onTapGesture { onOpen() }
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) .listRowInsets(EdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18))
.listRowSeparator(.visible) .listRowSeparator(.visible)
.listRowSeparatorTint(style == .library ? Paper.rule.opacity(0.5) : nil)
.listRowBackground(style == .library ? Paper.sheet : nil)
// Delete is destructive and not undoable require an explicit tap on the // Delete is destructive and not undoable require an explicit tap on the
// revealed button rather than letting a single full swipe delete instantly. // revealed button rather than letting a single full swipe delete instantly.
.swipeActions(edge: .trailing, allowsFullSwipe: false) { .swipeActions(edge: .trailing, allowsFullSwipe: false) {
@@ -63,6 +68,24 @@ struct BookmarkListRow: View {
) )
} }
@ViewBuilder
private var row: some View {
switch style {
case .classic:
BookmarkRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
case .library:
LibraryListRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
}
}
private func handlePodcast() { private func handlePodcast() {
launchPodcast( launchPodcast(
for: bookmark, for: bookmark,
+35 -16
View File
@@ -71,12 +71,20 @@ struct BookmarksView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { VStack(spacing: 0) {
// Both layouts speak the same design now, so the filter strip
// belongs to the screen rather than to one mode which also
// means a tag filter can no longer go invisible when you switch.
LibraryTagStrip(
filters: $libraryFilters,
selection: $librarySelection,
available: availableTags,
onChange: applyTagFilter
)
if layout == .cards { if layout == .cards {
LibraryGridView( LibraryGridView(
viewModel: viewModel, viewModel: viewModel,
filters: $libraryFilters,
selection: $librarySelection,
onOpen: { browsingBookmark = $0 }, onOpen: { browsingBookmark = $0 },
onEdit: { editingBookmark = $0 } onEdit: { editingBookmark = $0 }
) )
@@ -87,9 +95,7 @@ struct BookmarksView: View {
// The large-title area draws from the content behind it, so the // The large-title area draws from the content behind it, so the
// paper ground has to reach past the safe area or the library // paper ground has to reach past the safe area or the library
// appears to start halfway down a white screen. // appears to start halfway down a white screen.
.background { .background(Paper.sheet.ignoresSafeArea())
if layout == .cards { Paper.sheet.ignoresSafeArea() }
}
.task { librarySelection = librarySelection ?? libraryFilters.first?.id } .task { librarySelection = librarySelection ?? libraryFilters.first?.id }
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks") .navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
@@ -294,6 +300,7 @@ struct BookmarksView: View {
bookmark: bookmark, bookmark: bookmark,
viewModel: viewModel, viewModel: viewModel,
readingProgress: readingProgress[bookmark.url] ?? 0, readingProgress: readingProgress[bookmark.url] ?? 0,
style: .library,
onOpen: { browsingBookmark = bookmark }, onOpen: { browsingBookmark = bookmark },
onEdit: { editingBookmark = bookmark } onEdit: { editingBookmark = bookmark }
) )
@@ -304,24 +311,36 @@ struct BookmarksView: View {
if viewModel.isLoadingMore { if viewModel.isLoadingMore {
HStack { Spacer(); ProgressView(); Spacer() } HStack { Spacer(); ProgressView(); Spacer() }
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(Paper.sheet)
} }
} }
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden)
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty) .animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
} }
/// Leaving the library also drops its tag filter. The classic list has no /// Tags of everything currently loaded, heaviest first, minus what's
/// filter strip to show one, so a filter that survived the switch would be /// already pinned as a tab.
/// invisible the list would just look like it had lost bookmarks. private var availableTags: [String] {
private func toggleLayout() { let taken = Set(libraryFilters.compactMap(\.tag))
let next = otherLayout var counts: [String: Int] = [:]
let hadTagFilter = libraryFilters.first { $0.id == librarySelection }?.tag != nil for b in viewModel.bookmarks {
withAnimation(.spring(duration: 0.35, bounce: 0.05)) { layoutRaw = next.rawValue } for tag in b.tagNames where !taken.contains(tag) { counts[tag, default: 0] += 1 }
if next == .list && hadTagFilter { }
librarySelection = libraryFilters.first { $0.tag == nil }?.id return counts.sorted { ($0.value, $1.key) > ($1.value, $0.key) }.map(\.key)
viewModel.searchQuery = "" }
/// Tag tabs filter server-side through linkding's `#tag` search syntax
/// filtering the loaded page client-side would only ever search the most
/// recent 50 of 600+ bookmarks and quietly look empty.
private func applyTagFilter() {
let tag = libraryFilters.first { $0.id == librarySelection }?.tag
viewModel.searchQuery = tag.map { "#\($0)" } ?? ""
Task { await viewModel.search() } Task { await viewModel.search() }
} }
private func toggleLayout() {
withAnimation(.spring(duration: 0.35, bounce: 0.05)) { layoutRaw = otherLayout.rawValue }
} }
private func maybeLoadMore(_ bookmark: Bookmark) { private func maybeLoadMore(_ bookmark: Bookmark) {
+7 -37
View File
@@ -1,13 +1,11 @@
import SwiftUI import SwiftUI
/// The library presentation of `viewModel.bookmarks`: color cards on paper, /// The library's card presentation of `viewModel.bookmarks`. Drops into
/// over a browser-tab strip of tag filters. Drops into BookmarksView's content /// BookmarksView's content area in place of the List and carries the same
/// area in place of the List, and carries the same actions tap to open, long /// actions tap to open, long press for the full menu. The filter strip above
/// press for the full menu. /// it belongs to BookmarksView, since both layouts share it.
struct LibraryGridView: View { struct LibraryGridView: View {
@Bindable var viewModel: BookmarksViewModel @Bindable var viewModel: BookmarksViewModel
@Binding var filters: [LibraryFilter]
@Binding var selection: LibraryFilter.ID?
let onOpen: (Bookmark) -> Void let onOpen: (Bookmark) -> Void
let onEdit: (Bookmark) -> Void let onEdit: (Bookmark) -> Void
@@ -19,26 +17,7 @@ struct LibraryGridView: View {
viewModel.bookmarks.map(LibraryItem.init(bookmark:)) viewModel.bookmarks.map(LibraryItem.init(bookmark:))
} }
/// Tags of everything currently loaded, heaviest first, minus what's
/// already pinned as a tab.
private var availableTags: [String] {
let taken = Set(filters.compactMap(\.tag))
var counts: [String: Int] = [:]
for b in viewModel.bookmarks where !b.tagNames.isEmpty {
for tag in b.tagNames where !taken.contains(tag) { counts[tag, default: 0] += 1 }
}
return counts.sorted { ($0.value, $1.key) > ($1.value, $0.key) }.map(\.key)
}
var body: some View { var body: some View {
VStack(spacing: 0) {
LibraryTagStrip(
filters: $filters,
selection: $selection,
available: availableTags,
onChange: applyFilter
)
ScrollView { ScrollView {
LazyVGrid( LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3),
@@ -57,7 +36,6 @@ struct LibraryGridView: View {
} }
} }
.scrollBounceBehavior(.basedOnSize) .scrollBounceBehavior(.basedOnSize)
}
.background(Paper.sheet) .background(Paper.sheet)
.podcastSheets( .podcastSheets(
viewModel: viewModel, viewModel: viewModel,
@@ -71,9 +49,10 @@ struct LibraryGridView: View {
// The grid renders LibraryItems, but every action needs the Bookmark it // The grid renders LibraryItems, but every action needs the Bookmark it
// came from. Ids are linkding's, so this is a direct lookup. // came from. Ids are linkding's, so this is a direct lookup.
if let bookmark = viewModel.bookmarks.first(where: { $0.id == item.id }) { if let bookmark = viewModel.bookmarks.first(where: { $0.id == item.id }) {
Button { onOpen(bookmark) } label: {
LibraryCard(item: item) LibraryCard(item: item)
.contentShape(.rect) }
.onTapGesture { onOpen(bookmark) } .buttonStyle(RowPressStyle())
.contextMenu { .contextMenu {
bookmarkMenuItems( bookmarkMenuItems(
bookmark: bookmark, bookmark: bookmark,
@@ -95,15 +74,6 @@ struct LibraryGridView: View {
} }
} }
/// Tag tabs filter server-side through linkding's `#tag` search syntax
/// filtering the loaded page client-side would only ever search the most
/// recent 50 of 600+ bookmarks and quietly look empty.
private func applyFilter() {
let tag = filters.first { $0.id == selection }?.tag
viewModel.searchQuery = tag.map { "#\($0)" } ?? ""
Task { await viewModel.search() }
}
private func maybeLoadMore(_ bookmark: Bookmark) { private func maybeLoadMore(_ bookmark: Bookmark) {
guard let last = viewModel.bookmarks.last, last.id == bookmark.id, guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return } viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
+5 -4
View File
@@ -240,9 +240,13 @@ enum LibraryLayout: String, CaseIterable, Identifiable {
// MARK: - Card // MARK: - Card
/// Purely presentational. Press feedback is deliberately *not* here a
/// zero-duration long-press gesture on the card swallows any tap the host
/// attaches, which silently broke tap-to-open. Hosts wrap this in a Button
/// with `RowPressStyle` instead, which gets the same scale without competing
/// for the gesture.
struct LibraryCard: View { struct LibraryCard: View {
let item: LibraryItem let item: LibraryItem
@State private var pressed = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -263,9 +267,6 @@ struct LibraryCard: View {
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
.aspectRatio(0.70, contentMode: .fit) .aspectRatio(0.70, contentMode: .fit)
.background(RoundedRectangle(cornerRadius: 5).fill(item.swatch)) .background(RoundedRectangle(cornerRadius: 5).fill(item.swatch))
.scaleEffect(pressed ? 0.965 : 1)
.animation(.spring(duration: 0.2, bounce: 0), value: pressed)
.onLongPressGesture(minimumDuration: 0, pressing: { pressed = $0 }, perform: {})
} }
} }
+164
View File
@@ -0,0 +1,164 @@
import SwiftUI
/// The library's list presentation. Carries everything `BookmarkRow` carried
/// unread state, excerpt, tags, podcast affordance, reading progress in the
/// paper vocabulary. The favicon becomes the color chip, so the same swatch
/// that identifies a card identifies its row.
struct LibraryListRow: View {
let bookmark: Bookmark
var readingProgress: Double = 0
var onPodcast: (() -> Void)? = nil
@State private var podcastTapCount = 0
@State private var podcastCached = false
private var item: LibraryItem { LibraryItem(bookmark: bookmark) }
/// Compact, static relative date ("6 min ago"). Using a formatter instead of
/// `Text(_, style: .relative)` avoids the live per-second ticking timer.
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
f.dateTimeStyle = .named
return f
}()
var body: some View {
HStack(alignment: .top, spacing: 12) {
RoundedRectangle(cornerRadius: 3)
.fill(item.swatch)
.frame(width: 26, height: 34)
.overlay(alignment: .topLeading) {
if bookmark.unread {
// Ink, not blue: the palette is the only color the
// library spends, and an accent dot would compete with
// the chip it sits on.
Circle()
.fill(Paper.ink)
.frame(width: 7, height: 7)
.overlay(Circle().stroke(Paper.sheet, lineWidth: 1.5))
.offset(x: -3, y: -3)
}
}
VStack(alignment: .leading, spacing: 5) {
Text(item.listDisplay)
.font(.system(size: 14, design: .serif))
.foregroundStyle(Paper.ink)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 5) {
Text(item.source)
Text("·")
Text(Self.relativeFormatter.localizedString(
for: bookmark.dateAdded, relativeTo: Date()
))
}
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Paper.ink.opacity(0.45))
.lineLimit(1)
if let excerpt = rowExcerpt {
Text(excerpt.text)
.font(.system(size: 12, design: .serif))
.italic(excerpt.isAI)
.foregroundStyle(Paper.ink.opacity(0.6))
.lineLimit(2)
}
if !effectiveTags.isEmpty {
tagRow
}
}
Spacer(minLength: 6)
if let onPodcast {
Button {
podcastTapCount += 1
onPodcast()
} label: {
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
.font(.system(size: 17, weight: .light))
.foregroundStyle(Paper.ink.opacity(podcastCached ? 0.75 : 0.3))
}
.buttonStyle(.plain)
.sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount)
}
}
.padding(.vertical, 12)
.contentShape(Rectangle())
.overlay(alignment: .bottom) {
if readingProgress > 0.02 {
GeometryReader { geo in
ZStack(alignment: .leading) {
Rectangle().fill(Paper.ink.opacity(0.1))
Rectangle()
.fill(Paper.ink.opacity(0.5))
.frame(width: geo.size.width * min(readingProgress, 1))
}
}
.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 tagRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 5) {
ForEach(effectiveTags, id: \.self) { tag in
Text(tag)
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Paper.ink.opacity(0.55))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.overlay(
RoundedRectangle(cornerRadius: 3)
.stroke(Paper.rule.opacity(0.6), lineWidth: 0.6)
)
}
}
.padding(.vertical, 1)
}
.scrollBounceBehavior(.basedOnSize)
// Without this the strip clips a tag mid-word at the trailing edge and
// reads as broken text rather than as something you can scroll.
.mask(
LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black, location: 0.9),
.init(color: .clear, location: 1),
],
startPoint: .leading,
endPoint: .trailing
)
)
}
/// 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] {
let base = bookmark.tagNames
let ai = bookmark.aiTags ?? []
let extra = ai.filter { !base.contains($0) }.prefix(3)
return (base + extra).prefix(6).map { $0 }
}
}