Compare commits

..
12 Commits
Author SHA1 Message Date
admin 1995b38b52 Merge pull request 'Library design: cards + redesigned list on the bookmarks screen, tag picker sheet' (#9) from feat/library-browse-prototype into master
CI / build-and-deploy (push) Successful in 57s
2026-07-30 04:03:02 +00:00
Krishna KumarandClaude Opus 5 69556afc08 Fix Spotlight indexing: route the entity URL to contentURL
CI / build-and-deploy (push) Successful in 26s
CI / build-and-deploy (pull_request) Successful in 25s
Every bookmark failed to reach the Spotlight index. Each item died in
translation with "Provided object for field url is of class NSURL,
expected class: NSString", so on-device search and Ask Your Bookmarks
were retrieving from an index that was effectively empty.

Left to itself, App Intents indexes BookmarkEntity's `url` property under
the attribute set's own `url` key, which Spotlight's Cascade translator
types as NSString. Giving the property an explicit
`indexingKey: \.contentURL` sends it to a URL-typed field instead — and
contentURL is the right field for "where this content lives" regardless.
Keeping the property a URL rather than retyping it to String means
existing Shortcuts that read it are unaffected.

The attribute set now sets contentURL too, and the retrieval side reads
it back, so the round trip stays on one field.

Why it went unnoticed: translation happens after `indexAppEntities`
returns, so indexing logged success the whole time. Measured on the
simulator against the live library — 202 translation failures per launch
before, 0 after.

Adds SpotlightRetrievalTests, which indexes a bookmark and retrieves it
through the assistant's own path. Nothing weaker would have caught this,
since the failure was silent at every layer above the index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-27 19:12:25 -05:00
Krishna KumarandClaude Opus 5 76df237473 Stop paperSurface() undoing the serif navigation titles
CI / build-and-deploy (pull_request) Failing after 2s
CI / build-and-deploy (push) Successful in 32s
Only the bookmarks screen had picked up the serif title. Every other
screen — Search, Sources, Podcasts, Settings, Ask, the sheets — was still
system bold sans, and the reason was paperSurface(): setting
.toolbarBackground makes SwiftUI build a fresh UINavigationBarAppearance
and discard the one PaperAppearance installed, text attributes included.
Bookmarks was the only screen not using the helper, which is why it alone
looked right.

The modifier no longer sets a toolbar background. It doesn't need one —
the bar is transparent by appearance and the screen already paints the
paper ground beneath it.

Also labels the bookmarks toolbar buttons (Settings, Add bookmark, Unread
filter, AI actions), which were bare SF Symbols announcing nothing to
VoiceOver.

Verified in both schemes: large titles on Bookmarks/Sources/Podcasts/
Search and inline titles on the Settings and Ask sheets all render serif,
on paper grounds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-27 14:04:22 -05:00
Krishna KumarandClaude Opus 5 dd1c09a00c Set the navigation and tab bars in the library's type
CI / build-and-deploy (pull_request) Successful in 27s
CI / build-and-deploy (push) Successful in 28s
The screen titles were still system bold sans — `navigationTitle` renders
through UIKit, which no SwiftUI font modifier reaches, so paperSurface()
could tint the bar but never restyle its text. PaperAppearance configures
UINavigationBarAppearance once at launch: serif large and inline titles in
ink, and monospaced tab bar labels.

One trap worth recording: configuring that appearance with
configureWithOpaqueBackground() makes iOS 26 stop laying out the large
title entirely — it vanishes rather than restyling, and it does so even
with the font attributes removed, so it reads like a font problem when it
isn't. Transparent works, and is right here anyway: every screen already
paints the paper ground itself.

Verified in both schemes. Suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-27 13:17:03 -05:00
Krishna KumarandClaude Opus 5 fffb3999bf Carry the library design across the rest of the app
CI / build-and-deploy (pull_request) Successful in 34s
CI / build-and-deploy (push) Successful in 34s
Every screen now speaks the paper vocabulary rather than only the
bookmarks screen: Search, Sources, Podcasts and the player, Settings,
Add/Edit, Smart Collections, Ask, Onboarding, the browser toolbar, the
Siri snippets, and the share extension's save card.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-27 12:50:45 -05:00
Krishna KumarandClaude Opus 5 8a777ffc83 Replace the Tags tab with a tag picker sheet
CI / build-and-deploy (pull_request) Successful in 31s
CI / build-and-deploy (push) Successful in 32s
The tab bar drops from five to four; Tags now opens as a sheet from the +
in the library's filter strip, which is the control that was already
about adding a tag.

Picking a tag pins it as a filter tab rather than pushing to a separate
per-tag list. That made TagBookmarksView redundant — the filter tab shows
the same thing, in whichever layout you're already using — so it's gone.

This also fixes what the + could reach. The old inline menu listed tags
found on the loaded page, so it offered 21 of 96 tags, and once a tag
filter was active it collapsed to just the tags co-occurring with that
one — pinning a second unrelated tag was impossible. The sheet sources
names from the tags endpoint via a new BookmarksViewModel.loadAllTags(),
and counts still come from loaded bookmarks, so a tag we haven't paged in
shows no number rather than a wrong one.

The sheet is searchable, marks already-pinned tags with a check, and
picking a pinned tag selects that tab instead of duplicating it.

Verified by driving the UI: the tab bar is now Bookmarks/Sources/
Podcasts/Search, + opens the sheet with 42 rows where the old menu had
21, and picking "ai" pinned an "ai" tab. Suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-26 11:12:47 -05:00
Krishna KumarandClaude Opus 5 69c2bc7514 Label the tag-pin button for VoiceOver
CI / build-and-deploy (push) Successful in 20s
CI / build-and-deploy (pull_request) Successful in 20s
The + in the filter strip was an unlabeled SF Symbol, so VoiceOver
announced nothing useful for it. Also makes the control queryable, which
is how the menu was verified.

Verified by driving the real UI with a temporary XCUITest target: tapping
+ opens a menu listing the available tags, and tapping a card opens the
browser. The target was removed afterwards rather than committed — both
tests need the live linkding server to have loaded bookmarks first, which
is not something the unit suite should depend on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-26 02:02:20 -05:00
Krishna KumarandClaude Opus 5 0b8b9fe512 Wrap list tags onto two lines instead of scrolling
CI / build-and-deploy (push) Successful in 20s
CI / build-and-deploy (pull_request) Successful in 20s
At the scaled-up type a horizontal tag strip clipped its third chip
mid-word, and a fade only made the clipping prettier. Tags now wrap: you
see whole tags or none.

SwiftUI has no wrapping stack, so this adds a small FlowLayout capped at
maxRows. Subviews past the cap are placed off-screen at zero size rather
than left unplaced — a Layout that declines to place a subview gets it
laid out at the origin instead of dropped, which would have stacked the
leftover tags on top of the first row. Verified by temporarily forcing
maxRows to 1: the overflow disappears cleanly, with no ghost chips.

Worth knowing: the cap does discard tags on real data. The heaviest
bookmarks carry five linkding tags plus AI tags, and two rows hold about
four chips at this size, so the tail is hidden rather than truncated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-26 01:59:19 -05:00
Krishna KumarandClaude Opus 5 cdbec4aaf8 Scale library type up 50%
CI / build-and-deploy (pull_request) Successful in 19s
CI / build-and-deploy (push) Successful in 21s
Every font size in the library design multiplied by 1.5 — card title and
stamp, list title, meta, excerpt and tags, filter tabs, and the
prototype's header.

Geometry had to follow, or the larger type would have broken the layout
it sits in:

- The card grid drops from three columns to two. At 16.5pt in a 95pt
  column a card gets about nine characters per line and every title
  truncates; the text size is what sets the column count.
- Filter tabs grow 30pt -> 42pt tall, the card gains padding and a
  slightly squarer aspect, and the list's color chip, unread dot and tag
  chips scale with the text they sit beside.

Verified in both layouts against the live server. Suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-26 01:54:51 -05:00
Krishna KumarandClaude Opus 5 3c7a783b6c Fix tap-to-open on cards, and move the list to the library design
CI / build-and-deploy (pull_request) Successful in 20s
CI / build-and-deploy (push) Successful in 21s
Two changes.

Tapping a card did nothing. LibraryCard carried its own press-scale
effect via onLongPressGesture(minimumDuration: 0), and a zero-duration
long press fires immediately and swallows the tap the host attaches. It
was invisible in the prototype because nothing was listening for taps
there. LibraryCard is now purely presentational and the grid wraps it in
a Button with the existing RowPressStyle, which gets the same scale
without competing for the gesture.

The list now speaks the same design as the cards: paper ground, the
color chip where the favicon was, serif quoted title, monospaced
domain/date, serif-italic AI summary, monospaced tag chips. Everything
the old row carried is still there — unread state, excerpt, tags,
podcast affordance, reading progress — along with all swipe actions,
since it is still a List.

BookmarkListRow takes a style rather than being rewritten, because the
Tags and Search screens use the same row and should not be silently
restyled by a change aimed at the bookmarks screen.

The tag filter strip moved up to BookmarksView. Both layouts speak the
same language now, so the strip belongs to the screen rather than to one
mode — which also retires the rule that leaving cards mode had to clear
the tag filter to stop it becoming invisible.

The tag chip strip fades at its trailing edge; without it a tag clipped
mid-word reads as broken text rather than as something scrollable.

Verified in both layouts and both color schemes against the live server.
Full suite passes (16 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-26 01:49:15 -05:00
Krishna KumarandClaude Opus 5 99add346fd Wire the library presentation into BookmarksView
CI / build-and-deploy (push) Successful in 19s
CI / build-and-deploy (pull_request) Successful in 20s
Adds a cards/list toggle to the bookmarks screen. List mode is the
existing screen, untouched and still the default; cards mode is the
library presentation running on real bookmarks.

Structure:

- LibraryKit.swift holds what both the shipping screen and the prototype
  draw from — the paper palette, the Bookmark -> LibraryItem projection,
  the color card, and the tab strip. The prototype in Views/Prototypes
  keeps only its standalone chrome and sample data, so the two can no
  longer drift.
- BookmarkActions.swift extracts the context menu and the podcast launch
  path out of BookmarkListRow. Cards and rows now offer exactly the same
  actions because they are the same code. The menu is a @ViewBuilder
  rather than a ViewModifier: each host already owns the sheets it
  presents, and a modifier would have forced a second copy of that state.
- LibraryGridView.swift is the Bookmark-driven grid, with the same tap-to
  -open, long-press-for-menu, pagination and podcast sheets as the list.

Two behaviors worth calling out:

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; verified against the
server that `q=#dev-tools` returns 64 tagged results where `q=dev-tools`
returns 0.

Leaving cards mode clears any tag filter. The classic list has no filter
strip to display one, so a filter that survived the switch would be
invisible and the list would look like it had lost bookmarks.

The layout choice persists in @AppStorage. The toolbar button shows the
layout it switches *to* — a two-state toggle labelled with its current
state reads as a status light rather than a control.

Verified on an iPhone 17 Pro simulator against the live linkding server in
both layouts and both color schemes. Full test suite passes (16 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-26 01:41:23 -05:00
Krishna KumarandClaude Opus 5 6a9b6f193f Add Library browse-screen prototype
CI / build-and-deploy (push) Successful in 52s
CI / build-and-deploy (pull_request) Successful in 22s
An unreferenced SwiftUI prototype of a paper/serif/monospace browse screen
for bookmarks: color blocks instead of thumbnails, a browser-tab strip of
saved tag filters, and cards/list modes that morph between each other via
matchedGeometryEffect. Nothing in the app links to it yet.

Built against the real linkding library (300 bookmarks), which is what
shaped it:

- No bookmark has a preview_image_url, so there is nothing to put in a
  thumbnail grid. Color blocks are the right primitive for this data.
- date_added is 2025 or 2026 for every bookmark, so the reference design's
  year stamp carries no signal. The domain takes that slot instead,
  reduced to its registered form because 92 of 300 hosts overflow the
  card's one monospaced line.
- Real titles run 3x longer than the design assumes (median 66 chars,
  p90 159, max 332) and are mostly "name: what it does". Cards show the
  name, the list carries the whole title.
- 15 bookmarks have no scraped title at all and fall back to the raw URL;
  those render unquoted with the path in the stamp slot.
- Color is hashed from the primary tag, not the domain: 128 of 300 are
  github.com, which would paint half the library one color.

Both color schemes are first-class. The dark palette is not the light one
dimmed uniformly - pale swatches drop a long way so they do not glare and
dark swatches come up so they do not vanish, converging on a mid band that
keeps all twelve distinguishable in either scheme.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-26 01:30:30 -05:00
29 changed files with 1894 additions and 519 deletions
+42 -4
View File
@@ -14,13 +14,18 @@
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; };
1F1CB72BBCFFB33B6533D5C9 /* LibraryListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E093C878E702891C64D21FD5 /* LibraryListRow.swift */; };
212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; };
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */; };
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; };
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7510EB352E624C7C9656EA33 /* LibraryView.swift */; };
55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */; };
59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */; };
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
@@ -46,8 +51,8 @@
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB1DA808EE8041C4546DAAB /* SourcesView.swift */; };
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; };
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
A30907113FD5D682478750A7 /* LibraryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */; };
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */; };
@@ -58,6 +63,7 @@
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; };
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; };
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
BC866F8D6189334650ADCB95 /* BookmarkActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */; };
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; };
@@ -126,9 +132,12 @@
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<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>"; };
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 +150,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 +163,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 +182,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 +246,21 @@
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 */,
);
path = Library;
sourceTree = "<group>";
};
58E8E316BE3F10C5149AADC3 /* Intents */ = {
isa = PBXGroup;
children = (
@@ -286,6 +308,14 @@
path = Services;
sourceTree = "<group>";
};
7F380B7CE96F87441C28DB93 /* Prototypes */ = {
isa = PBXGroup;
children = (
7510EB352E624C7C9656EA33 /* LibraryView.swift */,
);
path = Prototypes;
sourceTree = "<group>";
};
85E717A515682EBF67DD199A /* MarksWidget */ = {
isa = PBXGroup;
children = (
@@ -305,8 +335,8 @@
children = (
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
217E6702DE1210AC38ED16D1 /* AskView.swift */,
240DBB87940F8D255A812EB2 /* BookmarkActions.swift */,
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
629C41E0BC28EB6359D50CFD /* BrowserView.swift */,
@@ -318,6 +348,8 @@
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
CDB1DA808EE8041C4546DAAB /* SourcesView.swift */,
B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
585E3F011CBA8ECA6D1925C0 /* Library */,
7F380B7CE96F87441C28DB93 /* Prototypes */,
);
path = Views;
sourceTree = "<group>";
@@ -499,6 +531,7 @@
buildActionMask = 2147483647;
files = (
FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */,
59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -508,6 +541,7 @@
files = (
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */,
70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */,
A30907113FD5D682478750A7 /* LibraryKit.swift in Sources */,
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
@@ -540,11 +574,11 @@
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */,
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
BC866F8D6189334650ADCB95 /* BookmarkActions.swift in Sources */,
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */,
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */,
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */,
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
@@ -557,6 +591,10 @@
81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */,
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */,
4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */,
1F1CB72BBCFFB33B6533D5C9 /* LibraryListRow.swift in Sources */,
50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */,
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
212F713DCC289C48087B79AE /* Log.swift in Sources */,
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
+12 -2
View File
@@ -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
}
}
+13 -11
View File
@@ -8,18 +8,19 @@ struct BookmarkSnippetView: View {
HStack(spacing: 12) {
Image(systemName: "bookmark.fill")
.font(.title2)
.foregroundStyle(.tint)
.foregroundStyle(Paper.accent)
VStack(alignment: .leading, spacing: 3) {
Text(entity.title)
.font(.headline)
.font(PaperType.heading)
.foregroundStyle(Paper.ink)
.lineLimit(2)
Text(entity.host)
.font(.subheadline)
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
if !entity.tags.isEmpty {
Text(entity.tags.map { "#\($0)" }.joined(separator: " "))
.font(.caption)
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
.lineLimit(1)
}
}
@@ -38,14 +39,15 @@ struct SummarySnippetView: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "sparkles").foregroundStyle(.tint)
Text(title).font(.headline).lineLimit(2)
Image(systemName: "sparkles").foregroundStyle(Paper.accent)
Text(title).font(PaperType.heading).foregroundStyle(Paper.ink).lineLimit(2)
}
Text(host)
.font(.caption)
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
Text(summary)
.font(.body)
.font(PaperType.body)
.foregroundStyle(Paper.ink)
.fixedSize(horizontal: false, vertical: true)
}
.padding()
+1 -1
View File
@@ -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
+6 -3
View File
@@ -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)
}
+3 -3
View File
@@ -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 }
}
+22 -11
View File
@@ -18,6 +18,7 @@ struct AddBookmarkView: View {
Form {
Section {
TextEditor(text: $importText)
.paperField()
.frame(minHeight: 96)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
@@ -27,6 +28,8 @@ struct AddBookmarkView: View {
extractImportText()
} label: {
Label("Extract Link", systemImage: "link.badge.plus")
.font(PaperType.label)
.foregroundStyle(Paper.accent)
}
.disabled(importText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
@@ -34,56 +37,62 @@ struct AddBookmarkView: View {
if let importMessage {
Text(importMessage)
.font(.caption)
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
}
} header: {
Text("Import Text")
Text("Import Text").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} footer: {
Text("Paste an email, newsletter, or message and Marks will pull out the first web link.")
Text("Paste an email, newsletter, or message and Marks will pull out the first web link.").font(PaperType.micro).foregroundStyle(Paper.tertiary)
}
Section {
TextField("https://", text: $url)
.paperField()
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("URL")
Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section {
TextField("Optional", text: $title)
.paperField()
} header: {
Text("Title")
Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section {
TextField("Optional", text: $description, axis: .vertical)
.paperField()
.lineLimit(2...5)
} header: {
Text("Notes")
Text("Notes").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section {
TextField("comma separated", text: $tagsText)
.paperField()
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Tags")
Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} footer: {
Text("Separate tags with commas")
Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary)
}
if let error {
Section {
Text(error)
.foregroundStyle(.red)
.font(.footnote)
.font(PaperType.meta)
.foregroundStyle(Paper.alarm)
}
}
}
.listRowBackground(Paper.raised)
.paperSurface()
.navigationTitle("Add Bookmark")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -92,6 +101,8 @@ struct AddBookmarkView: View {
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.font(PaperType.label)
.tint(Paper.accent)
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
.overlay {
if isSaving { ProgressView().scaleEffect(0.7) }
+31 -17
View File
@@ -18,20 +18,23 @@ struct AskView: View {
case .checking:
ProgressView()
case .unavailable(let message):
ContentUnavailableView(
"Unavailable",
PaperEmptyState(
title: "Unavailable",
systemImage: "sparkles.slash",
description: Text(message)
message: message
)
case .ready:
ready
}
}
.paperSurface()
.navigationTitle("Ask Your Bookmarks")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.font(PaperType.label)
.tint(Paper.accent)
}
}
}
@@ -42,17 +45,19 @@ struct AskView: View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if answer.isEmpty && !isLoading && errorText == nil {
ContentUnavailableView {
Label("Ask anything", systemImage: "sparkles")
} description: {
Text("Answers come from your saved bookmarks, generated on-device.")
}
PaperEmptyState(
title: "Ask anything",
systemImage: "sparkles",
message: "Answers come from your saved bookmarks, generated on-device."
)
.padding(.top, 40)
}
if isLoading {
HStack(spacing: 8) {
ProgressView()
Text("Searching your bookmarks…").foregroundStyle(.secondary)
Text("Searching your bookmarks…")
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
}
}
if !answer.isEmpty {
@@ -61,7 +66,9 @@ struct AskView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
if let errorText {
Text(errorText).foregroundStyle(.red)
Text(errorText)
.font(PaperType.meta)
.foregroundStyle(Paper.alarm)
}
}
.padding()
@@ -69,17 +76,24 @@ struct AskView: View {
HStack(spacing: 10) {
TextField("Ask about your bookmarks…", text: $question, axis: .vertical)
.font(PaperType.body)
.foregroundStyle(Paper.ink)
.lineLimit(1...4)
.focused($focused)
.submitLabel(.send)
.onSubmit(send)
Button(action: send) {
Image(systemName: "arrow.up.circle.fill").font(.title2)
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 26))
.foregroundStyle(Paper.accent)
}
.disabled(question.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading)
}
.padding()
.background(.bar)
.background(Paper.raised)
.overlay(alignment: .top) {
Rectangle().fill(Paper.rule.opacity(0.5)).frame(height: 0.6)
}
}
.onAppear { focused = true }
}
@@ -112,18 +126,18 @@ private struct MarkdownAnswer: View {
private enum Block: Hashable { case heading(String), bullet(String), paragraph(String) }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
switch block {
case .heading(let line):
inline(line).font(.headline)
inline(line).font(PaperType.heading)
case .bullet(let line):
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("").foregroundStyle(.secondary)
inline(line)
Text("").foregroundStyle(Paper.tertiary)
inline(line).font(PaperType.body)
}
case .paragraph(let line):
inline(line)
inline(line).font(PaperType.body)
}
}
}
+125
View File
@@ -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
))
}
}
+24 -55
View File
@@ -1,7 +1,7 @@
import SwiftUI
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView.
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet.
/// Full-featured list row used by BookmarksView and SearchView. Owns podcast
/// and episode-picker sheet state; parent owns the BrowserView sheet.
struct BookmarkListRow: View {
let bookmark: Bookmark
let viewModel: BookmarksViewModel
@@ -14,15 +14,17 @@ struct BookmarkListRow: View {
@State private var episodePickerBookmark: Bookmark?
var body: some View {
BookmarkRow(
LibraryListRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
.contentShape(Rectangle())
.onTapGesture { onOpen() }
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowInsets(EdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18))
.listRowSeparator(.visible)
.listRowSeparatorTint(Paper.rule.opacity(0.5))
.listRowBackground(Paper.sheet)
// Delete is destructive and not undoable require an explicit tap on the
// revealed button rather than letting a single full swipe delete instantly.
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
@@ -47,61 +49,28 @@ struct BookmarkListRow: View {
}
}
.contextMenu {
if let onEdit {
Button { onEdit() } label: {
Label("Edit", systemImage: "pencil")
}
}
Button { onOpen() } label: {
Label("Open", systemImage: "globe")
}
Button {
if let url = URL(string: bookmark.url) { openURL(url) }
} label: {
Label("Open in Safari", systemImage: "safari")
}
Button { handlePodcast() } label: {
Label("Convert to Podcast", systemImage: "headphones")
}
Divider()
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: viewModel.podcastPlayer,
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
claude: viewModel.claude,
stopOnDismiss: false
bookmarkMenuItems(
bookmark: bookmark,
viewModel: viewModel,
openURL: openURL,
onOpen: onOpen,
onEdit: onEdit,
onPodcast: handlePodcast
)
}
.sheet(item: $episodePickerBookmark) { b in
EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
}
.podcastSheets(
viewModel: viewModel,
showFullPlayer: $showFullPlayer,
episodePicker: $episodePickerBookmark
)
}
private func handlePodcast() {
let episodes = PodcastIndex.find(for: bookmark.url)
if episodes.count >= 2 {
episodePickerBookmark = bookmark
} else if let ep = episodes.first {
viewModel.podcastPlayer.start(
articleUrl: ep.articleUrl,
articleTitle: ep.title ?? bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true
} else if viewModel.playOrGeneratePodcast(articleUrl: bookmark.url, title: bookmark.displayTitle) {
showFullPlayer = true
}
launchPodcast(
for: bookmark,
viewModel: viewModel,
showFullPlayer: $showFullPlayer,
episodePicker: $episodePickerBookmark
)
}
}
-170
View File
@@ -1,170 +0,0 @@
import SwiftUI
struct BookmarkRow: View {
let bookmark: Bookmark
var readingProgress: Double = 0
var onPodcast: (() -> Void)? = nil
@State private var podcastTapCount = 0
@State private var podcastCached = false
/// Compact, static relative date ("6 min ago"). Using a formatter instead of
/// `Text(_, style: .relative)` avoids the live per-second ticking timer.
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
f.dateTimeStyle = .named
return f
}()
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 11) {
FaviconView(url: bookmark.faviconUrl)
.overlay(alignment: .topLeading) {
if bookmark.unread {
Circle()
.fill(.blue)
.frame(width: 8, height: 8)
.offset(x: -3, y: -3)
}
}
.padding(.top, 1)
VStack(alignment: .leading, spacing: 4) {
Text(bookmark.displayTitle)
.font(.headline)
.foregroundStyle(.primary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 4) {
Text(bookmark.domain)
Text("·")
Text(Self.relativeFormatter.localizedString(for: bookmark.dateAdded, relativeTo: Date()))
}
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
if let onPodcast {
Button {
podcastTapCount += 1
onPodcast()
} label: {
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
.font(.title3)
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
}
.buttonStyle(.plain)
.padding(.top, 1)
.sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount)
}
}
if let excerpt = rowExcerpt {
Text(excerpt.text)
.font(.subheadline)
.italic(excerpt.isAI)
.foregroundStyle(.secondary)
.lineLimit(2)
.padding(.leading, 44)
}
if !effectiveTags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
GlassEffectContainer(spacing: 6) {
HStack(spacing: 6) {
ForEach(effectiveTags, id: \.self) { tag in
Text(tag)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.glassEffect(in: Capsule())
}
}
}
}
.padding(.leading, 44)
}
}
.padding(.vertical, 14)
.contentShape(Rectangle())
.overlay(alignment: .bottom) {
if readingProgress > 0.02 {
GeometryReader { geo in
ZStack(alignment: .leading) {
Rectangle()
.fill(Color(.systemGray5))
Rectangle()
.fill(Color(.systemGreen))
.frame(width: geo.size.width * min(readingProgress, 1))
}
}
.frame(height: 2)
}
}
.task(id: bookmark.url) {
// Stat the podcast cache off the render path: once per appearance
// (and when the URL changes), not on every `body` recomputation.
let path = ClaudeService.cachedPodcastURL(for: bookmark.url).path
podcastCached = await Task.detached { FileManager.default.fileExists(atPath: path) }.value
}
}
/// Excerpt shown under the title: prefer the AI summary (italic), else the
/// page's scraped description / user note. nil hides the line entirely.
private var rowExcerpt: (text: String, isAI: Bool)? {
if let s = bookmark.aiSummary?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty {
return (s, true)
}
if let e = bookmark.contentExcerpt {
return (e, false)
}
return nil
}
private var effectiveTags: [String] {
let base = bookmark.tagNames
let ai = bookmark.aiTags ?? []
let extra = ai.filter { !base.contains($0) }.prefix(3)
return (base + extra).prefix(6).map { $0 }
}
}
struct FaviconView: View {
let url: String?
var body: some View {
Group {
if let urlString = url, let faviconUrl = URL(string: urlString) {
AsyncImage(url: faviconUrl) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFit()
default:
placeholder
}
}
} else {
placeholder
}
}
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 7))
}
private var placeholder: some View {
RoundedRectangle(cornerRadius: 6)
.fill(Color(.systemGray5))
.overlay {
Image(systemName: "bookmark")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(.systemGray2))
}
}
}
+134 -46
View File
@@ -8,17 +8,17 @@ private struct SkeletonRow: View {
var body: some View {
HStack(alignment: .top, spacing: 11) {
RoundedRectangle(cornerRadius: 7)
.fill(Color(.systemGray5))
.frame(width: 32, height: 32)
RoundedRectangle(cornerRadius: 3)
.fill(Paper.ink.opacity(0.09))
.frame(width: 34, height: 46)
VStack(alignment: .leading, spacing: 7) {
Capsule()
.fill(Color(.systemGray5))
.fill(Paper.ink.opacity(0.09))
.frame(maxWidth: .infinity)
.frame(height: 13)
.frame(height: 15)
Capsule()
.fill(Color(.systemGray6))
.frame(width: 140, height: 10)
.fill(Paper.ink.opacity(0.06))
.frame(width: 140, height: 12)
}
.padding(.top, 4)
Spacer()
@@ -60,36 +60,44 @@ struct BookmarksView: View {
@State private var showFullPlayer = false
@State private var showAsk = false
@State private var readingProgress: [String: Double] = ReadingProgress.all()
@AppStorage("bookmarksLayout") private var layoutRaw = LibraryLayout.list.rawValue
@State private var libraryFilters: [LibraryFilter] = [
LibraryFilter(name: "Everything", tag: nil)
]
@State private var librarySelection: LibraryFilter.ID?
@State private var showTags = false
private var layout: LibraryLayout { LibraryLayout(rawValue: layoutRaw) ?? .list }
private var otherLayout: LibraryLayout { layout == .cards ? .list : .cards }
var body: some View {
NavigationStack {
List {
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
ForEach(0..<3, id: \.self) { i in
SkeletonRow(delay: Double(i) * 0.13)
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowSeparator(.visible)
}
} else {
ForEach(viewModel.bookmarks) { bookmark in
BookmarkListRow(
bookmark: bookmark,
viewModel: viewModel,
readingProgress: readingProgress[bookmark.url] ?? 0,
onOpen: { browsingBookmark = bookmark },
onEdit: { editingBookmark = bookmark }
)
.onAppear { maybeLoadMore(bookmark) }
}
}
VStack(spacing: 0) {
// Both layouts speak the same design now, so the filter strip
// belongs to the screen rather than to one mode which also
// means a tag filter can no longer go invisible when you switch.
LibraryTagStrip(
filters: $libraryFilters,
selection: $librarySelection,
onChange: applyTagFilter,
onBrowseTags: { showTags = true }
)
if viewModel.isLoadingMore {
HStack { Spacer(); ProgressView(); Spacer() }
.listRowSeparator(.hidden)
if layout == .cards {
LibraryGridView(
viewModel: viewModel,
onOpen: { browsingBookmark = $0 },
onEdit: { editingBookmark = $0 }
)
} else {
bookmarkList
}
}
.listStyle(.plain)
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
// The large-title area draws from the content behind it, so the
// paper ground has to reach past the safe area or the library
// appears to start halfway down a white screen.
.background(Paper.sheet.ignoresSafeArea())
.task { librarySelection = librarySelection ?? libraryFilters.first?.id }
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
.navigationBarTitleDisplayMode(.large)
.toolbar {
@@ -114,12 +122,24 @@ struct BookmarksView: View {
} label: {
Image(systemName: "sparkles")
}
.accessibilityLabel("AI actions")
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
// Shows where the tap goes, not where you are a
// two-state toggle labelled with its current state
// reads as a status light rather than a control.
Button { toggleLayout() } label: {
Image(systemName: otherLayout.symbol)
.contentTransition(.symbolEffect(.replace))
}
.accessibilityLabel(
otherLayout == .cards ? "Show as cards" : "Show as list"
)
Button { showAddBookmark = true } label: {
Image(systemName: "plus")
}
.accessibilityLabel("Add bookmark")
Button {
Task { await viewModel.toggleUnreadFilter() }
} label: {
@@ -128,18 +148,20 @@ struct BookmarksView: View {
: "line.3.horizontal.decrease.circle")
.contentTransition(.symbolEffect(.replace))
}
.accessibilityLabel("Unread filter")
Button { showSettings = true } label: {
Image(systemName: "gearshape")
}
.accessibilityLabel("Settings")
}
}
}
.overlay {
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
ContentUnavailableView(
"No Bookmarks",
PaperEmptyState(
title: "No Bookmarks",
systemImage: "bookmark",
description: Text("Bookmarks you save will appear here.")
message: "Bookmarks you save will appear here."
)
.transition(.opacity)
}
@@ -175,6 +197,13 @@ struct BookmarksView: View {
.sheet(isPresented: $showAsk) {
AskView()
}
.sheet(isPresented: $showTags) {
TagsView(
viewModel: viewModel,
pinned: Set(libraryFilters.compactMap(\.tag)),
onPick: pinTag
)
}
.sheet(isPresented: $showAddBookmark) {
AddBookmarkView(viewModel: viewModel)
}
@@ -223,15 +252,14 @@ struct BookmarksView: View {
HStack(spacing: 10) {
ProgressView(value: viewModel.enrichmentProgress)
.progressViewStyle(.linear)
.tint(.primary)
.tint(Paper.ink)
Text("Adding AI summaries…")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.paperCard(cornerRadius: 12)
.padding(.horizontal, 16)
}
@@ -240,15 +268,16 @@ struct BookmarksView: View {
return HStack(spacing: 10) {
Image(systemName: "waveform")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.blue)
.foregroundStyle(Paper.accent)
.symbolEffect(.variableColor.iterative, isActive: true)
VStack(alignment: .leading, spacing: 2) {
Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…")
.font(.system(size: 13, weight: .medium))
.font(PaperType.stamp)
.foregroundStyle(Paper.ink)
if let first = jobs.first {
Text(first.title.isEmpty ? first.label : first.title)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
.lineLimit(1)
}
}
@@ -256,7 +285,7 @@ struct BookmarksView: View {
if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.blue)
.tint(Paper.accent)
.frame(width: 44)
} else {
ProgressView()
@@ -264,11 +293,70 @@ struct BookmarksView: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.paperCard(cornerRadius: 12)
.padding(.horizontal, 16)
}
private var bookmarkList: some View {
List {
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
ForEach(0..<3, id: \.self) { i in
SkeletonRow(delay: Double(i) * 0.13)
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowSeparator(.visible)
}
} else {
ForEach(viewModel.bookmarks) { bookmark in
BookmarkListRow(
bookmark: bookmark,
viewModel: viewModel,
readingProgress: readingProgress[bookmark.url] ?? 0,
onOpen: { browsingBookmark = bookmark },
onEdit: { editingBookmark = bookmark }
)
.onAppear { maybeLoadMore(bookmark) }
}
}
if viewModel.isLoadingMore {
HStack { Spacer(); ProgressView(); Spacer() }
.listRowSeparator(.hidden)
.listRowBackground(Paper.sheet)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
}
/// Pin a tag as a filter tab and switch to it. Choosing one that's already
/// pinned selects that tab instead of adding a duplicate.
private func pinTag(_ tag: String) {
if let existing = libraryFilters.first(where: { $0.tag == tag }) {
librarySelection = existing.id
} else {
let new = LibraryFilter(name: tag, tag: tag)
withAnimation(.spring(duration: 0.35, bounce: 0.1)) {
libraryFilters.append(new)
librarySelection = new.id
}
}
applyTagFilter()
}
/// Tag tabs filter server-side through linkding's `#tag` search syntax
/// filtering the loaded page client-side would only ever search the most
/// recent 50 of 600+ bookmarks and quietly look empty.
private func applyTagFilter() {
let tag = libraryFilters.first { $0.id == librarySelection }?.tag
viewModel.searchQuery = tag.map { "#\($0)" } ?? ""
Task { await viewModel.search() }
}
private func toggleLayout() {
withAnimation(.spring(duration: 0.35, bounce: 0.05)) { layoutRaw = otherLayout.rawValue }
}
private func maybeLoadMore(_ bookmark: Bookmark) {
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
+11
View File
@@ -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
+4 -4
View File
@@ -253,7 +253,7 @@ struct BrowserView: View {
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
GeometryReader { geo in
Rectangle()
.fill(Color.blue.opacity(0.55))
.fill(Paper.accent.opacity(0.55))
.frame(width: geo.size.width * state.readingProgress, height: 3)
}
.frame(height: 3)
@@ -409,11 +409,11 @@ struct BrowserView: View {
.contentShape(Rectangle())
}
}
.font(.system(size: 17))
.foregroundStyle(.primary)
.font(PaperType.label)
.foregroundStyle(Paper.ink)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(.bar)
.background(Paper.raised)
.overlay(alignment: .top) { Divider() }
}
}
+16 -11
View File
@@ -11,41 +11,44 @@ struct CollectionsView: View {
VStack(spacing: 16) {
ProgressView()
Text("Building smart collections…")
.font(.system(size: 15))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.smartCollections.isEmpty {
ContentUnavailableView(
"No Collections Yet",
PaperEmptyState(
title: "No Collections Yet",
systemImage: "sparkles",
description: Text("Tap \"Smart Collections\" to group your bookmarks by topic.")
message: "Tap \"Smart Collections\" to group your bookmarks by topic."
)
} else {
List(viewModel.smartCollections) { collection in
Section {
let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) }
ForEach(items) { bookmark in
BookmarkRow(bookmark: bookmark)
LibraryListRow(bookmark: bookmark)
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
.listRowBackground(Paper.sheet)
.listRowSeparatorTint(Paper.rule.opacity(0.5))
}
} header: {
VStack(alignment: .leading, spacing: 2) {
Text(collection.name)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
.font(PaperType.heading)
.foregroundStyle(Paper.ink)
if !collection.description.isEmpty {
Text(collection.description)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
}
}
.padding(.vertical, 4)
}
}
.listStyle(.insetGrouped)
.listStyle(.plain)
}
}
.paperSurface()
.navigationTitle("Smart Collections")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -59,6 +62,8 @@ struct CollectionsView: View {
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.font(PaperType.label)
.tint(Paper.accent)
}
}
}
+24 -7
View File
@@ -27,44 +27,59 @@ struct EditBookmarkView: View {
var body: some View {
NavigationStack {
Form {
Section("URL") {
Section {
TextField("https://", text: $url)
.paperField()
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("URL").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section("Title") {
Section {
TextField("Optional", text: $title)
.paperField()
} header: {
Text("Title").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section("Description") {
Section {
TextField("Optional", text: $description, axis: .vertical)
.paperField()
.lineLimit(3...6)
} header: {
Text("Description").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section {
TextField("comma separated", text: $tagsText)
.paperField()
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Tags")
Text("Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} footer: {
Text("Separate tags with commas")
Text("Separate tags with commas").font(PaperType.micro).foregroundStyle(Paper.tertiary)
}
Section {
Toggle("Mark as unread", isOn: $unread)
.font(PaperType.label)
.foregroundStyle(Paper.ink)
.tint(Paper.accent)
}
if let error {
Section {
Text(error)
.foregroundStyle(.red)
.font(.footnote)
.font(PaperType.meta)
.foregroundStyle(Paper.alarm)
}
}
}
.listRowBackground(Paper.raised)
.paperSurface()
.navigationTitle("Edit Bookmark")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -73,6 +88,8 @@ struct EditBookmarkView: View {
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.font(PaperType.label)
.tint(Paper.accent)
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
.overlay {
if isSaving { ProgressView().scaleEffect(0.7) }
+85
View File
@@ -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() }
}
}
+644
View File
@@ -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 20252026, 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 ("/sharefo/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
}
}
+153
View File
@@ -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 }
}
}
+17 -14
View File
@@ -17,10 +17,11 @@ struct OnboardingView: View {
VStack(alignment: .leading, spacing: 8) {
Text("Marks")
.font(.system(size: 42, weight: .semibold, design: .rounded))
.font(.system(size: 42, design: .serif))
.foregroundStyle(Paper.ink)
Text("Your bookmarks, beautifully.")
.font(.system(size: 17))
.foregroundStyle(.secondary)
.font(PaperType.body)
.foregroundStyle(Paper.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 28)
@@ -29,8 +30,8 @@ struct OnboardingView: View {
VStack(spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Server URL")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.font(PaperType.stamp)
.foregroundStyle(Paper.tertiary)
.padding(.horizontal, 2)
TextField("https://links.example.com", text: $urlText)
.textContentType(.URL)
@@ -41,14 +42,15 @@ struct OnboardingView: View {
.submitLabel(.next)
.onSubmit { focused = .token }
.padding(14)
.background(Color(.systemGray6))
.paperField()
.background(Paper.raised)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
VStack(alignment: .leading, spacing: 6) {
Text("API Token")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.font(PaperType.stamp)
.foregroundStyle(Paper.tertiary)
.padding(.horizontal, 2)
SecureField("Paste your token", text: $token)
.textContentType(.password)
@@ -58,11 +60,12 @@ struct OnboardingView: View {
.submitLabel(.done)
.onSubmit { Task { await connect() } }
.padding(14)
.background(Color(.systemGray6))
.paperField()
.background(Paper.raised)
.clipShape(RoundedRectangle(cornerRadius: 12))
Text("Find your token at Settings → API Token in Linkding.")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
.padding(.horizontal, 2)
}
}
@@ -70,8 +73,8 @@ struct OnboardingView: View {
if let err = errorMessage {
Text(err)
.font(.system(size: 14))
.foregroundStyle(.red)
.font(PaperType.meta)
.foregroundStyle(Paper.alarm)
.padding(.top, 12)
.padding(.horizontal, 28)
}
@@ -84,7 +87,7 @@ struct OnboardingView: View {
ProgressView().tint(.white)
} else {
Text("Connect")
.font(.system(size: 17, weight: .semibold))
.font(PaperType.label)
}
}
.frame(maxWidth: .infinity)
+53 -51
View File
@@ -510,6 +510,7 @@ struct PodcastPlayerView: View {
Spacer()
}
.padding(.horizontal, 32)
.paperSurface()
.navigationTitle("Podcast")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -520,7 +521,7 @@ struct PodcastPlayerView: View {
ToolbarItem(placement: .topBarTrailing) {
Button { vm.stop(); dismiss() } label: {
Image(systemName: "stop.circle")
.foregroundStyle(.red)
.foregroundStyle(Paper.alarm)
}
}
}
@@ -556,16 +557,16 @@ struct PodcastPlayerView: View {
VStack(spacing: 28) {
Image(systemName: "waveform")
.font(.system(size: 52))
.foregroundStyle(.secondary)
.foregroundStyle(Paper.secondary)
.symbolEffect(.variableColor.iterative, isActive: true)
VStack(spacing: 10) {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.blue)
.tint(Paper.accent)
Text(label)
.font(.system(size: 14))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
}
}
}
@@ -573,16 +574,16 @@ struct PodcastPlayerView: View {
private func playerView(title: String) -> some View {
VStack(spacing: 28) {
RoundedRectangle(cornerRadius: 20)
.fill(Color(.systemGray6))
.fill(Paper.raised)
.frame(width: 220, height: 220)
.overlay {
Image(systemName: "waveform.circle.fill")
.font(.system(size: 80))
.foregroundStyle(.blue)
.foregroundStyle(Paper.accent)
}
Text(title)
.font(.system(size: 18, weight: .semibold))
.font(PaperType.heading)
.multilineTextAlignment(.center)
.lineLimit(3)
@@ -591,32 +592,32 @@ struct PodcastPlayerView: View {
value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }),
in: 0...vm.duration
)
.tint(.primary)
.tint(Paper.ink)
HStack {
Text(formatTime(vm.currentTime))
Spacer()
Text(formatTime(vm.duration))
}
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.tertiary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
HStack(spacing: 36) {
Button { vm.skipBackward15() } label: {
Image(systemName: "gobackward.15")
.font(.system(size: 32))
.foregroundStyle(.primary)
.foregroundStyle(Paper.ink)
}
Button { vm.togglePlayPause() } label: {
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.primary)
.foregroundStyle(Paper.ink)
}
Button { vm.skipForward15() } label: {
Image(systemName: "goforward.15")
.font(.system(size: 32))
.foregroundStyle(.primary)
.foregroundStyle(Paper.ink)
}
}
@@ -636,8 +637,8 @@ struct PodcastPlayerView: View {
}
} label: {
Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.glassEffect(in: Capsule())
@@ -652,7 +653,7 @@ struct PodcastPlayerView: View {
} label: {
Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(sleepActive ? Color.blue : .secondary)
.foregroundStyle(sleepActive ? Paper.accent : .secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.glassEffect(in: Capsule())
@@ -676,7 +677,7 @@ struct PodcastPlayerView: View {
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
Label("Share Episode", systemImage: "square.and.arrow.up")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
.foregroundStyle(Paper.secondary)
}
}
}
@@ -687,12 +688,12 @@ struct PodcastPlayerView: View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 44))
.foregroundStyle(.red)
.foregroundStyle(Paper.alarm)
Text("Generation Failed")
.font(.headline)
.font(PaperType.heading)
Text(message)
.font(.system(size: 14))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
.multilineTextAlignment(.center)
Button {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
@@ -701,7 +702,7 @@ struct PodcastPlayerView: View {
.font(.system(size: 15, weight: .semibold))
}
.buttonStyle(.glassProminent)
.tint(.blue)
.tint(Paper.accent)
.buttonBorderShape(.capsule)
.padding(.top, 4)
}
@@ -724,18 +725,18 @@ struct MiniPlayerView: View {
HStack(spacing: 14) {
Image(systemName: "waveform")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.blue)
.foregroundStyle(Paper.accent)
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(vm.currentArticleTitle.isEmpty ? "Podcast" : vm.currentArticleTitle)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
.font(PaperType.meta)
.foregroundStyle(Paper.ink)
.lineLimit(1)
Text(progressLabel)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.secondary)
}
Spacer()
@@ -749,7 +750,7 @@ struct MiniPlayerView: View {
} label: {
Image(systemName: vm.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18))
.foregroundStyle(.primary)
.foregroundStyle(Paper.ink)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
@@ -760,20 +761,20 @@ struct MiniPlayerView: View {
} label: {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.foregroundStyle(Paper.secondary)
.frame(width: 28, height: 28)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(.regularMaterial)
.background(Paper.raised)
.clipShape(RoundedRectangle(cornerRadius: 16))
.overlay(alignment: .bottom) {
// Progress bar along bottom edge
GeometryReader { geo in
Rectangle()
.fill(Color.blue.opacity(0.6))
.fill(Paper.accent.opacity(0.6))
.frame(width: geo.size.width * progressFraction, height: 3)
}
.frame(height: 3)
@@ -819,10 +820,10 @@ struct PodcastLibraryView: View {
NavigationStack {
Group {
if library.entries.isEmpty {
ContentUnavailableView(
"No Podcasts",
PaperEmptyState(
title: "No Podcasts",
systemImage: "headphones",
description: Text("Podcasts you generate from bookmarks will appear here.")
message: "Podcasts you generate from bookmarks will appear here."
)
} else {
List {
@@ -840,6 +841,7 @@ struct PodcastLibraryView: View {
.listStyle(.insetGrouped)
}
}
.paperSurface()
.navigationTitle("Podcasts")
.toolbar {
if !unplayed.isEmpty {
@@ -888,18 +890,18 @@ struct PodcastLibraryView: View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium))
.font(PaperType.quote)
.lineLimit(2)
.foregroundStyle(entry.isPlayed ? .secondary : .primary)
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.secondary)
}
Spacer()
if entry.isPlayed {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 15))
.foregroundStyle(.green)
.foregroundStyle(Paper.affirm)
.accessibilityLabel("Played")
}
Button {
@@ -907,7 +909,7 @@ struct PodcastLibraryView: View {
} label: {
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
.foregroundStyle(Paper.accent)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
@@ -1005,17 +1007,17 @@ struct EpisodePickerView: View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium))
.font(PaperType.quote)
.lineLimit(2)
if entry.articleUrl != bookmark.url {
Text(entry.articleUrl)
.font(.system(size: 11))
.foregroundStyle(.tertiary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
.lineLimit(1)
}
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.secondary)
}
Spacer()
Button {
@@ -1026,7 +1028,7 @@ struct EpisodePickerView: View {
} label: {
Image(systemName: "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
.foregroundStyle(Paper.accent)
}
.buttonStyle(.plain)
}
@@ -1095,21 +1097,21 @@ struct EpisodeDetailView: View {
Section {
VStack(alignment: .leading, spacing: 8) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 20, weight: .semibold))
.font(PaperType.display)
Label(domain, systemImage: "link")
.font(.subheadline)
.foregroundStyle(.secondary)
.foregroundStyle(Paper.secondary)
.lineLimit(1)
HStack(spacing: 12) {
Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted),
systemImage: "calendar")
if isPlayed {
Label("Played", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.foregroundStyle(Paper.affirm)
}
}
.font(.caption)
.foregroundStyle(.secondary)
.foregroundStyle(Paper.secondary)
}
.padding(.vertical, 4)
}
@@ -1118,7 +1120,7 @@ struct EpisodeDetailView: View {
Section("Summary") {
Text(summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.foregroundStyle(Paper.secondary)
}
}
+275
View File
@@ -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)
}
+3 -2
View File
@@ -21,6 +21,7 @@ struct SearchView: View {
}
}
.listStyle(.plain)
.paperSurface()
.navigationTitle("Search")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
@@ -33,9 +34,9 @@ struct SearchView: View {
}
.overlay {
if searchText.isEmpty {
ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag."))
PaperEmptyState(title: "Search Bookmarks", systemImage: "magnifyingglass", message: "Search by title, URL, or tag.")
} else if results.isEmpty && !viewModel.isLoading {
ContentUnavailableView.search(text: searchText)
PaperEmptyState(title: "No Results", systemImage: "magnifyingglass", message: "Nothing matches \u{201C}\(searchText)\u{201D}.")
}
if viewModel.isLoading {
ProgressView()
+20 -6
View File
@@ -11,14 +11,18 @@ struct SettingsView: View {
var body: some View {
NavigationStack {
List {
Section("AI Features") {
Section {
Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
} header: {
Text("AI Features").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
Section {
TextField("e.g. listen podcast", text: $autoTagText)
.font(PaperType.label)
.foregroundStyle(Paper.ink)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onChange(of: autoTagText) { _, new in
@@ -27,25 +31,35 @@ struct SettingsView: View {
.map(String.init)
}
} header: {
Text("Auto-Podcast Tags")
Text("Auto-Podcast Tags").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
} footer: {
Text("Saving a bookmark with any of these tags automatically generates a podcast for it.")
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
}
Section("Server") {
Button(role: .destructive) {
Section {
Button {
dismiss()
onDisconnect()
} label: {
Label("Disconnect", systemImage: "person.crop.circle.badge.minus")
.font(PaperType.label)
.foregroundStyle(Paper.alarm)
}
} header: {
Text("Server").font(PaperType.stamp).foregroundStyle(Paper.tertiary)
}
}
.listRowBackground(Paper.raised)
.paperSurface()
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.font(PaperType.label)
.tint(Paper.accent)
}
}
}
+31 -20
View File
@@ -35,8 +35,8 @@ struct SourcesView: View {
playOrGeneratePodcast(for: source)
} label: {
Image(systemName: podcastIcon(for: source))
.font(.title3)
.foregroundStyle(.blue)
.font(.system(size: 22))
.foregroundStyle(Paper.accent)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
@@ -48,7 +48,7 @@ struct SourcesView: View {
} label: {
Label("Podcast", systemImage: "headphones")
}
.tint(.blue)
.tint(Paper.accent)
Button(role: .destructive) {
library.delete(source)
@@ -59,6 +59,7 @@ struct SourcesView: View {
}
}
.listStyle(.plain)
.paperSurface()
.navigationTitle("Sources")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
@@ -80,10 +81,12 @@ struct SourcesView: View {
}
.overlay {
if results.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "No Sources" : "No Results",
PaperEmptyState(
title: searchText.isEmpty ? "No Sources" : "No Results",
systemImage: searchText.isEmpty ? "tray" : "magnifyingglass",
description: Text(searchText.isEmpty ? "Import text, PDFs, or files to keep non-web material in Marks." : "Try a different search.")
message: searchText.isEmpty
? "Import text, PDFs, or files to keep non-web material in Marks."
: "Try a different search."
)
}
}
@@ -198,19 +201,20 @@ private struct SourceRow: View {
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: iconName)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.blue)
.font(PaperType.stamp)
.foregroundStyle(Paper.accent)
.frame(width: 30, height: 30)
.background(Color.blue.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
.background(Paper.accent.opacity(0.12), in: RoundedRectangle(cornerRadius: 7))
VStack(alignment: .leading, spacing: 5) {
Text(source.displayTitle)
.font(.headline)
.font(PaperType.title)
.foregroundStyle(Paper.ink)
.lineLimit(2)
if !source.excerpt.isEmpty {
Text(source.excerpt)
.font(.subheadline)
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
.lineLimit(2)
}
HStack(spacing: 8) {
@@ -220,8 +224,8 @@ private struct SourceRow: View {
Text(source.tags.joined(separator: ", "))
}
}
.font(.caption)
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
}
.padding(.vertical, 8)
@@ -265,6 +269,7 @@ private struct ImportTextSourceView: View {
Text("Separate tags with commas")
}
}
.paperSurface()
.navigationTitle("Import Text")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -329,6 +334,8 @@ private struct EditSourceView: View {
Text("Separate tags with commas")
}
}
.listRowBackground(Paper.raised)
.paperSurface()
.navigationTitle("Edit Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -363,10 +370,11 @@ private struct SourceDetailView: View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(source.displayTitle)
.font(.title2.weight(.semibold))
.font(PaperType.display)
.foregroundStyle(Paper.ink)
Text(source.kind.label)
.font(.subheadline)
.foregroundStyle(.secondary)
.font(PaperType.meta)
.foregroundStyle(Paper.tertiary)
}
if !source.tags.isEmpty {
@@ -374,10 +382,11 @@ private struct SourceDetailView: View {
HStack {
ForEach(source.tags, id: \.self) { tag in
Text(tag)
.font(.caption.weight(.medium))
.font(PaperType.micro)
.foregroundStyle(Paper.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.blue.opacity(0.12), in: Capsule())
.background(Paper.accent.opacity(0.12), in: Capsule())
}
}
}
@@ -411,12 +420,14 @@ private struct SourceDetailView: View {
}
Text(source.bodyText.isEmpty ? "No extractable text." : source.bodyText)
.font(.body)
.font(PaperType.body)
.foregroundStyle(Paper.ink)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
}
.paperSurface()
.navigationTitle("Source")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
+77 -64
View File
@@ -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)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import Testing
import CoreSpotlight
@testable import Marks
/// End-to-end guard for the whole Spotlight path: index a bookmark, then
/// retrieve it the way the on-device assistant does.
///
/// Worth keeping. The bug this was written for made every item fail to
/// translate into the index while `indexAppEntities` still reported success,
/// so nothing short of a round trip would have caught it.
struct SpotlightRetrievalTests {
@Test func indexedBookmarkIsRetrievable() async throws {
let b = Bookmark(
id: 999_001,
url: "https://github.com/example/zqxjkltest",
title: "Zqxjkltest concurrency notes",
description: "A distinctive probe document about zqxjkltest.",
tagNames: ["zqxjkltest"],
dateAdded: Date(), dateModified: Date(),
isArchived: false, unread: false, shared: false,
websiteTitle: nil, websiteDescription: nil,
faviconUrl: nil, previewImageUrl: nil,
aiSummary: nil, aiTags: nil
)
await SpotlightIndexer.index([b])
var hits: [RetrievedBookmark] = []
for _ in 0..<20 {
try? await Task.sleep(for: .milliseconds(700))
hits = await SpotlightBookmarkSearch.run(query: "zqxjkltest", limit: 5)
if !hits.isEmpty { break }
}
await SpotlightIndexer.remove(ids: [999_001])
#expect(!hits.isEmpty, "Spotlight returned no hits for an indexed bookmark")
let hit = try #require(hits.first)
#expect(hit.title.contains("Zqxjkltest"))
#expect(hit.url == "https://github.com/example/zqxjkltest",
"url did not survive the round trip: \(hit.url)")
#expect(hit.host == "github.com", "host was \(hit.host)")
}
}
+25 -17
View File
@@ -65,7 +65,7 @@ struct ShareView: View {
case .loading:
HStack(spacing: 12) {
ProgressView()
Text("Checking…").foregroundStyle(.secondary)
Text("Checking…").font(PaperType.meta).foregroundStyle(Paper.secondary)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 24)
@@ -85,7 +85,11 @@ struct ShareView: View {
}
}
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.background(Paper.sheet, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(Paper.rule.opacity(0.5), lineWidth: 0.6)
)
.shadow(color: .black.opacity(0.15), radius: 24, y: 8)
}
@@ -99,19 +103,21 @@ struct ShareView: View {
? "Already saved"
: "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))")
} icon: {
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
Image(systemName: "checkmark.circle.fill").foregroundStyle(Paper.affirm)
}
.font(.subheadline.weight(.semibold))
.font(PaperType.label)
.foregroundStyle(Paper.ink)
} else {
Text("Save bookmark").font(.headline)
Text("Save bookmark").font(PaperType.heading).foregroundStyle(Paper.ink)
}
Text(title.isEmpty ? url : title)
.font(.subheadline.weight(.medium))
.font(PaperType.quote)
.foregroundStyle(Paper.ink)
.lineLimit(2)
Text(domain)
.font(.caption)
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
}
@@ -128,8 +134,8 @@ struct ShareView: View {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Suggesting tags…")
.font(.caption)
.foregroundStyle(.secondary)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
}
@@ -139,11 +145,11 @@ struct ShareView: View {
ForEach(suggestionChips, id: \.tag) { item in
Button { addTag(item.tag) } label: {
Text(item.isAI ? "\(item.tag)" : "+ \(item.tag)")
.font(.caption.weight(.medium))
.font(PaperType.micro)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background((item.isAI ? Color.purple : .blue).opacity(0.12), in: Capsule())
.foregroundStyle(item.isAI ? Color.purple : .blue)
.background((item.isAI ? Paper.accent : Paper.ink).opacity(0.1), in: Capsule())
.foregroundStyle(item.isAI ? Paper.accent : Paper.secondary)
}
.buttonStyle(.plain)
}
@@ -157,12 +163,14 @@ struct ShareView: View {
.textFieldStyle(.roundedBorder)
Toggle("Read later", isOn: $readLater)
.font(.subheadline)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
Toggle(isOn: $createPodcast) {
Label("Create podcast", systemImage: "headphones")
}
.font(.subheadline)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
}
}
@@ -189,8 +197,8 @@ struct ShareView: View {
private func statusCard(icon: String, tint: Color, text: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon).font(.title3).foregroundStyle(tint)
Text(text).font(.headline)
Image(systemName: icon).font(.system(size: 22)).foregroundStyle(tint)
Text(text).font(PaperType.heading).foregroundStyle(Paper.ink)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
+3
View File
@@ -93,6 +93,9 @@ targets:
- path: Marks/Services/Log.swift
# Cross-process podcast request queue: the extension enqueues, the app runs it.
- path: Marks/Services/PodcastRequests.swift
# The save card is a user-facing surface, so it uses the same palette and
# type scale as the app.
- path: Marks/Views/Library/LibraryKit.swift
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension