diff --git a/Marks/Views/Prototypes/LibraryView.swift b/Marks/Views/Prototypes/LibraryView.swift new file mode 100644 index 0000000..bd4a759 --- /dev/null +++ b/Marks/Views/Prototypes/LibraryView.swift @@ -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 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 + } +} + +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) +}