From 3c7a783b6cf5a5330042b8417dcc94bc3c2aac1c Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Sun, 26 Jul 2026 01:49:15 -0500 Subject: [PATCH] Fix tap-to-open on cards, and move the list to the library design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes. Tapping a card did nothing. LibraryCard carried its own press-scale effect via onLongPressGesture(minimumDuration: 0), and a zero-duration long press fires immediately and swallows the tap the host attaches. It was invisible in the prototype because nothing was listening for taps there. LibraryCard is now purely presentational and the grid wraps it in a Button with the existing RowPressStyle, which gets the same scale without competing for the gesture. The list now speaks the same design as the cards: paper ground, the color chip where the favicon was, serif quoted title, monospaced domain/date, serif-italic AI summary, monospaced tag chips. Everything the old row carried is still there — unread state, excerpt, tags, podcast affordance, reading progress — along with all swipe actions, since it is still a List. BookmarkListRow takes a style rather than being rewritten, because the Tags and Search screens use the same row and should not be silently restyled by a change aimed at the bookmarks screen. The tag filter strip moved up to BookmarksView. Both layouts speak the same language now, so the strip belongs to the screen rather than to one mode — which also retires the rule that leaving cards mode had to clear the tag filter to stop it becoming invisible. The tag chip strip fades at its trailing edge; without it a tag clipped mid-word reads as broken text rather than as something scrollable. Verified in both layouts and both color schemes against the live server. Full suite passes (16 tests). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks.xcodeproj/project.pbxproj | 4 + Marks/Views/BookmarkListRow.swift | 35 ++++- Marks/Views/BookmarksView.swift | 53 ++++--- Marks/Views/Library/LibraryGridView.swift | 112 ++++++--------- Marks/Views/Library/LibraryKit.swift | 9 +- Marks/Views/Library/LibraryListRow.swift | 164 ++++++++++++++++++++++ 6 files changed, 279 insertions(+), 98 deletions(-) create mode 100644 Marks/Views/Library/LibraryListRow.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index e14c9f1..cdaa114 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.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 */; }; 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.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 = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = ""; }; + E093C878E702891C64D21FD5 /* LibraryListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListRow.swift; sourceTree = ""; }; E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = ""; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSpotlightIndexer.swift; sourceTree = ""; }; @@ -252,6 +254,7 @@ children = ( 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */, AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */, + E093C878E702891C64D21FD5 /* LibraryListRow.swift */, ); path = Library; sourceTree = ""; @@ -588,6 +591,7 @@ EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */, 55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */, 4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */, + 1F1CB72BBCFFB33B6533D5C9 /* LibraryListRow.swift in Sources */, 50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, 212F713DCC289C48087B79AE /* Log.swift in Sources */, diff --git a/Marks/Views/BookmarkListRow.swift b/Marks/Views/BookmarkListRow.swift index 0d97c49..e865d43 100644 --- a/Marks/Views/BookmarkListRow.swift +++ b/Marks/Views/BookmarkListRow.swift @@ -3,9 +3,16 @@ import SwiftUI /// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView. /// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet. 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 viewModel: BookmarksViewModel var readingProgress: Double = 0 + var style: Style = .classic let onOpen: () -> Void var onEdit: (() -> Void)? = nil @@ -14,15 +21,13 @@ struct BookmarkListRow: View { @State private var episodePickerBookmark: Bookmark? var body: some View { - BookmarkRow( - bookmark: bookmark, - readingProgress: readingProgress, - onPodcast: handlePodcast - ) + row .contentShape(Rectangle()) .onTapGesture { onOpen() } - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .listRowInsets(EdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18)) .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 // revealed button rather than letting a single full swipe delete instantly. .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() { launchPodcast( for: bookmark, diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index caab27e..71140ab 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -71,12 +71,20 @@ struct BookmarksView: View { var body: some View { 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 { LibraryGridView( viewModel: viewModel, - filters: $libraryFilters, - selection: $librarySelection, onOpen: { browsingBookmark = $0 }, onEdit: { editingBookmark = $0 } ) @@ -87,9 +95,7 @@ struct BookmarksView: View { // The large-title area draws from the content behind it, so the // paper ground has to reach past the safe area or the library // appears to start halfway down a white screen. - .background { - if layout == .cards { Paper.sheet.ignoresSafeArea() } - } + .background(Paper.sheet.ignoresSafeArea()) .task { librarySelection = librarySelection ?? libraryFilters.first?.id } .navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks") .navigationBarTitleDisplayMode(.large) @@ -294,6 +300,7 @@ struct BookmarksView: View { bookmark: bookmark, viewModel: viewModel, readingProgress: readingProgress[bookmark.url] ?? 0, + style: .library, onOpen: { browsingBookmark = bookmark }, onEdit: { editingBookmark = bookmark } ) @@ -304,24 +311,36 @@ struct BookmarksView: View { if viewModel.isLoadingMore { HStack { Spacer(); ProgressView(); Spacer() } .listRowSeparator(.hidden) + .listRowBackground(Paper.sheet) } } .listStyle(.plain) + .scrollContentBackground(.hidden) .animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty) } - /// Leaving the library also drops its tag filter. The classic list has no - /// filter strip to show one, so a filter that survived the switch would be - /// invisible — the list would just look like it had lost bookmarks. - private func toggleLayout() { - let next = otherLayout - let hadTagFilter = libraryFilters.first { $0.id == librarySelection }?.tag != nil - withAnimation(.spring(duration: 0.35, bounce: 0.05)) { layoutRaw = next.rawValue } - if next == .list && hadTagFilter { - librarySelection = libraryFilters.first { $0.tag == nil }?.id - viewModel.searchQuery = "" - Task { await viewModel.search() } + /// Tags of everything currently loaded, heaviest first, minus what's + /// already pinned as a tab. + private var availableTags: [String] { + let taken = Set(libraryFilters.compactMap(\.tag)) + var counts: [String: Int] = [:] + for b in viewModel.bookmarks { + 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) + } + + /// 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() } + } + + private func toggleLayout() { + withAnimation(.spring(duration: 0.35, bounce: 0.05)) { layoutRaw = otherLayout.rawValue } } private func maybeLoadMore(_ bookmark: Bookmark) { diff --git a/Marks/Views/Library/LibraryGridView.swift b/Marks/Views/Library/LibraryGridView.swift index 9f9fc95..edceb8b 100644 --- a/Marks/Views/Library/LibraryGridView.swift +++ b/Marks/Views/Library/LibraryGridView.swift @@ -1,13 +1,11 @@ import SwiftUI -/// The library presentation of `viewModel.bookmarks`: color cards on paper, -/// over a browser-tab strip of tag filters. Drops into BookmarksView's content -/// area in place of the List, and carries the same actions — tap to open, long -/// press for the full menu. +/// The library's card presentation of `viewModel.bookmarks`. Drops into +/// BookmarksView's content area in place of the List and carries the same +/// actions — tap to open, long press for the full menu. The filter strip above +/// it belongs to BookmarksView, since both layouts share it. struct LibraryGridView: View { @Bindable var viewModel: BookmarksViewModel - @Binding var filters: [LibraryFilter] - @Binding var selection: LibraryFilter.ID? let onOpen: (Bookmark) -> Void let onEdit: (Bookmark) -> Void @@ -19,45 +17,25 @@ struct LibraryGridView: View { 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 { - VStack(spacing: 0) { - LibraryTagStrip( - filters: $filters, - selection: $selection, - available: availableTags, - onChange: applyFilter - ) - - ScrollView { - LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), - spacing: 8 - ) { - ForEach(items) { item in - card(item) - } - } - .padding(.horizontal, 18) - .padding(.top, 16) - .padding(.bottom, 40) - - if viewModel.isLoadingMore { - ProgressView().padding(.bottom, 28) + ScrollView { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), + spacing: 8 + ) { + ForEach(items) { item in + card(item) } } - .scrollBounceBehavior(.basedOnSize) + .padding(.horizontal, 18) + .padding(.top, 16) + .padding(.bottom, 40) + + if viewModel.isLoadingMore { + ProgressView().padding(.bottom, 28) + } } + .scrollBounceBehavior(.basedOnSize) .background(Paper.sheet) .podcastSheets( viewModel: viewModel, @@ -71,39 +49,31 @@ struct LibraryGridView: View { // The grid renders LibraryItems, but every action needs the Bookmark it // came from. Ids are linkding's, so this is a direct lookup. if let bookmark = viewModel.bookmarks.first(where: { $0.id == item.id }) { - LibraryCard(item: item) - .contentShape(.rect) - .onTapGesture { onOpen(bookmark) } - .contextMenu { - bookmarkMenuItems( - bookmark: bookmark, - viewModel: viewModel, - openURL: openURL, - onOpen: { onOpen(bookmark) }, - onEdit: { onEdit(bookmark) }, - onPodcast: { - launchPodcast( - for: bookmark, - viewModel: viewModel, - showFullPlayer: $showFullPlayer, - episodePicker: $episodePicker - ) - } - ) - } - .onAppear { maybeLoadMore(bookmark) } + Button { onOpen(bookmark) } label: { + LibraryCard(item: item) + } + .buttonStyle(RowPressStyle()) + .contextMenu { + bookmarkMenuItems( + bookmark: bookmark, + viewModel: viewModel, + openURL: openURL, + onOpen: { onOpen(bookmark) }, + onEdit: { onEdit(bookmark) }, + onPodcast: { + launchPodcast( + for: bookmark, + viewModel: viewModel, + showFullPlayer: $showFullPlayer, + episodePicker: $episodePicker + ) + } + ) + } + .onAppear { maybeLoadMore(bookmark) } } } - /// 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) { guard let last = viewModel.bookmarks.last, last.id == bookmark.id, viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return } diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index 3e7332d..5b7dc85 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -240,9 +240,13 @@ enum LibraryLayout: String, CaseIterable, Identifiable { // 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 { let item: LibraryItem - @State private var pressed = false var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -263,9 +267,6 @@ struct LibraryCard: View { .frame(maxWidth: .infinity, alignment: .topLeading) .aspectRatio(0.70, contentMode: .fit) .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: {}) } } diff --git a/Marks/Views/Library/LibraryListRow.swift b/Marks/Views/Library/LibraryListRow.swift new file mode 100644 index 0000000..288b409 --- /dev/null +++ b/Marks/Views/Library/LibraryListRow.swift @@ -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 } + } +}