From fffb3999bf1555b25c9aa53075bb7415592e6077 Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Mon, 27 Jul 2026 12:50:45 -0500 Subject: [PATCH] Carry the library design across the rest of the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every screen now speaks the paper vocabulary rather than only the bookmarks screen: Search, Sources, Podcasts and the player, Settings, Add/Edit, Smart Collections, Ask, Onboarding, the browser toolbar, the Siri snippets, and the share extension's save card. The change is mostly a design system rather than per-screen tweaks. LibraryKit gains: - Semantic colors — secondary/tertiary/faint text, `raised` surfaces, and accent/alarm/affirm, so screens stop reaching for .secondary, systemGray, .blue, .red and .green. The three status colors come out of the palette (the swatch blue, vermilion and forest) rather than from the system set, so the design keeps spending one set of inks. - PaperType — the two voices made explicit. Prose (titles, summaries, anything a person wrote) is serif; anything the machine contributes (domains, dates, counts, tags, labels, buttons) is monospaced. Keeping that split strict is what makes the design read as archival rather than as decoration. - paperSurface() / paperField() / paperCard() for grounds, inputs and raised blocks. - PaperEmptyState, because ContentUnavailableView can't be restyled — it draws its own bold system type and grey, which was the loudest remaining system voice once the screens moved onto the sheet. All nine usages are converted. The app also sets one accent for the system chrome it doesn't draw — tab bar, search fields, switches, swipe actions — which otherwise stayed system blue around paper screens. BookmarkRow is deleted. Once Search moved to the library row and TagBookmarksView was gone, nothing passed .classic, so the style fork in BookmarkListRow went with it. There is one bookmark row now. The share extension compiles LibraryKit directly, since its save card is a user-facing surface and should not be the one place still using system styling. Verified on device in both color schemes; screens toured with a temporary UI test harness (removed). Suite passes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM --- Marks.xcodeproj/project.pbxproj | 6 +- Marks/Intents/IntentSnippetViews.swift | 24 ++-- Marks/MarksApp.swift | 4 + Marks/Views/AddBookmarkView.swift | 33 +++-- Marks/Views/AskView.swift | 48 ++++--- Marks/Views/BookmarkListRow.swift | 39 ++---- Marks/Views/BookmarkRow.swift | 170 ----------------------- Marks/Views/BookmarksView.swift | 44 +++--- Marks/Views/BrowserView.swift | 8 +- Marks/Views/CollectionsView.swift | 27 ++-- Marks/Views/EditBookmarkView.swift | 31 ++++- Marks/Views/Library/LibraryKit.swift | 112 +++++++++++++++ Marks/Views/Library/LibraryListRow.swift | 9 +- Marks/Views/OnboardingView.swift | 31 +++-- Marks/Views/PodcastPlayerView.swift | 104 +++++++------- Marks/Views/SearchView.swift | 5 +- Marks/Views/SettingsView.swift | 26 +++- Marks/Views/SourcesView.swift | 51 ++++--- Marks/Views/TagsView.swift | 8 +- ShareExtension/ShareView.swift | 42 +++--- project.yml | 3 + 21 files changed, 418 insertions(+), 407 deletions(-) delete mode 100644 Marks/Views/BookmarkRow.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index cdaa114..c90b335 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -50,8 +50,8 @@ 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; }; 94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; }; 95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB1DA808EE8041C4546DAAB /* SourcesView.swift */; }; - 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; }; 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; }; + A30907113FD5D682478750A7 /* LibraryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */; }; A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; }; A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; }; AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */; }; @@ -165,7 +165,6 @@ AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = ""; }; AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; }; ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; - B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = ""; }; B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = ""; }; BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTool.swift; sourceTree = ""; }; @@ -335,7 +334,6 @@ 217E6702DE1210AC38ED16D1 /* AskView.swift */, 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */, CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */, - B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */, 629C41E0BC28EB6359D50CFD /* BrowserView.swift */, @@ -539,6 +537,7 @@ files = ( 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */, 70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */, + A30907113FD5D682478750A7 /* LibraryKit.swift in Sources */, 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */, B085CDBFC47F0357D1A28911 /* Log.swift in Sources */, B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */, @@ -576,7 +575,6 @@ FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */, CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */, 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */, - 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, 44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, diff --git a/Marks/Intents/IntentSnippetViews.swift b/Marks/Intents/IntentSnippetViews.swift index 120e0fe..7f05de0 100644 --- a/Marks/Intents/IntentSnippetViews.swift +++ b/Marks/Intents/IntentSnippetViews.swift @@ -8,18 +8,19 @@ struct BookmarkSnippetView: View { HStack(spacing: 12) { Image(systemName: "bookmark.fill") .font(.title2) - .foregroundStyle(.tint) + .foregroundStyle(Paper.accent) VStack(alignment: .leading, spacing: 3) { Text(entity.title) - .font(.headline) + .font(PaperType.heading) + .foregroundStyle(Paper.ink) .lineLimit(2) Text(entity.host) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) if !entity.tags.isEmpty { Text(entity.tags.map { "#\($0)" }.joined(separator: " ")) - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) .lineLimit(1) } } @@ -38,14 +39,15 @@ struct SummarySnippetView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { - Image(systemName: "sparkles").foregroundStyle(.tint) - Text(title).font(.headline).lineLimit(2) + Image(systemName: "sparkles").foregroundStyle(Paper.accent) + Text(title).font(PaperType.heading).foregroundStyle(Paper.ink).lineLimit(2) } Text(host) - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) Text(summary) - .font(.body) + .font(PaperType.body) + .foregroundStyle(Paper.ink) .fixedSize(horizontal: false, vertical: true) } .padding() diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index 929e545..ed6a8e2 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -70,6 +70,10 @@ struct MainContainer: View { SearchView(viewModel: viewModel) } } + // One accent for every system control the app doesn't draw itself — + // tab bar selection, search fields, switches, swipe actions. Without + // this the paper screens sit inside system-blue chrome. + .tint(Paper.accent) .onOpenURL { url in handleDeepLink(url) } diff --git a/Marks/Views/AddBookmarkView.swift b/Marks/Views/AddBookmarkView.swift index 3a8ef46..22cc87c 100644 --- a/Marks/Views/AddBookmarkView.swift +++ b/Marks/Views/AddBookmarkView.swift @@ -18,6 +18,7 @@ struct AddBookmarkView: View { Form { Section { TextEditor(text: $importText) + .paperField() .frame(minHeight: 96) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -27,6 +28,8 @@ struct AddBookmarkView: View { extractImportText() } label: { Label("Extract Link", systemImage: "link.badge.plus") + .font(PaperType.label) + .foregroundStyle(Paper.accent) } .disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) @@ -34,56 +37,62 @@ struct AddBookmarkView: View { if let importMessage { Text(importMessage) - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } } } header: { - Text("Import Text") + Text("Import Text").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } footer: { - Text("Paste an email, newsletter, or message and Marks will pull out the first web link.") + Text("Paste an email, newsletter, or message and Marks will pull out the first web link.").font(PaperType.micro).foregroundStyle(Paper.tertiary) } Section { TextField("https://", text: $url) + .paperField() .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() } header: { - Text("URL") + Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("Optional", text: $title) + .paperField() } header: { - Text("Title") + Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("Optional", text: $description, axis: .vertical) + .paperField() .lineLimit(2...5) } header: { - Text("Notes") + Text("Notes").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("comma separated", text: $tagsText) + .paperField() .textInputAutocapitalization(.never) .autocorrectionDisabled() } header: { - Text("Tags") + Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } footer: { - Text("Separate tags with commas") + Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary) } if let error { Section { Text(error) - .foregroundStyle(.red) - .font(.footnote) + .font(PaperType.meta) + .foregroundStyle(Paper.alarm) } } } + .listRowBackground(Paper.raised) + .paperSurface() .navigationTitle("Add Bookmark") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -92,6 +101,8 @@ struct AddBookmarkView: View { } ToolbarItem(placement: .confirmationAction) { Button("Save") { save() } + .font(PaperType.label) + .tint(Paper.accent) .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving) .overlay { if isSaving { ProgressView().scaleEffect(0.7) } diff --git a/Marks/Views/AskView.swift b/Marks/Views/AskView.swift index 3988741..cf28088 100644 --- a/Marks/Views/AskView.swift +++ b/Marks/Views/AskView.swift @@ -18,20 +18,23 @@ struct AskView: View { case .checking: ProgressView() case .unavailable(let message): - ContentUnavailableView( - "Unavailable", + PaperEmptyState( + title: "Unavailable", systemImage: "sparkles.slash", - description: Text(message) + message: message ) case .ready: ready } } + .paperSurface() .navigationTitle("Ask Your Bookmarks") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } + .font(PaperType.label) + .tint(Paper.accent) } } } @@ -42,17 +45,19 @@ struct AskView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { if answer.isEmpty && !isLoading && errorText == nil { - ContentUnavailableView { - Label("Ask anything", systemImage: "sparkles") - } description: { - Text("Answers come from your saved bookmarks, generated on-device.") - } + PaperEmptyState( + title: "Ask anything", + systemImage: "sparkles", + message: "Answers come from your saved bookmarks, generated on-device." + ) .padding(.top, 40) } if isLoading { HStack(spacing: 8) { ProgressView() - Text("Searching your bookmarks…").foregroundStyle(.secondary) + Text("Searching your bookmarks…") + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } } if !answer.isEmpty { @@ -61,7 +66,9 @@ struct AskView: View { .frame(maxWidth: .infinity, alignment: .leading) } if let errorText { - Text(errorText).foregroundStyle(.red) + Text(errorText) + .font(PaperType.meta) + .foregroundStyle(Paper.alarm) } } .padding() @@ -69,17 +76,24 @@ struct AskView: View { HStack(spacing: 10) { TextField("Ask about your bookmarks…", text: $question, axis: .vertical) + .font(PaperType.body) + .foregroundStyle(Paper.ink) .lineLimit(1...4) .focused($focused) .submitLabel(.send) .onSubmit(send) Button(action: send) { - Image(systemName: "arrow.up.circle.fill").font(.title2) + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 26)) + .foregroundStyle(Paper.accent) } .disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading) } .padding() - .background(.bar) + .background(Paper.raised) + .overlay(alignment: .top) { + Rectangle().fill(Paper.rule.opacity(0.5)).frame(height: 0.6) + } } .onAppear { focused = true } } @@ -112,18 +126,18 @@ private struct MarkdownAnswer: View { private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) } var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in switch block { case .heading(let line): - inline(line).font(.headline) + inline(line).font(PaperType.heading) case .bullet(let line): HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("•").foregroundStyle(.secondary) - inline(line) + Text("•").foregroundStyle(Paper.tertiary) + inline(line).font(PaperType.body) } case .paragraph(let line): - inline(line) + inline(line).font(PaperType.body) } } } diff --git a/Marks/Views/BookmarkListRow.swift b/Marks/Views/BookmarkListRow.swift index e865d43..8ca675d 100644 --- a/Marks/Views/BookmarkListRow.swift +++ b/Marks/Views/BookmarkListRow.swift @@ -1,18 +1,11 @@ import SwiftUI -/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView. -/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet. +/// Full-featured list row used by BookmarksView and SearchView. Owns podcast +/// and episode-picker sheet state; parent owns the BrowserView sheet. struct BookmarkListRow: View { - /// Which visual language the row speaks. `.library` is the paper design - /// used by the bookmarks screen; `.classic` is the system-styled row the - /// Tags and Search screens still use, so redesigning one doesn't silently - /// restyle the others. - enum Style { case classic, library } - let bookmark: Bookmark let viewModel: BookmarksViewModel var readingProgress: Double = 0 - var style: Style = .classic let onOpen: () -> Void var onEdit: (() -> Void)? = nil @@ -21,13 +14,17 @@ struct BookmarkListRow: View { @State private var episodePickerBookmark: Bookmark? var body: some View { - row + LibraryListRow( + bookmark: bookmark, + readingProgress: readingProgress, + onPodcast: handlePodcast + ) .contentShape(Rectangle()) .onTapGesture { onOpen() } .listRowInsets(EdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18)) .listRowSeparator(.visible) - .listRowSeparatorTint(style == .library ? Paper.rule.opacity(0.5) : nil) - .listRowBackground(style == .library ? Paper.sheet : nil) + .listRowSeparatorTint(Paper.rule.opacity(0.5)) + .listRowBackground(Paper.sheet) // Delete is destructive and not undoable — require an explicit tap on the // revealed button rather than letting a single full swipe delete instantly. .swipeActions(edge: .trailing, allowsFullSwipe: false) { @@ -68,24 +65,6 @@ struct BookmarkListRow: View { ) } - @ViewBuilder - private var row: some View { - switch style { - case .classic: - BookmarkRow( - bookmark: bookmark, - readingProgress: readingProgress, - onPodcast: handlePodcast - ) - case .library: - LibraryListRow( - bookmark: bookmark, - readingProgress: readingProgress, - onPodcast: handlePodcast - ) - } - } - private func handlePodcast() { launchPodcast( for: bookmark, diff --git a/Marks/Views/BookmarkRow.swift b/Marks/Views/BookmarkRow.swift deleted file mode 100644 index 9f8f638..0000000 --- a/Marks/Views/BookmarkRow.swift +++ /dev/null @@ -1,170 +0,0 @@ -import SwiftUI - -struct BookmarkRow: View { - let bookmark: Bookmark - var readingProgress: Double = 0 - var onPodcast: (() -> Void)? = nil - - @State private var podcastTapCount = 0 - @State private var podcastCached = false - - /// Compact, static relative date ("6 min ago"). Using a formatter instead of - /// `Text(_, style: .relative)` avoids the live per-second ticking timer. - private static let relativeFormatter: RelativeDateTimeFormatter = { - let f = RelativeDateTimeFormatter() - f.unitsStyle = .abbreviated - f.dateTimeStyle = .named - return f - }() - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top, spacing: 11) { - FaviconView(url: bookmark.faviconUrl) - .overlay(alignment: .topLeading) { - if bookmark.unread { - Circle() - .fill(.blue) - .frame(width: 8, height: 8) - .offset(x: -3, y: -3) - } - } - .padding(.top, 1) - - VStack(alignment: .leading, spacing: 4) { - Text(bookmark.displayTitle) - .font(.headline) - .foregroundStyle(.primary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - - HStack(spacing: 4) { - Text(bookmark.domain) - Text("·") - Text(Self.relativeFormatter.localizedString(for: bookmark.dateAdded, relativeTo: Date())) - } - .font(.footnote) - .foregroundStyle(.secondary) - } - - Spacer() - - if let onPodcast { - Button { - podcastTapCount += 1 - onPodcast() - } label: { - Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle") - .font(.title3) - .foregroundStyle(podcastCached ? .blue : Color(.systemGray3)) - } - .buttonStyle(.plain) - .padding(.top, 1) - .sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount) - } - } - - if let excerpt = rowExcerpt { - Text(excerpt.text) - .font(.subheadline) - .italic(excerpt.isAI) - .foregroundStyle(.secondary) - .lineLimit(2) - .padding(.leading, 44) - } - - if !effectiveTags.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - GlassEffectContainer(spacing: 6) { - HStack(spacing: 6) { - ForEach(effectiveTags, id: \.self) { tag in - Text(tag) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .glassEffect(in: Capsule()) - } - } - } - } - .padding(.leading, 44) - } - - } - .padding(.vertical, 14) - .contentShape(Rectangle()) - .overlay(alignment: .bottom) { - if readingProgress > 0.02 { - GeometryReader { geo in - ZStack(alignment: .leading) { - Rectangle() - .fill(Color(.systemGray5)) - Rectangle() - .fill(Color(.systemGreen)) - .frame(width: geo.size.width * min(readingProgress, 1)) - } - } - .frame(height: 2) - } - } - .task(id: bookmark.url) { - // Stat the podcast cache off the render path: once per appearance - // (and when the URL changes), not on every `body` recomputation. - let path = ClaudeService.cachedPodcastURL(for: bookmark.url).path - podcastCached = await Task.detached { FileManager.default.fileExists(atPath: path) }.value - } - } - - /// Excerpt shown under the title: prefer the AI summary (italic), else the - /// page's scraped description / user note. nil hides the line entirely. - private var rowExcerpt: (text: String, isAI: Bool)? { - if let s = bookmark.aiSummary?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty { - return (s, true) - } - if let e = bookmark.contentExcerpt { - return (e, false) - } - return nil - } - - private var effectiveTags: [String] { - let base = bookmark.tagNames - let ai = bookmark.aiTags ?? [] - let extra = ai.filter { !base.contains($0) }.prefix(3) - return (base + extra).prefix(6).map { $0 } - } -} - -struct FaviconView: View { - let url: String? - - var body: some View { - Group { - if let urlString = url, let faviconUrl = URL(string: urlString) { - AsyncImage(url: faviconUrl) { phase in - switch phase { - case .success(let image): - image.resizable().scaledToFit() - default: - placeholder - } - } - } else { - placeholder - } - } - .frame(width: 32, height: 32) - .clipShape(RoundedRectangle(cornerRadius: 7)) - } - - private var placeholder: some View { - RoundedRectangle(cornerRadius: 6) - .fill(Color(.systemGray5)) - .overlay { - Image(systemName: "bookmark") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color(.systemGray2)) - } - } -} diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 4af771d..54bacf5 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -8,17 +8,17 @@ private struct SkeletonRow: View { var body: some View { HStack(alignment: .top, spacing: 11) { - RoundedRectangle(cornerRadius: 7) - .fill(Color(.systemGray5)) - .frame(width: 32, height: 32) + RoundedRectangle(cornerRadius: 3) + .fill(Paper.ink.opacity(0.09)) + .frame(width: 34, height: 46) VStack(alignment: .leading, spacing: 7) { Capsule() - .fill(Color(.systemGray5)) + .fill(Paper.ink.opacity(0.09)) .frame(maxWidth: .infinity) - .frame(height: 13) + .frame(height: 15) Capsule() - .fill(Color(.systemGray6)) - .frame(width: 140, height: 10) + .fill(Paper.ink.opacity(0.06)) + .frame(width: 140, height: 12) } .padding(.top, 4) Spacer() @@ -154,10 +154,10 @@ struct BookmarksView: View { } .overlay { if !viewModel.isLoading && viewModel.bookmarks.isEmpty { - ContentUnavailableView( - "No Bookmarks", + PaperEmptyState( + title: "No Bookmarks", systemImage: "bookmark", - description: Text("Bookmarks you save will appear here.") + message: "Bookmarks you save will appear here." ) .transition(.opacity) } @@ -248,15 +248,14 @@ struct BookmarksView: View { HStack(spacing: 10) { ProgressView(value: viewModel.enrichmentProgress) .progressViewStyle(.linear) - .tint(.primary) + .tint(Paper.ink) Text("Adding AI summaries…") - .font(.system(size: 13)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } .padding(.horizontal, 20) .padding(.vertical, 12) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .paperCard(cornerRadius: 12) .padding(.horizontal, 16) } @@ -265,15 +264,16 @@ struct BookmarksView: View { return HStack(spacing: 10) { Image(systemName: "waveform") .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) .symbolEffect(.variableColor.iterative, isActive: true) VStack(alignment: .leading, spacing: 2) { Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…") - .font(.system(size: 13, weight: .medium)) + .font(PaperType.stamp) + .foregroundStyle(Paper.ink) if let first = jobs.first { Text(first.title.isEmpty ? first.label : first.title) - .font(.system(size: 11)) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) .lineLimit(1) } } @@ -281,7 +281,7 @@ struct BookmarksView: View { if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 { ProgressView(value: progress) .progressViewStyle(.linear) - .tint(.blue) + .tint(Paper.accent) .frame(width: 44) } else { ProgressView() @@ -289,8 +289,7 @@ struct BookmarksView: View { } .padding(.horizontal, 16) .padding(.vertical, 10) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .paperCard(cornerRadius: 12) .padding(.horizontal, 16) } @@ -308,7 +307,6 @@ struct BookmarksView: View { bookmark: bookmark, viewModel: viewModel, readingProgress: readingProgress[bookmark.url] ?? 0, - style: .library, onOpen: { browsingBookmark = bookmark }, onEdit: { editingBookmark = bookmark } ) diff --git a/Marks/Views/BrowserView.swift b/Marks/Views/BrowserView.swift index 6204abd..b81e3d1 100644 --- a/Marks/Views/BrowserView.swift +++ b/Marks/Views/BrowserView.swift @@ -253,7 +253,7 @@ struct BrowserView: View { if state.readingProgress > 0.01 && state.readingProgress < 0.99 { GeometryReader { geo in Rectangle() - .fill(Color.blue.opacity(0.55)) + .fill(Paper.accent.opacity(0.55)) .frame(width: geo.size.width * state.readingProgress, height: 3) } .frame(height: 3) @@ -409,11 +409,11 @@ struct BrowserView: View { .contentShape(Rectangle()) } } - .font(.system(size: 17)) - .foregroundStyle(.primary) + .font(PaperType.label) + .foregroundStyle(Paper.ink) .padding(.horizontal, 12) .padding(.vertical, 4) - .background(.bar) + .background(Paper.raised) .overlay(alignment: .top) { Divider() } } } diff --git a/Marks/Views/CollectionsView.swift b/Marks/Views/CollectionsView.swift index 65ac644..a3075a8 100644 --- a/Marks/Views/CollectionsView.swift +++ b/Marks/Views/CollectionsView.swift @@ -11,41 +11,44 @@ struct CollectionsView: View { VStack(spacing: 16) { ProgressView() Text("Building smart collections…") - .font(.system(size: 15)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.smartCollections.isEmpty { - ContentUnavailableView( - "No Collections Yet", + PaperEmptyState( + title: "No Collections Yet", systemImage: "sparkles", - description: Text("Tap \"Smart Collections\" to group your bookmarks by topic.") + message: "Tap \"Smart Collections\" to group your bookmarks by topic." ) } else { List(viewModel.smartCollections) { collection in Section { let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) } ForEach(items) { bookmark in - BookmarkRow(bookmark: bookmark) + LibraryListRow(bookmark: bookmark) .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + .listRowBackground(Paper.sheet) + .listRowSeparatorTint(Paper.rule.opacity(0.5)) } } header: { VStack(alignment: .leading, spacing: 2) { Text(collection.name) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.primary) + .font(PaperType.heading) + .foregroundStyle(Paper.ink) if !collection.description.isEmpty { Text(collection.description) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) } } .padding(.vertical, 4) } } - .listStyle(.insetGrouped) + .listStyle(.plain) } } + .paperSurface() .navigationTitle("Smart Collections") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -59,6 +62,8 @@ struct CollectionsView: View { } ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } + .font(PaperType.label) + .tint(Paper.accent) } } } diff --git a/Marks/Views/EditBookmarkView.swift b/Marks/Views/EditBookmarkView.swift index 33430e2..1d5d3c9 100644 --- a/Marks/Views/EditBookmarkView.swift +++ b/Marks/Views/EditBookmarkView.swift @@ -27,44 +27,59 @@ struct EditBookmarkView: View { var body: some View { NavigationStack { Form { - Section("URL") { + Section { TextField("https://", text: $url) + .paperField() .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() + } header: { + Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } - Section("Title") { + Section { TextField("Optional", text: $title) + .paperField() + } header: { + Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } - Section("Description") { + Section { TextField("Optional", text: $description, axis: .vertical) + .paperField() .lineLimit(3...6) + } header: { + Text("Description").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("comma separated", text: $tagsText) + .paperField() .textInputAutocapitalization(.never) .autocorrectionDisabled() } header: { - Text("Tags") + Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } footer: { - Text("Separate tags with commas") + Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary) } Section { Toggle("Mark as unread", isOn: $unread) + .font(PaperType.label) + .foregroundStyle(Paper.ink) + .tint(Paper.accent) } if let error { Section { Text(error) - .foregroundStyle(.red) - .font(.footnote) + .font(PaperType.meta) + .foregroundStyle(Paper.alarm) } } } + .listRowBackground(Paper.raised) + .paperSurface() .navigationTitle("Edit Bookmark") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -73,6 +88,8 @@ struct EditBookmarkView: View { } ToolbarItem(placement: .confirmationAction) { Button("Save") { save() } + .font(PaperType.label) + .tint(Paper.accent) .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving) .overlay { if isSaving { ProgressView().scaleEffect(0.7) } diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index 7f41ce3..53d35ba 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -17,6 +17,29 @@ enum Paper { 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. /// @@ -73,6 +96,95 @@ enum Paper { } } +// 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 tints the bar to match so a + /// large title doesn't sit on a white strip above the content. + func paperSurface() -> some View { + self + .scrollContentBackground(.hidden) + .background(Paper.sheet.ignoresSafeArea()) + .toolbarBackground(Paper.sheet, for: .navigationBar) + } + + /// 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: - 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( diff --git a/Marks/Views/Library/LibraryListRow.swift b/Marks/Views/Library/LibraryListRow.swift index de0e2fb..9036cfa 100644 --- a/Marks/Views/Library/LibraryListRow.swift +++ b/Marks/Views/Library/LibraryListRow.swift @@ -1,9 +1,10 @@ import SwiftUI -/// The library's list presentation. Carries everything `BookmarkRow` carried — -/// unread state, excerpt, tags, podcast affordance, reading progress — in the -/// paper vocabulary. The favicon becomes the color chip, so the same swatch -/// that identifies a card identifies its row. +/// The library's list presentation, and now the app's only bookmark row. It +/// carries what the old system-styled row did — unread state, excerpt, tags, +/// podcast affordance, reading progress — in the paper vocabulary. The favicon +/// became the color chip, so the same swatch that identifies a card in the grid +/// identifies its row in the list. struct LibraryListRow: View { let bookmark: Bookmark var readingProgress: Double = 0 diff --git a/Marks/Views/OnboardingView.swift b/Marks/Views/OnboardingView.swift index 2df303e..eae00a2 100644 --- a/Marks/Views/OnboardingView.swift +++ b/Marks/Views/OnboardingView.swift @@ -17,10 +17,11 @@ struct OnboardingView: View { VStack(alignment: .leading, spacing: 8) { Text("Marks") - .font(.system(size: 42, weight: .semibold, design: .rounded)) + .font(.system(size: 42, design: .serif)) + .foregroundStyle(Paper.ink) Text("Your bookmarks, beautifully.") - .font(.system(size: 17)) - .foregroundStyle(.secondary) + .font(PaperType.body) + .foregroundStyle(Paper.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 28) @@ -29,8 +30,8 @@ struct OnboardingView: View { VStack(spacing: 14) { VStack(alignment: .leading, spacing: 6) { Text("Server URL") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) + .font(PaperType.stamp) + .foregroundStyle(Paper.tertiary) .padding(.horizontal, 2) TextField("https://links.example.com", text: $urlText) .textContentType(.URL) @@ -41,14 +42,15 @@ struct OnboardingView: View { .submitLabel(.next) .onSubmit { focused = .token } .padding(14) - .background(Color(.systemGray6)) + .paperField() + .background(Paper.raised) .clipShape(RoundedRectangle(cornerRadius: 12)) } VStack(alignment: .leading, spacing: 6) { Text("API Token") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) + .font(PaperType.stamp) + .foregroundStyle(Paper.tertiary) .padding(.horizontal, 2) SecureField("Paste your token", text: $token) .textContentType(.password) @@ -58,11 +60,12 @@ struct OnboardingView: View { .submitLabel(.done) .onSubmit { Task { await connect() } } .padding(14) - .background(Color(.systemGray6)) + .paperField() + .background(Paper.raised) .clipShape(RoundedRectangle(cornerRadius: 12)) Text("Find your token at Settings → API Token in Linkding.") - .font(.system(size: 12)) - .foregroundStyle(.tertiary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) .padding(.horizontal, 2) } } @@ -70,8 +73,8 @@ struct OnboardingView: View { if let err = errorMessage { Text(err) - .font(.system(size: 14)) - .foregroundStyle(.red) + .font(PaperType.meta) + .foregroundStyle(Paper.alarm) .padding(.top, 12) .padding(.horizontal, 28) } @@ -84,7 +87,7 @@ struct OnboardingView: View { ProgressView().tint(.white) } else { Text("Connect") - .font(.system(size: 17, weight: .semibold)) + .font(PaperType.label) } } .frame(maxWidth: .infinity) diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index 158eced..78a2483 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -510,6 +510,7 @@ struct PodcastPlayerView: View { Spacer() } .padding(.horizontal, 32) + .paperSurface() .navigationTitle("Podcast") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -520,7 +521,7 @@ struct PodcastPlayerView: View { ToolbarItem(placement: .topBarTrailing) { Button { vm.stop(); dismiss() } label: { Image(systemName: "stop.circle") - .foregroundStyle(.red) + .foregroundStyle(Paper.alarm) } } } @@ -556,16 +557,16 @@ struct PodcastPlayerView: View { VStack(spacing: 28) { Image(systemName: "waveform") .font(.system(size: 52)) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) .symbolEffect(.variableColor.iterative, isActive: true) VStack(spacing: 10) { ProgressView(value: progress) .progressViewStyle(.linear) - .tint(.blue) + .tint(Paper.accent) Text(label) - .font(.system(size: 14)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } } } @@ -573,16 +574,16 @@ struct PodcastPlayerView: View { private func playerView(title: String) -> some View { VStack(spacing: 28) { RoundedRectangle(cornerRadius: 20) - .fill(Color(.systemGray6)) + .fill(Paper.raised) .frame(width: 220, height: 220) .overlay { Image(systemName: "waveform.circle.fill") .font(.system(size: 80)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) } Text(title) - .font(.system(size: 18, weight: .semibold)) + .font(PaperType.heading) .multilineTextAlignment(.center) .lineLimit(3) @@ -591,32 +592,32 @@ struct PodcastPlayerView: View { value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }), in: 0...vm.duration ) - .tint(.primary) + .tint(Paper.ink) HStack { Text(formatTime(vm.currentTime)) Spacer() Text(formatTime(vm.duration)) } - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(.tertiary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } HStack(spacing: 36) { Button { vm.skipBackward15() } label: { Image(systemName: "gobackward.15") .font(.system(size: 32)) - .foregroundStyle(.primary) + .foregroundStyle(Paper.ink) } Button { vm.togglePlayPause() } label: { Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 72)) - .foregroundStyle(.primary) + .foregroundStyle(Paper.ink) } Button { vm.skipForward15() } label: { Image(systemName: "goforward.15") .font(.system(size: 32)) - .foregroundStyle(.primary) + .foregroundStyle(Paper.ink) } } @@ -636,8 +637,8 @@ struct PodcastPlayerView: View { } } label: { Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) .padding(.horizontal, 14) .padding(.vertical, 7) .glassEffect(in: Capsule()) @@ -652,7 +653,7 @@ struct PodcastPlayerView: View { } label: { Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon") .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(sleepActive ? Color.blue : .secondary) + .foregroundStyle(sleepActive ? Paper.accent : .secondary) .padding(.horizontal, 14) .padding(.vertical, 7) .glassEffect(in: Capsule()) @@ -676,7 +677,7 @@ struct PodcastPlayerView: View { ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) { Label("Share Episode", systemImage: "square.and.arrow.up") .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) } } } @@ -687,12 +688,12 @@ struct PodcastPlayerView: View { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 44)) - .foregroundStyle(.red) + .foregroundStyle(Paper.alarm) Text("Generation Failed") - .font(.headline) + .font(PaperType.heading) Text(message) - .font(.system(size: 14)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) .multilineTextAlignment(.center) Button { vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude) @@ -701,7 +702,7 @@ struct PodcastPlayerView: View { .font(.system(size: 15, weight: .semibold)) } .buttonStyle(.glassProminent) - .tint(.blue) + .tint(Paper.accent) .buttonBorderShape(.capsule) .padding(.top, 4) } @@ -724,18 +725,18 @@ struct MiniPlayerView: View { HStack(spacing: 14) { Image(systemName: "waveform") .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) .symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating) .frame(width: 22) VStack(alignment: .leading, spacing: 2) { Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.primary) + .font(PaperType.meta) + .foregroundStyle(Paper.ink) .lineLimit(1) Text(progressLabel) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.secondary) } Spacer() @@ -749,7 +750,7 @@ struct MiniPlayerView: View { } label: { Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 18)) - .foregroundStyle(.primary) + .foregroundStyle(Paper.ink) .frame(width: 36, height: 36) } .buttonStyle(.plain) @@ -760,20 +761,20 @@ struct MiniPlayerView: View { } label: { Image(systemName: "xmark") .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) .frame(width: 28, height: 28) } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 12) - .background(.regularMaterial) + .background(Paper.raised) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay(alignment: .bottom) { // Progress bar along bottom edge GeometryReader { geo in Rectangle() - .fill(Color.blue.opacity(0.6)) + .fill(Paper.accent.opacity(0.6)) .frame(width: geo.size.width * progressFraction, height: 3) } .frame(height: 3) @@ -819,10 +820,10 @@ struct PodcastLibraryView: View { NavigationStack { Group { if library.entries.isEmpty { - ContentUnavailableView( - "No Podcasts", + PaperEmptyState( + title: "No Podcasts", systemImage: "headphones", - description: Text("Podcasts you generate from bookmarks will appear here.") + message: "Podcasts you generate from bookmarks will appear here." ) } else { List { @@ -840,6 +841,7 @@ struct PodcastLibraryView: View { .listStyle(.insetGrouped) } } + .paperSurface() .navigationTitle("Podcasts") .toolbar { if !unplayed.isEmpty { @@ -888,18 +890,18 @@ struct PodcastLibraryView: View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(entry.title ?? entry.articleUrl) - .font(.system(size: 15, weight: .medium)) + .font(PaperType.quote) .lineLimit(2) .foregroundStyle(entry.isPlayed ? .secondary : .primary) Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.secondary) } Spacer() if entry.isPlayed { Image(systemName: "checkmark.circle.fill") .font(.system(size: 15)) - .foregroundStyle(.green) + .foregroundStyle(Paper.affirm) .accessibilityLabel("Played") } Button { @@ -907,7 +909,7 @@ struct PodcastLibraryView: View { } label: { Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 36)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) @@ -1005,17 +1007,17 @@ struct EpisodePickerView: View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(entry.title ?? entry.articleUrl) - .font(.system(size: 15, weight: .medium)) + .font(PaperType.quote) .lineLimit(2) if entry.articleUrl != bookmark.url { Text(entry.articleUrl) - .font(.system(size: 11)) - .foregroundStyle(.tertiary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) .lineLimit(1) } Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.secondary) } Spacer() Button { @@ -1026,7 +1028,7 @@ struct EpisodePickerView: View { } label: { Image(systemName: "play.circle.fill") .font(.system(size: 36)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) } .buttonStyle(.plain) } @@ -1095,21 +1097,21 @@ struct EpisodeDetailView: View { Section { VStack(alignment: .leading, spacing: 8) { Text(entry.title ?? entry.articleUrl) - .font(.system(size: 20, weight: .semibold)) + .font(PaperType.display) Label(domain, systemImage: "link") .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) .lineLimit(1) HStack(spacing: 12) { Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted), systemImage: "calendar") if isPlayed { Label("Played", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) + .foregroundStyle(Paper.affirm) } } .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) } .padding(.vertical, 4) } @@ -1118,7 +1120,7 @@ struct EpisodeDetailView: View { Section("Summary") { Text(summary) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) } } diff --git a/Marks/Views/SearchView.swift b/Marks/Views/SearchView.swift index c9805de..63f0ab3 100644 --- a/Marks/Views/SearchView.swift +++ b/Marks/Views/SearchView.swift @@ -21,6 +21,7 @@ struct SearchView: View { } } .listStyle(.plain) + .paperSurface() .navigationTitle("Search") .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -33,9 +34,9 @@ struct SearchView: View { } .overlay { if searchText.isEmpty { - ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag.")) + PaperEmptyState(title: "Search Bookmarks", systemImage: "magnifyingglass", message: "Search by title, URL, or tag.") } else if results.isEmpty && !viewModel.isLoading { - ContentUnavailableView.search(text: searchText) + PaperEmptyState(title: "No Results", systemImage: "magnifyingglass", message: "Nothing matches \u{201C}\(searchText)\u{201D}.") } if viewModel.isLoading { ProgressView() diff --git a/Marks/Views/SettingsView.swift b/Marks/Views/SettingsView.swift index 45e3649..d5137e9 100644 --- a/Marks/Views/SettingsView.swift +++ b/Marks/Views/SettingsView.swift @@ -11,14 +11,18 @@ struct SettingsView: View { var body: some View { NavigationStack { List { - Section("AI Features") { + Section { Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles") - .font(.system(size: 13)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) + } header: { + Text("AI Features").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("e.g. listen podcast", text: $autoTagText) + .font(PaperType.label) + .foregroundStyle(Paper.ink) .textInputAutocapitalization(.never) .autocorrectionDisabled() .onChange(of: autoTagText) { _, new in @@ -27,25 +31,35 @@ struct SettingsView: View { .map(String.init) } } header: { - Text("Auto-Podcast Tags") + Text("Auto-Podcast Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } footer: { Text("Saving a bookmark with any of these tags automatically generates a podcast for it.") + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) } - Section("Server") { - Button(role: .destructive) { + Section { + Button { dismiss() onDisconnect() } label: { Label("Disconnect", systemImage: "person.crop.circle.badge.minus") + .font(PaperType.label) + .foregroundStyle(Paper.alarm) } + } header: { + Text("Server").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } } + .listRowBackground(Paper.raised) + .paperSurface() .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } + .font(PaperType.label) + .tint(Paper.accent) } } } diff --git a/Marks/Views/SourcesView.swift b/Marks/Views/SourcesView.swift index 6b82cf5..462381a 100644 --- a/Marks/Views/SourcesView.swift +++ b/Marks/Views/SourcesView.swift @@ -35,8 +35,8 @@ struct SourcesView: View { playOrGeneratePodcast(for: source) } label: { Image(systemName: podcastIcon(for: source)) - .font(.title3) - .foregroundStyle(.blue) + .font(.system(size: 22)) + .foregroundStyle(Paper.accent) .frame(width: 36, height: 36) } .buttonStyle(.plain) @@ -48,7 +48,7 @@ struct SourcesView: View { } label: { Label("Podcast", systemImage: "headphones") } - .tint(.blue) + .tint(Paper.accent) Button(role: .destructive) { library.delete(source) @@ -59,6 +59,7 @@ struct SourcesView: View { } } .listStyle(.plain) + .paperSurface() .navigationTitle("Sources") .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -80,10 +81,12 @@ struct SourcesView: View { } .overlay { if results.isEmpty { - ContentUnavailableView( - searchText.isEmpty ? "No Sources" : "No Results", + PaperEmptyState( + title: searchText.isEmpty ? "No Sources" : "No Results", systemImage: searchText.isEmpty ? "tray" : "magnifyingglass", - description: Text(searchText.isEmpty ? "Import text, PDFs, or files to keep non-web material in Marks." : "Try a different search.") + message: searchText.isEmpty + ? "Import text, PDFs, or files to keep non-web material in Marks." + : "Try a different search." ) } } @@ -198,19 +201,20 @@ private struct SourceRow: View { var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: iconName) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.blue) + .font(PaperType.stamp) + .foregroundStyle(Paper.accent) .frame(width: 30, height: 30) - .background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) + .background(Paper.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) VStack(alignment: .leading, spacing: 5) { Text(source.displayTitle) - .font(.headline) + .font(PaperType.title) + .foregroundStyle(Paper.ink) .lineLimit(2) if !source.excerpt.isEmpty { Text(source.excerpt) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) .lineLimit(2) } HStack(spacing: 8) { @@ -220,8 +224,8 @@ private struct SourceRow: View { Text(source.tags.joined(separator: ", ")) } } - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } } .padding(.vertical, 8) @@ -265,6 +269,7 @@ private struct ImportTextSourceView: View { Text("Separate tags with commas") } } + .paperSurface() .navigationTitle("Import Text") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -329,6 +334,8 @@ private struct EditSourceView: View { Text("Separate tags with commas") } } + .listRowBackground(Paper.raised) + .paperSurface() .navigationTitle("Edit Source") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -363,10 +370,11 @@ private struct SourceDetailView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text(source.displayTitle) - .font(.title2.weight(.semibold)) + .font(PaperType.display) + .foregroundStyle(Paper.ink) Text(source.kind.label) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) } if !source.tags.isEmpty { @@ -374,10 +382,11 @@ private struct SourceDetailView: View { HStack { ForEach(source.tags, id: \.self) { tag in Text(tag) - .font(.caption.weight(.medium)) + .font(PaperType.micro) + .foregroundStyle(Paper.secondary) .padding(.horizontal, 10) .padding(.vertical, 5) - .background(Color.blue.opacity(0.12), in: Capsule()) + .background(Paper.accent.opacity(0.12), in: Capsule()) } } } @@ -411,12 +420,14 @@ private struct SourceDetailView: View { } Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText) - .font(.body) + .font(PaperType.body) + .foregroundStyle(Paper.ink) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } .padding() } + .paperSurface() .navigationTitle("Source") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Marks/Views/TagsView.swift b/Marks/Views/TagsView.swift index b77f343..c9fe6fb 100644 --- a/Marks/Views/TagsView.swift +++ b/Marks/Views/TagsView.swift @@ -40,14 +40,12 @@ struct TagsView: View { } .overlay { if visibleTags.isEmpty { - ContentUnavailableView( - query.isEmpty ? "No Tags" : "No Matching Tags", + PaperEmptyState( + title: query.isEmpty ? "No Tags" : "No Matching Tags", systemImage: "tag", - description: Text( - query.isEmpty + message: query.isEmpty ? "Tags from your bookmarks will appear here." : "No tag matches “\(query)”." - ) ) } } diff --git a/ShareExtension/ShareView.swift b/ShareExtension/ShareView.swift index a9c4758..9cc1031 100644 --- a/ShareExtension/ShareView.swift +++ b/ShareExtension/ShareView.swift @@ -65,7 +65,7 @@ struct ShareView: View { case .loading: HStack(spacing: 12) { ProgressView() - Text("Checking…").foregroundStyle(.secondary) + Text("Checking…").font(PaperType.meta).foregroundStyle(Paper.secondary) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 24) @@ -85,7 +85,11 @@ struct ShareView: View { } } .padding(20) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .background(Paper.sheet, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(Paper.rule.opacity(0.5), lineWidth: 0.6) + ) .shadow(color: .black.opacity(0.15), radius: 24, y: 8) } @@ -99,19 +103,21 @@ struct ShareView: View { ? "Already saved" : "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))") } icon: { - Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + Image(systemName: "checkmark.circle.fill").foregroundStyle(Paper.affirm) } - .font(.subheadline.weight(.semibold)) + .font(PaperType.label) + .foregroundStyle(Paper.ink) } else { - Text("Save bookmark").font(.headline) + Text("Save bookmark").font(PaperType.heading).foregroundStyle(Paper.ink) } Text(title.isEmpty ? url : title) - .font(.subheadline.weight(.medium)) + .font(PaperType.quote) + .foregroundStyle(Paper.ink) .lineLimit(2) Text(domain) - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } } @@ -128,8 +134,8 @@ struct ShareView: View { HStack(spacing: 6) { ProgressView().controlSize(.small) Text("Suggesting tags…") - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } } @@ -139,11 +145,11 @@ struct ShareView: View { ForEach(suggestionChips, id: \.tag) { item in Button { addTag(item.tag) } label: { Text(item.isAI ? "✨ \(item.tag)" : "+ \(item.tag)") - .font(.caption.weight(.medium)) + .font(PaperType.micro) .padding(.horizontal, 10) .padding(.vertical, 5) - .background((item.isAI ? Color.purple : .blue).opacity(0.12), in: Capsule()) - .foregroundStyle(item.isAI ? Color.purple : .blue) + .background((item.isAI ? Paper.accent : Paper.ink).opacity(0.1), in: Capsule()) + .foregroundStyle(item.isAI ? Paper.accent : Paper.secondary) } .buttonStyle(.plain) } @@ -157,12 +163,14 @@ struct ShareView: View { .textFieldStyle(.roundedBorder) Toggle("Read later", isOn: $readLater) - .font(.subheadline) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) Toggle(isOn: $createPodcast) { Label("Create podcast", systemImage: "headphones") } - .font(.subheadline) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } } @@ -189,8 +197,8 @@ struct ShareView: View { private func statusCard(icon: String, tint: Color, text: String) -> some View { HStack(spacing: 10) { - Image(systemName: icon).font(.title3).foregroundStyle(tint) - Text(text).font(.headline) + Image(systemName: icon).font(.system(size: 22)).foregroundStyle(tint) + Text(text).font(PaperType.heading).foregroundStyle(Paper.ink) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 16) diff --git a/project.yml b/project.yml index f03ab79..2be5962 100644 --- a/project.yml +++ b/project.yml @@ -93,6 +93,9 @@ targets: - path: Marks/Services/Log.swift # Cross-process podcast request queue: the extension enqueues, the app runs it. - path: Marks/Services/PodcastRequests.swift + # The save card is a user-facing surface, so it uses the same palette and + # type scale as the app. + - path: Marks/Views/Library/LibraryKit.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension