Library design: cards + redesigned list on the bookmarks screen, tag picker sheet #9

Merged
admin merged 11 commits from feat/library-browse-prototype into master 2026-07-30 04:03:03 +00:00
21 changed files with 418 additions and 407 deletions
Showing only changes of commit fffb3999bf - Show all commits
+2 -4
View File
@@ -50,8 +50,8 @@
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; }; 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; }; 94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB1DA808EE8041C4546DAAB /* SourcesView.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 */; }; 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 */; }; A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; }; A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.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 = "<group>"; }; AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = "<group>"; };
B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = "<group>"; }; B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = "<group>"; };
BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; }; BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTool.swift; sourceTree = "<group>"; }; C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTool.swift; sourceTree = "<group>"; };
@@ -335,7 +334,6 @@
217E6702DE1210AC38ED16D1 /* AskView.swift */, 217E6702DE1210AC38ED16D1 /* AskView.swift */,
240DBB87940F8D255A812EB2 /* BookmarkActions.swift */, 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */,
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */, CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */, CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
629C41E0BC28EB6359D50CFD /* BrowserView.swift */, 629C41E0BC28EB6359D50CFD /* BrowserView.swift */,
@@ -539,6 +537,7 @@
files = ( files = (
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */, 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */,
70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */, 70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */,
A30907113FD5D682478750A7 /* LibraryKit.swift in Sources */,
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */, 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */, B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */, B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
@@ -576,7 +575,6 @@
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */, FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */, CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */, 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */,
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */, 44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */,
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
+13 -11
View File
@@ -8,18 +8,19 @@ struct BookmarkSnippetView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "bookmark.fill") Image(systemName: "bookmark.fill")
.font(.title2) .font(.title2)
.foregroundStyle(.tint) .foregroundStyle(Paper.accent)
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text(entity.title) Text(entity.title)
.font(.headline) .font(PaperType.heading)
.foregroundStyle(Paper.ink)
.lineLimit(2) .lineLimit(2)
Text(entity.host) Text(entity.host)
.font(.subheadline) .font(PaperType.meta)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
if !entity.tags.isEmpty { if !entity.tags.isEmpty {
Text(entity.tags.map { "#\($0)" }.joined(separator: " ")) Text(entity.tags.map { "#\($0)" }.joined(separator: " "))
.font(.caption) .font(PaperType.micro)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
.lineLimit(1) .lineLimit(1)
} }
} }
@@ -38,14 +39,15 @@ struct SummarySnippetView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "sparkles").foregroundStyle(.tint) Image(systemName: "sparkles").foregroundStyle(Paper.accent)
Text(title).font(.headline).lineLimit(2) Text(title).font(PaperType.heading).foregroundStyle(Paper.ink).lineLimit(2)
} }
Text(host) Text(host)
.font(.caption) .font(PaperType.micro)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
Text(summary) Text(summary)
.font(.body) .font(PaperType.body)
.foregroundStyle(Paper.ink)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
.padding() .padding()
+4
View File
@@ -70,6 +70,10 @@ struct MainContainer: View {
SearchView(viewModel: viewModel) 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 .onOpenURL { url in
handleDeepLink(url) handleDeepLink(url)
} }
+22 -11
View File
@@ -18,6 +18,7 @@ struct AddBookmarkView: View {
Form { Form {
Section { Section {
TextEditor(text: $importText) TextEditor(text: $importText)
.paperField()
.frame(minHeight: 96) .frame(minHeight: 96)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
@@ -27,6 +28,8 @@ struct AddBookmarkView: View {
extractImportText() extractImportText()
} label: { } label: {
Label("Extract Link", systemImage: "link.badge.plus") Label("Extract Link", systemImage: "link.badge.plus")
.font(PaperType.label)
.foregroundStyle(Paper.accent)
} }
.disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
@@ -34,56 +37,62 @@ struct AddBookmarkView: View {
if let importMessage { if let importMessage {
Text(importMessage) Text(importMessage)
.font(.caption) .font(PaperType.micro)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
} }
} }
} header: { } header: {
Text("Import Text") Text("Import Text").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} footer: { } 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 { Section {
TextField("https://", text: $url) TextField("https://", text: $url)
.paperField()
.keyboardType(.URL) .keyboardType(.URL)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
} header: { } header: {
Text("URL") Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} }
Section { Section {
TextField("Optional", text: $title) TextField("Optional", text: $title)
.paperField()
} header: { } header: {
Text("Title") Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} }
Section { Section {
TextField("Optional", text: $description, axis: .vertical) TextField("Optional", text: $description, axis: .vertical)
.paperField()
.lineLimit(2...5) .lineLimit(2...5)
} header: { } header: {
Text("Notes") Text("Notes").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} }
Section { Section {
TextField("comma separated", text: $tagsText) TextField("comma separated", text: $tagsText)
.paperField()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
} header: { } header: {
Text("Tags") Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} footer: { } footer: {
Text("Separate tags with commas") Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary)
} }
if let error { if let error {
Section { Section {
Text(error) Text(error)
.foregroundStyle(.red) .font(PaperType.meta)
.font(.footnote) .foregroundStyle(Paper.alarm)
} }
} }
} }
.listRowBackground(Paper.raised)
.paperSurface()
.navigationTitle("Add Bookmark") .navigationTitle("Add Bookmark")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -92,6 +101,8 @@ struct AddBookmarkView: View {
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() } Button("Save") { save() }
.font(PaperType.label)
.tint(Paper.accent)
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving) .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
.overlay { .overlay {
if isSaving { ProgressView().scaleEffect(0.7) } if isSaving { ProgressView().scaleEffect(0.7) }
+31 -17
View File
@@ -18,20 +18,23 @@ struct AskView: View {
case .checking: case .checking:
ProgressView() ProgressView()
case .unavailable(let message): case .unavailable(let message):
ContentUnavailableView( PaperEmptyState(
"Unavailable", title: "Unavailable",
systemImage: "sparkles.slash", systemImage: "sparkles.slash",
description: Text(message) message: message
) )
case .ready: case .ready:
ready ready
} }
} }
.paperSurface()
.navigationTitle("Ask Your Bookmarks") .navigationTitle("Ask Your Bookmarks")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() } Button("Done") { dismiss() }
.font(PaperType.label)
.tint(Paper.accent)
} }
} }
} }
@@ -42,17 +45,19 @@ struct AskView: View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
if answer.isEmpty && !isLoading && errorText == nil { if answer.isEmpty && !isLoading && errorText == nil {
ContentUnavailableView { PaperEmptyState(
Label("Ask anything", systemImage: "sparkles") title: "Ask anything",
} description: { systemImage: "sparkles",
Text("Answers come from your saved bookmarks, generated on-device.") message: "Answers come from your saved bookmarks, generated on-device."
} )
.padding(.top, 40) .padding(.top, 40)
} }
if isLoading { if isLoading {
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView() ProgressView()
Text("Searching your bookmarks…").foregroundStyle(.secondary) Text("Searching your bookmarks…")
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
} }
} }
if !answer.isEmpty { if !answer.isEmpty {
@@ -61,7 +66,9 @@ struct AskView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
if let errorText { if let errorText {
Text(errorText).foregroundStyle(.red) Text(errorText)
.font(PaperType.meta)
.foregroundStyle(Paper.alarm)
} }
} }
.padding() .padding()
@@ -69,17 +76,24 @@ struct AskView: View {
HStack(spacing: 10) { HStack(spacing: 10) {
TextField("Ask about your bookmarks…", text: $question, axis: .vertical) TextField("Ask about your bookmarks…", text: $question, axis: .vertical)
.font(PaperType.body)
.foregroundStyle(Paper.ink)
.lineLimit(1...4) .lineLimit(1...4)
.focused($focused) .focused($focused)
.submitLabel(.send) .submitLabel(.send)
.onSubmit(send) .onSubmit(send)
Button(action: 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) .disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading)
} }
.padding() .padding()
.background(.bar) .background(Paper.raised)
.overlay(alignment: .top) {
Rectangle().fill(Paper.rule.opacity(0.5)).frame(height: 0.6)
}
} }
.onAppear { focused = true } .onAppear { focused = true }
} }
@@ -112,18 +126,18 @@ private struct MarkdownAnswer: View {
private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) } private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 10) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
switch block { switch block {
case .heading(let line): case .heading(let line):
inline(line).font(.headline) inline(line).font(PaperType.heading)
case .bullet(let line): case .bullet(let line):
HStack(alignment: .firstTextBaseline, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("").foregroundStyle(.secondary) Text("").foregroundStyle(Paper.tertiary)
inline(line) inline(line).font(PaperType.body)
} }
case .paragraph(let line): case .paragraph(let line):
inline(line) inline(line).font(PaperType.body)
} }
} }
} }
+9 -30
View File
@@ -1,18 +1,11 @@
import SwiftUI import SwiftUI
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView. /// Full-featured list row used by BookmarksView and SearchView. Owns podcast
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet. /// and episode-picker sheet state; parent owns the BrowserView sheet.
struct BookmarkListRow: View { 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 bookmark: Bookmark
let viewModel: BookmarksViewModel let viewModel: BookmarksViewModel
var readingProgress: Double = 0 var readingProgress: Double = 0
var style: Style = .classic
let onOpen: () -> Void let onOpen: () -> Void
var onEdit: (() -> Void)? = nil var onEdit: (() -> Void)? = nil
@@ -21,13 +14,17 @@ struct BookmarkListRow: View {
@State private var episodePickerBookmark: Bookmark? @State private var episodePickerBookmark: Bookmark?
var body: some View { var body: some View {
row LibraryListRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { onOpen() } .onTapGesture { onOpen() }
.listRowInsets(EdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18)) .listRowInsets(EdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18))
.listRowSeparator(.visible) .listRowSeparator(.visible)
.listRowSeparatorTint(style == .library ? Paper.rule.opacity(0.5) : nil) .listRowSeparatorTint(Paper.rule.opacity(0.5))
.listRowBackground(style == .library ? Paper.sheet : nil) .listRowBackground(Paper.sheet)
// Delete is destructive and not undoable require an explicit tap on the // Delete is destructive and not undoable require an explicit tap on the
// revealed button rather than letting a single full swipe delete instantly. // revealed button rather than letting a single full swipe delete instantly.
.swipeActions(edge: .trailing, allowsFullSwipe: false) { .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() { private func handlePodcast() {
launchPodcast( launchPodcast(
for: bookmark, for: bookmark,
-170
View File
@@ -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))
}
}
}
+21 -23
View File
@@ -8,17 +8,17 @@ private struct SkeletonRow: View {
var body: some View { var body: some View {
HStack(alignment: .top, spacing: 11) { HStack(alignment: .top, spacing: 11) {
RoundedRectangle(cornerRadius: 7) RoundedRectangle(cornerRadius: 3)
.fill(Color(.systemGray5)) .fill(Paper.ink.opacity(0.09))
.frame(width: 32, height: 32) .frame(width: 34, height: 46)
VStack(alignment: .leading, spacing: 7) { VStack(alignment: .leading, spacing: 7) {
Capsule() Capsule()
.fill(Color(.systemGray5)) .fill(Paper.ink.opacity(0.09))
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 13) .frame(height: 15)
Capsule() Capsule()
.fill(Color(.systemGray6)) .fill(Paper.ink.opacity(0.06))
.frame(width: 140, height: 10) .frame(width: 140, height: 12)
} }
.padding(.top, 4) .padding(.top, 4)
Spacer() Spacer()
@@ -154,10 +154,10 @@ struct BookmarksView: View {
} }
.overlay { .overlay {
if !viewModel.isLoading && viewModel.bookmarks.isEmpty { if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
ContentUnavailableView( PaperEmptyState(
"No Bookmarks", title: "No Bookmarks",
systemImage: "bookmark", systemImage: "bookmark",
description: Text("Bookmarks you save will appear here.") message: "Bookmarks you save will appear here."
) )
.transition(.opacity) .transition(.opacity)
} }
@@ -248,15 +248,14 @@ struct BookmarksView: View {
HStack(spacing: 10) { HStack(spacing: 10) {
ProgressView(value: viewModel.enrichmentProgress) ProgressView(value: viewModel.enrichmentProgress)
.progressViewStyle(.linear) .progressViewStyle(.linear)
.tint(.primary) .tint(Paper.ink)
Text("Adding AI summaries…") Text("Adding AI summaries…")
.font(.system(size: 13)) .font(PaperType.meta)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(.regularMaterial) .paperCard(cornerRadius: 12)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
@@ -265,15 +264,16 @@ struct BookmarksView: View {
return HStack(spacing: 10) { return HStack(spacing: 10) {
Image(systemName: "waveform") Image(systemName: "waveform")
.font(.system(size: 15, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
.foregroundStyle(.blue) .foregroundStyle(Paper.accent)
.symbolEffect(.variableColor.iterative, isActive: true) .symbolEffect(.variableColor.iterative, isActive: true)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…") 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 { if let first = jobs.first {
Text(first.title.isEmpty ? first.label : first.title) Text(first.title.isEmpty ? first.label : first.title)
.font(.system(size: 11)) .font(PaperType.micro)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
.lineLimit(1) .lineLimit(1)
} }
} }
@@ -281,7 +281,7 @@ struct BookmarksView: View {
if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 { if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 {
ProgressView(value: progress) ProgressView(value: progress)
.progressViewStyle(.linear) .progressViewStyle(.linear)
.tint(.blue) .tint(Paper.accent)
.frame(width: 44) .frame(width: 44)
} else { } else {
ProgressView() ProgressView()
@@ -289,8 +289,7 @@ struct BookmarksView: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 10) .padding(.vertical, 10)
.background(.regularMaterial) .paperCard(cornerRadius: 12)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
@@ -308,7 +307,6 @@ struct BookmarksView: View {
bookmark: bookmark, bookmark: bookmark,
viewModel: viewModel, viewModel: viewModel,
readingProgress: readingProgress[bookmark.url] ?? 0, readingProgress: readingProgress[bookmark.url] ?? 0,
style: .library,
onOpen: { browsingBookmark = bookmark }, onOpen: { browsingBookmark = bookmark },
onEdit: { editingBookmark = bookmark } onEdit: { editingBookmark = bookmark }
) )
+4 -4
View File
@@ -253,7 +253,7 @@ struct BrowserView: View {
if state.readingProgress > 0.01 && state.readingProgress < 0.99 { if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
GeometryReader { geo in GeometryReader { geo in
Rectangle() Rectangle()
.fill(Color.blue.opacity(0.55)) .fill(Paper.accent.opacity(0.55))
.frame(width: geo.size.width * state.readingProgress, height: 3) .frame(width: geo.size.width * state.readingProgress, height: 3)
} }
.frame(height: 3) .frame(height: 3)
@@ -409,11 +409,11 @@ struct BrowserView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
} }
.font(.system(size: 17)) .font(PaperType.label)
.foregroundStyle(.primary) .foregroundStyle(Paper.ink)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 4) .padding(.vertical, 4)
.background(.bar) .background(Paper.raised)
.overlay(alignment: .top) { Divider() } .overlay(alignment: .top) { Divider() }
} }
} }
+16 -11
View File
@@ -11,41 +11,44 @@ struct CollectionsView: View {
VStack(spacing: 16) { VStack(spacing: 16) {
ProgressView() ProgressView()
Text("Building smart collections…") Text("Building smart collections…")
.font(.system(size: 15)) .font(PaperType.meta)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.smartCollections.isEmpty { } else if viewModel.smartCollections.isEmpty {
ContentUnavailableView( PaperEmptyState(
"No Collections Yet", title: "No Collections Yet",
systemImage: "sparkles", systemImage: "sparkles",
description: Text("Tap \"Smart Collections\" to group your bookmarks by topic.") message: "Tap \"Smart Collections\" to group your bookmarks by topic."
) )
} else { } else {
List(viewModel.smartCollections) { collection in List(viewModel.smartCollections) { collection in
Section { Section {
let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) } let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) }
ForEach(items) { bookmark in ForEach(items) { bookmark in
BookmarkRow(bookmark: bookmark) LibraryListRow(bookmark: bookmark)
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
.listRowBackground(Paper.sheet)
.listRowSeparatorTint(Paper.rule.opacity(0.5))
} }
} header: { } header: {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(collection.name) Text(collection.name)
.font(.system(size: 14, weight: .semibold)) .font(PaperType.heading)
.foregroundStyle(.primary) .foregroundStyle(Paper.ink)
if !collection.description.isEmpty { if !collection.description.isEmpty {
Text(collection.description) Text(collection.description)
.font(.system(size: 12)) .font(PaperType.meta)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
} }
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
} }
.listStyle(.insetGrouped) .listStyle(.plain)
} }
} }
.paperSurface()
.navigationTitle("Smart Collections") .navigationTitle("Smart Collections")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -59,6 +62,8 @@ struct CollectionsView: View {
} }
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() } Button("Done") { dismiss() }
.font(PaperType.label)
.tint(Paper.accent)
} }
} }
} }
+24 -7
View File
@@ -27,44 +27,59 @@ struct EditBookmarkView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
Section("URL") { Section {
TextField("https://", text: $url) TextField("https://", text: $url)
.paperField()
.keyboardType(.URL) .keyboardType(.URL)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
} header: {
Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} }
Section("Title") { Section {
TextField("Optional", text: $title) TextField("Optional", text: $title)
.paperField()
} header: {
Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} }
Section("Description") { Section {
TextField("Optional", text: $description, axis: .vertical) TextField("Optional", text: $description, axis: .vertical)
.paperField()
.lineLimit(3...6) .lineLimit(3...6)
} header: {
Text("Description").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} }
Section { Section {
TextField("comma separated", text: $tagsText) TextField("comma separated", text: $tagsText)
.paperField()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
} header: { } header: {
Text("Tags") Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} footer: { } footer: {
Text("Separate tags with commas") Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary)
} }
Section { Section {
Toggle("Mark as unread", isOn: $unread) Toggle("Mark as unread", isOn: $unread)
.font(PaperType.label)
.foregroundStyle(Paper.ink)
.tint(Paper.accent)
} }
if let error { if let error {
Section { Section {
Text(error) Text(error)
.foregroundStyle(.red) .font(PaperType.meta)
.font(.footnote) .foregroundStyle(Paper.alarm)
} }
} }
} }
.listRowBackground(Paper.raised)
.paperSurface()
.navigationTitle("Edit Bookmark") .navigationTitle("Edit Bookmark")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -73,6 +88,8 @@ struct EditBookmarkView: View {
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() } Button("Save") { save() }
.font(PaperType.label)
.tint(Paper.accent)
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving) .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
.overlay { .overlay {
if isSaving { ProgressView().scaleEffect(0.7) } if isSaving { ProgressView().scaleEffect(0.7) }
+112
View File
@@ -17,6 +17,29 @@ enum Paper {
static let ink = dynamic(light: 0x14110C, dark: 0xF1ECE1) static let ink = dynamic(light: 0x14110C, dark: 0xF1ECE1)
static var rule: Color { ink.opacity(0.28) } 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 /// Swatches lifted from the reference, each paired with a dark-mode
/// counterpart. Order matters items hash into it. /// 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 { extension UIColor {
convenience init(rgb: UInt32) { convenience init(rgb: UInt32) {
self.init( self.init(
+5 -4
View File
@@ -1,9 +1,10 @@
import SwiftUI import SwiftUI
/// The library's list presentation. Carries everything `BookmarkRow` carried /// The library's list presentation, and now the app's only bookmark row. It
/// unread state, excerpt, tags, podcast affordance, reading progress in the /// carries what the old system-styled row did unread state, excerpt, tags,
/// paper vocabulary. The favicon becomes the color chip, so the same swatch /// podcast affordance, reading progress in the paper vocabulary. The favicon
/// that identifies a card identifies its row. /// became the color chip, so the same swatch that identifies a card in the grid
/// identifies its row in the list.
struct LibraryListRow: View { struct LibraryListRow: View {
let bookmark: Bookmark let bookmark: Bookmark
var readingProgress: Double = 0 var readingProgress: Double = 0
+17 -14
View File
@@ -17,10 +17,11 @@ struct OnboardingView: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Marks") Text("Marks")
.font(.system(size: 42, weight: .semibold, design: .rounded)) .font(.system(size: 42, design: .serif))
.foregroundStyle(Paper.ink)
Text("Your bookmarks, beautifully.") Text("Your bookmarks, beautifully.")
.font(.system(size: 17)) .font(PaperType.body)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 28) .padding(.horizontal, 28)
@@ -29,8 +30,8 @@ struct OnboardingView: View {
VStack(spacing: 14) { VStack(spacing: 14) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("Server URL") Text("Server URL")
.font(.system(size: 13, weight: .medium)) .font(PaperType.stamp)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
.padding(.horizontal, 2) .padding(.horizontal, 2)
TextField("https://links.example.com", text: $urlText) TextField("https://links.example.com", text: $urlText)
.textContentType(.URL) .textContentType(.URL)
@@ -41,14 +42,15 @@ struct OnboardingView: View {
.submitLabel(.next) .submitLabel(.next)
.onSubmit { focused = .token } .onSubmit { focused = .token }
.padding(14) .padding(14)
.background(Color(.systemGray6)) .paperField()
.background(Paper.raised)
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
} }
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("API Token") Text("API Token")
.font(.system(size: 13, weight: .medium)) .font(PaperType.stamp)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
.padding(.horizontal, 2) .padding(.horizontal, 2)
SecureField("Paste your token", text: $token) SecureField("Paste your token", text: $token)
.textContentType(.password) .textContentType(.password)
@@ -58,11 +60,12 @@ struct OnboardingView: View {
.submitLabel(.done) .submitLabel(.done)
.onSubmit { Task { await connect() } } .onSubmit { Task { await connect() } }
.padding(14) .padding(14)
.background(Color(.systemGray6)) .paperField()
.background(Paper.raised)
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
Text("Find your token at Settings → API Token in Linkding.") Text("Find your token at Settings → API Token in Linkding.")
.font(.system(size: 12)) .font(PaperType.micro)
.foregroundStyle(.tertiary) .foregroundStyle(Paper.tertiary)
.padding(.horizontal, 2) .padding(.horizontal, 2)
} }
} }
@@ -70,8 +73,8 @@ struct OnboardingView: View {
if let err = errorMessage { if let err = errorMessage {
Text(err) Text(err)
.font(.system(size: 14)) .font(PaperType.meta)
.foregroundStyle(.red) .foregroundStyle(Paper.alarm)
.padding(.top, 12) .padding(.top, 12)
.padding(.horizontal, 28) .padding(.horizontal, 28)
} }
@@ -84,7 +87,7 @@ struct OnboardingView: View {
ProgressView().tint(.white) ProgressView().tint(.white)
} else { } else {
Text("Connect") Text("Connect")
.font(.system(size: 17, weight: .semibold)) .font(PaperType.label)
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
+53 -51
View File
@@ -510,6 +510,7 @@ struct PodcastPlayerView: View {
Spacer() Spacer()
} }
.padding(.horizontal, 32) .padding(.horizontal, 32)
.paperSurface()
.navigationTitle("Podcast") .navigationTitle("Podcast")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -520,7 +521,7 @@ struct PodcastPlayerView: View {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { vm.stop(); dismiss() } label: { Button { vm.stop(); dismiss() } label: {
Image(systemName: "stop.circle") Image(systemName: "stop.circle")
.foregroundStyle(.red) .foregroundStyle(Paper.alarm)
} }
} }
} }
@@ -556,16 +557,16 @@ struct PodcastPlayerView: View {
VStack(spacing: 28) { VStack(spacing: 28) {
Image(systemName: "waveform") Image(systemName: "waveform")
.font(.system(size: 52)) .font(.system(size: 52))
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
.symbolEffect(.variableColor.iterative, isActive: true) .symbolEffect(.variableColor.iterative, isActive: true)
VStack(spacing: 10) { VStack(spacing: 10) {
ProgressView(value: progress) ProgressView(value: progress)
.progressViewStyle(.linear) .progressViewStyle(.linear)
.tint(.blue) .tint(Paper.accent)
Text(label) Text(label)
.font(.system(size: 14)) .font(PaperType.meta)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
} }
} }
} }
@@ -573,16 +574,16 @@ struct PodcastPlayerView: View {
private func playerView(title: String) -> some View { private func playerView(title: String) -> some View {
VStack(spacing: 28) { VStack(spacing: 28) {
RoundedRectangle(cornerRadius: 20) RoundedRectangle(cornerRadius: 20)
.fill(Color(.systemGray6)) .fill(Paper.raised)
.frame(width: 220, height: 220) .frame(width: 220, height: 220)
.overlay { .overlay {
Image(systemName: "waveform.circle.fill") Image(systemName: "waveform.circle.fill")
.font(.system(size: 80)) .font(.system(size: 80))
.foregroundStyle(.blue) .foregroundStyle(Paper.accent)
} }
Text(title) Text(title)
.font(.system(size: 18, weight: .semibold)) .font(PaperType.heading)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(3) .lineLimit(3)
@@ -591,32 +592,32 @@ struct PodcastPlayerView: View {
value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }), value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }),
in: 0...vm.duration in: 0...vm.duration
) )
.tint(.primary) .tint(Paper.ink)
HStack { HStack {
Text(formatTime(vm.currentTime)) Text(formatTime(vm.currentTime))
Spacer() Spacer()
Text(formatTime(vm.duration)) Text(formatTime(vm.duration))
} }
.font(.system(size: 12, design: .monospaced)) .font(PaperType.micro)
.foregroundStyle(.tertiary) .foregroundStyle(Paper.tertiary)
} }
HStack(spacing: 36) { HStack(spacing: 36) {
Button { vm.skipBackward15() } label: { Button { vm.skipBackward15() } label: {
Image(systemName: "gobackward.15") Image(systemName: "gobackward.15")
.font(.system(size: 32)) .font(.system(size: 32))
.foregroundStyle(.primary) .foregroundStyle(Paper.ink)
} }
Button { vm.togglePlayPause() } label: { Button { vm.togglePlayPause() } label: {
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72)) .font(.system(size: 72))
.foregroundStyle(.primary) .foregroundStyle(Paper.ink)
} }
Button { vm.skipForward15() } label: { Button { vm.skipForward15() } label: {
Image(systemName: "goforward.15") Image(systemName: "goforward.15")
.font(.system(size: 32)) .font(.system(size: 32))
.foregroundStyle(.primary) .foregroundStyle(Paper.ink)
} }
} }
@@ -636,8 +637,8 @@ struct PodcastPlayerView: View {
} }
} label: { } label: {
Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×") Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×")
.font(.system(size: 14, weight: .semibold)) .font(PaperType.meta)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 7) .padding(.vertical, 7)
.glassEffect(in: Capsule()) .glassEffect(in: Capsule())
@@ -652,7 +653,7 @@ struct PodcastPlayerView: View {
} label: { } label: {
Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon") Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon")
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(sleepActive ? Color.blue : .secondary) .foregroundStyle(sleepActive ? Paper.accent : .secondary)
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 7) .padding(.vertical, 7)
.glassEffect(in: Capsule()) .glassEffect(in: Capsule())
@@ -676,7 +677,7 @@ struct PodcastPlayerView: View {
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) { ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
Label("Share Episode", systemImage: "square.and.arrow.up") Label("Share Episode", systemImage: "square.and.arrow.up")
.font(.system(size: 14, weight: .medium)) .font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
} }
} }
} }
@@ -687,12 +688,12 @@ struct PodcastPlayerView: View {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.system(size: 44)) .font(.system(size: 44))
.foregroundStyle(.red) .foregroundStyle(Paper.alarm)
Text("Generation Failed") Text("Generation Failed")
.font(.headline) .font(PaperType.heading)
Text(message) Text(message)
.font(.system(size: 14)) .font(PaperType.meta)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Button { Button {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude) vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
@@ -701,7 +702,7 @@ struct PodcastPlayerView: View {
.font(.system(size: 15, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
} }
.buttonStyle(.glassProminent) .buttonStyle(.glassProminent)
.tint(.blue) .tint(Paper.accent)
.buttonBorderShape(.capsule) .buttonBorderShape(.capsule)
.padding(.top, 4) .padding(.top, 4)
} }
@@ -724,18 +725,18 @@ struct MiniPlayerView: View {
HStack(spacing: 14) { HStack(spacing: 14) {
Image(systemName: "waveform") Image(systemName: "waveform")
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundStyle(.blue) .foregroundStyle(Paper.accent)
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating) .symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
.frame(width: 22) .frame(width: 22)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle) Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle)
.font(.system(size: 14, weight: .semibold)) .font(PaperType.meta)
.foregroundStyle(.primary) .foregroundStyle(Paper.ink)
.lineLimit(1) .lineLimit(1)
Text(progressLabel) Text(progressLabel)
.font(.system(size: 12)) .font(PaperType.micro)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
} }
Spacer() Spacer()
@@ -749,7 +750,7 @@ struct MiniPlayerView: View {
} label: { } label: {
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill") Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18)) .font(.system(size: 18))
.foregroundStyle(.primary) .foregroundStyle(Paper.ink)
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -760,20 +761,20 @@ struct MiniPlayerView: View {
} label: { } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
.frame(width: 28, height: 28) .frame(width: 28, height: 28)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
.background(.regularMaterial) .background(Paper.raised)
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
// Progress bar along bottom edge // Progress bar along bottom edge
GeometryReader { geo in GeometryReader { geo in
Rectangle() Rectangle()
.fill(Color.blue.opacity(0.6)) .fill(Paper.accent.opacity(0.6))
.frame(width: geo.size.width * progressFraction, height: 3) .frame(width: geo.size.width * progressFraction, height: 3)
} }
.frame(height: 3) .frame(height: 3)
@@ -819,10 +820,10 @@ struct PodcastLibraryView: View {
NavigationStack { NavigationStack {
Group { Group {
if library.entries.isEmpty { if library.entries.isEmpty {
ContentUnavailableView( PaperEmptyState(
"No Podcasts", title: "No Podcasts",
systemImage: "headphones", systemImage: "headphones",
description: Text("Podcasts you generate from bookmarks will appear here.") message: "Podcasts you generate from bookmarks will appear here."
) )
} else { } else {
List { List {
@@ -840,6 +841,7 @@ struct PodcastLibraryView: View {
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
} }
} }
.paperSurface()
.navigationTitle("Podcasts") .navigationTitle("Podcasts")
.toolbar { .toolbar {
if !unplayed.isEmpty { if !unplayed.isEmpty {
@@ -888,18 +890,18 @@ struct PodcastLibraryView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl) Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium)) .font(PaperType.quote)
.lineLimit(2) .lineLimit(2)
.foregroundStyle(entry.isPlayed ? .secondary : .primary) .foregroundStyle(entry.isPlayed ? .secondary : .primary)
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12)) .font(PaperType.micro)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
} }
Spacer() Spacer()
if entry.isPlayed { if entry.isPlayed {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(.green) .foregroundStyle(Paper.affirm)
.accessibilityLabel("Played") .accessibilityLabel("Played")
} }
Button { Button {
@@ -907,7 +909,7 @@ struct PodcastLibraryView: View {
} label: { } label: {
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36)) .font(.system(size: 36))
.foregroundStyle(.blue) .foregroundStyle(Paper.accent)
.contentTransition(.symbolEffect(.replace)) .contentTransition(.symbolEffect(.replace))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -1005,17 +1007,17 @@ struct EpisodePickerView: View {
HStack(spacing: 12) { HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl) Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium)) .font(PaperType.quote)
.lineLimit(2) .lineLimit(2)
if entry.articleUrl != bookmark.url { if entry.articleUrl != bookmark.url {
Text(entry.articleUrl) Text(entry.articleUrl)
.font(.system(size: 11)) .font(PaperType.micro)
.foregroundStyle(.tertiary) .foregroundStyle(Paper.tertiary)
.lineLimit(1) .lineLimit(1)
} }
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12)) .font(PaperType.micro)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
} }
Spacer() Spacer()
Button { Button {
@@ -1026,7 +1028,7 @@ struct EpisodePickerView: View {
} label: { } label: {
Image(systemName: "play.circle.fill") Image(systemName: "play.circle.fill")
.font(.system(size: 36)) .font(.system(size: 36))
.foregroundStyle(.blue) .foregroundStyle(Paper.accent)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -1095,21 +1097,21 @@ struct EpisodeDetailView: View {
Section { Section {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(entry.title ?? entry.articleUrl) Text(entry.title ?? entry.articleUrl)
.font(.system(size: 20, weight: .semibold)) .font(PaperType.display)
Label(domain, systemImage: "link") Label(domain, systemImage: "link")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
.lineLimit(1) .lineLimit(1)
HStack(spacing: 12) { HStack(spacing: 12) {
Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted), Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted),
systemImage: "calendar") systemImage: "calendar")
if isPlayed { if isPlayed {
Label("Played", systemImage: "checkmark.circle.fill") Label("Played", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green) .foregroundStyle(Paper.affirm)
} }
} }
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
@@ -1118,7 +1120,7 @@ struct EpisodeDetailView: View {
Section("Summary") { Section("Summary") {
Text(summary) Text(summary)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
} }
} }
+3 -2
View File
@@ -21,6 +21,7 @@ struct SearchView: View {
} }
} }
.listStyle(.plain) .listStyle(.plain)
.paperSurface()
.navigationTitle("Search") .navigationTitle("Search")
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
@@ -33,9 +34,9 @@ struct SearchView: View {
} }
.overlay { .overlay {
if searchText.isEmpty { 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 { } 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 { if viewModel.isLoading {
ProgressView() ProgressView()
+20 -6
View File
@@ -11,14 +11,18 @@ struct SettingsView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
Section("AI Features") { Section {
Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles") Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles")
.font(.system(size: 13)) .font(PaperType.meta)
.foregroundStyle(.secondary) .foregroundStyle(Paper.secondary)
} header: {
Text("AI Features").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} }
Section { Section {
TextField("e.g. listen podcast", text: $autoTagText) TextField("e.g. listen podcast", text: $autoTagText)
.font(PaperType.label)
.foregroundStyle(Paper.ink)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.onChange(of: autoTagText) { _, new in .onChange(of: autoTagText) { _, new in
@@ -27,25 +31,35 @@ struct SettingsView: View {
.map(String.init) .map(String.init)
} }
} header: { } header: {
Text("Auto-Podcast Tags") Text("Auto-Podcast Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} footer: { } footer: {
Text("Saving a bookmark with any of these tags automatically generates a podcast for it.") Text("Saving a bookmark with any of these tags automatically generates a podcast for it.")
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
} }
Section("Server") { Section {
Button(role: .destructive) { Button {
dismiss() dismiss()
onDisconnect() onDisconnect()
} label: { } label: {
Label("Disconnect", systemImage: "person.crop.circle.badge.minus") 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") .navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() } Button("Done") { dismiss() }
.font(PaperType.label)
.tint(Paper.accent)
} }
} }
} }
+31 -20
View File
@@ -35,8 +35,8 @@ struct SourcesView: View {
playOrGeneratePodcast(for: source) playOrGeneratePodcast(for: source)
} label: { } label: {
Image(systemName: podcastIcon(for: source)) Image(systemName: podcastIcon(for: source))
.font(.title3) .font(.system(size: 22))
.foregroundStyle(.blue) .foregroundStyle(Paper.accent)
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -48,7 +48,7 @@ struct SourcesView: View {
} label: { } label: {
Label("Podcast", systemImage: "headphones") Label("Podcast", systemImage: "headphones")
} }
.tint(.blue) .tint(Paper.accent)
Button(role: .destructive) { Button(role: .destructive) {
library.delete(source) library.delete(source)
@@ -59,6 +59,7 @@ struct SourcesView: View {
} }
} }
.listStyle(.plain) .listStyle(.plain)
.paperSurface()
.navigationTitle("Sources") .navigationTitle("Sources")
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
@@ -80,10 +81,12 @@ struct SourcesView: View {
} }
.overlay { .overlay {
if results.isEmpty { if results.isEmpty {
ContentUnavailableView( PaperEmptyState(
searchText.isEmpty ? "No Sources" : "No Results", title: searchText.isEmpty ? "No Sources" : "No Results",
systemImage: searchText.isEmpty ? "tray" : "magnifyingglass", 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 { var body: some View {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: iconName) Image(systemName: iconName)
.font(.system(size: 18, weight: .semibold)) .font(PaperType.stamp)
.foregroundStyle(.blue) .foregroundStyle(Paper.accent)
.frame(width: 30, height: 30) .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) { VStack(alignment: .leading, spacing: 5) {
Text(source.displayTitle) Text(source.displayTitle)
.font(.headline) .font(PaperType.title)
.foregroundStyle(Paper.ink)
.lineLimit(2) .lineLimit(2)
if !source.excerpt.isEmpty { if !source.excerpt.isEmpty {
Text(source.excerpt) Text(source.excerpt)
.font(.subheadline) .font(PaperType.meta)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
.lineLimit(2) .lineLimit(2)
} }
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -220,8 +224,8 @@ private struct SourceRow: View {
Text(source.tags.joined(separator: ", ")) Text(source.tags.joined(separator: ", "))
} }
} }
.font(.caption) .font(PaperType.micro)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
} }
} }
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -265,6 +269,7 @@ private struct ImportTextSourceView: View {
Text("Separate tags with commas") Text("Separate tags with commas")
} }
} }
.paperSurface()
.navigationTitle("Import Text") .navigationTitle("Import Text")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -329,6 +334,8 @@ private struct EditSourceView: View {
Text("Separate tags with commas") Text("Separate tags with commas")
} }
} }
.listRowBackground(Paper.raised)
.paperSurface()
.navigationTitle("Edit Source") .navigationTitle("Edit Source")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -363,10 +370,11 @@ private struct SourceDetailView: View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(source.displayTitle) Text(source.displayTitle)
.font(.title2.weight(.semibold)) .font(PaperType.display)
.foregroundStyle(Paper.ink)
Text(source.kind.label) Text(source.kind.label)
.font(.subheadline) .font(PaperType.meta)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
} }
if !source.tags.isEmpty { if !source.tags.isEmpty {
@@ -374,10 +382,11 @@ private struct SourceDetailView: View {
HStack { HStack {
ForEach(source.tags, id: \.self) { tag in ForEach(source.tags, id: \.self) { tag in
Text(tag) Text(tag)
.font(.caption.weight(.medium)) .font(PaperType.micro)
.foregroundStyle(Paper.secondary)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 5) .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) Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText)
.font(.body) .font(PaperType.body)
.foregroundStyle(Paper.ink)
.textSelection(.enabled) .textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.padding() .padding()
} }
.paperSurface()
.navigationTitle("Source") .navigationTitle("Source")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
+3 -5
View File
@@ -40,14 +40,12 @@ struct TagsView: View {
} }
.overlay { .overlay {
if visibleTags.isEmpty { if visibleTags.isEmpty {
ContentUnavailableView( PaperEmptyState(
query.isEmpty ? "No Tags" : "No Matching Tags", title: query.isEmpty ? "No Tags" : "No Matching Tags",
systemImage: "tag", systemImage: "tag",
description: Text( message: query.isEmpty
query.isEmpty
? "Tags from your bookmarks will appear here." ? "Tags from your bookmarks will appear here."
: "No tag matches “\(query)”." : "No tag matches “\(query)”."
)
) )
} }
} }
+25 -17
View File
@@ -65,7 +65,7 @@ struct ShareView: View {
case .loading: case .loading:
HStack(spacing: 12) { HStack(spacing: 12) {
ProgressView() ProgressView()
Text("Checking…").foregroundStyle(.secondary) Text("Checking…").font(PaperType.meta).foregroundStyle(Paper.secondary)
} }
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 24) .padding(.vertical, 24)
@@ -85,7 +85,11 @@ struct ShareView: View {
} }
} }
.padding(20) .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) .shadow(color: .black.opacity(0.15), radius: 24, y: 8)
} }
@@ -99,19 +103,21 @@ struct ShareView: View {
? "Already saved" ? "Already saved"
: "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))") : "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))")
} icon: { } 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 { } else {
Text("Save bookmark").font(.headline) Text("Save bookmark").font(PaperType.heading).foregroundStyle(Paper.ink)
} }
Text(title.isEmpty ? url : title) Text(title.isEmpty ? url : title)
.font(.subheadline.weight(.medium)) .font(PaperType.quote)
.foregroundStyle(Paper.ink)
.lineLimit(2) .lineLimit(2)
Text(domain) Text(domain)
.font(.caption) .font(PaperType.micro)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
} }
} }
@@ -128,8 +134,8 @@ struct ShareView: View {
HStack(spacing: 6) { HStack(spacing: 6) {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
Text("Suggesting tags…") Text("Suggesting tags…")
.font(.caption) .font(PaperType.micro)
.foregroundStyle(.secondary) .foregroundStyle(Paper.tertiary)
} }
} }
@@ -139,11 +145,11 @@ struct ShareView: View {
ForEach(suggestionChips, id: \.tag) { item in ForEach(suggestionChips, id: \.tag) { item in
Button { addTag(item.tag) } label: { Button { addTag(item.tag) } label: {
Text(item.isAI ? "\(item.tag)" : "+ \(item.tag)") Text(item.isAI ? "\(item.tag)" : "+ \(item.tag)")
.font(.caption.weight(.medium)) .font(PaperType.micro)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.padding(.vertical, 5) .padding(.vertical, 5)
.background((item.isAI ? Color.purple : .blue).opacity(0.12), in: Capsule()) .background((item.isAI ? Paper.accent : Paper.ink).opacity(0.1), in: Capsule())
.foregroundStyle(item.isAI ? Color.purple : .blue) .foregroundStyle(item.isAI ? Paper.accent : Paper.secondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -157,12 +163,14 @@ struct ShareView: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
Toggle("Read later", isOn: $readLater) Toggle("Read later", isOn: $readLater)
.font(.subheadline) .font(PaperType.meta)
.foregroundStyle(Paper.secondary)
Toggle(isOn: $createPodcast) { Toggle(isOn: $createPodcast) {
Label("Create podcast", systemImage: "headphones") 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 { private func statusCard(icon: String, tint: Color, text: String) -> some View {
HStack(spacing: 10) { HStack(spacing: 10) {
Image(systemName: icon).font(.title3).foregroundStyle(tint) Image(systemName: icon).font(.system(size: 22)).foregroundStyle(tint)
Text(text).font(.headline) Text(text).font(PaperType.heading).foregroundStyle(Paper.ink)
} }
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16) .padding(.vertical, 16)
+3
View File
@@ -93,6 +93,9 @@ targets:
- path: Marks/Services/Log.swift - path: Marks/Services/Log.swift
# Cross-process podcast request queue: the extension enqueues, the app runs it. # Cross-process podcast request queue: the extension enqueues, the app runs it.
- path: Marks/Services/PodcastRequests.swift - 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: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension