Add Library browse-screen prototype
CI / build-and-deploy (push) Successful in 52s
CI / build-and-deploy (pull_request) Successful in 22s

An unreferenced SwiftUI prototype of a paper/serif/monospace browse screen
for bookmarks: color blocks instead of thumbnails, a browser-tab strip of
saved tag filters, and cards/list modes that morph between each other via
matchedGeometryEffect. Nothing in the app links to it yet.

Built against the real linkding library (300 bookmarks), which is what
shaped it:

- No bookmark has a preview_image_url, so there is nothing to put in a
  thumbnail grid. Color blocks are the right primitive for this data.
- date_added is 2025 or 2026 for every bookmark, so the reference design's
  year stamp carries no signal. The domain takes that slot instead,
  reduced to its registered form because 92 of 300 hosts overflow the
  card's one monospaced line.
- Real titles run 3x longer than the design assumes (median 66 chars,
  p90 159, max 332) and are mostly "name: what it does". Cards show the
  name, the list carries the whole title.
- 15 bookmarks have no scraped title at all and fall back to the raw URL;
  those render unquoted with the path in the stamp slot.
- Color is hashed from the primary tag, not the domain: 128 of 300 are
  github.com, which would paint half the library one color.

Both color schemes are first-class. The dark palette is not the light one
dimmed uniformly - pale swatches drop a long way so they do not glare and
dark swatches come up so they do not vanish, converging on a mid band that
keeps all twelve distinguishable in either scheme.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
This commit is contained in:
Krishna Kumar
2026-07-26 01:30:30 -05:00
co-authored by Claude Opus 5
parent 8e1d94a4ea
commit 6a9b6f193f
+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)
}