agentleFS
everpcpceverpcpc/KMReader/.agents/skills/repo-conventions/SKILL.md

repo-conventions

Skill118 starsChanged 7 days agoMITView on GitHub

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.

2 other projects use a version of this.

Adopted by

Most recently taken up by

  • Other versions2 projects use a version of this.

    Word for word · 0

    None found.

    Edited · 0

    None found.

    Wrote their own version · 2

    1. masseater/skills★ 2same name
    2. saurabhjambure-pixel/claude-skills★ 0same name

    How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.

  • In this repository8 other files it gives its agents.

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 2 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Every non-incognito session with a context records the entry point while the setting is on, including seamless cross-book moves …

    line 102
  2. Very specific:

    Only download/extraction write paths create the on-disk book or instance directory (bookDirectory/offlineDirectory).

    line 145
  3. Very specific:

    Read list reading state (read_list_reading_states) is user state, kept apart from the KomgaReadList server mirror.

    line 161
  4. Distinctive:

    Only ordered read lists count as being read, including for Stop Reading, whatever another device synced.

    line 165
  5. Distinctive:

    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 …

    line 250

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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.

DIVINA Content & View Items

  • ReaderContentService owns DIVINA book/series/page/manifest/TOC and adjacent-segment I/O orchestration, returning values without writing observable reader state. ReaderViewModel and DivinaReaderView apply those results and retain all position, loading, and session state; segment preloads still recapture the committed anchor after their awaits.
  • ReaderViewItemGenerator is the pure view-item/index algorithm. ReaderViewModel.regenerateViewState synchronously applies its output, restores the committed position, and only then synchronizes the page scheduler and manga translation; the generator owns no observable state.

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 per chapter plus preloaded boundary neighbors (≤3 chapter controllers), never one per page; each chapter host owns an EpubPageRenderer and in-chapter turns are renderer scrolls or display-list swaps on that live view. EpubPageControllerFactory and EpubPageTargetResolver share chapter configuration and target calculation between curl and cover, while their mounting and transition coordinators remain distinct. 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 chapter 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 Rendering Engines

  • epubRenderingEngine defaults to .webView; the iOS EPUB settings offer WebView or CoreText, while macOS always uses WebView.
  • Both engines share EpubReaderViewModel's committed chapter/page and progress submission. Engine, theme, or viewport changes restore the current chapter's saved page or progression instead of resetting to the first chapter, and only results from the latest layout generation may commit.
  • EpubPageViewController is the engine-neutral chapter host: it owns chapter/page restoration, geometry, loading, overlay, and shared callbacks. WebViewEpubPageRenderer owns WebKit and JS pagination; CoreTextEpubPageRenderer owns Core Text layout and display lists. Renderers report through the host and never write the reader view model or commit transitions directly.
  • CoreTextEpubLayoutOperation is created per reload and shared by the paged renderer, CoreText paged, and CoreText scrolled pipelines. Each caller retains its own task, generation, and cancellation lifecycle; no global layout session is shared.
  • CoreText uses YueduCoreText 0.9 on iOS 17+, reads chapters and resources from the same offline WebPub root, and supports paged and scrolled reading with scroll, cover, and page-curl transitions. Unsupported EPUB features surface an error and never silently fall back to WebView.
  • Core Text starts chapter layout only after the chapter view is attached to a window and has its final safe-area geometry. Initial saved-progression and previous-chapter-last-page anchors are reapplied on subsequent geometry re-layouts until the user explicitly navigates, so a later page-count change cannot strand the restored index.

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.

Chapter-Level Error Page (CoreText EPUB)

  • A chapter the CoreText engine cannot lay out (including missing chapter resources) renders as an in-chapter error page — CoreTextChapterErrorView, themed, installed by the paged/scrolled controllers or by CoreTextEpubPageRenderer for cover/curl — instead of the book-level ReaderUnavailableView. The chapter counts as a single page, so paging gestures, tap zones, boundary flings, and TOC navigation keep working in every CoreText mode. There is no Retry button: paging away and back re-runs the layout.
  • Error chapters never emit reading progress. Error paths mark the chapter through EpubReaderViewModel.markChapterLoadFailed (which also reports the single placeholder page), pageDidChange/flushProgress skip marked chapters, and the next updateChapterPageCount report for the chapter clears the mark — a produced page count means the layout succeeded.

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

  • Online, the next book is whatever the server's /next endpoint returns; the client sends no parameter and keeps no preference of its own. kmrs can skip read books by a server setting, so every client of that server agrees. The previous book always stays plain order.
  • The server profile carries that setting (skipReadOnNext on /api/v1/settings, readable by every kmrs user); servers that never send it (Komga) count as off.
  • Offline, and for the local continue-reading target, the local projection applies the same rule: with the setting on, 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 (Collection.nextToRead(after:isRead:)); with it off, the plain next book. 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).

Hardware Page Turns

  • Volume-button page turns are an iOS-only, opt-in preference (volumeButtonPageTurnEnabled, default off) in ReaderPreferencesView. ReaderVolumeButtonHandler observes AVAudioSession.outputVolume only while its reader window is foreground-active, the preference is on, content is ready, and no modal sheet or page context menu is presented. Capture activates a .playback audio session with .mixWithOthers and deactivates it with .notifyOthersOnDeactivation when capture stops, so volume KVO remains live without interrupting other apps' audio. Activation is cancellation-safe across sheet, scene, and reader teardown and uses the native asynchronous activation/deactivation APIs on iOS 27+ rather than blocking the main thread. An attached, invisible MPVolumeView suppresses the volume HUD and restores the previous volume after each step. The baseline is clamped to 1%–99% while capturing so both buttons retain room to produce a change, and restoration echoes matching the baseline are ignored. Hardware input is suspended in the background and on reader teardown, and hardware volume behavior requires a physical-device check because MPVolumeView cannot change volume in Simulator.
  • Volume up means the next step in reading order and volume down the previous step, matching the existing directional-key mapping (including reversal in RTL). Each reader routes these steps through its established navigation functions rather than committing a position directly: DIVINA preserves panel walking, whole spreads, Webtoon scrolling, and adjacent-book navigation; EPUB and PDF preserve their own end-page and navigation behavior.

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.
  • While a page-image context menu is presented (iOS), taps and page-turn pans stay off: NativePageItem counts the menu on ReaderViewModel.presentedPageContextMenuCount from willDisplayMenuFor until the dismissal animation completes (willEndFor animator completion, or prepareForDismantle if the page is torn down first) — the touch that dismisses the menu otherwise also reaches the reader and turns the page underneath the preview. Taps are gated at their single funnel, DivinaReaderView.handleTapZoneAction; engine pans (curl, dual curl, cover, paged scroll via shouldBeginPaging) are gated where each engine decides a pan may begin, both reading isPageContextMenuPresented. NativePageItem.makePreview() must return nil when imageView has no window: UITargetedPreview traps on a windowless view, which happens when a still-presented menu is cancelled because the page left the window.
  • 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; the bottom bar declares no container hit shape, so its empty areas fall through to the reader's tap zones and any per-page translate button pinned underneath stays tappable while the controls are shown.
  • Manga translation is opt-in per reading session on iOS/macOS — the toggle lives in a labeled capsule at the bottom control bar's trailing edge (also in the macOS Reader command menu), defaults off for every newly opened book, and does not exist in Settings (that page keeps model, provider, and language configuration) — and mutually exclusive with Live Text: enabling translation flips the global Live Text preference off, while enabling Live Text (preference toggles or the keyboard shortcut) disables the session translation via the reader view's onChange; shake-to-toggle does not exist. Once enabled, a single serial queue translates the committed view item first — every page of it as displayed, so a dual-page spread switches its overlays together; session keys track the displayed geometry, so a merged split kept whole gets one whole-page key in single-page presentation but one key per half in dual-page presentation, where each slot renders a cropped half; only the focused page owns the live phase, the sibling pages ride with prefetch semantics — and silently prefetches the next view item (page turns rebuild the queue rather than cancelling per page; prefetch failures never notify and are never kept in memory, so they retry when the page becomes current). Translated and no-text results live in a per-session in-memory table keyed by page plus split/rotation/crop geometry plus a synchronous language/provider/model preference stamp (provider configuration edits in GRDB are invisible to the stamp, so dismissing the reader's translation settings sheet clears and re-runs the session table wholesale via refreshMangaTranslationConfiguration), so scrolled-away pages keep their overlay and revisits skip inference. Each displayed page image owns a Live Text–style translate button (MangaTranslationButton, pinned 12pt inside the image's bottom-right corner: a hand-drawn 35pt material circle on iOS and macOS (matching the system Live Text button's measured size and corner inset), with a dark glyph, inverting to solid black while the overlay shows) hosted by NativePageItem on iOS/macOS, so every page of a spread has its own; it owns translation, retry, and original/translated toggling for its page, appears only once the page image is displayed, and rides along with page zoom/scroll. Webtoon does not support the feature — a strip binds several pages into one view item, so page-level triggering and failure feedback cannot be attributed cleanly; the control toggle and command entries hide in that direction and switching to it cancels the translation session. The button state (MangaTranslationButtonState) is computed per page by the reader view model, and state changes fire .mangaTranslation on the page-presentation invalidation channel so the UIKit/AppKit hosts, which cannot observe @Observable themselves, re-render visible pages. Tapping the focused page runs the usual item-wide queue (translateCurrentPage, so a dual-page spread still switches together), while tapping an untranslated sibling page translates that page on its own manual queue so the focused page's result is neither dropped nor force-retried; tapping a translated page toggles that page's own original/translated visibility (a per-page override over the shared default, dropped when a fresh result lands or the session cache is cleared). The settings sheet opens from the reader controls menu while the feature is on and the macOS Reader command menu — there is no long-press gesture. Matching source/target primary language subtags (so Simplified and Traditional Chinese count as one language) skip model preparation, detection, recognition, and provider work; the button is disabled in that state. The button distinguishes model preparation, detection, recognition, and translation through phase-specific pulsing icons on the focused page only (other pages pulse the plain translate glyph while they sit in the queue), never a separate progress overlay or toast. Original/translated toggling keeps the same translate glyph and inverts the circle to solid black with a white glyph while the translation overlay is visible, swapping via the SF Symbol replace transition. Configuration, network, and provider failures on the current page notify through the reader toast even during automatic attempts; recognition/no-text failures notify only on manual retries; and a failure on a manually tapped page notifies and keeps its button failure-tinted until retried. The button shows a failure icon only for actual errors, while an empty recognition keeps the normal translation icon; starting a translation always reveals its result, and disabling the feature cancels the queue and clears every cached overlay.
  • 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.

Panel Mode

  • Panel mode (walk a page panel by panel) runs on iOS only, paged DIVINA in LTR/RTL only (never Webtoon or Vertical); macOS and tvOS keep the regular reader, gated at DivinaReaderView.panelModeActive. A labeled capsule toggle at the bottom control bar's leading edge enters and exits the mode, balanced by the translation toggle on the trailing edge while the page button stays centered; there is no long-press or reader-menu entry. Active panel walking pins Cover. It persists per book (AppConfig.panelMode(for:), keyed by the segment book being read), and within a session it carries across book boundaries without being saved for the next book. The top-level Manga Tools settings page, placed directly below DIVINA Reader, owns translation configuration and the Panel Mode section's model management and zoom fill; the compact in-reader Reader Settings shows zoom fill whenever the current direction supports the mode. While active the reader forces single-page layout and pins the cover transition; a button-driven exit first awaits the zoom-out completion, then restores the saved layout and transition. ReaderRenderConfig.panelMode includes that exit transition in the presentation key.
  • PanelModeController owns the engaged state and the cursor over the current page's sequence and never calls back into the host: the host reports each page with a synchronous beginPage before the awaited updateCurrentPage, so no step acts on the previous page's walk. A step at either end of the walk returns .turnPage and the host turns through goToPagedReaderPosition; while that turn is pending further steps queue, and a denied turn (boundary nudge) calls pageTurnDidNotHappen(). Zoom reaches the cover engine only through the injected weak-target PanelZoomController, which retains the latest zoom intent until Cover has a nonzero front-slot viewport, then delivers it. Clearing the target also clears the pending intent so exit or engine replacement cannot replay stale zoom.
  • Taps keep the user's tap zones: while engaged, forward/backward redispatch at goToReaderPosition (taps, keys), and the zoom guard carve-outs gate on engaged, never isZoomed. Double Tap Action is only Zoom/Off (DoubleTapZoomMode); Panel Mode never uses double-tap. Long Press Action (ReaderLongPressAction) selects only Image Context Menu or Off and has no effect on Panel Mode, so Live Text and the image context menu never contend with a panel-entry gesture. The control-button entry starts at the first panel. While panel mode is active the cover engine and page-image host disable their double-tap recognizers entirely, so panel stepping is immediate. Entering is cancellable, and page generations reject detection results after a page change or exit. Button-driven mode changes notify through the reader toast with Panel Mode On/Off and light haptic feedback.
  • Detection goes through PanelDetectionProvider and runs on the original, unrotated, unsplit page; reading order is decided there, and rects map into the displayed page through PanelPagePresentation (PreparedReaderPageGeometry rotation and border crop). Sequences live in an in-memory cache keyed by instance, book, page image identity, detector version, presentation, and preprocessing version; transient failures show the whole page and are never cached.
  • The detection model is not bundled: Manga Tools settings let the user explicitly download a supported panel_detection entry with byte progress; PanelModelService compiles its single RF-DETR component through MangaModelManager.prepareSupportedPaths and preheats it with RFDETRCoreMLDetector.prepare(_:). Enabling panel mode never downloads a model; an uninstalled model opens the management view. PanelModelLocation.installed resolves only the active package with its compiled model, and the reader replaces its provider when the active version changes so old sequences cannot survive an update. During an update the previous active model remains usable until the replacement is prepared. The provider loads with its own compute-unit ladder (.all first for the Neural Engine, falling back to GPU and CPU; CPU only on the simulator). Its compiled .mlmodelc stays at a stable per-archive path, and the catalog version and archive digest tag the detection cache. Translation-only engine support and model selection remain in the manga_ocr path; panel models have no selection state. iPhone/iPad model settings offer install, update, and removal, with the catalog and package carrying Manga109-s attribution.

Sync, Offline & Caching

SSE

  • SSE callbacks are single-assignment closures; implement dispatchers when multiple components need the same event.
  • SSEService keeps the last received domain-event ID in memory, scoped to the current instance and stream, and sends it as Last-Event-ID on reconnect so kmrs can replay a complete retained history. The cursor survives lifecycle suspension, offline recovery, and SSE preference toggles; login, logout, and account/server switching reset it explicitly. Old streams cannot update state after their cursor or identity changes; delayed reconnects are single-assignment and cancelled by explicit connection/disconnection. Task snapshots and heartbeats do not advance the cursor.

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.
  • Migration bodies and schema helpers belong only in LocalDatabase.swift; record models and feature stores must not define migration methods or table schemas. New migrations use migration-local literal table/column names rather than current model symbols so later model changes cannot rewrite migration history; do not rewrite frozen migrations solely to retrofit this rule.
  • New persisted field: update the record model and CodingKeys, then add a migration backfilling a safe default. Validate both upgrade and fresh-install paths.
  • Manga translations are isolated per Komga instance: MangaTranslationPageKey includes the instance id, source/geometry/provider/model signatures, and rows use (instance_id, cache_key) as the primary key. Each save prunes same-page rows whose model signature retired (legacy empty signature, or the same archive on an older inference version); other archives and same-signature language/provider/geometry variants survive, so stale entries converge lazily as pages are revisited. Each attempt has an owner; cancelled or superseded attempts must not update reader state or overwrite newer cached results. The format-version-2 model catalog and matching package manifests declare capabilities and per-component engine/version/files/labels; detector and recognizer are separate adapters, unknown engines fail before download, and manga translation consumes only supported manga_ocr archives. Two detector engines exist: rt_detr_v2 (text-tight boxes, the label table drives recognize/orientation) and yolo11n_seg (single bubble label, whole-bubble boxes; the CoreML export drops the mask-proto branch because regions carry boxes only, confidence arrives sigmoid-squashed in-graph, input is the same stretched 640×640). Two recognizer engines share one CoreML I/O contract and the MangaGreedyDecoder loop, differing only in preprocessing selected per engine via MangaOCRPreprocessing: manga_ocr_transformer (stretch to 224×224, [-1,1]) and manga_ocr_mobile (letterbox to 224×224 on white, [0,1]); the letterbox path resamples with a hand-rolled bilinear filter matching the training-time coordinate mapping — CoreGraphics scaling shifts pixel centers and flips glyphs. Detection and recognition models are selected independently per role (MangaModelRole, storage keys manga_ocr.detector/.recognizer, the legacy combined key serves as fallback until the next select, and is dropped once both roles have explicit picks — downgrading past the role keys then finds no selection, though downloads survive and re-selecting fixes it): an archive stays the distribution unit, but the two roles may come from different archives, the one-tap select writes only the roles the archive supports, the management sheet offers each role only role-capable archives (MangaModelSet.supports(_:for:), which deliberately tolerates unknown extra components so future components do not break this build's downloads), and the translation service keeps one prepared MangaModelSet per selected archive (two at most, shared when both roles pick the same archive). The model signature is <detector sha256>:<recognizer sha256>:<inference cache version>, so cache pruning keys on the detector prefix and legacy single-archive signatures retire through the same prefix. The catalog ships only role-split archives (capabilities detection or ocr with a single component); the legacy combined archives were withdrawn from the catalog, and already-downloaded ones keep working because the downloaded-model registry is independent of the catalog. Model rows show the archive's capability as chips (MangaModelCapabilityChips, derived from its components) alongside the catalog comment. Bundles are downloaded from R2 with byte-level progress feeding the management row's progress bar, SHA-256 verified, and stored under Application Support/Models in per-purpose, per-archive namespaces excluded from device backups; hashing and TAR extraction stream off MainActor, the downloaded-model registry is independent of the current catalog, and models never ship in the app bundle. Translation uses a selected custom AI provider when configured, otherwise the on-device system provider on iOS 26+/macOS 26+; older versions report missing configuration before OCR instead of doing wasted recognition. Provider configurations, including API keys, live in GRDB. Custom AI translation and model-list requests share a 30-second hard timeout in MangaProviderRequest. Source language prefers series metadata before the default, target language defaults to the system language, comic metadata is optional in the LLM prompt, and overlay geometry is clamped to the displayed page bounds; bubble backgrounds are feathered-edged rounded rectangles with an opaque center, tinted with the reader's sepia background, and the sizing follows the detector's box semantics — each page stores the detector's MangaTextBoxKind (text for text-tight rt_detr boxes, bubble for whole-bubble yolo boxes; legacy cached rows decode as text, and the rounded rectangle hugs the box for either kind) and MangaBubbleBackground derives the background and text frames from the box kind: the blob outgrows text-tight boxes by the feather so the glyphs stay covered, while whole-bubble boxes keep the blob and its feather inside the box so the bubble's own border line survives; whole-bubble boxes also shrink the text area to a centered fraction of the box so glyphs keep a manga-like white margin inside the bubble's own padding, while text-tight boxes use the small-inset text area. Detector labels without orientation metadata fall back to physical box geometry (taller than wide, compared in display pixels — normalized heights on portrait pages hide near-square vertical bubbles), and each MangaPageTranslation stores its targetLanguage (legacy rows decode as empty). OCR crops are cut from the unrotated source image and brought into reading orientation before recognition (MangaInferenceUtilities.readingOrientation applies the page rotation only); the recognizer reads upright vertical text natively, so vertical columns must never be de-rotated — sideways glyphs are unreadable to it. Overlay text renders vertically when the region's resolved orientation says vertical — unknown from legacy caches falls back to a conservative normalized-box comparison — and the result's target language supports it (MangaTranslationLanguage.supportsVerticalLayout: Chinese/Japanese only, other targets stay horizontal); the textDirection preference (auto/horizontal/vertical) overrides the gate, with horizontal and vertical forcing one axis for every region. Vertical layout draws through CoreText vertical forms in MangaVerticalTextCompositor/MangaVerticalTextLabel with columns flowing right-to-left at a pinned 1.5em pitch, shrink-to-fit sizing, and a scale-to-fit fallback instead of clipping; the compositor also rewrites punctuation for vertical rendering (half-width to full-width, Unicode vertical presentation forms, one-cell compression of consecutive !?/?!/!!/?? with VS15 pinned on the emoji-capable marks), so the rendered text intentionally differs from the cached translation string. Loaded Core ML models are released when the reader closes and on memory warning without deleting their packages. Compute units are split per component: the detector runs on .cpuAndGPU (fixed 640×640 input, and .all distorts boxes on device) while the recognizer always runs on .cpuOnly — GPU/ANE specialize the decoder graph per sequence bucket and each specialization adds gigabytes of compile state, which memory-kills small devices when a decode crosses into long buckets; CPU speed is on par with flat memory. The mobile recognizer's encoder ships fp32 compute because fp16-compute convolutions diverge between ANE and CPU (CPU-run output flipped decoded glyphs), and the recognizer only ever runs on CPU. Live Text and local LLMs are not used.

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).

Server Profile

  • What a server offers beyond Komga is read once per connection, never probed per feature: ServerProfileService.refresh reads /api/v1/server/info (features, kmrs only; a 404 means Komga) and, on kmrs, the settings every user can read from /api/v1/settings (skipReadOnNext, recentReadingDays). It runs alongside the user request in AuthViewModel.loadCurrentUser (startup, reconnect) and before libraries load on login and server switch, bypassing the offline check like the user request. The result is stored per instance in AppConfig.serverProfiles; a failed refresh keeps the last profile.
  • kmrs-private requests (server/library stats, reading stats, smart lists, native sessions, komf, trackers, update check) are made only where the profile lists the matching ServerFeature. kmrs lists a feature that needs setup only once it is set up (komf connected, update check enabled). An instance without a profile, including older kmrs without features, gets none of them; there are no 404 verdicts or recheck intervals.
  • kmrs's recent reading window (recentReadingDays, 0 = off) limits Keep Reading and On Deck to series read within it. The server already filters /api/v1/books/ondeck; Keep Reading comes from the book search, so DashboardSection.fetchBooks adds a kmrs readDate condition (BookSearchFilters.readDateAfter) and the server filters and pages it. The offline Keep Reading and On Deck queries and the Keep Reading widget apply the same cutoff from ServerProfileService.recentReadingCutoff.

Reading Stats

  • Reading stats come from the server's /api/v1/stats/reading/* endpoints where the server profile lists readingStats, and from local GRDB aggregation otherwise (Komga, offline). 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. SmartListsViewModel loads them only where the server profile lists smartLists; elsewhere it reports unsupported without a request.
  • Smart lists are DTO-driven and online-only: the lists themselves are NOT mirrored into GRDB (no tables, 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). Pin state has no mirror table to hang on, so it is client-local per-instance in UserDefaults (PinnedSmartListIds via AppConfig.pinnedSmartListIds, views read it through @AppStorage("pinnedSmartListIds")) — device-local and server-invisible like the GRDB-backed collection/read-list pins. Browse surfaces prepend pinned lists via PinnedSmartListIds.pinnedFirst (stable partition preserving the server's name order), and cards/rows carry the same pin badge as the other list types.
  • 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). notableEntity exact-pair filters preserve kind and name separately in MetadataFilterConfig and round-trip through SmartListFilterMapper, including all/any grouping; unknown or richer server-side predicates remain lossy and are not silently discarded on unrelated edits.
  • 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 + pin toggle, edit/delete for owner/admin; no 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.

Reading Trackers

  • Trackers (/api/v1/trackers/*) are kmrs-private per-user integrations, not admin features and absent on Komga. TrackerCapabilityStore loads preferences only where the server profile lists trackers, clears preferences on account switches, and gates series actions by both support and per-user library preferences. Account and series management are online-only on iOS/macOS; tvOS has no tracker UI.
  • Native OAuth is intentionally not implemented: kmrs's current callback requires a browser session, while KMReader uses API keys. Disconnected providers link to /account/trackers in the server web UI, never to a relay carrying the KMReader credential. Connected account status and logout use the kmrs endpoints; provider tokens remain server-side.
  • TrackerSeriesSheet lists existing per-series bindings, adds a provider only when connected, and opens TrackerLinkEditSheet for automatic initial search, switchable series alternate titles, binding mode/offset, and sparse remote updates. Empty remote fields are never submitted, binding-only saves do not call remote update, and an Unlink never edits the provider entry. Server-side automatic sync owns progress aggregation; the client does not mirror it. Grouped bindings and the manual-update ledger use PaginationState; the ledger is recent manual updates, not a full bound-series catalog. Search results (TrackerSearchResultRow) mirror kmweb: a kind tag from series + mediaType (Manga Series / Novel Series / Series, or Manga / Novel) and an In Your List marker for tracked. Series metadata links that point at a provider entry (TrackerProvider.entryURLs(in:), same host/path rules as kmweb) are resolved through the search endpoint and pinned above the results as From Series Links, so callers pass the series links down to the edit sheet.
  • Rows mixing an action with other content inside a Form/List (account Disconnect/Connect on Web, entry Open/Change) use .buttonStyle(.borderless) — a default-style button there fires on any tap of the row. Disconnect always goes through a confirmation alert because reconnecting requires the server web UI. Linked Series rows (TrackerBindingRow) are compact: series thumbnail, local series title (falling back to the remote title), and provider/mode/offset chips; the row navigates to the series detail page, and per-provider open/unlink live in its context menu behind an unlink confirmation.
  • Supported, enabled series detail pages always expose the tracker flow below the metadata chips: branded binding chips show remote reading status, open their own TrackerLinkEditSheet on tap, and a persistent Add Tracker button opens TrackerSeriesSheet; the card and row context-menu entries remain. The chips use TrackerLogo, the same provider artwork as kmweb, also shown in tracker account rows. No extra section title or enclosing card.

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 browse scope (a LibraryBrowseScope of .pinned/.all/.library(id), default .pinned, resolved via resolvedIds(pinned:)) is per-surface: the iPhone Library/Search tabs and Offline persist independent choices through @AppStorage (libraryBrowseScope, searchBrowseScope, and offlineBrowseScope); the Dashboard's session scope lives in DashboardLibraryScopeStore.shared and resets in DashboardLibrarySelectionStore.loadSelection (app start and server switch); pushed browse pages keep local @State, and iPad/macOS browse roots take it from the shell (next bullet). A scoped library that vanishes from the loaded list falls back to the aggregate; 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) and /api/v1/server/info (flat version/git identity), with actuator health and metrics as fallback for other servers; the shared /stats/server 404 probe (AppConfig.serverStatsCapability) and independent /api/v1/server/info 404 probe (AppConfig.serverManagementCapability) pick those paths. The overview shows server identity (version, commit, OS), process and resources (start time, uptime, CPU, memory, disk), content totals, and the admin /api/v1/update-check result when supported. Maintenance offers log file download via share sheet (hidden when the HEAD probe 404s as on stock Komga), plus server shutdown behind confirmation only when native kmrs is not confirmed — current kmrs has no HTTP shutdown endpoint, so never probe shutdown via POST. Tasks, sessions, and scheduled tasks are sub-pages: Tasks pairs the SSE live queue and Cancel All with per-type execution metrics (from /stats/server on kmrs and komga.tasks.* on Komga), and Scheduled Tasks lists actuator fixed-rate registrations (Java FQN targets shortened to their meaningful tail). Native session management uses the paginated /api/v2/users/me/sessions for the account page and /api/v2/users/sessions for admin sessions; independent 24h negative capability verdicts live in AppConfig.serverSessionsCapability. The admin page falls back to /actuator/sessions, then its current-user ?username= variant on Komga; the account page never calls that admin-only fallback. Current sessions cannot be revoked from the UI, and native admin rows show owner email/user id. A failed load keeps previously stored numbers. 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 — "Pinned" (.browse(scope: .pinned), only while the pinned set is non-empty) and "All Libraries" (NavDestination.browse(scope: .all)) — inside the same section as the individual libraries, and the pinned aggregate leads everywhere the two are listed (sidebar, tab section, scope menu) because pinned is the default scope on every surface — on iPad this also means the tab bar's Libraries item opens the pinned scope, since it 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:).

Metadata & Notable Entities

  • Book metadata owns notableEntities (kind, name) and its lock; series booksMetadata exposes the read-only aggregate, so only Book and Oneshot editors submit entities. The existing raw JSON blobs in KomgaBook/KomgaSeries persist both shapes, with no separate schema migration. NotableEntitiesEditor preserves arbitrary future kinds and trims/lowercases kinds while keeping names verbatim.
  • Detail pages render grouped NotableEntityChipFlows (known kinds first, localized names) that navigate to exact-pair book/series browse filters. MetadataFilterConfig persists a stable kind+name key and logic, maps to notableEntity conditions in both search models, and round-trips through SmartListFilterMapper. The v3 referential endpoint has a flat PageResponse, not the ordinary Spring Page. Offline matching decodes raw metadata; series residual filtering must run whenever a notable filter is active.

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), and the horizontal card strip. Empty sections are not rendered at all: loads live in the central DashboardViewModel, not in view lifetimes, so a hidden section never needs to stay mounted. Item mutations animate through withAnimation in the loading view models (applyPage/removeItem), which drives the strip's per-item interpolation and the band's animated exit when its last item leaves; the first fill (cache seed or first page) never animates, so an initial load appears in place without per-section unfold animations. 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. Its leading connection-status icon is the Dashboard's only connection-status affordance: a non-interactive WiFi icon, green online and red offline, turning orange with a repeating pulse while a (re)connection attempt is in flight (AuthViewModel.bootstrapState == .validating), and the scope metrics refresh whenever the offline flag flips back to online. The row always renders — the title stays blank until the library list arrives — so the sections below never jump when it appears, and LibraryScopeStore.refresh seeds from the local projection (load) before its server round-trip to keep that window short. 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 follows the same split: its body never reads scopeStore or DashboardViewModel's section state — the scope header, scope menu, and empty-library guidance are child views (DashboardScopeHeaderView, DashboardScopeMenu, DashboardEmptyLibraryGuidanceView) that observe the stores themselves, and only the section views read section state. LibraryScopeStore.load() still skips no-change assignments on every property (a same-value write like hasLoaded = true still invalidates @Observable observers), so store reads anywhere stay free of spurious re-renders. 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 band runs as a render-server Core Animation (CABasicAnimation inside a UIView/NSView representable), so no SwiftUI state changes per frame — a value-driven perpetual animation inside scroll content keeps the layout engine churning, and after a rotation that churn locks the dashboard's nested scroll views into a re-layout loop. 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. kmrs lists komf in the server profile only while the integration is connected, so KomfIntegrationStore.isAvailable is just admin + online + that feature, with no request of its own; a 409 from a komf action re-reads the profile. Menu items render only while available.
  • 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) (no scope header; the empty pre-query placeholder carries the scope menu itself — the tab auto-focuses the search field on appear, which hides the toolbar's scope picker). 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 and the iPhone Search tab's empty placeholder show 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, TabSections 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 ScrollViews 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 trailing item by a fixed ToolbarSpacer). Tab-root toolbars compose two shared items: InlineLargeTitleToolbarItem (iPhone/iOS 26+ inline large title, enabled gates pushed pages) and LibraryScopeToolbarItem (the scope menu at the platform's placement — macOS .navigation, iPad .cancellationAction, iPhone .confirmationAction; isVisible hides it on all platforms, showsOnPhone hides it on iPhone only, separatesTrailingMenu adds that iPhone spacer). 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 control to the Settings toolbar and Server page). The Dashboard toolbar has no connection-status or refresh buttons; tvOS keeps its refresh button in the header only while online. 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 / Manga Translation / 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 connection control is a low-frequency plain action, never a navigation-styled row: every Settings page carries the shared ConnectionStatusButton in its trailing toolbar, and the Server page additionally keeps a small bordered button at the bottom. The Settings button reflects current state (green wifi online, red wifi.slash offline, orange with a repeating pulse while a connection check is in flight) rather than the action; online it enters manual offline via AuthViewModel.enterOfflineMode() (drops pending dashboard auto-refreshes, flips the manual flag, disconnects SSE), while 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). Reading Stats and Manga Translation sit above the Reader group everywhere (each has its own SettingsSection case, including the SettingsView_macOS sidebar); recognition-model and provider management open in separate sheets from Manga Translation. Catalog URLs are read-only in recognition-model management; editing and resetting them happens in a secondary sheet.
  • Manga Translation puts its enable toggle first. Its forms never use standalone rows for transient status or routine actions: status goes through ErrorManager, routine actions live in section headers or SheetView controls, available-model choices share the model input row, and refresh/fetch actions animate their icons. Provider rows in Translation Providers are selection-only; test/edit/delete are swipe actions and context-menu items, and test results surface in a sheet-local alert because presented sheets cover the toast overlay. Low-frequency destructive cache clearing sits in a full-width row at the bottom.
  • 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.
  • Presentation modifiers (.sheet, .onAppear) go on the Form, never on a Group or Section inside it: SwiftUI copies them onto every row, and the copies fight over one binding (the sheet opens and closes at once). Reusable section views take the state from their owner instead — the manga tools sections share a MangaToolsSettingsModel whose single sheet the Form presents through .mangaToolsSheets(model:).
  • 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.
  • Reader notifications use ReaderNotificationOverlay at the BookReaderView overlay layer, above every reader engine and its chrome; the app-level NotificationOverlay is covered by full-screen reader presentation and must not be relied on there.
  • 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.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.