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[.. 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 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 } // 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 } } // 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: 8) { Text(item.cardDisplay) .font(.system(size: 16.5, design: .serif)) .foregroundStyle(item.inkOnSwatch) .multilineTextAlignment(.leading) .lineLimit(5) .minimumScaleFactor(0.8) Spacer(minLength: 4) Text(item.stamp) .font(.system(size: 16.5, design: .monospaced)) .foregroundStyle(item.inkOnSwatch) .lineLimit(1) .truncationMode(.middle) } .padding(13) .frame(maxWidth: .infinity, alignment: .topLeading) .aspectRatio(0.82, 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? 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 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) } 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) } .padding(.horizontal, 18) } } .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: 16.5, 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: 12, weight: .medium)) .foregroundStyle(Paper.ink.opacity(active ? 0.5 : 0.25)) } .buttonStyle(.plain) } } .padding(.horizontal, 15) .frame(height: 42) .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 close(_ filter: LibraryFilter) { filters.removeAll { $0.id == filter.id } if selection == filter.id { selection = filters.first?.id onChange() } } } // 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 { 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 } }