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
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
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
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
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
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
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
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
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
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
Resolves the two deviations from the episode detail sheet:
- AI summary now shows in the detail sheet. Added a URL-keyed AISummaryStore
that BookmarksViewModel populates as it enriches/loads bookmarks, so the
decoupled Podcasts tab can surface a summary from just the episode URL —
without recoupling to BookmarksViewModel.
- "Open Bookmark" opens the in-app BrowserView as a nested sheet (generator
threaded through PodcastLibraryView / EpisodePickerView), alongside the
existing "Open in Safari" and Share.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New EpisodeDetailView: title, source domain, created date, played badge,
and per-episode actions — Play/Pause, Mark Played/Unplayed, Open Source,
Share, Delete — all routed through the PodcastLibrary store so the badge
stays in sync.
- Reachable by tapping a row (or "Episode Details" in the context menu) in the
Podcasts tab, and by tapping a row in the episode picker.
Note: AI summary is intentionally omitted for now — it's keyed by bookmark id
(not URL), so surfacing it would recouple the deliberately-decoupled Podcasts
tab to BookmarksViewModel. Open Source uses Safari rather than the in-app
browser to avoid sheet-stacking. Both are noted as follow-ups on issue #8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Unread badge on the Podcasts tab via a new observable PodcastLibrary store
that mirrors PodcastIndex; all played/remove/generation mutations route
through it so the count stays live.
- Persist playback speed across sessions (UserDefaults), applied on setup.
- Sleep timer (15/30/45 min or end-of-episode) with a moon menu in the full
player; end-of-episode stops instead of auto-advancing.
- Persist the current episode + Up Next queue and restore a *paused* session on
launch; save position when backgrounding. Audio session is only claimed on
actual playback so a restored session never interrupts other audio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add a long-press context menu on each library row (Play/Pause,
Mark as Played/Unplayed, Delete).
- Add a Mark as Played/Unplayed control next to Share Episode in the full
player, tracking the loaded episode across queue advances.
- Show a green checkmark on played rows, on top of the existing Played
section + dimmed title, so played state reads at a glance.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turns the Podcasts tab into a proper podcast-app experience across three
features that compose on a shared background-generation service.
Background generation (#2)
- New PodcastGenerationManager runs generate→poll→download off the player,
so producing a new episode never stops current playback.
- Headphone taps never interrupt: idle → generate-and-play in the foreground
(unchanged); already playing → generate in the background, episode lands in
the library. A "Generating…" banner surfaces active jobs.
Played/unplayed + queue (#1)
- PodcastEntry gains playedAt (old index.json decodes as unplayed).
- Podcasts tab splits into Up Next / Played with a Play All queue that
auto-advances; finishing marks played; swipe to toggle played state.
Share-extension audio (#3)
- "Create podcast" toggle on the save card, plus configurable Auto-Podcast
Tags in Settings. The extension enqueues a request into the App Group; the
main app drains it on launch/foreground and generates in the background.
Closes#1Closes#2Closes#3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface every podcast in PodcastIndex as a top-level tab instead of a
buried sheet. Repurpose PodcastLibraryView into the tab: play/pause toggle
per episode, a now-playing bar that opens the full player, swipe/Edit to
delete. Uses the shared podcastPlayer so playback state is consistent
across tabs. Removes the redundant Podcasts entry from the bookmarks menu.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swap hardcoded systemGray/Color.blue pill and button fills for the iOS 26
Liquid Glass APIs so controls read native under the new design system:
- BookmarkRow tag chips: glassEffect in a GlassEffectContainer
- Podcast speed button: glassEffect capsule
- Podcast retry + Onboarding Connect: glassProminent button style
Onboarding text-field fills left as systemGray6 (standard adaptive field
fill; glass behind editable text hurts legibility).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- SpotlightIndexer: actually push BookmarkEntity into the Spotlight index via
indexAppEntities (IndexedEntity conformance alone indexes nothing). Wired into
every sync/mutation point. This is what lets Apple Intelligence answer
free-form Siri questions grounded in the user's bookmarks.
- On-device RAG: BookmarkAssistant (LanguageModelSession) + BookmarkSearchTool
+ SpotlightBookmarkSearch (CSSearchQuery retrieval), surfaced via AskView with
lightweight Markdown rendering of answers.
- Log: os.Logger facility (com.magicive.marks) replacing ad-hoc print(); shared
with ShareExtension. ClaudeService now logs server error bodies on non-2xx.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the fire-and-forget share toast with an interactive SwiftUI card.
On open it checks linkding's /api/bookmarks/check/ so an already-saved URL
routes to an Update (no more silent duplicates) and a new URL shows a save
form with title, tags, notes, and a read-later toggle.
Tag suggestions are hybrid: fetch the user's existing tag vocabulary
(/api/tags/) and AI tag ideas (/v1/marks/enrich via anonymous device auth),
auto-fill known-vocabulary matches into the field and surface net-new ideas
as tappable chips. Best-effort and non-blocking — failures never block save.
Also:
- Refresh bookmarks when the app returns to the foreground (scenePhase).
- Render bookmark dates as a static relative label instead of the live
ticking RelativeDateTime timer.
- Share LinkdingAPI/Bookmark/ServerConfig/MarksAuth into the ShareExtension
target via project.yml (durable across xcodegen regen) and regenerate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- bookmarkOnscreen(_:) publishes the viewed bookmark as the on-screen
entity (NSUserActivity.appEntityIdentifier) so Siri can resolve "this"
/ "summarize this one"; applied to the BrowserView sheets in
BookmarksView and SearchView
- Add MarksTests (XCTest) covering entity mapping, EntityIdentifier,
IntentRouter, and the real perform() of SearchMarksIntent and
OpenBookmarkIntent; wired into the Marks scheme test phase
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Expose Marks to Siri, Spotlight, and Shortcuts via App Intents:
- BookmarkEntity (AppEntity + IndexedEntity, keyed on the Linkding
server id) with a server-backed EntityStringQuery for live lookup
- Intents: Add, Open (OpenIntent), Search, ShowUnread, and an AI
Summarize intent backed by ClaudeService; AppShortcutsProvider with
spoken phrases; Siri snippet views and dialog
- SearchMarksIntent conforms to the system.search App Schema
(@AppIntent(schema:), ShowInAppSearchResultsIntent) so it is
executable by conversational Siri / Apple Intelligence
- IntentRouter bridges intents (run in the app process) to the SwiftUI
scene for open/search navigation; widgets refreshed after adds
- Link AppIntents.framework so the metadata processor extracts shortcut
phrases and validates the assistant schema
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface a bit of the page content in the list without fetching/parsing pages
ourselves — linkding already returns the scraped metadata in the bookmark
payload. Add Bookmark.contentExcerpt (website_description, falling back to the
user's note) and render it under the title: AI summary takes priority and stays
italic, otherwise the scraped excerpt shows. Rows with neither degrade to the
existing favicon + title layout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apply WWDC26 design-brief fixes to the bookmark list reading surface:
- Dynamic Type: replace fixed .system(size:) points with text styles
(title .headline, domain/date .footnote, summary .subheadline, tags
.caption, podcast glyph .title3). Rows now scale and wrap with the
user's text-size setting instead of staying fixed.
- Perf: podcastCached was a FileManager.fileExists stat evaluated inside
body — i.e. per row, per scroll frame. Move it to @State populated by a
.task(id:) that stats off the main actor, once per appearance.
- Forgiveness: destructive trailing swipe is now allowsFullSwipe: false so
a single fling can't permanently delete; requires tapping the button.
Adds docs/design-wwdc26.md (consolidated brief + codebase audit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace Button wrapper with onTapGesture so swipe gestures don't compete
with the row tap recognizer and accidentally open bookmarks.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Meta line .tertiary → .secondary (more readable domain/date line)
- Tag font 11pt → 12pt
- Reading progress bar: full-width, green, pinned to row bottom edge via overlay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Save serverConfig to App Group on every app launch so ShareExtension
can always find credentials without requiring a disconnect/reconnect flow
- Add MarksWidget target with RandomBookmarkWidget (random saved bookmark,
reloads hourly) using file-based App Group storage to avoid CFPreferences issues
- Extract BookmarkListRow as shared component with editorial typography tweaks
(17pt semibold title, 13pt domain, relative date, 32×32 favicon, italic AI summary,
20pt podcast icon, 16pt horizontal insets)
- Add MarksWidget and ShareExtension Xcode schemes for direct run/debug
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Mini player shows as soon as generation starts (not just when audio ready)
- Idempotency: tapping headphones again during generation is a no-op
- Playback resets to position 0 on completion (standard player behavior)
- Stop button in full player visible during generation
- PodcastLibraryView lists all generated podcasts
- Sparkles menu gains Podcasts entry
- Fix AnalyticsService Sendable error (serialize before Task.detached boundary)