Files
linkding-ios/Marks/Views/Library/LibraryKit.swift
T
Krishna KumarandClaude Opus 5 3c7a783b6c
CI / build-and-deploy (pull_request) Successful in 20s
CI / build-and-deploy (push) Successful in 21s
Fix tap-to-open on cards, and move the list to the library design
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
2026-07-26 01:49:15 -05:00

400 lines
16 KiB
Swift
Raw Blame History

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