Compare commits

...
15 Commits
Author SHA1 Message Date
admin c932814166 Brand mark: scallop ribbon shape, app icon in light / dark / tinted (#11)
CI / build-and-deploy (push) Successful in 18s
2026-08-03 17:00:52 +00:00
admin b3d8719874 Merge pull request 'Fix crash: enrichment held array indices across awaits' (#10) from fix/enrichment-index-crash into master
CI / build-and-deploy (push) Successful in 59s
2026-07-31 22:23:28 +00:00
Krishna KumarandClaude Opus 5 2e1b454c09 Fix crash: enrichment held array indices across awaits
CI / build-and-deploy (pull_request) Successful in 30s
`startEnrichment` snapshotted `bookmarks.indices`, then awaited a network
call per bookmark. `bookmarks` is replaced wholesale by loads, searches
and the unread filter, so by the time the loop read `bookmarks[i]` the
index could be out of range — EXC_BREAKPOINT in
Array._checkSubscript, straight from the read. The write path already
guarded with `i < bookmarks.count`; the read did not.

`enrichAll()` had the same shape and the same exposure.

Both now track bookmark ids and re-resolve the position after each await,
via a shared `apply(summary:tags:toId:)` that skips a bookmark that is no
longer loaded rather than writing to whatever now sits at that index —
which is the other half of the bug: a stale-but-in-range index would have
silently attached one bookmark's summary to another.

Found while investigating leftover Spotlight "translation error ...
Code=1 (null)" entries after the indexing fix. Those turned out to be a
symptom, not a separate bug: the crash tore the process down mid-donation
and the in-flight items failed to translate. With the crash fixed they
are gone, and the suite went from crashing (0 tests executed) to green
across three consecutive runs.

Adds a source round-trip to SpotlightRetrievalTests, so both donation
paths — app entities and raw searchable items — are covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-29 23:46:07 -05:00
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
38 changed files with 3031 additions and 545 deletions
+46 -4
View File
@@ -14,13 +14,19 @@
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.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 */; }; 212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; }; 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
2F80DFB7298B733965FE04F5 /* MarksMark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EC8E834AB663A823D77BCB1 /* MarksMark.swift */; };
337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; }; 337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; };
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.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 */; }; 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; }; 44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; };
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.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 */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; }; 66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
@@ -46,8 +52,8 @@
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; }; 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; }; 94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
95D9848F60EB303D9EACCDDA /* SourcesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB1DA808EE8041C4546DAAB /* SourcesView.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 */; }; 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 */; }; A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; }; A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */; }; AB0BF1F51887D25CC9D6EE1C /* SpotlightIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */; };
@@ -58,6 +64,7 @@
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; }; B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; };
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; }; B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; };
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.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 */; }; BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; }; CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; };
@@ -126,9 +133,13 @@
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
240DBB87940F8D255A812EB2 /* BookmarkActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkActions.swift; sourceTree = "<group>"; };
2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightRetrievalTests.swift; sourceTree = "<group>"; };
3EC8E834AB663A823D77BCB1 /* MarksMark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksMark.swift; sourceTree = "<group>"; };
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
@@ -141,6 +152,7 @@
64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSource.swift; sourceTree = "<group>"; };
@@ -153,10 +165,10 @@
9B7A85A23A13D754F6A75E4D /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchTool.swift; sourceTree = "<group>"; };
@@ -172,6 +184,7 @@
D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSpotlightIndexer.swift; sourceTree = "<group>"; };
@@ -235,10 +248,22 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
7623601C25E481DF58371F2A /* AppIntentsTests.swift */, 7623601C25E481DF58371F2A /* AppIntentsTests.swift */,
2526E22D8EC5453358F0FCF9 /* SpotlightRetrievalTests.swift */,
); );
path = MarksTests; path = MarksTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
585E3F011CBA8ECA6D1925C0 /* Library */ = {
isa = PBXGroup;
children = (
1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */,
AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */,
E093C878E702891C64D21FD5 /* LibraryListRow.swift */,
3EC8E834AB663A823D77BCB1 /* MarksMark.swift */,
);
path = Library;
sourceTree = "<group>";
};
58E8E316BE3F10C5149AADC3 /* Intents */ = { 58E8E316BE3F10C5149AADC3 /* Intents */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -286,6 +311,14 @@
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
7F380B7CE96F87441C28DB93 /* Prototypes */ = {
isa = PBXGroup;
children = (
7510EB352E624C7C9656EA33 /* LibraryView.swift */,
);
path = Prototypes;
sourceTree = "<group>";
};
85E717A515682EBF67DD199A /* MarksWidget */ = { 85E717A515682EBF67DD199A /* MarksWidget */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -305,8 +338,8 @@
children = ( children = (
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
217E6702DE1210AC38ED16D1 /* AskView.swift */, 217E6702DE1210AC38ED16D1 /* AskView.swift */,
240DBB87940F8D255A812EB2 /* BookmarkActions.swift */,
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */, CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */, CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
629C41E0BC28EB6359D50CFD /* BrowserView.swift */, 629C41E0BC28EB6359D50CFD /* BrowserView.swift */,
@@ -318,6 +351,8 @@
5C29CB878BC334639E6194E2 /* SettingsView.swift */, 5C29CB878BC334639E6194E2 /* SettingsView.swift */,
CDB1DA808EE8041C4546DAAB /* SourcesView.swift */, CDB1DA808EE8041C4546DAAB /* SourcesView.swift */,
B13B9F2D890C7953531AC0D2 /* TagsView.swift */, B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
585E3F011CBA8ECA6D1925C0 /* Library */,
7F380B7CE96F87441C28DB93 /* Prototypes */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -499,6 +534,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */, FD656A44CEE8AE28B136AD85 /* AppIntentsTests.swift in Sources */,
59E46C9653C9033F5BB1CEC7 /* SpotlightRetrievalTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -508,6 +544,7 @@
files = ( files = (
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */, 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */,
70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */, 70DE16F6334D0F3798C98064 /* IngestPayload.swift in Sources */,
A30907113FD5D682478750A7 /* LibraryKit.swift in Sources */,
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */, 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */, B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */, B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
@@ -540,11 +577,11 @@
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */, B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */, 68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */,
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
BC866F8D6189334650ADCB95 /* BookmarkActions.swift in Sources */,
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */, 8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */,
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */, FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */, CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */, 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */,
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */, 44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */,
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
@@ -557,11 +594,16 @@
81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */, 81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */,
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */, DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.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 */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
212F713DCC289C48087B79AE /* Log.swift in Sources */, 212F713DCC289C48087B79AE /* Log.swift in Sources */,
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */, 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */, E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */,
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */, 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */,
2F80DFB7298B733965FE04F5 /* MarksMark.swift in Sources */,
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */, 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */, 773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */,
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */, 0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 14 KiB

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