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 */; };
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB1DA808EE8041C4546DAAB /* SourcesView.swift */; };
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; };
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
A30907113FD5D682478750A7 /* LibraryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */; };
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */; };
@@ -165,7 +165,6 @@
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
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>"; };
@@ -335,7 +334,6 @@
217E6702DE1210AC38ED16D1 /* AskView.swift */,
240DBB87940F8D255A812EB2 /* BookmarkActions.swift */,
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
629C41E0BC28EB6359D50CFD /* BrowserView.swift */,
@@ -539,6 +537,7 @@
files = (
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */,
70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */,
A30907113FD5D682478750A7 /* LibraryKit.swift in Sources */,
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
@@ -576,7 +575,6 @@
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */,
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */,
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
+13 -11
View File
@@ -8,18 +8,19 @@ struct BookmarkSnippetView: View {
HStack(spacing: 12) {
Image(systemName: "bookmark.fill")
.font(.title2)
.foregroundStyle(.tint)
.foregroundStyle(Paper.accent)
VStack(alignment: .leading, spacing: 3) {
Text(entity.title)
.font(.headline)
.font(PaperType.heading)
.foregroundStyle(Paper.ink)
.lineLimit(2)
Text(entity.host)
.font(.subheadline)
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
if !entity.tags.isEmpty {
Text(entity.tags.map { "#\($0)" }.joined(separator: " "))
.font(.caption)
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
.lineLimit(1)
}
}
@@ -38,14 +39,15 @@ struct SummarySnippetView: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "sparkles").foregroundStyle(.tint)
Text(title).font(.headline).lineLimit(2)
Image(systemName: "sparkles").foregroundStyle(Paper.accent)
Text(title).font(PaperType.heading).foregroundStyle(Paper.ink).lineLimit(2)
}
Text(host)
.font(.caption)
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
Text(summary)
.font(.body)
.font(PaperType.body)
.foregroundStyle(Paper.ink)
.fixedSize(horizontal: false, vertical: true)
}
.padding()
+4
View File
@@ -70,6 +70,10 @@ struct MainContainer: View {
SearchView(viewModel: viewModel)
}
}
// One accent for every system control the app doesn't draw itself
// tab bar selection, search fields, switches, swipe actions. Without
// this the paper screens sit inside system-blue chrome.
.tint(Paper.accent)
.onOpenURL { url in
handleDeepLink(url)
}
+22 -11
View File
@@ -18,6 +18,7 @@ struct AddBookmarkView: View {
Form {
Section {
TextEditor(text: $importText)
.paperField()
.frame(minHeight: 96)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
@@ -27,6 +28,8 @@ struct AddBookmarkView: View {
extractImportText()
} label: {
Label("Extract Link", systemImage: "link.badge.plus")
.font(PaperType.label)
.foregroundStyle(Paper.accent)
}
.disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
@@ -34,56 +37,62 @@ struct AddBookmarkView: View {
if let importMessage {
Text(importMessage)
.font(.caption)
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
}
} header: {
Text("Import Text")
Text("Import Text").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} footer: {
Text("Paste an email, newsletter, or message and Marks will pull out the first web link.")
Text("Paste an email, newsletter, or message and Marks will pull out the first web link.").font(PaperType.micro).foregroundStyle(Paper.tertiary)
}
Section {
TextField("https://", text: $url)
.paperField()
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("URL")
Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section {
TextField("Optional", text: $title)
.paperField()
} header: {
Text("Title")
Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section {
TextField("Optional", text: $description, axis: .vertical)
.paperField()
.lineLimit(2...5)
} header: {
Text("Notes")
Text("Notes").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section {
TextField("comma separated", text: $tagsText)
.paperField()
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Tags")
Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} footer: {
Text("Separate tags with commas")
Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary)
}
if let error {
Section {
Text(error)
.foregroundStyle(.red)
.font(.footnote)
.font(PaperType.meta)
.foregroundStyle(Paper.alarm)
}
}
}
.listRowBackground(Paper.raised)
.paperSurface()
.navigationTitle("Add Bookmark")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -92,6 +101,8 @@ struct AddBookmarkView: View {
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.font(PaperType.label)
.tint(Paper.accent)
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
.overlay {
if isSaving { ProgressView().scaleEffect(0.7) }
+31 -17
View File
@@ -18,20 +18,23 @@ struct AskView: View {
case .checking:
ProgressView()
case .unavailable(let message):
ContentUnavailableView(
"Unavailable",
PaperEmptyState(
title: "Unavailable",
systemImage: "sparkles.slash",
description: Text(message)
message: message
)
case .ready:
ready
}
}
.paperSurface()
.navigationTitle("Ask Your Bookmarks")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.font(PaperType.label)
.tint(Paper.accent)
}
}
}
@@ -42,17 +45,19 @@ struct AskView: View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if answer.isEmpty && !isLoading && errorText == nil {
ContentUnavailableView {
Label("Ask anything", systemImage: "sparkles")
} description: {
Text("Answers come from your saved bookmarks, generated on-device.")
}
PaperEmptyState(
title: "Ask anything",
systemImage: "sparkles",
message: "Answers come from your saved bookmarks, generated on-device."
)
.padding(.top, 40)
}
if isLoading {
HStack(spacing: 8) {
ProgressView()
Text("Searching your bookmarks…").foregroundStyle(.secondary)
Text("Searching your bookmarks…")
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
}
}
if !answer.isEmpty {
@@ -61,7 +66,9 @@ struct AskView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
if let errorText {
Text(errorText).foregroundStyle(.red)
Text(errorText)
.font(PaperType.meta)
.foregroundStyle(Paper.alarm)
}
}
.padding()
@@ -69,17 +76,24 @@ struct AskView: View {
HStack(spacing: 10) {
TextField("Ask about your bookmarks…", text: $question, axis: .vertical)
.font(PaperType.body)
.foregroundStyle(Paper.ink)
.lineLimit(1...4)
.focused($focused)
.submitLabel(.send)
.onSubmit(send)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill").font(.title2)
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 26))
.foregroundStyle(Paper.accent)
}
.disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading)
}
.padding()
.background(.bar)
.background(Paper.raised)
.overlay(alignment: .top) {
Rectangle().fill(Paper.rule.opacity(0.5)).frame(height: 0.6)
}
}
.onAppear { focused = true }
}
@@ -112,18 +126,18 @@ private struct MarkdownAnswer: View {
private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
switch block {
case .heading(let line):
inline(line).font(.headline)
inline(line).font(PaperType.heading)
case .bullet(let line):
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("").foregroundStyle(.secondary)
inline(line)
Text("").foregroundStyle(Paper.tertiary)
inline(line).font(PaperType.body)
}
case .paragraph(let line):
inline(line)
inline(line).font(PaperType.body)
}
}
}
+9 -30
View File
@@ -1,18 +1,11 @@
import SwiftUI
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView.
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet.
/// Full-featured list row used by BookmarksView and SearchView. Owns podcast
/// and episode-picker sheet state; parent owns the BrowserView sheet.
struct BookmarkListRow: View {
/// Which visual language the row speaks. `.library` is the paper design
/// used by the bookmarks screen; `.classic` is the system-styled row the
/// Tags and Search screens still use, so redesigning one doesn't silently
/// restyle the others.
enum Style { case classic, library }
let bookmark: Bookmark
let viewModel: BookmarksViewModel
var readingProgress: Double = 0
var style: Style = .classic
let onOpen: () -> Void
var onEdit: (() -> Void)? = nil
@@ -21,13 +14,17 @@ struct BookmarkListRow: View {
@State private var episodePickerBookmark: Bookmark?
var body: some View {
row
LibraryListRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
.contentShape(Rectangle())
.onTapGesture { onOpen() }
.listRowInsets(EdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18))
.listRowSeparator(.visible)
.listRowSeparatorTint(style == .library ? Paper.rule.opacity(0.5) : nil)
.listRowBackground(style == .library ? Paper.sheet : nil)
.listRowSeparatorTint(Paper.rule.opacity(0.5))
.listRowBackground(Paper.sheet)
// Delete is destructive and not undoable require an explicit tap on the
// revealed button rather than letting a single full swipe delete instantly.
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
@@ -68,24 +65,6 @@ struct BookmarkListRow: View {
)
}
@ViewBuilder
private var row: some View {
switch style {
case .classic:
BookmarkRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
case .library:
LibraryListRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
}
}
private func handlePodcast() {
launchPodcast(
for: bookmark,
-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 {
HStack(alignment: .top, spacing: 11) {
RoundedRectangle(cornerRadius: 7)
.fill(Color(.systemGray5))
.frame(width: 32, height: 32)
RoundedRectangle(cornerRadius: 3)
.fill(Paper.ink.opacity(0.09))
.frame(width: 34, height: 46)
VStack(alignment: .leading, spacing: 7) {
Capsule()
.fill(Color(.systemGray5))
.fill(Paper.ink.opacity(0.09))
.frame(maxWidth: .infinity)
.frame(height: 13)
.frame(height: 15)
Capsule()
.fill(Color(.systemGray6))
.frame(width: 140, height: 10)
.fill(Paper.ink.opacity(0.06))
.frame(width: 140, height: 12)
}
.padding(.top, 4)
Spacer()
@@ -154,10 +154,10 @@ struct BookmarksView: View {
}
.overlay {
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
ContentUnavailableView(
"No Bookmarks",
PaperEmptyState(
title: "No Bookmarks",
systemImage: "bookmark",
description: Text("Bookmarks you save will appear here.")
message: "Bookmarks you save will appear here."
)
.transition(.opacity)
}
@@ -248,15 +248,14 @@ struct BookmarksView: View {
HStack(spacing: 10) {
ProgressView(value: viewModel.enrichmentProgress)
.progressViewStyle(.linear)
.tint(.primary)
.tint(Paper.ink)
Text("Adding AI summaries…")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.paperCard(cornerRadius: 12)
.padding(.horizontal, 16)
}
@@ -265,15 +264,16 @@ struct BookmarksView: View {
return HStack(spacing: 10) {
Image(systemName: "waveform")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.blue)
.foregroundStyle(Paper.accent)
.symbolEffect(.variableColor.iterative, isActive: true)
VStack(alignment: .leading, spacing: 2) {
Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…")
.font(.system(size: 13, weight: .medium))
.font(PaperType.stamp)
.foregroundStyle(Paper.ink)
if let first = jobs.first {
Text(first.title.isEmpty ? first.label : first.title)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
.lineLimit(1)
}
}
@@ -281,7 +281,7 @@ struct BookmarksView: View {
if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.blue)
.tint(Paper.accent)
.frame(width: 44)
} else {
ProgressView()
@@ -289,8 +289,7 @@ struct BookmarksView: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.paperCard(cornerRadius: 12)
.padding(.horizontal, 16)
}
@@ -308,7 +307,6 @@ struct BookmarksView: View {
bookmark: bookmark,
viewModel: viewModel,
readingProgress: readingProgress[bookmark.url] ?? 0,
style: .library,
onOpen: { browsingBookmark = bookmark },
onEdit: { editingBookmark = bookmark }
)
+4 -4
View File
@@ -253,7 +253,7 @@ struct BrowserView: View {
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
GeometryReader { geo in
Rectangle()
.fill(Color.blue.opacity(0.55))
.fill(Paper.accent.opacity(0.55))
.frame(width: geo.size.width * state.readingProgress, height: 3)
}
.frame(height: 3)
@@ -409,11 +409,11 @@ struct BrowserView: View {
.contentShape(Rectangle())
}
}
.font(.system(size: 17))
.foregroundStyle(.primary)
.font(PaperType.label)
.foregroundStyle(Paper.ink)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(.bar)
.background(Paper.raised)
.overlay(alignment: .top) { Divider() }
}
}
+16 -11
View File
@@ -11,41 +11,44 @@ struct CollectionsView: View {
VStack(spacing: 16) {
ProgressView()
Text("Building smart collections…")
.font(.system(size: 15))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.smartCollections.isEmpty {
ContentUnavailableView(
"No Collections Yet",
PaperEmptyState(
title: "No Collections Yet",
systemImage: "sparkles",
description: Text("Tap \"Smart Collections\" to group your bookmarks by topic.")
message: "Tap \"Smart Collections\" to group your bookmarks by topic."
)
} else {
List(viewModel.smartCollections) { collection in
Section {
let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) }
ForEach(items) { bookmark in
BookmarkRow(bookmark: bookmark)
LibraryListRow(bookmark: bookmark)
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
.listRowBackground(Paper.sheet)
.listRowSeparatorTint(Paper.rule.opacity(0.5))
}
} header: {
VStack(alignment: .leading, spacing: 2) {
Text(collection.name)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
.font(PaperType.heading)
.foregroundStyle(Paper.ink)
if !collection.description.isEmpty {
Text(collection.description)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
}
}
.padding(.vertical, 4)
}
}
.listStyle(.insetGrouped)
.listStyle(.plain)
}
}
.paperSurface()
.navigationTitle("Smart Collections")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -59,6 +62,8 @@ struct CollectionsView: View {
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.font(PaperType.label)
.tint(Paper.accent)
}
}
}
+24 -7
View File
@@ -27,44 +27,59 @@ struct EditBookmarkView: View {
var body: some View {
NavigationStack {
Form {
Section("URL") {
Section {
TextField("https://", text: $url)
.paperField()
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section("Title") {
Section {
TextField("Optional", text: $title)
.paperField()
} header: {
Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section("Description") {
Section {
TextField("Optional", text: $description, axis: .vertical)
.paperField()
.lineLimit(3...6)
} header: {
Text("Description").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section {
TextField("comma separated", text: $tagsText)
.paperField()
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Tags")
Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} footer: {
Text("Separate tags with commas")
Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary)
}
Section {
Toggle("Mark as unread", isOn: $unread)
.font(PaperType.label)
.foregroundStyle(Paper.ink)
.tint(Paper.accent)
}
if let error {
Section {
Text(error)
.foregroundStyle(.red)
.font(.footnote)
.font(PaperType.meta)
.foregroundStyle(Paper.alarm)
}
}
}
.listRowBackground(Paper.raised)
.paperSurface()
.navigationTitle("Edit Bookmark")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -73,6 +88,8 @@ struct EditBookmarkView: View {
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.font(PaperType.label)
.tint(Paper.accent)
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
.overlay {
if isSaving { ProgressView().scaleEffect(0.7) }
+112
View File
@@ -17,6 +17,29 @@ enum Paper {
static let ink = dynamic(light: 0x14110C, dark: 0xF1ECE1)
static var rule: Color { ink.opacity(0.28) }
/// Text weights, named by role so screens stop hand-picking opacities.
static var secondary: Color { ink.opacity(0.6) }
static var tertiary: Color { ink.opacity(0.45) }
static var faint: Color { ink.opacity(0.3) }
/// A raised surface on the sheet cards, fields, banners. Barely separated
/// from the ground on purpose: this palette does contrast with rules and
/// type, not with stacked greys.
static let raised = dynamic(light: 0xFFFDF8, dark: 0x201C16)
/// The one non-palette color the design spends, for genuinely interactive
/// affordances (links, progress, selection). It is the palette's own blue
/// rather than the system blue, so it belongs to the paper.
static let accent = dynamic(light: 0x115AB5, dark: 0x5B8FD0)
/// Destructive actions still need to read as dangerous; the vermilion
/// swatch does that without importing systemRed.
static let alarm = dynamic(light: 0xC03A18, dark: 0xE07A5C)
/// "Done / played / complete." The forest swatch, so success still comes
/// out of the palette rather than from systemGreen.
static let affirm = dynamic(light: 0x2F6B34, dark: 0x6FA772)
/// Swatches lifted from the reference, each paired with a dark-mode
/// counterpart. Order matters items hash into it.
///
@@ -73,6 +96,95 @@ enum Paper {
}
}
// MARK: Type
/// The library's two voices. Prose titles, summaries, anything a person
/// wrote is serif. Everything the machine contributes domains, dates,
/// counts, tags, labels, buttons is monospaced. Keeping the split strict is
/// what makes the design read as archival rather than as decoration.
enum PaperType {
/// Screen titles that aren't the navigation bar's.
static let display = Font.system(size: 28, design: .serif)
static let title = Font.system(size: 21, design: .serif)
static let heading = Font.system(size: 18, weight: .medium, design: .serif)
static let body = Font.system(size: 18, design: .serif)
static let quote = Font.system(size: 16.5, design: .serif)
/// Machine voice.
static let label = Font.system(size: 16.5, design: .monospaced)
static let meta = Font.system(size: 15, design: .monospaced)
static let micro = Font.system(size: 12, design: .monospaced)
/// Section headers and anything that wants to read as a stamp.
static let stamp = Font.system(size: 12, weight: .medium, design: .monospaced)
}
// MARK: Surfaces
extension View {
/// Puts a screen on the paper ground: clears the system list/scroll
/// background so the sheet shows through, and tints the bar to match so a
/// large title doesn't sit on a white strip above the content.
func paperSurface() -> some View {
self
.scrollContentBackground(.hidden)
.background(Paper.sheet.ignoresSafeArea())
.toolbarBackground(Paper.sheet, for: .navigationBar)
}
/// Editable text. Monospaced, because in this design anything you type is
/// data rather than prose which is also what the reference's form screen
/// did with every field.
func paperField() -> some View {
self
.font(PaperType.label)
.foregroundStyle(Paper.ink)
.tint(Paper.accent)
}
/// A raised block settings groups, banners, editor fields.
func paperCard(cornerRadius: CGFloat = 8) -> some View {
self
.background(
RoundedRectangle(cornerRadius: cornerRadius).fill(Paper.raised)
)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(Paper.rule.opacity(0.5), lineWidth: 0.6)
)
}
}
// MARK: - Empty state
/// The paper equivalent of `ContentUnavailableView`, which can't be restyled
/// it draws its own bold system type and grey, which was the loudest remaining
/// system voice once every screen moved onto the sheet.
struct PaperEmptyState: View {
let title: String
let systemImage: String
var message: String?
var body: some View {
VStack(spacing: 12) {
Image(systemName: systemImage)
.font(.system(size: 34, weight: .ultraLight))
.foregroundStyle(Paper.faint)
Text(title)
.font(PaperType.title)
.foregroundStyle(Paper.secondary)
if let message {
Text(message)
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
.multilineTextAlignment(.center)
.frame(maxWidth: 280)
}
}
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
extension UIColor {
convenience init(rgb: UInt32) {
self.init(
+5 -4
View File
@@ -1,9 +1,10 @@
import SwiftUI
/// The library's list presentation. Carries everything `BookmarkRow` carried
/// unread state, excerpt, tags, podcast affordance, reading progress in the
/// paper vocabulary. The favicon becomes the color chip, so the same swatch
/// that identifies a card identifies its row.
/// The library's list presentation, and now the app's only bookmark row. It
/// carries what the old system-styled row did unread state, excerpt, tags,
/// podcast affordance, reading progress in the paper vocabulary. The favicon
/// became the color chip, so the same swatch that identifies a card in the grid
/// identifies its row in the list.
struct LibraryListRow: View {
let bookmark: Bookmark
var readingProgress: Double = 0
+17 -14
View File
@@ -17,10 +17,11 @@ struct OnboardingView: View {
VStack(alignment: .leading, spacing: 8) {
Text("Marks")
.font(.system(size: 42, weight: .semibold, design: .rounded))
.font(.system(size: 42, design: .serif))
.foregroundStyle(Paper.ink)
Text("Your bookmarks, beautifully.")
.font(.system(size: 17))
.foregroundStyle(.secondary)
.font(PaperType.body)
.foregroundStyle(Paper.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 28)
@@ -29,8 +30,8 @@ struct OnboardingView: View {
VStack(spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Server URL")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.font(PaperType.stamp)
.foregroundStyle(Paper.tertiary)
.padding(.horizontal, 2)
TextField("https://links.example.com", text: $urlText)
.textContentType(.URL)
@@ -41,14 +42,15 @@ struct OnboardingView: View {
.submitLabel(.next)
.onSubmit { focused = .token }
.padding(14)
.background(Color(.systemGray6))
.paperField()
.background(Paper.raised)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
VStack(alignment: .leading, spacing: 6) {
Text("API Token")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.font(PaperType.stamp)
.foregroundStyle(Paper.tertiary)
.padding(.horizontal, 2)
SecureField("Paste your token", text: $token)
.textContentType(.password)
@@ -58,11 +60,12 @@ struct OnboardingView: View {
.submitLabel(.done)
.onSubmit { Task { await connect() } }
.padding(14)
.background(Color(.systemGray6))
.paperField()
.background(Paper.raised)
.clipShape(RoundedRectangle(cornerRadius: 12))
Text("Find your token at Settings → API Token in Linkding.")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
.padding(.horizontal, 2)
}
}
@@ -70,8 +73,8 @@ struct OnboardingView: View {
if let err = errorMessage {
Text(err)
.font(.system(size: 14))
.foregroundStyle(.red)
.font(PaperType.meta)
.foregroundStyle(Paper.alarm)
.padding(.top, 12)
.padding(.horizontal, 28)
}
@@ -84,7 +87,7 @@ struct OnboardingView: View {
ProgressView().tint(.white)
} else {
Text("Connect")
.font(.system(size: 17, weight: .semibold))
.font(PaperType.label)
}
}
.frame(maxWidth: .infinity)
+53 -51
View File
@@ -510,6 +510,7 @@ struct PodcastPlayerView: View {
Spacer()
}
.padding(.horizontal, 32)
.paperSurface()
.navigationTitle("Podcast")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -520,7 +521,7 @@ struct PodcastPlayerView: View {
ToolbarItem(placement: .topBarTrailing) {
Button { vm.stop(); dismiss() } label: {
Image(systemName: "stop.circle")
.foregroundStyle(.red)
.foregroundStyle(Paper.alarm)
}
}
}
@@ -556,16 +557,16 @@ struct PodcastPlayerView: View {
VStack(spacing: 28) {
Image(systemName: "waveform")
.font(.system(size: 52))
.foregroundStyle(.secondary)
.foregroundStyle(Paper.secondary)
.symbolEffect(.variableColor.iterative, isActive: true)
VStack(spacing: 10) {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.blue)
.tint(Paper.accent)
Text(label)
.font(.system(size: 14))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
}
}
}
@@ -573,16 +574,16 @@ struct PodcastPlayerView: View {
private func playerView(title: String) -> some View {
VStack(spacing: 28) {
RoundedRectangle(cornerRadius: 20)
.fill(Color(.systemGray6))
.fill(Paper.raised)
.frame(width: 220, height: 220)
.overlay {
Image(systemName: "waveform.circle.fill")
.font(.system(size: 80))
.foregroundStyle(.blue)
.foregroundStyle(Paper.accent)
}
Text(title)
.font(.system(size: 18, weight: .semibold))
.font(PaperType.heading)
.multilineTextAlignment(.center)
.lineLimit(3)
@@ -591,32 +592,32 @@ struct PodcastPlayerView: View {
value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }),
in: 0...vm.duration
)
.tint(.primary)
.tint(Paper.ink)
HStack {
Text(formatTime(vm.currentTime))
Spacer()
Text(formatTime(vm.duration))
}
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.tertiary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
HStack(spacing: 36) {
Button { vm.skipBackward15() } label: {
Image(systemName: "gobackward.15")
.font(.system(size: 32))
.foregroundStyle(.primary)
.foregroundStyle(Paper.ink)
}
Button { vm.togglePlayPause() } label: {
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.primary)
.foregroundStyle(Paper.ink)
}
Button { vm.skipForward15() } label: {
Image(systemName: "goforward.15")
.font(.system(size: 32))
.foregroundStyle(.primary)
.foregroundStyle(Paper.ink)
}
}
@@ -636,8 +637,8 @@ struct PodcastPlayerView: View {
}
} label: {
Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.glassEffect(in: Capsule())
@@ -652,7 +653,7 @@ struct PodcastPlayerView: View {
} label: {
Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(sleepActive ? Color.blue : .secondary)
.foregroundStyle(sleepActive ? Paper.accent : .secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.glassEffect(in: Capsule())
@@ -676,7 +677,7 @@ struct PodcastPlayerView: View {
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
Label("Share Episode", systemImage: "square.and.arrow.up")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
.foregroundStyle(Paper.secondary)
}
}
}
@@ -687,12 +688,12 @@ struct PodcastPlayerView: View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 44))
.foregroundStyle(.red)
.foregroundStyle(Paper.alarm)
Text("Generation Failed")
.font(.headline)
.font(PaperType.heading)
Text(message)
.font(.system(size: 14))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
.multilineTextAlignment(.center)
Button {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
@@ -701,7 +702,7 @@ struct PodcastPlayerView: View {
.font(.system(size: 15, weight: .semibold))
}
.buttonStyle(.glassProminent)
.tint(.blue)
.tint(Paper.accent)
.buttonBorderShape(.capsule)
.padding(.top, 4)
}
@@ -724,18 +725,18 @@ struct MiniPlayerView: View {
HStack(spacing: 14) {
Image(systemName: "waveform")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.blue)
.foregroundStyle(Paper.accent)
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
.font(PaperType.meta)
.foregroundStyle(Paper.ink)
.lineLimit(1)
Text(progressLabel)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.secondary)
}
Spacer()
@@ -749,7 +750,7 @@ struct MiniPlayerView: View {
} label: {
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18))
.foregroundStyle(.primary)
.foregroundStyle(Paper.ink)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
@@ -760,20 +761,20 @@ struct MiniPlayerView: View {
} label: {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.foregroundStyle(Paper.secondary)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.regularMaterial)
.background(Paper.raised)
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(alignment: .bottom) {
// Progress bar along bottom edge
GeometryReader { geo in
Rectangle()
.fill(Color.blue.opacity(0.6))
.fill(Paper.accent.opacity(0.6))
.frame(width: geo.size.width * progressFraction, height: 3)
}
.frame(height: 3)
@@ -819,10 +820,10 @@ struct PodcastLibraryView: View {
NavigationStack {
Group {
if library.entries.isEmpty {
ContentUnavailableView(
"No Podcasts",
PaperEmptyState(
title: "No Podcasts",
systemImage: "headphones",
description: Text("Podcasts you generate from bookmarks will appear here.")
message: "Podcasts you generate from bookmarks will appear here."
)
} else {
List {
@@ -840,6 +841,7 @@ struct PodcastLibraryView: View {
.listStyle(.insetGrouped)
}
}
.paperSurface()
.navigationTitle("Podcasts")
.toolbar {
if !unplayed.isEmpty {
@@ -888,18 +890,18 @@ struct PodcastLibraryView: View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium))
.font(PaperType.quote)
.lineLimit(2)
.foregroundStyle(entry.isPlayed ? .secondary : .primary)
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.secondary)
}
Spacer()
if entry.isPlayed {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 15))
.foregroundStyle(.green)
.foregroundStyle(Paper.affirm)
.accessibilityLabel("Played")
}
Button {
@@ -907,7 +909,7 @@ struct PodcastLibraryView: View {
} label: {
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
.foregroundStyle(Paper.accent)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
@@ -1005,17 +1007,17 @@ struct EpisodePickerView: View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium))
.font(PaperType.quote)
.lineLimit(2)
if entry.articleUrl != bookmark.url {
Text(entry.articleUrl)
.font(.system(size: 11))
.foregroundStyle(.tertiary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
.lineLimit(1)
}
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.secondary)
}
Spacer()
Button {
@@ -1026,7 +1028,7 @@ struct EpisodePickerView: View {
} label: {
Image(systemName: "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
.foregroundStyle(Paper.accent)
}
.buttonStyle(.plain)
}
@@ -1095,21 +1097,21 @@ struct EpisodeDetailView: View {
Section {
VStack(alignment: .leading, spacing: 8) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 20, weight: .semibold))
.font(PaperType.display)
Label(domain, systemImage: "link")
.font(.subheadline)
.foregroundStyle(.secondary)
.foregroundStyle(Paper.secondary)
.lineLimit(1)
HStack(spacing: 12) {
Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted),
systemImage: "calendar")
if isPlayed {
Label("Played", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.foregroundStyle(Paper.affirm)
}
}
.font(.caption)
.foregroundStyle(.secondary)
.foregroundStyle(Paper.secondary)
}
.padding(.vertical, 4)
}
@@ -1118,7 +1120,7 @@ struct EpisodeDetailView: View {
Section("Summary") {
Text(summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.foregroundStyle(Paper.secondary)
}
}
+3 -2
View File
@@ -21,6 +21,7 @@ struct SearchView: View {
}
}
.listStyle(.plain)
.paperSurface()
.navigationTitle("Search")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
@@ -33,9 +34,9 @@ struct SearchView: View {
}
.overlay {
if searchText.isEmpty {
ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag."))
PaperEmptyState(title: "Search Bookmarks", systemImage: "magnifyingglass", message: "Search by title, URL, or tag.")
} else if results.isEmpty && !viewModel.isLoading {
ContentUnavailableView.search(text: searchText)
PaperEmptyState(title: "No Results", systemImage: "magnifyingglass", message: "Nothing matches \u{201C}\(searchText)\u{201D}.")
}
if viewModel.isLoading {
ProgressView()
+20 -6
View File
@@ -11,14 +11,18 @@ struct SettingsView: View {
var body: some View {
NavigationStack {
List {
Section("AI Features") {
Section {
Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
} header: {
Text("AI Features").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section {
TextField("e.g. listen podcast", text: $autoTagText)
.font(PaperType.label)
.foregroundStyle(Paper.ink)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onChange(of: autoTagText) { _, new in
@@ -27,25 +31,35 @@ struct SettingsView: View {
.map(String.init)
}
} header: {
Text("Auto-Podcast Tags")
Text("Auto-Podcast Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} footer: {
Text("Saving a bookmark with any of these tags automatically generates a podcast for it.")
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
}
Section("Server") {
Button(role: .destructive) {
Section {
Button {
dismiss()
onDisconnect()
} label: {
Label("Disconnect", systemImage: "person.crop.circle.badge.minus")
.font(PaperType.label)
.foregroundStyle(Paper.alarm)
}
} header: {
Text("Server").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
}
.listRowBackground(Paper.raised)
.paperSurface()
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.font(PaperType.label)
.tint(Paper.accent)
}
}
}
+31 -20
View File
@@ -35,8 +35,8 @@ struct SourcesView: View {
playOrGeneratePodcast(for: source)
} label: {
Image(systemName: podcastIcon(for: source))
.font(.title3)
.foregroundStyle(.blue)
.font(.system(size: 22))
.foregroundStyle(Paper.accent)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
@@ -48,7 +48,7 @@ struct SourcesView: View {
} label: {
Label("Podcast", systemImage: "headphones")
}
.tint(.blue)
.tint(Paper.accent)
Button(role: .destructive) {
library.delete(source)
@@ -59,6 +59,7 @@ struct SourcesView: View {
}
}
.listStyle(.plain)
.paperSurface()
.navigationTitle("Sources")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
@@ -80,10 +81,12 @@ struct SourcesView: View {
}
.overlay {
if results.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "No Sources" : "No Results",
PaperEmptyState(
title: searchText.isEmpty ? "No Sources" : "No Results",
systemImage: searchText.isEmpty ? "tray" : "magnifyingglass",
description: Text(searchText.isEmpty ? "Import text, PDFs, or files to keep non-web material in Marks." : "Try a different search.")
message: searchText.isEmpty
? "Import text, PDFs, or files to keep non-web material in Marks."
: "Try a different search."
)
}
}
@@ -198,19 +201,20 @@ private struct SourceRow: View {
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: iconName)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.blue)
.font(PaperType.stamp)
.foregroundStyle(Paper.accent)
.frame(width: 30, height: 30)
.background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
.background(Paper.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
VStack(alignment: .leading, spacing: 5) {
Text(source.displayTitle)
.font(.headline)
.font(PaperType.title)
.foregroundStyle(Paper.ink)
.lineLimit(2)
if !source.excerpt.isEmpty {
Text(source.excerpt)
.font(.subheadline)
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
.lineLimit(2)
}
HStack(spacing: 8) {
@@ -220,8 +224,8 @@ private struct SourceRow: View {
Text(source.tags.joined(separator: ", "))
}
}
.font(.caption)
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
}
.padding(.vertical, 8)
@@ -265,6 +269,7 @@ private struct ImportTextSourceView: View {
Text("Separate tags with commas")
}
}
.paperSurface()
.navigationTitle("Import Text")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -329,6 +334,8 @@ private struct EditSourceView: View {
Text("Separate tags with commas")
}
}
.listRowBackground(Paper.raised)
.paperSurface()
.navigationTitle("Edit Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -363,10 +370,11 @@ private struct SourceDetailView: View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(source.displayTitle)
.font(.title2.weight(.semibold))
.font(PaperType.display)
.foregroundStyle(Paper.ink)
Text(source.kind.label)
.font(.subheadline)
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
}
if !source.tags.isEmpty {
@@ -374,10 +382,11 @@ private struct SourceDetailView: View {
HStack {
ForEach(source.tags, id: \.self) { tag in
Text(tag)
.font(.caption.weight(.medium))
.font(PaperType.micro)
.foregroundStyle(Paper.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.blue.opacity(0.12), in: Capsule())
.background(Paper.accent.opacity(0.12), in: Capsule())
}
}
}
@@ -411,12 +420,14 @@ private struct SourceDetailView: View {
}
Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText)
.font(.body)
.font(PaperType.body)
.foregroundStyle(Paper.ink)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
}
.paperSurface()
.navigationTitle("Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
+3 -5
View File
@@ -40,14 +40,12 @@ struct TagsView: View {
}
.overlay {
if visibleTags.isEmpty {
ContentUnavailableView(
query.isEmpty ? "No Tags" : "No Matching Tags",
PaperEmptyState(
title: query.isEmpty ? "No Tags" : "No Matching Tags",
systemImage: "tag",
description: Text(
query.isEmpty
message: query.isEmpty
? "Tags from your bookmarks will appear here."
: "No tag matches “\(query)”."
)
)
}
}
+25 -17
View File
@@ -65,7 +65,7 @@ struct ShareView: View {
case .loading:
HStack(spacing: 12) {
ProgressView()
Text("Checking…").foregroundStyle(.secondary)
Text("Checking…").font(PaperType.meta).foregroundStyle(Paper.secondary)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 24)
@@ -85,7 +85,11 @@ struct ShareView: View {
}
}
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.background(Paper.sheet, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(Paper.rule.opacity(0.5), lineWidth: 0.6)
)
.shadow(color: .black.opacity(0.15), radius: 24, y: 8)
}
@@ -99,19 +103,21 @@ struct ShareView: View {
? "Already saved"
: "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))")
} icon: {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
Image(systemName: "checkmark.circle.fill").foregroundStyle(Paper.affirm)
}
.font(.subheadline.weight(.semibold))
.font(PaperType.label)
.foregroundStyle(Paper.ink)
} else {
Text("Save bookmark").font(.headline)
Text("Save bookmark").font(PaperType.heading).foregroundStyle(Paper.ink)
}
Text(title.isEmpty ? url : title)
.font(.subheadline.weight(.medium))
.font(PaperType.quote)
.foregroundStyle(Paper.ink)
.lineLimit(2)
Text(domain)
.font(.caption)
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
}
@@ -128,8 +134,8 @@ struct ShareView: View {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Suggesting tags…")
.font(.caption)
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
}
@@ -139,11 +145,11 @@ struct ShareView: View {
ForEach(suggestionChips, id: \.tag) { item in
Button { addTag(item.tag) } label: {
Text(item.isAI ? "\(item.tag)" : "+ \(item.tag)")
.font(.caption.weight(.medium))
.font(PaperType.micro)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background((item.isAI ? Color.purple : .blue).opacity(0.12), in: Capsule())
.foregroundStyle(item.isAI ? Color.purple : .blue)
.background((item.isAI ? Paper.accent : Paper.ink).opacity(0.1), in: Capsule())
.foregroundStyle(item.isAI ? Paper.accent : Paper.secondary)
}
.buttonStyle(.plain)
}
@@ -157,12 +163,14 @@ struct ShareView: View {
.textFieldStyle(.roundedBorder)
Toggle("Read later", isOn: $readLater)
.font(.subheadline)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
Toggle(isOn: $createPodcast) {
Label("Create podcast", systemImage: "headphones")
}
.font(.subheadline)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
}
}
@@ -189,8 +197,8 @@ struct ShareView: View {
private func statusCard(icon: String, tint: Color, text: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon).font(.title3).foregroundStyle(tint)
Text(text).font(.headline)
Image(systemName: icon).font(.system(size: 22)).foregroundStyle(tint)
Text(text).font(PaperType.heading).foregroundStyle(Paper.ink)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
+3
View File
@@ -93,6 +93,9 @@ targets:
- path: Marks/Services/Log.swift
# Cross-process podcast request queue: the extension enqueues, the app runs it.
- path: Marks/Services/PodcastRequests.swift
# The save card is a user-facing surface, so it uses the same palette and
# type scale as the app.
- path: Marks/Views/Library/LibraryKit.swift
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension