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