iOS Networking
dpearson2699/swift-ios-skills
Build, review, or improve networking code in iOS/macOS apps using URLSession with async/await, structured concurrency, and modern Swift patterns.
KMReader subsystem conventions and invariants — reader state boundaries, reading-progress sync, offline downloads and caching, local database (GRDB) migrations, SSE dispatch, browse and dashboard…
$ npx skills add kmworks/kmreader --skill repo-conventions -a claude-codeProject install by default; add -g for ~/.claude/skills/.
$ gh skill install kmworks/kmreader repo-conventions --agent claude-codeProject scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).
$ git clone --depth 1 https://github.com/kmworks/kmreader.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.agents/skills/repo-conventions .claude/skills/repo-conventions && rm -rf skills-srcUse ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.
Claude Code skills documentation · loads skills from .claude/skills/
Install the "repo-conventions" agent skill from https://github.com/kmworks/kmreader/tree/main/.agents/skills/repo-conventions into .claude/skills/repo-conventions/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "repo-conventions", then confirm the skill loads.Claude Code copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$skill-installer install https://github.com/kmworks/kmreader/tree/main/.agents/skills/repo-conventionsType this inside Codex. $skill-installer <name> installs a curated skill from openai/skills. The installer writes to $CODEX_HOME/skills (default ~/.codex/skills). Restart Codex if the skill does not show up.
$ npx skills add kmworks/kmreader --skill repo-conventions -a codexProject install goes to .agents/skills/; add -g for ~/.codex/skills/.
$ gh skill install kmworks/kmreader repo-conventions --agent codexProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/kmworks/kmreader.git skills-src && mkdir -p .agents/skills && cp -r skills-src/.agents/skills/repo-conventions .agents/skills/repo-conventions && rm -rf skills-srcUse ~/.agents/skills/ instead of .agents/skills for a personal install.
Codex skills documentation · loads skills from .agents/skills/
Install the "repo-conventions" agent skill from https://github.com/kmworks/kmreader/tree/main/.agents/skills/repo-conventions into .agents/skills/repo-conventions/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "repo-conventions", then confirm the skill loads.Codex copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add kmworks/kmreader --skill repo-conventions -a cursorProject install goes to .agents/skills/; add -g for ~/.cursor/skills/.
$ gh skill install kmworks/kmreader repo-conventions --agent cursorProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/kmworks/kmreader.git skills-src && mkdir -p .cursor/skills && cp -r skills-src/.agents/skills/repo-conventions .cursor/skills/repo-conventions && rm -rf skills-srcUse ~/.cursor/skills/ instead of .cursor/skills for a personal install.
Cursor skills documentation · loads skills from .cursor/skills/, .agents/skills/, .claude/skills/, .codex/skills/
Install the "repo-conventions" agent skill from https://github.com/kmworks/kmreader/tree/main/.agents/skills/repo-conventions into .cursor/skills/repo-conventions/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "repo-conventions", then confirm the skill loads.Cursor copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gemini skills install https://github.com/kmworks/kmreader.git --path .agents/skills/repo-conventions--scope user (default) or --scope workspace; --path is the subfolder of the repo that holds the skill; --consent skips the security confirmation prompt.
$ npx skills add kmworks/kmreader --skill repo-conventions -a gemini-cliProject install goes to .agents/skills/; add -g for ~/.gemini/skills/.
$ gh skill install kmworks/kmreader repo-conventions --agent gemini-cliProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/kmworks/kmreader.git skills-src && mkdir -p .gemini/skills && cp -r skills-src/.agents/skills/repo-conventions .gemini/skills/repo-conventions && rm -rf skills-srcUse ~/.gemini/skills/ instead of .gemini/skills for a personal install, then run /skills reload.
Gemini CLI skills documentation · loads skills from .gemini/skills/, .agents/skills/
Install the "repo-conventions" agent skill from https://github.com/kmworks/kmreader/tree/main/.agents/skills/repo-conventions into .gemini/skills/repo-conventions/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "repo-conventions", then confirm the skill loads.Gemini CLI copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ gh skill install kmworks/kmreader repo-conventionsInstalls for Copilot at project scope by default; add --scope user for a personal install. Preview a skill first with gh skill preview. Needs GitHub CLI 2.90.0 or later (public preview).
$ npx skills add kmworks/kmreader --skill repo-conventions -a github-copilotProject install goes to .agents/skills/; add -g for ~/.copilot/skills/.
$ git clone --depth 1 https://github.com/kmworks/kmreader.git skills-src && mkdir -p .github/skills && cp -r skills-src/.agents/skills/repo-conventions .github/skills/repo-conventions && rm -rf skills-srcUse ~/.copilot/skills/ instead of .github/skills for a personal install. Commit .github/skills so cloud agent and code review can use it.
GitHub Copilot skills documentation · loads skills from .github/skills/, .claude/skills/, .agents/skills/
Install the "repo-conventions" agent skill from https://github.com/kmworks/kmreader/tree/main/.agents/skills/repo-conventions into .github/skills/repo-conventions/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "repo-conventions", then confirm the skill loads.GitHub Copilot copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
$ npx skills add kmworks/kmreader --skill repo-conventions -a opencodeOpenCode documents no install command of its own. Project install goes to .agents/skills/; add -g for ~/.config/opencode/skills/.
$ gh skill install kmworks/kmreader repo-conventions --agent opencodeProject scope by default (.agents/skills/); add --scope user for a personal install.
$ git clone --depth 1 https://github.com/kmworks/kmreader.git skills-src && mkdir -p .opencode/skills && cp -r skills-src/.agents/skills/repo-conventions .opencode/skills/repo-conventions && rm -rf skills-srcUse ~/.config/opencode/skills/ instead of .opencode/skills for a personal install.
OpenCode skills documentation · loads skills from .opencode/skills/, .claude/skills/, .agents/skills/
Install the "repo-conventions" agent skill from https://github.com/kmworks/kmreader/tree/main/.agents/skills/repo-conventions into .opencode/skills/repo-conventions/ in this project. Copy the whole folder (SKILL.md and every file beside it), keep the folder name "repo-conventions", then confirm the skill loads.OpenCode copies the folder itself, the same result as the manual copy. Check what it changed before you commit it.
repo-conventionsKMReader subsystem conventions and invariants — reader state boundaries, reading-progress sync, offline downloads and caching, local database (GRDB) migrations, SSE dispatch, browse and dashboard…
Repo Conventions is an agent skill from kmworks/kmreader. 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.
Its SKILL.md is about 20k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.
It sits in Documents & Office, covering Database schema design and Caching. It works with iOS and macOS. The repository describes itself as: A full-featured, native Komga client for iOS, macOS, and tvOS. The licence is MIT.
Read from SKILL.md and the folder at commit d977fc7. It shows what the files ask for, not the result of running them.
Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.
From allowed-tools in the SKILL.md frontmatter.
No scripts in the folder and no shell commands in SKILL.md.
From the folder's file list and the shell code blocks in SKILL.md.
No URLs in SKILL.md.
From URLs in SKILL.md, links to its own repository left out.
Names no API keys, tokens, secrets or passwords.
From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.
Repo Conventions loads about 20k tokens when it runs. Until then it costs about 124 tokens; SKILL.md has 10,671 words of instructions outside code blocks.
Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.
The automated check found no risky patterns in SKILL.md.
Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.
The full file from kmworks/kmreader at commit d977fc7, republished under its MIT licence (© kmworks). 10,671 words, ~19,969 tokens.
.claude/skills/repo-conventions/SKILL.md (or your agent's skills folder).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).
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.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).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.ReaderPositionAnchor.preferredSplitPart and ReaderViewItem.preferredSplitPart(preserving:) whenever adapters construct a new anchor.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.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.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.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.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.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.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.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.WebPubPagedCurlView/WebPubPagedCoverView) run one live EpubPageViewController web view per chapter plus preloaded boundary neighbors (≤3 web views), never one per page; in-chapter turns are JS scrolls on the live view. In curl, UIPageViewController slots are dumb shells (CurlPageShellViewController): a chapter-internal flip covers the front shell with a snapshot of the current page and migrates the web view's view into a pre-sized target shell (a zero-size pass would trigger a throwaway re-pagination), so the reveal and the committed page are the same live view; a cross-chapter flip keeps the live leaf and reveals the preloaded neighbor. Interactive turns commit or restore in didFinishAnimating, programmatic turns in their own setViewControllers completion, and the internal pan recognizers carry a target that discards dataSource prefetches which never became a curl (handleInternalPanState).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).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).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.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.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.OfflineManager.getOfflinePageImageURL throws; the try? callers treat it as plain absence), or offline unavailability. View code never invents its own reason text.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.ReaderViewModel.nextBookOfflineState is the single observable for the next book's offline readiness, rendered by end-page/footer UIs.isHidden, which would re-lay out the page; in streaming mode the slot collapses entirely.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.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.NextBookToReadResolver makes the single /next request with skipRead=true (servers that support it skip read books themselves); when the returned book is read (servers that ignore the parameter, such as Komga), the local projection supplies the first unread book after the current one, never adding a request. Offline, and for the local continue-reading target, the same rule runs on the local projection (Collection.nextToRead(after:isRead:)). An appended next segment records the book it follows as its previous book, so a skipped book never lands between two segments.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.updatePresentedBook records only when the book id changes, not on refreshes of the same book).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.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.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.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.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.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.await ProgressSyncService.syncPendingProgress (it waits for an in-flight push), so a pull never overwrites newer offline-queued local progress.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.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.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.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.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.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.CodingKeys, then add a migration backfilling a safe default. Validate both upgrade and fresh-install paths.read_list_reading_states) is user state, kept apart from the KomgaReadList server mirror.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.isStopped tombstone) — then pushes the local changes that won, so neither a pull nor an offline stop discards a newer change.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.ReadListReadingService records, syncs, and resolves nothing and publishes an empty snapshot (no local reads or writes, no client-settings requests)./api/v1/stats/reading/* endpoints (kmrs 0.17.0+; absent on Komga) and fall back to local GRDB aggregation. The first fetch of the three endpoints doubles as the capability probe: a 404 from any of them records the instance as unsupported and the same load is then served locally. Per-instance verdicts live in AppConfig.serverReadingStatsCapability (UserDefaults); an unsupported verdict expires after 24h so a server upgrade is picked up automatically, and offline mode never probes. The fetch result carries its ReadingStatsDataSource, persisted on the cached snapshot, so the view can hide local-sync hints whenever the data came from the server./api/v1/smart-lists/*) are kmrs-private saved searches evaluated live server-side, absent on Komga (the whole API 404s). Support is probed per instance: the first SmartListsViewModel load doubles as the probe, a 404 records ServerSmartListCapability in AppConfig.serverSmartListCapability for 24h (the same verdict pattern as komf/reading stats), and a marked-unsupported instance skips the doomed request..smartListsDidChange. Fetched members DO upsert into the books/series tables like every other fetched page (SyncService.syncSmartListBooks/syncSmartListSeries).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).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.ListsBrowseView, hidden when unsupported/empty/offline — the library scope does not filter it), the full browseSmartLists page (SmartListsBrowseView, client-side name search, no sort/filter/library scope — the endpoint is name-sorted with no query params), and SmartListDetailView (browse + read + edit/delete for owner/admin; no pin, download, or selection mode). Create/edit goes through SmartListEditSheet (entry points: the browse page's trailing + and its empty state, the detail-page menu, card/row context menus): the target is fixed after creation, no sort is stored (chosen at browse time — the options sheets take showsSort: false, which also hides preset saving since presets write the shared browse-options keys), and visibility/share-target picking is admin-only (non-admins always create private lists). On update the sheet sends search only when the filter subsection diverges from its parsed baseline, and the reverse mapping's lossy flag shows a warning that any filter change replaces the whole stored document — web-UI-built filters the editor cannot represent survive a rename. Member lists revalidate on every book/series projection change and on .smartListMembershipDidChange, never gated on isSensitiveToReadingProgress: the stored server-side filter may be read-status-based even when the overlay options are not.dashboard.libraryIds, empty = all libraries) is the persisted aggregate scope — per-instance via DashboardLibrarySelectionStore (UserDefaults dashboard config + GRDB komga_instances.selected_library_ids_raw), edited through the checklist in LibraryPickerSheet and consumed by the Dashboard, the iPhone Library/Search tab roots, Offline, search, and widgets. The session scope (a LibraryBrowseScope of .pinned/.all/.library(id), default .pinned, resolved via resolvedIds(pinned:)) is per-surface view state: the Dashboard keeps it in DashboardLibraryScopeStore.shared, the iPhone Library/Search tabs, Offline, and pushed browse pages keep it in local @State, and iPad/macOS browse roots take it from the shell (next bullet); never persisted, the dashboard's resets in DashboardLibrarySelectionStore.loadSelection (app start and server switch) and when the scoped library vanishes from the loaded list (browse pages reset the same way); dashboard sections fetch with effectiveLibraryIds(pinned:) while widgets always follow the pinned set.LibraryBrowseScope.title(pinnedIds:libraries:) / .facts(pinnedIds:libraries:allLibrariesEntry:) (the shared title text and covered-metrics item; metrics are admin-only: .all/empty-.pinned read the server's all-libraries entry since overlapping roots make client-side sums overstate file size, a non-empty .pinned sums the covered libraries client-side, .library(id) uses the library's own row). The Dashboard shows LibraryScopeHeader (Dashboard/Views): icon matching the scope menu (books.vertical for All/a library, pin for Pinned), the title, and a single truncating facts line (file size first, then series/books — no sidecars — via LibraryMetricsText.sizeAndMetrics). Browse pages and Offline instead show a caption trailing the content-type chip — the scope title set off from the facts that follow in secondary: just the file size on browse pages (the series/books counts ride the chip itself in parentheses, from the same scope facts item; collections/read lists carry no counts), just the total downloaded size on Offline (the downloaded series/books counts ride the chip in parentheses, from fetchDownloadedSeriesCount and fetchOfflineBooksStats(instanceId:libraryIds:) — both matching their offline list filters, refreshed on scope/instance change and on DownloadProgressTracker.queueUpdateToken). LibraryScopeStore.refreshMetrics reloads the metrics (admin-gated, no-op offline) on every dashboard refresh and browse-page library refresh; full reloads stay with LibraryPickerSheet and Settings → Libraries. Library metrics prefer the kmrs-native stats endpoints (/api/v1/stats/libraries for per-library counts and the all-libraries total) and fall back to the Komga actuator metrics (komga.books.filesize/komga.series/komga.books, untagged totals plus per-library tags) when they 404 — the fallback covers only library size/series/books, so sidecar counts simply never appear on Komga; per-instance verdicts live in AppConfig.serverStatsCapability (24h recheck, the same probe pattern as reading stats), and the stored numbers are cleared only when both sources fail, so previously stored numbers never linger. Server metrics come only from /api/v1/stats/server — task, process, and global stats — with no actuator fallback. Sidecar counts (per-library and total) ride the /stats/libraries payload but are only sent to admins; the payload carries no collections/read lists counts, and library-level RL/C counts are not surfaced anywhere. The Server Info page works on both servers: on kmrs it loads /stats/server (process snapshot, content totals, per-type task metrics) plus the actuator info/health endpoints, and on Komga the same rows fall back to per-metric actuator reads (process.* for CPU/uptime/start/memory — CPU percent vs 0-1 ratio told apart by the meter's baseUnit — and the komga.* gauges for content); the shared /stats/server 404 probe (AppConfig.serverStatsCapability) picks the path. The overview shows server identity (version, commit, OS), process and resources (start time, uptime, CPU, memory, disk), and content totals, plus maintenance actions (log file download via share sheet, hidden when the HEAD probe 404s as on stock Komga; server shutdown behind confirmation); the task queue, sessions, and scheduled tasks are sub-pages — Tasks pairs the SSE live queue and Cancel All with the per-type execution metrics (runs/total/longest/failures, from /stats/server on kmrs and the komga.tasks.* meters on Komga), Sessions lists /actuator/sessions with per-session kick (kmrs lists all users, Komga rejects the cross-user listing and falls back to the current user's ?username= query), Scheduled Tasks lists the actuator fixed-rate registrations (Java FQN targets shortened to their meaningful tail). A failed load keeps the previously stored numbers.NavDestination.browseLibrary). On iPad/macOS the Libraries section leads with the aggregate entries — "All Libraries" (NavDestination.browse(scope: .all)) and, only while the pinned set is non-empty, "Pinned" (.browse(scope: .pinned)) — inside the same section as the individual libraries, because the tab bar's Libraries item opens the section's first tab (a separate aggregate section would land on the first library instead); the aggregate rows are plain text like the library rows (no icons, no separator — the iPad TabSection accepts only tabs), and the shells redirect a live pinned-aggregate selection to All when the last pin is removed. Browse roots on those platforms take their scope from the shell through the \.libraryScopeBinding environment key (a value-type Binding<LibraryBrowseScope> provided by PadTabView/MainSplitView): the scope menu writes through to the shell's selection, so an in-page pick switches sidebar row/tab and the highlight follows. Pushed browse pages get the binding cleared to nil (in View+Navigation) and keep a page-local session scope, the pushed selection only its initial value; on iPhone, pushed single-library pages carry no scope menu.checkmark.circle button next to the filter chip row; tvOS always disables it (supportsSelectionMode), and the online-only modes hide it while offline.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.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.syncSeriesDetail/syncAllSeriesBooks + postSeriesBooksDidChange; the cross-series books browse: syncVisitedItems + postBooksAndSeriesDidChange), followed by one DashboardSectionRefreshNotifier.postReadStatusChanged and a full list reload.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:).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.Collection.localizedSorted(); authors via Author.sortedByRole(). Never revert to raw .sorted(). (MetadataIndex encode keys and SQL clause ordering intentionally keep plain .sorted().)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:).DashboardSectionLayout owns the gradient band, the header navigation link (title + chevron, optional DashboardCardKindMenu), the horizontal card strip, and the empty-collapse (opacity + zero height while isEmpty). Item mutations animate through withAnimation in the loading view models (applyPage/removeItem/cache seed), which drives both the band's collapse/expand and the strip's per-item interpolation. The band's vertical rhythm comes from LayoutConfig — top padding above the header (dashboardSectionTopPadding) and bottom padding below the cards (dashboardSectionBottomPadding(gradientBackground:), which shrinks to the top value without the gradient since the band edge is invisible), a tighter header-to-cards gap (dashboardSectionHeaderSpacing), and no inter-band spacing, so adjacent gradient bands touch and each band's gray gradient edge is the section separator (Apple Books style). The scope header (LibraryScopeHeader) is a plain row above the bands, not a band itself; its padding stays minimal so it reads as a caption under the navigation title, not as another section. On macOS the strip's vertical padding sits outside the ScrollView so the hover scroll arrows' (macHorizontalScrollButtons) overlay hugs the cards — iOS/tvOS keep it in the scroll content so the padding area still drags the strip; the arrows center on the cover block, which DashboardSectionLayout derives from the section's effective card kind (grid cards carry metadata below the cover, horizontal cards are cover-tall and center on the strip).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..readingProgress; other sections skip unless browse options are progress-sensitive (isSensitiveToReadingProgress).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).isLoading guards that drop a re-run..refreshable closures must await the reload they trigger, so the refresh control dismisses onto settled content instead of racing in-flight view updates..refreshable re-renders, taking the action's child tasks (and their network requests) down with it. The refreshable-carrying body must therefore read no state the reload mutates: refreshing view models stay on the shell, while the content that reads them is a child view (ListsBrowseView shell + ListsBrowseContentView strips). The Dashboard's body DOES read scopeStore while refreshDashboard mutates it — it survives because LibraryScopeStore.load() skips no-change assignments, so in practice the body does not re-evaluate; keep that guard intact when touching the store. Its body must likewise never read DashboardViewModel's section state — only the section views may. Re-evaluating a child's body during the refresh is fine — only the carrier's body matters.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.refreshableWithMinimumHold so the control stays up for a minimum visible time instead of snapping back.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.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.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.ReadListContinuationContextMenu, ReadListContinuationProgressText, and ReaderActions.open(continuation:).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.ReadListContinuationHintView pointing to the setting, injected through the detail view's actions slot.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.LayoutConfig); there is no free-form density slider.LayoutConfig.cardTextStyle(cardWidth:)/cardBadgeSize, and card views receive their width (cardWidth, defaulting to gridCardWidth) rather than a style flag.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.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.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).coverOnly): every text line truncates at that width and stops carrying information, and card text overlay mode never renders on them.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..shimmer(cornerRadius:)): the singleton ShimmerClock keeps every visible skeleton in lockstep and stops when the last one leaves, so off-screen skeletons cost nothing; Reduce Motion falls back to the static placeholder. A failed load stays static — shimmer reads as a load still in flight. The thumbnail's overlay slot (text overlay, corner badge) renders on the placeholder as well, so a card stays identifiable while its cover is still loading.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.navDestination), which leads the menu alongside Peek (incognito) and series navigation. Series cards navigate to their detail page on tap.komf endpoints (/api/v1/komf/*) are kmrs-private and admin-only, absent on Komga servers. Availability is probed per instance by KomfIntegrationStore; a 404 records ServerKomfCapability in AppConfig.serverKomfCapability for 24h. The probe runs once per instance at ContentView's per-instance startup and again on detail-page visits, latching only a connected state (and the 24h negative record) so a later connection is picked up on the next visit; probes are fire-and-forget and iOS/macOS-only. Menu items render only while connected.
komf actions live in a Komf submenu (verbatim title, product name): the series and oneshot detail ellipsis menus carry Identify / Match / Reset Metadata (iOS/macOS only), and SeriesContextMenu carries a sibling Komf submenu next to Manage with Identify / Match (Reset stays on detail pages, which already own the confirmation alert). Identify sheets are plumbed through onKomfIdentifyRequested like onEditRequested.
KomfIdentifySheet parses provider links from series.metadata.links (oneshot: the single book's links when the series has none) into KomfProviderLink submissions, mirroring kmweb.
Jobs are tracked by polling in KomfJobTracker (kmweb uses the SSE firehose; the app only needs its own jobs). Tracking is bound to the submitting instance — a server switch stops polling silently. Completion runs the same refresh chain as an edit save (SyncService.syncSeriesDetail → ContentProjectionNotifier.postSeriesDidChange → DashboardSectionRefreshNotifier.postSeriesContentChanged); server-side SSE events already cover projection updates while SSE is on.
Detail page header zones follow the measured content width: hero, action card, and timestamps center below LayoutConfig.detailWideLayoutMinimumWidth and lead above it, driven by the detailHeroCentered environment value injected by each content view (each measures its own width via onGeometryChange; wide two-column rails inject true); the content sections below (summary, chips, media information, membership sections) always stay leading. Pages follow a fixed hierarchy: hero (cover with title, author chips, and quiet DetailMetadataRow facts), a DetailActionCard grouping reading state with primary actions (the page's single focal point; the series continue-reading button and SeriesDownloadActionsSection are injected by SeriesDetailView through the content view's actions slot), summary, DetailChipFlowSection chips for relational metadata (genres/tags/publisher/external links), headline sections (alternate titles, BookMediaInfoSection media information, DetailMembershipSection membership rows).
On book detail pages (book and oneshot) the hero's series title is the series navigation — a plain icon + title + chevron NavigationLink styled like the dashboard section headers, hidden when the page is presented in a sheet (the sheet's own book/series toggle covers it); there is no View Series button. The action block leads with the shared ReadingActionButton — a prominent capsule on its own row filling the action card, a two-line label (Start Reading, or Resume Reading while the book is in progress, over a detail line with the reading progress Page X · Y%, falling back to the page count), the same component the series continue-reading button uses — with Peek and the download toggle as a secondary row beneath; the whole block centers below the detail wide-layout minimum width and leads above it (where the download status chip sits at the trailing edge of the card's state line).
Headline-section titles are plain Text(...).font(.headline) in primary color with no icon; an icon-tinted secondary header reads as another row of the section above. Membership sections render nothing while empty and carry their own top padding to separate from the block above.
Alternate titles render through the shared SeriesAlternateTitlesView (series detail in both layouts, and the oneshot detail page); more than 2 entries collapse behind a Show More/Show Less toggle. Membership sections (DetailMembershipSection: read lists on book/oneshot pages, collections on series/oneshot pages) likewise collapse beyond 3 entries. The toggle is the shared ExpandToggleButton, also used by ExpandableSummaryView.
DetailTimestampsView sits directly under the DetailActionCard (ReadList/Collection cards carry the count line — ReadListBookCountView/CollectionBookCountView — with ReadListDownloadActionsSection injected through the read list content view's actions slot).
Chips use DetailChip: a small neutral capsule with no per-field colors; hero creator chips (publisher/authors) and external-link chips render with glassEffect on iOS/macOS/tvOS 26+ (falling back to a quiet secondary fill on older OS), while genre/tag chips always use the secondary fill (glass: false); icons only where they carry meaning (author role, external link). Neutral fills (chips, action cards, membership rows, count badges) share one constant, LayoutConfig.neutralFillColor.
State color appears only as text: green/orange/red and MediaStatus.detailColor/series statusColor in the action card; static metadata stays secondary.
The scrolling content always carries .padding(.vertical) (horizontal padding stays per-section); in the wide two-column layout each column's ScrollView content pads itself.
Detail-page ScrollViews use an eager VStack, never LazyVStack: the only lazy content is the books/series list, which carries its own lazy containers, and a LazyVStack transiently misplaces children during animated section updates.
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.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).LibraryBrowseView, a thin wrapper over BrowseView(libraryTab: true, libraryIds:libraryTabScope:) holding the tab-local LibraryBrowseScope state; the scope re-filters the root in place (no push). The iPhone Search tab root is SearchBrowseView, the same wrapper shape over BrowseView(searchOnly: true) (menu only, no scope header). The iPhone Lists tab root is ListsBrowseView: Collections, Read Lists, and Smart Lists as horizontal strips (pinned items first, fed by the same CollectionsViewModel/ReadListsViewModel/SmartListsViewModel as the full pages), whose section headers link to the single-type browseCollections/browseReadLists/browseSmartLists pages; the library scope filters the collections/read lists strips (smart lists have no library filter). It splits shell from content like the Dashboard: ListsBrowseView owns the view models and carries .refreshable, while ListsBrowseContentView reads them and renders the strips, so the refresh action survives the reload (see Pull-to-Refresh). iPad/macOS roots open the same page via NavDestination.browseLists (whose libraryScopeBinding is nil, so its scope stays page-local on every platform, unlike the shell-synced browse roots), and tvOS gets its own Lists tab; Dashboard section headers still push the single-type pages. Remote collection/read-list SSE events debounce (5s) a full projection sync through ListProjectionSyncService, since no dashboard section owns them. LibraryScopeMenu is the shared scope dropdown on the Dashboard, Offline, the iPhone Library/Search tabs, and every iPad/macOS browse page (All / Pinned / single-library scope switch + Pin Libraries, driven by a LibraryBrowseScope binding — shell-owned on iPad/macOS roots, page-local elsewhere), always shown regardless of library count, a single library titled with its name. It is a filter, not a page title: icon-only on iOS and macOS (the tvOS dashboard header shows the scope title instead), on iPhone at the trailing toolbar edge immediately left of the actions menu (tab roots and pushed browse pages alike), on iPad and macOS at the leading edge (.cancellationAction / .navigation). The icon is the lines glyph (line.3.horizontal, near-square so the glass capsule stays round) for all libraries, line.3.horizontal.decrease for a single library (pinned or scoped), and checklist for a multi-library pinned subset; the scope name is carried by the accessibility label and hover tooltip. Inside the menu, individual library rows never carry icons — glyphs are reserved for the All/Pinned entries (books.vertical for All Libraries, pin for Pinned; Pinned is offered only while pins exist), separated from the library rows by a divider. Content-type switching is always the BrowseContentTypeMenu chip (left-aligned), never a segmented picker — Offline's series/books switch included. The chip only ever offers series/books: collections and read lists browse lives on the Lists page (present on every platform, tvOS included), and RL/C search lives on the full single-type pages.NavDestination.browseLibrary carries its LibrarySelection in the destination value; do not reintroduce side channels into BrowseView. Explicit library scopes (the tab root) go through BrowseView(libraryIds:) — never closures in the environment. The iPad/macOS shell-to-page scope sync uses the \.libraryScopeBinding environment key (a value-type binding, not a closure): the shell maps scope writes back to its own selection, including the aggregate All/Pinned entry.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.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.browseLibrary) offers only the Series/Books content tabs; collections and read lists live at the shell's top level.NavDestination.settingsReadingStats; tvOS additionally keeps the dashboard header button.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.DetailTimestampsView row follow the same value; the card itself is width-capped, centered in centered mode and leading otherwise.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.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.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.SeriesDetailContentView.SeriesHeroInfoView, SeriesBookCountView, SeriesSummaryView, SeriesDetailChipsView, SeriesAlternateTitlesView).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.Spacer-pushed trailing cluster.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.ToolbarSpacer). Everything else lives inside the ellipsis menu; sheet/alert presentations from menu items must go through deferMenuActionPresentation. The Dashboard's trailing item is the Settings gear on iPhone only; iPad already has a Settings tab/sidebar entry, and macOS has the Settings scene, so neither shows the gear. Settings stays reachable in offline mode, and its former ellipsis entries moved out (Reading Stats to the Settings page, the offline-mode toggle to the Settings toolbar on iPhone and the Server page elsewhere) — and the toolbar gains no offline/refresh buttons on top of it (iPad toolbar space). The offline status is instead a tappable reconnect banner at the top of the dashboard content (iOS/macOS; tvOS keeps it in the header). The browse pages (Library tab, BrowseView, OfflineView) carry the same trailing ellipsis menu on iOS and macOS, BrowseActionsMenu: a copy of the chip row's layout picker, presets, and filter entries, binding the same per-type *BrowseLayout AppStorage keys as the chip row's LayoutModeMenu so both stay in sync without re-threading state. Never add standalone filter toolbar buttons — buttons pushed into the system overflow menu can no longer present their sheets (the dropped presentation sticks the driving state flag, leaving the button dead until the view is recreated). On the iPhone Search tab the menu is always present, but its Filter item stays disabled until a query is submitted — the filter sheet is hosted by the per-type chip row, which does not exist while the search placeholder is showing (a presentation flag set there would stick with no sheet to consume it).OfflineManager.enterManualOfflineMode() (drops pending dashboard auto-refreshes, flips the manual flag, disconnects SSE); offline it reconnects via AuthViewModel.reconnect() (probes the server first — a failed probe keeps the offline classification — then flips back online, reconnects SSE, and reloads dashboard sections; the Dashboard's reconnect banner shares it). Reading Stats sits above the Reader group everywhere (a SettingsSection.readingStats case, including the SettingsView_macOS sidebar).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.SettingsSystemFeaturesView groups Handoff and Spotlight indexing and is not linked on tvOS; new pages register a SettingsSection case and use SettingsBadgeRow/SettingsSectionRow entries.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.clamp(chars/14, 5, 8)s for long text, 5s with an action button.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.© kmworks, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file
Just SKILL.md in .agents/skills/repo-conventions of kmworks/kmreader.
Open the folder on GitHubat commit d977fc7
Repo Conventions next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.
| Skill | Stars | Used in | Tokens | Auto-check | Licence | Repo updated |
|---|---|---|---|---|---|---|
| Repo Conventions this skillkmworks/kmreader | 113 | — | ~20k | Automated safety check: Pass | MIT | |
| iOS Networkingdpearson2699/swift-ios-skills | 1.2k | — | ~4.2k | Automated safety check: Pass | Custom licence | |
| Image Loadinggustavscirulis/snapgrid | 116 | 1 repos | ~1.6k | Automated safety check: Notes | Custom licence | |
| Swiftui Whats New 27CamilleScholtz/swmpc | 239 | — | ~1.1k | Automated safety check: Pass | EUPL-1.2 | |
| Stellar iOS Mac SDKSoneso/stellar-ios-mac-sdk | 132 | — | ~4.3k | Automated safety check: Pass | Apache-2.0 | |
| Swiftdata ArchitectureKartikLabhshetwar/better-shot | 2.4k | 2 repos | ~1.2k | Automated safety check: Pass | Custom licence |
dpearson2699/swift-ios-skills
Build, review, or improve networking code in iOS/macOS apps using URLSession with async/await, structured concurrency, and modern Swift patterns.
gustavscirulis/snapgrid
Generates an image loading pipeline with memory/disk caching, deduplication, and a CachedAsyncImage SwiftUI view.
CamilleScholtz/swmpc
New SwiftUI features and patterns for iOS 27 and macOS 27: @State macro, reorderable containers, AsyncImage caching, and new swipe actions.
Soneso/stellar-ios-mac-sdk
Guides Stellar blockchain development in Swift using stellar-ios-mac-sdk.
KartikLabhshetwar/better-shot
Deep dive into SwiftData design patterns and best practices.
flaqai/backlink_skills
SPD V1 Batch. An agent skill from flaqai/backlink_skills.
kmworks/kmreader
Coordinate the KMReader App Store release workflow. An agent skill from kmworks/kmreader.
kmworks/kmreader
A skill your agent uses when updating KMReader translations in Localizable.xcstrings after code changes.
kmworks/kmreader
Create and merge KMReader GitHub pull requests. An agent skill from kmworks/kmreader.
kmworks/kmreader
A skill your agent uses when observing or driving an iOS simulator for KMReader debugging — screenshots, accessibility tree, tap/swipe/gestures, hardware buttons, orientation, and unified logs via…
kmworks/kmreader
Distribute KMReader builds to TestFlight groups via the asc CLI.
kmworks/kmreader
Generate App Store changelog text from commits between the latest tag and HEAD.
KMReader subsystem conventions and invariants — reader state boundaries, reading-progress sync, offline downloads and caching, local database (GRDB) migrations, SSE dispatch, browse and dashboard…. Repo Conventions is an agent skill from kmworks/kmreader. 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.
Repo Conventions fits situations like: working on the reader (DIVINA/PDF/EPUB engines; offline features; the local database schema; dashboard sections.
Run `npx skills add kmworks/kmreader --skill repo-conventions -a claude-code`. Or copy the skill folder (.agents/skills/repo-conventions in kmworks/kmreader) into .claude/skills/repo-conventions in your project. Claude Code loads it when a task matches its description.
Run `npx skills add kmworks/kmreader --skill repo-conventions -a codex`. Or copy the skill folder (.agents/skills/repo-conventions in kmworks/kmreader) into .agents/skills/repo-conventions in your project. Codex loads it when a task matches its description.
Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add kmworks/kmreader --skill repo-conventions -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/repo-conventions, .gemini/skills/repo-conventions, .github/skills/repo-conventions and .opencode/skills/repo-conventions in your project.
SKILL.md names no scripts, command-line tools or credentials: Repo Conventions is instructions for the agent only.
SKILL.md contains no URLs. Any network use would come from the scripts or tools the agent runs. This is read from the text; nothing was executed.
Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.
Repo Conventions is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.
About 20k tokens (SKILL.md is roughly 80k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.
Skills that share tags, products or a category with Repo Conventions: iOS Networking (dpearson2699/swift-ios-skills, 1.2k stars), Image Loading (gustavscirulis/snapgrid, 116 stars), Swiftui Whats New 27 (CamilleScholtz/swmpc, 239 stars) and Stellar iOS Mac SDK (Soneso/stellar-ios-mac-sdk, 132 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.
kmworks (a GitHub organization) maintains it in kmworks/kmreader, which has 113 GitHub stars. The repository holds 8 skills in this directory. The repository was last updated on October 10, 2026.
Source: kmworks/kmreader on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.