Carry the library design across the rest of the app
CI / build-and-deploy (pull_request) Successful in 34s
CI / build-and-deploy (push) Successful in 34s

Every screen now speaks the paper vocabulary rather than only the
bookmarks screen: Search, Sources, Podcasts and the player, Settings,
Add/Edit, Smart Collections, Ask, Onboarding, the browser toolbar, the
Siri snippets, and the share extension's save card.

The change is mostly a design system rather than per-screen tweaks.
LibraryKit gains:

- Semantic colors — secondary/tertiary/faint text, `raised` surfaces,
  and accent/alarm/affirm, so screens stop reaching for .secondary,
  systemGray, .blue, .red and .green. The three status colors come out
  of the palette (the swatch blue, vermilion and forest) rather than
  from the system set, so the design keeps spending one set of inks.
- PaperType — the two voices made explicit. Prose (titles, summaries,
  anything a person wrote) is serif; anything the machine contributes
  (domains, dates, counts, tags, labels, buttons) is monospaced. Keeping
  that split strict is what makes the design read as archival rather
  than as decoration.
- paperSurface() / paperField() / paperCard() for grounds, inputs and
  raised blocks.
- PaperEmptyState, because ContentUnavailableView can't be restyled — it
  draws its own bold system type and grey, which was the loudest
  remaining system voice once the screens moved onto the sheet. All nine
  usages are converted.

The app also sets one accent for the system chrome it doesn't draw —
tab bar, search fields, switches, swipe actions — which otherwise stayed
system blue around paper screens.

BookmarkRow is deleted. Once Search moved to the library row and
TagBookmarksView was gone, nothing passed .classic, so the style fork in
BookmarkListRow went with it. There is one bookmark row now.

The share extension compiles LibraryKit directly, since its save card is
a user-facing surface and should not be the one place still using system
styling.

Verified on device in both color schemes; screens toured with a
temporary UI test harness (removed). Suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
This commit is contained in:
Krishna Kumar
2026-07-27 12:50:45 -05:00
co-authored by Claude Opus 5
parent 8a777ffc83
commit fffb3999bf
21 changed files with 418 additions and 407 deletions
+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()
+21 -7
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,15 +40,13 @@ 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