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 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 } } // 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 } }