Compare commits
15
Commits
8e1d94a4ea
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c932814166 | ||
|
|
b3d8719874 | ||
|
|
2e1b454c09 | ||
|
|
1995b38b52 | ||
|
|
69556afc08 | ||
|
|
76df237473 | ||
|
|
dd1c09a00c | ||
|
|
fffb3999bf | ||
|
|
8a777ffc83 | ||
|
|
69c2bc7514 | ||
|
|
0b8b9fe512 | ||
|
|
cdbec4aaf8 | ||
|
|
3c7a783b6c | ||
|
|
99add346fd | ||
|
|
6a9b6f193f |
@@ -14,13 +14,19 @@
|
||||
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 */; };
|
||||
2F80DFB7298B733965FE04F5 /* MarksMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EC8E834AB663A823D77BCB1 /* MarksMark.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 +52,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 +64,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 +133,13 @@
|
||||
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<group>"; };
|
||||
18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSourceStore.swift; sourceTree = "<group>"; };
|
||||
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; };
|
||||
1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryGridView.swift; sourceTree = "<group>"; };
|
||||
217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; };
|
||||
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightRetrievalTests.swift; sourceTree = "<group>"; };
|
||||
3EC8E834AB663A823D77BCB1 /* MarksMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksMark.swift; sourceTree = "<group>"; };
|
||||
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = "<group>"; };
|
||||
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; };
|
||||
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
|
||||
@@ -141,6 +152,7 @@
|
||||
64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = "<group>"; };
|
||||
6905CD5B1864895E2F84C7DF /* TagSuggester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSuggester.swift; sourceTree = "<group>"; };
|
||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; };
|
||||
7510EB352E624C7C9656EA33 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
|
||||
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; };
|
||||
7623601C25E481DF58371F2A /* AppIntentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTests.swift; sourceTree = "<group>"; };
|
||||
7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSource.swift; sourceTree = "<group>"; };
|
||||
@@ -153,10 +165,10 @@
|
||||
9B7A85A23A13D754F6A75E4D /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
||||
9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSnippetViews.swift; sourceTree = "<group>"; };
|
||||
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; };
|
||||
AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryKit.swift; sourceTree = "<group>"; };
|
||||
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
|
||||
AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = "<group>"; };
|
||||
B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = "<group>"; };
|
||||
BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTool.swift; sourceTree = "<group>"; };
|
||||
@@ -172,6 +184,7 @@
|
||||
D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; };
|
||||
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
|
||||
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; };
|
||||
E093C878E702891C64D21FD5 /* LibraryListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListRow.swift; sourceTree = "<group>"; };
|
||||
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = "<group>"; };
|
||||
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSpotlightIndexer.swift; sourceTree = "<group>"; };
|
||||
@@ -235,10 +248,22 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7623601C25E481DF58371F2A /* AppIntentsTests.swift */,
|
||||
2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */,
|
||||
);
|
||||
path = MarksTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
585E3F011CBA8ECA6D1925C0 /* Library */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */,
|
||||
AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */,
|
||||
E093C878E702891C64D21FD5 /* LibraryListRow.swift */,
|
||||
3EC8E834AB663A823D77BCB1 /* MarksMark.swift */,
|
||||
);
|
||||
path = Library;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
58E8E316BE3F10C5149AADC3 /* Intents */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -286,6 +311,14 @@
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7F380B7CE96F87441C28DB93 /* Prototypes */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7510EB352E624C7C9656EA33 /* LibraryView.swift */,
|
||||
);
|
||||
path = Prototypes;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
85E717A515682EBF67DD199A /* MarksWidget */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -305,8 +338,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 +351,8 @@
|
||||
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
||||
CDB1DA808EE8041C4546DAAB /* SourcesView.swift */,
|
||||
B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
|
||||
585E3F011CBA8ECA6D1925C0 /* Library */,
|
||||
7F380B7CE96F87441C28DB93 /* Prototypes */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
@@ -499,6 +534,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */,
|
||||
59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -508,6 +544,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 +577,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,11 +594,16 @@
|
||||
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 */,
|
||||
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */,
|
||||
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */,
|
||||
2F80DFB7298B733965FE04F5 /* MarksMark.swift in Sources */,
|
||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
|
||||
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */,
|
||||
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 14 KiB |
@@ -5,6 +5,30 @@
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon-Dark.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon-Tinted.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ struct AddBookmarkView: View {
|
||||
Form {
|
||||
Section {
|
||||
TextEditor(text: $importText)
|
||||
.paperField()
|
||||
.frame(minHeight: 96)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
@@ -27,6 +28,8 @@ struct AddBookmarkView: View {
|
||||
extractImportText()
|
||||
} label: {
|
||||
Label("Extract Link", systemImage: "link.badge.plus")
|
||||
.font(PaperType.label)
|
||||
.foregroundStyle(Paper.accent)
|
||||
}
|
||||
.disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
@@ -34,56 +37,62 @@ struct AddBookmarkView: View {
|
||||
|
||||
if let importMessage {
|
||||
Text(importMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Import Text")
|
||||
Text("Import Text").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
} footer: {
|
||||
Text("Paste an email, newsletter, or message and Marks will pull out the first web link.")
|
||||
Text("Paste an email, newsletter, or message and Marks will pull out the first web link.").font(PaperType.micro).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("https://", text: $url)
|
||||
.paperField()
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("URL")
|
||||
Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Optional", text: $title)
|
||||
.paperField()
|
||||
} header: {
|
||||
Text("Title")
|
||||
Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Optional", text: $description, axis: .vertical)
|
||||
.paperField()
|
||||
.lineLimit(2...5)
|
||||
} header: {
|
||||
Text("Notes")
|
||||
Text("Notes").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("comma separated", text: $tagsText)
|
||||
.paperField()
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("Tags")
|
||||
Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
} footer: {
|
||||
Text("Separate tags with commas")
|
||||
Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
if let error {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Paper.raised)
|
||||
.paperSurface()
|
||||
.navigationTitle("Add Bookmark")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -92,6 +101,8 @@ struct AddBookmarkView: View {
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { save() }
|
||||
.font(PaperType.label)
|
||||
.tint(Paper.accent)
|
||||
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
|
||||
.overlay {
|
||||
if isSaving { ProgressView().scaleEffect(0.7) }
|
||||
|
||||
+31
-17
@@ -18,20 +18,23 @@ struct AskView: View {
|
||||
case .checking:
|
||||
ProgressView()
|
||||
case .unavailable(let message):
|
||||
ContentUnavailableView(
|
||||
"Unavailable",
|
||||
PaperEmptyState(
|
||||
title: "Unavailable",
|
||||
systemImage: "sparkles.slash",
|
||||
description: Text(message)
|
||||
message: message
|
||||
)
|
||||
case .ready:
|
||||
ready
|
||||
}
|
||||
}
|
||||
.paperSurface()
|
||||
.navigationTitle("Ask Your Bookmarks")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.font(PaperType.label)
|
||||
.tint(Paper.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,17 +45,19 @@ struct AskView: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if answer.isEmpty && !isLoading && errorText == nil {
|
||||
ContentUnavailableView {
|
||||
Label("Ask anything", systemImage: "sparkles")
|
||||
} description: {
|
||||
Text("Answers come from your saved bookmarks, generated on-device.")
|
||||
}
|
||||
PaperEmptyState(
|
||||
title: "Ask anything",
|
||||
systemImage: "sparkles",
|
||||
message: "Answers come from your saved bookmarks, generated on-device."
|
||||
)
|
||||
.padding(.top, 40)
|
||||
}
|
||||
if isLoading {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
Text("Searching your bookmarks…").foregroundStyle(.secondary)
|
||||
Text("Searching your bookmarks…")
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
}
|
||||
if !answer.isEmpty {
|
||||
@@ -61,7 +66,9 @@ struct AskView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if let errorText {
|
||||
Text(errorText).foregroundStyle(.red)
|
||||
Text(errorText)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -69,17 +76,24 @@ struct AskView: View {
|
||||
|
||||
HStack(spacing: 10) {
|
||||
TextField("Ask about your bookmarks…", text: $question, axis: .vertical)
|
||||
.font(PaperType.body)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.lineLimit(1...4)
|
||||
.focused($focused)
|
||||
.submitLabel(.send)
|
||||
.onSubmit(send)
|
||||
Button(action: send) {
|
||||
Image(systemName: "arrow.up.circle.fill").font(.title2)
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 26))
|
||||
.foregroundStyle(Paper.accent)
|
||||
}
|
||||
.disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading)
|
||||
}
|
||||
.padding()
|
||||
.background(.bar)
|
||||
.background(Paper.raised)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle().fill(Paper.rule.opacity(0.5)).frame(height: 0.6)
|
||||
}
|
||||
}
|
||||
.onAppear { focused = true }
|
||||
}
|
||||
@@ -112,18 +126,18 @@ private struct MarkdownAnswer: View {
|
||||
private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
|
||||
switch block {
|
||||
case .heading(let line):
|
||||
inline(line).font(.headline)
|
||||
inline(line).font(PaperType.heading)
|
||||
case .bullet(let line):
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("•").foregroundStyle(.secondary)
|
||||
inline(line)
|
||||
Text("•").foregroundStyle(Paper.tertiary)
|
||||
inline(line).font(PaperType.body)
|
||||
}
|
||||
case .paragraph(let line):
|
||||
inline(line)
|
||||
inline(line).font(PaperType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Bool>,
|
||||
episodePicker: Binding<Bookmark?>
|
||||
) {
|
||||
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<Bool>,
|
||||
episodePicker: Binding<Bookmark?>
|
||||
) -> some View {
|
||||
modifier(PodcastSheets(
|
||||
viewModel: viewModel,
|
||||
showFullPlayer: showFullPlayer,
|
||||
episodePicker: episodePicker
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
launchPodcast(
|
||||
for: bookmark,
|
||||
viewModel: viewModel,
|
||||
showFullPlayer: $showFullPlayer,
|
||||
episodePicker: $episodePickerBookmark
|
||||
)
|
||||
showFullPlayer = true
|
||||
} else if viewModel.playOrGeneratePodcast(articleUrl: bookmark.url, title: bookmark.displayTitle) {
|
||||
showFullPlayer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
-45
@@ -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 }
|
||||
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 }
|
||||
)
|
||||
.onAppear { maybeLoadMore(bookmark) }
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
@@ -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
|
||||
@@ -214,52 +225,71 @@ final class BookmarksViewModel {
|
||||
}
|
||||
|
||||
func enrichAll() async {
|
||||
let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }
|
||||
// Track ids, not indices. Each iteration awaits the network, and
|
||||
// `bookmarks` is replaced wholesale by loads, searches and filter
|
||||
// toggles — an index captured before the await can be out of range by
|
||||
// the time it is used.
|
||||
let toEnrich = bookmarks.filter { $0.aiSummary == nil }.map(\.id)
|
||||
guard !toEnrich.isEmpty else { return }
|
||||
enrichmentProgress = 0
|
||||
for (done, i) in toEnrich.enumerated() {
|
||||
var completed = 0
|
||||
for id in toEnrich {
|
||||
guard let bookmark = bookmarks.first(where: { $0.id == id }) else { continue }
|
||||
do {
|
||||
let (summary, tags) = try await claude.enrich(bookmark: bookmarks[i])
|
||||
bookmarks[i].aiSummary = summary
|
||||
bookmarks[i].aiTags = tags
|
||||
saveAIData(for: bookmarks[i])
|
||||
let enriched = bookmarks[i]
|
||||
Task { await SpotlightIndexer.index([enriched]) }
|
||||
enrichmentProgress = Double(done + 1) / Double(toEnrich.count)
|
||||
let (summary, tags) = try await claude.enrich(bookmark: bookmark)
|
||||
apply(summary: summary, tags: tags, toId: id)
|
||||
completed += 1
|
||||
enrichmentProgress = Double(completed) / Double(toEnrich.count)
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
let enriched = toEnrich.count - bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.count
|
||||
if enriched > 0 { Analytics.track("enrich.completed", ["count": enriched]) }
|
||||
if completed > 0 { Analytics.track("enrich.completed", ["count": completed]) }
|
||||
enrichmentProgress = 0
|
||||
}
|
||||
|
||||
// Silently enrich new bookmarks that lack summaries (background, non-blocking)
|
||||
private func startEnrichment() {
|
||||
enrichTask?.cancel()
|
||||
enrichTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) }
|
||||
Log.ai.info("startEnrichment: \(indices.count, privacy: .public) to enrich")
|
||||
for i in indices {
|
||||
guard !Task.isCancelled else { return }
|
||||
let claude = await MainActor.run(body: { self.claude })
|
||||
do {
|
||||
let bm = await MainActor.run { bookmarks[i] }
|
||||
Log.ai.debug("startEnrichment enriching id=\(bm.id, privacy: .public)")
|
||||
let (summary, tags) = try await claude.enrich(bookmark: bm)
|
||||
Log.ai.debug("startEnrichment done id=\(bm.id, privacy: .public)")
|
||||
await MainActor.run {
|
||||
guard i < bookmarks.count else { return }
|
||||
/// Write an enrichment result back by id, skipping it if the bookmark is no
|
||||
/// longer loaded (deleted, archived, or filtered away mid-flight).
|
||||
private func apply(summary: String, tags: [String], toId id: Int) {
|
||||
guard let i = bookmarks.firstIndex(where: { $0.id == id }) else {
|
||||
Log.ai.notice("enrich result dropped: id=\(id, privacy: .public) no longer loaded")
|
||||
return
|
||||
}
|
||||
bookmarks[i].aiSummary = summary
|
||||
bookmarks[i].aiTags = tags
|
||||
saveAIData(for: bookmarks[i])
|
||||
let enriched = bookmarks[i]
|
||||
Task { await SpotlightIndexer.index([enriched]) }
|
||||
}
|
||||
|
||||
// Silently enrich new bookmarks that lack summaries (background, non-blocking)
|
||||
//
|
||||
// Everything here is keyed by bookmark id rather than array index. This loop
|
||||
// awaits a network call per bookmark, and `bookmarks` gets replaced under it
|
||||
// by loads, searches and the unread filter — an index captured up front used
|
||||
// to crash on the read (`bookmarks[i]`), which the write path already
|
||||
// guarded against but the read did not.
|
||||
private func startEnrichment() {
|
||||
enrichTask?.cancel()
|
||||
enrichTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let ids = await MainActor.run {
|
||||
bookmarks.filter { $0.aiSummary == nil }.prefix(5).map(\.id)
|
||||
}
|
||||
Log.ai.info("startEnrichment: \(ids.count, privacy: .public) to enrich")
|
||||
for id in ids {
|
||||
guard !Task.isCancelled else { return }
|
||||
let claude = await MainActor.run(body: { self.claude })
|
||||
guard let bm = await MainActor.run(body: {
|
||||
bookmarks.first { $0.id == id }
|
||||
}) else { continue }
|
||||
do {
|
||||
Log.ai.debug("startEnrichment enriching id=\(id, privacy: .public)")
|
||||
let (summary, tags) = try await claude.enrich(bookmark: bm)
|
||||
Log.ai.debug("startEnrichment done id=\(id, privacy: .public)")
|
||||
await MainActor.run { apply(summary: summary, tags: tags, toId: id) }
|
||||
} catch {
|
||||
Log.ai.error("startEnrichment failed idx=\(i, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
Log.ai.error("startEnrichment failed id=\(id, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ struct BrowserView: View {
|
||||
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.fill(Color.blue.opacity(0.55))
|
||||
.fill(Paper.accent.opacity(0.55))
|
||||
.frame(width: geo.size.width * state.readingProgress, height: 3)
|
||||
}
|
||||
.frame(height: 3)
|
||||
@@ -409,11 +409,11 @@ struct BrowserView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(.primary)
|
||||
.font(PaperType.label)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(.bar)
|
||||
.background(Paper.raised)
|
||||
.overlay(alignment: .top) { Divider() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,41 +11,44 @@ struct CollectionsView: View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text("Building smart collections…")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if viewModel.smartCollections.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Collections Yet",
|
||||
PaperEmptyState(
|
||||
title: "No Collections Yet",
|
||||
systemImage: "sparkles",
|
||||
description: Text("Tap \"Smart Collections\" to group your bookmarks by topic.")
|
||||
message: "Tap \"Smart Collections\" to group your bookmarks by topic."
|
||||
)
|
||||
} else {
|
||||
List(viewModel.smartCollections) { collection in
|
||||
Section {
|
||||
let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) }
|
||||
ForEach(items) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
LibraryListRow(bookmark: bookmark)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||
.listRowBackground(Paper.sheet)
|
||||
.listRowSeparatorTint(Paper.rule.opacity(0.5))
|
||||
}
|
||||
} header: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(collection.name)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.font(PaperType.heading)
|
||||
.foregroundStyle(Paper.ink)
|
||||
if !collection.description.isEmpty {
|
||||
Text(collection.description)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
.paperSurface()
|
||||
.navigationTitle("Smart Collections")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -59,6 +62,8 @@ struct CollectionsView: View {
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.font(PaperType.label)
|
||||
.tint(Paper.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,44 +27,59 @@ struct EditBookmarkView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("URL") {
|
||||
Section {
|
||||
TextField("https://", text: $url)
|
||||
.paperField()
|
||||
.keyboardType(.URL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section("Title") {
|
||||
Section {
|
||||
TextField("Optional", text: $title)
|
||||
.paperField()
|
||||
} header: {
|
||||
Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section("Description") {
|
||||
Section {
|
||||
TextField("Optional", text: $description, axis: .vertical)
|
||||
.paperField()
|
||||
.lineLimit(3...6)
|
||||
} header: {
|
||||
Text("Description").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("comma separated", text: $tagsText)
|
||||
.paperField()
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
} header: {
|
||||
Text("Tags")
|
||||
Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
} footer: {
|
||||
Text("Separate tags with commas")
|
||||
Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Mark as unread", isOn: $unread)
|
||||
.font(PaperType.label)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.tint(Paper.accent)
|
||||
}
|
||||
|
||||
if let error {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Paper.raised)
|
||||
.paperSurface()
|
||||
.navigationTitle("Edit Bookmark")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -73,6 +88,8 @@ struct EditBookmarkView: View {
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { save() }
|
||||
.font(PaperType.label)
|
||||
.tint(Paper.accent)
|
||||
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
|
||||
.overlay {
|
||||
if isSaving { ProgressView().scaleEffect(0.7) }
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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[..<colon.lowerBound])
|
||||
}
|
||||
if title.count > 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<String> = ["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 <title> 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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The mark
|
||||
//
|
||||
// One path, four tails.
|
||||
//
|
||||
// The brand mark is a single closed shape rather than a stack of separate
|
||||
// ribbons, and that is the whole design decision. A logo gets tested in two
|
||||
// places that have nothing to do with how it looks on a slide: at 16pt in a
|
||||
// Spotlight result, and in iOS 26's tinted and monochrome icon modes, where the
|
||||
// palette is thrown away and only the silhouette survives. Every arrangement of
|
||||
// N separate ribbons collapses into a blob under both tests, because the shapes
|
||||
// were only ever told apart by colour. A scalloped baseline is told apart by
|
||||
// its outline, so it reads the same tinted, monochrome, and 16pt tall.
|
||||
//
|
||||
// Geometry is defined in a canonical 88 x 98 box and fitted to whatever rect it
|
||||
// is handed, so the proportions can't drift with the container.
|
||||
|
||||
struct ScallopMark: Shape {
|
||||
/// Downward points along the baseline. Four is the drawn default: three
|
||||
/// reads as a plain banner, and five starts to look like bunting.
|
||||
var tails: Int = 4
|
||||
|
||||
/// How far the notches cut up from the baseline, as a fraction of height.
|
||||
/// Shallower than this and the tails read as damage rather than intent.
|
||||
var notchDepth: CGFloat = 16.0 / 98.0
|
||||
|
||||
/// Top-corner radius, as a fraction of width.
|
||||
var capRadius: CGFloat = 10.0 / 88.0
|
||||
|
||||
/// Canonical proportions of the drawn mark. Everything else derives from
|
||||
/// this, including the app icon artwork — see scripts/appicon.
|
||||
static let aspectRatio: CGFloat = 88.0 / 98.0
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
// Fit the canonical box into `rect` without distorting it, so callers
|
||||
// can hand this any frame and still get the drawn proportions.
|
||||
let height = min(rect.height, rect.width / Self.aspectRatio)
|
||||
let width = height * Self.aspectRatio
|
||||
let minX = rect.midX - width / 2
|
||||
let minY = rect.midY - height / 2
|
||||
let maxX = minX + width
|
||||
let maxY = minY + height
|
||||
|
||||
let radius = min(capRadius * width, min(width, height) / 2)
|
||||
let depth = notchDepth * height
|
||||
let points = max(2, tails)
|
||||
let step = width / CGFloat(points - 1)
|
||||
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: minX + radius, y: minY))
|
||||
// Top edge, right cap, then straight down the right side.
|
||||
path.addArc(
|
||||
tangent1End: CGPoint(x: maxX, y: minY),
|
||||
tangent2End: CGPoint(x: maxX, y: minY + radius),
|
||||
radius: radius
|
||||
)
|
||||
path.addLine(to: CGPoint(x: maxX, y: maxY))
|
||||
// The tails, cut right to left so the winding stays consistent with
|
||||
// the caps above.
|
||||
for i in 1..<points {
|
||||
let apex = maxX - step * (CGFloat(i) - 0.5)
|
||||
path.addLine(to: CGPoint(x: apex, y: maxY - depth))
|
||||
path.addLine(to: CGPoint(x: maxX - step * CGFloat(i), y: maxY))
|
||||
}
|
||||
// Up the left side and through the left cap. The loop above already
|
||||
// left the pen on the bottom-left point.
|
||||
path.addArc(
|
||||
tangent1End: CGPoint(x: minX, y: minY),
|
||||
tangent2End: CGPoint(x: minX + radius, y: minY),
|
||||
radius: radius
|
||||
)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Brand colour
|
||||
|
||||
extension Paper {
|
||||
/// The mark's bands, left to right: navy, vermilion, yellow.
|
||||
///
|
||||
/// These are palette swatches rather than bespoke brand colours on
|
||||
/// purpose. The app already hashes every bookmark into `swatchPairs`, so a
|
||||
/// mark built from three of them is made of the same ink as the content —
|
||||
/// which is also the only part of this design a competitor can't lift
|
||||
/// without lifting the system underneath it.
|
||||
static let markBands: [Color] = [swatch(5), swatch(1), swatch(0)]
|
||||
}
|
||||
|
||||
// MARK: - The mark, coloured
|
||||
|
||||
/// The mark as it should normally be used. Sizes itself to the drawn aspect
|
||||
/// ratio, so `.frame(height:)` is the natural way to scale it.
|
||||
struct MarksMark: View {
|
||||
var bands: [Color] = Paper.markBands
|
||||
var tails: Int = 4
|
||||
|
||||
/// A single-ink version, for anywhere the bands would be noise: monochrome
|
||||
/// contexts, the share sheet, a nav bar, print.
|
||||
static func monochrome(_ color: Color = Paper.ink) -> MarksMark {
|
||||
MarksMark(bands: [color])
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScallopMark(tails: tails)
|
||||
.fill(bandFill)
|
||||
.aspectRatio(ScallopMark.aspectRatio, contentMode: .fit)
|
||||
}
|
||||
|
||||
/// Hard-stopped, not blended: the bands are three inks laid side by side,
|
||||
/// not a gradient. Doubling the stop at each boundary is what kills the
|
||||
/// ramp SwiftUI would otherwise interpolate.
|
||||
private var bandFill: LinearGradient {
|
||||
let stops = bands.enumerated().flatMap { index, color in
|
||||
[
|
||||
Gradient.Stop(color: color, location: Double(index) / Double(bands.count)),
|
||||
Gradient.Stop(color: color, location: Double(index + 1) / Double(bands.count)),
|
||||
]
|
||||
}
|
||||
return LinearGradient(stops: stops, startPoint: .leading, endPoint: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("Size ladder") {
|
||||
VStack(spacing: 28) {
|
||||
HStack(alignment: .bottom, spacing: 22) {
|
||||
ForEach([128, 64, 40, 24, 16], id: \.self) { size in
|
||||
MarksMark().frame(height: CGFloat(size))
|
||||
}
|
||||
}
|
||||
// The test the shape was chosen to pass: no colour, same mark.
|
||||
HStack(alignment: .bottom, spacing: 22) {
|
||||
ForEach([128, 64, 40, 24, 16], id: \.self) { size in
|
||||
MarksMark.monochrome().frame(height: CGFloat(size))
|
||||
}
|
||||
}
|
||||
HStack(spacing: 22) {
|
||||
ForEach(3..<6) { tails in
|
||||
MarksMark(tails: tails).frame(height: 72)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(40)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Paper.sheet)
|
||||
}
|
||||
|
||||
#Preview("Lockup") {
|
||||
HStack(spacing: 18) {
|
||||
MarksMark().frame(height: 64)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Marks").font(.system(size: 46, design: .serif))
|
||||
Text("EVERYTHING YOU SAVED")
|
||||
.font(PaperType.micro)
|
||||
.tracking(3)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Paper.ink)
|
||||
.padding(40)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Paper.sheet)
|
||||
}
|
||||
@@ -17,10 +17,11 @@ struct OnboardingView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Marks")
|
||||
.font(.system(size: 42, weight: .semibold, design: .rounded))
|
||||
.font(.system(size: 42, design: .serif))
|
||||
.foregroundStyle(Paper.ink)
|
||||
Text("Your bookmarks, beautifully.")
|
||||
.font(.system(size: 17))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.body)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 28)
|
||||
@@ -29,8 +30,8 @@ struct OnboardingView: View {
|
||||
VStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Server URL")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.stamp)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.padding(.horizontal, 2)
|
||||
TextField("https://links.example.com", text: $urlText)
|
||||
.textContentType(.URL)
|
||||
@@ -41,14 +42,15 @@ struct OnboardingView: View {
|
||||
.submitLabel(.next)
|
||||
.onSubmit { focused = .token }
|
||||
.padding(14)
|
||||
.background(Color(.systemGray6))
|
||||
.paperField()
|
||||
.background(Paper.raised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("API Token")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.stamp)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.padding(.horizontal, 2)
|
||||
SecureField("Paste your token", text: $token)
|
||||
.textContentType(.password)
|
||||
@@ -58,11 +60,12 @@ struct OnboardingView: View {
|
||||
.submitLabel(.done)
|
||||
.onSubmit { Task { await connect() } }
|
||||
.padding(14)
|
||||
.background(Color(.systemGray6))
|
||||
.paperField()
|
||||
.background(Paper.raised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
Text("Find your token at Settings → API Token in Linkding.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
@@ -70,8 +73,8 @@ struct OnboardingView: View {
|
||||
|
||||
if let err = errorMessage {
|
||||
Text(err)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.red)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
.padding(.top, 12)
|
||||
.padding(.horizontal, 28)
|
||||
}
|
||||
@@ -84,7 +87,7 @@ struct OnboardingView: View {
|
||||
ProgressView().tint(.white)
|
||||
} else {
|
||||
Text("Connect")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.font(PaperType.label)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
@@ -510,6 +510,7 @@ struct PodcastPlayerView: View {
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.paperSurface()
|
||||
.navigationTitle("Podcast")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -520,7 +521,7 @@ struct PodcastPlayerView: View {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { vm.stop(); dismiss() } label: {
|
||||
Image(systemName: "stop.circle")
|
||||
.foregroundStyle(.red)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,16 +557,16 @@ struct PodcastPlayerView: View {
|
||||
VStack(spacing: 28) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 52))
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
.symbolEffect(.variableColor.iterative, isActive: true)
|
||||
|
||||
VStack(spacing: 10) {
|
||||
ProgressView(value: progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(.blue)
|
||||
.tint(Paper.accent)
|
||||
Text(label)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -573,16 +574,16 @@ struct PodcastPlayerView: View {
|
||||
private func playerView(title: String) -> some View {
|
||||
VStack(spacing: 28) {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color(.systemGray6))
|
||||
.fill(Paper.raised)
|
||||
.frame(width: 220, height: 220)
|
||||
.overlay {
|
||||
Image(systemName: "waveform.circle.fill")
|
||||
.font(.system(size: 80))
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(Paper.accent)
|
||||
}
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.font(PaperType.heading)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(3)
|
||||
|
||||
@@ -591,32 +592,32 @@ struct PodcastPlayerView: View {
|
||||
value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }),
|
||||
in: 0...vm.duration
|
||||
)
|
||||
.tint(.primary)
|
||||
.tint(Paper.ink)
|
||||
|
||||
HStack {
|
||||
Text(formatTime(vm.currentTime))
|
||||
Spacer()
|
||||
Text(formatTime(vm.duration))
|
||||
}
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
HStack(spacing: 36) {
|
||||
Button { vm.skipBackward15() } label: {
|
||||
Image(systemName: "gobackward.15")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.primary)
|
||||
.foregroundStyle(Paper.ink)
|
||||
}
|
||||
Button { vm.togglePlayPause() } label: {
|
||||
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 72))
|
||||
.foregroundStyle(.primary)
|
||||
.foregroundStyle(Paper.ink)
|
||||
}
|
||||
Button { vm.skipForward15() } label: {
|
||||
Image(systemName: "goforward.15")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.primary)
|
||||
.foregroundStyle(Paper.ink)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,8 +637,8 @@ struct PodcastPlayerView: View {
|
||||
}
|
||||
} label: {
|
||||
Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.glassEffect(in: Capsule())
|
||||
@@ -652,7 +653,7 @@ struct PodcastPlayerView: View {
|
||||
} label: {
|
||||
Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(sleepActive ? Color.blue : .secondary)
|
||||
.foregroundStyle(sleepActive ? Paper.accent : .secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.glassEffect(in: Capsule())
|
||||
@@ -676,7 +677,7 @@ struct PodcastPlayerView: View {
|
||||
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
|
||||
Label("Share Episode", systemImage: "square.and.arrow.up")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -687,12 +688,12 @@ struct PodcastPlayerView: View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(.red)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
Text("Generation Failed")
|
||||
.font(.headline)
|
||||
.font(PaperType.heading)
|
||||
Text(message)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button {
|
||||
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
|
||||
@@ -701,7 +702,7 @@ struct PodcastPlayerView: View {
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
.buttonStyle(.glassProminent)
|
||||
.tint(.blue)
|
||||
.tint(Paper.accent)
|
||||
.buttonBorderShape(.capsule)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
@@ -724,18 +725,18 @@ struct MiniPlayerView: View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(Paper.accent)
|
||||
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
|
||||
.frame(width: 22)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.lineLimit(1)
|
||||
Text(progressLabel)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -749,7 +750,7 @@ struct MiniPlayerView: View {
|
||||
} label: {
|
||||
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(.primary)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -760,20 +761,20 @@ struct MiniPlayerView: View {
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(.regularMaterial)
|
||||
.background(Paper.raised)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(alignment: .bottom) {
|
||||
// Progress bar along bottom edge
|
||||
GeometryReader { geo in
|
||||
Rectangle()
|
||||
.fill(Color.blue.opacity(0.6))
|
||||
.fill(Paper.accent.opacity(0.6))
|
||||
.frame(width: geo.size.width * progressFraction, height: 3)
|
||||
}
|
||||
.frame(height: 3)
|
||||
@@ -819,10 +820,10 @@ struct PodcastLibraryView: View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if library.entries.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Podcasts",
|
||||
PaperEmptyState(
|
||||
title: "No Podcasts",
|
||||
systemImage: "headphones",
|
||||
description: Text("Podcasts you generate from bookmarks will appear here.")
|
||||
message: "Podcasts you generate from bookmarks will appear here."
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
@@ -840,6 +841,7 @@ struct PodcastLibraryView: View {
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
.paperSurface()
|
||||
.navigationTitle("Podcasts")
|
||||
.toolbar {
|
||||
if !unplayed.isEmpty {
|
||||
@@ -888,18 +890,18 @@ struct PodcastLibraryView: View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.font(PaperType.quote)
|
||||
.lineLimit(2)
|
||||
.foregroundStyle(entry.isPlayed ? .secondary : .primary)
|
||||
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if entry.isPlayed {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(.green)
|
||||
.foregroundStyle(Paper.affirm)
|
||||
.accessibilityLabel("Played")
|
||||
}
|
||||
Button {
|
||||
@@ -907,7 +909,7 @@ struct PodcastLibraryView: View {
|
||||
} label: {
|
||||
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(Paper.accent)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -1005,17 +1007,17 @@ struct EpisodePickerView: View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.font(PaperType.quote)
|
||||
.lineLimit(2)
|
||||
if entry.articleUrl != bookmark.url {
|
||||
Text(entry.articleUrl)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
@@ -1026,7 +1028,7 @@ struct EpisodePickerView: View {
|
||||
} label: {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.blue)
|
||||
.foregroundStyle(Paper.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -1095,21 +1097,21 @@ struct EpisodeDetailView: View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(entry.title ?? entry.articleUrl)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.font(PaperType.display)
|
||||
Label(domain, systemImage: "link")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 12) {
|
||||
Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted),
|
||||
systemImage: "calendar")
|
||||
if isPlayed {
|
||||
Label("Played", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.foregroundStyle(Paper.affirm)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
@@ -1118,7 +1120,7 @@ struct EpisodeDetailView: View {
|
||||
Section("Summary") {
|
||||
Text(summary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ struct SearchView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.paperSurface()
|
||||
.navigationTitle("Search")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@@ -33,9 +34,9 @@ struct SearchView: View {
|
||||
}
|
||||
.overlay {
|
||||
if searchText.isEmpty {
|
||||
ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag."))
|
||||
PaperEmptyState(title: "Search Bookmarks", systemImage: "magnifyingglass", message: "Search by title, URL, or tag.")
|
||||
} else if results.isEmpty && !viewModel.isLoading {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
PaperEmptyState(title: "No Results", systemImage: "magnifyingglass", message: "Nothing matches \u{201C}\(searchText)\u{201D}.")
|
||||
}
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
|
||||
@@ -11,14 +11,18 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("AI Features") {
|
||||
Section {
|
||||
Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
} header: {
|
||||
Text("AI Features").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("e.g. listen podcast", text: $autoTagText)
|
||||
.font(PaperType.label)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.onChange(of: autoTagText) { _, new in
|
||||
@@ -27,25 +31,35 @@ struct SettingsView: View {
|
||||
.map(String.init)
|
||||
}
|
||||
} header: {
|
||||
Text("Auto-Podcast Tags")
|
||||
Text("Auto-Podcast Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
} footer: {
|
||||
Text("Saving a bookmark with any of these tags automatically generates a podcast for it.")
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
Section("Server") {
|
||||
Button(role: .destructive) {
|
||||
Section {
|
||||
Button {
|
||||
dismiss()
|
||||
onDisconnect()
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "person.crop.circle.badge.minus")
|
||||
.font(PaperType.label)
|
||||
.foregroundStyle(Paper.alarm)
|
||||
}
|
||||
} header: {
|
||||
Text("Server").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listRowBackground(Paper.raised)
|
||||
.paperSurface()
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.font(PaperType.label)
|
||||
.tint(Paper.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ struct SourcesView: View {
|
||||
playOrGeneratePodcast(for: source)
|
||||
} label: {
|
||||
Image(systemName: podcastIcon(for: source))
|
||||
.font(.title3)
|
||||
.foregroundStyle(.blue)
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(Paper.accent)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -48,7 +48,7 @@ struct SourcesView: View {
|
||||
} label: {
|
||||
Label("Podcast", systemImage: "headphones")
|
||||
}
|
||||
.tint(.blue)
|
||||
.tint(Paper.accent)
|
||||
|
||||
Button(role: .destructive) {
|
||||
library.delete(source)
|
||||
@@ -59,6 +59,7 @@ struct SourcesView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.paperSurface()
|
||||
.navigationTitle("Sources")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@@ -80,10 +81,12 @@ struct SourcesView: View {
|
||||
}
|
||||
.overlay {
|
||||
if results.isEmpty {
|
||||
ContentUnavailableView(
|
||||
searchText.isEmpty ? "No Sources" : "No Results",
|
||||
PaperEmptyState(
|
||||
title: searchText.isEmpty ? "No Sources" : "No Results",
|
||||
systemImage: searchText.isEmpty ? "tray" : "magnifyingglass",
|
||||
description: Text(searchText.isEmpty ? "Import text, PDFs, or files to keep non-web material in Marks." : "Try a different search.")
|
||||
message: searchText.isEmpty
|
||||
? "Import text, PDFs, or files to keep non-web material in Marks."
|
||||
: "Try a different search."
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -198,19 +201,20 @@ private struct SourceRow: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.font(PaperType.stamp)
|
||||
.foregroundStyle(Paper.accent)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
|
||||
.background(Paper.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(source.displayTitle)
|
||||
.font(.headline)
|
||||
.font(PaperType.title)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.lineLimit(2)
|
||||
if !source.excerpt.isEmpty {
|
||||
Text(source.excerpt)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
@@ -220,8 +224,8 @@ private struct SourceRow: View {
|
||||
Text(source.tags.joined(separator: ", "))
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
@@ -265,6 +269,7 @@ private struct ImportTextSourceView: View {
|
||||
Text("Separate tags with commas")
|
||||
}
|
||||
}
|
||||
.paperSurface()
|
||||
.navigationTitle("Import Text")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -329,6 +334,8 @@ private struct EditSourceView: View {
|
||||
Text("Separate tags with commas")
|
||||
}
|
||||
}
|
||||
.listRowBackground(Paper.raised)
|
||||
.paperSurface()
|
||||
.navigationTitle("Edit Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -363,10 +370,11 @@ private struct SourceDetailView: View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(source.displayTitle)
|
||||
.font(.title2.weight(.semibold))
|
||||
.font(PaperType.display)
|
||||
.foregroundStyle(Paper.ink)
|
||||
Text(source.kind.label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(PaperType.meta)
|
||||
.foregroundStyle(Paper.tertiary)
|
||||
}
|
||||
|
||||
if !source.tags.isEmpty {
|
||||
@@ -374,10 +382,11 @@ private struct SourceDetailView: View {
|
||||
HStack {
|
||||
ForEach(source.tags, id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.caption.weight(.medium))
|
||||
.font(PaperType.micro)
|
||||
.foregroundStyle(Paper.secondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.blue.opacity(0.12), in: Capsule())
|
||||
.background(Paper.accent.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,12 +420,14 @@ private struct SourceDetailView: View {
|
||||
}
|
||||
|
||||
Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText)
|
||||
.font(.body)
|
||||
.font(PaperType.body)
|
||||
.foregroundStyle(Paper.ink)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.paperSurface()
|
||||
.navigationTitle("Source")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
||||
+77
-64
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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)")
|
||||
}
|
||||
|
||||
@Test func indexedSourceIsRetrievable() async throws {
|
||||
let source = IngestedSource(
|
||||
kind: .text,
|
||||
title: "Wqvbnmtest meeting notes",
|
||||
bodyText: "A distinctive probe document about wqvbnmtest.",
|
||||
tags: ["wqvbnmtest"]
|
||||
)
|
||||
await SourceSpotlightIndexer.index([source])
|
||||
var hits: [RetrievedBookmark] = []
|
||||
for _ in 0..<20 {
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
hits = await SpotlightBookmarkSearch.run(query: "wqvbnmtest", limit: 5)
|
||||
if !hits.isEmpty { break }
|
||||
}
|
||||
await SourceSpotlightIndexer.remove(ids: [source.id])
|
||||
#expect(!hits.isEmpty, "Spotlight returned no hits for an indexed source")
|
||||
#expect(hits.first?.title.contains("Wqvbnmtest") == true)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Marks — C1 development</title>
|
||||
<style>
|
||||
:root{--ink:#14110C;--rule:rgba(20,17,12,.16);
|
||||
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
--serif:ui-serif,Georgia,"Times New Roman",serif}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:#EFEBE3;color:var(--ink);font-family:var(--mono);font-size:12px;-webkit-font-smoothing:antialiased}
|
||||
header{padding:56px 40px 28px;border-bottom:1px solid var(--rule)}
|
||||
h1{font-family:var(--serif);font-size:34px;font-weight:400;margin:0 0 8px}
|
||||
header p{margin:0;max-width:66ch;line-height:1.7;opacity:.62}
|
||||
section{padding:40px;border-bottom:1px solid var(--rule)}
|
||||
h2{font-family:var(--serif);font-size:22px;font-weight:400;margin:0 0 4px}
|
||||
.sub{opacity:.55;margin:0 0 28px;line-height:1.7;max-width:74ch}
|
||||
.tag{display:inline-block;font-size:10px;letter-spacing:.14em;text-transform:uppercase;opacity:.45;margin-bottom:10px}
|
||||
em{font-style:normal;border-bottom:1px solid rgba(20,17,12,.3)}
|
||||
|
||||
/* geometry is shared; colour is injected */
|
||||
.pal {--c1:#22C7F0;--c2:#8B4DEE;--c3:#5E7CF5;--bg:#fff}
|
||||
.p-orig {--c1:#22C7F0;--c2:#8B4DEE;--c3:#5E7CF5;--bg:#fff}
|
||||
.p-paper{--c1:#001A55;--c2:#ED663F;--c3:#FECD00;--bg:#F8F5EF}
|
||||
.p-ink {--c1:#14110C;--c2:#ED663F;--c3:#E0D1BB;--bg:#F8F5EF}
|
||||
.p-tri {--c1:#FECD00;--c2:#ED663F;--c3:#115AB5;--bg:#F8F5EF}
|
||||
.p-mono {--c1:#14110C;--c2:#14110C;--c3:#14110C;--bg:#F8F5EF}
|
||||
.p-dark {--c1:#5B8FD0;--c2:#E07A5C;--c3:#D8AD10;--bg:#15130F}
|
||||
|
||||
.grid{display:flex;flex-wrap:wrap;gap:18px}
|
||||
.card{background:var(--bg);border:1px solid var(--rule);border-radius:14px;padding:22px;width:243px}
|
||||
.card .stage{display:flex;align-items:center;justify-content:center;height:150px}
|
||||
.card svg{display:block}
|
||||
.card h3{font-family:var(--serif);font-size:15px;font-weight:400;margin:14px 0 5px}
|
||||
.card p{margin:0;font-size:10.5px;line-height:1.65;opacity:.62}
|
||||
.card .own{margin-top:8px;font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;opacity:.5}
|
||||
|
||||
table{border-collapse:collapse}
|
||||
th{font-weight:400;font-size:10px;letter-spacing:.1em;text-transform:uppercase;opacity:.45;padding:0 0 12px;text-align:center}
|
||||
th.rowh{text-align:right;padding-right:16px;text-transform:none;letter-spacing:0;font-size:11px;opacity:.7;white-space:nowrap}
|
||||
td{padding:5px}
|
||||
td .cell{width:92px;height:92px;border-radius:11px;background:var(--bg);border:1px solid var(--rule);display:flex;align-items:center;justify-content:center}
|
||||
|
||||
.ladder{display:flex;align-items:flex-end;gap:22px;background:var(--bg);border:1px solid var(--rule);border-radius:14px;padding:22px 26px;margin-bottom:12px}
|
||||
.ladder .n{font-size:9.5px;opacity:.4;text-align:center;margin-top:8px}
|
||||
.ladder .item{display:flex;flex-direction:column;align-items:center}
|
||||
.ladder .name{font-family:var(--serif);font-size:14px;width:118px;opacity:.8}
|
||||
|
||||
.icons{display:flex;flex-wrap:wrap;gap:20px;align-items:flex-end}
|
||||
.icon{width:118px;height:118px;border-radius:26px;display:flex;align-items:center;justify-content:center;background:var(--bg);box-shadow:0 6px 18px rgba(20,17,12,.14)}
|
||||
.icon.sm{width:58px;height:58px;border-radius:13px}
|
||||
.icoWrap{display:flex;flex-direction:column;align-items:center;gap:10px}
|
||||
.icoWrap span{font-size:10px;opacity:.5}
|
||||
|
||||
.lockup{display:flex;align-items:center;gap:20px;background:var(--bg);border:1px solid var(--rule);border-radius:14px;padding:32px 40px;margin-bottom:12px}
|
||||
.lockup .wm{font-family:var(--serif);font-size:50px;letter-spacing:-.02em;color:var(--ink)}
|
||||
.lockup.p-dark .wm{color:#F1ECE1}
|
||||
.lockup .tl{font-size:10px;letter-spacing:.22em;text-transform:uppercase;opacity:.5;margin-top:6px}
|
||||
.lockup.p-dark .tl{color:#F1ECE1;opacity:.5}
|
||||
|
||||
.pattern{display:grid;grid-template-columns:repeat(13,54px);background:var(--bg);border-radius:14px;padding:20px;width:742px;overflow:hidden}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>C1 — three ribbons, developed</h1>
|
||||
<p>C1 had the most robust silhouette of the seven and the least ownership: three bookmark ribbons is a shape any read-later app could reach for. Ownability is the thing to attack, so each variant below adds one specific idea — a rhythm, a structure, a colour rule — and the caption says whether it actually buys anything.</p>
|
||||
</header>
|
||||
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
|
||||
|
||||
<!-- a. Stepped — the baseline from the first sheet. Tops aligned, bottoms
|
||||
stepped long/longest/short. -->
|
||||
<symbol id="v-step" viewBox="0 0 120 120">
|
||||
<path fill="var(--c1)" d="M24 14 H36 A6 6 0 0 1 42 20 V100 L30 86 L18 100 V20 A6 6 0 0 1 24 14 Z"/>
|
||||
<path fill="var(--c2)" d="M54 14 H66 A6 6 0 0 1 72 20 V112 L60 98 L48 112 V20 A6 6 0 0 1 54 14 Z"/>
|
||||
<path fill="var(--c3)" d="M84 14 H96 A6 6 0 0 1 102 20 V92 L90 78 L78 92 V20 A6 6 0 0 1 84 14 Z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- b. Stair — monotonic descent instead of an arbitrary step. Directional,
|
||||
so it reads as progression rather than decoration. -->
|
||||
<symbol id="v-stair" viewBox="0 0 120 120">
|
||||
<path fill="var(--c1)" d="M24 14 H36 A6 6 0 0 1 42 20 V92 L30 78 L18 92 V20 A6 6 0 0 1 24 14 Z"/>
|
||||
<path fill="var(--c2)" d="M54 14 H66 A6 6 0 0 1 72 20 V102 L60 88 L48 102 V20 A6 6 0 0 1 54 14 Z"/>
|
||||
<path fill="var(--c3)" d="M84 14 H96 A6 6 0 0 1 102 20 V112 L90 98 L78 112 V20 A6 6 0 0 1 84 14 Z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- c. Tally — four thin equal ribbons. Tally marks and bookmarks are the
|
||||
same shape, and the app is called Marks. The pun is the ownership. -->
|
||||
<symbol id="v-tally" viewBox="0 0 120 120">
|
||||
<path fill="var(--c1)" d="M21 16 H27 A4 4 0 0 1 31 20 V104 L24 94 L17 104 V20 A4 4 0 0 1 21 16 Z"/>
|
||||
<path fill="var(--c2)" d="M45 16 H51 A4 4 0 0 1 55 20 V104 L48 94 L41 104 V20 A4 4 0 0 1 45 16 Z"/>
|
||||
<path fill="var(--c1)" d="M69 16 H75 A4 4 0 0 1 79 20 V104 L72 94 L65 104 V20 A4 4 0 0 1 69 16 Z"/>
|
||||
<path fill="var(--c3)" d="M93 16 H99 A4 4 0 0 1 103 20 V104 L96 94 L89 104 V20 A4 4 0 0 1 93 16 Z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- d. Rail — hung from a bar. Restores the shelf/box-file reading the
|
||||
reference had and C1 threw away, for the cost of one rectangle. -->
|
||||
<symbol id="v-rail" viewBox="0 0 120 120">
|
||||
<path fill="var(--c2)" d="M22 30 H44 V104 L33 91 L22 104 Z"/>
|
||||
<path fill="var(--c3)" d="M49 30 H71 V112 L60 99 L49 112 Z"/>
|
||||
<path fill="var(--c2)" d="M76 30 H98 V96 L87 83 L76 96 Z"/>
|
||||
<path fill="var(--c1)" d="M20 14 H100 A6 6 0 0 1 106 20 V34 H14 V20 A6 6 0 0 1 20 14 Z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- e. Overlap — wide ribbons that cross, multiplied. This is the trick the
|
||||
reference actually runs on: depth from two flat colours, no shadow. -->
|
||||
<symbol id="v-overlap" viewBox="0 0 120 120">
|
||||
<g style="isolation:isolate">
|
||||
<path style="mix-blend-mode:multiply" fill="var(--c1)" d="M16 14 H42 A8 8 0 0 1 50 22 V100 L29 84 L8 100 V22 A8 8 0 0 1 16 14 Z"/>
|
||||
<path style="mix-blend-mode:multiply" fill="var(--c2)" d="M47 14 H73 A8 8 0 0 1 81 22 V112 L60 96 L39 112 V22 A8 8 0 0 1 47 14 Z"/>
|
||||
<path style="mix-blend-mode:multiply" fill="var(--c1)" d="M78 14 H104 A8 8 0 0 1 112 22 V94 L91 78 L70 94 V22 A8 8 0 0 1 78 14 Z"/>
|
||||
</g>
|
||||
</symbol>
|
||||
|
||||
<!-- f. Foreground — one ribbon pulled forward and keylined in the ground
|
||||
colour. Asymmetry is cheap ownership; symmetry is what makes C1 generic. -->
|
||||
<symbol id="v-front" viewBox="0 0 120 120">
|
||||
<path fill="var(--c1)" d="M26 14 H40 A6 6 0 0 1 46 20 V94 L33 81 L20 94 V20 A6 6 0 0 1 26 14 Z"/>
|
||||
<path fill="var(--c1)" d="M80 14 H94 A6 6 0 0 1 100 20 V94 L87 81 L74 94 V20 A6 6 0 0 1 80 14 Z"/>
|
||||
<path fill="var(--c2)" stroke="var(--bg)" stroke-width="5" stroke-linejoin="round"
|
||||
d="M50 24 H70 A8 8 0 0 1 78 32 V114 L60 96 L42 114 V32 A8 8 0 0 1 50 24 Z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- g. Fan — the same ribbon rotated. Reads as pages fanned rather than
|
||||
objects lined up; the only variant with any motion in it. -->
|
||||
<symbol id="v-fan" viewBox="0 0 120 120">
|
||||
<path fill="var(--c1)" transform="rotate(-15 60 110)" d="M53 18 H67 A6 6 0 0 1 73 24 V104 L60 90 L47 104 V24 A6 6 0 0 1 53 18 Z"/>
|
||||
<path fill="var(--c3)" transform="rotate(15 60 110)" d="M53 18 H67 A6 6 0 0 1 73 24 V104 L60 90 L47 104 V24 A6 6 0 0 1 53 18 Z"/>
|
||||
<path fill="var(--c2)" d="M53 14 H67 A6 6 0 0 1 73 20 V108 L60 94 L47 108 V20 A6 6 0 0 1 53 14 Z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- h. Pair — two, not three. Fewer parts survive smaller; the question is
|
||||
whether two still reads as "a collection". -->
|
||||
<symbol id="v-pair" viewBox="0 0 120 120">
|
||||
<path fill="var(--c1)" d="M33 14 H49 A7 7 0 0 1 56 21 V100 L41 84 L26 100 V21 A7 7 0 0 1 33 14 Z"/>
|
||||
<path fill="var(--c2)" d="M71 14 H87 A7 7 0 0 1 94 21 V112 L79 96 L64 112 V21 A7 7 0 0 1 71 14 Z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- i. Nested — concentric rather than side by side. A notch inside a notch
|
||||
inside a notch: an archive, not a row. -->
|
||||
<symbol id="v-nest" viewBox="0 0 120 120">
|
||||
<path fill="var(--c1)" d="M26 14 H94 A10 10 0 0 1 104 24 V112 L60 92 L16 112 V24 A10 10 0 0 1 26 14 Z"/>
|
||||
<path fill="var(--c3)" d="M42 14 H78 A8 8 0 0 1 86 22 V92 L60 77 L34 92 V22 A8 8 0 0 1 42 14 Z"/>
|
||||
<path fill="var(--c2)" d="M54 14 H66 A6 6 0 0 1 72 20 V68 L60 58 L48 68 V20 A6 6 0 0 1 54 14 Z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- j. Scallop — one shape, four tails. Everything above is N objects; this
|
||||
is a single path, which is what the tinted/mono icon modes want. -->
|
||||
<symbol id="v-scallop" viewBox="0 0 120 120">
|
||||
<clipPath id="clipScallop">
|
||||
<path d="M26 14 H94 A10 10 0 0 1 104 24 V112 L89 96 L75 112 L60 96 L45 112 L31 96 L16 112 V24 A10 10 0 0 1 26 14 Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#clipScallop)">
|
||||
<rect x="0" y="0" width="45" height="120" fill="var(--c1)"/>
|
||||
<rect x="45" y="0" width="30" height="120" fill="var(--c2)"/>
|
||||
<rect x="75" y="0" width="45" height="120" fill="var(--c3)"/>
|
||||
</g>
|
||||
</symbol>
|
||||
|
||||
</defs></svg>
|
||||
|
||||
<section>
|
||||
<span class="tag">01</span>
|
||||
<h2>Ten developments</h2>
|
||||
<p class="sub">Shown in the app's own palette (navy / vermilion / yellow from <code>Paper.swatchPairs</code>) rather than the reference cyan-violet, since that was the conclusion of the last sheet. The <em>ownable?</em> line is the honest verdict, not a summary.</p>
|
||||
<div class="grid" id="cards"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span class="tag">02</span>
|
||||
<h2>Palette</h2>
|
||||
<p class="sub">The three-swatch column is the interesting one. The app already hashes bookmarks into a twelve-swatch palette — a mark built from three of those swatches is describing the app's actual data model, which is ownership no competitor can copy without copying the system underneath it.</p>
|
||||
<table id="matrix"></table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span class="tag">03</span>
|
||||
<h2>Size ladder</h2>
|
||||
<p class="sub">128 / 64 / 40 / 24 / 16 px. C1's whole argument is that it survives this; the developments have to survive it too or they've bought ownership with legibility.</p>
|
||||
<div id="ladders"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span class="tag">04</span>
|
||||
<h2>App icon</h2>
|
||||
<p class="sub">Squircle at 118 and 58 px, mark at ~62% of the tile.</p>
|
||||
<div class="icons" id="icons"></div>
|
||||
<div class="icons" id="iconsSm" style="margin-top:22px"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<span class="tag">05</span>
|
||||
<h2>Lockups</h2>
|
||||
<div id="lockups"></div>
|
||||
</section>
|
||||
|
||||
<section style="border-bottom:none">
|
||||
<span class="tag">06</span>
|
||||
<h2>Pattern</h2>
|
||||
<p class="sub">The test C1 is most likely to win: a repeating field is where a simple silhouette beats a clever one.</p>
|
||||
<div class="pattern pal p-paper" id="pat"></div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const VARIANTS = [
|
||||
{id:'v-step', name:'C1·a Stepped', desc:'The baseline. Tops aligned, bottoms stepped. Balanced and readable, but the step pattern is arbitrary — nothing decides it, so nothing defends it.', own:'ownable? — no'},
|
||||
{id:'v-stair', name:'C1·b Stair', desc:'Same three ribbons, monotonic descent. Now the rhythm means something (progression, a queue getting shorter) and the eye has a direction to travel.', own:'ownable? — a little'},
|
||||
{id:'v-tally', name:'C1·c Tally', desc:'Four thin ribbons. A tally mark and a bookmark ribbon are already the same shape, and the app is called Marks — the mark states the name instead of illustrating the category.', own:'ownable? — yes'},
|
||||
{id:'v-rail', name:'C1·d Rail', desc:'Hung from a bar. Buys back the shelf/box-file reading that made the reference work three ways, for the cost of one rectangle.', own:'ownable? — yes'},
|
||||
{id:'v-overlap', name:'C1·e Overlap', desc:'Wide ribbons crossing, multiplied. This is the trick the reference actually runs on: depth out of two flat colours and no shadow at all.', own:'ownable? — a little'},
|
||||
{id:'v-front', name:'C1·f Foreground', desc:'One ribbon pulled forward and keylined in the ground colour. Asymmetry is cheap ownership — the symmetry is most of why the baseline feels anonymous.', own:'ownable? — a little'},
|
||||
{id:'v-fan', name:'C1·g Fan', desc:'The same ribbon rotated about a point below the mark. Reads as pages fanned rather than objects filed. The only one with motion, and the only one that animates for free.', own:'ownable? — yes'},
|
||||
{id:'v-pair', name:'C1·h Pair', desc:'Two instead of three. Calmer and it holds smaller, but two things read as a pair, not a collection — which is the wrong idea for an archive.', own:'ownable? — no'},
|
||||
{id:'v-nest', name:'C1·i Nested', desc:'Concentric instead of side by side: a notch inside a notch inside a notch. Says archive rather than row, and it is the only one that suggests depth without overlap.', own:'ownable? — yes'},
|
||||
{id:'v-scallop', name:'C1·j Scallop', desc:'One path, four tails. Everything above is N objects fighting for space; this is a single shape, which is what iOS 26 tinted and monochrome icon modes actually want.', own:'ownable? — yes'},
|
||||
]
|
||||
const PALETTES = [
|
||||
{cls:'p-paper', label:'Navy / Verm.'},
|
||||
{cls:'p-tri', label:'Three swatches'},
|
||||
{cls:'p-ink', label:'Ink / Verm.'},
|
||||
{cls:'p-mono', label:'Ink only'},
|
||||
{cls:'p-dark', label:'Dark'},
|
||||
{cls:'p-orig', label:'Reference'},
|
||||
]
|
||||
const NS = 'http://www.w3.org/2000/svg'
|
||||
const mark = (id, size, cls) => {
|
||||
const svg = document.createElementNS(NS, 'svg')
|
||||
svg.setAttribute('width', size); svg.setAttribute('height', size)
|
||||
svg.setAttribute('viewBox', '0 0 120 120')
|
||||
if (cls) svg.setAttribute('class', cls)
|
||||
const use = document.createElementNS(NS, 'use')
|
||||
use.setAttribute('href', '#' + id)
|
||||
svg.appendChild(use)
|
||||
return svg
|
||||
}
|
||||
const el = (tag, cls, html) => {
|
||||
const n = document.createElement(tag)
|
||||
if (cls) n.className = cls
|
||||
if (html != null) n.innerHTML = html
|
||||
return n
|
||||
}
|
||||
|
||||
// 01 — cards
|
||||
const cards = document.getElementById('cards')
|
||||
for (const v of VARIANTS) {
|
||||
const card = el('div', 'card pal p-paper')
|
||||
const stage = el('div', 'stage'); stage.appendChild(mark(v.id, 132))
|
||||
card.append(stage, el('h3', null, v.name), el('p', null, v.desc), el('div', 'own', v.own))
|
||||
cards.appendChild(card)
|
||||
}
|
||||
|
||||
// 02 — matrix
|
||||
const matrix = document.getElementById('matrix')
|
||||
const head = document.createElement('tr')
|
||||
head.appendChild(el('th', 'rowh'))
|
||||
for (const p of PALETTES) head.appendChild(el('th', null, p.label))
|
||||
matrix.appendChild(head)
|
||||
for (const v of VARIANTS) {
|
||||
const row = document.createElement('tr')
|
||||
row.appendChild(el('th', 'rowh', v.name))
|
||||
for (const p of PALETTES) {
|
||||
const td = document.createElement('td')
|
||||
const cell = el('div', 'cell pal ' + p.cls)
|
||||
cell.appendChild(mark(v.id, 68))
|
||||
td.appendChild(cell); row.appendChild(td)
|
||||
}
|
||||
matrix.appendChild(row)
|
||||
}
|
||||
|
||||
// 03 — size ladder, for the variants that claimed ownership
|
||||
const ladders = document.getElementById('ladders')
|
||||
for (const v of VARIANTS.filter(v => v.own.endsWith('yes'))) {
|
||||
const row = el('div', 'ladder pal p-paper')
|
||||
row.appendChild(el('div', 'name', v.name))
|
||||
for (const s of [128, 64, 40, 24, 16]) {
|
||||
const item = el('div', 'item')
|
||||
item.appendChild(mark(v.id, s))
|
||||
item.appendChild(el('div', 'n', s))
|
||||
row.appendChild(item)
|
||||
}
|
||||
ladders.appendChild(row)
|
||||
}
|
||||
|
||||
// 04 — icons
|
||||
const ICONS = [
|
||||
['v-tally', 'p-paper'], ['v-rail', 'p-tri'], ['v-fan', 'p-paper'],
|
||||
['v-nest', 'p-ink'], ['v-scallop','p-tri'], ['v-scallop', 'p-dark'],
|
||||
]
|
||||
for (const [wrap, size, big] of [['icons', 72, true], ['iconsSm', 36, false]]) {
|
||||
const host = document.getElementById(wrap)
|
||||
for (const [id, cls] of ICONS) {
|
||||
const w = el('div', 'icoWrap')
|
||||
const tile = el('div', 'icon pal ' + cls + (big ? '' : ' sm'))
|
||||
tile.appendChild(mark(id, size))
|
||||
w.append(tile, el('span', null, big ? id.slice(2) + ' · ' + cls.slice(2) : '58pt'))
|
||||
host.appendChild(w)
|
||||
}
|
||||
}
|
||||
|
||||
// 05 — lockups
|
||||
const lockups = document.getElementById('lockups')
|
||||
for (const [id, cls] of [['v-tally','p-paper'], ['v-rail','p-tri'], ['v-scallop','p-ink'], ['v-fan','p-dark']]) {
|
||||
const row = el('div', 'lockup pal ' + cls)
|
||||
row.appendChild(mark(id, 64))
|
||||
const txt = el('div')
|
||||
txt.append(el('div', 'wm', 'Marks'), el('div', 'tl', 'Everything you saved'))
|
||||
row.appendChild(txt)
|
||||
lockups.appendChild(row)
|
||||
}
|
||||
|
||||
// 06 — pattern. Row offset stops the palette cycle lining up into stripes.
|
||||
const cycle = ['p-paper', 'p-tri', 'p-ink', 'p-dark'], COLS = 13
|
||||
const pat = document.getElementById('pat')
|
||||
for (let i = 0; i < COLS * 5; i++) {
|
||||
pat.appendChild(mark('v-tally', 54, 'pal ' + cycle[(i + Math.floor(i / COLS)) % cycle.length]))
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,447 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Marks — logo variants</title>
|
||||
<style>
|
||||
:root{
|
||||
--ink:#14110C; --sheet:#F8F5EF; --rule:rgba(20,17,12,.16);
|
||||
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
--serif:ui-serif,Georgia,"Times New Roman",serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:#EFEBE3;color:var(--ink);font-family:var(--mono);font-size:12px;-webkit-font-smoothing:antialiased}
|
||||
header{padding:56px 40px 28px;border-bottom:1px solid var(--rule)}
|
||||
h1{font-family:var(--serif);font-size:34px;font-weight:400;margin:0 0 8px}
|
||||
header p{margin:0;max-width:60ch;line-height:1.7;opacity:.62}
|
||||
section{padding:40px;border-bottom:1px solid var(--rule)}
|
||||
h2{font-family:var(--serif);font-size:22px;font-weight:400;margin:0 0 4px}
|
||||
.sub{opacity:.55;margin:0 0 28px;line-height:1.7;max-width:72ch}
|
||||
.tag{display:inline-block;font-size:10px;letter-spacing:.14em;text-transform:uppercase;opacity:.45;margin-bottom:10px}
|
||||
|
||||
/* ---- palettes: geometry is shared, colour is injected ---- */
|
||||
.pal{--c1:#22C7F0;--c2:#8B4DEE;--dot:#fff;--bg:#fff}
|
||||
.p-orig {--c1:#22C7F0;--c2:#8B4DEE;--dot:#fff; --bg:#fff}
|
||||
.p-paper{--c1:#001A55;--c2:#ED663F;--dot:#F8F5EF;--bg:#F8F5EF} /* navy + vermilion */
|
||||
.p-ink {--c1:#14110C;--c2:#ED663F;--dot:#F8F5EF;--bg:#F8F5EF} /* ink + vermilion */
|
||||
.p-warm {--c1:#FECD00;--c2:#ED663F;--dot:#14110C;--bg:#F8F5EF} /* yellow + vermilion */
|
||||
.p-blue {--c1:#115AB5;--c2:#033B00;--dot:#F8F5EF;--bg:#F8F5EF} /* blue + forest */
|
||||
.p-dark {--c1:#5B8FD0;--c2:#E07A5C;--dot:#15130F;--bg:#15130F} /* dark-mode pair */
|
||||
.p-mono1{--c1:#14110C;--c2:#14110C;--dot:#F8F5EF;--bg:#F8F5EF} /* single-colour test */
|
||||
|
||||
.grid{display:flex;flex-wrap:wrap;gap:18px}
|
||||
.card{background:var(--bg);border:1px solid var(--rule);border-radius:14px;padding:22px;width:236px}
|
||||
.card .stage{display:flex;align-items:center;justify-content:center;height:150px}
|
||||
.card svg{display:block}
|
||||
.card h3{font-family:var(--serif);font-size:15px;font-weight:400;margin:14px 0 5px}
|
||||
.card p{margin:0;font-size:10.5px;line-height:1.65;opacity:.6}
|
||||
.p-dark h3,.p-dark p{color:#F1ECE1}
|
||||
.p-dark{border-color:rgba(241,236,225,.14)}
|
||||
|
||||
/* matrix */
|
||||
table{border-collapse:collapse}
|
||||
th{font-weight:400;font-size:10px;letter-spacing:.1em;text-transform:uppercase;opacity:.45;padding:0 0 12px;text-align:center}
|
||||
th.rowh{text-align:right;padding-right:16px;text-transform:none;letter-spacing:0;font-size:11px;opacity:.65;white-space:nowrap}
|
||||
td{padding:6px}
|
||||
td .cell{width:104px;height:104px;border-radius:12px;background:var(--bg);border:1px solid var(--rule);display:flex;align-items:center;justify-content:center}
|
||||
|
||||
/* size ladder */
|
||||
.ladder{display:flex;align-items:flex-end;gap:22px;background:var(--bg);border:1px solid var(--rule);border-radius:14px;padding:22px 26px;margin-bottom:14px}
|
||||
.ladder .n{font-size:9.5px;opacity:.4;text-align:center;margin-top:8px}
|
||||
.ladder .item{display:flex;flex-direction:column;align-items:center}
|
||||
.ladder .name{font-family:var(--serif);font-size:14px;width:120px;opacity:.8}
|
||||
|
||||
/* app icons */
|
||||
.icons{display:flex;flex-wrap:wrap;gap:20px;align-items:flex-end}
|
||||
.icon{width:120px;height:120px;border-radius:26.5px;display:flex;align-items:center;justify-content:center;background:var(--bg);box-shadow:0 6px 18px rgba(20,17,12,.14)}
|
||||
.icon.sm{width:60px;height:60px;border-radius:13.5px}
|
||||
.icoWrap{display:flex;flex-direction:column;align-items:center;gap:10px}
|
||||
.icoWrap span{font-size:10px;opacity:.5}
|
||||
|
||||
/* wordmark */
|
||||
.lockup{display:flex;align-items:center;gap:20px;background:var(--bg);border:1px solid var(--rule);border-radius:14px;padding:34px 40px;margin-bottom:14px}
|
||||
.lockup .wm{font-family:var(--serif);font-size:52px;letter-spacing:-.02em;color:var(--c1)}
|
||||
.lockup .wm.ink{color:var(--ink)}
|
||||
.lockup.p-dark .wm.ink{color:#F1ECE1}
|
||||
.lockup.p-dark .tl{color:#F1ECE1;opacity:.5}
|
||||
.lockup .tl{font-size:10px;letter-spacing:.22em;text-transform:uppercase;opacity:.5;margin-top:6px}
|
||||
|
||||
.pattern{display:grid;grid-template-columns:repeat(13,54px);gap:0;background:var(--bg);border-radius:14px;padding:20px;width:742px;overflow:hidden}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>Marks — logo variants</h1>
|
||||
<p>Five geometries derived from the reference (letter + box files + bookmark ribbon), each drawn once and re-coloured through the app's existing <code>Paper</code> palette. Geometry lives in <code><symbol></code>; colour is injected with CSS custom properties, so any cell in the matrix is a real, buildable combination.</p>
|
||||
</header>
|
||||
|
||||
<!-- ============ GEOMETRY DEFINITIONS ============ -->
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<defs>
|
||||
|
||||
<!-- 1. Binder-B: faithful reconstruction. Violet ribbon stem, three cyan
|
||||
binder bars (wide / narrow / wide = the B), notch clears the bottom bar. -->
|
||||
<symbol id="binderB" viewBox="0 0 120 120">
|
||||
<g style="isolation:isolate">
|
||||
<path style="mix-blend-mode:multiply" fill="var(--c2)"
|
||||
d="M31 10 H49 A7 7 0 0 1 56 17 V110 L40 96 L24 110 V17 A7 7 0 0 1 31 10 Z"/>
|
||||
<path style="mix-blend-mode:multiply" fill="var(--c1)" d="M40 10 H79 A13 13 0 0 1 79 36 H40 Z"/>
|
||||
<path style="mix-blend-mode:multiply" fill="var(--c1)" d="M40 40 H69 A13 13 0 0 1 69 66 H40 Z"/>
|
||||
<path style="mix-blend-mode:multiply" fill="var(--c1)" d="M40 70 H83 A13 13 0 0 1 83 96 H40 Z"/>
|
||||
</g>
|
||||
<circle cx="32" cy="23" r="5" fill="var(--dot)"/>
|
||||
<circle cx="32" cy="53" r="5" fill="var(--dot)"/>
|
||||
<circle cx="32" cy="83" r="5" fill="var(--dot)"/>
|
||||
</symbol>
|
||||
|
||||
<!-- 2. Ribbon-B: same skeleton, but every terminal is a swallowtail.
|
||||
Consistent language, loses some of the B. -->
|
||||
<symbol id="ribbonB" viewBox="0 0 120 120">
|
||||
<g style="isolation:isolate">
|
||||
<path style="mix-blend-mode:multiply" fill="var(--c2)"
|
||||
d="M31 10 H49 A7 7 0 0 1 56 17 V110 L40 96 L24 110 V17 A7 7 0 0 1 31 10 Z"/>
|
||||
<path style="mix-blend-mode:multiply" fill="var(--c1)" d="M40 10 H92 L80 23 L92 36 H40 Z"/>
|
||||
<path style="mix-blend-mode:multiply" fill="var(--c1)" d="M40 40 H82 L70 53 L82 66 H40 Z"/>
|
||||
<path style="mix-blend-mode:multiply" fill="var(--c1)" d="M40 70 H96 L84 83 L96 96 H40 Z"/>
|
||||
</g>
|
||||
<circle cx="32" cy="23" r="5" fill="var(--dot)"/>
|
||||
<circle cx="32" cy="53" r="5" fill="var(--dot)"/>
|
||||
<circle cx="32" cy="83" r="5" fill="var(--dot)"/>
|
||||
</symbol>
|
||||
|
||||
<!-- 3. Slab-M, tailed. A real M: the counters are wedges, apex at the cap
|
||||
line, widening to the baseline — that is what makes it a letter and not
|
||||
a slotted slab. Each of the three feet then takes a swallowtail. -->
|
||||
<symbol id="slabMdeep" viewBox="0 0 120 120">
|
||||
<clipPath id="clipMdeep">
|
||||
<path d="M24 16 H96 A10 10 0 0 1 106 26 V104 L97 92 L88 104 L86 20 L69 104 L60 92 L51 104 L34 20 L32 104 L23 92 L14 104 V26 A10 10 0 0 1 24 16 Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#clipMdeep)">
|
||||
<rect x="0" y="0" width="42" height="120" fill="var(--c1)"/>
|
||||
<rect x="42" y="0" width="36" height="120" fill="var(--c2)"/>
|
||||
<rect x="78" y="0" width="42" height="120" fill="var(--c1)"/>
|
||||
</g>
|
||||
<circle cx="23" cy="68" r="5.5" fill="var(--dot)"/>
|
||||
<circle cx="60" cy="68" r="5.5" fill="var(--dot)"/>
|
||||
<circle cx="97" cy="68" r="5.5" fill="var(--dot)"/>
|
||||
</symbol>
|
||||
|
||||
<!-- 4. Slab-M, flat-footed. Same letter, no tails — the bookmark cue is
|
||||
carried by the rounded cap and the colour split alone. Letterform
|
||||
first; the ribbon reading is gone. -->
|
||||
<symbol id="slabMshallow" viewBox="0 0 120 120">
|
||||
<clipPath id="clipMshallow">
|
||||
<path d="M24 16 H96 A10 10 0 0 1 106 26 V104 H88 L86 20 L69 104 H51 L34 20 L32 104 H14 V26 A10 10 0 0 1 24 16 Z"/>
|
||||
</clipPath>
|
||||
<g clip-path="url(#clipMshallow)">
|
||||
<rect x="0" y="0" width="42" height="120" fill="var(--c1)"/>
|
||||
<rect x="42" y="0" width="36" height="120" fill="var(--c2)"/>
|
||||
<rect x="78" y="0" width="42" height="120" fill="var(--c1)"/>
|
||||
</g>
|
||||
<circle cx="23" cy="68" r="5.5" fill="var(--dot)"/>
|
||||
<circle cx="60" cy="68" r="5.5" fill="var(--dot)"/>
|
||||
<circle cx="97" cy="68" r="5.5" fill="var(--dot)"/>
|
||||
</symbol>
|
||||
|
||||
<!-- 5. Ribbon + knockout: single ribbon, letter cut out of it.
|
||||
The one that survives 16px and tinted/monochrome icon modes. -->
|
||||
<symbol id="ribbonM" viewBox="0 0 120 120">
|
||||
<mask id="maskM">
|
||||
<rect width="120" height="120" fill="#000"/>
|
||||
<path fill="#fff" d="M38 12 H82 A12 12 0 0 1 94 24 V110 L60 92 L26 110 V24 A12 12 0 0 1 38 12 Z"/>
|
||||
<text x="60" y="70" text-anchor="middle" fill="#000"
|
||||
font-family="ui-serif,Georgia,serif" font-size="58" font-weight="600">M</text>
|
||||
</mask>
|
||||
<rect width="120" height="120" fill="var(--c1)" mask="url(#maskM)"/>
|
||||
</symbol>
|
||||
|
||||
<!-- 6. Three ribbons: no letterform at all. Stepped heights carry the
|
||||
rhythm; the only thing that has to survive at 16px is the silhouette. -->
|
||||
<symbol id="threeRibbons" viewBox="0 0 120 120">
|
||||
<path fill="var(--c1)" d="M24 14 H36 A6 6 0 0 1 42 20 V98 L30 84 L18 98 V20 A6 6 0 0 1 24 14 Z"/>
|
||||
<path fill="var(--c2)" d="M54 14 H66 A6 6 0 0 1 72 20 V112 L60 98 L48 112 V20 A6 6 0 0 1 54 14 Z"/>
|
||||
<path fill="var(--c1)" d="M84 14 H96 A6 6 0 0 1 102 20 V90 L90 76 L78 90 V20 A6 6 0 0 1 84 14 Z"/>
|
||||
</symbol>
|
||||
|
||||
<!-- 7. Monoline: the Binder-B as strokes only. Closest to the app's
|
||||
archival/letterpress register; no fills to fight the paper ground. -->
|
||||
<symbol id="binderLine" viewBox="0 0 120 120">
|
||||
<g fill="none" stroke="var(--c1)" stroke-width="6" stroke-linejoin="round">
|
||||
<path d="M31 13 H49 A4 4 0 0 1 53 17 V104 L40 93 L27 104 V17 A4 4 0 0 1 31 13 Z"/>
|
||||
<path d="M50 13 H76 A11 11 0 0 1 76 35 H50"/>
|
||||
<path d="M50 42 H67 A11 11 0 0 1 67 64 H50"/>
|
||||
<path d="M50 71 H80 A11 11 0 0 1 80 93 H50"/>
|
||||
</g>
|
||||
<circle cx="34" cy="24" r="3" fill="var(--c2)"/>
|
||||
<circle cx="34" cy="53" r="3" fill="var(--c2)"/>
|
||||
<circle cx="34" cy="82" r="3" fill="var(--c2)"/>
|
||||
</symbol>
|
||||
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<!-- ============ 1. THE GEOMETRIES ============ -->
|
||||
<section>
|
||||
<span class="tag">01</span>
|
||||
<h2>Seven geometries</h2>
|
||||
<p class="sub">Shown in the reference palette (cyan #22C7F0 / violet #8B4DEE) with multiply overlap, so they're directly comparable to the original.</p>
|
||||
<div class="grid">
|
||||
|
||||
<div class="card pal p-orig">
|
||||
<div class="stage"><svg width="130" height="130" viewBox="0 0 120 120"><use href="#binderB"/></svg></div>
|
||||
<h3>A1 · Binder-B</h3>
|
||||
<p>Faithful reconstruction. Ribbon stem carries the notch; three bars go wide / narrow / wide to make the B. Closest to the source.</p>
|
||||
</div>
|
||||
|
||||
<div class="card pal p-orig">
|
||||
<div class="stage"><svg width="130" height="130" viewBox="0 0 120 120"><use href="#ribbonB"/></svg></div>
|
||||
<h3>A2 · Ribbon-B</h3>
|
||||
<p>Every terminal becomes a swallowtail. More internally consistent, but the bowls stop reading as bowls — the B gets weaker.</p>
|
||||
</div>
|
||||
|
||||
<div class="card pal p-orig">
|
||||
<div class="stage"><svg width="130" height="130" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg></div>
|
||||
<h3>B1 · Slab-M, tailed</h3>
|
||||
<p>Retargeted to the app's actual name. A real M — counters are wedges, apex at the cap line — with a swallowtail on each of the three feet.</p>
|
||||
</div>
|
||||
|
||||
<div class="card pal p-orig">
|
||||
<div class="stage"><svg width="130" height="130" viewBox="0 0 120 120"><use href="#slabMshallow"/></svg></div>
|
||||
<h3>B2 · Slab-M, flat</h3>
|
||||
<p>Same letter, tails removed. Sturdier and more type-like, but only the rounded cap is left carrying the bookmark idea — probably too little.</p>
|
||||
</div>
|
||||
|
||||
<div class="card pal p-orig">
|
||||
<div class="stage"><svg width="130" height="130" viewBox="0 0 120 120"><use href="#ribbonM"/></svg></div>
|
||||
<h3>B3 · Ribbon knockout</h3>
|
||||
<p>Single ribbon, letter knocked out. One colour, one shape — the only variant that's trivially correct in iOS 26 tinted and mono icon modes.</p>
|
||||
</div>
|
||||
|
||||
<div class="card pal p-orig">
|
||||
<div class="stage"><svg width="130" height="130" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg></div>
|
||||
<h3>C1 · Three ribbons</h3>
|
||||
<p>Drops the letterform entirely. Stepped heights do the work. Most robust silhouette, least ownable — many apps could use this.</p>
|
||||
</div>
|
||||
|
||||
<div class="card pal p-orig">
|
||||
<div class="stage"><svg width="130" height="130" viewBox="0 0 120 120"><use href="#binderLine"/></svg></div>
|
||||
<h3>C2 · Monoline</h3>
|
||||
<p>Binder-B as strokes. Sits in the same register as the app's serif/paper styling instead of fighting it. Fails below ~28px.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 2. PALETTE MATRIX ============ -->
|
||||
<section>
|
||||
<span class="tag">02</span>
|
||||
<h2>Palette matrix</h2>
|
||||
<p class="sub">Each geometry through the reference palette and five pairs drawn from <code>Paper.swatchPairs</code> in <code>LibraryKit.swift</code>. The <em>Dark</em> column uses the palette's own dark-mode counterparts, not dimmed light values.</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th class="rowh"></th>
|
||||
<th>Reference</th><th>Navy + Verm.</th><th>Ink + Verm.</th>
|
||||
<th>Yellow + Verm.</th><th>Blue + Forest</th><th>Ink only</th><th>Dark</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="rowh">A1 Binder-B</th>
|
||||
<td><div class="cell pal p-orig"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderB"/></svg></div></td>
|
||||
<td><div class="cell pal p-paper"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderB"/></svg></div></td>
|
||||
<td><div class="cell pal p-ink"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderB"/></svg></div></td>
|
||||
<td><div class="cell pal p-warm"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderB"/></svg></div></td>
|
||||
<td><div class="cell pal p-blue"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderB"/></svg></div></td>
|
||||
<td><div class="cell pal p-mono1"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderB"/></svg></div></td>
|
||||
<td><div class="cell pal p-dark"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderB"/></svg></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="rowh">A2 Ribbon-B</th>
|
||||
<td><div class="cell pal p-orig"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonB"/></svg></div></td>
|
||||
<td><div class="cell pal p-paper"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonB"/></svg></div></td>
|
||||
<td><div class="cell pal p-ink"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonB"/></svg></div></td>
|
||||
<td><div class="cell pal p-warm"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonB"/></svg></div></td>
|
||||
<td><div class="cell pal p-blue"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonB"/></svg></div></td>
|
||||
<td><div class="cell pal p-mono1"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonB"/></svg></div></td>
|
||||
<td><div class="cell pal p-dark"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonB"/></svg></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="rowh">B1 Slab-M deep</th>
|
||||
<td><div class="cell pal p-orig"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg></div></td>
|
||||
<td><div class="cell pal p-paper"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg></div></td>
|
||||
<td><div class="cell pal p-ink"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg></div></td>
|
||||
<td><div class="cell pal p-warm"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg></div></td>
|
||||
<td><div class="cell pal p-blue"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg></div></td>
|
||||
<td><div class="cell pal p-mono1"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg></div></td>
|
||||
<td><div class="cell pal p-dark"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="rowh">B2 Slab-M shallow</th>
|
||||
<td><div class="cell pal p-orig"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMshallow"/></svg></div></td>
|
||||
<td><div class="cell pal p-paper"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMshallow"/></svg></div></td>
|
||||
<td><div class="cell pal p-ink"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMshallow"/></svg></div></td>
|
||||
<td><div class="cell pal p-warm"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMshallow"/></svg></div></td>
|
||||
<td><div class="cell pal p-blue"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMshallow"/></svg></div></td>
|
||||
<td><div class="cell pal p-mono1"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMshallow"/></svg></div></td>
|
||||
<td><div class="cell pal p-dark"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMshallow"/></svg></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="rowh">B3 Knockout</th>
|
||||
<td><div class="cell pal p-orig"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonM"/></svg></div></td>
|
||||
<td><div class="cell pal p-paper"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonM"/></svg></div></td>
|
||||
<td><div class="cell pal p-ink"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonM"/></svg></div></td>
|
||||
<td><div class="cell pal p-warm"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonM"/></svg></div></td>
|
||||
<td><div class="cell pal p-blue"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonM"/></svg></div></td>
|
||||
<td><div class="cell pal p-mono1"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonM"/></svg></div></td>
|
||||
<td><div class="cell pal p-dark"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonM"/></svg></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="rowh">C1 Three ribbons</th>
|
||||
<td><div class="cell pal p-orig"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg></div></td>
|
||||
<td><div class="cell pal p-paper"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg></div></td>
|
||||
<td><div class="cell pal p-ink"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg></div></td>
|
||||
<td><div class="cell pal p-warm"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg></div></td>
|
||||
<td><div class="cell pal p-blue"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg></div></td>
|
||||
<td><div class="cell pal p-mono1"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg></div></td>
|
||||
<td><div class="cell pal p-dark"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg></div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="rowh">C2 Monoline</th>
|
||||
<td><div class="cell pal p-orig"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderLine"/></svg></div></td>
|
||||
<td><div class="cell pal p-paper"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderLine"/></svg></div></td>
|
||||
<td><div class="cell pal p-ink"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderLine"/></svg></div></td>
|
||||
<td><div class="cell pal p-warm"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderLine"/></svg></div></td>
|
||||
<td><div class="cell pal p-blue"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderLine"/></svg></div></td>
|
||||
<td><div class="cell pal p-mono1"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderLine"/></svg></div></td>
|
||||
<td><div class="cell pal p-dark"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderLine"/></svg></div></td>
|
||||
</tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- ============ 3. SIZE LADDER ============ -->
|
||||
<section>
|
||||
<span class="tag">03</span>
|
||||
<h2>Size ladder</h2>
|
||||
<p class="sub">128 / 64 / 40 / 24 / 16 px. 40px is the Settings-row and tab-bar size; 16px is the favicon / Spotlight-result size. Anything that turns to mush at 24 is a wordmark, not a mark.</p>
|
||||
|
||||
<div class="ladder pal p-paper">
|
||||
<div class="name">A1 Binder-B</div>
|
||||
<div class="item"><svg width="128" height="128" viewBox="0 0 120 120"><use href="#binderB"/></svg><div class="n">128</div></div>
|
||||
<div class="item"><svg width="64" height="64" viewBox="0 0 120 120"><use href="#binderB"/></svg><div class="n">64</div></div>
|
||||
<div class="item"><svg width="40" height="40" viewBox="0 0 120 120"><use href="#binderB"/></svg><div class="n">40</div></div>
|
||||
<div class="item"><svg width="24" height="24" viewBox="0 0 120 120"><use href="#binderB"/></svg><div class="n">24</div></div>
|
||||
<div class="item"><svg width="16" height="16" viewBox="0 0 120 120"><use href="#binderB"/></svg><div class="n">16</div></div>
|
||||
</div>
|
||||
|
||||
<div class="ladder pal p-paper">
|
||||
<div class="name">B1 Slab-M deep</div>
|
||||
<div class="item"><svg width="128" height="128" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg><div class="n">128</div></div>
|
||||
<div class="item"><svg width="64" height="64" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg><div class="n">64</div></div>
|
||||
<div class="item"><svg width="40" height="40" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg><div class="n">40</div></div>
|
||||
<div class="item"><svg width="24" height="24" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg><div class="n">24</div></div>
|
||||
<div class="item"><svg width="16" height="16" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg><div class="n">16</div></div>
|
||||
</div>
|
||||
|
||||
<div class="ladder pal p-paper">
|
||||
<div class="name">B3 Knockout</div>
|
||||
<div class="item"><svg width="128" height="128" viewBox="0 0 120 120"><use href="#ribbonM"/></svg><div class="n">128</div></div>
|
||||
<div class="item"><svg width="64" height="64" viewBox="0 0 120 120"><use href="#ribbonM"/></svg><div class="n">64</div></div>
|
||||
<div class="item"><svg width="40" height="40" viewBox="0 0 120 120"><use href="#ribbonM"/></svg><div class="n">40</div></div>
|
||||
<div class="item"><svg width="24" height="24" viewBox="0 0 120 120"><use href="#ribbonM"/></svg><div class="n">24</div></div>
|
||||
<div class="item"><svg width="16" height="16" viewBox="0 0 120 120"><use href="#ribbonM"/></svg><div class="n">16</div></div>
|
||||
</div>
|
||||
|
||||
<div class="ladder pal p-paper">
|
||||
<div class="name">C1 Three ribbons</div>
|
||||
<div class="item"><svg width="128" height="128" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg><div class="n">128</div></div>
|
||||
<div class="item"><svg width="64" height="64" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg><div class="n">64</div></div>
|
||||
<div class="item"><svg width="40" height="40" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg><div class="n">40</div></div>
|
||||
<div class="item"><svg width="24" height="24" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg><div class="n">24</div></div>
|
||||
<div class="item"><svg width="16" height="16" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg><div class="n">16</div></div>
|
||||
</div>
|
||||
|
||||
<div class="ladder pal p-paper">
|
||||
<div class="name">C2 Monoline</div>
|
||||
<div class="item"><svg width="128" height="128" viewBox="0 0 120 120"><use href="#binderLine"/></svg><div class="n">128</div></div>
|
||||
<div class="item"><svg width="64" height="64" viewBox="0 0 120 120"><use href="#binderLine"/></svg><div class="n">64</div></div>
|
||||
<div class="item"><svg width="40" height="40" viewBox="0 0 120 120"><use href="#binderLine"/></svg><div class="n">40</div></div>
|
||||
<div class="item"><svg width="24" height="24" viewBox="0 0 120 120"><use href="#binderLine"/></svg><div class="n">24</div></div>
|
||||
<div class="item"><svg width="16" height="16" viewBox="0 0 120 120"><use href="#binderLine"/></svg><div class="n">16</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 4. APP ICON ============ -->
|
||||
<section id="s4">
|
||||
<span class="tag">04</span>
|
||||
<h2>App icon</h2>
|
||||
<p class="sub">iOS squircle at 120pt and 60pt. The mark is set at ~62% of the tile — Apple's grid wants generous margins, and every one of these was drawn edge-to-edge in its own box.</p>
|
||||
<div class="icons">
|
||||
<div class="icoWrap"><div class="icon pal p-paper"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderB"/></svg></div><span>A1 · navy</span></div>
|
||||
<div class="icoWrap"><div class="icon pal p-warm"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg></div><span>B1 · yellow</span></div>
|
||||
<div class="icoWrap"><div class="icon pal p-ink"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#ribbonM"/></svg></div><span>B3 · ink</span></div>
|
||||
<div class="icoWrap"><div class="icon pal p-blue"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg></div><span>C1 · blue</span></div>
|
||||
<div class="icoWrap"><div class="icon pal p-dark"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderB"/></svg></div><span>A1 · dark</span></div>
|
||||
<div class="icoWrap"><div class="icon pal p-orig"><svg width="76" height="76" viewBox="0 0 120 120"><use href="#binderB"/></svg></div><span>A1 · reference</span></div>
|
||||
</div>
|
||||
<div class="icons" style="margin-top:22px">
|
||||
<div class="icoWrap"><div class="icon sm pal p-paper"><svg width="38" height="38" viewBox="0 0 120 120"><use href="#binderB"/></svg></div><span>60pt</span></div>
|
||||
<div class="icoWrap"><div class="icon sm pal p-warm"><svg width="38" height="38" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg></div><span>60pt</span></div>
|
||||
<div class="icoWrap"><div class="icon sm pal p-ink"><svg width="38" height="38" viewBox="0 0 120 120"><use href="#ribbonM"/></svg></div><span>60pt</span></div>
|
||||
<div class="icoWrap"><div class="icon sm pal p-blue"><svg width="38" height="38" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg></div><span>60pt</span></div>
|
||||
<div class="icoWrap"><div class="icon sm pal p-dark"><svg width="38" height="38" viewBox="0 0 120 120"><use href="#binderB"/></svg></div><span>60pt</span></div>
|
||||
<div class="icoWrap"><div class="icon sm pal p-orig"><svg width="38" height="38" viewBox="0 0 120 120"><use href="#binderB"/></svg></div><span>60pt</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 5. LOCKUP ============ -->
|
||||
<section>
|
||||
<span class="tag">05</span>
|
||||
<h2>Lockups</h2>
|
||||
<p class="sub">Wordmark set in the app's own voice — serif for the name, mono letterspaced for the descriptor — rather than the reference's geometric sans, which belongs to a different product.</p>
|
||||
|
||||
<div class="lockup pal p-paper">
|
||||
<svg width="66" height="66" viewBox="0 0 120 120"><use href="#binderB"/></svg>
|
||||
<div><div class="wm ink">Marks</div><div class="tl">Everything you saved</div></div>
|
||||
</div>
|
||||
|
||||
<div class="lockup pal p-ink">
|
||||
<svg width="66" height="66" viewBox="0 0 120 120"><use href="#ribbonM"/></svg>
|
||||
<div><div class="wm ink">Marks</div><div class="tl">Everything you saved</div></div>
|
||||
</div>
|
||||
|
||||
<div class="lockup pal p-warm">
|
||||
<svg width="66" height="66" viewBox="0 0 120 120"><use href="#slabMdeep"/></svg>
|
||||
<div><div class="wm ink">Marks</div><div class="tl">Everything you saved</div></div>
|
||||
</div>
|
||||
|
||||
<div class="lockup pal p-dark">
|
||||
<svg width="66" height="66" viewBox="0 0 120 120"><use href="#threeRibbons"/></svg>
|
||||
<div><div class="wm ink">Marks</div><div class="tl" style="color:#F1ECE1;opacity:.5">Everything you saved</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 6. PATTERN ============ -->
|
||||
<section style="border-bottom:none">
|
||||
<span class="tag">06</span>
|
||||
<h2>Pattern test</h2>
|
||||
<p class="sub">The reference proves itself by tiling. Same test here — if the mark only works alone, it isn't a system.</p>
|
||||
<div class="pattern pal p-paper" id="pat"></div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// 13 columns x 5 rows. The row offset stops the palette cycle from lining up
|
||||
// into vertical stripes, which is what makes it read as a field and not a grid.
|
||||
const cycle = ['p-paper', 'p-warm', 'p-ink', 'p-blue'], COLS = 13
|
||||
const NS = 'http://www.w3.org/2000/svg', pat = document.getElementById('pat')
|
||||
for (let i = 0; i < COLS * 5; i++) {
|
||||
const svg = document.createElementNS(NS, 'svg')
|
||||
svg.setAttribute('width', 54); svg.setAttribute('height', 54)
|
||||
svg.setAttribute('viewBox', '0 0 120 120')
|
||||
svg.setAttribute('class', 'pal ' + cycle[(i + Math.floor(i / COLS)) % cycle.length])
|
||||
const use = document.createElementNS(NS, 'use')
|
||||
use.setAttribute('href', '#threeRibbons')
|
||||
svg.appendChild(use); pat.appendChild(svg)
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
App icon artwork for Marks. Rendered to PNG by generate.sh — do not edit the
|
||||
PNGs in AppIcon.appiconset directly, they are build output.
|
||||
|
||||
The path below is the same geometry as ScallopMark in
|
||||
Marks/Views/Library/MarksMark.swift, in the same canonical 88 x 98 box. If you
|
||||
change one, change the other; there is no shared source between Swift and SVG.
|
||||
|
||||
Three variants, per Apple's iOS 18+ app icon model:
|
||||
light — full-bleed artwork on the paper ground, flattened (icons may not
|
||||
carry an alpha channel).
|
||||
dark — artwork only, on transparency. The system supplies the dark
|
||||
background, so the bands are the palette's dark counterparts,
|
||||
which are lifted rather than dimmed.
|
||||
tinted — greyscale on transparency. The system maps luminance to the user's
|
||||
chosen tint, so the three bands are carried as three greys whose
|
||||
relative luminance matches the colour version. Flattening them to
|
||||
one grey would throw away the band reading in exactly the mode
|
||||
that has nothing else left.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Marks app icon</title>
|
||||
<style>
|
||||
html,body{margin:0;padding:0;background:transparent}
|
||||
#tile{width:1024px;height:1024px;display:flex;align-items:center;justify-content:center}
|
||||
svg{display:block}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="tile"></div>
|
||||
<script>
|
||||
// 88 x 98 canonical box. 4 tails => 3 steps of 88/3.
|
||||
const W = 88, H = 98, R = 10, DEPTH = 16, TAILS = 4
|
||||
const step = W / (TAILS - 1)
|
||||
|
||||
let d = `M${R} 0 H${W - R} A${R} ${R} 0 0 1 ${W} ${R} V${H}`
|
||||
for (let i = 1; i < TAILS; i++) {
|
||||
d += ` L${(W - step * (i - 0.5)).toFixed(3)} ${H - DEPTH}`
|
||||
d += ` L${(W - step * i).toFixed(3)} ${H}`
|
||||
}
|
||||
d += ` V${R} A${R} ${R} 0 0 1 ${R} 0 Z`
|
||||
|
||||
const VARIANTS = {
|
||||
// navy, vermilion, yellow on the paper sheet
|
||||
light: {ground: '#F8F5EF', bands: ['#001A55', '#ED663F', '#FECD00']},
|
||||
// the palette's dark counterparts, lifted so they carry on a dark ground
|
||||
dark: {ground: null, bands: ['#5B8FD0', '#E07A5C', '#D8AD10']},
|
||||
// greys ordered by the colour version's relative luminance
|
||||
tinted: {ground: null, bands: ['#737373', '#B8B8B8', '#F2F2F2']},
|
||||
}
|
||||
|
||||
const v = VARIANTS[new URLSearchParams(location.search).get('v') || 'light']
|
||||
// Mark occupies 62% of the tile's height, which lands it inside Apple's grid
|
||||
// with the margin the squircle mask wants.
|
||||
const markH = 1024 * 0.62
|
||||
|
||||
const svg = [`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">`]
|
||||
if (v.ground) svg.push(`<rect width="1024" height="1024" fill="${v.ground}"/>`)
|
||||
svg.push(`<defs><clipPath id="mark"><path d="${d}"/></clipPath></defs>`)
|
||||
svg.push(`<g transform="translate(${512 - markH * W / H / 2} ${512 - markH / 2}) scale(${markH / H})">`)
|
||||
svg.push(`<g clip-path="url(#mark)">`)
|
||||
v.bands.forEach((color, i) => {
|
||||
svg.push(`<rect x="${(W / 3 * i).toFixed(3)}" y="0" width="${(W / 3).toFixed(3)}" height="${H}" fill="${color}"/>`)
|
||||
})
|
||||
svg.push(`</g></g></svg>`)
|
||||
document.getElementById('tile').innerHTML = svg.join('')
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# Renders the three app-icon variants from appicon.html into the asset catalog.
|
||||
#
|
||||
# scripts/appicon/generate.sh
|
||||
#
|
||||
# Chrome is the rasteriser because it is the only thing on a stock Mac that
|
||||
# renders SVG the same way the browser preview sheets in docs/ do. The PNGs it
|
||||
# writes are build output — edit appicon.html, not them.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUT="$HERE/../../Marks/Assets.xcassets/AppIcon.appiconset"
|
||||
CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
|
||||
[ -x "$CHROME" ] || { echo "Google Chrome not found at $CHROME" >&2; exit 1; }
|
||||
command -v magick >/dev/null || { echo "ImageMagick (magick) not found" >&2; exit 1; }
|
||||
|
||||
render() {
|
||||
local variant=$1 name=$2
|
||||
"$CHROME" --headless=new --disable-gpu --hide-scrollbars \
|
||||
--default-background-color=00000000 \
|
||||
--screenshot="$OUT/$name" --window-size=1024,1024 \
|
||||
--virtual-time-budget=2000 \
|
||||
"file://$HERE/appicon.html?v=$variant" >/dev/null 2>&1
|
||||
echo " $name"
|
||||
}
|
||||
|
||||
echo "rendering app icon ->"
|
||||
render light AppIcon.png
|
||||
render dark AppIcon-Dark.png
|
||||
render tinted AppIcon-Tinted.png
|
||||
|
||||
# The light icon is the one shipped as the home-screen artwork, and iOS rejects
|
||||
# an alpha channel there. The dark and tinted variants are composited by the
|
||||
# system over a background it supplies, so they must keep theirs.
|
||||
magick "$OUT/AppIcon.png" -background '#F8F5EF' -alpha remove -alpha off "$OUT/AppIcon.png"
|
||||
|
||||
echo "done"
|
||||
Reference in New Issue
Block a user