---
name: repo-conventions
description: KMReader subsystem conventions and invariants — reader state boundaries, reading-progress sync, offline downloads and caching, local database (GRDB) migrations, SSE dispatch, browse and dashboard behavior, detail page structure, platform UI placement. Use when working on the reader (DIVINA/PDF/EPUB engines, navigation, position), progress sync or offline features, the local database schema, SSE, dashboard sections or cards, browse pages, detail pages, or platform-specific UI.
---

# Repo Conventions

Subsystem conventions and invariants for KMReader. `AGENTS.md` holds repo-wide rules; this file holds the per-subsystem boundaries. Record boundaries, ownership, invariants, and explicit design rules; leave styling values (font sizes, point paddings, scale and opacity numbers) to `LayoutConfig` and the views, since they drift with design tweaks. When a change alters one of these boundaries, update this file in the same change (AGENTS.md rule 18).

## Reader State Boundaries

### Reader Settings vs Session

- `ReaderSettingsSheet` is for persisted preferences only. Session-only options (e.g. page rotation) belong in the reader controls menu / platform command menu. Rotation is session-only, paged DIVINA modes only, never Webtoon.

### Position & Navigation

- `ReaderViewModel` owns the committed semantic position as a full `ReaderViewItem` plus its focused `ReaderPageID`.
- `navigationTarget` is reserved for explicit navigation commands; rebuilds restore from the committed position and adapter snapshots, never synthesize commands, and a later interactive commit supersedes any restoration anchor. When a command already resolves to the current position, clear `navigationTarget` synchronously before committing (committing first loops).
- Seamless cross-book navigation: the committed `ReaderPositionAnchor` is the source of truth; `.end` items retain their segment's final `ReaderPageID`; `currentBook`/`ReaderSession.book` follow the segment; `currentBookId` remains the whole-book load anchor.
- Split wide pages keep their committed side across layout rebuilds; propagate it via `ReaderPositionAnchor.preferredSplitPart` and `ReaderViewItem.preferredSplitPart(preserving:)` whenever adapters construct a new anchor.

### Whole Spreads

- Whole-spread panning is opt-in: it applies only when Split Wide Pages is set to Scroll (`ReaderViewModel.keepsSplitSpreadsWhole`); every other mode pages through `.first`/`.second` halves, as macOS and tvOS always do. With Scroll on iOS, single-page presentation keeps a split wide page whole: `generateViewItems` emits one `.split(id, .both)`, the same item dual presentation uses, and single-page engines render it uncut at the scale a single page gets (`WholeSpreadLayout`), panning across it at base zoom through `SpreadPanningScrollView`. Engines build `WholeSpreadPresentation` via `ReaderViewModel.wholeSpreadPresentation(for:isDualPagePresentation:…)` from their own mode, never the view model's dual flag.
- A whole spread has two stops, its start and end edges in reading order (`ReaderSpreadEdge`, mapped onto `.first`/`.second`). Paged steps (taps, keys, remote) go through `ReaderViewModel.requestPagedStep(offset:)`: a step first pans to the edge it leaves through, as a navigation target on the same item that names that edge, and stepping back onto a spread lands on its end edge. Steps chain from an in-flight target. While the reader is zoomed, steps skip the stops and turn the page, as for any zoomed page. Engines apply a same-item target's edge to the current page host, clearing the target like any command that resolves to the current position.
- Swipes pan freely and never turn the page mid-gesture: the host's scroll view begins for a touch that catches the spread mid-glide (UIKit begins that pan at touch-down, before it has a direction) and otherwise only for horizontal drags it can still follow, and each engine's page-turn gesture (collection view pan, cover pan, curl pan) refuses drags the current spread can still follow. A turn needs a new drag from the far edge. Both sides judge a drag with `UIPanGestureRecognizer.horizontalDrag(in:)`, so each drag goes to exactly one of them. A touch while the spread bounces back from past an edge is not caught: the touch ends the bounce and the drag is judged by its direction, so a swipe on past the edge still turns the page. Each engine's tap zone waits for the page host's scroll pan to fail (`SpreadPanningScrollView.isPagePan`), so a tap that catches a gliding page only stops it.
- Both page hosts (`NativePagedPageContentView`, `NativeImagePageViewController`) keep their zoom scroll view in a `PageScrollController`, which owns the content wiring, the spread's width, placement, panning, and resting edge; hosts supply only the displayed image size, whether they show the committed page, and what to do on zoom. A spread keeps its resting edge in reading order across item, viewport, and content-size changes, and a flipped start side moves it to where that edge now is.
- The shared controller reports the edges the spread rests at (`recordWholeSpreadPosition(pageID:restingEdges:)`) whenever it places the spread, a pan settles, or the host starts showing the committed item, but only while the host shows the committed page; a single resting edge becomes the committed split side, so rebuilds reopen the spread there. While a finger moves the spread or it glides, the controller reports no resting edge, so a step taken meanwhile pans to the edge it leaves through, and a pan that would stop just short of an edge settles on it (`spreadRestingOffset(forTarget:)`). A host starting to show a spread opens it at `wholeSpreadArrivalEdge(for:relativeTo:)`: an explicit target's edge, the committed side for the current item, the end edge for the item right before the current one, else the start edge.

### Page Image Preparation

- `ReaderPageLoadScheduler` decodes page bitmaps under a short-edge budget: screen width in pixels × a fixed zoom headroom (`pageDecodeZoomHeadroom`, 2), via ImageIO in `loadImageFromFile`. Pages within budget decode at full resolution; larger ones downsample uniformly, staying sharp through a 2× zoom. The short edge is the binding axis so the same decode stays valid for paged fit-screen, webtoon fit-width, split halves, and 90° rotation, and aspect preservation keeps `webtoonPixelSize` layout and the rotate → border-crop → split preparation unchanged. The budget applies to whatever file the pipeline resolves, including cached upscaled @2x pages (auto mode only fires on pages smaller than the screen, so a fresh upscale always lands inside the budget). Originals on disk are never rewritten, and Share actions export original pixels via `ReaderViewModel.originalPageImage`, which reloads from the resolved source file and bypasses the preloaded bitmap. Animated-page posters decode at full size — the inline player streams frames from the source file anyway.
- `NativePageItem` prepares a displayed page in one fixed order: rotate, border-crop the whole page, then split into halves. Border cropping never runs on an individual split half — the halves of a wide page must share the whole page's crop rect so a rejoined spread (`fillEqually` slots, one per half) stays seamless.

### Page Curl & Cover Adapters

- Page Curl adapters must not publish a position while mounting or dismantling.
- Page Curl indices are local to one coordinator-owned immutable snapshot; cross update/preload/rotation/teardown boundaries by stable reader-item identity, never array indices or view tags.
- Programmatic turns keep their `setViewControllers` completion authoritative; `willTransitionTo` must not invalidate the in-flight transition token. Programmatic `setViewControllers` must never land while a pan gesture is active (`willTransitionTo` only fires once a curl starts); coordinators gate on the pan recognizers' live state and stash work until the pan ends, like `isTransitioning`.
- Cover adapters (`NativeCoverPageView`) never finalize a page-turn transition across an item-list rebuild; `teardown()` invalidates in-flight tokens, and a viewport size change cancels an in-flight drag.
- EPUB paged modes (iOS `WebPubPagedCurlView`/`WebPubPagedCoverView`) run one live `EpubPageViewController` web view per chapter plus preloaded boundary neighbors (≤3 web views), never one per page; in-chapter turns are JS scrolls on the live view. In curl, `UIPageViewController` slots are dumb shells (`CurlPageShellViewController`): a chapter-internal flip covers the front shell with a snapshot of the current page and migrates the web view's view into a pre-sized target shell (a zero-size pass would trigger a throwaway re-pagination), so the reveal and the committed page are the same live view; a cross-chapter flip keeps the live leaf and reveals the preloaded neighbor. Interactive turns commit or restore in `didFinishAnimating`, programmatic turns in their own `setViewControllers` completion, and the internal pan recognizers carry a target that discards dataSource prefetches which never became a curl (`handleInternalPanState`).
- A curl flip's backside mirrors the current page when moving forward and the target page when moving backward (so the curl lands seamlessly on the revealed destination). The target page's pixels are resolved in order: a small LRU cache of half-scale images captured as each page becomes the committed page (`scheduleBacksideImageCapture`, the only synchronous source for a neighbor that has not finished loading; the chapter-page-keyed cache is dropped whenever pagination re-runs on changed geometry, via `onRepagination`), then a live capture of the settled view (`makeBacksideSnapshotImage` — renders through the render server with a forced commit, since a covered web view's latest scroll is not composited yet), then the mirrored current page as the transient fallback, which is swapped to the target page once it renders (`updateMirroredSnapshot`): an in-chapter backward flip captures the live view once its scroll settles, a cross-chapter backward flip captures the neighbor once its pagination becomes ready (`onPaginationReady`).
- Chapter identity for reload decisions is the resource-scheme URL: the web view loads `kmreader-resource://` URLs via `loadEPUBDocument`, so `loadContentIfNeeded` must compare `webView.url` against `EpubResourceScheme.url(for:rootURL:)`, never the raw chapter file URL (which never matches and would force a full reload on every ensure call).

### EPUB Pagination Settling

- Chapter pagination (the shared `WebPubPagedJavaScriptBuilder.makePaginationScript` behind every paged engine, and the scrolled views' inline scripts) must not finalize a target whose offset depends on the full content extent — a last-page jump (`preferLast`/`jumpToLastPage`) or any target past page 0 — while images are still in flight: the layout check waits for `document.images` to complete (forcing `loading=lazy` images eager), bounded by the layout-check attempt cap. The guard and its cap live in one shared fragment (`WebPubPagedJavaScriptBuilder.fullExtentGuardScript`) interpolated by all three scripts. Finalizing early measures a short document, and the content growth that follows silently strands the position mid-chapter. Page-0 targets finalize without the image wait since their offset does not depend on content size.

### Scroll Engine

- `ScrollReaderEngine` restores anchors strictly by page identity across item-list rebuilds, carrying the pending position as a full `ReaderPositionAnchor` (never a bare `ReaderViewItem`). Unresolvable anchors are discarded (`nil`), never positionally substituted.

### Page Load Failure State

- DIVINA page image load failures are recorded in `ReaderPageLoadScheduler` as a typed `ReaderPageLoadFailure` per page (cancellation never counts as failure), cleared on success and surfaced through the page-presentation invalidation channel; `NativePageData.failure` (paged/scroll/curl) and the Webtoon cell error state render `failure.title` / `failure.detail` with a retry button. Retry goes through `ReaderViewModel.retryImageLoad(for:)` — never re-enter the load pipeline from view code directly.
- The failure value comes from the load pipeline itself: a server/HTTP status from the remote page fetch, a network error description, a local read failure from archive materialization (`OfflineManager.getOfflinePageImageURL` throws; the `try?` callers treat it as plain absence), or offline unavailability. View code never invents its own reason text.

### Book-Level Unavailable State

- Book-level reader failures (deleted or unsupported media, missing offline file, load error, no pages, no content) all render through the shared `ReaderUnavailableView`: icon, title, optional message, an optional prominent Retry, and Close. Engines never hand-draw their own error layout or hardcode reader-context colors — the view's adaptive colors already handle the dark reader context.

### Next-Book Offline State

- In offline-first reading, `ReaderViewModel.nextBookOfflineState` is the single observable for the next book's offline readiness, rendered by end-page/footer UIs.
- The status row is a permanently reserved constant-height slot toggled by alpha — never `isHidden`, which would re-lay out the page; in streaming mode the slot collapses entirely.
- Adapters refresh end content only via the page-presentation invalidation channel. The next-segment preload trigger distance must stay ahead of the download, not just the page turn.

### End-Page Remaining Unread

- `ReaderViewModel.remainingUnreadCount(forSegmentBookId:)` backs the end page's remaining-unread line with the series unread count from the local projection. It refreshes when a segment loads and again after a completing progress snapshot settles (the projection only moves then), publishing through the page-presentation invalidation channel like `nextBookOfflineState`.
- The line renders only in a series context — hidden in a read-list context, where the page reports list order, not series membership — and only while the count is positive.

### Next Book Suggestions

- With "Suggest Next Unread Book" on (`suggestNextUnreadBook`, on by default), the next book skips books already read, like the dashboard: the first later book in series order (or the read list's order in a read-list context) that isn't read, else the plain next book, so re-reading still moves forward. With it off, the plain next book. The previous book always stays plain order. This covers the reader's next book (end page, next segment and its preload) and the series continue-reading target after the last read book.
- Online, `NextBookToReadResolver` makes the single `/next` request with `skipRead=true` (servers that support it skip read books themselves); when the returned book is read (servers that ignore the parameter, such as Komga), the local projection supplies the first unread book after the current one, never adding a request. Offline, and for the local continue-reading target, the same rule runs on the local projection (`Collection.nextToRead(after:isRead:)`). An appended next segment records the book it follows as its previous book, so a skipped book never lands between two segments.

### Read List Continuation

- Read list continuation is opt-in (`readListContinuationEnabled`, default off): `ReaderPresentationManager.present` resolves a nil read list context through `ReadListReadingService.ownerContext(forBookId:)`, so a book owned by a read list the user is reading continues in read-list order from any entry point; an explicit context (opened from that read list) always wins and works regardless of the setting.
- Every non-incognito session with a context records the entry point while the setting is on, including seamless cross-book moves (`updatePresentedBook` records only when the book id changes, not on refreshes of the same book).

### Tap Zones, Chrome & Boundary Feedback

- Single-tap zone actions globally wait out the double-tap zoom window via a static `singleTap.require(toFail: doubleTap)` in every engine; with zoom disabled the recognizer auto-fails, so taps stay immediate. Do not make arbitration zone-dependent — page-turn zones must still honor double-tap zoom, which works anywhere on the page.
- Drag gestures win over single taps: tap recognizers require the engine's pan/scroll recognizer to fail, so a recognized drag cannot fall through as a tap after the gesture ends. PDF uses its explicit movement/duration validation for the same invariant.
- Chrome visibility animates split: opacity on a quick curve (`appCurve`), bar slide on `appSpring`; tvOS keeps conditional insertion so the focus engine never sees hidden chrome, and the always-on mini progress bar keeps its conditional matched-geometry morph. The readability scrim extends past the bars with hit-testing disabled there, and stays gated on `showGradientBackground`.
- A denied first/last-page turn (no adjacent book) answers with a two-phase nudge along the attempted direction plus `HapticFeedback.light()` (tap path `performBoundaryNudge`, cover rubber-band path, scroll flick path); a successful cross-book turn fires the same light haptic. Haptics fire only on discrete boundary crossings, never on continuous motion.
- Paged DIVINA modes show `PageFilmstripView` above the bottom bar's page button while controls are visible (never tvOS, webtoon, or the always-on mini bar). It follows the committed page index — engines expose no per-frame turn progress — is not independently scrollable, jumps on tap, and scrubs pages on drag with `selectionChanged()` per crossed page. Drag tracking is immediate: the strip's offset and its per-page re-centering bypass implicit animation (offset sits outside the `.animation(value:)` scope, re-centering runs in a nil-animation transaction, and the bottom bar's page/progress animations are scoped to the page button and progress bar, never the filmstrip) so the strip never glides behind the finger; only the lens width/opacity animate. Per-page re-centering uses the base item advance, so a crossing can overshoot the finger by a few points while the lens is expanded. The lens grows in both dimensions with its width derived from the page's own aspect (`BookPage.width/height`, 2:3 fallback when missing), so the full page stays visible; only ultra-wide pages center-crop at the lens cap.

## Sync, Offline & Caching

### SSE

- SSE callbacks are single-assignment closures; implement dispatchers when multiple components need the same event.

### Read Progress

- Read progress has a per-session recording threshold (`progressRecordingThreshold`, default 3, 0 = record immediately): for a book that was unread or finished when the session reached it, page-change submissions and the close/background flush are withheld until the position moves at least that many pages from the session's first page, unless the page completes the book. A book already in progress records any page turn, and once a book records in a session it keeps recording; opening a book without turning a page never records.
- All three engines apply the policy through one `ReaderProgressRecordingGate` per book and measure the distance from the session start themselves (DIVINA per book id, PDF by page number, EPUB from the start chapter and page, with both ends recomputed from the current chapter page counts), so an accidental reader open never creates progress or resets a finished book.
- The session start is the page the reader settles on, never a provisional one: the PDF document view reports no page change while it is moving to its start page or a jump target (PDFKit shows the first page meanwhile), and the EPUB reader restarts its start once the saved position is applied to a freshly measured chapter.
- Any path that pulls reading progress after coming online must first `await ProgressSyncService.syncPendingProgress` (it waits for an in-flight push), so a pull never overwrites newer offline-queued local progress.

### Downloads & Caches

- Cancelling a download removes its on-disk book directory; failed downloads keep partial content for resume.
- Series/read-list download rollups (`SeriesDownloadStatus`) fold per-book states into one aggregate: any pending book makes it pending; with nothing in flight, any failed book surfaces as `failed` (red status icon, like the book-level failure state); otherwise downloaded / partially downloaded / not downloaded. Full recounts (`syncSeriesDownloadStatus`, `applyReadListDownloadSummary`) own the rollup including failures; the delta fast paths (`applySeriesDownloadDelta`/`applyReadListDownloadDelta`) adjust only downloaded/pending counts and defer to a full recount whenever a failed state is involved or the aggregate currently is failed.
- Only download/extraction write paths create the on-disk book or instance directory (`bookDirectory`/`offlineDirectory`); read-only lookups go through the non-creating `bookDirectoryURL`/`offlineDirectoryURL`/`webPubRootDirectoryURL` so opening a book never leaves an empty shell behind.
- `OfflineManager.cleanupOrphanedFiles` sweeps every instance namespace under `OfflineBooks/`, skipping in-flight downloads of the current instance: directories of books with no download intent (notDownloaded or no local row) are deleted; pending/failed books keep directories that hold files (resume on retry) and lose empty shells; downloaded books whose directory holds no files are deleted and reset to `.notDownloaded`; empty instance directories are pruned at the end. It runs once at launch after the database opens, after stale-book reconcile during full sync, and from the Offline page's manual action.
- Clearing caches or server data goes through `CacheManager` and the GRDB stores only.
- `ThumbnailCache` is the multi-level cover cache (memory → disk → network): image consumers call `ThumbnailCache.shared.image(id:type:page:)` and get a decoded image without juggling tiers; view initializers seed synchronously from the memory tier via `ThumbnailCache.cachedImage(id:type:)`, so cells recreated by lazy grids/lists never re-read disk or flash the loading placeholder. Cover loads join one in-flight task per key, so a cell recreated while its first load is still running shares the download and decode, and a cancelled cell load still lands its result in the memory tier. A force refresh or cache clear drops the in-flight registration, so a stale decode can never be written back over fresh state. The memory tier (`ThumbnailMemoryCache`, NSCache, per-instance keys, honoring the cover-cache expiration) is its private concern and holds covers only — page thumbnails skip it, since jump sheets stream them by the hundreds and would churn the cover cache out. A successful force download evicts the key centrally, and the cache-clear paths empty it. Cover refreshes must go through `ThumbnailCache.refreshThumbnail` (it posts `.thumbnailDidRefresh`), never a bare `ensureThumbnail(force:)`. File-level consumers (widgets, Spotlight indexing, tint extraction) still use `ensureThumbnail`.
- Queueing a download backfills the book's `KomgaSeries` row from the server when missing (`OfflineManager.ensureSeriesRow`, with a `startDownload` backstop): single-book download entries don't guarantee the series row, and Offline series browse plus series download rollups query the series table.
- Deleting downloads is staged behind an undo toast, on every entry path: the Offline pages (downloaded-book swipe, group Delete All, Remove All/Read, task-row cancel), series/read-list detail download sections and context menus (`removeSeriesOfflineWithUndo`/`removeReadListOfflineWithUndo`), and single-book download toggles (`toggleDownload` → `deleteBookWithUndo` for downloaded books, `cancelDownloadWithUndo` for pending ones; toggling a staged book cancels its pending deletion instead). `OfflineManager` holds `PendingDownloadDeletion` records and the Offline pages filter staged books out of their snapshots, so rows vanish immediately while data stays intact. The commit fires when the toast settles (timeout, replacement, swipe-away) and deletes exactly the books captured at staging — anything downloaded during the undo window survives; Undo runs the toast's cancel closure, which just drops the record (the books were never touched), and an explicit (re)download of a staged book cancels its pending deletion the same way. Background flows (auto-delete read books, sync cleanup) never stage.

### Local Database

- Runtime GRDB migrations in `LocalDatabase` are immutable once committed: never mutate an already-registered migration (e.g. `create_runtime_schema_v1`, `00002_add_protected_server_flag`) or its helpers; they are the frozen baseline. Any table shape change is a new numbered migration after the latest one; fresh installs run baseline + all later migrations in order.
- New persisted field: update the record model and `CodingKeys`, then add a migration backfilling a safe default. Validate both upgrade and fresh-install paths.

### Read List Reading State

- Read list reading state (`read_list_reading_states`) is user state, kept apart from the `KomgaReadList` server mirror.
- It syncs through Komga's per-user client settings, one key per read list (`kmreader.readlist.<lowercased id>`; keys must match Komga's lowercase namespace pattern), piggybacking on the reading-progress catch-up. Requires Komga 1.20.0+, the app's minimum server version.
- Reconcile pulls first and keeps, per read list, whichever change happened last on any device — a read or a stop (a stop records its time on its `isStopped` tombstone) — then pushes the local changes that won, so neither a pull nor an offline stop discards a newer change.
- Remote states are applied and pending changes pushed only while the sync's instance is still current; a server switch mid-sync drops the round.
- Only ordered read lists count as being read, including for Stop Reading, whatever another device synced.
- The local snapshot loads with each instance in `ContentView`'s per-instance startup task, independent of the network catch-up (which is offline-gated and debounced), so continuation works on offline launches and with On Deck hidden.
- While the setting is off, `ReadListReadingService` records, syncs, and resolves nothing and publishes an empty snapshot (no local reads or writes, no client-settings requests).

### Reading Stats

- Reading stats prefer the server's `/api/v1/stats/reading/*` endpoints (kmrs 0.17.0+; absent on Komga) and fall back to local GRDB aggregation. The first fetch of the three endpoints doubles as the capability probe: a 404 from any of them records the instance as unsupported and the same load is then served locally. Per-instance verdicts live in `AppConfig.serverReadingStatsCapability` (UserDefaults); an unsupported verdict expires after 24h so a server upgrade is picked up automatically, and offline mode never probes. The fetch result carries its `ReadingStatsDataSource`, persisted on the cached snapshot, so the view can hide local-sync hints whenever the data came from the server.

### Smart Lists

- Smart lists (`/api/v1/smart-lists/*`) are kmrs-private saved searches evaluated live server-side, absent on Komga (the whole API 404s). Support is probed per instance: the first `SmartListsViewModel` load doubles as the probe, a 404 records `ServerSmartListCapability` in `AppConfig.serverSmartListCapability` for 24h (the same verdict pattern as komf/reading stats), and a marked-unsupported instance skips the doomed request.
- Smart lists are DTO-driven and online-only: the lists themselves are NOT mirrored into GRDB (no tables, no pinning, no offline branch), so the view model holds plain DTOs and re-fetches on `.smartListsDidChange`. Fetched members DO upsert into the books/series tables like every other fetched page (`SyncService.syncSmartListBooks`/`syncSmartListSeries`).
- Member loads POST a `BookSearch`/`SeriesSearch` overlay that narrows the stored filter server-side, built from the browse options via `BookBrowseOptions.toSearchFilters()`/`SeriesBrowseOptions.toSearchFilters()` + `buildCondition` (the same mapping the browse endpoints use; scoping ids stay with the caller).
- Remote smart-list SSE events debounce (5s) through `ListProjectionSyncService.scheduleSmartListSync`, which posts `.smartListsDidChange` per accumulated id — there is no projection to sync, but observers filter the post by smartListId, so ids accumulate like the other families. `SmartListThumbnailChanged` force-refreshes the cover through `ThumbnailCache.refreshThumbnail(id:type: .smartList)`. Remote read-progress SSE events debounce separately into `.smartListMembershipDidChange` (no id — any read-status-based list may move), which member lists revalidate on.
- UI surfaces: a third strip on the Lists page (`ListsBrowseView`, hidden when unsupported/empty/offline — the library scope does not filter it), the full `browseSmartLists` page (`SmartListsBrowseView`, client-side name search, no sort/filter/library scope — the endpoint is name-sorted with no query params), and `SmartListDetailView` (browse + read + edit/delete for owner/admin; no pin, download, or selection mode). Create/edit goes through `SmartListEditSheet` (entry points: the browse page's trailing + and its empty state, the detail-page menu, card/row context menus): the target is fixed after creation, no sort is stored (chosen at browse time — the options sheets take `showsSort: false`, which also hides preset saving since presets write the shared browse-options keys), and visibility/share-target picking is admin-only (non-admins always create private lists). On update the sheet sends `search` only when the filter subsection diverges from its parsed baseline, and the reverse mapping's `lossy` flag shows a warning that any filter change replaces the whole stored document — web-UI-built filters the editor cannot represent survive a rename. Member lists revalidate on every book/series projection change and on `.smartListMembershipDidChange`, never gated on `isSensitiveToReadingProgress`: the stored server-side filter may be read-status-based even when the overlay options are not.

## Browse & Dashboard

### Library Selection

- Two separate concepts share the library scope UI: the **pinned set** (`dashboard.libraryIds`, empty = all libraries) is the persisted aggregate scope — per-instance via `DashboardLibrarySelectionStore` (UserDefaults `dashboard` config + GRDB `komga_instances.selected_library_ids_raw`), edited through the checklist in `LibraryPickerSheet` and consumed by the Dashboard, the iPhone Library/Search tab roots, Offline, search, and widgets. The **session scope** (a `LibraryBrowseScope` of `.pinned`/`.all`/`.library(id)`, default `.pinned`, resolved via `resolvedIds(pinned:)`) is per-surface view state: the Dashboard keeps it in `DashboardLibraryScopeStore.shared`, the iPhone Library/Search tabs, Offline, and pushed browse pages keep it in local `@State`, and iPad/macOS browse roots take it from the shell (next bullet); never persisted, the dashboard's resets in `DashboardLibrarySelectionStore.loadSelection` (app start and server switch) and when the scoped library vanishes from the loaded list (browse pages reset the same way); dashboard sections fetch with `effectiveLibraryIds(pinned:)` while widgets always follow the pinned set.
- Scope identity renders in two forms, both fed by `LibraryBrowseScope.title(pinnedIds:libraries:)` / `.facts(pinnedIds:libraries:allLibrariesEntry:)` (the shared title text and covered-metrics item; metrics are admin-only: `.all`/empty-`.pinned` read the server's all-libraries entry since overlapping roots make client-side sums overstate file size, a non-empty `.pinned` sums the covered libraries client-side, `.library(id)` uses the library's own row). The **Dashboard** shows `LibraryScopeHeader` (Dashboard/Views): icon matching the scope menu (`books.vertical` for All/a library, `pin` for Pinned), the title, and a single truncating facts line (file size first, then series/books — no sidecars — via `LibraryMetricsText.sizeAndMetrics`). **Browse pages and Offline** instead show a caption trailing the content-type chip — the scope title set off from the facts that follow in secondary: just the file size on browse pages (the series/books counts ride the chip itself in parentheses, from the same scope facts item; collections/read lists carry no counts), just the total downloaded size on Offline (the downloaded series/books counts ride the chip in parentheses, from `fetchDownloadedSeriesCount` and `fetchOfflineBooksStats(instanceId:libraryIds:)` — both matching their offline list filters, refreshed on scope/instance change and on `DownloadProgressTracker.queueUpdateToken`). `LibraryScopeStore.refreshMetrics` reloads the metrics (admin-gated, no-op offline) on every dashboard refresh and browse-page library refresh; full reloads stay with `LibraryPickerSheet` and Settings → Libraries. Library metrics prefer the kmrs-native stats endpoints (`/api/v1/stats/libraries` for per-library counts and the all-libraries total) and fall back to the Komga actuator metrics (`komga.books.filesize`/`komga.series`/`komga.books`, untagged totals plus per-library tags) when they 404 — the fallback covers only library size/series/books, so sidecar counts simply never appear on Komga; per-instance verdicts live in `AppConfig.serverStatsCapability` (24h recheck, the same probe pattern as reading stats), and the stored numbers are cleared only when both sources fail, so previously stored numbers never linger. Server metrics come only from `/api/v1/stats/server` — task, process, and global stats — with no actuator fallback. Sidecar counts (per-library and total) ride the `/stats/libraries` payload but are only sent to admins; the payload carries no collections/read lists counts, and library-level RL/C counts are not surfaced anywhere. The Server Info page works on both servers: on kmrs it loads `/stats/server` (process snapshot, content totals, per-type task metrics) plus the actuator info/health endpoints, and on Komga the same rows fall back to per-metric actuator reads (`process.*` for CPU/uptime/start/memory — CPU percent vs 0-1 ratio told apart by the meter's `baseUnit` — and the `komga.*` gauges for content); the shared `/stats/server` 404 probe (`AppConfig.serverStatsCapability`) picks the path. The overview shows server identity (version, commit, OS), process and resources (start time, uptime, CPU, memory, disk), and content totals, plus maintenance actions (log file download via share sheet, hidden when the HEAD probe 404s as on stock Komga; server shutdown behind confirmation); the task queue, sessions, and scheduled tasks are sub-pages — Tasks pairs the SSE live queue and Cancel All with the per-type execution metrics (runs/total/longest/failures, from `/stats/server` on kmrs and the `komga.tasks.*` meters on Komga), Sessions lists `/actuator/sessions` with per-session kick (kmrs lists all users, Komga rejects the cross-user listing and falls back to the current user's `?username=` query), Scheduled Tasks lists the actuator fixed-rate registrations (Java FQN targets shortened to their meaningful tail). A failed load keeps the previously stored numbers.
- Browsing a single library is navigation on all platforms (the sidebar/tab bar selects `NavDestination.browseLibrary`). On iPad/macOS the Libraries section leads with the aggregate entries — "All Libraries" (`NavDestination.browse(scope: .all)`) and, only while the pinned set is non-empty, "Pinned" (`.browse(scope: .pinned)`) — inside the same section as the individual libraries, because the tab bar's Libraries item opens the section's first tab (a separate aggregate section would land on the first library instead); the aggregate rows are plain text like the library rows (no icons, no separator — the iPad `TabSection` accepts only tabs), and the shells redirect a live pinned-aggregate selection to All when the last pin is removed. Browse roots on those platforms take their scope from the shell through the `\.libraryScopeBinding` environment key (a value-type `Binding<LibraryBrowseScope>` provided by `PadTabView`/`MainSplitView`): the scope menu writes through to the shell's selection, so an in-page pick switches sidebar row/tab and the highlight follows. Pushed browse pages get the binding cleared to nil (in `View+Navigation`) and keep a page-local session scope, the pushed selection only its initial value; on iPhone, pushed single-library pages carry no scope menu.

### Selection Mode

- Selection mode exists on the series detail books list, the books and series browse pages, and the read list/collection membership lists (remove-only, admin). Entry is a trailing `checkmark.circle` button next to the filter chip row; tvOS always disables it (`supportsSelectionMode`), and the online-only modes hide it while offline.
- Two toolbars: `SelectionActionsToolbar` (select all, mark read/unread, one list-membership add action, cancel) for the books/series lists, and `SelectionToolbar` (remove-only) for read list/collection membership, which keeps full selection disabled so the list can never be emptied through it.
- Select-all scope: detail lists cover full membership from GRDB; browse pages cover only the loaded window (kmweb parity).
- The membership pickers (`ReadListPickerSheet`/`CollectionPickerSheet`) take id arrays, not single ids; a row is `alreadyIn` (disabled) only when it contains every selected id. Batch add goes through `ReadListService.addBooksToReadList`/`CollectionService.addSeriesToCollection` (GET + full-replace PATCH), then syncs and posts the projection change.
- Batch mark read/unread runs per-item requests in a task group, then re-syncs the succeeded items (series-scoped lists: `syncSeriesDetail`/`syncAllSeriesBooks` + `postSeriesBooksDidChange`; the cross-series books browse: `syncVisitedItems` + `postBooksAndSeriesDidChange`), followed by one `DashboardSectionRefreshNotifier.postReadStatusChanged` and a full list reload.

### Page Structure

- Browse pages split shell from content: `BrowseContentView` is the scrollable content (optional library header, content-type menu, search placeholder, per-type lists) and owns no chrome; the content-type switcher is `BrowseContentTypeMenu`, a leading-aligned chip-style dropdown (current type icon + name + chevron, same chrome as `LayoutModeMenu`/`FilterChip`), never a segmented picker. `BrowseView` is the standalone-page shell around it (search field + `onSubmit` query state, toolbar, navigation title, refresh triggers), the Dashboard search overlay is `DashboardSearchResultsView`, which feeds the Dashboard's submitted query straight to `BrowseContentView`, filtered by the dashboard's session scope, and the Lists page's single-type detail pages use `ListsBrowseDetailView` (Collections/Read Lists/Smart Lists fixed content, scope menu except smart lists, layout/filter actions, inline navigation title) — split from `BrowseView` so that shell keeps only the tab-root and search modes plus metadata-filtered series/books pages. The overlay appears only once a query is submitted — there is no placeholder state on iPad/macOS; while typing or after clearing the field, the dashboard stays visible. Embedded content never gets its own search field or toolbar — they would duplicate the enclosing page's chrome. Every shell wires real filter-sheet state into `BrowseContentView` (the in-content filter bars drive those bindings; a `.constant` leaves the sort/preset chips dead), and the effective content type resolves through `BrowseContentType.effective(fixed:offered:persisted:)`.

### Pagination & Ordering

- Browse pages paginate with `PaginationState(pageSize: 50)`. Notification-driven refreshes revalidate the loaded window in place via `PaginationState.replaceItems`; full `pagination.reset()` is reserved for initial loads and explicit user actions.
- `.task`/`.task(id:)` re-runs when a view re-appears after a pushed navigation child pops back to it, so initial-load tasks (detail pages and their book/series list views) guard on a `loadedXxxId` state key and skip re-fires for the same id; returning from a child must not re-sync or reset pagination.
- User-facing metadata lists (authors, publishers, genres, tags, languages) sort with `Collection.localizedSorted()`; authors via `Author.sortedByRole()`. Never revert to raw `.sorted()`. (`MetadataIndex` encode keys and SQL clause ordering intentionally keep plain `.sorted()`.)
- Online ordering is server-side; the app-local pinned flag is invisible to the server, so online pages prepend pinned items and filter them out of the server stream.

### Empty & Error States

- Empty and error states render through `ContentUnavailableView` (icon label, description, actions) — never hand-drawn icon + text stacks. Browse lists go through the shared `BrowseStateView` (loading/empty/content wrapper), which owns the `ContentUnavailableView` empty state internally; retry actions use `.adaptiveButtonStyle(.borderedProminent)`. Search-with-no-results uses `ContentUnavailableView.search(text:)`.

### Dashboard Rows

- Section chrome is shared: `DashboardSectionLayout` owns the gradient band, the header navigation link (title + chevron, optional `DashboardCardKindMenu`), the horizontal card strip, and the empty-collapse (`opacity` + zero height while `isEmpty`). Item mutations animate through `withAnimation` in the loading view models (`applyPage`/`removeItem`/cache seed), which drives both the band's collapse/expand and the strip's per-item interpolation. The band's vertical rhythm comes from `LayoutConfig` — top padding above the header (`dashboardSectionTopPadding`) and bottom padding below the cards (`dashboardSectionBottomPadding(gradientBackground:)`, which shrinks to the top value without the gradient since the band edge is invisible), a tighter header-to-cards gap (`dashboardSectionHeaderSpacing`), and no inter-band spacing, so adjacent gradient bands touch and each band's gray gradient edge is the section separator (Apple Books style). The scope header (`LibraryScopeHeader`) is a plain row above the bands, not a band itself; its padding stays minimal so it reads as a caption under the navigation title, not as another section. On macOS the strip's vertical padding sits outside the ScrollView so the hover scroll arrows' (`macHorizontalScrollButtons`) overlay hugs the cards — iOS/tvOS keep it in the scroll content so the padding area still drags the strip; the arrows center on the cover block, which `DashboardSectionLayout` derives from the section's effective card kind (grid cards carry metadata below the cover, horizontal cards are cover-tall and center on the strip).
- Strips on the Dashboard and Lists outer pages show the first page only — there is no in-strip pagination; deeper browsing lives in the section's detail page. Besides the header chevron, pulling a strip past its trailing edge and releasing pushes the same destination (`trailingOverscrollTrigger`, iOS 18+ only: a chevron hint arms past the threshold with a haptic and fires on release; the header link stays the entry elsewhere). The push goes through the `\.pushNavDestination` environment action published by `PushableNavigationStack` at each tab/split stack root, so an overscroll push resolves through the same `navigationDestination(for:)` mapping as the header link.
- Dashboard progress sections always revalidate on `.readingProgress`; other sections skip unless browse options are progress-sensitive (`isSensitiveToReadingProgress`).
- Dashboard rows load through the central `DashboardViewModel` that `DashboardView` owns, never in a view `.task` and never in the section views: switching the macOS split-view sidebar back to Home adds, removes, and re-adds the dashboard within milliseconds with its state kept, which cancels view-scoped loads mid-request. Section views are pure renderers with no lifecycle or notification modifiers of their own — loads and reloads must not depend on a section view's lifetime. `DashboardView` starts loads (`ensureLoaded`) on appear and when the section list changes, awaits the view model directly for its own refreshes, and observes `.dashboardSectionsShouldReload` once at the root, dispatching commands (`applyReloadCommand`) with the rendered sections and effective library ids; the read-lists-in-progress section is special-cased there (`ReadListReadingService` sync/refresh).
- Appearing starts a load only for sections where none has completed or is running; a reload supersedes a load in flight (newest wins) instead of being dropped. Do not reintroduce load-once flags or `isLoading` guards that drop a re-run.

### Pull-to-Refresh

- `.refreshable` closures must await the reload they trigger, so the refresh control dismisses onto settled content instead of racing in-flight view updates.
- An active refresh action is cancelled as soon as the view carrying `.refreshable` re-renders, taking the action's child tasks (and their network requests) down with it. The refreshable-carrying body must therefore read no state the reload mutates: refreshing view models stay on the shell, while the content that reads them is a child view (`ListsBrowseView` shell + `ListsBrowseContentView` strips). The Dashboard's body DOES read `scopeStore` while `refreshDashboard` mutates it — it survives because `LibraryScopeStore.load()` skips no-change assignments, so in practice the body does not re-evaluate; keep that guard intact when touching the store. Its body must likewise never read `DashboardViewModel`'s section state — only the section views may. Re-evaluating a child's body during the refresh is fine — only the carrier's body matters.
- Dashboard manual refreshes await the central `DashboardViewModel.reload` directly (refreshable dismisses onto settled content, no per-section acknowledgement). `DashboardRefreshCoordinator` remains only for auto-refresh debounce (5s) and reader-session deferral of auto/projection refreshes; externally posted reload commands (reconnect, SSE auto, projections, read-status changes) arrive as notifications the dashboard root observes once. A dashboard that is recreated later loads fresh on appear, so off-dashboard refreshes (e.g. the Settings/Server offline toggle) are not lost.
- The Dashboard has no toolbar refresh affordance at all — pull-to-refresh covers iOS/macOS and tvOS keeps its header button (disabled while a refresh is in flight). Pages whose reload can finish near-instantly (Dashboard, Offline, Lists) use `refreshableWithMinimumHold` so the control stays up for a minimum visible time instead of snapping back.
- On iPhone the Offline page pins its search bar (`navigationBarDrawer(displayMode: .always)`): in automatic mode the drawer's hide/reveal animation fights the refresh control during the pull. iPad/macOS keep `.automatic` — their search field lives in the toolbar and never conflicts.
- `OfflineView` awaits the browse view models it owns and shares with `OfflineSeriesBrowseView`/`OfflineBooksBrowseView`, whose `refreshBrowse()` re-runs the view model's current query; library-selection and account-switch reloads instead bump a `refreshTrigger` passed to those child views, so the reload runs through the child and captures the current `libraryIds` rather than replaying the view model's stale query.

### Read Lists in Progress

- Read lists continue like series: only ordered read lists with reading state and a book to continue with take part (a book in progress, or once a book is finished the next unread one, searching forward from the last book read).
- They surface only in their own `readListsInProgress` dashboard section, first by default, driven directly by `ReadListReadingService.continuations`; On Deck and Keep Reading stay Komga-native, never merged or filtered.
- `ReadListsInProgressSectionView` renders one card per list, most recently read first, with no pagination or detail page; the header links to the read lists browse page.
- Cards follow the section's card kind. `ReadListContinuationHorizontalCardView` is the default: it shares Keep Reading's horizontal-card chrome through `HorizontalCardSkeleton`, and its text column follows the horizontal-card contract — semibold title line (the list's name), primary book line, secondary progress line.
- `ReadListContinuationCardView` renders large/small, with the list's name in the series slot, cover-only when small.
- Both share `ReadListContinuationContextMenu`, `ReadListContinuationProgressText`, and `ReaderActions.open(continuation:)`.
- The library scope hides a card by its continuation book's library without changing which book a list resolves to.
- The section is opt-in: it is not in `DashboardSection.defaultSections`, the setting's toggle (`SettingsReadListContinuationToggle`) adds and removes it, `DashboardSection.isAvailable` hides it from the settings lists and Reset while off, and `DashboardView` renders it only while on.
- While off, ordered, non-empty read list pages show `ReadListContinuationHintView` pointing to the setting, injected through the detail view's `actions` slot.

### Section Downloads

- Dashboard section offline downloads live only on the section detail page (`DashboardSectionDetailView`), as a single download menu (toolbar on iOS/macOS, inline on tvOS) shown for `supportsDownloadLatest` sections: every such section offers queueing the latest 20 books, and Keep Reading / On Deck (`supportsDownloadAll`) additionally offer queueing the whole section page by page. The Dashboard home toolbar carries no download actions.

### Cards

- Card sizes are fixed per platform and layout mode, calibrated against Apple Books (`LayoutConfig`); there is no free-form density slider.
- Card text styles and the corner badge size are not fixed: they scale with the card width via `LayoutConfig.cardTextStyle(cardWidth:)`/`cardBadgeSize`, and card views receive their width (`cardWidth`, defaulting to `gridCardWidth`) rather than a style flag.
- Large and small grid cards share the `GridCardView` skeleton (cover with badge and optional text overlay, progress bar row, text block), which owns the card preferences; each card supplies only its badge, menu, and status line. Collection and read list grid cards build on the same skeleton, so their text styles scale with `cardWidth` like book/series cards.
- Dashboard sections render one of four card kinds: `large` (fresh content showcase: on deck, recently released/added books, recently updated series), `medium` (midway width, text below the cover like large), `small` (library activity/history: recently added series, recently read books), `horizontal` (Keep Reading books, read lists in progress).
- `DashboardSection.cardKind` is only the default — the user can override any books/series section, and read lists in progress, from the menu at the trailing edge of its header row (`DashboardCardKindMenu`, shared by the section views; books sections and read lists in progress, whose cards are each list's next book, offer large/medium/small/horizontal, series sections large/medium/small), persisted in `DashboardConfiguration.cardKindOverrides` (overrides that are no longer offered are ignored). The menu itself can be hidden via the Dashboard settings toggle (`showDashboardCardKindMenu`, on by default); hiding it keeps the chosen overrides in effect.
- Section list edits in Settings (show, hide, reorder, Reset) change only `sections`, keeping the overrides and the library selection; 6.4's Keep Reading toggle (`dashboardHorizontalBookCards`) is carried over once at launch (off → Keep Reading `large`).
- Small cards are cover-only (`coverOnly`): every text line truncates at that width and stops carrying information, and card text overlay mode never renders on them.
- Cover corner badges are gated on `thumbnailShowUnreadIndicator`: series cards show the unread count, book cards show a completed checkmark — books have no unread dot. The badge animates count changes with `.contentTransition(.numericText())` and bounces only when the count increases; its font uses monospaced digits (`.monospacedDigit()`), so same-length counts never resize it. The completed badge shares the count badge's exact height and square shape through a hidden digit that supplies the same line box, with the checkmark glyph overlaid on top. Appear/disappear rides the parent's insertion transition (`SeriesCardView` animates `count > 0`) — never an `onAppear` bounce, which replays every time a lazy container recycles the view.
- Loading placeholders sweep a shared shimmer band (`.shimmer(cornerRadius:)`): the singleton `ShimmerClock` keeps every visible skeleton in lockstep and stops when the last one leaves, so off-screen skeletons cost nothing; Reduce Motion falls back to the static placeholder. A failed load stays static — shimmer reads as a load still in flight. The thumbnail's overlay slot (text overlay, corner badge) renders on the placeholder as well, so a card stays identifiable while its cover is still loading.
- Horizontal cards share the `HorizontalCardSkeleton` chrome — the cover-tinted rounded background with its tint loading, the single cover+text button, and the trailing accessories (a passive download status indicator and an `EllipsisMenuButton` mirroring the card's context menu, both at `LayoutConfig.horizontalCardAccessoryIconSize`); each card supplies only its text column and menu. The text column distributes vertical slack evenly across every gap (above the title, between the lines, below the bottom bar, with a small minimum above the series line): a two-line title fills the card (title flush to the top, bottom bar flush to the bottom), while a one-line title spreads the freed space across all gaps instead of letting one area go empty. Lines follow Apple Books ordering — semibold title on top, series line under it, meta (progress/status) at the bottom — stepping down in size from the title (`LayoutConfig.horizontalCardFontSize`); the cover height matches the resulting four-line text column so the card is no taller than its text. The title and series line use the primary color (the title turns secondary once completed); only the bottom bar is secondary. Horizontal cards sit on a cover-tinted background, Apple Books style: the cover's average color, brightness-clamped so white text stays readable without the card going pitch black (`ThumbnailTintColorCache`, loaded per card via `ThumbnailTint`, which also reloads on `.thumbnailDidRefresh`); until the tint resolves the card falls back to the `cardBackground` color asset with the adaptive colors above. On the tint all text is white, the series line and bottom bar dimmed (the title dims once completed); cover and text form one button (one tvOS focus target), while the trailing accessories stay separate targets, Apple Books style.
- Book cards open the reader directly on tap — grid cards, list rows, and horizontal cards alike; the book detail page (oneshot detail for oneshots) is reached through the context menu's Details entry (`Book.navDestination`), which leads the menu alongside Peek (incognito) and series navigation. Series cards navigate to their detail page on tap.

## Detail Pages

### komf Integration

- komf endpoints (`/api/v1/komf/*`) are kmrs-private and admin-only, absent on Komga servers. Availability is probed per instance by `KomfIntegrationStore`; a 404 records `ServerKomfCapability` in `AppConfig.serverKomfCapability` for 24h. The probe runs once per instance at ContentView's per-instance startup and again on detail-page visits, latching only a `connected` state (and the 24h negative record) so a later connection is picked up on the next visit; probes are fire-and-forget and iOS/macOS-only. Menu items render only while connected.
- komf actions live in a `Komf` submenu (verbatim title, product name): the series and oneshot detail ellipsis menus carry Identify / Match / Reset Metadata (iOS/macOS only), and `SeriesContextMenu` carries a sibling Komf submenu next to Manage with Identify / Match (Reset stays on detail pages, which already own the confirmation alert). Identify sheets are plumbed through `onKomfIdentifyRequested` like `onEditRequested`.
- `KomfIdentifySheet` parses provider links from `series.metadata.links` (oneshot: the single book's links when the series has none) into `KomfProviderLink` submissions, mirroring kmweb.
- Jobs are tracked by polling in `KomfJobTracker` (kmweb uses the SSE firehose; the app only needs its own jobs). Tracking is bound to the submitting instance — a server switch stops polling silently. Completion runs the same refresh chain as an edit save (`SyncService.syncSeriesDetail` → `ContentProjectionNotifier.postSeriesDidChange` → `DashboardSectionRefreshNotifier.postSeriesContentChanged`); server-side SSE events already cover projection updates while SSE is on.

- Detail page header zones follow the measured content width: hero, action card, and timestamps center below `LayoutConfig.detailWideLayoutMinimumWidth` and lead above it, driven by the `detailHeroCentered` environment value injected by each content view (each measures its own width via `onGeometryChange`; wide two-column rails inject `true`); the content sections below (summary, chips, media information, membership sections) always stay leading. Pages follow a fixed hierarchy: hero (cover with title, author chips, and quiet `DetailMetadataRow` facts), a `DetailActionCard` grouping reading state with primary actions (the page's single focal point; the series continue-reading button and `SeriesDownloadActionsSection` are injected by `SeriesDetailView` through the content view's `actions` slot), summary, `DetailChipFlowSection` chips for relational metadata (genres/tags/publisher/external links), headline sections (alternate titles, `BookMediaInfoSection` media information, `DetailMembershipSection` membership rows).
- On book detail pages (book and oneshot) the hero's series title is the series navigation — a plain icon + title + chevron `NavigationLink` styled like the dashboard section headers, hidden when the page is presented in a sheet (the sheet's own book/series toggle covers it); there is no View Series button. The action block leads with the shared `ReadingActionButton` — a prominent capsule on its own row filling the action card, a two-line label (Start Reading, or Resume Reading while the book is in progress, over a detail line with the reading progress Page X · Y%, falling back to the page count), the same component the series continue-reading button uses — with Peek and the download toggle as a secondary row beneath; the whole block centers below the detail wide-layout minimum width and leads above it (where the download status chip sits at the trailing edge of the card's state line).
- Headline-section titles are plain `Text(...).font(.headline)` in primary color with no icon; an icon-tinted secondary header reads as another row of the section above. Membership sections render nothing while empty and carry their own top padding to separate from the block above.
- Alternate titles render through the shared `SeriesAlternateTitlesView` (series detail in both layouts, and the oneshot detail page); more than 2 entries collapse behind a Show More/Show Less toggle. Membership sections (`DetailMembershipSection`: read lists on book/oneshot pages, collections on series/oneshot pages) likewise collapse beyond 3 entries. The toggle is the shared `ExpandToggleButton`, also used by `ExpandableSummaryView`.
- `DetailTimestampsView` sits directly under the `DetailActionCard` (ReadList/Collection cards carry the count line — `ReadListBookCountView`/`CollectionBookCountView` — with `ReadListDownloadActionsSection` injected through the read list content view's `actions` slot).
- Chips use `DetailChip`: a small neutral capsule with no per-field colors; hero creator chips (publisher/authors) and external-link chips render with `glassEffect` on iOS/macOS/tvOS 26+ (falling back to a quiet secondary fill on older OS), while genre/tag chips always use the secondary fill (`glass: false`); icons only where they carry meaning (author role, external link). Neutral fills (chips, action cards, membership rows, count badges) share one constant, `LayoutConfig.neutralFillColor`.
- State color appears only as text: green/orange/red and `MediaStatus.detailColor`/series `statusColor` in the action card; static metadata stays secondary.
- The scrolling content always carries `.padding(.vertical)` (horizontal padding stays per-section); in the wide two-column layout each column's `ScrollView` content pads itself.
- Detail-page ScrollViews use an eager `VStack`, never `LazyVStack`: the only lazy content is the books/series list, which carries its own lazy containers, and a `LazyVStack` transiently misplaces children during animated section updates.

## Platform UI Placement

### Tabs & Navigation Entries

- Series continue-reading accessory: `tabViewBottomAccessory(isEnabled:)` in `PhoneTabView` (iOS 26.1+ only), modifier permanently attached with `isEnabled` toggling visibility; other platforms use the inline `SeriesReadingActionButton`. Do not reintroduce the floating `safeAreaInset` bar. The reading target is resolved from the local projection first (presented as the page appears) and only confirmed against the server after the detail sync, so the bar never pops in late with fallback content.
- iPhone has no Server tab: the current-server card, Libraries/Account, and admin-only management entries (Server Info/History/Media) live inline in `SettingsView` on iPhone only; iPad/tvOS keep the full `ServerView`, whose Management tiles are likewise admin-only. iPhone also has no Settings tab — the tab bar caps at five entries, and Dashboard/Library/Lists/Offline plus the system Search tab fill it — so the Settings entry is the Dashboard's trailing gear button, pushing `NavDestination.settings` on the home stack (macOS opens the Settings scene instead; iOS 17's `OldTabView` follows the same structure; the Settings tab stays only on tvOS).
- iPhone Library tab root is `LibraryBrowseView`, a thin wrapper over `BrowseView(libraryTab: true, libraryIds:libraryTabScope:)` holding the tab-local `LibraryBrowseScope` state; the scope re-filters the root in place (no push). The iPhone Search tab root is `SearchBrowseView`, the same wrapper shape over `BrowseView(searchOnly: true)` (menu only, no scope header). The iPhone Lists tab root is `ListsBrowseView`: Collections, Read Lists, and Smart Lists as horizontal strips (pinned items first, fed by the same `CollectionsViewModel`/`ReadListsViewModel`/`SmartListsViewModel` as the full pages), whose section headers link to the single-type `browseCollections`/`browseReadLists`/`browseSmartLists` pages; the library scope filters the collections/read lists strips (smart lists have no library filter). It splits shell from content like the Dashboard: `ListsBrowseView` owns the view models and carries `.refreshable`, while `ListsBrowseContentView` reads them and renders the strips, so the refresh action survives the reload (see Pull-to-Refresh). iPad/macOS roots open the same page via `NavDestination.browseLists` (whose `libraryScopeBinding` is nil, so its scope stays page-local on every platform, unlike the shell-synced browse roots), and tvOS gets its own Lists tab; Dashboard section headers still push the single-type pages. Remote collection/read-list SSE events debounce (5s) a full projection sync through `ListProjectionSyncService`, since no dashboard section owns them. `LibraryScopeMenu` is the shared scope dropdown on the Dashboard, Offline, the iPhone Library/Search tabs, and every iPad/macOS browse page (All / Pinned / single-library scope switch + Pin Libraries, driven by a `LibraryBrowseScope` binding — shell-owned on iPad/macOS roots, page-local elsewhere), always shown regardless of library count, a single library titled with its name. It is a filter, not a page title: icon-only on iOS and macOS (the tvOS dashboard header shows the scope title instead), on iPhone at the trailing toolbar edge immediately left of the actions menu (tab roots and pushed browse pages alike), on iPad and macOS at the leading edge (`.cancellationAction` / `.navigation`). The icon is the lines glyph (`line.3.horizontal`, near-square so the glass capsule stays round) for all libraries, `line.3.horizontal.decrease` for a single library (pinned or scoped), and `checklist` for a multi-library pinned subset; the scope name is carried by the accessibility label and hover tooltip. Inside the menu, individual library rows never carry icons — glyphs are reserved for the All/Pinned entries (`books.vertical` for All Libraries, `pin` for Pinned; Pinned is offered only while pins exist), separated from the library rows by a divider. Content-type switching is always the `BrowseContentTypeMenu` chip (left-aligned), never a segmented picker — Offline's series/books switch included. The chip only ever offers series/books: collections and read lists browse lives on the Lists page (present on every platform, tvOS included), and RL/C search lives on the full single-type pages.
- `NavDestination.browseLibrary` carries its `LibrarySelection` in the destination value; do not reintroduce side channels into `BrowseView`. Explicit library scopes (the tab root) go through `BrowseView(libraryIds:)` — never closures in the environment. The iPad/macOS shell-to-page scope sync uses the `\.libraryScopeBinding` environment key (a value-type binding, not a closure): the shell maps scope writes back to its own selection, including the aggregate All/Pinned entry.
- Deep links route through the shared `deepLinkRouting(selection:path:home:downloads:search:searchDestination:)` modifier (`View+DeepLinkRouting`), attached by every tab/split shell (`PhoneTabView`, `PadTabView`, `OldTabView`, `TVTabView`, `MainSplitView`); it consumes `DeepLinkRouter.pendingDeepLink` on appear and on change. Book/series links select home and replace the home path with the destination in a single assignment — reset and push must stay atomic, a deferred append after a reset races the stack rebuild and can drop the push. Shells without a search tab (iPad) pass `searchDestination` to push the search page on the home stack instead of selecting a tab.
- iPad (iOS 18+) uses `PadTabView`, a `sidebarAdaptable` TabView: Home/Offline, a Libraries `TabSection` with one tab per library (sidebar-only, title-only — a repeated library icon on every row reads as noise; keyed by library id so count updates never invalidate the selection), then Lists, Server, and Settings as plain tabs. No search tab — iPad/macOS search lives on the Dashboard as `.searchable` with automatic placement (a toolbar-trailing icon that expands into a field), with results in a `DashboardSearchResultsView` overlay driven by the submitted query, so the Dashboard stays mounted underneath and cancelling never reloads sections. Results re-query only on submit: field text and submitted text are separate states joined by `onSubmit(of: .search)` attached next to `.searchable` — the same handler inside a descendant never fires. In the sidebar, `TabSection`s always render after every plain tab regardless of declaration order (libraries sit at the bottom — the system idiom), and tab sidebars render no count badges (`.badge` does not bridge to sidebar rows), so the iPad drops the split-view sidebar's count pills. Each tab keeps its own `NavigationStack`, so tab switches preserve per-tab state — no `.id(nav)` root recreation. Library admin actions (scan/analyze/…) are not on the tab rows; they live in Settings → Libraries (`LibraryRowView`). macOS and iOS 17 iPad keep `MainSplitView` + `SidebarView` with the same entry set, the expandable Libraries section, per-library context menus, and sidebar refresh (pull-to-refresh on iOS, a bottom refresh button on macOS). iPad users on iOS 18+ can opt back into that split-view shell with Settings → Appearance → Classic Navigation (`useClassicPadNavigation`, off by default — `ContentView` picks `MainSplitView` over `PadTabView` while it is on, and the toggle only renders on iPad with iOS 18+). Sidebar data (libraries) loads through the shared `SidebarItemsStore`, driven by instance changes and `sidebarProjectionDidChange`.
- Split-view sidebar (macOS, iPad with classic navigation): Home/Offline/Server, then the expandable Libraries section, then a single Lists row opening the merged Collections/Read Lists page — never per-item expandable sections, since collections/read lists can be numerous. Per-library browse (`browseLibrary`) offers only the Series/Books content tabs; collections and read lists live at the shell's top level.
- Reading stats entry lives in the Settings page (own group above the Reader settings; macOS Settings sidebar), pushing `NavDestination.settingsReadingStats`; tvOS additionally keeps the dashboard header button.

### Detail Heroes & Wide Layouts

- Detail page heroes (Series/Book/OneShot/ReadList/Collection) share `DetailHeroView`: in centered mode the cover stacks above the centered info block; otherwise it sits beside the leading info block at `PlatformHelper.detailThumbnailWidth`. The mode comes from the `detailHeroCentered` environment value — never read `horizontalSizeClass` inside shared hero subviews.
- Action card contents (book-count line, reading/download action rows, state lines) and the `DetailTimestampsView` row follow the same value; the card itself is width-capped, centered in centered mode and leading otherwise.
- All chip rows (hero creator row, genres, tags, links) go through the single data-driven `DetailChipFlow` component; do not add per-field chip containers. It is the one component used both inside and outside the hero, so each content view scopes its `detailHeroCentered` injection to the header zone (hero + action card + timestamps, wrapped in one `Group`): the flow centers inside the hero and stays leading in the sections below.
- Series detail uses the two-column layout only while the detail column is at least `LayoutConfig.detailWideLayoutMinimumWidth` wide (`usesWideLayout`: measured width via `onGeometryChange`; iPad additionally requires regular size class, macOS decides by window width alone). The wide shell is the shared `DetailWideLayoutView` (Series/ReadList/Collection detail pages all compose it); each page passes its own rail content and right column.
- `DetailWideLayoutView` owns the two-column shell: the left rail takes the golden-ratio slice of the detail column (width/φ²) and carries identity plus about-info (series: cover, hero info, width-capped action card, timestamps, summary, chips, alternate titles; read list/collection: cover, hero info, count action card, timestamps), while the right column flows collections and the books/series list. Rail and column are independent `ScrollView`s so the rail never scrolls away with the list.
- The rail's scroll bounds extend `railEffectMargin` into the empty space around the rail — `contentMargins(.horizontal, railEffectMargin, for: .scrollContent)`, with leading padding and column spacing shrunk by the same amount so the visual grid is unchanged. Glass chips render their rim highlight only with a few points of room outside the capsule; flush against the scroll bounds the rim is clipped away. Do not use `scrollClipDisabled()` on the rail: vertical scrolling then paints content past the rail's top/bottom bounds.
- Narrower columns (iPad portrait with a docked sidebar, iPad mini portrait, narrow macOS windows, compact widths, tvOS) keep the single-column `SeriesDetailContentView`.
- Both layouts compose the same extracted section views (`SeriesHeroInfoView`, `SeriesBookCountView`, `SeriesSummaryView`, `SeriesDetailChipsView`, `SeriesAlternateTitlesView`).

### Layout Toggle & Chip Rows

- Browse layout switching (row / medium / large cards, `BrowseLayoutMode.list`/`.grid`/`.largeGrid`) lives in `LayoutModeMenu`, a dropdown menu showing the current layout's icon, rendered at the front of the filter chip row (the chip-row views take an optional `layoutMode` binding); pages without a chip row place the same menu above the content (e.g. `DashboardSectionDetailView`). The two card modes differ only in card width (`LayoutConfig.gridCardWidth` vs `largeGridCardWidth` via `BrowseLayoutMode.cardWidth`), which feeds `adaptiveColumns(cardWidth:)` and the item views' `cardWidth`.
- Chip rows are always full-width leading-aligned — the selection-mode button sits trailing (series/read list/collection detail pages and the books/series browse pages) — so the menu aligns with the content below; do not wrap the row in a `Spacer`-pushed trailing cluster.
- The menu matches chip height through a blank caption-weight text line (icon glyphs alone render shorter). Do not reintroduce toolbar layout pickers.

### Toolbar

- Navigation-bar titles are not shown on iOS/tvOS anywhere: tab labels, detail heroes, and section headers carry page identity, so `platformNavigationTitle` sets only the macOS window title. The exceptions are iPhone tab roots (Dashboard/Library/Lists/Offline/Search, iOS 26+): they render their title as an `InlineLargeBarTitle` toolbar item (`ToolbarItem(placement: .largeTitle)`, serif `.title`) — the only placement that lands leading on the bar row (`.title` centers on some pages) — paired with `inlineLargeBarTitleStyle` on the page's scroll content, which sets `.toolbarTitleDisplayMode(.inlineLarge)` underneath the page's `.inline` pin (any other modifier order drops the item) and keeps the bar background and top scroll-edge blur hidden so only the floating buttons remain on scroll. The system hides and restores the title on scroll by itself; do not add scroll-driven visibility state for it. Pushed detail pages (`DashboardSectionDetailView`, `ListsBrowseDetailView`) instead get the small inline system title via `inlineNavigationTitle` (visible title on iOS + macOS window title, never combined with `platformNavigationTitle`) — the inline-large bar title overlaps the Dashboard's own during the push transition, and `ToolbarItemPlacement.largeTitle` is unavailable on macOS.
- Toolbar trailing policy: at most one trailing toolbar button per content page (two only when a primary action sits next to the single ellipsis menu, e.g. Dashboard search on iPad/macOS; on iPhone the library scope capsule is a standing second trailing item, split from the menu by a fixed `ToolbarSpacer`). Everything else lives inside the ellipsis menu; sheet/alert presentations from menu items must go through `deferMenuActionPresentation`. The Dashboard's trailing item is the Settings gear on iPhone only; iPad already has a Settings tab/sidebar entry, and macOS has the Settings scene, so neither shows the gear. Settings stays reachable in offline mode, and its former ellipsis entries moved out (Reading Stats to the Settings page, the offline-mode toggle to the Settings toolbar on iPhone and the Server page elsewhere) — and the toolbar gains no offline/refresh buttons on top of it (iPad toolbar space). The offline status is instead a tappable reconnect banner at the top of the dashboard content (iOS/macOS; tvOS keeps it in the header). The browse pages (Library tab, BrowseView, OfflineView) carry the same trailing ellipsis menu on iOS and macOS, `BrowseActionsMenu`: a copy of the chip row's layout picker, presets, and filter entries, binding the same per-type `*BrowseLayout` AppStorage keys as the chip row's `LayoutModeMenu` so both stay in sync without re-threading state. Never add standalone filter toolbar buttons — buttons pushed into the system overflow menu can no longer present their sheets (the dropped presentation sticks the driving state flag, leaving the button dead until the view is recreated). On the iPhone Search tab the menu is always present, but its Filter item stays disabled until a query is submitted — the filter sheet is hosted by the per-type chip row, which does not exist while the search placeholder is showing (a presentation flag set there would stick with no sheet to consume it).
- Detail pages carry no Filter/Saved Filters toolbar entries; the filter chip row owns those entry points.
- Toolbar button ordering: conditional buttons go on the inside of a trailing group (closer to the title); unconditional buttons hold the outer edge, so the edge position never shifts when the condition toggles.

### Settings Pages

- Settings pages carry no group titles; top-level groups are Server (iPhone only: server card, then Libraries/Account) / Display / Reading Stats / Reader / server management (iPhone only, admin-only) / Behavior / Advanced / About. The Libraries entry is admin-only everywhere — its page is read-only for regular users. The offline-mode toggle is a low-frequency plain action, never a navigation-styled row: on iPhone a trailing toolbar button on the Settings page, on iPad/tvOS/macOS a small bordered button at the bottom of the Server page. Online it enters offline via `OfflineManager.enterManualOfflineMode()` (drops pending dashboard auto-refreshes, flips the manual flag, disconnects SSE); offline it reconnects via `AuthViewModel.reconnect()` (probes the server first — a failed probe keeps the offline classification — then flips back online, reconnects SSE, and reloads dashboard sections; the Dashboard's reconnect banner shares it). Reading Stats sits above the Reader group everywhere (a `SettingsSection.readingStats` case, including the `SettingsView_macOS` sidebar).
- Settings shared by all readers (DIVINA, EPUB, PDF) live in the Reader group's first entry, `SettingsSection.reading` (`ReaderPreferencesView`) — never in the DIVINA-only `ReaderSettingsSheet` or the per-reader preference pages; reading-session feature toggles (Keep Screen Awake, Reader Live Activity, Auto Full Screen on Open) live there too, as do the offline-reading preference toggles (Offline-first Reading, Auto Delete Read Books, in the page's first section). The read list continuation toggle (`SettingsReadListContinuationToggle`) lives there as well, in the page's Read Lists section: it is a reading behavior (entry-point resolution, recording, sync) whose dashboard section is a side effect.
- In-reader settings sheets stay compact (no description text); full settings pages may carry description text.
- `SettingsSystemFeaturesView` groups Handoff and Spotlight indexing and is not linked on tvOS; new pages register a `SettingsSection` case and use `SettingsBadgeRow`/`SettingsSectionRow` entries.

### Notifications

- Toasts share one bottom slot (`NotificationToastStack`): a newer toast supersedes the visible one, which collapses in place — they never stack vertically. Entry is `appSpring` (fade + scale + rise), exit is an `appCurve` fade + shrink; a fast or long vertical drag swipes the toast away (tvOS excluded). One exception: a plain toast never supersedes an action toast — it queues in `ErrorManager.queuedMessages` and shows only after the action toast settles, so a background event can't hide a pending Undo and let its commit run unseen.
- Toast lifetime follows content: 3s for short text, up to `clamp(chars/14, 5, 8)`s for long text, 5s with an action button.
- Deferred destructive work uses the delayed-commit model (`ErrorManager.notify(message:actionTitle:commit:cancel:)` / `notifyUndo`): the UI reflects the outcome immediately, the commit closure — stored in `ErrorManager`, never in a view — fires on timeout, replacement, or swipe-away, and the action button runs the cancel closure instead. Actions are plain text buttons; the remaining window shows as a thin depleting ring with the seconds count inside (`NotificationCountdownRing`) at the toast's leading edge.
