diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index bab44e0..9974c22 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -14,13 +14,18 @@ 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; }; + 1F1CB72BBCFFB33B6533D5C9 /* LibraryListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E093C878E702891C64D21FD5 /* LibraryListRow.swift */; }; 212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; }; 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; }; 337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; }; 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; + 4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */; }; 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; }; 44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; }; 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; + 50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7510EB352E624C7C9656EA33 /* LibraryView.swift */; }; + 55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */; }; + 59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; 66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; }; @@ -46,8 +51,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 */; }; @@ -58,6 +63,7 @@ B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; }; B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; }; B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; }; + BC866F8D6189334650ADCB95 /* BookmarkActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */; }; BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; }; C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; }; @@ -126,9 +132,12 @@ 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = ""; }; 18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSourceStore.swift; sourceTree = ""; }; 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = ""; }; + 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryGridView.swift; sourceTree = ""; }; 217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = ""; }; 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkActions.swift; sourceTree = ""; }; + 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightRetrievalTests.swift; sourceTree = ""; }; 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = ""; }; 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = ""; }; 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; @@ -141,6 +150,7 @@ 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = ""; }; 6905CD5B1864895E2F84C7DF /* TagSuggester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSuggester.swift; sourceTree = ""; }; 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = ""; }; + 7510EB352E624C7C9656EA33 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = ""; }; 7623601C25E481DF58371F2A /* AppIntentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTests.swift; sourceTree = ""; }; 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSource.swift; sourceTree = ""; }; @@ -153,10 +163,10 @@ 9B7A85A23A13D754F6A75E4D /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = ""; }; 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSnippetViews.swift; sourceTree = ""; }; A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = ""; }; + AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryKit.swift; sourceTree = ""; }; AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = ""; }; AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; }; ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; - B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = ""; }; B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = ""; }; BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTool.swift; sourceTree = ""; }; @@ -172,6 +182,7 @@ D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = ""; }; + E093C878E702891C64D21FD5 /* LibraryListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListRow.swift; sourceTree = ""; }; E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = ""; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSpotlightIndexer.swift; sourceTree = ""; }; @@ -235,10 +246,21 @@ isa = PBXGroup; children = ( 7623601C25E481DF58371F2A /* AppIntentsTests.swift */, + 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */, ); path = MarksTests; sourceTree = ""; }; + 585E3F011CBA8ECA6D1925C0 /* Library */ = { + isa = PBXGroup; + children = ( + 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */, + AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */, + E093C878E702891C64D21FD5 /* LibraryListRow.swift */, + ); + path = Library; + sourceTree = ""; + }; 58E8E316BE3F10C5149AADC3 /* Intents */ = { isa = PBXGroup; children = ( @@ -286,6 +308,14 @@ path = Services; sourceTree = ""; }; + 7F380B7CE96F87441C28DB93 /* Prototypes */ = { + isa = PBXGroup; + children = ( + 7510EB352E624C7C9656EA33 /* LibraryView.swift */, + ); + path = Prototypes; + sourceTree = ""; + }; 85E717A515682EBF67DD199A /* MarksWidget */ = { isa = PBXGroup; children = ( @@ -305,8 +335,8 @@ children = ( AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, 217E6702DE1210AC38ED16D1 /* AskView.swift */, + 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */, CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */, - B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */, 629C41E0BC28EB6359D50CFD /* BrowserView.swift */, @@ -318,6 +348,8 @@ 5C29CB878BC334639E6194E2 /* SettingsView.swift */, CDB1DA808EE8041C4546DAAB /* SourcesView.swift */, B13B9F2D890C7953531AC0D2 /* TagsView.swift */, + 585E3F011CBA8ECA6D1925C0 /* Library */, + 7F380B7CE96F87441C28DB93 /* Prototypes */, ); path = Views; sourceTree = ""; @@ -499,6 +531,7 @@ buildActionMask = 2147483647; files = ( FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */, + 59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -508,6 +541,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 */, @@ -540,11 +574,11 @@ B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */, 68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */, 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, + BC866F8D6189334650ADCB95 /* BookmarkActions.swift in Sources */, 8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */, 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 */, @@ -557,6 +591,10 @@ 81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */, DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */, EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */, + 55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */, + 4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */, + 1F1CB72BBCFFB33B6533D5C9 /* LibraryListRow.swift in Sources */, + 50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, 212F713DCC289C48087B79AE /* Log.swift in Sources */, 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */, diff --git a/Marks/Intents/BookmarkEntity.swift b/Marks/Intents/BookmarkEntity.swift index 94f9164..f619805 100644 --- a/Marks/Intents/BookmarkEntity.swift +++ b/Marks/Intents/BookmarkEntity.swift @@ -17,7 +17,17 @@ struct BookmarkEntity: AppEntity, IndexedEntity { @Property(title: "Title") var title: String - @Property(title: "URL") + /// The explicit `indexingKey` is load-bearing. Left to itself App Intents + /// indexes this property under the attribute set's own `url` key, and + /// Spotlight's Cascade translator types that field as NSString — so every + /// item failed with "Provided object for field url is of class NSURL, + /// expected class: NSString" and nothing reached the index. `contentURL` + /// is URL-typed there, and is the right field for "where this lives" + /// anyway. Routing it there keeps the property a URL for Shortcuts. + /// + /// The failure is silent: translation happens after `indexAppEntities` + /// returns, so indexing logs success either way. + @Property(title: "URL", indexingKey: \.contentURL) var url: URL @Property(title: "Website") @@ -53,7 +63,7 @@ struct BookmarkEntity: AppEntity, IndexedEntity { attrs.title = title attrs.contentDescription = details.isEmpty ? summary : details attrs.keywords = tags - attrs.url = url + attrs.contentURL = url return attrs } } diff --git a/Marks/Intents/IntentSnippetViews.swift b/Marks/Intents/IntentSnippetViews.swift index 120e0fe..7f05de0 100644 --- a/Marks/Intents/IntentSnippetViews.swift +++ b/Marks/Intents/IntentSnippetViews.swift @@ -8,18 +8,19 @@ struct BookmarkSnippetView: View { HStack(spacing: 12) { Image(systemName: "bookmark.fill") .font(.title2) - .foregroundStyle(.tint) + .foregroundStyle(Paper.accent) VStack(alignment: .leading, spacing: 3) { Text(entity.title) - .font(.headline) + .font(PaperType.heading) + .foregroundStyle(Paper.ink) .lineLimit(2) Text(entity.host) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) if !entity.tags.isEmpty { Text(entity.tags.map { "#\($0)" }.joined(separator: " ")) - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) .lineLimit(1) } } @@ -38,14 +39,15 @@ struct SummarySnippetView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { - Image(systemName: "sparkles").foregroundStyle(.tint) - Text(title).font(.headline).lineLimit(2) + Image(systemName: "sparkles").foregroundStyle(Paper.accent) + Text(title).font(PaperType.heading).foregroundStyle(Paper.ink).lineLimit(2) } Text(host) - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) Text(summary) - .font(.body) + .font(PaperType.body) + .foregroundStyle(Paper.ink) .fixedSize(horizontal: false, vertical: true) } .padding() diff --git a/Marks/Intents/IntentSupport.swift b/Marks/Intents/IntentSupport.swift index c0a09bb..ca310b2 100644 --- a/Marks/Intents/IntentSupport.swift +++ b/Marks/Intents/IntentSupport.swift @@ -3,7 +3,7 @@ import AppIntents /// Which top-level tab the app is showing. Used so an intent can switch tabs. enum AppTab: Hashable { - case bookmarks, tags, sources, podcasts, search + case bookmarks, sources, podcasts, search } /// Bridges App Intents (which run in the main app process, since there is no diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index a9516eb..8b0555d 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -12,6 +12,8 @@ private let defaultConfig = ServerConfig( struct MarksApp: App { @State private var serverConfig: ServerConfig = ServerConfig.load() ?? defaultConfig + init() { PaperAppearance.apply() } + var body: some Scene { WindowGroup { MainContainer(config: serverConfig) { @@ -54,9 +56,6 @@ struct MainContainer: View { Tab("Bookmarks", systemImage: "bookmark", value: AppTab.bookmarks) { BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect) } - Tab("Tags", systemImage: "tag", value: AppTab.tags) { - TagsView(viewModel: viewModel) - } Tab("Sources", systemImage: "tray.full", value: AppTab.sources) { SourcesView( library: sourceLibrary, @@ -73,6 +72,10 @@ struct MainContainer: View { SearchView(viewModel: viewModel) } } + // One accent for every system control the app doesn't draw itself — + // tab bar selection, search fields, switches, swipe actions. Without + // this the paper screens sit inside system-blue chrome. + .tint(Paper.accent) .onOpenURL { url in handleDeepLink(url) } diff --git a/Marks/Services/SpotlightBookmarkSearch.swift b/Marks/Services/SpotlightBookmarkSearch.swift index bb55a6d..2e50766 100644 --- a/Marks/Services/SpotlightBookmarkSearch.swift +++ b/Marks/Services/SpotlightBookmarkSearch.swift @@ -24,7 +24,7 @@ enum SpotlightBookmarkSearch { Log.spotlight.debug("Search query=\(rawQuery, privacy: .public) predicate=\(queryString, privacy: .public)") let context = CSSearchQueryContext() - context.fetchAttributes = ["title", "contentDescription", "keywords", "url"] + context.fetchAttributes = ["title", "contentDescription", "keywords", "contentURL"] let query = CSSearchQuery(queryString: queryString, queryContext: context) var out: [RetrievedBookmark] = [] @@ -33,9 +33,9 @@ enum SpotlightBookmarkSearch { let a = result.item.attributeSet out.append(RetrievedBookmark( title: a.title ?? "Untitled", - host: a.url?.host() ?? "", + host: a.contentURL?.host() ?? "", description: a.contentDescription ?? "", - url: a.url?.absoluteString ?? "" + url: a.contentURL?.absoluteString ?? "" )) if out.count >= limit { break } } diff --git a/Marks/Views/AddBookmarkView.swift b/Marks/Views/AddBookmarkView.swift index 3a8ef46..22cc87c 100644 --- a/Marks/Views/AddBookmarkView.swift +++ b/Marks/Views/AddBookmarkView.swift @@ -18,6 +18,7 @@ struct AddBookmarkView: View { Form { Section { TextEditor(text: $importText) + .paperField() .frame(minHeight: 96) .textInputAutocapitalization(.never) .autocorrectionDisabled() @@ -27,6 +28,8 @@ struct AddBookmarkView: View { extractImportText() } label: { Label("Extract Link", systemImage: "link.badge.plus") + .font(PaperType.label) + .foregroundStyle(Paper.accent) } .disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) @@ -34,56 +37,62 @@ struct AddBookmarkView: View { if let importMessage { Text(importMessage) - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } } } header: { - Text("Import Text") + Text("Import Text").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } footer: { - Text("Paste an email, newsletter, or message and Marks will pull out the first web link.") + Text("Paste an email, newsletter, or message and Marks will pull out the first web link.").font(PaperType.micro).foregroundStyle(Paper.tertiary) } Section { TextField("https://", text: $url) + .paperField() .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() } header: { - Text("URL") + Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("Optional", text: $title) + .paperField() } header: { - Text("Title") + Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("Optional", text: $description, axis: .vertical) + .paperField() .lineLimit(2...5) } header: { - Text("Notes") + Text("Notes").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("comma separated", text: $tagsText) + .paperField() .textInputAutocapitalization(.never) .autocorrectionDisabled() } header: { - Text("Tags") + Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } footer: { - Text("Separate tags with commas") + Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary) } if let error { Section { Text(error) - .foregroundStyle(.red) - .font(.footnote) + .font(PaperType.meta) + .foregroundStyle(Paper.alarm) } } } + .listRowBackground(Paper.raised) + .paperSurface() .navigationTitle("Add Bookmark") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -92,6 +101,8 @@ struct AddBookmarkView: View { } ToolbarItem(placement: .confirmationAction) { Button("Save") { save() } + .font(PaperType.label) + .tint(Paper.accent) .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving) .overlay { if isSaving { ProgressView().scaleEffect(0.7) } diff --git a/Marks/Views/AskView.swift b/Marks/Views/AskView.swift index 3988741..cf28088 100644 --- a/Marks/Views/AskView.swift +++ b/Marks/Views/AskView.swift @@ -18,20 +18,23 @@ struct AskView: View { case .checking: ProgressView() case .unavailable(let message): - ContentUnavailableView( - "Unavailable", + PaperEmptyState( + title: "Unavailable", systemImage: "sparkles.slash", - description: Text(message) + message: message ) case .ready: ready } } + .paperSurface() .navigationTitle("Ask Your Bookmarks") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } + .font(PaperType.label) + .tint(Paper.accent) } } } @@ -42,17 +45,19 @@ struct AskView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { if answer.isEmpty && !isLoading && errorText == nil { - ContentUnavailableView { - Label("Ask anything", systemImage: "sparkles") - } description: { - Text("Answers come from your saved bookmarks, generated on-device.") - } + PaperEmptyState( + title: "Ask anything", + systemImage: "sparkles", + message: "Answers come from your saved bookmarks, generated on-device." + ) .padding(.top, 40) } if isLoading { HStack(spacing: 8) { ProgressView() - Text("Searching your bookmarks…").foregroundStyle(.secondary) + Text("Searching your bookmarks…") + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } } if !answer.isEmpty { @@ -61,7 +66,9 @@ struct AskView: View { .frame(maxWidth: .infinity, alignment: .leading) } if let errorText { - Text(errorText).foregroundStyle(.red) + Text(errorText) + .font(PaperType.meta) + .foregroundStyle(Paper.alarm) } } .padding() @@ -69,17 +76,24 @@ struct AskView: View { HStack(spacing: 10) { TextField("Ask about your bookmarks…", text: $question, axis: .vertical) + .font(PaperType.body) + .foregroundStyle(Paper.ink) .lineLimit(1...4) .focused($focused) .submitLabel(.send) .onSubmit(send) Button(action: send) { - Image(systemName: "arrow.up.circle.fill").font(.title2) + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 26)) + .foregroundStyle(Paper.accent) } .disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading) } .padding() - .background(.bar) + .background(Paper.raised) + .overlay(alignment: .top) { + Rectangle().fill(Paper.rule.opacity(0.5)).frame(height: 0.6) + } } .onAppear { focused = true } } @@ -112,18 +126,18 @@ private struct MarkdownAnswer: View { private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) } var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in switch block { case .heading(let line): - inline(line).font(.headline) + inline(line).font(PaperType.heading) case .bullet(let line): HStack(alignment: .firstTextBaseline, spacing: 8) { - Text("•").foregroundStyle(.secondary) - inline(line) + Text("•").foregroundStyle(Paper.tertiary) + inline(line).font(PaperType.body) } case .paragraph(let line): - inline(line) + inline(line).font(PaperType.body) } } } diff --git a/Marks/Views/BookmarkActions.swift b/Marks/Views/BookmarkActions.swift new file mode 100644 index 0000000..7832911 --- /dev/null +++ b/Marks/Views/BookmarkActions.swift @@ -0,0 +1,125 @@ +import SwiftUI + +// MARK: - Shared bookmark actions +// +// The context menu and the podcast launch path are identical whether a bookmark +// is presented as a list row or as a library card. They live here so the two +// presentations can't drift apart — the menu is a @ViewBuilder rather than a +// ViewModifier because each host already owns the sheets it needs to present, +// and a modifier would have forced a second copy of that state. + +/// Every action a bookmark offers, in the order they appear in the menu. +/// +/// `@MainActor` because the callbacks are plain (non-Sendable) UI closures — +/// without it, Swift 6 treats handing them to this function as sending them +/// across isolation domains. +@MainActor +@ViewBuilder +func bookmarkMenuItems( + bookmark: Bookmark, + viewModel: BookmarksViewModel, + openURL: OpenURLAction, + onOpen: @escaping () -> Void, + onEdit: (() -> Void)?, + onPodcast: @escaping () -> Void +) -> some View { + if let onEdit { + Button { onEdit() } label: { + Label("Edit", systemImage: "pencil") + } + } + Button { onOpen() } label: { + Label("Open", systemImage: "globe") + } + Button { + if let url = URL(string: bookmark.url) { openURL(url) } + } label: { + Label("Open in Safari", systemImage: "safari") + } + Button { onPodcast() } label: { + Label("Convert to Podcast", systemImage: "headphones") + } + Divider() + Button { + Task { await viewModel.archive(bookmark) } + } label: { + Label("Archive", systemImage: "archivebox") + } + Button(role: .destructive) { + Task { await viewModel.delete(bookmark) } + } label: { + Label("Delete", systemImage: "trash") + } +} + +/// Resolve what a "convert to podcast" tap should do. Several cached episodes +/// means the user picks; one means play it; none means generate — and +/// generating only takes over the player when it isn't already busy. +/// +/// The caller owns the sheet state because the presenting view has to. +@MainActor +func launchPodcast( + for bookmark: Bookmark, + viewModel: BookmarksViewModel, + showFullPlayer: Binding, + episodePicker: Binding +) { + let episodes = PodcastIndex.find(for: bookmark.url) + if episodes.count >= 2 { + episodePicker.wrappedValue = bookmark + } else if let ep = episodes.first { + viewModel.podcastPlayer.start( + articleUrl: ep.articleUrl, + articleTitle: ep.title ?? bookmark.displayTitle, + claude: viewModel.claude + ) + showFullPlayer.wrappedValue = true + } else if viewModel.playOrGeneratePodcast( + articleUrl: bookmark.url, + title: bookmark.displayTitle + ) { + showFullPlayer.wrappedValue = true + } +} + +/// The two sheets any bookmark presentation needs once it offers podcasts. +struct PodcastSheets: ViewModifier { + let viewModel: BookmarksViewModel + @Binding var showFullPlayer: Bool + @Binding var episodePicker: Bookmark? + + func body(content: Content) -> some View { + content + .sheet(isPresented: $showFullPlayer) { + PodcastPlayerView( + vm: viewModel.podcastPlayer, + articleUrl: viewModel.podcastPlayer.currentArticleUrl, + articleTitle: viewModel.podcastPlayer.currentArticleTitle, + claude: viewModel.claude, + stopOnDismiss: false + ) + } + .sheet(item: $episodePicker) { b in + EpisodePickerView( + bookmark: b, + vm: viewModel.podcastPlayer, + claude: viewModel.claude, + podcastGenerator: viewModel.podcastGenerator + ) + } + } +} + +extension View { + func podcastSheets( + viewModel: BookmarksViewModel, + showFullPlayer: Binding, + episodePicker: Binding + ) -> some View { + modifier(PodcastSheets( + viewModel: viewModel, + showFullPlayer: showFullPlayer, + episodePicker: episodePicker + )) + } +} diff --git a/Marks/Views/BookmarkListRow.swift b/Marks/Views/BookmarkListRow.swift index 1e19e2e..8ca675d 100644 --- a/Marks/Views/BookmarkListRow.swift +++ b/Marks/Views/BookmarkListRow.swift @@ -1,7 +1,7 @@ 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 { let bookmark: Bookmark let viewModel: BookmarksViewModel @@ -14,15 +14,17 @@ struct BookmarkListRow: View { @State private var episodePickerBookmark: Bookmark? var body: some View { - BookmarkRow( + LibraryListRow( bookmark: bookmark, readingProgress: readingProgress, onPodcast: handlePodcast ) .contentShape(Rectangle()) .onTapGesture { onOpen() } - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .listRowInsets(EdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18)) .listRowSeparator(.visible) + .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) { @@ -47,61 +49,28 @@ struct BookmarkListRow: View { } } .contextMenu { - if let onEdit { - Button { onEdit() } label: { - Label("Edit", systemImage: "pencil") - } - } - Button { onOpen() } label: { - Label("Open", systemImage: "globe") - } - Button { - if let url = URL(string: bookmark.url) { openURL(url) } - } label: { - Label("Open in Safari", systemImage: "safari") - } - Button { handlePodcast() } label: { - Label("Convert to Podcast", systemImage: "headphones") - } - Divider() - Button { - Task { await viewModel.archive(bookmark) } - } label: { - Label("Archive", systemImage: "archivebox") - } - Button(role: .destructive) { - Task { await viewModel.delete(bookmark) } - } label: { - Label("Delete", systemImage: "trash") - } - } - .sheet(isPresented: $showFullPlayer) { - PodcastPlayerView( - vm: viewModel.podcastPlayer, - articleUrl: viewModel.podcastPlayer.currentArticleUrl, - articleTitle: viewModel.podcastPlayer.currentArticleTitle, - claude: viewModel.claude, - stopOnDismiss: false + bookmarkMenuItems( + bookmark: bookmark, + viewModel: viewModel, + openURL: openURL, + onOpen: onOpen, + onEdit: onEdit, + onPodcast: handlePodcast ) } - .sheet(item: $episodePickerBookmark) { b in - EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator) - } + .podcastSheets( + viewModel: viewModel, + showFullPlayer: $showFullPlayer, + episodePicker: $episodePickerBookmark + ) } private func handlePodcast() { - let episodes = PodcastIndex.find(for: bookmark.url) - if episodes.count >= 2 { - episodePickerBookmark = bookmark - } else if let ep = episodes.first { - viewModel.podcastPlayer.start( - articleUrl: ep.articleUrl, - articleTitle: ep.title ?? bookmark.displayTitle, - claude: viewModel.claude - ) - showFullPlayer = true - } else if viewModel.playOrGeneratePodcast(articleUrl: bookmark.url, title: bookmark.displayTitle) { - showFullPlayer = true - } + launchPodcast( + for: bookmark, + viewModel: viewModel, + showFullPlayer: $showFullPlayer, + episodePicker: $episodePickerBookmark + ) } } diff --git a/Marks/Views/BookmarkRow.swift b/Marks/Views/BookmarkRow.swift deleted file mode 100644 index 9f8f638..0000000 --- a/Marks/Views/BookmarkRow.swift +++ /dev/null @@ -1,170 +0,0 @@ -import SwiftUI - -struct BookmarkRow: View { - let bookmark: Bookmark - var readingProgress: Double = 0 - var onPodcast: (() -> Void)? = nil - - @State private var podcastTapCount = 0 - @State private var podcastCached = false - - /// Compact, static relative date ("6 min ago"). Using a formatter instead of - /// `Text(_, style: .relative)` avoids the live per-second ticking timer. - private static let relativeFormatter: RelativeDateTimeFormatter = { - let f = RelativeDateTimeFormatter() - f.unitsStyle = .abbreviated - f.dateTimeStyle = .named - return f - }() - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .top, spacing: 11) { - FaviconView(url: bookmark.faviconUrl) - .overlay(alignment: .topLeading) { - if bookmark.unread { - Circle() - .fill(.blue) - .frame(width: 8, height: 8) - .offset(x: -3, y: -3) - } - } - .padding(.top, 1) - - VStack(alignment: .leading, spacing: 4) { - Text(bookmark.displayTitle) - .font(.headline) - .foregroundStyle(.primary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - - HStack(spacing: 4) { - Text(bookmark.domain) - Text("·") - Text(Self.relativeFormatter.localizedString(for: bookmark.dateAdded, relativeTo: Date())) - } - .font(.footnote) - .foregroundStyle(.secondary) - } - - Spacer() - - if let onPodcast { - Button { - podcastTapCount += 1 - onPodcast() - } label: { - Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle") - .font(.title3) - .foregroundStyle(podcastCached ? .blue : Color(.systemGray3)) - } - .buttonStyle(.plain) - .padding(.top, 1) - .sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount) - } - } - - if let excerpt = rowExcerpt { - Text(excerpt.text) - .font(.subheadline) - .italic(excerpt.isAI) - .foregroundStyle(.secondary) - .lineLimit(2) - .padding(.leading, 44) - } - - if !effectiveTags.isEmpty { - ScrollView(.horizontal, showsIndicators: false) { - GlassEffectContainer(spacing: 6) { - HStack(spacing: 6) { - ForEach(effectiveTags, id: \.self) { tag in - Text(tag) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .glassEffect(in: Capsule()) - } - } - } - } - .padding(.leading, 44) - } - - } - .padding(.vertical, 14) - .contentShape(Rectangle()) - .overlay(alignment: .bottom) { - if readingProgress > 0.02 { - GeometryReader { geo in - ZStack(alignment: .leading) { - Rectangle() - .fill(Color(.systemGray5)) - Rectangle() - .fill(Color(.systemGreen)) - .frame(width: geo.size.width * min(readingProgress, 1)) - } - } - .frame(height: 2) - } - } - .task(id: bookmark.url) { - // Stat the podcast cache off the render path: once per appearance - // (and when the URL changes), not on every `body` recomputation. - let path = ClaudeService.cachedPodcastURL(for: bookmark.url).path - podcastCached = await Task.detached { FileManager.default.fileExists(atPath: path) }.value - } - } - - /// Excerpt shown under the title: prefer the AI summary (italic), else the - /// page's scraped description / user note. nil hides the line entirely. - private var rowExcerpt: (text: String, isAI: Bool)? { - if let s = bookmark.aiSummary?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty { - return (s, true) - } - if let e = bookmark.contentExcerpt { - return (e, false) - } - return nil - } - - private var effectiveTags: [String] { - let base = bookmark.tagNames - let ai = bookmark.aiTags ?? [] - let extra = ai.filter { !base.contains($0) }.prefix(3) - return (base + extra).prefix(6).map { $0 } - } -} - -struct FaviconView: View { - let url: String? - - var body: some View { - Group { - if let urlString = url, let faviconUrl = URL(string: urlString) { - AsyncImage(url: faviconUrl) { phase in - switch phase { - case .success(let image): - image.resizable().scaledToFit() - default: - placeholder - } - } - } else { - placeholder - } - } - .frame(width: 32, height: 32) - .clipShape(RoundedRectangle(cornerRadius: 7)) - } - - private var placeholder: some View { - RoundedRectangle(cornerRadius: 6) - .fill(Color(.systemGray5)) - .overlay { - Image(systemName: "bookmark") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color(.systemGray2)) - } - } -} diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 100f816..3427c91 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -8,17 +8,17 @@ private struct SkeletonRow: View { var body: some View { HStack(alignment: .top, spacing: 11) { - RoundedRectangle(cornerRadius: 7) - .fill(Color(.systemGray5)) - .frame(width: 32, height: 32) + RoundedRectangle(cornerRadius: 3) + .fill(Paper.ink.opacity(0.09)) + .frame(width: 34, height: 46) VStack(alignment: .leading, spacing: 7) { Capsule() - .fill(Color(.systemGray5)) + .fill(Paper.ink.opacity(0.09)) .frame(maxWidth: .infinity) - .frame(height: 13) + .frame(height: 15) Capsule() - .fill(Color(.systemGray6)) - .frame(width: 140, height: 10) + .fill(Paper.ink.opacity(0.06)) + .frame(width: 140, height: 12) } .padding(.top, 4) Spacer() @@ -60,36 +60,44 @@ struct BookmarksView: View { @State private var showFullPlayer = false @State private var showAsk = false @State private var readingProgress: [String: Double] = ReadingProgress.all() + @AppStorage("bookmarksLayout") private var layoutRaw = LibraryLayout.list.rawValue + @State private var libraryFilters: [LibraryFilter] = [ + LibraryFilter(name: "Everything", tag: nil) + ] + @State private var librarySelection: LibraryFilter.ID? + @State private var showTags = false + + private var layout: LibraryLayout { LibraryLayout(rawValue: layoutRaw) ?? .list } + private var otherLayout: LibraryLayout { layout == .cards ? .list : .cards } var body: some View { NavigationStack { - List { - if viewModel.isLoading && viewModel.bookmarks.isEmpty { - ForEach(0..<3, id: \.self) { i in - SkeletonRow(delay: Double(i) * 0.13) - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .listRowSeparator(.visible) - } - } else { - ForEach(viewModel.bookmarks) { bookmark in - BookmarkListRow( - bookmark: bookmark, - viewModel: viewModel, - readingProgress: readingProgress[bookmark.url] ?? 0, - onOpen: { browsingBookmark = bookmark }, - onEdit: { editingBookmark = bookmark } - ) - .onAppear { maybeLoadMore(bookmark) } - } - } + VStack(spacing: 0) { + // Both layouts speak the same design now, so the filter strip + // belongs to the screen rather than to one mode — which also + // means a tag filter can no longer go invisible when you switch. + LibraryTagStrip( + filters: $libraryFilters, + selection: $librarySelection, + onChange: applyTagFilter, + onBrowseTags: { showTags = true } + ) - if viewModel.isLoadingMore { - HStack { Spacer(); ProgressView(); Spacer() } - .listRowSeparator(.hidden) + if layout == .cards { + LibraryGridView( + viewModel: viewModel, + onOpen: { browsingBookmark = $0 }, + onEdit: { editingBookmark = $0 } + ) + } else { + bookmarkList } } - .listStyle(.plain) - .animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty) + // The large-title area draws from the content behind it, so the + // paper ground has to reach past the safe area or the library + // appears to start halfway down a white screen. + .background(Paper.sheet.ignoresSafeArea()) + .task { librarySelection = librarySelection ?? libraryFilters.first?.id } .navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks") .navigationBarTitleDisplayMode(.large) .toolbar { @@ -114,12 +122,24 @@ struct BookmarksView: View { } label: { Image(systemName: "sparkles") } + .accessibilityLabel("AI actions") } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 16) { + // Shows where the tap goes, not where you are — a + // two-state toggle labelled with its current state + // reads as a status light rather than a control. + Button { toggleLayout() } label: { + Image(systemName: otherLayout.symbol) + .contentTransition(.symbolEffect(.replace)) + } + .accessibilityLabel( + otherLayout == .cards ? "Show as cards" : "Show as list" + ) Button { showAddBookmark = true } label: { Image(systemName: "plus") } + .accessibilityLabel("Add bookmark") Button { Task { await viewModel.toggleUnreadFilter() } } label: { @@ -128,18 +148,20 @@ struct BookmarksView: View { : "line.3.horizontal.decrease.circle") .contentTransition(.symbolEffect(.replace)) } + .accessibilityLabel("Unread filter") Button { showSettings = true } label: { Image(systemName: "gearshape") } + .accessibilityLabel("Settings") } } } .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) } @@ -175,6 +197,13 @@ struct BookmarksView: View { .sheet(isPresented: $showAsk) { AskView() } + .sheet(isPresented: $showTags) { + TagsView( + viewModel: viewModel, + pinned: Set(libraryFilters.compactMap(\.tag)), + onPick: pinTag + ) + } .sheet(isPresented: $showAddBookmark) { AddBookmarkView(viewModel: viewModel) } @@ -223,15 +252,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) } @@ -240,15 +268,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) } } @@ -256,7 +285,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() @@ -264,11 +293,70 @@ struct BookmarksView: View { } .padding(.horizontal, 16) .padding(.vertical, 10) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .paperCard(cornerRadius: 12) .padding(.horizontal, 16) } + private var bookmarkList: some View { + List { + if viewModel.isLoading && viewModel.bookmarks.isEmpty { + ForEach(0..<3, id: \.self) { i in + SkeletonRow(delay: Double(i) * 0.13) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .listRowSeparator(.visible) + } + } else { + ForEach(viewModel.bookmarks) { bookmark in + BookmarkListRow( + bookmark: bookmark, + viewModel: viewModel, + readingProgress: readingProgress[bookmark.url] ?? 0, + onOpen: { browsingBookmark = bookmark }, + onEdit: { editingBookmark = bookmark } + ) + .onAppear { maybeLoadMore(bookmark) } + } + } + + if viewModel.isLoadingMore { + HStack { Spacer(); ProgressView(); Spacer() } + .listRowSeparator(.hidden) + .listRowBackground(Paper.sheet) + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty) + } + + /// Pin a tag as a filter tab and switch to it. Choosing one that's already + /// pinned selects that tab instead of adding a duplicate. + private func pinTag(_ tag: String) { + if let existing = libraryFilters.first(where: { $0.tag == tag }) { + librarySelection = existing.id + } else { + let new = LibraryFilter(name: tag, tag: tag) + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { + libraryFilters.append(new) + librarySelection = new.id + } + } + applyTagFilter() + } + + /// Tag tabs filter server-side through linkding's `#tag` search syntax — + /// filtering the loaded page client-side would only ever search the most + /// recent 50 of 600+ bookmarks and quietly look empty. + private func applyTagFilter() { + let tag = libraryFilters.first { $0.id == librarySelection }?.tag + viewModel.searchQuery = tag.map { "#\($0)" } ?? "" + Task { await viewModel.search() } + } + + private func toggleLayout() { + withAnimation(.spring(duration: 0.35, bounce: 0.05)) { layoutRaw = otherLayout.rawValue } + } + private func maybeLoadMore(_ bookmark: Bookmark) { guard let last = viewModel.bookmarks.last, last.id == bookmark.id, viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return } diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index 0ff4d0c..5b5ea18 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -11,6 +11,9 @@ final class BookmarksViewModel { var searchQuery = "" var nextPageUrl: String? var smartCollections: [SmartCollection] = [] + /// Every tag name on the server. The loaded bookmark page only ever + /// exposes the tags of the most recent 50, which is not the vocabulary. + var allTags: [String] = [] var isGeneratingCollections = false var enrichmentProgress: Double = 0 var unreadFilter = false @@ -200,6 +203,14 @@ final class BookmarksViewModel { } } + /// Best-effort: an empty tag list just means the picker falls back to the + /// tags it can see on loaded bookmarks, so a failure here isn't worth an + /// error alert. + func loadAllTags() async { + guard let tags = try? await api.fetchTags() else { return } + allTags = tags + } + func generateSmartCollections() async { guard !bookmarks.isEmpty else { return } isGeneratingCollections = true diff --git a/Marks/Views/BrowserView.swift b/Marks/Views/BrowserView.swift index 6204abd..b81e3d1 100644 --- a/Marks/Views/BrowserView.swift +++ b/Marks/Views/BrowserView.swift @@ -253,7 +253,7 @@ struct BrowserView: View { if state.readingProgress > 0.01 && state.readingProgress < 0.99 { GeometryReader { geo in Rectangle() - .fill(Color.blue.opacity(0.55)) + .fill(Paper.accent.opacity(0.55)) .frame(width: geo.size.width * state.readingProgress, height: 3) } .frame(height: 3) @@ -409,11 +409,11 @@ struct BrowserView: View { .contentShape(Rectangle()) } } - .font(.system(size: 17)) - .foregroundStyle(.primary) + .font(PaperType.label) + .foregroundStyle(Paper.ink) .padding(.horizontal, 12) .padding(.vertical, 4) - .background(.bar) + .background(Paper.raised) .overlay(alignment: .top) { Divider() } } } diff --git a/Marks/Views/CollectionsView.swift b/Marks/Views/CollectionsView.swift index 65ac644..a3075a8 100644 --- a/Marks/Views/CollectionsView.swift +++ b/Marks/Views/CollectionsView.swift @@ -11,41 +11,44 @@ struct CollectionsView: View { VStack(spacing: 16) { ProgressView() Text("Building smart collections…") - .font(.system(size: 15)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if viewModel.smartCollections.isEmpty { - ContentUnavailableView( - "No Collections Yet", + PaperEmptyState( + title: "No Collections Yet", systemImage: "sparkles", - description: Text("Tap \"Smart Collections\" to group your bookmarks by topic.") + message: "Tap \"Smart Collections\" to group your bookmarks by topic." ) } else { List(viewModel.smartCollections) { collection in Section { let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) } ForEach(items) { bookmark in - BookmarkRow(bookmark: bookmark) + LibraryListRow(bookmark: bookmark) .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + .listRowBackground(Paper.sheet) + .listRowSeparatorTint(Paper.rule.opacity(0.5)) } } header: { VStack(alignment: .leading, spacing: 2) { Text(collection.name) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.primary) + .font(PaperType.heading) + .foregroundStyle(Paper.ink) if !collection.description.isEmpty { Text(collection.description) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) } } .padding(.vertical, 4) } } - .listStyle(.insetGrouped) + .listStyle(.plain) } } + .paperSurface() .navigationTitle("Smart Collections") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -59,6 +62,8 @@ struct CollectionsView: View { } ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } + .font(PaperType.label) + .tint(Paper.accent) } } } diff --git a/Marks/Views/EditBookmarkView.swift b/Marks/Views/EditBookmarkView.swift index 33430e2..1d5d3c9 100644 --- a/Marks/Views/EditBookmarkView.swift +++ b/Marks/Views/EditBookmarkView.swift @@ -27,44 +27,59 @@ struct EditBookmarkView: View { var body: some View { NavigationStack { Form { - Section("URL") { + Section { TextField("https://", text: $url) + .paperField() .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() + } header: { + Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } - Section("Title") { + Section { TextField("Optional", text: $title) + .paperField() + } header: { + Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } - Section("Description") { + Section { TextField("Optional", text: $description, axis: .vertical) + .paperField() .lineLimit(3...6) + } header: { + Text("Description").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("comma separated", text: $tagsText) + .paperField() .textInputAutocapitalization(.never) .autocorrectionDisabled() } header: { - Text("Tags") + Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } footer: { - Text("Separate tags with commas") + Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary) } Section { Toggle("Mark as unread", isOn: $unread) + .font(PaperType.label) + .foregroundStyle(Paper.ink) + .tint(Paper.accent) } if let error { Section { Text(error) - .foregroundStyle(.red) - .font(.footnote) + .font(PaperType.meta) + .foregroundStyle(Paper.alarm) } } } + .listRowBackground(Paper.raised) + .paperSurface() .navigationTitle("Edit Bookmark") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -73,6 +88,8 @@ struct EditBookmarkView: View { } ToolbarItem(placement: .confirmationAction) { Button("Save") { save() } + .font(PaperType.label) + .tint(Paper.accent) .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving) .overlay { if isSaving { ProgressView().scaleEffect(0.7) } diff --git a/Marks/Views/Library/LibraryGridView.swift b/Marks/Views/Library/LibraryGridView.swift new file mode 100644 index 0000000..ab216cb --- /dev/null +++ b/Marks/Views/Library/LibraryGridView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +/// The library's card presentation of `viewModel.bookmarks`. Drops into +/// BookmarksView's content area in place of the List and carries the same +/// actions — tap to open, long press for the full menu. The filter strip above +/// it belongs to BookmarksView, since both layouts share it. +struct LibraryGridView: View { + @Bindable var viewModel: BookmarksViewModel + let onOpen: (Bookmark) -> Void + let onEdit: (Bookmark) -> Void + + @Environment(\.openURL) private var openURL + @State private var showFullPlayer = false + @State private var episodePicker: Bookmark? + + private var items: [LibraryItem] { + viewModel.bookmarks.map(LibraryItem.init(bookmark:)) + } + + var body: some View { + ScrollView { + LazyVGrid( + // Two columns, not three: at the scaled-up type a third + // column leaves ~9 characters per line and every title + // truncates. The text size sets the column count. + columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 2), + spacing: 10 + ) { + ForEach(items) { item in + card(item) + } + } + .padding(.horizontal, 18) + .padding(.top, 16) + .padding(.bottom, 40) + + if viewModel.isLoadingMore { + ProgressView().padding(.bottom, 28) + } + } + .scrollBounceBehavior(.basedOnSize) + .background(Paper.sheet) + .podcastSheets( + viewModel: viewModel, + showFullPlayer: $showFullPlayer, + episodePicker: $episodePicker + ) + } + + @ViewBuilder + private func card(_ item: LibraryItem) -> some View { + // The grid renders LibraryItems, but every action needs the Bookmark it + // came from. Ids are linkding's, so this is a direct lookup. + if let bookmark = viewModel.bookmarks.first(where: { $0.id == item.id }) { + Button { onOpen(bookmark) } label: { + LibraryCard(item: item) + } + .buttonStyle(RowPressStyle()) + .contextMenu { + bookmarkMenuItems( + bookmark: bookmark, + viewModel: viewModel, + openURL: openURL, + onOpen: { onOpen(bookmark) }, + onEdit: { onEdit(bookmark) }, + onPodcast: { + launchPodcast( + for: bookmark, + viewModel: viewModel, + showFullPlayer: $showFullPlayer, + episodePicker: $episodePicker + ) + } + ) + } + .onAppear { maybeLoadMore(bookmark) } + } + } + + private func maybeLoadMore(_ bookmark: Bookmark) { + guard let last = viewModel.bookmarks.last, last.id == bookmark.id, + viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return } + Task { await viewModel.loadMore() } + } +} diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift new file mode 100644 index 0000000..43e89d6 --- /dev/null +++ b/Marks/Views/Library/LibraryKit.swift @@ -0,0 +1,644 @@ +import SwiftUI + +// MARK: - Library design kit +// +// The vocabulary the library presentation is built from: a paper palette, the +// Bookmark -> LibraryItem projection, and the two pieces of chrome (color card, +// browser-tab filter strip) shared by the real screen and the standalone +// prototype in Views/Prototypes/LibraryView.swift. + +// MARK: Tokens + +enum Paper { + /// Dark mode is not an inversion of this palette — paper stock lit from a + /// different angle. The ground keeps the same warm cast (it is brown-black, + /// not neutral black) so the swatches sit on it the way ink sits on paper. + static let sheet = dynamic(light: 0xF8F5EF, dark: 0x15130F) + 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. + /// + /// The dark variants are not the light ones dimmed uniformly. The pale end + /// of the palette (shell, blush, pale blue) would glare as bright slabs + /// against a dark ground, so it drops a long way; the dark end (forest, + /// navy) would vanish into the ground, so it comes *up*. Both ends + /// converge on the same mid band, which is what keeps twelve swatches + /// distinguishable from each other in either scheme. + static let swatchPairs: [(light: UInt32, dark: UInt32)] = [ + (0xFECD00, 0xD8AD10), // yellow + (0xED663F, 0xC4552F), // vermilion + (0xFE9D6B, 0xC87E53), // peach + (0xAB6A1C, 0x8B5717), // ochre + (0x033B00, 0x1F4D1B), // forest — lifted off the ground + (0x001A55, 0x1E3167), // navy — lifted off the ground + (0x115AB5, 0x1B5596), // blue + (0xD4E0E8, 0x7C8E99), // pale blue — dropped hard + (0xD4DCCF, 0x828E7C), // sage — dropped hard + (0xE0D1BB, 0x94806A), // sand — dropped hard + (0xF5D1BC, 0xA47A63), // blush — dropped hard + (0xFDEDE0, 0x8E8175), // shell — dropped hard + ] + + static func swatch(_ index: Int) -> Color { + let pair = swatchPairs[index % swatchPairs.count] + return dynamic(light: pair.light, dark: pair.dark) + } + + /// Text that stays legible on `swatch(index)`. Resolved per scheme rather + /// than once, because a swatch can be light in one scheme and mid in the + /// other — a single luminance test would get one of them wrong. + static func inkOn(_ index: Int) -> Color { + let pair = swatchPairs[index % swatchPairs.count] + return Color(uiColor: UIColor { traits in + let dark = traits.userInterfaceStyle == .dark + let onLight = luminance(of: dark ? pair.dark : pair.light) > 0.55 + let text: UInt32 = onLight ? 0x14110C : (dark ? 0xF1ECE1 : 0xF8F5EF) + return UIColor(rgb: text).withAlphaComponent(onLight ? 0.86 : 1) + }) + } + + static func dynamic(light: UInt32, dark: UInt32) -> Color { + Color(uiColor: dynamicUI(light: light, dark: dark)) + } + + static func dynamicUI(light: UInt32, dark: UInt32) -> UIColor { + UIColor { traits in + UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light) + } + } + + /// UIKit equivalents, for the chrome SwiftUI can't reach — chiefly the + /// navigation bar, whose title font has no SwiftUI API at all. + static let uiSheet = dynamicUI(light: 0xF8F5EF, dark: 0x15130F) + static let uiInk = dynamicUI(light: 0x14110C, dark: 0xF1ECE1) + + static func luminance(of hex: UInt32) -> Double { + let r = Double((hex >> 16) & 0xFF) / 255 + let g = Double((hex >> 8) & 0xFF) / 255 + let b = Double(hex & 0xFF) / 255 + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } +} + +// 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 lets the paper reach past the + /// safe area so the bar sits on it. + /// + /// Deliberately no `.toolbarBackground` — setting it makes SwiftUI build a + /// fresh UINavigationBarAppearance and throw away the one PaperAppearance + /// installed, which silently reverted these screens' titles to the system + /// bold sans. The bar is transparent by appearance, so the background + /// below is all it needs. + func paperSurface() -> some View { + self + .scrollContentBackground(.hidden) + .background(Paper.sheet.ignoresSafeArea()) + } + + /// 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: - UIKit chrome + +/// The navigation and tab bars are UIKit underneath, and their fonts have no +/// SwiftUI equivalent — `navigationTitle` will render in the system bold sans +/// whatever you do to the view. Both are configured once at launch so screen +/// titles speak the same serif as the content beneath them. +enum PaperAppearance { + static func apply() { + let bar = UINavigationBarAppearance() + // Transparent, not opaque: the screens already paint the paper ground + // themselves, and an opaque bar config stops iOS 26 laying out the + // large title at all. + bar.configureWithTransparentBackground() + bar.largeTitleTextAttributes = [ + .font: serif(34, weight: .regular), + .foregroundColor: Paper.uiInk, + ] + bar.titleTextAttributes = [ + .font: serif(17, weight: .medium), + .foregroundColor: Paper.uiInk, + ] + UINavigationBar.appearance().standardAppearance = bar + UINavigationBar.appearance().compactAppearance = bar + UINavigationBar.appearance().scrollEdgeAppearance = bar + + let tabs = UITabBarAppearance() + tabs.configureWithDefaultBackground() + let label: [NSAttributedString.Key: Any] = [ + .font: UIFont.monospacedSystemFont(ofSize: 10, weight: .medium) + ] + for item in [tabs.stackedLayoutAppearance, + tabs.inlineLayoutAppearance, + tabs.compactInlineLayoutAppearance] { + item.normal.titleTextAttributes = label + item.selected.titleTextAttributes = label + } + UITabBar.appearance().standardAppearance = tabs + UITabBar.appearance().scrollEdgeAppearance = tabs + } + + /// `withDesign(.serif)` is the only route to the system serif in UIKit, and + /// it returns nil if the design is unavailable — fall back rather than + /// force-unwrap a font. + private static func serif(_ size: CGFloat, weight: UIFont.Weight) -> UIFont { + let base = UIFont.systemFont(ofSize: size, weight: weight) + guard let descriptor = base.fontDescriptor.withDesign(.serif) else { return base } + return UIFont(descriptor: descriptor, size: size) + } +} + +// 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( + red: CGFloat((rgb >> 16) & 0xFF) / 255, + green: CGFloat((rgb >> 8) & 0xFF) / 255, + blue: CGFloat(rgb & 0xFF) / 255, + alpha: 1 + ) + } +} + +// MARK: Model + +struct LibraryItem: Identifiable, Hashable { + let id: Int + var title: String + /// The bottom-left mono stamp. The reference used a publication year; real + /// linkding data is all 2025–2026, so the year carries no signal and the + /// domain takes the slot instead. + var stamp: String + var source: String + var tags: [String] + /// nil = derive from the primary tag, so an untouched library still reads + /// as color-coded by subject rather than as noise. + var colorIndex: Int? + /// The page never gave us a title. Rendering the raw URL inside curly + /// quotes reads as a quotation that isn't one, so these skip the quotes. + var isUntitled = false + + var swatch: Color { Paper.swatch(resolvedIndex) } + var inkOnSwatch: Color { Paper.inkOn(resolvedIndex) } + + /// What a card shows. Cards get one clause; the list gets the whole title. + /// Real titles are overwhelmingly "name: what it does" — at card width the + /// name is the identifier and the blurb is filler, so past 60 characters we + /// keep the name and let the list carry the rest. + var cardTitle: String { + if title.count > 60, + let colon = title.range(of: ": "), + title.distance(from: title.startIndex, to: colon.lowerBound) <= 40 { + return String(title[.. 56 { + return String(title.prefix(56)).trimmingCharacters(in: .whitespaces) + "…" + } + return title + } + + /// Cards quote the title the way the reference does — except when there is + /// no real title to quote. + var cardDisplay: String { isUntitled ? cardTitle : "“\(cardTitle)”" } + var listDisplay: String { isUntitled ? title : "“\(title)”" } + + private var resolvedIndex: Int { + if let colorIndex { return colorIndex % Paper.swatchPairs.count } + // Tag first: 128 of 300 real bookmarks are github.com, so hashing the + // domain would paint half the library one color. Tags spread wider + // (top tag is 32 items). Untagged falls back to source. + let seed = tags.first ?? source + return abs(seed.unicodeScalars.reduce(5381) { ($0 &* 33) &+ Int($1.value) }) + % Paper.swatchPairs.count + } +} + +// MARK: Bookmark -> LibraryItem + +extension LibraryItem { + init(bookmark: Bookmark) { + self.id = bookmark.id + self.source = bookmark.domain.replacingOccurrences(of: "www.", with: "") + self.tags = bookmark.tagNames + self.colorIndex = nil + + // `displayTitle` falls back to the raw URL when linkding scraped no + // title — 15 of 300 real bookmarks. Show the domain instead and hand + // the stamp slot the path, so both slots still say something. + let raw = bookmark.displayTitle + if raw.hasPrefix("http") { + self.isUntitled = true + self.title = self.source + self.stamp = Self.path(of: bookmark.url) + } else { + self.title = Self.clean(raw) + self.stamp = Self.registered(self.source) + } + } + + /// Cards give the stamp one monospaced line, roughly 14 characters at the + /// card's width. 92 of 300 real domains are longer than that, so drop the + /// subdomain: `toolkit.artlist.io` -> `artlist.io`. The list shows it full. + static func registered(_ host: String) -> String { + let parts = host.split(separator: ".") + guard parts.count > 2 else { return host } + // Two-part public suffixes (.co.uk, .com.au) need one more label. + let secondLevel: Set = ["co", "com", "net", "org", "ac", "gov", "edu"] + let keep = secondLevel.contains(String(parts[parts.count - 2])) ? 3 : 2 + return parts.suffix(keep).joined(separator: ".") + } + + /// Returned whole: the stamp label middle-truncates, and pre-clipping here + /// too would elide it twice ("/share…fo/194…"). + static func path(of url: String) -> String { + guard let p = URL(string: url)?.path, p != "/", !p.isEmpty else { return "—" } + return p + } + + /// Linkding stores whatever the page's said, which for the bulk of a + /// real library means "GitHub - owner/repo: <the entire README blurb>". + /// Median real title is 66 chars and the 90th percentile is 159 — the + /// reference design assumed ~30. Strip the boilerplate, then clamp. + static func clean(_ raw: String) -> String { + var t = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // "GitHub - owner/repo: blurb" -> "repo: blurb" + if t.hasPrefix("GitHub - ") { + t = String(t.dropFirst("GitHub - ".count)) + if let slash = t.firstIndex(of: "/"), + let colon = t.firstIndex(of: ":"), slash < colon { + t = String(t[t.index(after: slash)...]) + } + } + + // Trailing site furniture: "Title | Publisher", "Title - Latent.Space". + // Only strip a short trailing fragment off a title with something left + // over, so hyphenated titles survive. + for sep in [" | ", " · ", " — ", " – ", " - "] { + if let r = t.range(of: sep, options: .backwards), + t.distance(from: r.upperBound, to: t.endIndex) < 24, + t.distance(from: t.startIndex, to: r.lowerBound) > 12 { + t = String(t[..<r.lowerBound]) + } + } + + // Generous clamp: this is the list-mode title. `cardTitle` cuts harder. + if t.count > 120 { + t = String(t.prefix(120)).trimmingCharacters(in: .whitespaces) + "…" + } + return t.isEmpty ? "Untitled" : t + } +} + +// MARK: Filters + +struct LibraryFilter: Identifiable, Hashable { + let id = UUID() + var name: String + /// nil = "everything", the tab you can't close. + var tag: String? +} + +/// Two modes, not three. A 4-column grid was in the first pass and died on real +/// data: with a median title of 66 characters every tile truncated mid-word, so +/// it read as a wall of clipped text rather than as color. +enum LibraryLayout: String, CaseIterable, Identifiable { + case cards, list + var id: String { rawValue } + + var symbol: String { + switch self { + case .cards: "rectangle.inset.filled" + case .list: "line.3.horizontal" + } + } +} + +// MARK: - Card + +/// Purely presentational. Press feedback is deliberately *not* here — a +/// zero-duration long-press gesture on the card swallows any tap the host +/// attaches, which silently broke tap-to-open. Hosts wrap this in a Button +/// with `RowPressStyle` instead, which gets the same scale without competing +/// for the gesture. +struct LibraryCard: View { + let item: LibraryItem + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(item.cardDisplay) + .font(.system(size: 16.5, design: .serif)) + .foregroundStyle(item.inkOnSwatch) + .multilineTextAlignment(.leading) + .lineLimit(5) + .minimumScaleFactor(0.8) + Spacer(minLength: 4) + Text(item.stamp) + .font(.system(size: 16.5, design: .monospaced)) + .foregroundStyle(item.inkOnSwatch) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(13) + .frame(maxWidth: .infinity, alignment: .topLeading) + .aspectRatio(0.82, contentMode: .fit) + .background(RoundedRectangle(cornerRadius: 5).fill(item.swatch)) + } +} + +// MARK: - Filter strip + +/// The browser-tab strip of saved filters. Tabs sit on a hairline that the live +/// tab erases, which is what sells the metaphor. +struct LibraryTagStrip: View { + @Binding var filters: [LibraryFilter] + @Binding var selection: LibraryFilter.ID? + var onChange: () -> Void = {} + /// Opens the tag picker. It replaced an inline Menu, which could only list + /// tags found on the loaded page — and once a tag filter was active, that + /// collapsed to the handful of tags co-occurring with it. + var onBrowseTags: () -> Void = {} + + @Namespace private var strip + + var body: some View { + ZStack(alignment: .bottom) { + Rectangle() + .fill(Paper.rule) + .frame(height: 0.6) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(filters) { filter in + tab(filter) + } + Button { onBrowseTags() } label: { + Image(systemName: "plus") + .font(.system(size: 18, weight: .light)) + .foregroundStyle(Paper.ink.opacity(0.6)) + .frame(width: 50, height: 42) + } + .buttonStyle(.plain) + .accessibilityLabel("Pin a tag") + Spacer(minLength: 0) + } + .padding(.horizontal, 18) + } + } + .frame(height: 42) + } + + private func tab(_ filter: LibraryFilter) -> some View { + let active = filter.id == selection + return HStack(spacing: 7) { + Text(filter.name) + .font(.system(size: 16.5, design: .monospaced)) + .foregroundStyle(active ? Paper.ink : Paper.ink.opacity(0.45)) + .lineLimit(1) + if filters.count > 1 { + Button { + withAnimation(.spring(duration: 0.3, bounce: 0)) { close(filter) } + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Paper.ink.opacity(active ? 0.5 : 0.25)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 15) + .frame(height: 42) + .background(alignment: .bottom) { + if active { + // Paper fill sits 0.6pt proud so it erases the strip rule + // beneath the live tab — the browser-tab read. + UnevenRoundedRectangle( + topLeadingRadius: 6, bottomLeadingRadius: 0, + bottomTrailingRadius: 0, topTrailingRadius: 6 + ) + .fill(Paper.sheet) + .overlay(TabOutline().stroke(Paper.rule, lineWidth: 0.6)) + .padding(.bottom, -0.6) + .matchedGeometryEffect(id: "tab", in: strip) + } + } + .contentShape(.rect) + .onTapGesture { + guard filter.id != selection else { return } + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { selection = filter.id } + onChange() + } + } + + private func close(_ filter: LibraryFilter) { + filters.removeAll { $0.id == filter.id } + if selection == filter.id { + selection = filters.first?.id + onChange() + } + } +} + +// MARK: - Flow layout + +/// Wraps subviews onto as many rows as fit, capped at `maxRows`. Anything past +/// the cap is placed off-screen at zero size rather than skipped — a Layout +/// that declines to place a subview gets it laid out at the origin instead of +/// dropped, which would stack leftover tags on top of the first row. +struct FlowLayout: Layout { + var spacing: CGFloat = 5 + var lineSpacing: CGFloat = 5 + var maxRows: Int = 2 + + struct Row { + var indices: [Int] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let rows = rows(width: proposal.width ?? .infinity, subviews: subviews) + let height = rows.reduce(0) { $0 + $1.height } + + CGFloat(max(0, rows.count - 1)) * lineSpacing + return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Void + ) { + let rows = rows(width: bounds.width, subviews: subviews) + let placed = Set(rows.flatMap(\.indices)) + var y = bounds.minY + + for row in rows { + var x = bounds.minX + for i in row.indices { + let size = subviews[i].sizeThatFits(.unspecified) + subviews[i].place( + at: CGPoint(x: x, y: y), + anchor: .topLeading, + proposal: ProposedViewSize(size) + ) + x += size.width + spacing + } + y += row.height + lineSpacing + } + + // Far enough to be off any row, small enough not to poison the layout + // arithmetic the way a near-infinite coordinate would. + for i in subviews.indices where !placed.contains(i) { + subviews[i].place(at: CGPoint(x: bounds.minX - 10_000, y: bounds.minY), + anchor: .topLeading, + proposal: .zero) + } + } + + private func rows(width: CGFloat, subviews: Subviews) -> [Row] { + var rows: [Row] = [] + var current = Row() + + for i in subviews.indices { + let size = subviews[i].sizeThatFits(.unspecified) + let needed = current.indices.isEmpty ? size.width : current.width + spacing + size.width + if needed > width && !current.indices.isEmpty { + rows.append(current) + if rows.count == maxRows { return rows } + current = Row() + current.indices = [i] + current.width = size.width + current.height = size.height + } else { + current.indices.append(i) + current.width = needed + current.height = max(current.height, size.height) + } + } + if !current.indices.isEmpty { rows.append(current) } + return rows + } +} + +/// Open path: up the left edge, across the top, down the right — no bottom +/// stroke, so the tab merges into the page. +struct TabOutline: Shape { + func path(in rect: CGRect) -> Path { + let r: CGFloat = 6 + var p = Path() + p.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) + p.addQuadCurve( + to: CGPoint(x: rect.minX + r, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + p.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY)) + p.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + r), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + return p + } +} diff --git a/Marks/Views/Library/LibraryListRow.swift b/Marks/Views/Library/LibraryListRow.swift new file mode 100644 index 0000000..9036cfa --- /dev/null +++ b/Marks/Views/Library/LibraryListRow.swift @@ -0,0 +1,153 @@ +import SwiftUI + +/// 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 + var onPodcast: (() -> Void)? = nil + + @State private var podcastTapCount = 0 + @State private var podcastCached = false + + private var item: LibraryItem { LibraryItem(bookmark: bookmark) } + + /// 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 { + HStack(alignment: .top, spacing: 12) { + RoundedRectangle(cornerRadius: 3) + .fill(item.swatch) + .frame(width: 34, height: 46) + .overlay(alignment: .topLeading) { + if bookmark.unread { + // Ink, not blue: the palette is the only color the + // library spends, and an accent dot would compete with + // the chip it sits on. + Circle() + .fill(Paper.ink) + .frame(width: 10, height: 10) + .overlay(Circle().stroke(Paper.sheet, lineWidth: 2)) + .offset(x: -4, y: -4) + } + } + + VStack(alignment: .leading, spacing: 5) { + Text(item.listDisplay) + .font(.system(size: 21, design: .serif)) + .foregroundStyle(Paper.ink) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 5) { + Text(item.source) + Text("·") + Text(Self.relativeFormatter.localizedString( + for: bookmark.dateAdded, relativeTo: Date() + )) + } + .font(.system(size: 15, design: .monospaced)) + .foregroundStyle(Paper.ink.opacity(0.45)) + .lineLimit(1) + + if let excerpt = rowExcerpt { + Text(excerpt.text) + .font(.system(size: 18, design: .serif)) + .italic(excerpt.isAI) + .foregroundStyle(Paper.ink.opacity(0.6)) + .lineLimit(2) + } + + if !effectiveTags.isEmpty { + tagRow + } + } + + Spacer(minLength: 6) + + if let onPodcast { + Button { + podcastTapCount += 1 + onPodcast() + } label: { + Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle") + .font(.system(size: 25.5, weight: .light)) + .foregroundStyle(Paper.ink.opacity(podcastCached ? 0.75 : 0.3)) + } + .buttonStyle(.plain) + .sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount) + } + } + .padding(.vertical, 14) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + if readingProgress > 0.02 { + GeometryReader { geo in + ZStack(alignment: .leading) { + Rectangle().fill(Paper.ink.opacity(0.1)) + Rectangle() + .fill(Paper.ink.opacity(0.5)) + .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 + } + } + + /// Tags wrap onto up to two lines rather than scrolling sideways. At the + /// scaled-up type a horizontal strip clipped its third chip mid-word, which + /// read as broken text; wrapping shows whole tags or none. + private var tagRow: some View { + FlowLayout(spacing: 5, lineSpacing: 5, maxRows: 2) { + ForEach(effectiveTags, id: \.self) { tag in + Text(tag) + .font(.system(size: 15, design: .monospaced)) + .foregroundStyle(Paper.ink.opacity(0.55)) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Paper.rule.opacity(0.6), lineWidth: 0.6) + ) + } + } + .padding(.top, 1) + } + + /// 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 } + } +} diff --git a/Marks/Views/OnboardingView.swift b/Marks/Views/OnboardingView.swift index 2df303e..eae00a2 100644 --- a/Marks/Views/OnboardingView.swift +++ b/Marks/Views/OnboardingView.swift @@ -17,10 +17,11 @@ struct OnboardingView: View { VStack(alignment: .leading, spacing: 8) { Text("Marks") - .font(.system(size: 42, weight: .semibold, design: .rounded)) + .font(.system(size: 42, design: .serif)) + .foregroundStyle(Paper.ink) Text("Your bookmarks, beautifully.") - .font(.system(size: 17)) - .foregroundStyle(.secondary) + .font(PaperType.body) + .foregroundStyle(Paper.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 28) @@ -29,8 +30,8 @@ struct OnboardingView: View { VStack(spacing: 14) { VStack(alignment: .leading, spacing: 6) { Text("Server URL") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) + .font(PaperType.stamp) + .foregroundStyle(Paper.tertiary) .padding(.horizontal, 2) TextField("https://links.example.com", text: $urlText) .textContentType(.URL) @@ -41,14 +42,15 @@ struct OnboardingView: View { .submitLabel(.next) .onSubmit { focused = .token } .padding(14) - .background(Color(.systemGray6)) + .paperField() + .background(Paper.raised) .clipShape(RoundedRectangle(cornerRadius: 12)) } VStack(alignment: .leading, spacing: 6) { Text("API Token") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) + .font(PaperType.stamp) + .foregroundStyle(Paper.tertiary) .padding(.horizontal, 2) SecureField("Paste your token", text: $token) .textContentType(.password) @@ -58,11 +60,12 @@ struct OnboardingView: View { .submitLabel(.done) .onSubmit { Task { await connect() } } .padding(14) - .background(Color(.systemGray6)) + .paperField() + .background(Paper.raised) .clipShape(RoundedRectangle(cornerRadius: 12)) Text("Find your token at Settings → API Token in Linkding.") - .font(.system(size: 12)) - .foregroundStyle(.tertiary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) .padding(.horizontal, 2) } } @@ -70,8 +73,8 @@ struct OnboardingView: View { if let err = errorMessage { Text(err) - .font(.system(size: 14)) - .foregroundStyle(.red) + .font(PaperType.meta) + .foregroundStyle(Paper.alarm) .padding(.top, 12) .padding(.horizontal, 28) } @@ -84,7 +87,7 @@ struct OnboardingView: View { ProgressView().tint(.white) } else { Text("Connect") - .font(.system(size: 17, weight: .semibold)) + .font(PaperType.label) } } .frame(maxWidth: .infinity) diff --git a/Marks/Views/PodcastPlayerView.swift b/Marks/Views/PodcastPlayerView.swift index 158eced..78a2483 100644 --- a/Marks/Views/PodcastPlayerView.swift +++ b/Marks/Views/PodcastPlayerView.swift @@ -510,6 +510,7 @@ struct PodcastPlayerView: View { Spacer() } .padding(.horizontal, 32) + .paperSurface() .navigationTitle("Podcast") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -520,7 +521,7 @@ struct PodcastPlayerView: View { ToolbarItem(placement: .topBarTrailing) { Button { vm.stop(); dismiss() } label: { Image(systemName: "stop.circle") - .foregroundStyle(.red) + .foregroundStyle(Paper.alarm) } } } @@ -556,16 +557,16 @@ struct PodcastPlayerView: View { VStack(spacing: 28) { Image(systemName: "waveform") .font(.system(size: 52)) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) .symbolEffect(.variableColor.iterative, isActive: true) VStack(spacing: 10) { ProgressView(value: progress) .progressViewStyle(.linear) - .tint(.blue) + .tint(Paper.accent) Text(label) - .font(.system(size: 14)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } } } @@ -573,16 +574,16 @@ struct PodcastPlayerView: View { private func playerView(title: String) -> some View { VStack(spacing: 28) { RoundedRectangle(cornerRadius: 20) - .fill(Color(.systemGray6)) + .fill(Paper.raised) .frame(width: 220, height: 220) .overlay { Image(systemName: "waveform.circle.fill") .font(.system(size: 80)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) } Text(title) - .font(.system(size: 18, weight: .semibold)) + .font(PaperType.heading) .multilineTextAlignment(.center) .lineLimit(3) @@ -591,32 +592,32 @@ struct PodcastPlayerView: View { value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }), in: 0...vm.duration ) - .tint(.primary) + .tint(Paper.ink) HStack { Text(formatTime(vm.currentTime)) Spacer() Text(formatTime(vm.duration)) } - .font(.system(size: 12, design: .monospaced)) - .foregroundStyle(.tertiary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } HStack(spacing: 36) { Button { vm.skipBackward15() } label: { Image(systemName: "gobackward.15") .font(.system(size: 32)) - .foregroundStyle(.primary) + .foregroundStyle(Paper.ink) } Button { vm.togglePlayPause() } label: { Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 72)) - .foregroundStyle(.primary) + .foregroundStyle(Paper.ink) } Button { vm.skipForward15() } label: { Image(systemName: "goforward.15") .font(.system(size: 32)) - .foregroundStyle(.primary) + .foregroundStyle(Paper.ink) } } @@ -636,8 +637,8 @@ struct PodcastPlayerView: View { } } label: { Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) .padding(.horizontal, 14) .padding(.vertical, 7) .glassEffect(in: Capsule()) @@ -652,7 +653,7 @@ struct PodcastPlayerView: View { } label: { Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon") .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(sleepActive ? Color.blue : .secondary) + .foregroundStyle(sleepActive ? Paper.accent : .secondary) .padding(.horizontal, 14) .padding(.vertical, 7) .glassEffect(in: Capsule()) @@ -676,7 +677,7 @@ struct PodcastPlayerView: View { ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) { Label("Share Episode", systemImage: "square.and.arrow.up") .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) } } } @@ -687,12 +688,12 @@ struct PodcastPlayerView: View { VStack(spacing: 16) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 44)) - .foregroundStyle(.red) + .foregroundStyle(Paper.alarm) Text("Generation Failed") - .font(.headline) + .font(PaperType.heading) Text(message) - .font(.system(size: 14)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) .multilineTextAlignment(.center) Button { vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude) @@ -701,7 +702,7 @@ struct PodcastPlayerView: View { .font(.system(size: 15, weight: .semibold)) } .buttonStyle(.glassProminent) - .tint(.blue) + .tint(Paper.accent) .buttonBorderShape(.capsule) .padding(.top, 4) } @@ -724,18 +725,18 @@ struct MiniPlayerView: View { HStack(spacing: 14) { Image(systemName: "waveform") .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) .symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating) .frame(width: 22) VStack(alignment: .leading, spacing: 2) { Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.primary) + .font(PaperType.meta) + .foregroundStyle(Paper.ink) .lineLimit(1) Text(progressLabel) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.secondary) } Spacer() @@ -749,7 +750,7 @@ struct MiniPlayerView: View { } label: { Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 18)) - .foregroundStyle(.primary) + .foregroundStyle(Paper.ink) .frame(width: 36, height: 36) } .buttonStyle(.plain) @@ -760,20 +761,20 @@ struct MiniPlayerView: View { } label: { Image(systemName: "xmark") .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) .frame(width: 28, height: 28) } .buttonStyle(.plain) } .padding(.horizontal, 16) .padding(.vertical, 12) - .background(.regularMaterial) + .background(Paper.raised) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay(alignment: .bottom) { // Progress bar along bottom edge GeometryReader { geo in Rectangle() - .fill(Color.blue.opacity(0.6)) + .fill(Paper.accent.opacity(0.6)) .frame(width: geo.size.width * progressFraction, height: 3) } .frame(height: 3) @@ -819,10 +820,10 @@ struct PodcastLibraryView: View { NavigationStack { Group { if library.entries.isEmpty { - ContentUnavailableView( - "No Podcasts", + PaperEmptyState( + title: "No Podcasts", systemImage: "headphones", - description: Text("Podcasts you generate from bookmarks will appear here.") + message: "Podcasts you generate from bookmarks will appear here." ) } else { List { @@ -840,6 +841,7 @@ struct PodcastLibraryView: View { .listStyle(.insetGrouped) } } + .paperSurface() .navigationTitle("Podcasts") .toolbar { if !unplayed.isEmpty { @@ -888,18 +890,18 @@ struct PodcastLibraryView: View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(entry.title ?? entry.articleUrl) - .font(.system(size: 15, weight: .medium)) + .font(PaperType.quote) .lineLimit(2) .foregroundStyle(entry.isPlayed ? .secondary : .primary) Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.secondary) } Spacer() if entry.isPlayed { Image(systemName: "checkmark.circle.fill") .font(.system(size: 15)) - .foregroundStyle(.green) + .foregroundStyle(Paper.affirm) .accessibilityLabel("Played") } Button { @@ -907,7 +909,7 @@ struct PodcastLibraryView: View { } label: { Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 36)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) @@ -1005,17 +1007,17 @@ struct EpisodePickerView: View { HStack(spacing: 12) { VStack(alignment: .leading, spacing: 4) { Text(entry.title ?? entry.articleUrl) - .font(.system(size: 15, weight: .medium)) + .font(PaperType.quote) .lineLimit(2) if entry.articleUrl != bookmark.url { Text(entry.articleUrl) - .font(.system(size: 11)) - .foregroundStyle(.tertiary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) .lineLimit(1) } Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted)) - .font(.system(size: 12)) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.secondary) } Spacer() Button { @@ -1026,7 +1028,7 @@ struct EpisodePickerView: View { } label: { Image(systemName: "play.circle.fill") .font(.system(size: 36)) - .foregroundStyle(.blue) + .foregroundStyle(Paper.accent) } .buttonStyle(.plain) } @@ -1095,21 +1097,21 @@ struct EpisodeDetailView: View { Section { VStack(alignment: .leading, spacing: 8) { Text(entry.title ?? entry.articleUrl) - .font(.system(size: 20, weight: .semibold)) + .font(PaperType.display) Label(domain, systemImage: "link") .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) .lineLimit(1) HStack(spacing: 12) { Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted), systemImage: "calendar") if isPlayed { Label("Played", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) + .foregroundStyle(Paper.affirm) } } .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) } .padding(.vertical, 4) } @@ -1118,7 +1120,7 @@ struct EpisodeDetailView: View { Section("Summary") { Text(summary) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(Paper.secondary) } } diff --git a/Marks/Views/Prototypes/LibraryView.swift b/Marks/Views/Prototypes/LibraryView.swift new file mode 100644 index 0000000..5ab25a0 --- /dev/null +++ b/Marks/Views/Prototypes/LibraryView.swift @@ -0,0 +1,275 @@ +import SwiftUI + +// MARK: - Library prototype +// +// The standalone browse screen the design was worked out in: its own header, +// search field, and sample data, with no view model behind it. Kept as the +// place to iterate on the look in Previews — the shipping version is +// LibraryGridView, and both draw their pieces from LibraryKit. + +// MARK: - Screen + +struct LibraryView: View { + @State private var items: [LibraryItem] + @State private var filters: [LibraryFilter] + @State private var selectedFilter: LibraryFilter.ID? + @State private var layout: LibraryLayout = .cards + @State private var searching = false + @State private var query = "" + @State private var appeared = false + @FocusState private var searchFocused: Bool + @Namespace private var blocks + + init(items: [LibraryItem] = .sample, filters: [LibraryFilter]? = nil) { + _items = State(initialValue: items) + _filters = State(initialValue: filters ?? Self.defaultFilters(for: items)) + } + + /// With 96 real tags there is no sensible "all tabs" answer — open on + /// Everything plus the five heaviest tags, and let `+` pin the rest. + static func defaultFilters(for items: [LibraryItem]) -> [LibraryFilter] { + var counts: [String: Int] = [:] + for item in items { for tag in item.tags { counts[tag, default: 0] += 1 } } + let top = counts.sorted { ($0.value, $1.key) > ($1.value, $0.key) }.prefix(5) + return [LibraryFilter(name: "Everything", tag: nil)] + + top.map { LibraryFilter(name: $0.key, tag: $0.key) } + } + + private var visibleItems: [LibraryItem] { + let tag = filters.first { $0.id == selectedFilter }?.tag + return items.filter { item in + let matchesTag = tag.map { item.tags.contains($0) } ?? true + let matchesQuery = query.isEmpty + || item.title.localizedCaseInsensitiveContains(query) + || item.source.localizedCaseInsensitiveContains(query) + return matchesTag && matchesQuery + } + } + + /// Tags not already pinned as a tab — the menu behind `+`. + private var unusedTags: [String] { + let taken = Set(filters.compactMap(\.tag)) + return Set(items.flatMap(\.tags)).subtracting(taken).sorted() + } + + var body: some View { + VStack(spacing: 0) { + header + filterStrip + content + } + .background(Paper.sheet) + .task { + selectedFilter = filters.first?.id + withAnimation(.easeOut(duration: 0.45)) { appeared = true } + } + } + + // MARK: Header + + private var header: some View { + VStack(spacing: 14) { + HStack(alignment: .firstTextBaseline) { + Text("Library") + .font(.system(size: 51, weight: .regular, design: .serif)) + .foregroundStyle(Paper.ink) + Spacer(minLength: 12) + Button { + withAnimation(.spring(duration: 0.3, bounce: 0.15)) { + searching.toggle() + if !searching { query = "" } + } + searchFocused = searching + } label: { + Image(systemName: searching ? "xmark" : "magnifyingglass") + .font(.system(size: 22.5, weight: .light)) + .foregroundStyle(Paper.ink) + .frame(width: 28, height: 28) + } + layoutToggle + } + + if searching { + VStack(spacing: 5) { + TextField("", text: $query, prompt: searchPrompt) + .font(.system(size: 19.5, design: .monospaced)) + .foregroundStyle(Paper.ink) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($searchFocused) + Rectangle().fill(Paper.rule).frame(height: 0.6) + } + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .padding(.horizontal, 22) + .padding(.top, 8) + .padding(.bottom, 14) + } + + private var searchPrompt: Text { + Text("search the library") + .font(.system(size: 19.5, design: .monospaced)) + .foregroundColor(Paper.ink.opacity(0.35)) + } + + private var layoutToggle: some View { + HStack(spacing: 0) { + ForEach(LibraryLayout.allCases) { option in + let active = layout == option + Button { + withAnimation(.spring(duration: 0.42, bounce: 0.12)) { layout = option } + } label: { + Image(systemName: option.symbol) + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(active ? Paper.sheet : Paper.ink.opacity(0.55)) + .frame(width: 46, height: 36) + .background { + if active { + RoundedRectangle(cornerRadius: 7) + .fill(Paper.ink) + .padding(2) + .matchedGeometryEffect(id: "layoutPill", in: blocks) + } + } + } + .buttonStyle(.plain) + } + } + .overlay( + RoundedRectangle(cornerRadius: 9).stroke(Paper.rule, lineWidth: 0.6) + ) + } + + // MARK: Filter tabs + + private var filterStrip: some View { + LibraryTagStrip( + filters: $filters, + selection: $selectedFilter, + // The prototype has no tag picker sheet behind it; + pins the + // heaviest tag that isn't already open, which is enough to exercise + // the strip's layout. + onBrowseTags: { + guard let tag = unusedTags.first else { return } + let new = LibraryFilter(name: tag, tag: tag) + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { + filters.append(new) + selectedFilter = new.id + } + } + ) + } + + // MARK: Content + + @ViewBuilder + private var content: some View { + ScrollView { + Group { + switch layout { + case .cards: cardGrid + case .list: listRows + } + } + .padding(.horizontal, 18) + .padding(.top, 16) + .padding(.bottom, 40) + } + .scrollBounceBehavior(.basedOnSize) + } + + private var cardGrid: some View { + LazyVGrid( + columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 2), + spacing: 10 + ) { + ForEach(Array(visibleItems.enumerated()), id: \.element.id) { index, item in + LibraryCard(item: item) + .matchedGeometryEffect(id: item.id, in: blocks) + .opacity(appeared ? 1 : 0) + .offset(y: appeared ? 0 : 10) + .animation( + .easeOut(duration: 0.4).delay(Double(index) * 0.028), + value: appeared + ) + } + } + } + + private var listRows: some View { + VStack(spacing: 0) { + ForEach(visibleItems) { item in + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 3) + .fill(item.swatch) + .frame(width: 34, height: 46) + .matchedGeometryEffect(id: item.id, in: blocks) + VStack(alignment: .leading, spacing: 3) { + Text(item.listDisplay) + .font(.system(size: 19.5, design: .serif)) + .foregroundStyle(Paper.ink) + .lineLimit(1) + Text(item.source) + .font(.system(size: 15, design: .monospaced)) + .foregroundStyle(Paper.ink.opacity(0.45)) + .lineLimit(1) + } + Spacer(minLength: 8) + // The card's stamp is the domain, which list mode already + // shows as the subtitle — so the right column carries the + // primary tag instead of repeating it. + Text(item.tags.first ?? item.stamp) + .font(.system(size: 16.5, design: .monospaced)) + .foregroundStyle(Paper.ink.opacity(0.55)) + .lineLimit(1) + } + .padding(.vertical, 11) + Rectangle().fill(Paper.rule.opacity(0.5)).frame(height: 0.6) + } + } + } +} + +// MARK: - Sample data + +extension Array where Element == LibraryItem { + static var sample: [LibraryItem] { + [ + .init(id: 1, title: "The Way of the Shogun", stamp: "1833", + source: "Edo Historical Review", tags: ["meiji", "shogunate"], colorIndex: 0), + .init(id: 2, title: "Feudal Procession sets out from Nihonbashi in Edo 1869", + stamp: "1869", source: "Nihonbashi Archive", tags: ["meiji", "edo"], colorIndex: 2), + .init(id: 3, title: "The Hamlet of Otsumago and its People", stamp: "1836", + source: "Times Daily National Intelligencer", tags: ["meiji", "villages"], + colorIndex: 1), + .init(id: 4, title: "Meiji Restoration Period of Crisis", stamp: "1923", + source: "Kyoto Press", tags: ["meiji", "restoration"], colorIndex: 3), + .init(id: 5, title: "Loss of Our Traditions Cause Civil Unrest", stamp: "1903", + source: "Osaka Herald", tags: ["meiji", "unrest"], colorIndex: 6), + .init(id: 6, title: "Musogukai & The Revenant Demons", stamp: "1893", + source: "Folklore Quarterly", tags: ["meiji", "folklore"], colorIndex: 8), + .init(id: 7, title: "It Will Never Be the Same Here Again", stamp: "1833", + source: "Letters from Otsumago", tags: ["meiji", "letters"], colorIndex: 5), + .init(id: 8, title: "Feudal Lords Clash Over Unchartered Territories", stamp: "1869", + source: "Provincial Record", tags: ["meiji", "shogunate"], colorIndex: 7), + .init(id: 9, title: "Woodblock Printing in the Late Tokugawa", stamp: "1841", + source: "Ukiyo-e Studies", tags: ["printing", "edo"], colorIndex: 9), + .init(id: 10, title: "Rice Riots and the Merchant Class", stamp: "1918", + source: "Economic Histories", tags: ["unrest", "trade"], colorIndex: 4), + .init(id: 11, title: "Correspondence of a Provincial Magistrate", stamp: "1877", + source: "Letters from Otsumago", tags: ["letters"], colorIndex: 10), + .init(id: 12, title: "Mountain Roads of the Nakasendō", stamp: "1852", + source: "Survey Notes", tags: ["villages", "edo"], colorIndex: 11), + ] + } +} + +#Preview("Library") { + LibraryView() +} + +#Preview("Library — Dark") { + LibraryView() + .preferredColorScheme(.dark) +} diff --git a/Marks/Views/SearchView.swift b/Marks/Views/SearchView.swift index c9805de..63f0ab3 100644 --- a/Marks/Views/SearchView.swift +++ b/Marks/Views/SearchView.swift @@ -21,6 +21,7 @@ struct SearchView: View { } } .listStyle(.plain) + .paperSurface() .navigationTitle("Search") .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -33,9 +34,9 @@ struct SearchView: View { } .overlay { if searchText.isEmpty { - ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag.")) + PaperEmptyState(title: "Search Bookmarks", systemImage: "magnifyingglass", message: "Search by title, URL, or tag.") } else if results.isEmpty && !viewModel.isLoading { - ContentUnavailableView.search(text: searchText) + PaperEmptyState(title: "No Results", systemImage: "magnifyingglass", message: "Nothing matches \u{201C}\(searchText)\u{201D}.") } if viewModel.isLoading { ProgressView() diff --git a/Marks/Views/SettingsView.swift b/Marks/Views/SettingsView.swift index 45e3649..d5137e9 100644 --- a/Marks/Views/SettingsView.swift +++ b/Marks/Views/SettingsView.swift @@ -11,14 +11,18 @@ struct SettingsView: View { var body: some View { NavigationStack { List { - Section("AI Features") { + Section { Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles") - .font(.system(size: 13)) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) + } header: { + Text("AI Features").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } Section { TextField("e.g. listen podcast", text: $autoTagText) + .font(PaperType.label) + .foregroundStyle(Paper.ink) .textInputAutocapitalization(.never) .autocorrectionDisabled() .onChange(of: autoTagText) { _, new in @@ -27,25 +31,35 @@ struct SettingsView: View { .map(String.init) } } header: { - Text("Auto-Podcast Tags") + Text("Auto-Podcast Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } footer: { Text("Saving a bookmark with any of these tags automatically generates a podcast for it.") + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) } - Section("Server") { - Button(role: .destructive) { + Section { + Button { dismiss() onDisconnect() } label: { Label("Disconnect", systemImage: "person.crop.circle.badge.minus") + .font(PaperType.label) + .foregroundStyle(Paper.alarm) } + } header: { + Text("Server").font(PaperType.stamp).foregroundStyle(Paper.tertiary) } } + .listRowBackground(Paper.raised) + .paperSurface() .navigationTitle("Settings") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } + .font(PaperType.label) + .tint(Paper.accent) } } } diff --git a/Marks/Views/SourcesView.swift b/Marks/Views/SourcesView.swift index 6b82cf5..462381a 100644 --- a/Marks/Views/SourcesView.swift +++ b/Marks/Views/SourcesView.swift @@ -35,8 +35,8 @@ struct SourcesView: View { playOrGeneratePodcast(for: source) } label: { Image(systemName: podcastIcon(for: source)) - .font(.title3) - .foregroundStyle(.blue) + .font(.system(size: 22)) + .foregroundStyle(Paper.accent) .frame(width: 36, height: 36) } .buttonStyle(.plain) @@ -48,7 +48,7 @@ struct SourcesView: View { } label: { Label("Podcast", systemImage: "headphones") } - .tint(.blue) + .tint(Paper.accent) Button(role: .destructive) { library.delete(source) @@ -59,6 +59,7 @@ struct SourcesView: View { } } .listStyle(.plain) + .paperSurface() .navigationTitle("Sources") .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -80,10 +81,12 @@ struct SourcesView: View { } .overlay { if results.isEmpty { - ContentUnavailableView( - searchText.isEmpty ? "No Sources" : "No Results", + PaperEmptyState( + title: searchText.isEmpty ? "No Sources" : "No Results", systemImage: searchText.isEmpty ? "tray" : "magnifyingglass", - description: Text(searchText.isEmpty ? "Import text, PDFs, or files to keep non-web material in Marks." : "Try a different search.") + message: searchText.isEmpty + ? "Import text, PDFs, or files to keep non-web material in Marks." + : "Try a different search." ) } } @@ -198,19 +201,20 @@ private struct SourceRow: View { var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: iconName) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.blue) + .font(PaperType.stamp) + .foregroundStyle(Paper.accent) .frame(width: 30, height: 30) - .background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) + .background(Paper.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) VStack(alignment: .leading, spacing: 5) { Text(source.displayTitle) - .font(.headline) + .font(PaperType.title) + .foregroundStyle(Paper.ink) .lineLimit(2) if !source.excerpt.isEmpty { Text(source.excerpt) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) .lineLimit(2) } HStack(spacing: 8) { @@ -220,8 +224,8 @@ private struct SourceRow: View { Text(source.tags.joined(separator: ", ")) } } - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } } .padding(.vertical, 8) @@ -265,6 +269,7 @@ private struct ImportTextSourceView: View { Text("Separate tags with commas") } } + .paperSurface() .navigationTitle("Import Text") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -329,6 +334,8 @@ private struct EditSourceView: View { Text("Separate tags with commas") } } + .listRowBackground(Paper.raised) + .paperSurface() .navigationTitle("Edit Source") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -363,10 +370,11 @@ private struct SourceDetailView: View { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text(source.displayTitle) - .font(.title2.weight(.semibold)) + .font(PaperType.display) + .foregroundStyle(Paper.ink) Text(source.kind.label) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(PaperType.meta) + .foregroundStyle(Paper.tertiary) } if !source.tags.isEmpty { @@ -374,10 +382,11 @@ private struct SourceDetailView: View { HStack { ForEach(source.tags, id: \.self) { tag in Text(tag) - .font(.caption.weight(.medium)) + .font(PaperType.micro) + .foregroundStyle(Paper.secondary) .padding(.horizontal, 10) .padding(.vertical, 5) - .background(Color.blue.opacity(0.12), in: Capsule()) + .background(Paper.accent.opacity(0.12), in: Capsule()) } } } @@ -411,12 +420,14 @@ private struct SourceDetailView: View { } Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText) - .font(.body) + .font(PaperType.body) + .foregroundStyle(Paper.ink) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } .padding() } + .paperSurface() .navigationTitle("Source") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Marks/Views/TagsView.swift b/Marks/Views/TagsView.swift index 259f203..c9fe6fb 100644 --- a/Marks/Views/TagsView.swift +++ b/Marks/Views/TagsView.swift @@ -1,36 +1,93 @@ import SwiftUI +/// The tag surface, presented as a sheet from the library's filter strip. +/// +/// It used to be a top-level tab that pushed to a per-tag bookmark list. The +/// library's filter tabs now do that job, so this is a picker: choose a tag, +/// it becomes a tab. The old TagBookmarksView went with it. struct TagsView: View { @Bindable var viewModel: BookmarksViewModel + /// Tags already pinned as filter tabs, shown as such rather than hidden — + /// their absence would just read as a missing tag. + let pinned: Set<String> + let onPick: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var query = "" var body: some View { NavigationStack { - List { - ForEach(allTags, id: \.tag) { entry in - NavigationLink { - TagBookmarksView(tag: entry.tag, viewModel: viewModel) - } label: { - HStack { - Text(entry.tag) - .font(.system(size: 17)) - Spacer() - Text("\(entry.count)") - .font(.system(size: 15)) - .foregroundStyle(.secondary) - } + ScrollView { + LazyVStack(spacing: 0) { + ForEach(visibleTags, id: \.tag) { entry in + row(entry) + Rectangle() + .fill(Paper.rule.opacity(0.4)) + .frame(height: 0.6) } } + .padding(.horizontal, 18) } + .background(Paper.sheet.ignoresSafeArea()) + .scrollDismissesKeyboard(.immediately) + .searchable(text: $query, prompt: "Filter tags") .navigationTitle("Tags") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } .overlay { - if viewModel.bookmarks.isEmpty { - ContentUnavailableView("No Tags", systemImage: "tag", description: Text("Tags from your bookmarks will appear here.")) + if visibleTags.isEmpty { + PaperEmptyState( + title: query.isEmpty ? "No Tags" : "No Matching Tags", + systemImage: "tag", + message: query.isEmpty + ? "Tags from your bookmarks will appear here." + : "No tag matches “\(query)”." + ) } } } + .task { await viewModel.loadAllTags() } } - private var allTags: [(tag: String, count: Int)] { + private func row(_ entry: (tag: String, count: Int)) -> some View { + Button { + onPick(entry.tag) + dismiss() + } label: { + HStack(spacing: 10) { + Text(entry.tag) + .font(.system(size: 16.5, design: .monospaced)) + .foregroundStyle(Paper.ink) + .lineLimit(1) + if pinned.contains(entry.tag) { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Paper.ink.opacity(0.4)) + } + Spacer(minLength: 8) + // Counts come from the loaded pages, so a tag the server knows + // about but we haven't paged in yet shows no number rather than + // a wrong one. + if entry.count > 0 { + Text("\(entry.count)") + .font(.system(size: 15, design: .monospaced)) + .foregroundStyle(Paper.ink.opacity(0.4)) + } + } + .padding(.vertical, 14) + .contentShape(.rect) + } + .buttonStyle(.plain) + } + + /// Every tag the server knows, not just those on the loaded page — the + /// filter strip's old inline menu could only offer tags from the current + /// 50 bookmarks, which collapsed to almost nothing once a filter was on. + private var visibleTags: [(tag: String, count: Int)] { var counts: [String: Int] = [:] for bookmark in viewModel.bookmarks { for tag in bookmark.tagNames { @@ -40,54 +97,10 @@ struct TagsView: View { counts[tag, default: 0] += 1 } } - return counts.map { (tag: $0.key, count: $0.value) } + let names = Set(viewModel.allTags).union(counts.keys) + return names + .filter { query.isEmpty || $0.localizedCaseInsensitiveContains(query) } + .map { (tag: $0, count: counts[$0] ?? 0) } .sorted { $0.count != $1.count ? $0.count > $1.count : $0.tag < $1.tag } } } - -struct TagBookmarksView: View { - let tag: String - @Bindable var viewModel: BookmarksViewModel - - @State private var browsingBookmark: Bookmark? - - var body: some View { - List { - ForEach(filteredBookmarks) { bookmark in - BookmarkListRow( - bookmark: bookmark, - viewModel: viewModel, - onOpen: { browsingBookmark = bookmark } - ) - } - } - .listStyle(.plain) - .navigationTitle(tag) - .navigationBarTitleDisplayMode(.large) - .overlay { - if filteredBookmarks.isEmpty { - ContentUnavailableView("No Bookmarks", systemImage: "tag") - } - } - .sensoryFeedback(.selection, trigger: browsingBookmark?.id) - .sheet(item: $browsingBookmark) { bookmark in - if let url = URL(string: bookmark.url) { - BrowserView( - url: url, - title: bookmark.displayTitle, - claude: viewModel.claude, - podcastPlayer: viewModel.podcastPlayer, - podcastGenerator: viewModel.podcastGenerator - ) { - await viewModel.archive(bookmark) - } - } - } - } - - private var filteredBookmarks: [Bookmark] { - viewModel.bookmarks.filter { - $0.tagNames.contains(tag) || ($0.aiTags ?? []).contains(tag) - } - } -} diff --git a/MarksTests/SpotlightRetrievalTests.swift b/MarksTests/SpotlightRetrievalTests.swift new file mode 100644 index 0000000..9413564 --- /dev/null +++ b/MarksTests/SpotlightRetrievalTests.swift @@ -0,0 +1,40 @@ +import Testing +import CoreSpotlight +@testable import Marks + +/// End-to-end guard for the whole Spotlight path: index a bookmark, then +/// retrieve it the way the on-device assistant does. +/// +/// Worth keeping. The bug this was written for made every item fail to +/// translate into the index while `indexAppEntities` still reported success, +/// so nothing short of a round trip would have caught it. +struct SpotlightRetrievalTests { + @Test func indexedBookmarkIsRetrievable() async throws { + let b = Bookmark( + id: 999_001, + url: "https://github.com/example/zqxjkltest", + title: "Zqxjkltest concurrency notes", + description: "A distinctive probe document about zqxjkltest.", + tagNames: ["zqxjkltest"], + dateAdded: Date(), dateModified: Date(), + isArchived: false, unread: false, shared: false, + websiteTitle: nil, websiteDescription: nil, + faviconUrl: nil, previewImageUrl: nil, + aiSummary: nil, aiTags: nil + ) + await SpotlightIndexer.index([b]) + var hits: [RetrievedBookmark] = [] + for _ in 0..<20 { + try? await Task.sleep(for: .milliseconds(700)) + hits = await SpotlightBookmarkSearch.run(query: "zqxjkltest", limit: 5) + if !hits.isEmpty { break } + } + await SpotlightIndexer.remove(ids: [999_001]) + #expect(!hits.isEmpty, "Spotlight returned no hits for an indexed bookmark") + let hit = try #require(hits.first) + #expect(hit.title.contains("Zqxjkltest")) + #expect(hit.url == "https://github.com/example/zqxjkltest", + "url did not survive the round trip: \(hit.url)") + #expect(hit.host == "github.com", "host was \(hit.host)") + } +} diff --git a/ShareExtension/ShareView.swift b/ShareExtension/ShareView.swift index a9c4758..9cc1031 100644 --- a/ShareExtension/ShareView.swift +++ b/ShareExtension/ShareView.swift @@ -65,7 +65,7 @@ struct ShareView: View { case .loading: HStack(spacing: 12) { ProgressView() - Text("Checking…").foregroundStyle(.secondary) + Text("Checking…").font(PaperType.meta).foregroundStyle(Paper.secondary) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 24) @@ -85,7 +85,11 @@ struct ShareView: View { } } .padding(20) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .background(Paper.sheet, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(Paper.rule.opacity(0.5), lineWidth: 0.6) + ) .shadow(color: .black.opacity(0.15), radius: 24, y: 8) } @@ -99,19 +103,21 @@ struct ShareView: View { ? "Already saved" : "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))") } icon: { - Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + Image(systemName: "checkmark.circle.fill").foregroundStyle(Paper.affirm) } - .font(.subheadline.weight(.semibold)) + .font(PaperType.label) + .foregroundStyle(Paper.ink) } else { - Text("Save bookmark").font(.headline) + Text("Save bookmark").font(PaperType.heading).foregroundStyle(Paper.ink) } Text(title.isEmpty ? url : title) - .font(.subheadline.weight(.medium)) + .font(PaperType.quote) + .foregroundStyle(Paper.ink) .lineLimit(2) Text(domain) - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } } @@ -128,8 +134,8 @@ struct ShareView: View { HStack(spacing: 6) { ProgressView().controlSize(.small) Text("Suggesting tags…") - .font(.caption) - .foregroundStyle(.secondary) + .font(PaperType.micro) + .foregroundStyle(Paper.tertiary) } } @@ -139,11 +145,11 @@ struct ShareView: View { ForEach(suggestionChips, id: \.tag) { item in Button { addTag(item.tag) } label: { Text(item.isAI ? "✨ \(item.tag)" : "+ \(item.tag)") - .font(.caption.weight(.medium)) + .font(PaperType.micro) .padding(.horizontal, 10) .padding(.vertical, 5) - .background((item.isAI ? Color.purple : .blue).opacity(0.12), in: Capsule()) - .foregroundStyle(item.isAI ? Color.purple : .blue) + .background((item.isAI ? Paper.accent : Paper.ink).opacity(0.1), in: Capsule()) + .foregroundStyle(item.isAI ? Paper.accent : Paper.secondary) } .buttonStyle(.plain) } @@ -157,12 +163,14 @@ struct ShareView: View { .textFieldStyle(.roundedBorder) Toggle("Read later", isOn: $readLater) - .font(.subheadline) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) Toggle(isOn: $createPodcast) { Label("Create podcast", systemImage: "headphones") } - .font(.subheadline) + .font(PaperType.meta) + .foregroundStyle(Paper.secondary) } } @@ -189,8 +197,8 @@ struct ShareView: View { private func statusCard(icon: String, tint: Color, text: String) -> some View { HStack(spacing: 10) { - Image(systemName: icon).font(.title3).foregroundStyle(tint) - Text(text).font(.headline) + Image(systemName: icon).font(.system(size: 22)).foregroundStyle(tint) + Text(text).font(PaperType.heading).foregroundStyle(Paper.ink) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 16) diff --git a/project.yml b/project.yml index f03ab79..2be5962 100644 --- a/project.yml +++ b/project.yml @@ -93,6 +93,9 @@ targets: - path: Marks/Services/Log.swift # Cross-process podcast request queue: the extension enqueues, the app runs it. - path: Marks/Services/PodcastRequests.swift + # The save card is a user-facing surface, so it uses the same palette and + # type scale as the app. + - path: Marks/Views/Library/LibraryKit.swift settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension