Files
linkding-ios/Marks/Views/Library/LibraryKit.swift
T
Krishna KumarandClaude Opus 5 76df237473
CI / build-and-deploy (pull_request) Failing after 2s
CI / build-and-deploy (push) Successful in 32s
Stop paperSurface() undoing the serif navigation titles
Only the bookmarks screen had picked up the serif title. Every other
screen — Search, Sources, Podcasts, Settings, Ask, the sheets — was still
system bold sans, and the reason was paperSurface(): setting
.toolbarBackground makes SwiftUI build a fresh UINavigationBarAppearance
and discard the one PaperAppearance installed, text attributes included.
Bookmarks was the only screen not using the helper, which is why it alone
looked right.

The modifier no longer sets a toolbar background. It doesn't need one —
the bar is transparent by appearance and the screen already paints the
paper ground beneath it.

Also labels the bookmarks toolbar buttons (Settings, Add bookmark, Unread
filter, AI actions), which were bare SF Symbols announcing nothing to
VoiceOver.

Verified in both schemes: large titles on Bookmarks/Sources/Podcasts/
Search and inline titles on the Settings and Ask sheets all render serif,
on paper grounds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-27 14:04:22 -05:00

645 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) }
/// Text weights, named by role so screens stop hand-picking opacities.
static var secondary: Color { ink.opacity(0.6) }
static var tertiary: Color { ink.opacity(0.45) }
static var faint: Color { ink.opacity(0.3) }
/// A raised surface on the sheet — cards, fields, banners. Barely separated
/// from the ground on purpose: this palette does contrast with rules and
/// type, not with stacked greys.
static let raised = dynamic(light: 0xFFFDF8, dark: 0x201C16)
/// The one non-palette color the design spends, for genuinely interactive
/// affordances (links, progress, selection). It is the palette's own blue
/// rather than the system blue, so it belongs to the paper.
static let accent = dynamic(light: 0x115AB5, dark: 0x5B8FD0)
/// Destructive actions still need to read as dangerous; the vermilion
/// swatch does that without importing systemRed.
static let alarm = dynamic(light: 0xC03A18, dark: 0xE07A5C)
/// "Done / played / complete." The forest swatch, so success still comes
/// out of the palette rather than from systemGreen.
static let affirm = dynamic(light: 0x2F6B34, dark: 0x6FA772)
/// 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: dynamicUI(light: light, dark: dark))
}
static func dynamicUI(light: UInt32, dark: UInt32) -> UIColor {
UIColor { traits in
UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light)
}
}
/// UIKit equivalents, for the chrome SwiftUI can't reach — chiefly the
/// navigation bar, whose title font has no SwiftUI API at all.
static let uiSheet = dynamicUI(light: 0xF8F5EF, dark: 0x15130F)
static let uiInk = dynamicUI(light: 0x14110C, dark: 0xF1ECE1)
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
}
}
// MARK: Type
/// The library's two voices. Prose — titles, summaries, anything a person
/// wrote — is serif. Everything the machine contributes — domains, dates,
/// counts, tags, labels, buttons — is monospaced. Keeping the split strict is
/// what makes the design read as archival rather than as decoration.
enum PaperType {
/// Screen titles that aren't the navigation bar's.
static let display = Font.system(size: 28, design: .serif)
static let title = Font.system(size: 21, design: .serif)
static let heading = Font.system(size: 18, weight: .medium, design: .serif)
static let body = Font.system(size: 18, design: .serif)
static let quote = Font.system(size: 16.5, design: .serif)
/// Machine voice.
static let label = Font.system(size: 16.5, design: .monospaced)
static let meta = Font.system(size: 15, design: .monospaced)
static let micro = Font.system(size: 12, design: .monospaced)
/// Section headers and anything that wants to read as a stamp.
static let stamp = Font.system(size: 12, weight: .medium, design: .monospaced)
}
// MARK: Surfaces
extension View {
/// Puts a screen on the paper ground: clears the system list/scroll
/// background so the sheet shows through, and lets the paper reach past the
/// safe area so the bar sits on it.
///
/// Deliberately no `.toolbarBackground` — setting it makes SwiftUI build a
/// fresh UINavigationBarAppearance and throw away the one PaperAppearance
/// installed, which silently reverted these screens' titles to the system
/// bold sans. The bar is transparent by appearance, so the background
/// below is all it needs.
func paperSurface() -> some View {
self
.scrollContentBackground(.hidden)
.background(Paper.sheet.ignoresSafeArea())
}
/// Editable text. Monospaced, because in this design anything you type is
/// data rather than prose — which is also what the reference's form screen
/// did with every field.
func paperField() -> some View {
self
.font(PaperType.label)
.foregroundStyle(Paper.ink)
.tint(Paper.accent)
}
/// A raised block — settings groups, banners, editor fields.
func paperCard(cornerRadius: CGFloat = 8) -> some View {
self
.background(
RoundedRectangle(cornerRadius: cornerRadius).fill(Paper.raised)
)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(Paper.rule.opacity(0.5), lineWidth: 0.6)
)
}
}
// MARK: - UIKit chrome
/// The navigation and tab bars are UIKit underneath, and their fonts have no
/// SwiftUI equivalent — `navigationTitle` will render in the system bold sans
/// whatever you do to the view. Both are configured once at launch so screen
/// titles speak the same serif as the content beneath them.
enum PaperAppearance {
static func apply() {
let bar = UINavigationBarAppearance()
// Transparent, not opaque: the screens already paint the paper ground
// themselves, and an opaque bar config stops iOS 26 laying out the
// large title at all.
bar.configureWithTransparentBackground()
bar.largeTitleTextAttributes = [
.font: serif(34, weight: .regular),
.foregroundColor: Paper.uiInk,
]
bar.titleTextAttributes = [
.font: serif(17, weight: .medium),
.foregroundColor: Paper.uiInk,
]
UINavigationBar.appearance().standardAppearance = bar
UINavigationBar.appearance().compactAppearance = bar
UINavigationBar.appearance().scrollEdgeAppearance = bar
let tabs = UITabBarAppearance()
tabs.configureWithDefaultBackground()
let label: [NSAttributedString.Key: Any] = [
.font: UIFont.monospacedSystemFont(ofSize: 10, weight: .medium)
]
for item in [tabs.stackedLayoutAppearance,
tabs.inlineLayoutAppearance,
tabs.compactInlineLayoutAppearance] {
item.normal.titleTextAttributes = label
item.selected.titleTextAttributes = label
}
UITabBar.appearance().standardAppearance = tabs
UITabBar.appearance().scrollEdgeAppearance = tabs
}
/// `withDesign(.serif)` is the only route to the system serif in UIKit, and
/// it returns nil if the design is unavailable — fall back rather than
/// force-unwrap a font.
private static func serif(_ size: CGFloat, weight: UIFont.Weight) -> UIFont {
let base = UIFont.systemFont(ofSize: size, weight: weight)
guard let descriptor = base.fontDescriptor.withDesign(.serif) else { return base }
return UIFont(descriptor: descriptor, size: size)
}
}
// MARK: - Empty state
/// The paper equivalent of `ContentUnavailableView`, which can't be restyled —
/// it draws its own bold system type and grey, which was the loudest remaining
/// system voice once every screen moved onto the sheet.
struct PaperEmptyState: View {
let title: String
let systemImage: String
var message: String?
var body: some View {
VStack(spacing: 12) {
Image(systemName: systemImage)
.font(.system(size: 34, weight: .ultraLight))
.foregroundStyle(Paper.faint)
Text(title)
.font(PaperType.title)
.foregroundStyle(Paper.secondary)
if let message {
Text(message)
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: 280)
}
}
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
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 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
}
}