Library design: cards + redesigned list on the bookmarks screen, tag picker sheet #9

Merged
admin merged 11 commits from feat/library-browse-prototype into master 2026-07-30 04:03:03 +00:00
Showing only changes of commit 6a9b6f193f - Show all commits
+629
View File
@@ -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 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 ("/sharefo/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"
}
}
}
// 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)
}