Repo Conventions
SkillDocs & knowledgeGives your agent KMReader coding rules covering reader state, progress syncing, offline downloads, caching and page layout.
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; ahel provides instructions and does not run this skill.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the Repo Conventions skill
About this skill
KMReader subsystem conventions and invariants, reader state boundaries, reading-progress sync, offline downloads and caching, 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
What this skill tells your AI
The instructions your AI receives, as published by everpcpc/kmreader in .agents/skills/repo-conventions/SKILL.md and read by ahel’s review.
Subsystem conventions and invariants for KMReader. AGENTS.md holds repo-wide rules; this file holds the per-subsystem boundaries. 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
ReaderSettingsSheetis 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
ReaderViewModelowns the committed semantic position as a fullReaderViewItemplus its focusedReaderPageID.navigationTargetis 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, clearnavigationTargetsynchronously before committing (committing first loops).- Seamless cross-book navigation: the committed
ReaderPositionAnchoris the source of truth;.enditems retain their segment's finalReaderPageID;currentBook/ReaderSession.bookfollow the segment;currentBookIdremains the whole-book load anchor. - Split wide pages keep their committed side across layout rebuilds; propagate it via
ReaderPositionAnchor.preferredSplitPartandReaderViewItem.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/.secondhalves, as macOS and tvOS always do. With Scroll on iOS, single-page presentation keeps a split wide page whole:generateViewItemsemits 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 throughSpreadPanningScrollView. Engines buildWholeSpreadPresentationviaReaderViewModel.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 throughReaderViewModel.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 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. - Both page hosts (
NativePagedPageContentView,NativeImagePageViewController) keep their zoom scroll view in aPageScrollController, 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. A host starting to show a spread opens it atwholeSpreadArrivalEdge(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 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
setViewControllerscompletion authoritative;willTransitionTomust not invalidate the in-flight transition token. ProgrammaticsetViewControllersmust never land while a pan gesture is active (willTransitionToonly fires once a curl starts); coordinators gate on the pan recognizers' live state and stash work until the pan ends, likeisTransitioning. - 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.
Scroll Engine
ScrollReaderEnginerestores anchors strictly by page identity across item-list rebuilds, carrying the pending position as a fullReaderPositionAnchor(never a bareReaderViewItem). Unresolvable anchors are discarded (nil), never positionally substituted.
Page Load Failure State
- DIVINA page image load failures are recorded in
ReaderPageLoadScheduleras a typedReaderPageLoadFailureper 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 renderfailure.title/failure.detailwith a retry button. Retry goes throughReaderViewModel.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.getOfflinePageImageURLthrows; thetry?callers treat it as plain absence), or offline unavailability. View code never invents its own reason text.
Next-Book Offline State
- In offline-first reading,
ReaderViewModel.nextBookOfflineStateis 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.
Next Book Suggestions
- With "Suggest Next Unread Book" on (
suggestNextUnreadBook, on by default), the next book skips books already read, like the dashboard: the first later book in series order (or the read list's order in a read-list context) that isn't read, else the plain next book, so re-reading still moves forward. With it off, the plain next book. The previous book always stays plain order. This covers the reader's next book (end page, next segment and its preload) and the series continue-reading target after the last read book. - Online,
NextBookToReadResolvermakes the single/nextrequest withskipRead=true(servers that support it skip read books themselves); when the returned book is read (servers that ignore the parameter, such as Komga), the local projection supplies the first unread book after the current one, never adding a request. Offline, and for the local continue-reading target, the same rule runs on the local projection (Collection.nextToRead(after:isRead:)). An appended next segment records the book it follows as its previous book, so a skipped book never lands between two segments.
Read List Continuation
- Read list continuation is opt-in (
readListContinuationEnabled, default off):ReaderPresentationManager.presentresolves a nil read list context throughReadListReadingService.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 (
updatePresentedBookrecords only when the book id changes, not on refreshes of the same book).
Sync, Offline & Caching
SSE
- SSE callbacks are single-assignment closures; implement dispatchers when multiple components need the same event.
Read Progress
- Read progress has a per-session recording threshold (
progressRecordingThreshold, default 3, 0 = record immediately): for a book that was unread or finished when the session reached it, page-change submissions and the close/background flush are withheld until the position moves at least that many pages from the session's first page, unless the page completes the book. A book already in progress records any page turn, and once a book records in a session it keeps recording; opening a book without turning a page never records. - All three engines apply the policy through one
ReaderProgressRecordingGateper 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.
- Clearing caches or server data goes through
CacheManagerand the GRDB stores only. - Queueing a download backfills the book's
KomgaSeriesrow from the server when missing (OfflineManager.ensureSeriesRow, with astartDownloadbackstop): single-book download entries don't guarantee the series row, and Offline series browse plus series download rollups query the series table.
Local Database
- Runtime GRDB migrations in
LocalDatabaseare immutable once committed: never mutate an already-registered migration (e.g.create_runtime_schema_v1,00002_add_protected_server_flag) or its helpers; they are the frozen baseline. Any table shape change is a new numbered migration after the latest one; fresh installs run baseline + all later migrations in order. - New persisted field: update the record model and
CodingKeys, then add a migration backfilling a safe default. Validate both upgrade and fresh-install paths.
Read List Reading State
- Read list reading state (
read_list_reading_states) is user state, kept apart from theKomgaReadListserver 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
isStoppedtombstone) — 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,
ReadListReadingServicerecords, syncs, and resolves nothing and publishes an empty snapshot (no local reads or writes, no client-settings requests).
Browse & Dashboard
Library Selection
- Dashboard/library selections persist via
LibraryManagerand related managers.
Pagination & Ordering
- Browse pages paginate with
PaginationState(pageSize: 50). Notification-driven refreshes revalidate the loaded window in place viaPaginationState.replaceItems; fullpagination.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 aloadedXxxIdstate 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 viaAuthor.sortedByRole(). Never revert to raw.sorted(). (MetadataIndexencode 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.
Dashboard Rows
- Section chrome is shared:
DashboardSectionLayoutowns the gradient band, the header navigation link (title + chevron, optionalDashboardCardKindMenu), the horizontal card strip, and the empty-collapse. The band's vertical rhythm comes fromLayoutConfig— equal padding above the header and below the cards (dashboardSectionVerticalPadding), 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). With the gradient background off the band edge disappears, soDashboardSectionLayoutfalls back todashboardSectionVerticalPaddingCompactto keep sections from drifting apart. - Dashboard progress sections always revalidate on
.readingProgress; other sections skip unless browse options are progress-sensitive (isSensitiveToReadingProgress). - Dashboard rows load through view models the row views own (
DashboardSectionViewModel,DashboardPinnedSectionViewModel), never in a view.task: switching the 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. - Appearing starts a load only when none has completed or is running (pinned rows refresh on every appear but join a running refresh for the same server); a reload supersedes a load in flight (newest wins) instead of being dropped. Do not reintroduce load-once flags or
isLoadingguards that drop a re-run.
Pull-to-Refresh
.refreshableclosures must await the reload they trigger, so the refresh control dismisses onto settled content instead of racing in-flight view updates.- Dashboard manual refreshes suspend in
DashboardRefreshCoordinatoruntil every rendered section acknowledges the command (section views register on appear/disappear). - The Dashboard pull gesture leaves the toolbar untouched (
showsToolbarIndicator: false): the refresh control is the gesture's own indicator, and swapping the trailing toolbar item mid-gesture stutters the pin/bounce-back animations. The toolbar spinner remains for menu- and code-triggered refreshes, which have no gesture. Pages whose reload can finish near-instantly (Dashboard, Offline) userefreshableWithMinimumHoldso 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. OfflineViewawaits the browse view models it owns and shares withOfflineSeriesBrowseView/OfflineBooksBrowseView, whoserefreshBrowse()re-runs the view model's current query; library-selection and account-switch reloads instead bump arefreshTriggerpassed to those child views, so the reload runs through the child and captures the currentlibraryIdsrather 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
readListsInProgressdashboard section, first by default, driven directly byReadListReadingService.continuations; On Deck and Keep Reading stay Komga-native, never merged or filtered. ReadListsInProgressSectionViewrenders 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.
ReadListContinuationHorizontalCardViewis the default: it shares Keep Reading's horizontal-card look (solid card fill, trailing accessories) rather than copying it, and its text column follows the horizontal-card contract — semibold title line (the list's name), primary book line, secondary progress line. ReadListContinuationCardViewrenders large/small, with the list's name in the series slot, cover-only when small.- Both share
ReadListContinuationContextMenu,ReadListContinuationProgressText, andReaderActions.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.isAvailablehides it from the settings lists and Reset while off, andDashboardViewrenders it only while on. - While off, ordered, non-empty read list pages show
ReadListContinuationHintViewpointing to the setting, injected through the detail view'sactionsslot.
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 forsupportsDownloadLatestsections: 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 togridCardWidth) rather than a style flag. - Large and small grid cards share the
GridCardViewskeleton (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. - 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, pinned read lists/collections). DashboardSection.cardKindis 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; pinned sections stay horizontal-only), persisted inDashboardConfiguration.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 Readinglarge). - 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. - On horizontal cards the text column distributes vertical slack evenly across every gap (above the title, between the lines, below the bottom bar; the title keeps a 4pt 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 — and step down in size (
LayoutConfig.horizontalCardFontSizefor the title, −1 for the series line, −2 for meta); 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 viaThumbnailTint, which also reloads on.thumbnailDidRefresh); until the tint resolves the card falls back to thecardBackgroundcolor asset with the adaptive colors above. On the tint all text is white — title full white (70% once completed), series line 85%, bottom bar and accessories 70%; cover and text form one button (one tvOS focus target), while the trailing accessories stay separate targets, Apple Books style — a passive download status indicator (only while pending, downloaded, or failed) and anEllipsisMenuButtonmirroring the card's context menu, both atLayoutConfig.horizontalCardAccessoryIconSize. - 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
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 112
- Forks
- 13
- Last commit
- Oct 2026
Advanced
- Item type
- skill
- Key
repo-conventions- Source
- github.com/everpcpc/kmreader