From 6a9b6f193fd337896276535f2598dc015450f3ea Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Sun, 26 Jul 2026 01:30:30 -0500 Subject: [PATCH 01/11] Add Library browse-screen prototype An unreferenced SwiftUI prototype of a paper/serif/monospace browse screen for bookmarks: color blocks instead of thumbnails, a browser-tab strip of saved tag filters, and cards/list modes that morph between each other via matchedGeometryEffect. Nothing in the app links to it yet. Built against the real linkding library (300 bookmarks), which is what shaped it: - No bookmark has a preview_image_url, so there is nothing to put in a thumbnail grid. Color blocks are the right primitive for this data. - date_added is 2025 or 2026 for every bookmark, so the reference design's year stamp carries no signal. The domain takes that slot instead, reduced to its registered form because 92 of 300 hosts overflow the card's one monospaced line. - Real titles run 3x longer than the design assumes (median 66 chars, p90 159, max 332) and are mostly "name: what it does". Cards show the name, the list carries the whole title. - 15 bookmarks have no scraped title at all and fall back to the raw URL; those render unquoted with the path in the stamp slot. - Color is hashed from the primary tag, not the domain: 128 of 300 are github.com, which would paint half the library one color. Both color schemes are first-class. The dark palette is not the light one dimmed uniformly - pale swatches drop a long way so they do not glare and dark swatches come up so they do not vanish, converging on a mid band that keeps all twelve distinguishable in either scheme. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks/Views/Prototypes/LibraryView.swift | 629 +++++++++++++++++++++++ 1 file changed, 629 insertions(+) create mode 100644 Marks/Views/Prototypes/LibraryView.swift diff --git a/Marks/Views/Prototypes/LibraryView.swift b/Marks/Views/Prototypes/LibraryView.swift new file mode 100644 index 0000000..bd4a759 --- /dev/null +++ b/Marks/Views/Prototypes/LibraryView.swift @@ -0,0 +1,629 @@ +import SwiftUI + +// MARK: - Library prototype +// +// A self-contained mock of the "Library" browse screen: paper background, serif +// display type, monospaced chrome, and color blocks instead of thumbnails. +// Nothing here is wired into the app yet — it drives its own `LibraryItem` +// model so it can be previewed (and hosted in a scratch app) without touching +// BookmarksView. Mapping `Bookmark` -> `LibraryItem` is the last step, not the +// first: the point is to see whether color-as-index survives real data. + +// MARK: Tokens + +enum Paper { + /// Dark mode is not an inversion of this palette — paper stock lit from a + /// different angle. The ground keeps the same warm cast (it is brown-black, + /// not neutral black) so the swatches sit on it the way ink sits on paper. + static let sheet = dynamic(light: 0xF8F5EF, dark: 0x15130F) + static let ink = dynamic(light: 0x14110C, dark: 0xF1ECE1) + static var rule: Color { ink.opacity(0.28) } + + /// Swatches lifted from the reference, each paired with a dark-mode + /// counterpart. Order matters — items hash into it. + /// + /// The dark variants are not the light ones dimmed uniformly. The pale end + /// of the palette (shell, blush, pale blue) would glare as bright slabs + /// against a dark ground, so it drops a long way; the dark end (forest, + /// navy) would vanish into the ground, so it comes *up*. Both ends + /// converge on the same mid band, which is what keeps twelve swatches + /// distinguishable from each other in either scheme. + static let swatchPairs: [(light: UInt32, dark: UInt32)] = [ + (0xFECD00, 0xD8AD10), // yellow + (0xED663F, 0xC4552F), // vermilion + (0xFE9D6B, 0xC87E53), // peach + (0xAB6A1C, 0x8B5717), // ochre + (0x033B00, 0x1F4D1B), // forest — lifted off the ground + (0x001A55, 0x1E3167), // navy — lifted off the ground + (0x115AB5, 0x1B5596), // blue + (0xD4E0E8, 0x7C8E99), // pale blue — dropped hard + (0xD4DCCF, 0x828E7C), // sage — dropped hard + (0xE0D1BB, 0x94806A), // sand — dropped hard + (0xF5D1BC, 0xA47A63), // blush — dropped hard + (0xFDEDE0, 0x8E8175), // shell — dropped hard + ] + + static func swatch(_ index: Int) -> Color { + let pair = swatchPairs[index % swatchPairs.count] + return dynamic(light: pair.light, dark: pair.dark) + } + + /// Text that stays legible on `swatch(index)`. Resolved per scheme rather + /// than once, because a swatch can be light in one scheme and mid in the + /// other — a single luminance test would get one of them wrong. + static func inkOn(_ index: Int) -> Color { + let pair = swatchPairs[index % swatchPairs.count] + return Color(uiColor: UIColor { traits in + let dark = traits.userInterfaceStyle == .dark + let onLight = luminance(of: dark ? pair.dark : pair.light) > 0.55 + let text: UInt32 = onLight ? 0x14110C : (dark ? 0xF1ECE1 : 0xF8F5EF) + return UIColor(rgb: text).withAlphaComponent(onLight ? 0.86 : 1) + }) + } + + static func dynamic(light: UInt32, dark: UInt32) -> Color { + Color(uiColor: UIColor { traits in + UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light) + }) + } + + static func luminance(of hex: UInt32) -> Double { + let r = Double((hex >> 16) & 0xFF) / 255 + let g = Double((hex >> 8) & 0xFF) / 255 + let b = Double(hex & 0xFF) / 255 + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } +} + +extension UIColor { + convenience init(rgb: UInt32) { + self.init( + red: CGFloat((rgb >> 16) & 0xFF) / 255, + green: CGFloat((rgb >> 8) & 0xFF) / 255, + blue: CGFloat(rgb & 0xFF) / 255, + alpha: 1 + ) + } +} + +// MARK: Model + +struct LibraryItem: Identifiable, Hashable { + let id: Int + var title: String + /// The bottom-left mono stamp. The reference used a publication year; real + /// linkding data is all 2025–2026, so the year carries no signal and the + /// domain takes the slot instead. + var stamp: String + var source: String + var tags: [String] + /// nil = derive from the primary tag, so an untouched library still reads + /// as color-coded by subject rather than as noise. + var colorIndex: Int? + /// The page never gave us a title. Rendering the raw URL inside curly + /// quotes reads as a quotation that isn't one, so these skip the quotes. + var isUntitled = false + + var swatch: Color { Paper.swatch(resolvedIndex) } + var inkOnSwatch: Color { Paper.inkOn(resolvedIndex) } + + /// What a card shows. Cards get one clause; the list gets the whole title. + /// Real titles are overwhelmingly "name: what it does" — at card width the + /// name is the identifier and the blurb is filler, so past 60 characters we + /// keep the name and let the list carry the rest. + var cardTitle: String { + if title.count > 60, + let colon = title.range(of: ": "), + title.distance(from: title.startIndex, to: colon.lowerBound) <= 40 { + return String(title[.. 56 { + return String(title.prefix(56)).trimmingCharacters(in: .whitespaces) + "…" + } + return title + } + + /// Cards quote the title the way the reference does — except when there is + /// no real title to quote. + var cardDisplay: String { isUntitled ? cardTitle : "“\(cardTitle)”" } + var listDisplay: String { isUntitled ? title : "“\(title)”" } + + private var resolvedIndex: Int { + if let colorIndex { return colorIndex % Paper.swatchPairs.count } + // Tag first: 128 of 300 real bookmarks are github.com, so hashing the + // domain would paint half the library one color. Tags spread wider + // (top tag is 32 items). Untagged falls back to source. + let seed = tags.first ?? source + return abs(seed.unicodeScalars.reduce(5381) { ($0 &* 33) &+ Int($1.value) }) + % Paper.swatchPairs.count + } +} + +// MARK: Bookmark -> LibraryItem + +extension LibraryItem { + init(bookmark: Bookmark) { + self.id = bookmark.id + self.source = bookmark.domain.replacingOccurrences(of: "www.", with: "") + self.tags = bookmark.tagNames + self.colorIndex = nil + + // `displayTitle` falls back to the raw URL when linkding scraped no + // title — 15 of 300 real bookmarks. Show the domain instead and hand + // the stamp slot the path, so both slots still say something. + let raw = bookmark.displayTitle + if raw.hasPrefix("http") { + self.isUntitled = true + self.title = self.source + self.stamp = Self.path(of: bookmark.url) + } else { + self.title = Self.clean(raw) + self.stamp = Self.registered(self.source) + } + } + + /// Cards give the stamp one 11pt monospaced line, roughly 14 characters. + /// 92 of 300 real domains are longer than that, so drop the subdomain: + /// `toolkit.artlist.io` -> `artlist.io`. The list still shows it in full. + static func registered(_ host: String) -> String { + let parts = host.split(separator: ".") + guard parts.count > 2 else { return host } + // Two-part public suffixes (.co.uk, .com.au) need one more label. + let secondLevel: Set = ["co", "com", "net", "org", "ac", "gov", "edu"] + let keep = secondLevel.contains(String(parts[parts.count - 2])) ? 3 : 2 + return parts.suffix(keep).joined(separator: ".") + } + + /// Returned whole: the stamp label middle-truncates, and pre-clipping here + /// too would elide it twice ("/share…fo/194…"). + static func path(of url: String) -> String { + guard let p = URL(string: url)?.path, p != "/", !p.isEmpty else { return "—" } + return p + } + + /// Linkding stores whatever the page's said, which for the bulk of a + /// real library means "GitHub - owner/repo: <the entire README blurb>". + /// Median real title is 66 chars and the 90th percentile is 159 — the + /// reference design assumed ~30. Strip the boilerplate, then clamp. + static func clean(_ raw: String) -> String { + var t = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // "GitHub - owner/repo: blurb" -> "repo: blurb" + if t.hasPrefix("GitHub - ") { + t = String(t.dropFirst("GitHub - ".count)) + if let slash = t.firstIndex(of: "/"), + let colon = t.firstIndex(of: ":"), slash < colon { + t = String(t[t.index(after: slash)...]) + } + } + + // Trailing site furniture: "Title | Publisher", "Title - Latent.Space". + // Only strip a short trailing fragment off a title with something left + // over, so hyphenated titles survive. + for sep in [" | ", " · ", " — ", " – ", " - "] { + if let r = t.range(of: sep, options: .backwards), + t.distance(from: r.upperBound, to: t.endIndex) < 24, + t.distance(from: t.startIndex, to: r.lowerBound) > 12 { + t = String(t[..<r.lowerBound]) + } + } + + // Generous clamp: this is the list-mode title. `cardTitle` cuts harder. + if t.count > 120 { + t = String(t.prefix(120)).trimmingCharacters(in: .whitespaces) + "…" + } + return t.isEmpty ? "Untitled" : t + } +} + +struct LibraryFilter: Identifiable, Hashable { + let id = UUID() + var name: String + /// Empty = "everything", the tab you can't close. + var tag: String? +} + +// MARK: Display mode + +/// Two modes, not three. A 4-column grid was in the first pass and died on real +/// data: with a median title of 66 characters every tile truncated mid-word, so +/// it read as a wall of clipped text rather than as color. +enum LibraryLayout: String, CaseIterable, Identifiable { + case cards, list + var id: String { rawValue } + + var symbol: String { + switch self { + case .cards: "rectangle.inset.filled" + case .list: "line.3.horizontal" + } + } +} + +// MARK: - Screen + +struct LibraryView: View { + @State private var items: [LibraryItem] + @State private var filters: [LibraryFilter] + @State private var selectedFilter: LibraryFilter.ID? + @State private var layout: LibraryLayout = .cards + @State private var searching = false + @State private var query = "" + @State private var appeared = false + @FocusState private var searchFocused: Bool + @Namespace private var blocks + + init(items: [LibraryItem] = .sample, filters: [LibraryFilter]? = nil) { + _items = State(initialValue: items) + _filters = State(initialValue: filters ?? Self.defaultFilters(for: items)) + } + + /// With 96 real tags there is no sensible "all tabs" answer — open on + /// Everything plus the five heaviest tags, and let `+` pin the rest. + static func defaultFilters(for items: [LibraryItem]) -> [LibraryFilter] { + var counts: [String: Int] = [:] + for item in items { for tag in item.tags { counts[tag, default: 0] += 1 } } + let top = counts.sorted { ($0.value, $1.key) > ($1.value, $0.key) }.prefix(5) + return [LibraryFilter(name: "Everything", tag: nil)] + + top.map { LibraryFilter(name: $0.key, tag: $0.key) } + } + + private var visibleItems: [LibraryItem] { + let tag = filters.first { $0.id == selectedFilter }?.tag + return items.filter { item in + let matchesTag = tag.map { item.tags.contains($0) } ?? true + let matchesQuery = query.isEmpty + || item.title.localizedCaseInsensitiveContains(query) + || item.source.localizedCaseInsensitiveContains(query) + return matchesTag && matchesQuery + } + } + + /// Tags not already pinned as a tab — the menu behind `+`. + private var unusedTags: [String] { + let taken = Set(filters.compactMap(\.tag)) + return Set(items.flatMap(\.tags)).subtracting(taken).sorted() + } + + var body: some View { + VStack(spacing: 0) { + header + filterStrip + content + } + .background(Paper.sheet) + .task { + selectedFilter = filters.first?.id + withAnimation(.easeOut(duration: 0.45)) { appeared = true } + } + } + + // MARK: Header + + private var header: some View { + VStack(spacing: 14) { + HStack(alignment: .firstTextBaseline) { + Text("Library") + .font(.system(size: 34, weight: .regular, design: .serif)) + .foregroundStyle(Paper.ink) + Spacer(minLength: 12) + Button { + withAnimation(.spring(duration: 0.3, bounce: 0.15)) { + searching.toggle() + if !searching { query = "" } + } + searchFocused = searching + } label: { + Image(systemName: searching ? "xmark" : "magnifyingglass") + .font(.system(size: 15, weight: .light)) + .foregroundStyle(Paper.ink) + .frame(width: 28, height: 28) + } + layoutToggle + } + + if searching { + VStack(spacing: 5) { + TextField("", text: $query, prompt: searchPrompt) + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(Paper.ink) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($searchFocused) + Rectangle().fill(Paper.rule).frame(height: 0.6) + } + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .padding(.horizontal, 22) + .padding(.top, 8) + .padding(.bottom, 14) + } + + private var searchPrompt: Text { + Text("search the library") + .font(.system(size: 13, design: .monospaced)) + .foregroundColor(Paper.ink.opacity(0.35)) + } + + private var layoutToggle: some View { + HStack(spacing: 0) { + ForEach(LibraryLayout.allCases) { option in + let active = layout == option + Button { + withAnimation(.spring(duration: 0.42, bounce: 0.12)) { layout = option } + } label: { + Image(systemName: option.symbol) + .font(.system(size: 12, weight: .regular)) + .foregroundStyle(active ? Paper.sheet : Paper.ink.opacity(0.55)) + .frame(width: 34, height: 26) + .background { + if active { + RoundedRectangle(cornerRadius: 5) + .fill(Paper.ink) + .padding(2) + .matchedGeometryEffect(id: "layoutPill", in: blocks) + } + } + } + .buttonStyle(.plain) + } + } + .overlay( + RoundedRectangle(cornerRadius: 7).stroke(Paper.rule, lineWidth: 0.6) + ) + } + + // MARK: Filter tabs + + private var filterStrip: some View { + ZStack(alignment: .bottom) { + Rectangle() + .fill(Paper.rule) + .frame(height: 0.6) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(filters) { filter in + tab(filter) + } + Menu { + ForEach(unusedTags, id: \.self) { tag in + Button(tag) { addFilter(tag: tag) } + } + if !filters.contains(where: { $0.tag == nil }) { + Divider() + Button("Everything") { addFilter(tag: nil) } + } + } label: { + Image(systemName: "plus") + .font(.system(size: 12, weight: .light)) + .foregroundStyle(Paper.ink.opacity(0.6)) + .frame(width: 38, height: 30) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 18) + } + } + .frame(height: 30) + } + + private func tab(_ filter: LibraryFilter) -> some View { + let active = filter.id == selectedFilter + return HStack(spacing: 7) { + Text(filter.name) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(active ? Paper.ink : Paper.ink.opacity(0.45)) + .lineLimit(1) + if filters.count > 1 { + Button { + withAnimation(.spring(duration: 0.3, bounce: 0)) { close(filter) } + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .medium)) + .foregroundStyle(Paper.ink.opacity(active ? 0.5 : 0.25)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 11) + .frame(height: 30) + .background(alignment: .bottom) { + if active { + // Paper fill sits 0.6pt proud so it erases the strip rule + // beneath the live tab — the browser-tab read. + UnevenRoundedRectangle( + topLeadingRadius: 6, bottomLeadingRadius: 0, + bottomTrailingRadius: 0, topTrailingRadius: 6 + ) + .fill(Paper.sheet) + .overlay(TabOutline().stroke(Paper.rule, lineWidth: 0.6)) + .padding(.bottom, -0.6) + .matchedGeometryEffect(id: "tab", in: blocks) + } + } + .contentShape(.rect) + .onTapGesture { + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { selectedFilter = filter.id } + } + } + + private func addFilter(tag: String?) { + let new = LibraryFilter(name: tag?.capitalized ?? "Everything", tag: tag) + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { + filters.append(new) + selectedFilter = new.id + } + } + + private func close(_ filter: LibraryFilter) { + filters.removeAll { $0.id == filter.id } + if selectedFilter == filter.id { selectedFilter = filters.first?.id } + } + + // MARK: Content + + @ViewBuilder + private var content: some View { + ScrollView { + Group { + switch layout { + case .cards: cardGrid + case .list: listRows + } + } + .padding(.horizontal, 18) + .padding(.top, 16) + .padding(.bottom, 40) + } + .scrollBounceBehavior(.basedOnSize) + } + + private var cardGrid: some View { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), + spacing: 8 + ) { + ForEach(Array(visibleItems.enumerated()), id: \.element.id) { index, item in + LibraryCard(item: item) + .matchedGeometryEffect(id: item.id, in: blocks) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 10) + .animation( + .easeOut(duration: 0.4).delay(Double(index) * 0.028), + value: appeared + ) + } + } + } + + private var listRows: some View { + VStack(spacing: 0) { + ForEach(visibleItems) { item in + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 3) + .fill(item.swatch) + .frame(width: 26, height: 34) + .matchedGeometryEffect(id: item.id, in: blocks) + VStack(alignment: .leading, spacing: 3) { + Text(item.listDisplay) + .font(.system(size: 13, design: .serif)) + .foregroundStyle(Paper.ink) + .lineLimit(1) + Text(item.source) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Paper.ink.opacity(0.45)) + .lineLimit(1) + } + Spacer(minLength: 8) + // The card's stamp is the domain, which list mode already + // shows as the subtitle — so the right column carries the + // primary tag instead of repeating it. + Text(item.tags.first ?? item.stamp) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Paper.ink.opacity(0.55)) + .lineLimit(1) + } + .padding(.vertical, 11) + Rectangle().fill(Paper.rule.opacity(0.5)).frame(height: 0.6) + } + } + } +} + +// MARK: - Card + +private struct LibraryCard: View { + let item: LibraryItem + @State private var pressed = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(item.cardDisplay) + .font(.system(size: 11, design: .serif)) + .foregroundStyle(item.inkOnSwatch) + .multilineTextAlignment(.leading) + .lineLimit(5) + .minimumScaleFactor(0.85) + Spacer(minLength: 4) + Text(item.stamp) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(item.inkOnSwatch) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(9) + .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: {}) + } +} + +/// Open path: up the left edge, across the top, down the right — no bottom +/// stroke, so the tab merges into the page. +private struct TabOutline: Shape { + func path(in rect: CGRect) -> Path { + let r: CGFloat = 6 + var p = Path() + p.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) + p.addQuadCurve( + to: CGPoint(x: rect.minX + r, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + p.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY)) + p.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + r), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + return p + } +} + +// MARK: - Sample data + +extension Array where Element == LibraryItem { + static var sample: [LibraryItem] { + [ + .init(id: 1, title: "The Way of the Shogun", stamp: "1833", + source: "Edo Historical Review", tags: ["meiji", "shogunate"], colorIndex: 0), + .init(id: 2, title: "Feudal Procession sets out from Nihonbashi in Edo 1869", + stamp: "1869", source: "Nihonbashi Archive", tags: ["meiji", "edo"], colorIndex: 2), + .init(id: 3, title: "The Hamlet of Otsumago and its People", stamp: "1836", + source: "Times Daily National Intelligencer", tags: ["meiji", "villages"], + colorIndex: 1), + .init(id: 4, title: "Meiji Restoration Period of Crisis", stamp: "1923", + source: "Kyoto Press", tags: ["meiji", "restoration"], colorIndex: 3), + .init(id: 5, title: "Loss of Our Traditions Cause Civil Unrest", stamp: "1903", + source: "Osaka Herald", tags: ["meiji", "unrest"], colorIndex: 6), + .init(id: 6, title: "Musogukai & The Revenant Demons", stamp: "1893", + source: "Folklore Quarterly", tags: ["meiji", "folklore"], colorIndex: 8), + .init(id: 7, title: "It Will Never Be the Same Here Again", stamp: "1833", + source: "Letters from Otsumago", tags: ["meiji", "letters"], colorIndex: 5), + .init(id: 8, title: "Feudal Lords Clash Over Unchartered Territories", stamp: "1869", + source: "Provincial Record", tags: ["meiji", "shogunate"], colorIndex: 7), + .init(id: 9, title: "Woodblock Printing in the Late Tokugawa", stamp: "1841", + source: "Ukiyo-e Studies", tags: ["printing", "edo"], colorIndex: 9), + .init(id: 10, title: "Rice Riots and the Merchant Class", stamp: "1918", + source: "Economic Histories", tags: ["unrest", "trade"], colorIndex: 4), + .init(id: 11, title: "Correspondence of a Provincial Magistrate", stamp: "1877", + source: "Letters from Otsumago", tags: ["letters"], colorIndex: 10), + .init(id: 12, title: "Mountain Roads of the Nakasendō", stamp: "1852", + source: "Survey Notes", tags: ["villages", "edo"], colorIndex: 11), + ] + } +} + +#Preview("Library") { + LibraryView() +} + +#Preview("Library — Dark") { + LibraryView() + .preferredColorScheme(.dark) +} From 99add346fd35034583678120d13510964ede4488 Mon Sep 17 00:00:00 2001 From: Krishna Kumar <krish.kumar@gmail.com> Date: Sun, 26 Jul 2026 01:41:23 -0500 Subject: [PATCH 02/11] Wire the library presentation into BookmarksView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a cards/list toggle to the bookmarks screen. List mode is the existing screen, untouched and still the default; cards mode is the library presentation running on real bookmarks. Structure: - LibraryKit.swift holds what both the shipping screen and the prototype draw from — the paper palette, the Bookmark -> LibraryItem projection, the color card, and the tab strip. The prototype in Views/Prototypes keeps only its standalone chrome and sample data, so the two can no longer drift. - BookmarkActions.swift extracts the context menu and the podcast launch path out of BookmarkListRow. Cards and rows now offer exactly the same actions because they are the same code. The menu is a @ViewBuilder rather than a ViewModifier: each host already owns the sheets it presents, and a modifier would have forced a second copy of that state. - LibraryGridView.swift is the Bookmark-driven grid, with the same tap-to -open, long-press-for-menu, pagination and podcast sheets as the list. Two behaviors worth calling out: 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; verified against the server that `q=#dev-tools` returns 64 tagged results where `q=dev-tools` returns 0. Leaving cards mode clears any tag filter. The classic list has no filter strip to display one, so a filter that survived the switch would be invisible and the list would look like it had lost bookmarks. The layout choice persists in @AppStorage. The toolbar button shows the layout it switches *to* — a two-state toggle labelled with its current state reads as a status light rather than a control. Verified on an iPhone 17 Pro simulator against the live linkding server in both layouts and both color schemes. Full test suite passes (16 tests). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks.xcodeproj/project.pbxproj | 32 ++ Marks/Views/BookmarkActions.swift | 125 +++++++ Marks/Views/BookmarkListRow.swift | 69 +--- Marks/Views/BookmarksView.swift | 103 ++++-- Marks/Views/Library/LibraryGridView.swift | 112 ++++++ Marks/Views/Library/LibraryKit.swift | 398 ++++++++++++++++++++++ Marks/Views/Prototypes/LibraryView.swift | 382 +-------------------- 7 files changed, 773 insertions(+), 448 deletions(-) create mode 100644 Marks/Views/BookmarkActions.swift create mode 100644 Marks/Views/Library/LibraryGridView.swift create mode 100644 Marks/Views/Library/LibraryKit.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index bab44e0..e14c9f1 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -18,9 +18,12 @@ 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; }; 337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; }; 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; + 4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */; }; 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; }; 44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; }; 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; + 50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7510EB352E624C7C9656EA33 /* LibraryView.swift */; }; + 55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; 66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; }; @@ -58,6 +61,7 @@ B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; }; B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; }; B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; }; + BC866F8D6189334650ADCB95 /* BookmarkActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */; }; BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; }; C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; }; @@ -126,9 +130,11 @@ 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<group>"; }; 18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSourceStore.swift; sourceTree = "<group>"; }; 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; }; + 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryGridView.swift; sourceTree = "<group>"; }; 217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; }; 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkActions.swift; sourceTree = "<group>"; }; 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = "<group>"; }; 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; }; 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; }; @@ -141,6 +147,7 @@ 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = "<group>"; }; 6905CD5B1864895E2F84C7DF /* TagSuggester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSuggester.swift; sourceTree = "<group>"; }; 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; }; + 7510EB352E624C7C9656EA33 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; }; 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; }; 7623601C25E481DF58371F2A /* AppIntentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTests.swift; sourceTree = "<group>"; }; 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSource.swift; sourceTree = "<group>"; }; @@ -153,6 +160,7 @@ 9B7A85A23A13D754F6A75E4D /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; }; 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSnippetViews.swift; sourceTree = "<group>"; }; A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; }; + AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryKit.swift; sourceTree = "<group>"; }; AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; }; AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; }; ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; }; @@ -239,6 +247,15 @@ path = MarksTests; sourceTree = "<group>"; }; + 585E3F011CBA8ECA6D1925C0 /* Library */ = { + isa = PBXGroup; + children = ( + 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */, + AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */, + ); + path = Library; + sourceTree = "<group>"; + }; 58E8E316BE3F10C5149AADC3 /* Intents */ = { isa = PBXGroup; children = ( @@ -286,6 +303,14 @@ path = Services; sourceTree = "<group>"; }; + 7F380B7CE96F87441C28DB93 /* Prototypes */ = { + isa = PBXGroup; + children = ( + 7510EB352E624C7C9656EA33 /* LibraryView.swift */, + ); + path = Prototypes; + sourceTree = "<group>"; + }; 85E717A515682EBF67DD199A /* MarksWidget */ = { isa = PBXGroup; children = ( @@ -305,6 +330,7 @@ children = ( AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, 217E6702DE1210AC38ED16D1 /* AskView.swift */, + 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */, CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */, B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, @@ -318,6 +344,8 @@ 5C29CB878BC334639E6194E2 /* SettingsView.swift */, CDB1DA808EE8041C4546DAAB /* SourcesView.swift */, B13B9F2D890C7953531AC0D2 /* TagsView.swift */, + 585E3F011CBA8ECA6D1925C0 /* Library */, + 7F380B7CE96F87441C28DB93 /* Prototypes */, ); path = Views; sourceTree = "<group>"; @@ -540,6 +568,7 @@ B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */, 68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */, 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, + BC866F8D6189334650ADCB95 /* BookmarkActions.swift in Sources */, 8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */, FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */, CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */, @@ -557,6 +586,9 @@ 81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */, DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */, EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */, + 55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */, + 4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */, + 50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, 212F713DCC289C48087B79AE /* Log.swift in Sources */, 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */, diff --git a/Marks/Views/BookmarkActions.swift b/Marks/Views/BookmarkActions.swift new file mode 100644 index 0000000..7832911 --- /dev/null +++ b/Marks/Views/BookmarkActions.swift @@ -0,0 +1,125 @@ +import SwiftUI + +// MARK: - Shared bookmark actions +// +// The context menu and the podcast launch path are identical whether a bookmark +// is presented as a list row or as a library card. They live here so the two +// presentations can't drift apart — the menu is a @ViewBuilder rather than a +// ViewModifier because each host already owns the sheets it needs to present, +// and a modifier would have forced a second copy of that state. + +/// Every action a bookmark offers, in the order they appear in the menu. +/// +/// `@MainActor` because the callbacks are plain (non-Sendable) UI closures — +/// without it, Swift 6 treats handing them to this function as sending them +/// across isolation domains. +@MainActor +@ViewBuilder +func bookmarkMenuItems( + bookmark: Bookmark, + viewModel: BookmarksViewModel, + openURL: OpenURLAction, + onOpen: @escaping () -> Void, + onEdit: (() -> Void)?, + onPodcast: @escaping () -> Void +) -> some View { + if let onEdit { + Button { onEdit() } label: { + Label("Edit", systemImage: "pencil") + } + } + Button { onOpen() } label: { + Label("Open", systemImage: "globe") + } + Button { + if let url = URL(string: bookmark.url) { openURL(url) } + } label: { + Label("Open in Safari", systemImage: "safari") + } + Button { onPodcast() } label: { + Label("Convert to Podcast", systemImage: "headphones") + } + Divider() + Button { + Task { await viewModel.archive(bookmark) } + } label: { + Label("Archive", systemImage: "archivebox") + } + Button(role: .destructive) { + Task { await viewModel.delete(bookmark) } + } label: { + Label("Delete", systemImage: "trash") + } +} + +/// Resolve what a "convert to podcast" tap should do. Several cached episodes +/// means the user picks; one means play it; none means generate — and +/// generating only takes over the player when it isn't already busy. +/// +/// The caller owns the sheet state because the presenting view has to. +@MainActor +func launchPodcast( + for bookmark: Bookmark, + viewModel: BookmarksViewModel, + showFullPlayer: Binding<Bool>, + episodePicker: Binding<Bookmark?> +) { + let episodes = PodcastIndex.find(for: bookmark.url) + if episodes.count >= 2 { + episodePicker.wrappedValue = bookmark + } else if let ep = episodes.first { + viewModel.podcastPlayer.start( + articleUrl: ep.articleUrl, + articleTitle: ep.title ?? bookmark.displayTitle, + claude: viewModel.claude + ) + showFullPlayer.wrappedValue = true + } else if viewModel.playOrGeneratePodcast( + articleUrl: bookmark.url, + title: bookmark.displayTitle + ) { + showFullPlayer.wrappedValue = true + } +} + +/// The two sheets any bookmark presentation needs once it offers podcasts. +struct PodcastSheets: ViewModifier { + let viewModel: BookmarksViewModel + @Binding var showFullPlayer: Bool + @Binding var episodePicker: Bookmark? + + func body(content: Content) -> some View { + content + .sheet(isPresented: $showFullPlayer) { + PodcastPlayerView( + vm: viewModel.podcastPlayer, + articleUrl: viewModel.podcastPlayer.currentArticleUrl, + articleTitle: viewModel.podcastPlayer.currentArticleTitle, + claude: viewModel.claude, + stopOnDismiss: false + ) + } + .sheet(item: $episodePicker) { b in + EpisodePickerView( + bookmark: b, + vm: viewModel.podcastPlayer, + claude: viewModel.claude, + podcastGenerator: viewModel.podcastGenerator + ) + } + } +} + +extension View { + func podcastSheets( + viewModel: BookmarksViewModel, + showFullPlayer: Binding<Bool>, + episodePicker: Binding<Bookmark?> + ) -> some View { + modifier(PodcastSheets( + viewModel: viewModel, + showFullPlayer: showFullPlayer, + episodePicker: episodePicker + )) + } +} diff --git a/Marks/Views/BookmarkListRow.swift b/Marks/Views/BookmarkListRow.swift index 1e19e2e..0d97c49 100644 --- a/Marks/Views/BookmarkListRow.swift +++ b/Marks/Views/BookmarkListRow.swift @@ -47,61 +47,28 @@ struct BookmarkListRow: View { } } .contextMenu { - if let onEdit { - Button { onEdit() } label: { - Label("Edit", systemImage: "pencil") - } - } - Button { onOpen() } label: { - Label("Open", systemImage: "globe") - } - Button { - if let url = URL(string: bookmark.url) { openURL(url) } - } label: { - Label("Open in Safari", systemImage: "safari") - } - Button { handlePodcast() } label: { - Label("Convert to Podcast", systemImage: "headphones") - } - Divider() - Button { - Task { await viewModel.archive(bookmark) } - } label: { - Label("Archive", systemImage: "archivebox") - } - Button(role: .destructive) { - Task { await viewModel.delete(bookmark) } - } label: { - Label("Delete", systemImage: "trash") - } - } - .sheet(isPresented: $showFullPlayer) { - PodcastPlayerView( - vm: viewModel.podcastPlayer, - articleUrl: viewModel.podcastPlayer.currentArticleUrl, - articleTitle: viewModel.podcastPlayer.currentArticleTitle, - claude: viewModel.claude, - stopOnDismiss: false + bookmarkMenuItems( + bookmark: bookmark, + viewModel: viewModel, + openURL: openURL, + onOpen: onOpen, + onEdit: onEdit, + onPodcast: handlePodcast ) } - .sheet(item: $episodePickerBookmark) { b in - EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator) - } + .podcastSheets( + viewModel: viewModel, + showFullPlayer: $showFullPlayer, + episodePicker: $episodePickerBookmark + ) } private func handlePodcast() { - let episodes = PodcastIndex.find(for: bookmark.url) - if episodes.count >= 2 { - episodePickerBookmark = bookmark - } else if let ep = episodes.first { - viewModel.podcastPlayer.start( - articleUrl: ep.articleUrl, - articleTitle: ep.title ?? bookmark.displayTitle, - claude: viewModel.claude - ) - showFullPlayer = true - } else if viewModel.playOrGeneratePodcast(articleUrl: bookmark.url, title: bookmark.displayTitle) { - showFullPlayer = true - } + launchPodcast( + for: bookmark, + viewModel: viewModel, + showFullPlayer: $showFullPlayer, + episodePicker: $episodePickerBookmark + ) } } diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 100f816..caab27e 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -60,36 +60,37 @@ struct BookmarksView: View { @State private var showFullPlayer = false @State private var showAsk = false @State private var readingProgress: [String: Double] = ReadingProgress.all() + @AppStorage("bookmarksLayout") private var layoutRaw = LibraryLayout.list.rawValue + @State private var libraryFilters: [LibraryFilter] = [ + LibraryFilter(name: "Everything", tag: nil) + ] + @State private var librarySelection: LibraryFilter.ID? + + private var layout: LibraryLayout { LibraryLayout(rawValue: layoutRaw) ?? .list } + private var otherLayout: LibraryLayout { layout == .cards ? .list : .cards } var body: some View { NavigationStack { - List { - if viewModel.isLoading && viewModel.bookmarks.isEmpty { - ForEach(0..<3, id: \.self) { i in - SkeletonRow(delay: Double(i) * 0.13) - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .listRowSeparator(.visible) - } + Group { + if layout == .cards { + LibraryGridView( + viewModel: viewModel, + filters: $libraryFilters, + selection: $librarySelection, + onOpen: { browsingBookmark = $0 }, + onEdit: { editingBookmark = $0 } + ) } else { - ForEach(viewModel.bookmarks) { bookmark in - BookmarkListRow( - bookmark: bookmark, - viewModel: viewModel, - readingProgress: readingProgress[bookmark.url] ?? 0, - onOpen: { browsingBookmark = bookmark }, - onEdit: { editingBookmark = bookmark } - ) - .onAppear { maybeLoadMore(bookmark) } - } - } - - if viewModel.isLoadingMore { - HStack { Spacer(); ProgressView(); Spacer() } - .listRowSeparator(.hidden) + bookmarkList } } - .listStyle(.plain) - .animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty) + // 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() } + } + .task { librarySelection = librarySelection ?? libraryFilters.first?.id } .navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks") .navigationBarTitleDisplayMode(.large) .toolbar { @@ -117,6 +118,16 @@ struct BookmarksView: View { } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 16) { + // Shows where the tap goes, not where you are — a + // two-state toggle labelled with its current state + // reads as a status light rather than a control. + Button { toggleLayout() } label: { + Image(systemName: otherLayout.symbol) + .contentTransition(.symbolEffect(.replace)) + } + .accessibilityLabel( + otherLayout == .cards ? "Show as cards" : "Show as list" + ) Button { showAddBookmark = true } label: { Image(systemName: "plus") } @@ -269,6 +280,50 @@ struct BookmarksView: View { .padding(.horizontal, 16) } + private var bookmarkList: some View { + List { + if viewModel.isLoading && viewModel.bookmarks.isEmpty { + ForEach(0..<3, id: \.self) { i in + SkeletonRow(delay: Double(i) * 0.13) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .listRowSeparator(.visible) + } + } else { + ForEach(viewModel.bookmarks) { bookmark in + BookmarkListRow( + bookmark: bookmark, + viewModel: viewModel, + readingProgress: readingProgress[bookmark.url] ?? 0, + onOpen: { browsingBookmark = bookmark }, + onEdit: { editingBookmark = bookmark } + ) + .onAppear { maybeLoadMore(bookmark) } + } + } + + if viewModel.isLoadingMore { + HStack { Spacer(); ProgressView(); Spacer() } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .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() } + } + } + 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/LibraryGridView.swift b/Marks/Views/Library/LibraryGridView.swift new file mode 100644 index 0000000..9f9fc95 --- /dev/null +++ b/Marks/Views/Library/LibraryGridView.swift @@ -0,0 +1,112 @@ +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. +struct LibraryGridView: View { + @Bindable var viewModel: BookmarksViewModel + @Binding var filters: [LibraryFilter] + @Binding var selection: LibraryFilter.ID? + let onOpen: (Bookmark) -> Void + let onEdit: (Bookmark) -> Void + + @Environment(\.openURL) private var openURL + @State private var showFullPlayer = false + @State private var episodePicker: Bookmark? + + private var items: [LibraryItem] { + 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) + } + } + .scrollBounceBehavior(.basedOnSize) + } + .background(Paper.sheet) + .podcastSheets( + viewModel: viewModel, + showFullPlayer: $showFullPlayer, + episodePicker: $episodePicker + ) + } + + @ViewBuilder + private func card(_ item: LibraryItem) -> some 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) } + } + } + + /// 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 } + Task { await viewModel.loadMore() } + } +} diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift new file mode 100644 index 0000000..3e7332d --- /dev/null +++ b/Marks/Views/Library/LibraryKit.swift @@ -0,0 +1,398 @@ +import SwiftUI + +// MARK: - Library design kit +// +// The vocabulary the library presentation is built from: a paper palette, the +// Bookmark -> LibraryItem projection, and the two pieces of chrome (color card, +// browser-tab filter strip) shared by the real screen and the standalone +// prototype in Views/Prototypes/LibraryView.swift. + +// MARK: Tokens + +enum Paper { + /// Dark mode is not an inversion of this palette — paper stock lit from a + /// different angle. The ground keeps the same warm cast (it is brown-black, + /// not neutral black) so the swatches sit on it the way ink sits on paper. + static let sheet = dynamic(light: 0xF8F5EF, dark: 0x15130F) + static let ink = dynamic(light: 0x14110C, dark: 0xF1ECE1) + static var rule: Color { ink.opacity(0.28) } + + /// Swatches lifted from the reference, each paired with a dark-mode + /// counterpart. Order matters — items hash into it. + /// + /// The dark variants are not the light ones dimmed uniformly. The pale end + /// of the palette (shell, blush, pale blue) would glare as bright slabs + /// against a dark ground, so it drops a long way; the dark end (forest, + /// navy) would vanish into the ground, so it comes *up*. Both ends + /// converge on the same mid band, which is what keeps twelve swatches + /// distinguishable from each other in either scheme. + static let swatchPairs: [(light: UInt32, dark: UInt32)] = [ + (0xFECD00, 0xD8AD10), // yellow + (0xED663F, 0xC4552F), // vermilion + (0xFE9D6B, 0xC87E53), // peach + (0xAB6A1C, 0x8B5717), // ochre + (0x033B00, 0x1F4D1B), // forest — lifted off the ground + (0x001A55, 0x1E3167), // navy — lifted off the ground + (0x115AB5, 0x1B5596), // blue + (0xD4E0E8, 0x7C8E99), // pale blue — dropped hard + (0xD4DCCF, 0x828E7C), // sage — dropped hard + (0xE0D1BB, 0x94806A), // sand — dropped hard + (0xF5D1BC, 0xA47A63), // blush — dropped hard + (0xFDEDE0, 0x8E8175), // shell — dropped hard + ] + + static func swatch(_ index: Int) -> Color { + let pair = swatchPairs[index % swatchPairs.count] + return dynamic(light: pair.light, dark: pair.dark) + } + + /// Text that stays legible on `swatch(index)`. Resolved per scheme rather + /// than once, because a swatch can be light in one scheme and mid in the + /// other — a single luminance test would get one of them wrong. + static func inkOn(_ index: Int) -> Color { + let pair = swatchPairs[index % swatchPairs.count] + return Color(uiColor: UIColor { traits in + let dark = traits.userInterfaceStyle == .dark + let onLight = luminance(of: dark ? pair.dark : pair.light) > 0.55 + let text: UInt32 = onLight ? 0x14110C : (dark ? 0xF1ECE1 : 0xF8F5EF) + return UIColor(rgb: text).withAlphaComponent(onLight ? 0.86 : 1) + }) + } + + static func dynamic(light: UInt32, dark: UInt32) -> Color { + Color(uiColor: UIColor { traits in + UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light) + }) + } + + static func luminance(of hex: UInt32) -> Double { + let r = Double((hex >> 16) & 0xFF) / 255 + let g = Double((hex >> 8) & 0xFF) / 255 + let b = Double(hex & 0xFF) / 255 + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } +} + +extension UIColor { + convenience init(rgb: UInt32) { + self.init( + red: CGFloat((rgb >> 16) & 0xFF) / 255, + green: CGFloat((rgb >> 8) & 0xFF) / 255, + blue: CGFloat(rgb & 0xFF) / 255, + alpha: 1 + ) + } +} + +// MARK: Model + +struct LibraryItem: Identifiable, Hashable { + let id: Int + var title: String + /// The bottom-left mono stamp. The reference used a publication year; real + /// linkding data is all 2025–2026, so the year carries no signal and the + /// domain takes the slot instead. + var stamp: String + var source: String + var tags: [String] + /// nil = derive from the primary tag, so an untouched library still reads + /// as color-coded by subject rather than as noise. + var colorIndex: Int? + /// The page never gave us a title. Rendering the raw URL inside curly + /// quotes reads as a quotation that isn't one, so these skip the quotes. + var isUntitled = false + + var swatch: Color { Paper.swatch(resolvedIndex) } + var inkOnSwatch: Color { Paper.inkOn(resolvedIndex) } + + /// What a card shows. Cards get one clause; the list gets the whole title. + /// Real titles are overwhelmingly "name: what it does" — at card width the + /// name is the identifier and the blurb is filler, so past 60 characters we + /// keep the name and let the list carry the rest. + var cardTitle: String { + if title.count > 60, + let colon = title.range(of: ": "), + title.distance(from: title.startIndex, to: colon.lowerBound) <= 40 { + return String(title[..<colon.lowerBound]) + } + if title.count > 56 { + return String(title.prefix(56)).trimmingCharacters(in: .whitespaces) + "…" + } + return title + } + + /// Cards quote the title the way the reference does — except when there is + /// no real title to quote. + var cardDisplay: String { isUntitled ? cardTitle : "“\(cardTitle)”" } + var listDisplay: String { isUntitled ? title : "“\(title)”" } + + private var resolvedIndex: Int { + if let colorIndex { return colorIndex % Paper.swatchPairs.count } + // Tag first: 128 of 300 real bookmarks are github.com, so hashing the + // domain would paint half the library one color. Tags spread wider + // (top tag is 32 items). Untagged falls back to source. + let seed = tags.first ?? source + return abs(seed.unicodeScalars.reduce(5381) { ($0 &* 33) &+ Int($1.value) }) + % Paper.swatchPairs.count + } +} + +// MARK: Bookmark -> LibraryItem + +extension LibraryItem { + init(bookmark: Bookmark) { + self.id = bookmark.id + self.source = bookmark.domain.replacingOccurrences(of: "www.", with: "") + self.tags = bookmark.tagNames + self.colorIndex = nil + + // `displayTitle` falls back to the raw URL when linkding scraped no + // title — 15 of 300 real bookmarks. Show the domain instead and hand + // the stamp slot the path, so both slots still say something. + let raw = bookmark.displayTitle + if raw.hasPrefix("http") { + self.isUntitled = true + self.title = self.source + self.stamp = Self.path(of: bookmark.url) + } else { + self.title = Self.clean(raw) + self.stamp = Self.registered(self.source) + } + } + + /// Cards give the stamp one 11pt monospaced line, roughly 14 characters. + /// 92 of 300 real domains are longer than that, so drop the subdomain: + /// `toolkit.artlist.io` -> `artlist.io`. The list still shows it in full. + static func registered(_ host: String) -> String { + let parts = host.split(separator: ".") + guard parts.count > 2 else { return host } + // Two-part public suffixes (.co.uk, .com.au) need one more label. + let secondLevel: Set<String> = ["co", "com", "net", "org", "ac", "gov", "edu"] + let keep = secondLevel.contains(String(parts[parts.count - 2])) ? 3 : 2 + return parts.suffix(keep).joined(separator: ".") + } + + /// Returned whole: the stamp label middle-truncates, and pre-clipping here + /// too would elide it twice ("/share…fo/194…"). + static func path(of url: String) -> String { + guard let p = URL(string: url)?.path, p != "/", !p.isEmpty else { return "—" } + return p + } + + /// Linkding stores whatever the page's <title> said, which for the bulk of a + /// real library means "GitHub - owner/repo: <the entire README blurb>". + /// Median real title is 66 chars and the 90th percentile is 159 — the + /// reference design assumed ~30. Strip the boilerplate, then clamp. + static func clean(_ raw: String) -> String { + var t = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // "GitHub - owner/repo: blurb" -> "repo: blurb" + if t.hasPrefix("GitHub - ") { + t = String(t.dropFirst("GitHub - ".count)) + if let slash = t.firstIndex(of: "/"), + let colon = t.firstIndex(of: ":"), slash < colon { + t = String(t[t.index(after: slash)...]) + } + } + + // Trailing site furniture: "Title | Publisher", "Title - Latent.Space". + // Only strip a short trailing fragment off a title with something left + // over, so hyphenated titles survive. + for sep in [" | ", " · ", " — ", " – ", " - "] { + if let r = t.range(of: sep, options: .backwards), + t.distance(from: r.upperBound, to: t.endIndex) < 24, + t.distance(from: t.startIndex, to: r.lowerBound) > 12 { + t = String(t[..<r.lowerBound]) + } + } + + // Generous clamp: this is the list-mode title. `cardTitle` cuts harder. + if t.count > 120 { + t = String(t.prefix(120)).trimmingCharacters(in: .whitespaces) + "…" + } + return t.isEmpty ? "Untitled" : t + } +} + +// MARK: Filters + +struct LibraryFilter: Identifiable, Hashable { + let id = UUID() + var name: String + /// nil = "everything", the tab you can't close. + var tag: String? +} + +/// Two modes, not three. A 4-column grid was in the first pass and died on real +/// data: with a median title of 66 characters every tile truncated mid-word, so +/// it read as a wall of clipped text rather than as color. +enum LibraryLayout: String, CaseIterable, Identifiable { + case cards, list + var id: String { rawValue } + + var symbol: String { + switch self { + case .cards: "rectangle.inset.filled" + case .list: "line.3.horizontal" + } + } +} + +// MARK: - Card + +struct LibraryCard: View { + let item: LibraryItem + @State private var pressed = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(item.cardDisplay) + .font(.system(size: 11, design: .serif)) + .foregroundStyle(item.inkOnSwatch) + .multilineTextAlignment(.leading) + .lineLimit(5) + .minimumScaleFactor(0.85) + Spacer(minLength: 4) + Text(item.stamp) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(item.inkOnSwatch) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(9) + .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: {}) + } +} + +// MARK: - Filter strip + +/// The browser-tab strip of saved filters. Tabs sit on a hairline that the live +/// tab erases, which is what sells the metaphor. +struct LibraryTagStrip: View { + @Binding var filters: [LibraryFilter] + @Binding var selection: LibraryFilter.ID? + /// Tags available to pin that aren't already open. + let available: [String] + var onChange: () -> Void = {} + + @Namespace private var strip + + var body: some View { + ZStack(alignment: .bottom) { + Rectangle() + .fill(Paper.rule) + .frame(height: 0.6) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(filters) { filter in + tab(filter) + } + Menu { + ForEach(available, id: \.self) { tag in + Button(tag) { add(tag: tag) } + } + if !filters.contains(where: { $0.tag == nil }) { + Divider() + Button("Everything") { add(tag: nil) } + } + } label: { + Image(systemName: "plus") + .font(.system(size: 12, weight: .light)) + .foregroundStyle(Paper.ink.opacity(0.6)) + .frame(width: 38, height: 30) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 18) + } + } + .frame(height: 30) + } + + private func tab(_ filter: LibraryFilter) -> some View { + let active = filter.id == selection + return HStack(spacing: 7) { + Text(filter.name) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(active ? Paper.ink : Paper.ink.opacity(0.45)) + .lineLimit(1) + if filters.count > 1 { + Button { + withAnimation(.spring(duration: 0.3, bounce: 0)) { close(filter) } + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .medium)) + .foregroundStyle(Paper.ink.opacity(active ? 0.5 : 0.25)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 11) + .frame(height: 30) + .background(alignment: .bottom) { + if active { + // Paper fill sits 0.6pt proud so it erases the strip rule + // beneath the live tab — the browser-tab read. + UnevenRoundedRectangle( + topLeadingRadius: 6, bottomLeadingRadius: 0, + bottomTrailingRadius: 0, topTrailingRadius: 6 + ) + .fill(Paper.sheet) + .overlay(TabOutline().stroke(Paper.rule, lineWidth: 0.6)) + .padding(.bottom, -0.6) + .matchedGeometryEffect(id: "tab", in: strip) + } + } + .contentShape(.rect) + .onTapGesture { + guard filter.id != selection else { return } + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { selection = filter.id } + onChange() + } + } + + private func add(tag: String?) { + let new = LibraryFilter(name: tag ?? "Everything", tag: tag) + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { + filters.append(new) + selection = new.id + } + onChange() + } + + private func close(_ filter: LibraryFilter) { + filters.removeAll { $0.id == filter.id } + if selection == filter.id { + selection = filters.first?.id + onChange() + } + } +} + +/// Open path: up the left edge, across the top, down the right — no bottom +/// stroke, so the tab merges into the page. +struct TabOutline: Shape { + func path(in rect: CGRect) -> Path { + let r: CGFloat = 6 + var p = Path() + p.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) + p.addQuadCurve( + to: CGPoint(x: rect.minX + r, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + p.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY)) + p.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + r), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + return p + } +} diff --git a/Marks/Views/Prototypes/LibraryView.swift b/Marks/Views/Prototypes/LibraryView.swift index bd4a759..b5f03a9 100644 --- a/Marks/Views/Prototypes/LibraryView.swift +++ b/Marks/Views/Prototypes/LibraryView.swift @@ -2,243 +2,10 @@ import SwiftUI // MARK: - Library prototype // -// A self-contained mock of the "Library" browse screen: paper background, serif -// display type, monospaced chrome, and color blocks instead of thumbnails. -// Nothing here is wired into the app yet — it drives its own `LibraryItem` -// model so it can be previewed (and hosted in a scratch app) without touching -// BookmarksView. Mapping `Bookmark` -> `LibraryItem` is the last step, not the -// first: the point is to see whether color-as-index survives real data. - -// MARK: Tokens - -enum Paper { - /// Dark mode is not an inversion of this palette — paper stock lit from a - /// different angle. The ground keeps the same warm cast (it is brown-black, - /// not neutral black) so the swatches sit on it the way ink sits on paper. - static let sheet = dynamic(light: 0xF8F5EF, dark: 0x15130F) - static let ink = dynamic(light: 0x14110C, dark: 0xF1ECE1) - static var rule: Color { ink.opacity(0.28) } - - /// Swatches lifted from the reference, each paired with a dark-mode - /// counterpart. Order matters — items hash into it. - /// - /// The dark variants are not the light ones dimmed uniformly. The pale end - /// of the palette (shell, blush, pale blue) would glare as bright slabs - /// against a dark ground, so it drops a long way; the dark end (forest, - /// navy) would vanish into the ground, so it comes *up*. Both ends - /// converge on the same mid band, which is what keeps twelve swatches - /// distinguishable from each other in either scheme. - static let swatchPairs: [(light: UInt32, dark: UInt32)] = [ - (0xFECD00, 0xD8AD10), // yellow - (0xED663F, 0xC4552F), // vermilion - (0xFE9D6B, 0xC87E53), // peach - (0xAB6A1C, 0x8B5717), // ochre - (0x033B00, 0x1F4D1B), // forest — lifted off the ground - (0x001A55, 0x1E3167), // navy — lifted off the ground - (0x115AB5, 0x1B5596), // blue - (0xD4E0E8, 0x7C8E99), // pale blue — dropped hard - (0xD4DCCF, 0x828E7C), // sage — dropped hard - (0xE0D1BB, 0x94806A), // sand — dropped hard - (0xF5D1BC, 0xA47A63), // blush — dropped hard - (0xFDEDE0, 0x8E8175), // shell — dropped hard - ] - - static func swatch(_ index: Int) -> Color { - let pair = swatchPairs[index % swatchPairs.count] - return dynamic(light: pair.light, dark: pair.dark) - } - - /// Text that stays legible on `swatch(index)`. Resolved per scheme rather - /// than once, because a swatch can be light in one scheme and mid in the - /// other — a single luminance test would get one of them wrong. - static func inkOn(_ index: Int) -> Color { - let pair = swatchPairs[index % swatchPairs.count] - return Color(uiColor: UIColor { traits in - let dark = traits.userInterfaceStyle == .dark - let onLight = luminance(of: dark ? pair.dark : pair.light) > 0.55 - let text: UInt32 = onLight ? 0x14110C : (dark ? 0xF1ECE1 : 0xF8F5EF) - return UIColor(rgb: text).withAlphaComponent(onLight ? 0.86 : 1) - }) - } - - static func dynamic(light: UInt32, dark: UInt32) -> Color { - Color(uiColor: UIColor { traits in - UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light) - }) - } - - static func luminance(of hex: UInt32) -> Double { - let r = Double((hex >> 16) & 0xFF) / 255 - let g = Double((hex >> 8) & 0xFF) / 255 - let b = Double(hex & 0xFF) / 255 - return 0.2126 * r + 0.7152 * g + 0.0722 * b - } -} - -extension UIColor { - convenience init(rgb: UInt32) { - self.init( - red: CGFloat((rgb >> 16) & 0xFF) / 255, - green: CGFloat((rgb >> 8) & 0xFF) / 255, - blue: CGFloat(rgb & 0xFF) / 255, - alpha: 1 - ) - } -} - -// MARK: Model - -struct LibraryItem: Identifiable, Hashable { - let id: Int - var title: String - /// The bottom-left mono stamp. The reference used a publication year; real - /// linkding data is all 2025–2026, so the year carries no signal and the - /// domain takes the slot instead. - var stamp: String - var source: String - var tags: [String] - /// nil = derive from the primary tag, so an untouched library still reads - /// as color-coded by subject rather than as noise. - var colorIndex: Int? - /// The page never gave us a title. Rendering the raw URL inside curly - /// quotes reads as a quotation that isn't one, so these skip the quotes. - var isUntitled = false - - var swatch: Color { Paper.swatch(resolvedIndex) } - var inkOnSwatch: Color { Paper.inkOn(resolvedIndex) } - - /// What a card shows. Cards get one clause; the list gets the whole title. - /// Real titles are overwhelmingly "name: what it does" — at card width the - /// name is the identifier and the blurb is filler, so past 60 characters we - /// keep the name and let the list carry the rest. - var cardTitle: String { - if title.count > 60, - let colon = title.range(of: ": "), - title.distance(from: title.startIndex, to: colon.lowerBound) <= 40 { - return String(title[..<colon.lowerBound]) - } - if title.count > 56 { - return String(title.prefix(56)).trimmingCharacters(in: .whitespaces) + "…" - } - return title - } - - /// Cards quote the title the way the reference does — except when there is - /// no real title to quote. - var cardDisplay: String { isUntitled ? cardTitle : "“\(cardTitle)”" } - var listDisplay: String { isUntitled ? title : "“\(title)”" } - - private var resolvedIndex: Int { - if let colorIndex { return colorIndex % Paper.swatchPairs.count } - // Tag first: 128 of 300 real bookmarks are github.com, so hashing the - // domain would paint half the library one color. Tags spread wider - // (top tag is 32 items). Untagged falls back to source. - let seed = tags.first ?? source - return abs(seed.unicodeScalars.reduce(5381) { ($0 &* 33) &+ Int($1.value) }) - % Paper.swatchPairs.count - } -} - -// MARK: Bookmark -> LibraryItem - -extension LibraryItem { - init(bookmark: Bookmark) { - self.id = bookmark.id - self.source = bookmark.domain.replacingOccurrences(of: "www.", with: "") - self.tags = bookmark.tagNames - self.colorIndex = nil - - // `displayTitle` falls back to the raw URL when linkding scraped no - // title — 15 of 300 real bookmarks. Show the domain instead and hand - // the stamp slot the path, so both slots still say something. - let raw = bookmark.displayTitle - if raw.hasPrefix("http") { - self.isUntitled = true - self.title = self.source - self.stamp = Self.path(of: bookmark.url) - } else { - self.title = Self.clean(raw) - self.stamp = Self.registered(self.source) - } - } - - /// Cards give the stamp one 11pt monospaced line, roughly 14 characters. - /// 92 of 300 real domains are longer than that, so drop the subdomain: - /// `toolkit.artlist.io` -> `artlist.io`. The list still shows it in full. - static func registered(_ host: String) -> String { - let parts = host.split(separator: ".") - guard parts.count > 2 else { return host } - // Two-part public suffixes (.co.uk, .com.au) need one more label. - let secondLevel: Set<String> = ["co", "com", "net", "org", "ac", "gov", "edu"] - let keep = secondLevel.contains(String(parts[parts.count - 2])) ? 3 : 2 - return parts.suffix(keep).joined(separator: ".") - } - - /// Returned whole: the stamp label middle-truncates, and pre-clipping here - /// too would elide it twice ("/share…fo/194…"). - static func path(of url: String) -> String { - guard let p = URL(string: url)?.path, p != "/", !p.isEmpty else { return "—" } - return p - } - - /// Linkding stores whatever the page's <title> said, which for the bulk of a - /// real library means "GitHub - owner/repo: <the entire README blurb>". - /// Median real title is 66 chars and the 90th percentile is 159 — the - /// reference design assumed ~30. Strip the boilerplate, then clamp. - static func clean(_ raw: String) -> String { - var t = raw.trimmingCharacters(in: .whitespacesAndNewlines) - - // "GitHub - owner/repo: blurb" -> "repo: blurb" - if t.hasPrefix("GitHub - ") { - t = String(t.dropFirst("GitHub - ".count)) - if let slash = t.firstIndex(of: "/"), - let colon = t.firstIndex(of: ":"), slash < colon { - t = String(t[t.index(after: slash)...]) - } - } - - // Trailing site furniture: "Title | Publisher", "Title - Latent.Space". - // Only strip a short trailing fragment off a title with something left - // over, so hyphenated titles survive. - for sep in [" | ", " · ", " — ", " – ", " - "] { - if let r = t.range(of: sep, options: .backwards), - t.distance(from: r.upperBound, to: t.endIndex) < 24, - t.distance(from: t.startIndex, to: r.lowerBound) > 12 { - t = String(t[..<r.lowerBound]) - } - } - - // Generous clamp: this is the list-mode title. `cardTitle` cuts harder. - if t.count > 120 { - t = String(t.prefix(120)).trimmingCharacters(in: .whitespaces) + "…" - } - return t.isEmpty ? "Untitled" : t - } -} - -struct LibraryFilter: Identifiable, Hashable { - let id = UUID() - var name: String - /// Empty = "everything", the tab you can't close. - var tag: String? -} - -// MARK: Display mode - -/// Two modes, not three. A 4-column grid was in the first pass and died on real -/// data: with a median title of 66 characters every tile truncated mid-word, so -/// it read as a wall of clipped text rather than as color. -enum LibraryLayout: String, CaseIterable, Identifiable { - case cards, list - var id: String { rawValue } - - var symbol: String { - switch self { - case .cards: "rectangle.inset.filled" - case .list: "line.3.horizontal" - } - } -} +// The standalone browse screen the design was worked out in: its own header, +// search field, and sample data, with no view model behind it. Kept as the +// place to iterate on the look in Previews — the shipping version is +// LibraryGridView, and both draw their pieces from LibraryKit. // MARK: - Screen @@ -377,89 +144,11 @@ struct LibraryView: View { // MARK: Filter tabs private var filterStrip: some View { - ZStack(alignment: .bottom) { - Rectangle() - .fill(Paper.rule) - .frame(height: 0.6) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - ForEach(filters) { filter in - tab(filter) - } - Menu { - ForEach(unusedTags, id: \.self) { tag in - Button(tag) { addFilter(tag: tag) } - } - if !filters.contains(where: { $0.tag == nil }) { - Divider() - Button("Everything") { addFilter(tag: nil) } - } - } label: { - Image(systemName: "plus") - .font(.system(size: 12, weight: .light)) - .foregroundStyle(Paper.ink.opacity(0.6)) - .frame(width: 38, height: 30) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 18) - } - } - .frame(height: 30) - } - - private func tab(_ filter: LibraryFilter) -> some View { - let active = filter.id == selectedFilter - return HStack(spacing: 7) { - Text(filter.name) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(active ? Paper.ink : Paper.ink.opacity(0.45)) - .lineLimit(1) - if filters.count > 1 { - Button { - withAnimation(.spring(duration: 0.3, bounce: 0)) { close(filter) } - } label: { - Image(systemName: "xmark") - .font(.system(size: 8, weight: .medium)) - .foregroundStyle(Paper.ink.opacity(active ? 0.5 : 0.25)) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 11) - .frame(height: 30) - .background(alignment: .bottom) { - if active { - // Paper fill sits 0.6pt proud so it erases the strip rule - // beneath the live tab — the browser-tab read. - UnevenRoundedRectangle( - topLeadingRadius: 6, bottomLeadingRadius: 0, - bottomTrailingRadius: 0, topTrailingRadius: 6 - ) - .fill(Paper.sheet) - .overlay(TabOutline().stroke(Paper.rule, lineWidth: 0.6)) - .padding(.bottom, -0.6) - .matchedGeometryEffect(id: "tab", in: blocks) - } - } - .contentShape(.rect) - .onTapGesture { - withAnimation(.spring(duration: 0.35, bounce: 0.1)) { selectedFilter = filter.id } - } - } - - private func addFilter(tag: String?) { - let new = LibraryFilter(name: tag?.capitalized ?? "Everything", tag: tag) - withAnimation(.spring(duration: 0.35, bounce: 0.1)) { - filters.append(new) - selectedFilter = new.id - } - } - - private func close(_ filter: LibraryFilter) { - filters.removeAll { $0.id == filter.id } - if selectedFilter == filter.id { selectedFilter = filters.first?.id } + LibraryTagStrip( + filters: $filters, + selection: $selectedFilter, + available: unusedTags + ) } // MARK: Content @@ -532,59 +221,6 @@ struct LibraryView: View { } } -// MARK: - Card - -private struct LibraryCard: View { - let item: LibraryItem - @State private var pressed = false - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(item.cardDisplay) - .font(.system(size: 11, design: .serif)) - .foregroundStyle(item.inkOnSwatch) - .multilineTextAlignment(.leading) - .lineLimit(5) - .minimumScaleFactor(0.85) - Spacer(minLength: 4) - Text(item.stamp) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(item.inkOnSwatch) - .lineLimit(1) - .truncationMode(.middle) - } - .padding(9) - .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: {}) - } -} - -/// Open path: up the left edge, across the top, down the right — no bottom -/// stroke, so the tab merges into the page. -private struct TabOutline: Shape { - func path(in rect: CGRect) -> Path { - let r: CGFloat = 6 - var p = Path() - p.move(to: CGPoint(x: rect.minX, y: rect.maxY)) - p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) - p.addQuadCurve( - to: CGPoint(x: rect.minX + r, y: rect.minY), - control: CGPoint(x: rect.minX, y: rect.minY) - ) - p.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY)) - p.addQuadCurve( - to: CGPoint(x: rect.maxX, y: rect.minY + r), - control: CGPoint(x: rect.maxX, y: rect.minY) - ) - p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) - return p - } -} - // MARK: - Sample data extension Array where Element == LibraryItem { From 3c7a783b6cf5a5330042b8417dcc94bc3c2aac1c Mon Sep 17 00:00:00 2001 From: Krishna Kumar <krish.kumar@gmail.com> Date: Sun, 26 Jul 2026 01:49:15 -0500 Subject: [PATCH 03/11] 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) <noreply@anthropic.com> 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 = "<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>"; }; + 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>"; }; 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>"; }; @@ -252,6 +254,7 @@ children = ( 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */, AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */, + E093C878E702891C64D21FD5 /* LibraryListRow.swift */, ); path = Library; sourceTree = "<group>"; @@ -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 } + } +} From cdbec4aaf8701b2e072c083a3e6e1af583f43b95 Mon Sep 17 00:00:00 2001 From: Krishna Kumar <krish.kumar@gmail.com> Date: Sun, 26 Jul 2026 01:54:51 -0500 Subject: [PATCH 04/11] Scale library type up 50% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every font size in the library design multiplied by 1.5 — card title and stamp, list title, meta, excerpt and tags, filter tabs, and the prototype's header. Geometry had to follow, or the larger type would have broken the layout it sits in: - The card grid drops from three columns to two. At 16.5pt in a 95pt column a card gets about nine characters per line and every title truncates; the text size is what sets the column count. - Filter tabs grow 30pt -> 42pt tall, the card gains padding and a slightly squarer aspect, and the list's color chip, unread dot and tag chips scale with the text they sit beside. Verified in both layouts against the live server. Suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks/Views/Library/LibraryGridView.swift | 7 +++-- Marks/Views/Library/LibraryKit.swift | 32 +++++++++++------------ Marks/Views/Library/LibraryListRow.swift | 26 +++++++++--------- Marks/Views/Prototypes/LibraryView.swift | 28 ++++++++++---------- 4 files changed, 48 insertions(+), 45 deletions(-) diff --git a/Marks/Views/Library/LibraryGridView.swift b/Marks/Views/Library/LibraryGridView.swift index edceb8b..ab216cb 100644 --- a/Marks/Views/Library/LibraryGridView.swift +++ b/Marks/Views/Library/LibraryGridView.swift @@ -20,8 +20,11 @@ struct LibraryGridView: View { var body: some View { ScrollView { LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), - spacing: 8 + // Two columns, not three: at the scaled-up type a third + // column leaves ~9 characters per line and every title + // truncates. The text size sets the column count. + columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 2), + spacing: 10 ) { ForEach(items) { item in card(item) diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index 5b7dc85..9716368 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -160,9 +160,9 @@ extension LibraryItem { } } - /// Cards give the stamp one 11pt monospaced line, roughly 14 characters. - /// 92 of 300 real domains are longer than that, so drop the subdomain: - /// `toolkit.artlist.io` -> `artlist.io`. The list still shows it in full. + /// Cards give the stamp one monospaced line, roughly 14 characters at the + /// card's width. 92 of 300 real domains are longer than that, so drop the + /// subdomain: `toolkit.artlist.io` -> `artlist.io`. The list shows it full. static func registered(_ host: String) -> String { let parts = host.split(separator: ".") guard parts.count > 2 else { return host } @@ -249,23 +249,23 @@ struct LibraryCard: View { let item: LibraryItem var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { Text(item.cardDisplay) - .font(.system(size: 11, design: .serif)) + .font(.system(size: 16.5, design: .serif)) .foregroundStyle(item.inkOnSwatch) .multilineTextAlignment(.leading) .lineLimit(5) - .minimumScaleFactor(0.85) + .minimumScaleFactor(0.8) Spacer(minLength: 4) Text(item.stamp) - .font(.system(size: 11, design: .monospaced)) + .font(.system(size: 16.5, design: .monospaced)) .foregroundStyle(item.inkOnSwatch) .lineLimit(1) .truncationMode(.middle) } - .padding(9) + .padding(13) .frame(maxWidth: .infinity, alignment: .topLeading) - .aspectRatio(0.70, contentMode: .fit) + .aspectRatio(0.82, contentMode: .fit) .background(RoundedRectangle(cornerRadius: 5).fill(item.swatch)) } } @@ -304,23 +304,23 @@ struct LibraryTagStrip: View { } } label: { Image(systemName: "plus") - .font(.system(size: 12, weight: .light)) + .font(.system(size: 18, weight: .light)) .foregroundStyle(Paper.ink.opacity(0.6)) - .frame(width: 38, height: 30) + .frame(width: 50, height: 42) } Spacer(minLength: 0) } .padding(.horizontal, 18) } } - .frame(height: 30) + .frame(height: 42) } private func tab(_ filter: LibraryFilter) -> some View { let active = filter.id == selection return HStack(spacing: 7) { Text(filter.name) - .font(.system(size: 11, design: .monospaced)) + .font(.system(size: 16.5, design: .monospaced)) .foregroundStyle(active ? Paper.ink : Paper.ink.opacity(0.45)) .lineLimit(1) if filters.count > 1 { @@ -328,14 +328,14 @@ struct LibraryTagStrip: View { withAnimation(.spring(duration: 0.3, bounce: 0)) { close(filter) } } label: { Image(systemName: "xmark") - .font(.system(size: 8, weight: .medium)) + .font(.system(size: 12, weight: .medium)) .foregroundStyle(Paper.ink.opacity(active ? 0.5 : 0.25)) } .buttonStyle(.plain) } } - .padding(.horizontal, 11) - .frame(height: 30) + .padding(.horizontal, 15) + .frame(height: 42) .background(alignment: .bottom) { if active { // Paper fill sits 0.6pt proud so it erases the strip rule diff --git a/Marks/Views/Library/LibraryListRow.swift b/Marks/Views/Library/LibraryListRow.swift index 288b409..81db16e 100644 --- a/Marks/Views/Library/LibraryListRow.swift +++ b/Marks/Views/Library/LibraryListRow.swift @@ -27,7 +27,7 @@ struct LibraryListRow: View { HStack(alignment: .top, spacing: 12) { RoundedRectangle(cornerRadius: 3) .fill(item.swatch) - .frame(width: 26, height: 34) + .frame(width: 34, height: 46) .overlay(alignment: .topLeading) { if bookmark.unread { // Ink, not blue: the palette is the only color the @@ -35,15 +35,15 @@ struct LibraryListRow: View { // 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) + .frame(width: 10, height: 10) + .overlay(Circle().stroke(Paper.sheet, lineWidth: 2)) + .offset(x: -4, y: -4) } } VStack(alignment: .leading, spacing: 5) { Text(item.listDisplay) - .font(.system(size: 14, design: .serif)) + .font(.system(size: 21, design: .serif)) .foregroundStyle(Paper.ink) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) @@ -55,13 +55,13 @@ struct LibraryListRow: View { for: bookmark.dateAdded, relativeTo: Date() )) } - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 15, design: .monospaced)) .foregroundStyle(Paper.ink.opacity(0.45)) .lineLimit(1) if let excerpt = rowExcerpt { Text(excerpt.text) - .font(.system(size: 12, design: .serif)) + .font(.system(size: 18, design: .serif)) .italic(excerpt.isAI) .foregroundStyle(Paper.ink.opacity(0.6)) .lineLimit(2) @@ -80,14 +80,14 @@ struct LibraryListRow: View { onPodcast() } label: { Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle") - .font(.system(size: 17, weight: .light)) + .font(.system(size: 25.5, weight: .light)) .foregroundStyle(Paper.ink.opacity(podcastCached ? 0.75 : 0.3)) } .buttonStyle(.plain) .sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount) } } - .padding(.vertical, 12) + .padding(.vertical, 14) .contentShape(Rectangle()) .overlay(alignment: .bottom) { if readingProgress > 0.02 { @@ -115,12 +115,12 @@ struct LibraryListRow: View { HStack(spacing: 5) { ForEach(effectiveTags, id: \.self) { tag in Text(tag) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 15, design: .monospaced)) .foregroundStyle(Paper.ink.opacity(0.55)) - .padding(.horizontal, 6) - .padding(.vertical, 2) + .padding(.horizontal, 8) + .padding(.vertical, 3) .overlay( - RoundedRectangle(cornerRadius: 3) + RoundedRectangle(cornerRadius: 4) .stroke(Paper.rule.opacity(0.6), lineWidth: 0.6) ) } diff --git a/Marks/Views/Prototypes/LibraryView.swift b/Marks/Views/Prototypes/LibraryView.swift index b5f03a9..5672675 100644 --- a/Marks/Views/Prototypes/LibraryView.swift +++ b/Marks/Views/Prototypes/LibraryView.swift @@ -71,7 +71,7 @@ struct LibraryView: View { VStack(spacing: 14) { HStack(alignment: .firstTextBaseline) { Text("Library") - .font(.system(size: 34, weight: .regular, design: .serif)) + .font(.system(size: 51, weight: .regular, design: .serif)) .foregroundStyle(Paper.ink) Spacer(minLength: 12) Button { @@ -82,7 +82,7 @@ struct LibraryView: View { searchFocused = searching } label: { Image(systemName: searching ? "xmark" : "magnifyingglass") - .font(.system(size: 15, weight: .light)) + .font(.system(size: 22.5, weight: .light)) .foregroundStyle(Paper.ink) .frame(width: 28, height: 28) } @@ -92,7 +92,7 @@ struct LibraryView: View { if searching { VStack(spacing: 5) { TextField("", text: $query, prompt: searchPrompt) - .font(.system(size: 13, design: .monospaced)) + .font(.system(size: 19.5, design: .monospaced)) .foregroundStyle(Paper.ink) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -109,7 +109,7 @@ struct LibraryView: View { private var searchPrompt: Text { Text("search the library") - .font(.system(size: 13, design: .monospaced)) + .font(.system(size: 19.5, design: .monospaced)) .foregroundColor(Paper.ink.opacity(0.35)) } @@ -121,12 +121,12 @@ struct LibraryView: View { withAnimation(.spring(duration: 0.42, bounce: 0.12)) { layout = option } } label: { Image(systemName: option.symbol) - .font(.system(size: 12, weight: .regular)) + .font(.system(size: 18, weight: .regular)) .foregroundStyle(active ? Paper.sheet : Paper.ink.opacity(0.55)) - .frame(width: 34, height: 26) + .frame(width: 46, height: 36) .background { if active { - RoundedRectangle(cornerRadius: 5) + RoundedRectangle(cornerRadius: 7) .fill(Paper.ink) .padding(2) .matchedGeometryEffect(id: "layoutPill", in: blocks) @@ -137,7 +137,7 @@ struct LibraryView: View { } } .overlay( - RoundedRectangle(cornerRadius: 7).stroke(Paper.rule, lineWidth: 0.6) + RoundedRectangle(cornerRadius: 9).stroke(Paper.rule, lineWidth: 0.6) ) } @@ -171,8 +171,8 @@ struct LibraryView: View { private var cardGrid: some View { LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), - spacing: 8 + columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 2), + spacing: 10 ) { ForEach(Array(visibleItems.enumerated()), id: \.element.id) { index, item in LibraryCard(item: item) @@ -193,15 +193,15 @@ struct LibraryView: View { HStack(spacing: 12) { RoundedRectangle(cornerRadius: 3) .fill(item.swatch) - .frame(width: 26, height: 34) + .frame(width: 34, height: 46) .matchedGeometryEffect(id: item.id, in: blocks) VStack(alignment: .leading, spacing: 3) { Text(item.listDisplay) - .font(.system(size: 13, design: .serif)) + .font(.system(size: 19.5, design: .serif)) .foregroundStyle(Paper.ink) .lineLimit(1) Text(item.source) - .font(.system(size: 10, design: .monospaced)) + .font(.system(size: 15, design: .monospaced)) .foregroundStyle(Paper.ink.opacity(0.45)) .lineLimit(1) } @@ -210,7 +210,7 @@ struct LibraryView: View { // shows as the subtitle — so the right column carries the // primary tag instead of repeating it. Text(item.tags.first ?? item.stamp) - .font(.system(size: 11, design: .monospaced)) + .font(.system(size: 16.5, design: .monospaced)) .foregroundStyle(Paper.ink.opacity(0.55)) .lineLimit(1) } From 0b8b9fe5129c337c7f12c2489f0a41ad1cadaad7 Mon Sep 17 00:00:00 2001 From: Krishna Kumar <krish.kumar@gmail.com> Date: Sun, 26 Jul 2026 01:59:19 -0500 Subject: [PATCH 05/11] Wrap list tags onto two lines instead of scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the scaled-up type a horizontal tag strip clipped its third chip mid-word, and a fade only made the clipping prettier. Tags now wrap: you see whole tags or none. SwiftUI has no wrapping stack, so this adds a small FlowLayout capped at maxRows. Subviews past the cap are placed off-screen at zero size rather than left unplaced — a Layout that declines to place a subview gets it laid out at the origin instead of dropped, which would have stacked the leftover tags on top of the first row. Verified by temporarily forcing maxRows to 1: the overflow disappears cleanly, with no ghost chips. Worth knowing: the cap does discard tags on real data. The heaviest bookmarks carry five linkding tags plus AI tags, and two rows hold about four chips at this size, so the tail is hidden rather than truncated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks/Views/Library/LibraryKit.swift | 82 ++++++++++++++++++++++++ Marks/Views/Library/LibraryListRow.swift | 44 +++++-------- 2 files changed, 98 insertions(+), 28 deletions(-) diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index 9716368..64e2d9e 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -376,6 +376,88 @@ struct LibraryTagStrip: View { } } +// MARK: - Flow layout + +/// Wraps subviews onto as many rows as fit, capped at `maxRows`. Anything past +/// the cap is placed off-screen at zero size rather than skipped — a Layout +/// that declines to place a subview gets it laid out at the origin instead of +/// dropped, which would stack leftover tags on top of the first row. +struct FlowLayout: Layout { + var spacing: CGFloat = 5 + var lineSpacing: CGFloat = 5 + var maxRows: Int = 2 + + struct Row { + var indices: [Int] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let rows = rows(width: proposal.width ?? .infinity, subviews: subviews) + let height = rows.reduce(0) { $0 + $1.height } + + CGFloat(max(0, rows.count - 1)) * lineSpacing + return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Void + ) { + let rows = rows(width: bounds.width, subviews: subviews) + let placed = Set(rows.flatMap(\.indices)) + var y = bounds.minY + + for row in rows { + var x = bounds.minX + for i in row.indices { + let size = subviews[i].sizeThatFits(.unspecified) + subviews[i].place( + at: CGPoint(x: x, y: y), + anchor: .topLeading, + proposal: ProposedViewSize(size) + ) + x += size.width + spacing + } + y += row.height + lineSpacing + } + + // Far enough to be off any row, small enough not to poison the layout + // arithmetic the way a near-infinite coordinate would. + for i in subviews.indices where !placed.contains(i) { + subviews[i].place(at: CGPoint(x: bounds.minX - 10_000, y: bounds.minY), + anchor: .topLeading, + proposal: .zero) + } + } + + private func rows(width: CGFloat, subviews: Subviews) -> [Row] { + var rows: [Row] = [] + var current = Row() + + for i in subviews.indices { + let size = subviews[i].sizeThatFits(.unspecified) + let needed = current.indices.isEmpty ? size.width : current.width + spacing + size.width + if needed > width && !current.indices.isEmpty { + rows.append(current) + if rows.count == maxRows { return rows } + current = Row() + current.indices = [i] + current.width = size.width + current.height = size.height + } else { + current.indices.append(i) + current.width = needed + current.height = max(current.height, size.height) + } + } + if !current.indices.isEmpty { rows.append(current) } + return rows + } +} + /// Open path: up the left edge, across the top, down the right — no bottom /// stroke, so the tab merges into the page. struct TabOutline: Shape { diff --git a/Marks/Views/Library/LibraryListRow.swift b/Marks/Views/Library/LibraryListRow.swift index 81db16e..de0e2fb 100644 --- a/Marks/Views/Library/LibraryListRow.swift +++ b/Marks/Views/Library/LibraryListRow.swift @@ -110,37 +110,25 @@ struct LibraryListRow: View { } } + /// Tags wrap onto up to two lines rather than scrolling sideways. At the + /// scaled-up type a horizontal strip clipped its third chip mid-word, which + /// read as broken text; wrapping shows whole tags or none. private var tagRow: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 5) { - ForEach(effectiveTags, id: \.self) { tag in - Text(tag) - .font(.system(size: 15, design: .monospaced)) - .foregroundStyle(Paper.ink.opacity(0.55)) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(Paper.rule.opacity(0.6), lineWidth: 0.6) - ) - } + FlowLayout(spacing: 5, lineSpacing: 5, maxRows: 2) { + ForEach(effectiveTags, id: \.self) { tag in + Text(tag) + .font(.system(size: 15, design: .monospaced)) + .foregroundStyle(Paper.ink.opacity(0.55)) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .overlay( + RoundedRectangle(cornerRadius: 4) + .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 - ) - ) + .padding(.top, 1) } /// Excerpt shown under the title: prefer the AI summary (italic), else the From 69c2bc751409928f7cb2cac715ccd9270310f6ef Mon Sep 17 00:00:00 2001 From: Krishna Kumar <krish.kumar@gmail.com> Date: Sun, 26 Jul 2026 02:02:20 -0500 Subject: [PATCH 06/11] Label the tag-pin button for VoiceOver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The + in the filter strip was an unlabeled SF Symbol, so VoiceOver announced nothing useful for it. Also makes the control queryable, which is how the menu was verified. Verified by driving the real UI with a temporary XCUITest target: tapping + opens a menu listing the available tags, and tapping a card opens the browser. The target was removed afterwards rather than committed — both tests need the live linkding server to have loaded bookmarks first, which is not something the unit suite should depend on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks/Views/Library/LibraryKit.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index 64e2d9e..f735562 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -308,6 +308,7 @@ struct LibraryTagStrip: View { .foregroundStyle(Paper.ink.opacity(0.6)) .frame(width: 50, height: 42) } + .accessibilityLabel("Pin a tag") Spacer(minLength: 0) } .padding(.horizontal, 18) From 8a777ffc8360a028656ee7a129a106d101736ced Mon Sep 17 00:00:00 2001 From: Krishna Kumar <krish.kumar@gmail.com> Date: Sun, 26 Jul 2026 11:12:47 -0500 Subject: [PATCH 07/11] Replace the Tags tab with a tag picker sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab bar drops from five to four; Tags now opens as a sheet from the + in the library's filter strip, which is the control that was already about adding a tag. Picking a tag pins it as a filter tab rather than pushing to a separate per-tag list. That made TagBookmarksView redundant — the filter tab shows the same thing, in whichever layout you're already using — so it's gone. This also fixes what the + could reach. The old inline menu listed tags found on the loaded page, so it offered 21 of 96 tags, and once a tag filter was active it collapsed to just the tags co-occurring with that one — pinning a second unrelated tag was impossible. The sheet sources names from the tags endpoint via a new BookmarksViewModel.loadAllTags(), and counts still come from loaded bookmarks, so a tag we haven't paged in shows no number rather than a wrong one. The sheet is searchable, marks already-pinned tags with a check, and picking a pinned tag selects that tab instead of duplicating it. Verified by driving the UI: the tab bar is now Bookmarks/Sources/ Podcasts/Search, + opens the sheet with 42 rows where the old menu had 21, and picking "ai" pinned an "ai" tab. Suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks/Intents/IntentSupport.swift | 2 +- Marks/MarksApp.swift | 3 - Marks/Views/BookmarksView.swift | 32 +++-- Marks/Views/BookmarksViewModel.swift | 11 ++ Marks/Views/Library/LibraryKit.swift | 26 +---- Marks/Views/Prototypes/LibraryView.swift | 12 +- Marks/Views/TagsView.swift | 143 +++++++++++++---------- 7 files changed, 130 insertions(+), 99 deletions(-) diff --git a/Marks/Intents/IntentSupport.swift b/Marks/Intents/IntentSupport.swift index c0a09bb..ca310b2 100644 --- a/Marks/Intents/IntentSupport.swift +++ b/Marks/Intents/IntentSupport.swift @@ -3,7 +3,7 @@ import AppIntents /// Which top-level tab the app is showing. Used so an intent can switch tabs. enum AppTab: Hashable { - case bookmarks, tags, sources, podcasts, search + case bookmarks, sources, podcasts, search } /// Bridges App Intents (which run in the main app process, since there is no diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index a9516eb..929e545 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -54,9 +54,6 @@ struct MainContainer: View { Tab("Bookmarks", systemImage: "bookmark", value: AppTab.bookmarks) { BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect) } - Tab("Tags", systemImage: "tag", value: AppTab.tags) { - TagsView(viewModel: viewModel) - } Tab("Sources", systemImage: "tray.full", value: AppTab.sources) { SourcesView( library: sourceLibrary, diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 71140ab..4af771d 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -65,6 +65,7 @@ struct BookmarksView: View { LibraryFilter(name: "Everything", tag: nil) ] @State private var librarySelection: LibraryFilter.ID? + @State private var showTags = false private var layout: LibraryLayout { LibraryLayout(rawValue: layoutRaw) ?? .list } private var otherLayout: LibraryLayout { layout == .cards ? .list : .cards } @@ -78,8 +79,8 @@ struct BookmarksView: View { LibraryTagStrip( filters: $libraryFilters, selection: $librarySelection, - available: availableTags, - onChange: applyTagFilter + onChange: applyTagFilter, + onBrowseTags: { showTags = true } ) if layout == .cards { @@ -192,6 +193,13 @@ struct BookmarksView: View { .sheet(isPresented: $showAsk) { AskView() } + .sheet(isPresented: $showTags) { + TagsView( + viewModel: viewModel, + pinned: Set(libraryFilters.compactMap(\.tag)), + onPick: pinTag + ) + } .sheet(isPresented: $showAddBookmark) { AddBookmarkView(viewModel: viewModel) } @@ -319,15 +327,19 @@ struct BookmarksView: View { .animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty) } - /// 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 } + /// Pin a tag as a filter tab and switch to it. Choosing one that's already + /// pinned selects that tab instead of adding a duplicate. + private func pinTag(_ tag: String) { + if let existing = libraryFilters.first(where: { $0.tag == tag }) { + librarySelection = existing.id + } else { + let new = LibraryFilter(name: tag, tag: tag) + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { + libraryFilters.append(new) + librarySelection = new.id + } } - return counts.sorted { ($0.value, $1.key) > ($1.value, $0.key) }.map(\.key) + applyTagFilter() } /// Tag tabs filter server-side through linkding's `#tag` search syntax — diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index 0ff4d0c..5b5ea18 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -11,6 +11,9 @@ final class BookmarksViewModel { var searchQuery = "" var nextPageUrl: String? var smartCollections: [SmartCollection] = [] + /// Every tag name on the server. The loaded bookmark page only ever + /// exposes the tags of the most recent 50, which is not the vocabulary. + var allTags: [String] = [] var isGeneratingCollections = false var enrichmentProgress: Double = 0 var unreadFilter = false @@ -200,6 +203,14 @@ final class BookmarksViewModel { } } + /// Best-effort: an empty tag list just means the picker falls back to the + /// tags it can see on loaded bookmarks, so a failure here isn't worth an + /// error alert. + func loadAllTags() async { + guard let tags = try? await api.fetchTags() else { return } + allTags = tags + } + func generateSmartCollections() async { guard !bookmarks.isEmpty else { return } isGeneratingCollections = true diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index f735562..7f41ce3 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -277,9 +277,11 @@ struct LibraryCard: View { struct LibraryTagStrip: View { @Binding var filters: [LibraryFilter] @Binding var selection: LibraryFilter.ID? - /// Tags available to pin that aren't already open. - let available: [String] var onChange: () -> Void = {} + /// Opens the tag picker. It replaced an inline Menu, which could only list + /// tags found on the loaded page — and once a tag filter was active, that + /// collapsed to the handful of tags co-occurring with it. + var onBrowseTags: () -> Void = {} @Namespace private var strip @@ -294,20 +296,13 @@ struct LibraryTagStrip: View { ForEach(filters) { filter in tab(filter) } - Menu { - ForEach(available, id: \.self) { tag in - Button(tag) { add(tag: tag) } - } - if !filters.contains(where: { $0.tag == nil }) { - Divider() - Button("Everything") { add(tag: nil) } - } - } label: { + Button { onBrowseTags() } label: { Image(systemName: "plus") .font(.system(size: 18, weight: .light)) .foregroundStyle(Paper.ink.opacity(0.6)) .frame(width: 50, height: 42) } + .buttonStyle(.plain) .accessibilityLabel("Pin a tag") Spacer(minLength: 0) } @@ -359,15 +354,6 @@ struct LibraryTagStrip: View { } } - private func add(tag: String?) { - let new = LibraryFilter(name: tag ?? "Everything", tag: tag) - withAnimation(.spring(duration: 0.35, bounce: 0.1)) { - filters.append(new) - selection = new.id - } - onChange() - } - private func close(_ filter: LibraryFilter) { filters.removeAll { $0.id == filter.id } if selection == filter.id { diff --git a/Marks/Views/Prototypes/LibraryView.swift b/Marks/Views/Prototypes/LibraryView.swift index 5672675..5ab25a0 100644 --- a/Marks/Views/Prototypes/LibraryView.swift +++ b/Marks/Views/Prototypes/LibraryView.swift @@ -147,7 +147,17 @@ struct LibraryView: View { LibraryTagStrip( filters: $filters, selection: $selectedFilter, - available: unusedTags + // The prototype has no tag picker sheet behind it; + pins the + // heaviest tag that isn't already open, which is enough to exercise + // the strip's layout. + onBrowseTags: { + guard let tag = unusedTags.first else { return } + let new = LibraryFilter(name: tag, tag: tag) + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { + filters.append(new) + selectedFilter = new.id + } + } ) } diff --git a/Marks/Views/TagsView.swift b/Marks/Views/TagsView.swift index 259f203..b77f343 100644 --- a/Marks/Views/TagsView.swift +++ b/Marks/Views/TagsView.swift @@ -1,36 +1,95 @@ import SwiftUI +/// The tag surface, presented as a sheet from the library's filter strip. +/// +/// It used to be a top-level tab that pushed to a per-tag bookmark list. The +/// library's filter tabs now do that job, so this is a picker: choose a tag, +/// it becomes a tab. The old TagBookmarksView went with it. struct TagsView: View { @Bindable var viewModel: BookmarksViewModel + /// Tags already pinned as filter tabs, shown as such rather than hidden — + /// their absence would just read as a missing tag. + let pinned: Set<String> + let onPick: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var query = "" var body: some View { NavigationStack { - List { - ForEach(allTags, id: \.tag) { entry in - NavigationLink { - TagBookmarksView(tag: entry.tag, viewModel: viewModel) - } label: { - HStack { - Text(entry.tag) - .font(.system(size: 17)) - Spacer() - Text("\(entry.count)") - .font(.system(size: 15)) - .foregroundStyle(.secondary) - } + ScrollView { + LazyVStack(spacing: 0) { + ForEach(visibleTags, id: \.tag) { entry in + row(entry) + Rectangle() + .fill(Paper.rule.opacity(0.4)) + .frame(height: 0.6) } } + .padding(.horizontal, 18) } + .background(Paper.sheet.ignoresSafeArea()) + .scrollDismissesKeyboard(.immediately) + .searchable(text: $query, prompt: "Filter tags") .navigationTitle("Tags") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } .overlay { - if viewModel.bookmarks.isEmpty { - ContentUnavailableView("No Tags", systemImage: "tag", description: Text("Tags from your bookmarks will appear here.")) + if visibleTags.isEmpty { + ContentUnavailableView( + query.isEmpty ? "No Tags" : "No Matching Tags", + systemImage: "tag", + description: Text( + query.isEmpty + ? "Tags from your bookmarks will appear here." + : "No tag matches “\(query)”." + ) + ) } } } + .task { await viewModel.loadAllTags() } } - private var allTags: [(tag: String, count: Int)] { + private func row(_ entry: (tag: String, count: Int)) -> some View { + Button { + onPick(entry.tag) + dismiss() + } label: { + HStack(spacing: 10) { + Text(entry.tag) + .font(.system(size: 16.5, design: .monospaced)) + .foregroundStyle(Paper.ink) + .lineLimit(1) + if pinned.contains(entry.tag) { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Paper.ink.opacity(0.4)) + } + Spacer(minLength: 8) + // Counts come from the loaded pages, so a tag the server knows + // about but we haven't paged in yet shows no number rather than + // a wrong one. + if entry.count > 0 { + Text("\(entry.count)") + .font(.system(size: 15, design: .monospaced)) + .foregroundStyle(Paper.ink.opacity(0.4)) + } + } + .padding(.vertical, 14) + .contentShape(.rect) + } + .buttonStyle(.plain) + } + + /// Every tag the server knows, not just those on the loaded page — the + /// filter strip's old inline menu could only offer tags from the current + /// 50 bookmarks, which collapsed to almost nothing once a filter was on. + private var visibleTags: [(tag: String, count: Int)] { var counts: [String: Int] = [:] for bookmark in viewModel.bookmarks { for tag in bookmark.tagNames { @@ -40,54 +99,10 @@ struct TagsView: View { counts[tag, default: 0] += 1 } } - return counts.map { (tag: $0.key, count: $0.value) } + let names = Set(viewModel.allTags).union(counts.keys) + return names + .filter { query.isEmpty || $0.localizedCaseInsensitiveContains(query) } + .map { (tag: $0, count: counts[$0] ?? 0) } .sorted { $0.count != $1.count ? $0.count > $1.count : $0.tag < $1.tag } } } - -struct TagBookmarksView: View { - let tag: String - @Bindable var viewModel: BookmarksViewModel - - @State private var browsingBookmark: Bookmark? - - var body: some View { - List { - ForEach(filteredBookmarks) { bookmark in - BookmarkListRow( - bookmark: bookmark, - viewModel: viewModel, - onOpen: { browsingBookmark = bookmark } - ) - } - } - .listStyle(.plain) - .navigationTitle(tag) - .navigationBarTitleDisplayMode(.large) - .overlay { - if filteredBookmarks.isEmpty { - ContentUnavailableView("No Bookmarks", systemImage: "tag") - } - } - .sensoryFeedback(.selection, trigger: browsingBookmark?.id) - .sheet(item: $browsingBookmark) { bookmark in - if let url = URL(string: bookmark.url) { - BrowserView( - url: url, - title: bookmark.displayTitle, - claude: viewModel.claude, - podcastPlayer: viewModel.podcastPlayer, - podcastGenerator: viewModel.podcastGenerator - ) { - await viewModel.archive(bookmark) - } - } - } - } - - private var filteredBookmarks: [Bookmark] { - viewModel.bookmarks.filter { - $0.tagNames.contains(tag) || ($0.aiTags ?? []).contains(tag) - } - } -} From fffb3999bf1555b25c9aa53075bb7415592e6077 Mon Sep 17 00:00:00 2001 From: Krishna Kumar <krish.kumar@gmail.com> Date: Mon, 27 Jul 2026 12:50:45 -0500 Subject: [PATCH 08/11] Carry the library design across the rest of the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every screen now speaks the paper vocabulary rather than only the bookmarks screen: Search, Sources, Podcasts and the player, Settings, Add/Edit, Smart Collections, Ask, Onboarding, the browser toolbar, the Siri snippets, and the share extension's save card. The change is mostly a design system rather than per-screen tweaks. LibraryKit gains: - Semantic colors — secondary/tertiary/faint text, `raised` surfaces, and accent/alarm/affirm, so screens stop reaching for .secondary, systemGray, .blue, .red and .green. The three status colors come out of the palette (the swatch blue, vermilion and forest) rather than from the system set, so the design keeps spending one set of inks. - PaperType — the two voices made explicit. Prose (titles, summaries, anything a person wrote) is serif; anything the machine contributes (domains, dates, counts, tags, labels, buttons) is monospaced. Keeping that split strict is what makes the design read as archival rather than as decoration. - paperSurface() / paperField() / paperCard() for grounds, inputs and raised blocks. - PaperEmptyState, because ContentUnavailableView can't be restyled — it draws its own bold system type and grey, which was the loudest remaining system voice once the screens moved onto the sheet. All nine usages are converted. The app also sets one accent for the system chrome it doesn't draw — tab bar, search fields, switches, swipe actions — which otherwise stayed system blue around paper screens. BookmarkRow is deleted. Once Search moved to the library row and TagBookmarksView was gone, nothing passed .classic, so the style fork in BookmarkListRow went with it. There is one bookmark row now. The share extension compiles LibraryKit directly, since its save card is a user-facing surface and should not be the one place still using system styling. Verified on device in both color schemes; screens toured with a temporary UI test harness (removed). Suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks.xcodeproj/project.pbxproj | 6 +- Marks/Intents/IntentSnippetViews.swift | 24 ++-- Marks/MarksApp.swift | 4 + Marks/Views/AddBookmarkView.swift | 33 +++-- Marks/Views/AskView.swift | 48 ++++--- Marks/Views/BookmarkListRow.swift | 39 ++---- Marks/Views/BookmarkRow.swift | 170 ----------------------- Marks/Views/BookmarksView.swift | 44 +++--- Marks/Views/BrowserView.swift | 8 +- Marks/Views/CollectionsView.swift | 27 ++-- Marks/Views/EditBookmarkView.swift | 31 ++++- Marks/Views/Library/LibraryKit.swift | 112 +++++++++++++++ Marks/Views/Library/LibraryListRow.swift | 9 +- Marks/Views/OnboardingView.swift | 31 +++-- Marks/Views/PodcastPlayerView.swift | 104 +++++++------- Marks/Views/SearchView.swift | 5 +- Marks/Views/SettingsView.swift | 26 +++- Marks/Views/SourcesView.swift | 51 ++++--- Marks/Views/TagsView.swift | 8 +- ShareExtension/ShareView.swift | 42 +++--- project.yml | 3 + 21 files changed, 418 insertions(+), 407 deletions(-) delete mode 100644 Marks/Views/BookmarkRow.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index cdaa114..c90b335 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -50,8 +50,8 @@ 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; }; 94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; }; 95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB1DA808EE8041C4546DAAB /* SourcesView.swift */; }; - 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; }; 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; }; + A30907113FD5D682478750A7 /* LibraryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */; }; A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; }; A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; }; AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */; }; @@ -165,7 +165,6 @@ AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; }; AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; }; ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; }; - B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = "<group>"; }; B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = "<group>"; }; BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; }; C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTool.swift; sourceTree = "<group>"; }; @@ -335,7 +334,6 @@ 217E6702DE1210AC38ED16D1 /* AskView.swift */, 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */, CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */, - B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */, 629C41E0BC28EB6359D50CFD /* BrowserView.swift */, @@ -539,6 +537,7 @@ files = ( 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */, 70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */, + A30907113FD5D682478750A7 /* LibraryKit.swift in Sources */, 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */, B085CDBFC47F0357D1A28911 /* Log.swift in Sources */, B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */, @@ -576,7 +575,6 @@ FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */, CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */, 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */, - 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, 44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, diff --git a/Marks/Intents/IntentSnippetViews.swift b/Marks/Intents/IntentSnippetViews.swift index 120e0fe..7f05de0 100644 --- a/Marks/Intents/IntentSnippetViews.swift +++ b/Marks/Intents/IntentSnippetViews.swift @@ -8,18 +8,19 @@ struct BookmarkSnippetView: View { HStack(spacing: 12) { Image(systemName: "bookmark.fill") .font(.title2) - .foregroundStyle(.tint) + .foregroundStyle(Paper.accent) VStack(alignment: .leading, spacing: 3) { Text(entity.title) - .font(.headline) + .font(PaperType.heading) + .foregroundStyle(Paper.ink) .lineLimit(2) Text(entity.host) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) if !entity.tags.isEmpty { Text(entity.tags.map { "#\($0)" }.joined(separator: " ")) - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) .lineLimit(1) } } @@ -38,14 +39,15 @@ struct SummarySnippetView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { - Image(systemName: "sparkles").foregroundStyle(.tint) - Text(title).font(.headline).lineLimit(2) + Image(systemName: "sparkles").foregroundStyle(Paper.accent) + Text(title).font(PaperType.heading).foregroundStyle(Paper.ink).lineLimit(2) } Text(host) - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) Text(summary) - .font(.body) + .font(PaperType.body) + .foregroundStyle(Paper.ink) .fixedSize(horizontal: false, vertical: true) } .padding() diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index 929e545..ed6a8e2 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -70,6 +70,10 @@ struct MainContainer: View { SearchView(viewModel: viewModel) } } + // One accent for every system control the app doesn't draw itself — + // tab bar selection, search fields, switches, swipe actions. Without + // this the paper screens sit inside system-blue chrome. + .tint(Paper.accent) .onOpenURL { url in handleDeepLink(url) } diff --git a/Marks/Views/AddBookmarkView.swift b/Marks/Views/AddBookmarkView.swift index 3a8ef46..22cc87c 100644 --- a/Marks/Views/AddBookmarkView.swift +++ b/Marks/Views/AddBookmarkView.swift @@ -18,6 +18,7 @@ struct AddBookmarkView: View { Form { Section { TextEditor(text: $importText) + .paperField() .frame(minHeight: 96) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -27,6 +28,8 @@ struct AddBookmarkView: View { extractImportText() } label: { Label("Extract Link", systemImage: "link.badge.plus") + .font(PaperType.label) + .foregroundStyle(Paper.accent) } .disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) @@ -34,56 +37,62 @@ struct AddBookmarkView: View { if let importMessage { Text(importMessage) - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } } } header: { - Text("Import Text") + Text("Import Text").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } footer: { - Text("Paste an email, newsletter, or message and Marks will pull out the first web link.") + Text("Paste an email, newsletter, or message and Marks will pull out the first web link.").font(PaperType.micro).foregroundStyle(Paper.tertiary) } Section { TextField("https://", text: $url) + .paperField() .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() } header: { - Text("URL") + Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("Optional", text: $title) + .paperField() } header: { - Text("Title") + Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("Optional", text: $description, axis: .vertical) + .paperField() .lineLimit(2...5) } header: { - Text("Notes") + Text("Notes").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("comma separated", text: $tagsText) + .paperField() .textInputAutocapitalization(.never) .autocorrectionDisabled() } header: { - Text("Tags") + Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } footer: { - Text("Separate tags with commas") + Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary) } if let error { Section { Text(error) - .foregroundStyle(.red) - .font(.footnote) + .font(PaperType.meta) + .foregroundStyle(Paper.alarm) } } } + .listRowBackground(Paper.raised) + .paperSurface() .navigationTitle("Add Bookmark") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -92,6 +101,8 @@ struct AddBookmarkView: View { } ToolbarItem(placement: .confirmationAction) { Button("Save") { save() } + .font(PaperType.label) + .tint(Paper.accent) .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving) .overlay { if isSaving { ProgressView().scaleEffect(0.7) } diff --git a/Marks/Views/AskView.swift b/Marks/Views/AskView.swift index 3988741..cf28088 100644 --- a/Marks/Views/AskView.swift +++ b/Marks/Views/AskView.swift @@ -18,20 +18,23 @@ struct AskView: View { case .checking: ProgressView() case .unavailable(let message): - ContentUnavailableView( - "Unavailable", + PaperEmptyState( + title: "Unavailable", systemImage: "sparkles.slash", - description: Text(message) + message: message ) case .ready: ready } } + .paperSurface() .navigationTitle("Ask Your Bookmarks") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } + .font(PaperType.label) + .tint(Paper.accent) } } } @@ -42,17 +45,19 @@ struct AskView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { if answer.isEmpty && !isLoading && errorText == nil { - ContentUnavailableView { - Label("Ask anything", systemImage: "sparkles") - } description: { - Text("Answers come from your saved bookmarks, generated on-device.") - } + PaperEmptyState( + title: "Ask anything", + systemImage: "sparkles", + message: "Answers come from your saved bookmarks, generated on-device." + ) .padding(.top, 40) } if isLoading { HStack(spacing: 8) { ProgressView() - Text("Searching your bookmarks…").foregroundStyle(.secondary) + Text("Searching your bookmarks…") + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } } if !answer.isEmpty { @@ -61,7 +66,9 @@ struct AskView: View { .frame(maxWidth: .infinity, alignment: .leading) } if let errorText { - Text(errorText).foregroundStyle(.red) + Text(errorText) + .font(PaperType.meta) + .foregroundStyle(Paper.alarm) } } .padding() @@ -69,17 +76,24 @@ struct AskView: View { HStack(spacing: 10) { TextField("Ask about your bookmarks…", text: $question, axis: .vertical) + .font(PaperType.body) + .foregroundStyle(Paper.ink) .lineLimit(1...4) .focused($focused) .submitLabel(.send) .onSubmit(send) Button(action: send) { - Image(systemName: "arrow.up.circle.fill").font(.title2) + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 26)) + .foregroundStyle(Paper.accent) } .disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading) } .padding() - .background(.bar) + .background(Paper.raised) + .overlay(alignment: .top) { + Rectangle().fill(Paper.rule.opacity(0.5)).frame(height: 0.6) + } } .onAppear { focused = true } } @@ -112,18 +126,18 @@ private struct MarkdownAnswer: View { private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) } var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in switch block { case .heading(let line): - inline(line).font(.headline) + inline(line).font(PaperType.heading) case .bullet(let line): HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("•").foregroundStyle(.secondary) - inline(line) + Text("•").foregroundStyle(Paper.tertiary) + inline(line).font(PaperType.body) } case .paragraph(let line): - inline(line) + inline(line).font(PaperType.body) } } } diff --git a/Marks/Views/BookmarkListRow.swift b/Marks/Views/BookmarkListRow.swift index e865d43..8ca675d 100644 --- a/Marks/Views/BookmarkListRow.swift +++ b/Marks/Views/BookmarkListRow.swift @@ -1,18 +1,11 @@ import SwiftUI -/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView. -/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet. +/// Full-featured list row used by BookmarksView and SearchView. Owns podcast +/// and episode-picker sheet state; parent owns the 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 @@ -21,13 +14,17 @@ struct BookmarkListRow: View { @State private var episodePickerBookmark: Bookmark? var body: some View { - row + LibraryListRow( + bookmark: bookmark, + readingProgress: readingProgress, + onPodcast: handlePodcast + ) .contentShape(Rectangle()) .onTapGesture { onOpen() } .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) + .listRowSeparatorTint(Paper.rule.opacity(0.5)) + .listRowBackground(Paper.sheet) // 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) { @@ -68,24 +65,6 @@ 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/BookmarkRow.swift b/Marks/Views/BookmarkRow.swift deleted file mode 100644 index 9f8f638..0000000 --- a/Marks/Views/BookmarkRow.swift +++ /dev/null @@ -1,170 +0,0 @@ -import SwiftUI - -struct BookmarkRow: View { - let bookmark: Bookmark - var readingProgress: Double = 0 - var onPodcast: (() -> Void)? = nil - - @State private var podcastTapCount = 0 - @State private var podcastCached = false - - /// 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 { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top, spacing: 11) { - FaviconView(url: bookmark.faviconUrl) - .overlay(alignment: .topLeading) { - if bookmark.unread { - Circle() - .fill(.blue) - .frame(width: 8, height: 8) - .offset(x: -3, y: -3) - } - } - .padding(.top, 1) - - VStack(alignment: .leading, spacing: 4) { - Text(bookmark.displayTitle) - .font(.headline) - .foregroundStyle(.primary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - - HStack(spacing: 4) { - Text(bookmark.domain) - Text("·") - Text(Self.relativeFormatter.localizedString(for: bookmark.dateAdded, relativeTo: Date())) - } - .font(.footnote) - .foregroundStyle(.secondary) - } - - Spacer() - - if let onPodcast { - Button { - podcastTapCount += 1 - onPodcast() - } label: { - Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle") - .font(.title3) - .foregroundStyle(podcastCached ? .blue : Color(.systemGray3)) - } - .buttonStyle(.plain) - .padding(.top, 1) - .sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount) - } - } - - if let excerpt = rowExcerpt { - Text(excerpt.text) - .font(.subheadline) - .italic(excerpt.isAI) - .foregroundStyle(.secondary) - .lineLimit(2) - .padding(.leading, 44) - } - - if !effectiveTags.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - GlassEffectContainer(spacing: 6) { - HStack(spacing: 6) { - ForEach(effectiveTags, id: \.self) { tag in - Text(tag) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .glassEffect(in: Capsule()) - } - } - } - } - .padding(.leading, 44) - } - - } - .padding(.vertical, 14) - .contentShape(Rectangle()) - .overlay(alignment: .bottom) { - if readingProgress > 0.02 { - GeometryReader { geo in - ZStack(alignment: .leading) { - Rectangle() - .fill(Color(.systemGray5)) - Rectangle() - .fill(Color(.systemGreen)) - .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 - } - } - - /// 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 } - } -} - -struct FaviconView: View { - let url: String? - - var body: some View { - Group { - if let urlString = url, let faviconUrl = URL(string: urlString) { - AsyncImage(url: faviconUrl) { phase in - switch phase { - case .success(let image): - image.resizable().scaledToFit() - default: - placeholder - } - } - } else { - placeholder - } - } - .frame(width: 32, height: 32) - .clipShape(RoundedRectangle(cornerRadius: 7)) - } - - private var placeholder: some View { - RoundedRectangle(cornerRadius: 6) - .fill(Color(.systemGray5)) - .overlay { - Image(systemName: "bookmark") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color(.systemGray2)) - } - } -} diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 4af771d..54bacf5 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -8,17 +8,17 @@ private struct SkeletonRow: View { var body: some View { HStack(alignment: .top, spacing: 11) { - RoundedRectangle(cornerRadius: 7) - .fill(Color(.systemGray5)) - .frame(width: 32, height: 32) + RoundedRectangle(cornerRadius: 3) + .fill(Paper.ink.opacity(0.09)) + .frame(width: 34, height: 46) VStack(alignment: .leading, spacing: 7) { Capsule() - .fill(Color(.systemGray5)) + .fill(Paper.ink.opacity(0.09)) .frame(maxWidth: .infinity) - .frame(height: 13) + .frame(height: 15) Capsule() - .fill(Color(.systemGray6)) - .frame(width: 140, height: 10) + .fill(Paper.ink.opacity(0.06)) + .frame(width: 140, height: 12) } .padding(.top, 4) Spacer() @@ -154,10 +154,10 @@ struct BookmarksView: View { } .overlay { if !viewModel.isLoading && viewModel.bookmarks.isEmpty { - ContentUnavailableView( - "No Bookmarks", + PaperEmptyState( + title: "No Bookmarks", systemImage: "bookmark", - description: Text("Bookmarks you save will appear here.") + message: "Bookmarks you save will appear here." ) .transition(.opacity) } @@ -248,15 +248,14 @@ struct BookmarksView: View { HStack(spacing: 10) { ProgressView(value: viewModel.enrichmentProgress) .progressViewStyle(.linear) - .tint(.primary) + .tint(Paper.ink) Text("Adding AI summaries…") - .font(.system(size: 13)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } .padding(.horizontal, 20) .padding(.vertical, 12) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .paperCard(cornerRadius: 12) .padding(.horizontal, 16) } @@ -265,15 +264,16 @@ struct BookmarksView: View { return HStack(spacing: 10) { Image(systemName: "waveform") .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) .symbolEffect(.variableColor.iterative, isActive: true) VStack(alignment: .leading, spacing: 2) { Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…") - .font(.system(size: 13, weight: .medium)) + .font(PaperType.stamp) + .foregroundStyle(Paper.ink) if let first = jobs.first { Text(first.title.isEmpty ? first.label : first.title) - .font(.system(size: 11)) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) .lineLimit(1) } } @@ -281,7 +281,7 @@ struct BookmarksView: View { if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 { ProgressView(value: progress) .progressViewStyle(.linear) - .tint(.blue) + .tint(Paper.accent) .frame(width: 44) } else { ProgressView() @@ -289,8 +289,7 @@ struct BookmarksView: View { } .padding(.horizontal, 16) .padding(.vertical, 10) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .paperCard(cornerRadius: 12) .padding(.horizontal, 16) } @@ -308,7 +307,6 @@ struct BookmarksView: View { bookmark: bookmark, viewModel: viewModel, readingProgress: readingProgress[bookmark.url] ?? 0, - style: .library, onOpen: { browsingBookmark = bookmark }, onEdit: { editingBookmark = bookmark } ) diff --git a/Marks/Views/BrowserView.swift b/Marks/Views/BrowserView.swift index 6204abd..b81e3d1 100644 --- a/Marks/Views/BrowserView.swift +++ b/Marks/Views/BrowserView.swift @@ -253,7 +253,7 @@ struct BrowserView: View { if state.readingProgress > 0.01 && state.readingProgress < 0.99 { GeometryReader { geo in Rectangle() - .fill(Color.blue.opacity(0.55)) + .fill(Paper.accent.opacity(0.55)) .frame(width: geo.size.width * state.readingProgress, height: 3) } .frame(height: 3) @@ -409,11 +409,11 @@ struct BrowserView: View { .contentShape(Rectangle()) } } - .font(.system(size: 17)) - .foregroundStyle(.primary) + .font(PaperType.label) + .foregroundStyle(Paper.ink) .padding(.horizontal, 12) .padding(.vertical, 4) - .background(.bar) + .background(Paper.raised) .overlay(alignment: .top) { Divider() } } } diff --git a/Marks/Views/CollectionsView.swift b/Marks/Views/CollectionsView.swift index 65ac644..a3075a8 100644 --- a/Marks/Views/CollectionsView.swift +++ b/Marks/Views/CollectionsView.swift @@ -11,41 +11,44 @@ struct CollectionsView: View { VStack(spacing: 16) { ProgressView() Text("Building smart collections…") - .font(.system(size: 15)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.smartCollections.isEmpty { - ContentUnavailableView( - "No Collections Yet", + PaperEmptyState( + title: "No Collections Yet", systemImage: "sparkles", - description: Text("Tap \"Smart Collections\" to group your bookmarks by topic.") + message: "Tap \"Smart Collections\" to group your bookmarks by topic." ) } else { List(viewModel.smartCollections) { collection in Section { let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) } ForEach(items) { bookmark in - BookmarkRow(bookmark: bookmark) + LibraryListRow(bookmark: bookmark) .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + .listRowBackground(Paper.sheet) + .listRowSeparatorTint(Paper.rule.opacity(0.5)) } } header: { VStack(alignment: .leading, spacing: 2) { Text(collection.name) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.primary) + .font(PaperType.heading) + .foregroundStyle(Paper.ink) if !collection.description.isEmpty { Text(collection.description) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) } } .padding(.vertical, 4) } } - .listStyle(.insetGrouped) + .listStyle(.plain) } } + .paperSurface() .navigationTitle("Smart Collections") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -59,6 +62,8 @@ struct CollectionsView: View { } ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } + .font(PaperType.label) + .tint(Paper.accent) } } } diff --git a/Marks/Views/EditBookmarkView.swift b/Marks/Views/EditBookmarkView.swift index 33430e2..1d5d3c9 100644 --- a/Marks/Views/EditBookmarkView.swift +++ b/Marks/Views/EditBookmarkView.swift @@ -27,44 +27,59 @@ struct EditBookmarkView: View { var body: some View { NavigationStack { Form { - Section("URL") { + Section { TextField("https://", text: $url) + .paperField() .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() + } header: { + Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } - Section("Title") { + Section { TextField("Optional", text: $title) + .paperField() + } header: { + Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } - Section("Description") { + Section { TextField("Optional", text: $description, axis: .vertical) + .paperField() .lineLimit(3...6) + } header: { + Text("Description").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("comma separated", text: $tagsText) + .paperField() .textInputAutocapitalization(.never) .autocorrectionDisabled() } header: { - Text("Tags") + Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } footer: { - Text("Separate tags with commas") + Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary) } Section { Toggle("Mark as unread", isOn: $unread) + .font(PaperType.label) + .foregroundStyle(Paper.ink) + .tint(Paper.accent) } if let error { Section { Text(error) - .foregroundStyle(.red) - .font(.footnote) + .font(PaperType.meta) + .foregroundStyle(Paper.alarm) } } } + .listRowBackground(Paper.raised) + .paperSurface() .navigationTitle("Edit Bookmark") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -73,6 +88,8 @@ struct EditBookmarkView: View { } ToolbarItem(placement: .confirmationAction) { Button("Save") { save() } + .font(PaperType.label) + .tint(Paper.accent) .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving) .overlay { if isSaving { ProgressView().scaleEffect(0.7) } diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index 7f41ce3..53d35ba 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -17,6 +17,29 @@ enum Paper { static let ink = dynamic(light: 0x14110C, dark: 0xF1ECE1) static var rule: Color { ink.opacity(0.28) } + /// Text weights, named by role so screens stop hand-picking opacities. + static var secondary: Color { ink.opacity(0.6) } + static var tertiary: Color { ink.opacity(0.45) } + static var faint: Color { ink.opacity(0.3) } + + /// A raised surface on the sheet — cards, fields, banners. Barely separated + /// from the ground on purpose: this palette does contrast with rules and + /// type, not with stacked greys. + static let raised = dynamic(light: 0xFFFDF8, dark: 0x201C16) + + /// The one non-palette color the design spends, for genuinely interactive + /// affordances (links, progress, selection). It is the palette's own blue + /// rather than the system blue, so it belongs to the paper. + static let accent = dynamic(light: 0x115AB5, dark: 0x5B8FD0) + + /// Destructive actions still need to read as dangerous; the vermilion + /// swatch does that without importing systemRed. + static let alarm = dynamic(light: 0xC03A18, dark: 0xE07A5C) + + /// "Done / played / complete." The forest swatch, so success still comes + /// out of the palette rather than from systemGreen. + static let affirm = dynamic(light: 0x2F6B34, dark: 0x6FA772) + /// Swatches lifted from the reference, each paired with a dark-mode /// counterpart. Order matters — items hash into it. /// @@ -73,6 +96,95 @@ enum Paper { } } +// MARK: Type + +/// The library's two voices. Prose — titles, summaries, anything a person +/// wrote — is serif. Everything the machine contributes — domains, dates, +/// counts, tags, labels, buttons — is monospaced. Keeping the split strict is +/// what makes the design read as archival rather than as decoration. +enum PaperType { + /// Screen titles that aren't the navigation bar's. + static let display = Font.system(size: 28, design: .serif) + static let title = Font.system(size: 21, design: .serif) + static let heading = Font.system(size: 18, weight: .medium, design: .serif) + static let body = Font.system(size: 18, design: .serif) + static let quote = Font.system(size: 16.5, design: .serif) + + /// Machine voice. + static let label = Font.system(size: 16.5, design: .monospaced) + static let meta = Font.system(size: 15, design: .monospaced) + static let micro = Font.system(size: 12, design: .monospaced) + /// Section headers and anything that wants to read as a stamp. + static let stamp = Font.system(size: 12, weight: .medium, design: .monospaced) +} + +// MARK: Surfaces + +extension View { + /// Puts a screen on the paper ground: clears the system list/scroll + /// background so the sheet shows through, and tints the bar to match so a + /// large title doesn't sit on a white strip above the content. + func paperSurface() -> some View { + self + .scrollContentBackground(.hidden) + .background(Paper.sheet.ignoresSafeArea()) + .toolbarBackground(Paper.sheet, for: .navigationBar) + } + + /// Editable text. Monospaced, because in this design anything you type is + /// data rather than prose — which is also what the reference's form screen + /// did with every field. + func paperField() -> some View { + self + .font(PaperType.label) + .foregroundStyle(Paper.ink) + .tint(Paper.accent) + } + + /// A raised block — settings groups, banners, editor fields. + func paperCard(cornerRadius: CGFloat = 8) -> some View { + self + .background( + RoundedRectangle(cornerRadius: cornerRadius).fill(Paper.raised) + ) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Paper.rule.opacity(0.5), lineWidth: 0.6) + ) + } +} + +// MARK: - Empty state + +/// The paper equivalent of `ContentUnavailableView`, which can't be restyled — +/// it draws its own bold system type and grey, which was the loudest remaining +/// system voice once every screen moved onto the sheet. +struct PaperEmptyState: View { + let title: String + let systemImage: String + var message: String? + + var body: some View { + VStack(spacing: 12) { + Image(systemName: systemImage) + .font(.system(size: 34, weight: .ultraLight)) + .foregroundStyle(Paper.faint) + Text(title) + .font(PaperType.title) + .foregroundStyle(Paper.secondary) + if let message { + Text(message) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: 280) + } + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + extension UIColor { convenience init(rgb: UInt32) { self.init( diff --git a/Marks/Views/Library/LibraryListRow.swift b/Marks/Views/Library/LibraryListRow.swift index de0e2fb..9036cfa 100644 --- a/Marks/Views/Library/LibraryListRow.swift +++ b/Marks/Views/Library/LibraryListRow.swift @@ -1,9 +1,10 @@ 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. +/// The library's list presentation, and now the app's only bookmark row. It +/// carries what the old system-styled row did — unread state, excerpt, tags, +/// podcast affordance, reading progress — in the paper vocabulary. The favicon +/// became the color chip, so the same swatch that identifies a card in the grid +/// identifies its row in the list. struct LibraryListRow: View { let bookmark: Bookmark var readingProgress: Double = 0 diff --git a/Marks/Views/OnboardingView.swift b/Marks/Views/OnboardingView.swift index 2df303e..eae00a2 100644 --- a/Marks/Views/OnboardingView.swift +++ b/Marks/Views/OnboardingView.swift @@ -17,10 +17,11 @@ struct OnboardingView: View { VStack(alignment: .leading, spacing: 8) { Text("Marks") - .font(.system(size: 42, weight: .semibold, design: .rounded)) + .font(.system(size: 42, design: .serif)) + .foregroundStyle(Paper.ink) Text("Your bookmarks, beautifully.") - .font(.system(size: 17)) - .foregroundStyle(.secondary) + .font(PaperType.body) + .foregroundStyle(Paper.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 28) @@ -29,8 +30,8 @@ struct OnboardingView: View { VStack(spacing: 14) { VStack(alignment: .leading, spacing: 6) { Text("Server URL") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) + .font(PaperType.stamp) + .foregroundStyle(Paper.tertiary) .padding(.horizontal, 2) TextField("https://links.example.com", text: $urlText) .textContentType(.URL) @@ -41,14 +42,15 @@ struct OnboardingView: View { .submitLabel(.next) .onSubmit { focused = .token } .padding(14) - .background(Color(.systemGray6)) + .paperField() + .background(Paper.raised) .clipShape(RoundedRectangle(cornerRadius: 12)) } VStack(alignment: .leading, spacing: 6) { Text("API Token") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) + .font(PaperType.stamp) + .foregroundStyle(Paper.tertiary) .padding(.horizontal, 2) SecureField("Paste your token", text: $token) .textContentType(.password) @@ -58,11 +60,12 @@ struct OnboardingView: View { .submitLabel(.done) .onSubmit { Task { await connect() } } .padding(14) - .background(Color(.systemGray6)) + .paperField() + .background(Paper.raised) .clipShape(RoundedRectangle(cornerRadius: 12)) Text("Find your token at Settings → API Token in Linkding.") - .font(.system(size: 12)) - .foregroundStyle(.tertiary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) .padding(.horizontal, 2) } } @@ -70,8 +73,8 @@ struct OnboardingView: View { if let err = errorMessage { Text(err) - .font(.system(size: 14)) - .foregroundStyle(.red) + .font(PaperType.meta) + .foregroundStyle(Paper.alarm) .padding(.top, 12) .padding(.horizontal, 28) } @@ -84,7 +87,7 @@ struct OnboardingView: View { ProgressView().tint(.white) } else { Text("Connect") - .font(.system(size: 17, weight: .semibold)) + .font(PaperType.label) } } .frame(maxWidth: .infinity) diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index 158eced..78a2483 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -510,6 +510,7 @@ struct PodcastPlayerView: View { Spacer() } .padding(.horizontal, 32) + .paperSurface() .navigationTitle("Podcast") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -520,7 +521,7 @@ struct PodcastPlayerView: View { ToolbarItem(placement: .topBarTrailing) { Button { vm.stop(); dismiss() } label: { Image(systemName: "stop.circle") - .foregroundStyle(.red) + .foregroundStyle(Paper.alarm) } } } @@ -556,16 +557,16 @@ struct PodcastPlayerView: View { VStack(spacing: 28) { Image(systemName: "waveform") .font(.system(size: 52)) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) .symbolEffect(.variableColor.iterative, isActive: true) VStack(spacing: 10) { ProgressView(value: progress) .progressViewStyle(.linear) - .tint(.blue) + .tint(Paper.accent) Text(label) - .font(.system(size: 14)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } } } @@ -573,16 +574,16 @@ struct PodcastPlayerView: View { private func playerView(title: String) -> some View { VStack(spacing: 28) { RoundedRectangle(cornerRadius: 20) - .fill(Color(.systemGray6)) + .fill(Paper.raised) .frame(width: 220, height: 220) .overlay { Image(systemName: "waveform.circle.fill") .font(.system(size: 80)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) } Text(title) - .font(.system(size: 18, weight: .semibold)) + .font(PaperType.heading) .multilineTextAlignment(.center) .lineLimit(3) @@ -591,32 +592,32 @@ struct PodcastPlayerView: View { value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }), in: 0...vm.duration ) - .tint(.primary) + .tint(Paper.ink) HStack { Text(formatTime(vm.currentTime)) Spacer() Text(formatTime(vm.duration)) } - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(.tertiary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } HStack(spacing: 36) { Button { vm.skipBackward15() } label: { Image(systemName: "gobackward.15") .font(.system(size: 32)) - .foregroundStyle(.primary) + .foregroundStyle(Paper.ink) } Button { vm.togglePlayPause() } label: { Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 72)) - .foregroundStyle(.primary) + .foregroundStyle(Paper.ink) } Button { vm.skipForward15() } label: { Image(systemName: "goforward.15") .font(.system(size: 32)) - .foregroundStyle(.primary) + .foregroundStyle(Paper.ink) } } @@ -636,8 +637,8 @@ struct PodcastPlayerView: View { } } label: { Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) .padding(.horizontal, 14) .padding(.vertical, 7) .glassEffect(in: Capsule()) @@ -652,7 +653,7 @@ struct PodcastPlayerView: View { } label: { Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon") .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(sleepActive ? Color.blue : .secondary) + .foregroundStyle(sleepActive ? Paper.accent : .secondary) .padding(.horizontal, 14) .padding(.vertical, 7) .glassEffect(in: Capsule()) @@ -676,7 +677,7 @@ struct PodcastPlayerView: View { ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) { Label("Share Episode", systemImage: "square.and.arrow.up") .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) } } } @@ -687,12 +688,12 @@ struct PodcastPlayerView: View { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 44)) - .foregroundStyle(.red) + .foregroundStyle(Paper.alarm) Text("Generation Failed") - .font(.headline) + .font(PaperType.heading) Text(message) - .font(.system(size: 14)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) .multilineTextAlignment(.center) Button { vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude) @@ -701,7 +702,7 @@ struct PodcastPlayerView: View { .font(.system(size: 15, weight: .semibold)) } .buttonStyle(.glassProminent) - .tint(.blue) + .tint(Paper.accent) .buttonBorderShape(.capsule) .padding(.top, 4) } @@ -724,18 +725,18 @@ struct MiniPlayerView: View { HStack(spacing: 14) { Image(systemName: "waveform") .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) .symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating) .frame(width: 22) VStack(alignment: .leading, spacing: 2) { Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.primary) + .font(PaperType.meta) + .foregroundStyle(Paper.ink) .lineLimit(1) Text(progressLabel) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.secondary) } Spacer() @@ -749,7 +750,7 @@ struct MiniPlayerView: View { } label: { Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 18)) - .foregroundStyle(.primary) + .foregroundStyle(Paper.ink) .frame(width: 36, height: 36) } .buttonStyle(.plain) @@ -760,20 +761,20 @@ struct MiniPlayerView: View { } label: { Image(systemName: "xmark") .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) .frame(width: 28, height: 28) } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 12) - .background(.regularMaterial) + .background(Paper.raised) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay(alignment: .bottom) { // Progress bar along bottom edge GeometryReader { geo in Rectangle() - .fill(Color.blue.opacity(0.6)) + .fill(Paper.accent.opacity(0.6)) .frame(width: geo.size.width * progressFraction, height: 3) } .frame(height: 3) @@ -819,10 +820,10 @@ struct PodcastLibraryView: View { NavigationStack { Group { if library.entries.isEmpty { - ContentUnavailableView( - "No Podcasts", + PaperEmptyState( + title: "No Podcasts", systemImage: "headphones", - description: Text("Podcasts you generate from bookmarks will appear here.") + message: "Podcasts you generate from bookmarks will appear here." ) } else { List { @@ -840,6 +841,7 @@ struct PodcastLibraryView: View { .listStyle(.insetGrouped) } } + .paperSurface() .navigationTitle("Podcasts") .toolbar { if !unplayed.isEmpty { @@ -888,18 +890,18 @@ struct PodcastLibraryView: View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(entry.title ?? entry.articleUrl) - .font(.system(size: 15, weight: .medium)) + .font(PaperType.quote) .lineLimit(2) .foregroundStyle(entry.isPlayed ? .secondary : .primary) Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.secondary) } Spacer() if entry.isPlayed { Image(systemName: "checkmark.circle.fill") .font(.system(size: 15)) - .foregroundStyle(.green) + .foregroundStyle(Paper.affirm) .accessibilityLabel("Played") } Button { @@ -907,7 +909,7 @@ struct PodcastLibraryView: View { } label: { Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 36)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) @@ -1005,17 +1007,17 @@ struct EpisodePickerView: View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(entry.title ?? entry.articleUrl) - .font(.system(size: 15, weight: .medium)) + .font(PaperType.quote) .lineLimit(2) if entry.articleUrl != bookmark.url { Text(entry.articleUrl) - .font(.system(size: 11)) - .foregroundStyle(.tertiary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) .lineLimit(1) } Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.secondary) } Spacer() Button { @@ -1026,7 +1028,7 @@ struct EpisodePickerView: View { } label: { Image(systemName: "play.circle.fill") .font(.system(size: 36)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) } .buttonStyle(.plain) } @@ -1095,21 +1097,21 @@ struct EpisodeDetailView: View { Section { VStack(alignment: .leading, spacing: 8) { Text(entry.title ?? entry.articleUrl) - .font(.system(size: 20, weight: .semibold)) + .font(PaperType.display) Label(domain, systemImage: "link") .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) .lineLimit(1) HStack(spacing: 12) { Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted), systemImage: "calendar") if isPlayed { Label("Played", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) + .foregroundStyle(Paper.affirm) } } .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) } .padding(.vertical, 4) } @@ -1118,7 +1120,7 @@ struct EpisodeDetailView: View { Section("Summary") { Text(summary) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) } } diff --git a/Marks/Views/SearchView.swift b/Marks/Views/SearchView.swift index c9805de..63f0ab3 100644 --- a/Marks/Views/SearchView.swift +++ b/Marks/Views/SearchView.swift @@ -21,6 +21,7 @@ struct SearchView: View { } } .listStyle(.plain) + .paperSurface() .navigationTitle("Search") .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -33,9 +34,9 @@ struct SearchView: View { } .overlay { if searchText.isEmpty { - ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag.")) + PaperEmptyState(title: "Search Bookmarks", systemImage: "magnifyingglass", message: "Search by title, URL, or tag.") } else if results.isEmpty && !viewModel.isLoading { - ContentUnavailableView.search(text: searchText) + PaperEmptyState(title: "No Results", systemImage: "magnifyingglass", message: "Nothing matches \u{201C}\(searchText)\u{201D}.") } if viewModel.isLoading { ProgressView() diff --git a/Marks/Views/SettingsView.swift b/Marks/Views/SettingsView.swift index 45e3649..d5137e9 100644 --- a/Marks/Views/SettingsView.swift +++ b/Marks/Views/SettingsView.swift @@ -11,14 +11,18 @@ struct SettingsView: View { var body: some View { NavigationStack { List { - Section("AI Features") { + Section { Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles") - .font(.system(size: 13)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) + } header: { + Text("AI Features").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("e.g. listen podcast", text: $autoTagText) + .font(PaperType.label) + .foregroundStyle(Paper.ink) .textInputAutocapitalization(.never) .autocorrectionDisabled() .onChange(of: autoTagText) { _, new in @@ -27,25 +31,35 @@ struct SettingsView: View { .map(String.init) } } header: { - Text("Auto-Podcast Tags") + Text("Auto-Podcast Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } footer: { Text("Saving a bookmark with any of these tags automatically generates a podcast for it.") + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) } - Section("Server") { - Button(role: .destructive) { + Section { + Button { dismiss() onDisconnect() } label: { Label("Disconnect", systemImage: "person.crop.circle.badge.minus") + .font(PaperType.label) + .foregroundStyle(Paper.alarm) } + } header: { + Text("Server").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } } + .listRowBackground(Paper.raised) + .paperSurface() .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } + .font(PaperType.label) + .tint(Paper.accent) } } } diff --git a/Marks/Views/SourcesView.swift b/Marks/Views/SourcesView.swift index 6b82cf5..462381a 100644 --- a/Marks/Views/SourcesView.swift +++ b/Marks/Views/SourcesView.swift @@ -35,8 +35,8 @@ struct SourcesView: View { playOrGeneratePodcast(for: source) } label: { Image(systemName: podcastIcon(for: source)) - .font(.title3) - .foregroundStyle(.blue) + .font(.system(size: 22)) + .foregroundStyle(Paper.accent) .frame(width: 36, height: 36) } .buttonStyle(.plain) @@ -48,7 +48,7 @@ struct SourcesView: View { } label: { Label("Podcast", systemImage: "headphones") } - .tint(.blue) + .tint(Paper.accent) Button(role: .destructive) { library.delete(source) @@ -59,6 +59,7 @@ struct SourcesView: View { } } .listStyle(.plain) + .paperSurface() .navigationTitle("Sources") .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -80,10 +81,12 @@ struct SourcesView: View { } .overlay { if results.isEmpty { - ContentUnavailableView( - searchText.isEmpty ? "No Sources" : "No Results", + PaperEmptyState( + title: searchText.isEmpty ? "No Sources" : "No Results", systemImage: searchText.isEmpty ? "tray" : "magnifyingglass", - description: Text(searchText.isEmpty ? "Import text, PDFs, or files to keep non-web material in Marks." : "Try a different search.") + message: searchText.isEmpty + ? "Import text, PDFs, or files to keep non-web material in Marks." + : "Try a different search." ) } } @@ -198,19 +201,20 @@ private struct SourceRow: View { var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: iconName) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.blue) + .font(PaperType.stamp) + .foregroundStyle(Paper.accent) .frame(width: 30, height: 30) - .background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) + .background(Paper.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) VStack(alignment: .leading, spacing: 5) { Text(source.displayTitle) - .font(.headline) + .font(PaperType.title) + .foregroundStyle(Paper.ink) .lineLimit(2) if !source.excerpt.isEmpty { Text(source.excerpt) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) .lineLimit(2) } HStack(spacing: 8) { @@ -220,8 +224,8 @@ private struct SourceRow: View { Text(source.tags.joined(separator: ", ")) } } - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } } .padding(.vertical, 8) @@ -265,6 +269,7 @@ private struct ImportTextSourceView: View { Text("Separate tags with commas") } } + .paperSurface() .navigationTitle("Import Text") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -329,6 +334,8 @@ private struct EditSourceView: View { Text("Separate tags with commas") } } + .listRowBackground(Paper.raised) + .paperSurface() .navigationTitle("Edit Source") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -363,10 +370,11 @@ private struct SourceDetailView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text(source.displayTitle) - .font(.title2.weight(.semibold)) + .font(PaperType.display) + .foregroundStyle(Paper.ink) Text(source.kind.label) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) } if !source.tags.isEmpty { @@ -374,10 +382,11 @@ private struct SourceDetailView: View { HStack { ForEach(source.tags, id: \.self) { tag in Text(tag) - .font(.caption.weight(.medium)) + .font(PaperType.micro) + .foregroundStyle(Paper.secondary) .padding(.horizontal, 10) .padding(.vertical, 5) - .background(Color.blue.opacity(0.12), in: Capsule()) + .background(Paper.accent.opacity(0.12), in: Capsule()) } } } @@ -411,12 +420,14 @@ private struct SourceDetailView: View { } Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText) - .font(.body) + .font(PaperType.body) + .foregroundStyle(Paper.ink) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } .padding() } + .paperSurface() .navigationTitle("Source") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Marks/Views/TagsView.swift b/Marks/Views/TagsView.swift index b77f343..c9fe6fb 100644 --- a/Marks/Views/TagsView.swift +++ b/Marks/Views/TagsView.swift @@ -40,14 +40,12 @@ struct TagsView: View { } .overlay { if visibleTags.isEmpty { - ContentUnavailableView( - query.isEmpty ? "No Tags" : "No Matching Tags", + PaperEmptyState( + title: query.isEmpty ? "No Tags" : "No Matching Tags", systemImage: "tag", - description: Text( - query.isEmpty + message: query.isEmpty ? "Tags from your bookmarks will appear here." : "No tag matches “\(query)”." - ) ) } } diff --git a/ShareExtension/ShareView.swift b/ShareExtension/ShareView.swift index a9c4758..9cc1031 100644 --- a/ShareExtension/ShareView.swift +++ b/ShareExtension/ShareView.swift @@ -65,7 +65,7 @@ struct ShareView: View { case .loading: HStack(spacing: 12) { ProgressView() - Text("Checking…").foregroundStyle(.secondary) + Text("Checking…").font(PaperType.meta).foregroundStyle(Paper.secondary) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 24) @@ -85,7 +85,11 @@ struct ShareView: View { } } .padding(20) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .background(Paper.sheet, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(Paper.rule.opacity(0.5), lineWidth: 0.6) + ) .shadow(color: .black.opacity(0.15), radius: 24, y: 8) } @@ -99,19 +103,21 @@ struct ShareView: View { ? "Already saved" : "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))") } icon: { - Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + Image(systemName: "checkmark.circle.fill").foregroundStyle(Paper.affirm) } - .font(.subheadline.weight(.semibold)) + .font(PaperType.label) + .foregroundStyle(Paper.ink) } else { - Text("Save bookmark").font(.headline) + Text("Save bookmark").font(PaperType.heading).foregroundStyle(Paper.ink) } Text(title.isEmpty ? url : title) - .font(.subheadline.weight(.medium)) + .font(PaperType.quote) + .foregroundStyle(Paper.ink) .lineLimit(2) Text(domain) - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } } @@ -128,8 +134,8 @@ struct ShareView: View { HStack(spacing: 6) { ProgressView().controlSize(.small) Text("Suggesting tags…") - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } } @@ -139,11 +145,11 @@ struct ShareView: View { ForEach(suggestionChips, id: \.tag) { item in Button { addTag(item.tag) } label: { Text(item.isAI ? "✨ \(item.tag)" : "+ \(item.tag)") - .font(.caption.weight(.medium)) + .font(PaperType.micro) .padding(.horizontal, 10) .padding(.vertical, 5) - .background((item.isAI ? Color.purple : .blue).opacity(0.12), in: Capsule()) - .foregroundStyle(item.isAI ? Color.purple : .blue) + .background((item.isAI ? Paper.accent : Paper.ink).opacity(0.1), in: Capsule()) + .foregroundStyle(item.isAI ? Paper.accent : Paper.secondary) } .buttonStyle(.plain) } @@ -157,12 +163,14 @@ struct ShareView: View { .textFieldStyle(.roundedBorder) Toggle("Read later", isOn: $readLater) - .font(.subheadline) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) Toggle(isOn: $createPodcast) { Label("Create podcast", systemImage: "headphones") } - .font(.subheadline) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } } @@ -189,8 +197,8 @@ struct ShareView: View { private func statusCard(icon: String, tint: Color, text: String) -> some View { HStack(spacing: 10) { - Image(systemName: icon).font(.title3).foregroundStyle(tint) - Text(text).font(.headline) + Image(systemName: icon).font(.system(size: 22)).foregroundStyle(tint) + Text(text).font(PaperType.heading).foregroundStyle(Paper.ink) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 16) diff --git a/project.yml b/project.yml index f03ab79..2be5962 100644 --- a/project.yml +++ b/project.yml @@ -93,6 +93,9 @@ targets: - path: Marks/Services/Log.swift # Cross-process podcast request queue: the extension enqueues, the app runs it. - path: Marks/Services/PodcastRequests.swift + # The save card is a user-facing surface, so it uses the same palette and + # type scale as the app. + - path: Marks/Views/Library/LibraryKit.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension From dd1c09a00c855730d8d00f58dc5dcce519aba2f5 Mon Sep 17 00:00:00 2001 From: Krishna Kumar <krish.kumar@gmail.com> Date: Mon, 27 Jul 2026 13:17:03 -0500 Subject: [PATCH 09/11] Set the navigation and tab bars in the library's type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen titles were still system bold sans — `navigationTitle` renders through UIKit, which no SwiftUI font modifier reaches, so paperSurface() could tint the bar but never restyle its text. PaperAppearance configures UINavigationBarAppearance once at launch: serif large and inline titles in ink, and monospaced tab bar labels. One trap worth recording: configuring that appearance with configureWithOpaqueBackground() makes iOS 26 stop laying out the large title entirely — it vanishes rather than restyling, and it does so even with the font attributes removed, so it reads like a font problem when it isn't. Transparent works, and is right here anyway: every screen already paints the paper ground itself. Verified in both schemes. Suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks/MarksApp.swift | 2 + Marks/Views/Library/LibraryKit.swift | 65 ++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index ed6a8e2..8b0555d 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -12,6 +12,8 @@ private let defaultConfig = ServerConfig( struct MarksApp: App { @State private var serverConfig: ServerConfig = ServerConfig.load() ?? defaultConfig + init() { PaperAppearance.apply() } + var body: some Scene { WindowGroup { MainContainer(config: serverConfig) { diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index 53d35ba..b5da6e8 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -83,11 +83,20 @@ enum Paper { } static func dynamic(light: UInt32, dark: UInt32) -> Color { - Color(uiColor: UIColor { traits in - UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light) - }) + Color(uiColor: dynamicUI(light: light, dark: dark)) } + static func dynamicUI(light: UInt32, dark: UInt32) -> UIColor { + UIColor { traits in + UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light) + } + } + + /// UIKit equivalents, for the chrome SwiftUI can't reach — chiefly the + /// navigation bar, whose title font has no SwiftUI API at all. + static let uiSheet = dynamicUI(light: 0xF8F5EF, dark: 0x15130F) + static let uiInk = dynamicUI(light: 0x14110C, dark: 0xF1ECE1) + static func luminance(of hex: UInt32) -> Double { let r = Double((hex >> 16) & 0xFF) / 255 let g = Double((hex >> 8) & 0xFF) / 255 @@ -154,6 +163,56 @@ extension View { } } +// MARK: - UIKit chrome + +/// The navigation and tab bars are UIKit underneath, and their fonts have no +/// SwiftUI equivalent — `navigationTitle` will render in the system bold sans +/// whatever you do to the view. Both are configured once at launch so screen +/// titles speak the same serif as the content beneath them. +enum PaperAppearance { + static func apply() { + let bar = UINavigationBarAppearance() + // Transparent, not opaque: the screens already paint the paper ground + // themselves, and an opaque bar config stops iOS 26 laying out the + // large title at all. + bar.configureWithTransparentBackground() + bar.largeTitleTextAttributes = [ + .font: serif(34, weight: .regular), + .foregroundColor: Paper.uiInk, + ] + bar.titleTextAttributes = [ + .font: serif(17, weight: .medium), + .foregroundColor: Paper.uiInk, + ] + UINavigationBar.appearance().standardAppearance = bar + UINavigationBar.appearance().compactAppearance = bar + UINavigationBar.appearance().scrollEdgeAppearance = bar + + let tabs = UITabBarAppearance() + tabs.configureWithDefaultBackground() + let label: [NSAttributedString.Key: Any] = [ + .font: UIFont.monospacedSystemFont(ofSize: 10, weight: .medium) + ] + for item in [tabs.stackedLayoutAppearance, + tabs.inlineLayoutAppearance, + tabs.compactInlineLayoutAppearance] { + item.normal.titleTextAttributes = label + item.selected.titleTextAttributes = label + } + UITabBar.appearance().standardAppearance = tabs + UITabBar.appearance().scrollEdgeAppearance = tabs + } + + /// `withDesign(.serif)` is the only route to the system serif in UIKit, and + /// it returns nil if the design is unavailable — fall back rather than + /// force-unwrap a font. + private static func serif(_ size: CGFloat, weight: UIFont.Weight) -> UIFont { + let base = UIFont.systemFont(ofSize: size, weight: weight) + guard let descriptor = base.fontDescriptor.withDesign(.serif) else { return base } + return UIFont(descriptor: descriptor, size: size) + } +} + // MARK: - Empty state /// The paper equivalent of `ContentUnavailableView`, which can't be restyled — From 76df237473bf3dc0b8941ca7e747c192ad1d9e81 Mon Sep 17 00:00:00 2001 From: Krishna Kumar <krish.kumar@gmail.com> Date: Mon, 27 Jul 2026 14:04:22 -0500 Subject: [PATCH 10/11] Stop paperSurface() undoing the serif navigation titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the bookmarks screen had picked up the serif title. Every other screen — Search, Sources, Podcasts, Settings, Ask, the sheets — was still system bold sans, and the reason was paperSurface(): setting .toolbarBackground makes SwiftUI build a fresh UINavigationBarAppearance and discard the one PaperAppearance installed, text attributes included. Bookmarks was the only screen not using the helper, which is why it alone looked right. The modifier no longer sets a toolbar background. It doesn't need one — the bar is transparent by appearance and the screen already paints the paper ground beneath it. Also labels the bookmarks toolbar buttons (Settings, Add bookmark, Unread filter, AI actions), which were bare SF Symbols announcing nothing to VoiceOver. Verified in both schemes: large titles on Bookmarks/Sources/Podcasts/ Search and inline titles on the Settings and Ask sheets all render serif, on paper grounds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks/Views/BookmarksView.swift | 4 ++++ Marks/Views/Library/LibraryKit.swift | 11 ++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 54bacf5..3427c91 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -122,6 +122,7 @@ struct BookmarksView: View { } label: { Image(systemName: "sparkles") } + .accessibilityLabel("AI actions") } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 16) { @@ -138,6 +139,7 @@ struct BookmarksView: View { Button { showAddBookmark = true } label: { Image(systemName: "plus") } + .accessibilityLabel("Add bookmark") Button { Task { await viewModel.toggleUnreadFilter() } } label: { @@ -146,9 +148,11 @@ struct BookmarksView: View { : "line.3.horizontal.decrease.circle") .contentTransition(.symbolEffect(.replace)) } + .accessibilityLabel("Unread filter") Button { showSettings = true } label: { Image(systemName: "gearshape") } + .accessibilityLabel("Settings") } } } diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index b5da6e8..43e89d6 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -131,13 +131,18 @@ enum PaperType { extension View { /// Puts a screen on the paper ground: clears the system list/scroll - /// background so the sheet shows through, and tints the bar to match so a - /// large title doesn't sit on a white strip above the content. + /// background so the sheet shows through, and lets the paper reach past the + /// safe area so the bar sits on it. + /// + /// Deliberately no `.toolbarBackground` — setting it makes SwiftUI build a + /// fresh UINavigationBarAppearance and throw away the one PaperAppearance + /// installed, which silently reverted these screens' titles to the system + /// bold sans. The bar is transparent by appearance, so the background + /// below is all it needs. func paperSurface() -> some View { self .scrollContentBackground(.hidden) .background(Paper.sheet.ignoresSafeArea()) - .toolbarBackground(Paper.sheet, for: .navigationBar) } /// Editable text. Monospaced, because in this design anything you type is From 69556afc08da150f62ea7231b89467c28de23622 Mon Sep 17 00:00:00 2001 From: Krishna Kumar <krish.kumar@gmail.com> Date: Mon, 27 Jul 2026 19:12:25 -0500 Subject: [PATCH 11/11] Fix Spotlight indexing: route the entity URL to contentURL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every bookmark failed to reach the Spotlight index. Each item died in translation with "Provided object for field url is of class NSURL, expected class: NSString", so on-device search and Ask Your Bookmarks were retrieving from an index that was effectively empty. Left to itself, App Intents indexes BookmarkEntity's `url` property under the attribute set's own `url` key, which Spotlight's Cascade translator types as NSString. Giving the property an explicit `indexingKey: \.contentURL` sends it to a URL-typed field instead — and contentURL is the right field for "where this content lives" regardless. Keeping the property a URL rather than retyping it to String means existing Shortcuts that read it are unaffected. The attribute set now sets contentURL too, and the retrieval side reads it back, so the round trip stays on one field. Why it went unnoticed: translation happens after `indexAppEntities` returns, so indexing logged success the whole time. Measured on the simulator against the live library — 202 translation failures per launch before, 0 after. Adds SpotlightRetrievalTests, which indexes a bookmark and retrieves it through the assistant's own path. Nothing weaker would have caught this, since the failure was silent at every layer above the index. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks.xcodeproj/project.pbxproj | 4 ++ Marks/Intents/BookmarkEntity.swift | 14 ++++++- Marks/Services/SpotlightBookmarkSearch.swift | 6 +-- MarksTests/SpotlightRetrievalTests.swift | 40 ++++++++++++++++++++ 4 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 MarksTests/SpotlightRetrievalTests.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index c90b335..9974c22 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7510EB352E624C7C9656EA33 /* LibraryView.swift */; }; 55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */; }; + 59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; 66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; }; @@ -136,6 +137,7 @@ 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkActions.swift; sourceTree = "<group>"; }; + 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightRetrievalTests.swift; sourceTree = "<group>"; }; 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = "<group>"; }; 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; }; 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; }; @@ -244,6 +246,7 @@ isa = PBXGroup; children = ( 7623601C25E481DF58371F2A /* AppIntentsTests.swift */, + 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */, ); path = MarksTests; sourceTree = "<group>"; @@ -528,6 +531,7 @@ buildActionMask = 2147483647; files = ( FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */, + 59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Marks/Intents/BookmarkEntity.swift b/Marks/Intents/BookmarkEntity.swift index 94f9164..f619805 100644 --- a/Marks/Intents/BookmarkEntity.swift +++ b/Marks/Intents/BookmarkEntity.swift @@ -17,7 +17,17 @@ struct BookmarkEntity: AppEntity, IndexedEntity { @Property(title: "Title") var title: String - @Property(title: "URL") + /// The explicit `indexingKey` is load-bearing. Left to itself App Intents + /// indexes this property under the attribute set's own `url` key, and + /// Spotlight's Cascade translator types that field as NSString — so every + /// item failed with "Provided object for field url is of class NSURL, + /// expected class: NSString" and nothing reached the index. `contentURL` + /// is URL-typed there, and is the right field for "where this lives" + /// anyway. Routing it there keeps the property a URL for Shortcuts. + /// + /// The failure is silent: translation happens after `indexAppEntities` + /// returns, so indexing logs success either way. + @Property(title: "URL", indexingKey: \.contentURL) var url: URL @Property(title: "Website") @@ -53,7 +63,7 @@ struct BookmarkEntity: AppEntity, IndexedEntity { attrs.title = title attrs.contentDescription = details.isEmpty ? summary : details attrs.keywords = tags - attrs.url = url + attrs.contentURL = url return attrs } } diff --git a/Marks/Services/SpotlightBookmarkSearch.swift b/Marks/Services/SpotlightBookmarkSearch.swift index bb55a6d..2e50766 100644 --- a/Marks/Services/SpotlightBookmarkSearch.swift +++ b/Marks/Services/SpotlightBookmarkSearch.swift @@ -24,7 +24,7 @@ enum SpotlightBookmarkSearch { Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)") let context = CSSearchQueryContext() - context.fetchAttributes = ["title", "contentDescription", "keywords", "url"] + context.fetchAttributes = ["title", "contentDescription", "keywords", "contentURL"] let query = CSSearchQuery(queryString: queryString, queryContext: context) var out: [RetrievedBookmark] = [] @@ -33,9 +33,9 @@ enum SpotlightBookmarkSearch { let a = result.item.attributeSet out.append(RetrievedBookmark( title: a.title ?? "Untitled", - host: a.url?.host() ?? "", + host: a.contentURL?.host() ?? "", description: a.contentDescription ?? "", - url: a.url?.absoluteString ?? "" + url: a.contentURL?.absoluteString ?? "" )) if out.count >= limit { break } } diff --git a/MarksTests/SpotlightRetrievalTests.swift b/MarksTests/SpotlightRetrievalTests.swift new file mode 100644 index 0000000..9413564 --- /dev/null +++ b/MarksTests/SpotlightRetrievalTests.swift @@ -0,0 +1,40 @@ +import Testing +import CoreSpotlight +@testable import Marks + +/// End-to-end guard for the whole Spotlight path: index a bookmark, then +/// retrieve it the way the on-device assistant does. +/// +/// Worth keeping. The bug this was written for made every item fail to +/// translate into the index while `indexAppEntities` still reported success, +/// so nothing short of a round trip would have caught it. +struct SpotlightRetrievalTests { + @Test func indexedBookmarkIsRetrievable() async throws { + let b = Bookmark( + id: 999_001, + url: "https://github.com/example/zqxjkltest", + title: "Zqxjkltest concurrency notes", + description: "A distinctive probe document about zqxjkltest.", + tagNames: ["zqxjkltest"], + dateAdded: Date(), dateModified: Date(), + isArchived: false, unread: false, shared: false, + websiteTitle: nil, websiteDescription: nil, + faviconUrl: nil, previewImageUrl: nil, + aiSummary: nil, aiTags: nil + ) + await SpotlightIndexer.index([b]) + var hits: [RetrievedBookmark] = [] + for _ in 0..<20 { + try? await Task.sleep(for: .milliseconds(700)) + hits = await SpotlightBookmarkSearch.run(query: "zqxjkltest", limit: 5) + if !hits.isEmpty { break } + } + await SpotlightIndexer.remove(ids: [999_001]) + #expect(!hits.isEmpty, "Spotlight returned no hits for an indexed bookmark") + let hit = try #require(hits.first) + #expect(hit.title.contains("Zqxjkltest")) + #expect(hit.url == "https://github.com/example/zqxjkltest", + "url did not survive the round trip: \(hit.url)") + #expect(hit.host == "github.com", "host was \(hit.host)") + } +}