Carry the library design across the rest of the app
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:
co-authored by
Claude Opus 5
parent
8a777ffc83
commit
fffb3999bf
@@ -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 */,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)”."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user