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
469 lines
18 KiB
Swift
469 lines
18 KiB
Swift
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 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<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: 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
|
||
}
|
||
}
|