Architecture
Tankōbon is an Electron app (Vite + Electron Forge) for managing and reading digital comics (CBZ, CBR, PDF). This page is hand-written: it explains why the code is shaped the way it is. The what (signatures, types, IPC channels, database schema) is generated from the code, under Reference. Structural decisions are recorded as ADRs.
Processes and boundaries
The app follows the standard Electron three-process split, with a strict boundary: src/main.ts creates the window with contextIsolation: true, nodeIntegration: false and sandbox: true (see ADR 0001).
| Layer | Files | Role |
|---|---|---|
| Main process | src/main.ts, src/main/ | Owns the window, the SQLite database, the dialogs and the filesystem walk. Never parses a comic file. |
| Decoder process | src/main/decoder/, src/main/services/ | An Electron utilityProcess that opens archives, decodes pages and makes thumbnails (see Decoder process). |
| Preload | src/preload.ts | The only bridge between renderer and main. Exposes a single window.tankobon object via contextBridge. |
| Renderer | src/renderer.tsx, src/routes/, src/hooks/, src/components/, src/lib/ | React 19 UI; talks to main exclusively through window.tankobon. |
| Shared types | src/shared/ | Types used on both sides of the IPC boundary (ComicInfo, LibraryEntry, AppSettings…). |
The window is also locked to the app's own page: src/main.ts cancels will-navigate to any other URL and denies every window.open/middle-click new window, and src/renderer.tsx cancels the default of dragover/drop on the document (the components' own drop handlers still run), so dropping a PDF or an HTML file on the window can't replace the UI. The CSP in index.html adds base-uri 'none', form-action 'none' and object-src 'none'.
src/main.ts opens the database once, builds the repositories and the thumbnail cache, registers the IPC handlers of src/main/ipc/*.ts, then creates the window.
One instance at a time: main.ts takes app.requestSingleInstanceLock() first; a second launch quits at once and the running instance (second-instance) restores and focuses its window, so two processes never write the same database file or run two decoders on the same library. The connection also sets PRAGMA busy_timeout (configureConnection(), schema.ts, 5 s), so a write that meets a lock held by something else (a sync tool, a second machine's copy) waits instead of failing with "database is locked". The journal mode is left at SQLite's default: WAL is unsafe on the synced folders the database location may point at.
Start-up loader: index.html ships a static spinner inside #root (inline CSS, which the CSP's style-src 'unsafe-inline' allows; light/dark through prefers-color-scheme, since the settings aren't loaded yet). The window shows it while src/renderer.tsx waits for the settings and the language, and React's first root.render() replaces it, so there is nothing to hide.
IPC channels
Channel-name constants (e.g. COMIC_CHANNELS, LIBRARY_CHANNELS) live in the main-process IPC files, but the preload can't import them (different process and build target), so the strings are duplicated in src/preload.ts. Both sides must be kept in sync by hand; npm run docs:gen fails if they diverge, and generates the IPC reference.
The shape of window.tankobon is declared up front as explicit interfaces (TankobonApi, then one per namespace: ComicApi, LibraryApi…), and the api object only implements them. The method descriptions live on those interfaces, once: TypeDoc renders them as the API reference, and npm run docs:gen reads the same comments for the IPC reference's description column. A new method needs its signature and TSDoc in the interface, then its implementation in api.
Handlers that open a native file or folder dialog go through openDialogFor()/saveDialogFor() (src/main/ipc/dialogs.ts), which make it modal to the window that sent the request when it still exists.
Everything is invoke/handle (request/response), except three main → renderer push channels: library:scan-progress (see Folder scanning), library:image-scan-progress (see Page sizes) and data:changed (see Renderer data store).
The "À propos" section gets the app's version (app.getVersion(), main process only) and the runtime versions from app:get-info, as an AppInfo (src/shared/app.ts). Its links go through app:open-link, which takes a key of APP_LINKS (repository, documentation, releases, issues, license), never a URL: the main process only ever passes those fixed pages to shell.openExternal, so a compromised renderer can't make it open anything else. The license link opens the MIT LICENSE of the repository, which forge.config.ts also copies into the packaged app's resources (extraResource), since the license asks for its text to ship with every copy.
Update check: app:check-update (checkForUpdate(), src/main/services/update-checker.ts) asks GitHub's API for the latest release of the repository from the main process (the renderer's CSP allows no connection, and getJson() gives the translated errors) and compares its tag with app.getVersion() through the pure isNewerVersion() (src/shared/app.ts); the answer is an UpdateCheckResult, a failure being an error member, not a throw. The root layout's UpdateToast calls it once per window when AppSettings.checkUpdatesOnStartup is on and offers the releases link; Paramètres › À propos has the setting and a manual check. Nothing is downloaded by the app.
Opening a file from the OS: comicPathFromArgv() (src/main/launch-files.ts, pure) reads the supported comic from a command line, and main.ts feeds it three sources: this launch's process.argv, the second-instance event's argv (with its working directory) and macOS's open-file event (which can fire before the app is ready, hence a small queue). Each path goes to LaunchFiles.offer() (src/main/ipc/launch-files.ts), which registers it in the PickedFiles shared with comic:pick-file (picked-files.ts: the token store extracted from comic.ts, 20 tokens) and keeps the latest token. The renderer never receives a path: useLaunchFile() (root layout) calls app:take-launch-file at start-up and again on each app:launch-file push (an announcement without payload, so a window still loading loses nothing), then navigates to /reader?book=<token>. The token is registered with addToLibrary = false, so the addOpenedBooksToLibrary setting decides. File types are declared for packages by forge.config.ts (mimeType of the deb/rpm makers, extendInfo.CFBundleDocumentTypes on macOS); Squirrel (Windows) can't register types.
The app:get-system-languages channel returns the OS's preferred languages, so the renderer resolves the system language setting against the same list as the main process (see Interface language).
Arguments are validated in the main process (ADR 0009): every handler is registered with handle(channel, parse, fn) (src/main/ipc/handle.ts, ADR 0011), where parse is built with args() from the guards of src/main/ipc/validate.ts and gives fn its typed arguments (a refusal, including a wrong argument count, rejects with an IpcArgumentError, never shown to the user); scripts/gen-reference.mjs only counts a channel as handled through handle(). Settings are checked key by key by SETTING_VALIDATORS (src/shared/settings.ts), shared by settings:set and the JSON import; the renderer never names a filesystem path: comic:pick-file returns an opaque token for the file picked in the dialog and comic:open takes that token or a library entry id. settings:get-all returns PublicSettings, without the metadata API keys, which the Métadonnées page reads through settings:get-api-keys. A new setting therefore needs its validator, and a new handler its guards.
The sender is checked too: handle() also calls assertTrustedSender() (src/main/ipc/trusted-sender.ts), which refuses a call that doesn't come from the main frame of the application's own page (the dev server's origin, or the packaged index.html, declared once by main.ts through setAppUrl() before the handlers are registered). The window can't navigate away from that page already, so this is defence in depth: a foreign page or a sub-frame that ever loaded couldn't reach the main process. Until setAppUrl() is called (the unit tests of a single handler) nothing is checked.
Errors that the user should see come back as a { status: 'error', message } member of a result union (OpenComicResult, ImportResult, ExportResult, ClearLibraryResult, DatabaseLocationResult, MetadataSearchResult, ReadingListResult, ResyncResult) rather than as a thrown error: an ipcMain.handle rejection reaches the renderer wrapped in "Error invoking remote method …".
ArchiveInfo is what ComicService knows about an opened archive before it's matched to a library entry; the comic:open handler (src/main/ipc/comic.ts) merges it with the library entry to produce the ComicInfo sent to the renderer. A file that can't be opened (moved, deleted, corrupted, unsupported) comes back as an OpenComicResult error, translated by openErrorMessage() (src/main/services/open-error.ts: a dedicated message for ENOENT, otherwise the underlying reason), which useComic() shows in the reader. If the book was opened in the decoder process but recording the opening fails afterwards (read-only database, file gone), the handler closes that archive instead of leaving it open until the app quits. The tokens of comic:pick-file are kept for the last 20 picks only. When the failed book is a library entry id (found in useLibrary()), the reader's empty state also offers to remove that entry (useConfirm(), then library.remove()), then returns to the library.
Interface language
The UI is translated with i18next (and react-i18next in the renderer), in English and French (ADR 0008).
- One module, one instance per process:
src/shared/i18n.tscreates an i18next instance loaded synchronously with every locale (they are bundled: nothing is fetched,t()works from the first line). The main process and the renderer each get their own copy of the module, hence their own instance, switched byapplyLanguage(). It starts in English (FALLBACK_LANGUAGE), which is also what a string missing from a locale falls back to. - Locales split by area:
src/locales/<language>/<namespace>.ts, one namespace per area of the app (library,lists,achievements,reader,settings,metadata,bookEdit,bookfor field and role labels,nav,common, and the main process'serrorsanddialogs), gathered bysrc/locales/<language>/index.ts. Keys are typed (CustomTypeOptions, from the English locale): a mistyped key failsnpm run typecheck. Each French namespace is typedTranslation<typeof en>(src/locales/types.ts), so a key missing from or added to one locale only fails too;i18n.test.tsalso checks that both locales use the same. Plurals use i18next's_one/_othersuffixes with acount, so French and English each get their own rule (French singular for 0 and 1). - Which language:
AppSettings.languageissystem(the default) or a supported language.resolveLanguage()(pure, tested) returns the forced language, else the first of the OS's preferred languages whose base (frinfr-CA) is supported, else English; an unsupported stored value counts assystem, and the import turns it back intosystem. The main process applies it at start-up and whenever the setting is written or imported (applyMainLanguage(),src/main/language.ts, withapp.getPreferredSystemLanguages()). The renderer applies it before its first render, from the settings and the same OS list (app:get-system-languages), then on each change of thelanguagesetting, whoever makes it (the picker, a data import), throughkeepInterfaceLanguageInSync()(applyInterfaceLanguage(), which also sets<html lang>); components usinguseTranslation()re-render on their own. - Application menu:
src/main/app-menu.tsbuilds a minimal native menu (only roles that do something here; DevTools in development mode only) with labels from themenunamespace, andmain.tsrebuilds it throughonMainLanguageApplied(), so it follows the language. - Messages from the main process (result-union errors, rejected IPC calls, native dialog titles) are translated there, in the language last applied, so the renderer shows them as they come.
- Formatting follows the current language: file sizes (
formatFileSize(): units and decimal separator), language names (formatLanguage(), throughIntl.DisplayNames), tag sorting. Pure helpers insrc/lib/callt()directly rather than taking it as a parameter; their tests switch the language withapplyLanguage(), and the main-process tests that check user-facing messages run in French. - Flags:
languageCountry()(src/lib/language-flag.ts, pure) maps a language code to the country whose flag stands for it (fr→FR,en→GB; unknown codes givenulland fall back to the name).LanguageFlag(src/components/language-flag.tsx) draws it as an inline SVG fromcountry-flag-icons(named imports, so only the listed flags are bundled; emoji flags aren't drawn on Windows). Native<option>s can't hold an SVG, so the pickers show the flag of the selected value before the<select>; the library rows show it in place of the language name. - Data stays untranslated: what the user stores is never translated. The quick tags are stored as
Lu/À lirein every language (READ_TAG,TO_READ_TAG;isFinished()relies onLu), and only shown throughtagLabel().
Local database (src/main/db/)
Persistence uses Node's built-in node:sqlite (DatabaseSync): no native module to compile, and nothing stored in the cloud or in Chromium's localStorage/IndexedDB (ADR 0002). The generated schema reference lists the tables and columns.
- Repositories take a
DatabaseSyncin their constructor instead of opening it themselves, which makes them testable withnew DatabaseSync(':memory:')without touching Electron (app.getPath). - Migrations (ADR 0010):
schema.tsholds an orderedMIGRATIONSlist;migrate()applies those newer than the database'sPRAGMA user_version, each in awithTransaction()together with the version bump. A schema change is a new entry at the end, never an edit of a shipped one; a database from a newer release (higher version) is left alone. Migration 1 is everything that predates versioning, and stays idempotent so installed databases (version 0) go through it harmlessly; migration 2 addsidx_credits_person_id; migration 3 addslibrary_folders; migration 4 adds the nullableavg_page_width/avg_page_heightcolumns oflibrary; migration 5 addslibrary.finished_atand thereading_sessionstable (see Reading statistics); migration 6 addsachievements; migration 7 adds the users (see Users); migration 8 giveslibrary_foldersan id (see Library folders as roots). Within it,CREATE TABLE IF NOT EXISTSis used for tables; columns added after the initial release (file_count,file_size,rating,tags, thentitle_locked,series,volume,release_date,language, andreading_lists.position) go throughaddColumnIfMissing(), which checksPRAGMA table_infobefore anALTER TABLE … ADD COLUMN. Existing installed databases pick them up without a destructive migration. A new column goes throughaddColumnIfMissing()inside a new migration. - Transactions:
withTransaction(db, fn)(transaction.ts) wrapsBEGIN/COMMIT/ROLLBACKaroundfn. Every write that spans several statements uses it (LibraryRepository'supsert,setCreditsandremove;ReadingListRepository'supsert,reorderLists,removeEntry,removeand the position writes), so an interruptedremove()can't leave orphan credits. The same goes forclear()of both repositories andupdateMetadata()(the columns and the credits are one write).updateProgress()clamps the page to[0, page_count - 1]in SQL. Calls nest: inside an open transaction (db.isTransaction)fnjust joins it, and only the outermost call commits or rolls back.applyImport()takes theDatabaseSyncfor that reason: the whole JSON import is one transaction, so a failure midway leaves the database untouched, and it avoids one disk commit per statement (about 7.6 s for 2 000 books, 0.2 s in a transaction). - Tests run the real schema:
schema.tsholdsmigrate()on its own, away fromdatabase.ts(which importselectron), so the repository tests (*.test.tsinsrc/main/db/) build their:memory:databases with the samemigrate()as the app, and can't drift from it.schema.test.tscovers a fresh database, the upgrade of one from the initial release and the version bookkeeping.
Library (library-repository.ts)
One row per comic ever opened or scanned. The books and their metadata are common to every user; rating, tags, progress and last opening are the active user's (see Users). Three writers, with deliberately different rules:
| Method | Used by | Existing entry | rating/tags, looked-up metadata, credits |
|---|---|---|---|
touch() | comic:open (unless anonymous, see below) | Refreshes title (unless title_locked), page count, file count/size; bumps the active user's last opening and clamps their current_page if the page count shrank | Never touched |
register() | folder scan | Left completely alone (a scan is not a read) | Never touched |
upsert() | JSON import | Overwritten with the snapshot's values | Overwritten (restoring a backup is what the user asked for) |
Anonymous reading: with AppSettings.addOpenedBooksToLibrary off, comic:open (which then takes the SettingsRepository) skips touch(), the data:changed notification and the cover for a book whose path isn't in the library (hasPath()) and whose token came from the reader's picker; comic:pick-file takes an addToLibrary flag (true from the library page's "add a file"), remembered with the token, and such a token always adds the book, and returns libraryId: null, resumePage: 0. The renderer saves progress only when libraryId is set; a book already in the library behaves as usual.
updateRating()/updateTags()/updateProgress()/updateMetadata() are the dedicated writers for user-set fields.
updateMetadata()writes only the fields present in itsMetadataUpdate(the ones the user accepted in the lookup dialog). Writing atitlesetstitle_locked, which makestouch()keep it instead of putting the file name back;comic:openreturns the library title, so the reader header shows it too.The manual edit form ("Modifier la fiche",
BookEditDialoginsrc/components/book-edit-dialog.tsx) writes through the sameupdateMetadata(), with no lookup involved. Its logic is pure (src/lib/book-edit.ts):validateForm()refuses an empty title, a release date that isn't a realYYYY/YYYY-MM/YYYY-MM-DD, and a credit with a first name but no last name;formToUpdate()keeps only the fields that differ from the entry (so saving an untouched form locks nothing) and, unlike a lookup's review, compares names exactly. Sincepeoplematches names withCOLLATE NOCASE, a case-only rename finds the existing person and keeps its spelling.The file's format (CBZ/CBR/PDF) is not stored:
comicFormat()(src/shared/comic.ts) derives it from the path's extension, the same oneopenArchive()picks the decoder from, and the library row shows it.fileCountis the archive's total entry count (all files, not just image pages);fileSizeis the file's size on disk, computed withfs.statin the IPC layer, not inComicArchive.tagsis a JSON-encodedstring[]column: no normalized tags table, a personal library doesn't need one.People and credits are normalized, unlike tags, because people are meant to get their own page later (nationality, notes):
peopleholds each person once, unique on (first_name,last_name) withCOLLATE NOCASE;creditslinks a library entry, a person and aCreditRole, with apositionkeeping the source's order.PeopleRepository.findOrCreate()matches people by name, so two homonyms would be merged. Credits are always written as a whole list (setCredits(), fromupdateMetadata()andupsert());list()loads them all in one extra query rather than one per entry. Removing an entry deletes its credits explicitly (on top of theON DELETE CASCADE) and keeps its people.volumeisTEXT, not a number: issue numbers can be12.1,HS,1/2.release_datekeeps whatever precision the source has (YYYY,YYYY-MMorYYYY-MM-DD).lastOpenedPath()feeds the open dialog'sdefaultPath(src/main/ipc/comic.ts), falling back to the OS default when there is no history or that directory no longer exists.
Users (user-repository.ts)
See ADR 0019. Several people share one installation and one library. users holds each user (id, nickname, an optional avatar BLOB with its avatar_type, created_at and last_active_at); there is no password. UserRepository creates, renames and switches users and stores the picture, and activeId() is the user the app is switched to: the one with the latest last_active_at, so the app reopens on the last user (switchTo() stamps it, strictly after every earlier switch).
- What is shared and what is not:
library,people,credits,library_foldersand the shared settings are common. Everything personal is keyed by user:user_books(progresscurrent_page,rating,tags,finished_atandlast_opened_at, one row per user and book, absent until the user touches the book),reading_lists.user_id,reading_sessions.user_id,user_achievementsanduser_settings. The columns oflibrarythat used to hold the personal values (current_page,rating,tags,finished_at;last_opened_atis now only the date added) are no longer read, since SQLite can't drop them without rebuilding the table. A book the user never touched reads as unrated, untagged, on page 0 and "last opened" when it was added (ENTRY_SELECT,library-repository.ts). - Repositories take a user getter:
LibraryRepository,ReadingListRepository,SettingsRepository,StatsRepositoryandAchievementRepositorygetuserId: () => stringas a second constructor argument (default: the first user, so the tests that don't care don't pass one) and scope every personal read and write to it;main.tspasses() => userRepo.activeId(). A list or a setting of another user can't be reached by id (get()checks the owner). Removing a book deletes everyone'suser_booksrow;LibraryRepository.clear()empties them all,ReadingListRepository.clear()only the active user's lists. - Migration 7 creates the tables, makes the first user (
iddefault, nickname from the OS session name) and moves into it everything that existed:user_booksfromlibrary,user_settingsfromsettings(all of the rows; the shared keys keep being read fromsettings),user_achievementsfromachievements, and theuser_idof existing lists and sessions defaults to it. - Shared settings:
SHARED_SETTINGS(src/shared/settings.ts: the library's folders and organization, the metadata sources and keys, the resynchronization and update checks,addOpenedBooksToLibrary) are stored insettings; every otherAppSettingskey is per user inuser_settings.SettingsRepositoryroutes a key byisSharedSetting()and ignores a row stored on the wrong side. A new setting is personal unless it is added toSHARED_SETTINGS. - Profile picture: picked in a native dialog by the main process (
users:pick-avatarreturns the file's bytes after checking the format: the renderer never names a path), cropped to a centered square and shrunk to 256 px WebP by the renderer (shrinkAvatar(),src/lib/avatar.ts;squareCrop()is the pure part), then stored byusers:set-avatar, which checks the format again from the bytes (avatarType()) and the size (MAX_AVATAR_BYTES, 256 KiB). The renderer shows it through ablob:URL (UserAvatar), like the covers. - IPC:
users:list,users:create,users:switch, and for the active user onlyusers:rename,users:pick-avatar,users:set-avatar,users:remove-avatarandusers:remove.UserRepository.remove()deletes the user in one transaction with everything personal they own (user_books,user_settings,user_achievements,reading_sessions, their lists and list items) and refuses the last remaining user; the library is untouched, and when the active user is deleted the app moves to the user active before them (language applied as for a switch). Switching also applies the new user's language in the main process. Each write sendsdata:changedwith{ scope: 'users' }. - Choice screen:
AppSettings.askUserOnStartup(shared, off by default) makes the root layout'sUserChooser(src/components/user-chooser.tsx) cover the window once per launch (a module flag, so a route change doesn't bring it back) with the users to pick from; it switches throughusers:switchand its "don't ask again" box writes the setting. - Renderer: the
usersslice of the data store (useUsers(),useActiveUser()). When the active user changed,reloadUsers()drops the library, reading lists and achievements (null, "not loaded") and reloads them and the settings, ignoring any answer requested before the switch (anepochcounter), so no view shows or counts the previous user's data. The root layout remounts the achievement tracker and toasts for each user.UserMenu(sidebar) switches users and goes back to/, since the page shown (a book being read, a list) belonged to the previous one;/profileedits the nickname and picture. - Import / export work on the active user: an imported list whose id is owned by another user gets a new id (
isForeign()).
Library folders as roots (path-roots.ts, library-folder-repository.ts)
See ADR 0020. library.path does not hold a machine's absolute path when the file is inside a library folder: it holds root:<folder id>/<path relative to the folder> (forward slashes). PathRoots (src/main/db/path-roots.ts) is the codec: encode() picks the deepest folder containing the file, decode() joins it with the folder's path on this machine. Where each folder id is lives in <userData>/library-roots.json (fileRootsStore()), never in the database, which may be shared between machines. A file in no folder keeps its absolute path; a stored path whose folder has no local path decodes to itself, so the book reads as missing until the folder is relinked.
- Only
LibraryRepositoryknows the stored form: it takes the codec as its third constructor argument, encodes every path it writes or compares in SQL and decodes every one it returns, so the rest of the code, the renderer and the thumbnails (keyed by the decoded path) only see absolute paths.adoptRoots()rewrites the absolute paths that are now inside a folder;main.tsruns it at start-up, and the IPC handlers after a folder is added, picked as the organized folder or relinked. library_folders(migration 8) holdsid, thepaththe folder had where it was added (a hint),added_atandwatched.LibraryFolderRepository.list()returns the watched folders that have a path here;remove()only unwatches, since books are recorded relative to it;syncRoots()links at start-up the folders whose recorded path exists on this machine;unlinked()lists the others, andlibrary:list-unlinked-folders/library:relink-folder(the main process opens the dialog; the renderer only names the id) let Paramètres › Bibliothèque say where they are.- A resynchronization never removes the books under an unlinked folder: they aren't under a walked folder, and their parent directory isn't readable.
Reading lists (reading-list-repository.ts)
reading_lists holds each list (id, name, created_at, and its position among the lists); reading_list_items links a list to library entries with a position, unique per (list, entry). A list holds at most MAX_READING_LIST_SIZE (50) books, enforced by addEntry(). Refusals (full list, empty name, unknown list, stale order) come back as a ReadingListResult error.
Order of the lists:
list()sorts onposition, thencreated_at. A new list (create(), or a new one fromupsert()) goes afterMAX(position); lists created before the column existed all shareposition = 0, so they keep their creation order until the first reorder. The sidebar's drag and drop (ReadingListsNavinsrc/routes/__root.tsx, through the puremoveItem()) sends the whole new order toreorderLists(), which, likereorder()for books, refuses an order that doesn't hold exactly the current lists (ReadingListOrderResult). The import applies the snapshot's order (its lists first, then the local-only ones), the export'sreadingListsbeing inlist()order.Adding from the library by drag and drop: the library page's rows carry it on a grip handle (
GripVertical, at the left of the row, highlighted on hover; the drag image is the whole row) and nowhere else, so the rest of the row can open the book on click and the stars, buttons and tag field keep their mouse handling. They carry the book under the customLIBRARY_ENTRY_DRAG_TYPE(src/lib/reading-list.ts).ReadingListsNavonly accepts a drop on a list when the drag holds that type, which tells it from one of its own lists being reordered and from files dragged in from outside; it callsaddEntry()(already in the list is a no-op there) and showsdropFeedback()'s message under the lists for a few seconds.Adding several books (a series, or the library page's selection):
addEntries()appends them in the given order in one transaction and answers aReadingListAddResultcounting the booksadded,alreadyIn, left out once the list is full (overLimit) and no longer in the library (missing); a full list is not an error there, it takes what fits. The renderer goes throughbulkAddRequest()first (src/lib/reading-list.ts), which sends only the books the list doesn't hold yet, cut to the room left, so a selection of thousands of books stays a short call, andbulkAddFeedback()turns both into the message.seriesEntries()lists a book's series (names compared ignoring case and accents, natural volume order). The selection itself is page state (toggleSelection(),selectedEntries()insrc/lib/library-selection.ts): ids kept across filter changes, Shift-click ranges over the rows shown, sent in screen order then library order for the hidden ones. A drag from the grip of a selected book carries the whole selection (draggedEntries()): the drag data is always an array (encodeDraggedEntries()/decodeDraggedEntries()), andReadingListsNavsends one book throughaddEntry()anddropFeedback(), several throughbulkAddRequest(),addEntries()andbulkAddFeedback()."Finished" is derived, never stored:
isFinished()(src/lib/reading-list.ts) is true once the last page was reached or the entry carries theLutag. A list's progress, its next book (nextToRead()) and the greyed-out rows are all computed in the renderer from thelibrary:listentries, so they can't drift from the reading progress.Display of a list's page:
AppSettings.readingListView(aBookRowView, separate fromlibraryView) picks how much each book row shows, through the sameViewModePicker,BookDetails,BookRatingandBookTagsas the library page (see Library search & filters). Only a row's grip is draggable for reordering, as in the library, so the tag field and the click that opens the book don't start a drag.Reordering rule: finished books are pinned.
moveUnfinished()only swaps unfinished books among the slots they occupy, each finished book keeping its index; the repository'sreorder()only checks that the new order holds exactly the list's current entries (a stale order is refused rather than dropping or adding books).Deletions: like credits, items are deleted explicitly (
LibraryRepository.remove(),ReadingListRepository.remove()), sincePRAGMA foreign_keysis off and theON DELETE CASCADEdoesn't apply. Removing a library entry leaves a gap in the positions, which is whyaddEntry()appends afterMAX(position)rather than at the list's length.Loading lists with the library: the two list pages and the reader's next-book lookup read both from the data store (
useReadingLists(),useLibrary()) and recompute when either changes; nothing fetches them itself.Next book in the reader: the list pages open the reader with a
listsearch param;useNextInList()then finds the list's first unfinished book other than the current one (whose progress may not be saved yet), shown on the last page. It shows nothing when the current book isn't in that list (another file opened from the reader).
Reading statistics (stats-repository.ts)
The statistics page (/stats, src/routes/stats.tsx) shows the library's totals and the reading history by month or year.
- What is stored:
user_books.finished_at(ISO timestamp, per user), set once byLibraryRepositorywhenupdateProgress()reaches the last page orupdateTags()addsLu(READ_TAG,src/shared/library.ts) and never changed afterwards, andreading_sessions(user id, library id, localday, seconds), one row per report;stats:getand the completion dates (user_books.finished_at) are the active user's. There is no backfill for the books read before migration 4: their completion date and reading time are unknown, so they count in the totals (derived from the data store withisFinished()) but not in the charts. - Reading time:
useReadingTime()(src/hooks/use-reading-time.ts), called by the reader route with the book's library id (nullfor an anonymous read), checks every 5 s that the document is visible and focused and feedscreateReadingTimeAccumulator()(src/lib/reading-time.ts, pure), which callsstats:add-reading-timeabout once a minute and when the reader closes. The handler accepts 1 to 3 600 seconds per call andStatsRepository.addReadingTime()stamps them with the local day. - Reading the history:
stats:getreturns aReadingStats(src/shared/stats.ts): the completion timestamps and the seconds per day, oldest first. The page fetches it when it opens and when the library slice changes. It is not in the data store (only this page shows it), so the two stats channels send nodata:changed.buildBuckets()(src/lib/reading-stats.ts, pure and tested) groups it by local month (the last 12) or year, keeping empty periods, andBarChart(src/components/stats/bar-chart.tsx) draws the bars in SVG, with no charting dependency. - Profile history:
stats:history(StatsRepository.history()) returns the active user'sreading_sessionsgrouped by day and book (ReadingHistoryEntry, most recent first,READING_HISTORY_LIMITlines, the titlenullfor a book removed since) for the profile page, which reads it when it opens (not in the data store);stats:clear-history(clearHistory()) deletes that user's sessions only, so the statistics' reading time goes too, butfinished_atstays. - Lifecycle:
LibraryRepository.remove()keeps the book's sessions (the time was spent),clear()emptiesreading_sessions. The history is not exported (buildExport()/applyImport()don't touch it).
Settings (settings-repository.ts)
A generic key/value store (JSON-encoded values) for AppSettings, merged with DEFAULT_SETTINGS on read, so a new setting key needs no migration. Most keys are per user (user_settings); SHARED_SETTINGS are common to everyone (settings), see Users. Each stored row is checked on read (isSettingKey(), SETTING_VALIDATORS): an unreadable, unknown or invalid row counts as not stored, so a damaged value can't stop the app from starting.
Achievements (achievement-repository.ts)
See ADR 0014. user_achievements holds one row per user and earned achievement (user_id, id, unlocked_at; the table achievements of migration 6 is read only by migration 7); AchievementRepository.unlock() is INSERT OR IGNORE, so a date never changes and it returns only the newly earned ones. achievements:unlock (src/main/ipc/achievements.ts) validates every id against ACHIEVEMENT_IDS (src/shared/achievements.ts), stores them and notifies { scope: 'achievements', unlocked }. Nothing is deleted when a book or a list goes, data:clear-library keeps them, and they are not exported.
Database location (db-location.ts)
The location can't be an AppSettings entry, since the settings are stored in the database. It is a tiny db-location.json pointer file that always stays in userData (ADR 0003).
- A missing or malformed pointer silently falls back to
userDatarather than throwing, so a hand-edited (or sync-mangled) file can't stop the app from starting. - The functions are pure (they take the
userDatapath, never callapp.getPath), which makes them testable;database.tsis the only bridge to Electron. - Changing the location only rewrites the pointer: the existing file is not moved, because pointing at a directory that already holds a
tankobon.db(a synced folder, another machine) must keep that database. The open database stays open until the next start (database:relaunch).
Export / import (export-service.ts, import-service.ts)
buildExport()snapshots library, reading lists and settings as{ version: 1, exportedAt, library, readingLists, settings }, written bydata:exportthrough a save dialog, whose default file name (exportFileName()) carries the local date and time. The library entries carry their looked-up metadata and credits; reading lists reference their books by path. The format staysversion: 1, since those fields are only additions (an older export comes back with them empty). The settings include the metadata API keys.parseExport()validates a user-picked file: anything that isn't aversion: 1export is rejected; broken library entries are dropped (including those whosepathis not an absolute path of a supported comic: an edited export must not name arbitrary files, which are later opened and, by the library organization, moved), as are credits without a last name or with an unknown role; only known settings keys whose value type matches the default are kept. An export is a file the user can edit, so nothing in it is trusted.applyImport()merges it: entries match on path, not id (ids aren't stable across machines); the snapshot wins for the entries it contains; entries only present locally are untouched; settings are replaced wholesale. Reading lists match on id (random UUIDs, so the same list keeps its id on every machine) and are replaced by the snapshot's; their paths are resolved to the local entries after the library is merged, and a path with no entry is dropped.- Cover thumbnails are not exported: after
applyImport(),data:importstartsrebuildThumbnails()(ThumbnailCache.rebuild()in the decoder process) in the background (see Cover thumbnails) and returns without waiting for it. - Clearing the library (
data:clear-library):ReadingListRepository.clear()deletes every list, thenLibraryRepository.clear()emptieslibrary,credits,peopleandreading_list_items(people only exist through credits, so none would be left referenced);ThumbnailCache.prune([])then deletes every thumbnail. Settings, the database location and the files on disk are left alone. The renderer asks for confirmation, showing the counts from its data store; the handler'sdata:changedevents make the library page and the sidebar drop what was deleted.
Decoder process
See ADR 0012. Parsing a comic file (ZIP, RAR through wasm, PDF through pdf.js) and decoding its images (@napi-rs/canvas) happens on untrusted input, so it runs in an Electron utilityProcess rather than in the main process: a file that crashes a native decoder or exhausts memory stops that process, not the app and its database.
- Layout:
src/main/decoder/decoder-worker.tsis the process's entry (its own bundle,decoder-worker.cjs, built from a secondtarget: 'main'entry inforge.config.tswithvite.decoder.config.mts);decoder-handlers.tsholds what each call does (aComicServiceand aThumbnailCache, free of Electron so it can be tested);protocol.tstypes the messages,DecoderMethodsbeing the list of calls;decoder-client.tsis the main side;spawn-decoder.tsis the only file that touchesutilityProcessandMessageChannelMain. - Calls:
open,readPage,close(the reader),inspect(the scan),measure(the page size measure), and the thumbnail calls (thumbnail,removeThumbnail,pruneThumbnails,rebuildThumbnails). Each request carries an id, the answer the same id with a result or the error message; requests run concurrently in the process, so a library page asking for covers doesn't wait behind a page render. The IPC handlers ofsrc/main/ipc/callDecoderClientand nothing else touches archives. - Lifecycle: started on the first call, and told the thumbnails directory and the current language (
init; laterlanguage, sent byapplyMainLanguage()throughonMainLanguageApplied()). When it exits, calls in flight reject witherrors:decoder.crashed("Fichier illisible…") and the next call starts a new one. The reader's archives die with it: the next page request fails with "unknown archive" until the book is reopened. It is killed onwill-quit. - Hangs and leaks (ADR 0018):
open,inspectandmeasureare bounded byOPEN_TIMEOUT_MS(60 s); one still unanswered kills the process like a crash (the hung call fails witherrors:decoder.timeout, the others witherrors:decoder.crashed) and the next call starts a new one. Thecomic:openhandler records which window opened each archive (ArchiveOwners) and closes the ones left open when that window is destroyed;ComicServicekeeps at mostMAX_OPEN_ARCHIVES(8), closing the oldest first. - Pages between processes: a page's bytes are copied by structured clone (a utility process can only transfer ports), then once more by
ipcMain.handleto the renderer. - Errors keep their language: the decoder process has its own i18next instance, so its messages (
errors:archive.*) are translated there, in the language the main process last sent.
Comic archives (src/main/services/)
ComicArchive (comic-archive.ts) is the format-agnostic interface (pages, fileCount, readPage(), close()), implemented by CbzArchive (node-stream-zip), CbrArchive (node-unrar-js) and PdfArchive (pdfjs-dist).
fileCountcounts every non-directory entry;pagesis filtered and naturally sorted down to image entries (hidden files and__MACOSX/resource forks excluded). They commonly differ (aComicInfo.xmlsidecar counts infileCount); for PDFs they are always equal.openArchive()(comic-service.ts) picks the implementation from the file extension.ComicServicekeeps opened archives alive between IPC calls, addressed by an opaquerandomUUID(), distinct from the persistent library id. It lives in the decoder process, like everything on this page.
CBZ: closing while reading
CbzArchive.close() waits for in-flight readPage() calls to settle before closing the zip, and readPage() refuses to start once closing has begun. node-stream-zip closes its file descriptor immediately, and a read caught mid-way fails with EBADF on an internal stream whose error never reaches the entryData() promise: it becomes an uncaught exception in the decoder process. This happens routinely from the renderer (switching books while a page loads, leaving continuous mode while preloads run); see cbz-archive.test.ts.
CBR: the wasm file
node-unrar-js's Emscripten glue locates its .wasm relative to its own __dirname, which breaks once Vite bundles it into decoder-worker.cjs. vite.decoder.config.mts copies unrar.wasm next to the bundle (vite-plugin-static-copy), and cbr-archive.ts reads it itself and passes it as wasmBinary. A CBR is read fully into memory when opened.
CBR: one wasm module for every archive
All node-unrar-js extractors share a single wasm module, and each createExtractorFromData() points that module at the extractor just created. An extractor kept from open() and reused later would, once another CBR has been opened (a second book, a folder scan), read the other archive's bytes: File is not RAR archive, then a TypeError in getFiles. So CbrArchive keeps only the file's bytes, and every operation (open()'s listing, each readPage()) creates a fresh extractor and drains it synchronously, through a module-level queue so that no other creation can slip in between; see cbr-archive.test.ts.
PDF rendering
See ADR 0004. PdfArchive rasterizes each page on demand at a fixed RENDER_SCALE of 200/72 (~200 DPI) through pdfjs-dist's legacy build, returning PNG bytes.
- Page cache and prefetch: rendering a page is the slow part of turning a PDF page (a CBZ page is just extracted).
PdfArchive.readPage()keeps thePAGE_CACHE_SIZE(5) most recently used renders, as promises so that concurrent requests share one render, and after each read renders the next and previous pages in the background (errors ignored, nothing started onceclose()has begun), so the usual page turn finds its page ready. - Page size ceiling: a PDF can declare a page of thousands of points per side in a few hundred bytes, and the continuous mode renders several pages at once.
pageRenderScale()therefore lowers the scale belowRENDER_SCALEso that a page never exceedsMAX_PAGE_PIXELS(40 megapixels), computed from the page's size at scale 1. LikewiseCbzArchive.readPage()andCbrArchive.readPage()refuse an entry whose declared size (the ZIP entry's size, the RAR header'sunpSize) is aboveMAX_ENTRY_SIZE(200 MB,comic-archive.ts) with a translated error, before extracting anything. - Image dimensions ceiling (CBZ/CBR): a PNG of 30 000 × 30 000 px compresses to a few kilobytes, passes the
MAX_ENTRY_SIZEcheck, and would allocate about 3.6 GB when decoded (loadImage()inrenderThumbnail(), then the renderer's<img>).readImageSize()(src/main/services/image-size.ts) therefore reads the size from the PNG/JPEG/GIF/WebP header, without decoding, andCbzArchive/CbrArchive.readPage()refuse (throughcheckedPage()) a page aboveMAX_IMAGE_PIXELS(100 megapixels) with a translated error;renderThumbnail()applies the same check. A format whose header isn't read (AVIF, BMP) is let through. - pdf.js references
DOMMatrix/Path2D/ImageData/Imageas bare globals;pdf-archive.tsinstalls@napi-rs/canvas's implementations onglobalThisonce. page.render()is given thecanvasobject itself, not just acanvasContext.- pdf.js must believe it runs in Node. pdf.js sets
isNodeJSonce, at module load, and says no in an Electron process whoseprocess.typeisn'tbrowser: a utilityProcess (utility) is one. It then takes the browser paths and every PDF fails withNo "GlobalWorkerOptions.workerSrc" specified. Only the packaged app showed it (Vitest runs in plain Node).pdf-archive.tstherefore hidesprocess.typewhile it imports pdf.js (importPdfjs()). pdfjs-dist's package directory is found with the plain CommonJSrequire.resolve('pdfjs-dist/package.json'), notcreateRequire(import.meta.url): the main bundle is CommonJS, where Rollup rewritesimport.meta.urltoundefined, which would crash at module load time (the app would not start at all).
Cover thumbnails
See ADR 0007. ThumbnailCache (thumbnail-cache.ts) keeps a WebP of each book's first page, shrunk by renderThumbnail() (@napi-rs/canvas) to fit in 240 × 360 px, as a file in <userData>/thumbnails/ named after a hash of the book's path (thumbnailKey()). It is a pure cache: nothing in the database points at it, it stays in userData even when the database is moved, and any missing file is regenerated.
- Where: in the decoder process; the main process only calls
DecoderClient's thumbnail methods. - Writers: the folder scan, through the decoder's
inspect(which opens the archive, counts its pages and caches the cover from it:storeFromArchive());comic:open, in the background, from the reader's archive (ensure(path, readFirstPage)), so opening a book never waits for its cover;library:thumbnail, on demand, opening the file itself when the thumbnail is missing;rebuild()after a JSON import. - One queue: every generation (and
prune()) is serialized through a promise chain, one book at a time, so a library page asking for hundreds of covers or a rebuild never opens archives in parallel; concurrent requests for the same book share one generation.rebuild()awaits each book in turn, so the library page's requests interleave with it instead of waiting for the end. - A file that couldn't be opened is remembered for the session (until the next
rebuild()), so a missing file isn't reopened every time the library is shown. A failure of a given page reader (the reader closed the book meanwhile) is not remembered. - Files are written aside then renamed.
prune()deletes everything that isn't the thumbnail of a library book, temporary files included; it runs at start-up, inrebuild()and, with an empty list, when the library is cleared.library:removedeletes the book's thumbnail, through the same queue as the generations: a removal during a generation of that book waits for it, so no orphan file is left behind. - Renderer side:
BookCover(src/components/book-cover.tsx) askslibrary.thumbnail(id)only once its row comes within 400 px of the viewport, and shows the bytes through ablob:URL (allowed by the CSP'simg-src), revoked on unmount.
Metadata lookup (src/main/services/metadata-service.ts)
The "Rechercher les infos" action of the library looks a book up in public APIs (ADR 0005). The requests are made by the main process (metadata:search, src/main/ipc/metadata.ts), never by the renderer: the API keys stay out of the renderer, there is no CORS to deal with, and the renderer's CSP (index.html) still allows no connection beyond 'self'.
searchMetadata()queries the enabled sources in parallel (ComicVineClientonly when it has an API key,GoogleBooksClientalways when enabled) and normalizes their results toMetadataCandidate. A failing source adds its message (in the interface language) toerrorsnext to the other source's results;status: 'error'is only for nothing configured, an empty query, or every source failing.rankCandidates()puts the candidates matching the requested volume first.- Comic Vine (
comic-vine.ts): with a volume number, it searches the series (resources=volume) then that issue number in the top 3 series (/issues/?filter=volume:…,issue_number:…), else (or when that finds nothing) runs a plain issue search. The search results don't include credits, so each of the (at most 5) issues gets a detail request. All requests are sequential, since Comic Vine flags bursts. Its role names map toCreditRole(penciler→artist;editoris dropped). It needs a non-genericUser-Agent, henceTankobon/<version>. - Google Books (
google-books.ts): one full-text search. It has no series field and doesn't tell writers from artists, so series and volume are read from the title when it spells them out (splitSeriesTitle()), and every author gets the genericauthorrole. - Bédéthèque (
bedetheque.ts, ADR 0006): not a search source. A pasted album-page link (isBedethequeAlbumUrl(),src/shared/metadata.ts) goes throughmetadata:from-page→fetchMetadataPage(), which downloads the page and parses only its main album block (<section class="bdt-ah">) with regular expressions: series from the<h1>, number and title from thebdt-ah-subsubtitle, credits fromliste-auteurs(role labels mapped toCreditRole, placeholders such as<N&B>dropped), the legal deposit as release date (the exact day only when the page spells it out, since itsdatePublishedmeta pads an unknown day with01). Placeholders carry raw</>in theirtitleattribute, so tag patterns skip quoted attribute values as a whole. http-json.tsholds the network helpers: a JSON GET (getJson()) and an HTML GET (getHtml()), with a timeout and"<Source>: …"errors in the interface language (unreachable, timeout, exhausted quota, missing page, the API's own message). The IPC layer passes it Electron'snet.fetch, which goes through Chromium's network stack and honours the system proxy; tests pass a fakefetch. A body is read through a size cap (MAX_RESPONSE_BYTES, 5 MB: refused from itsContent-Length, or while streaming), and a redirect that ends on another site than the requested one (www.ignored) is refused, since the sources' addresses are fixed.- The pure text helpers are shared with the renderer (
src/shared/title-parsing.ts):guessQueryFromTitle()prefills the dialog's search from a file name (bracketed tags dropped, volume from "T03"/"Vol. 2"/"#12" or a standalone 1–3 digit number, so a bare year isn't taken for a volume),splitPersonName()turns "Jean Van Hamme" into first/last name (particles stay with the last name, a single word is a last name). - Renderer side:
MetadataDialog(src/components/metadata-dialog.tsx) runs search → pick a candidate → review →library.updateMetadata(); a Bédéthèque link in the search field callsmetadata.fromPage()instead and goes straight to the review. The review logic is pure (src/lib/metadata-review.ts):buildReview()lists only the fields the candidate knows, pre-accepted when they'd change something, plus the current credits (kept) and the candidate's new ones (added);reviewToUpdate()turns the accepted rows into aMetadataUpdate(an emptied field is cleared, an emptied title is ignored, credits become exactly the accepted rows). Cover thumbnails are loaded straight from the sources, which is why the CSP inindex.htmlallowsimg-srcfromcomicvine.gamespot.com,books.google.comandwww.bedetheque.com(and nothing else).
Build gotchas
These are the non-obvious constraints of the main-process bundle. Breaking one usually doesn't fail the build, only the packaged app.
| Constraint | Where | What breaks otherwise |
|---|---|---|
package.json's main is .vite/build/main.cjs, and src/main.ts loads preload.cjs | package.json, src/main.ts | Forge 8's Vite plugin emits the main and preload bundles as .cjs: electron-forge package refuses a main ending in .js, and a stale preload path leaves window.tankobon undefined. |
node:sqlite listed in build.rollupOptions.external | vite.main.config.mts | It isn't in Node's builtinModules yet, so Vite bundles an empty stub: DatabaseSync is silently undefined at runtime. |
unrar.wasm copied next to decoder-worker.cjs | vite.decoder.config.mts, cbr-archive.ts | CBR files can't be opened. |
pdfjs-dist and @napi-rs/canvas external | vite.decoder.config.mts | @napi-rs/canvas is a native .node binary Rollup can't inline; pdf.js's legacy build is a foreign webpack bundle Rollup can't safely re-bundle. Being real packages also ships pdf.js's standard_fonts/cmaps for free. |
hooks.packageAfterCopy copies pdfjs-dist, @napi-rs/canvas and the installed @napi-rs/canvas-<platform>-<arch> | forge.config.ts | The Forge Vite plugin only packages its build output plus package.json, never node_modules: PDFs fail in the packaged app. Only the platform package matching the machine that ran npm install exists, so a package must be built on its target platform. |
AutoUnpackNativesPlugin | forge.config.ts | The native binary would stay inside the asar archive, which can't be dlopened. |
packagerConfig.icon (assets/icon, without extension), extraResource: ['assets/icon.png'], the deb/rpm options.icon and Squirrel's setupIcon | forge.config.ts, src/main.ts | No app icon: Packager picks .ico/.icns by platform, but Linux has no embedded icon, so the window icon is the PNG copied next to the app and passed to BrowserWindow (windowIcon), and the deb/rpm menu entry needs its own icon option. The sources are assets/icon.svg and the files generated by npm run icons. |
packagerConfig.executableName is tankobon on Linux | forge.config.ts | Packager names the Linux binary after productName (Tankōbon), while the deb and rpm makers look for name (tankobon): npm run make fails on Linux with "could not find the Electron app binary". |
Renderer
Routing (src/routes/)
File-based routes via TanStack Router, with an in-memory history (createMemoryHistory in src/renderer.tsx). The app starts on the home page (/, src/routes/index.tsx, with five blocks: "continue reading" (continueReading(), src/lib/continue-reading.ts) one that lists the RECENT_BOOKS_COUNT (5) most recently added books from useLibrary() through the pure recentlyAdded(), src/lib/recent-books.ts, each linking to /reader?book=<id>, and one that suggests five random unread books, drawn by the pure pickRandomUnread() in src/lib/home-picks.ts and kept in page state until the user redraws; and one listing the five lowest-rated books (lowestRated(), src/lib/second-chance.ts: rated 1 to LOW_RATING_MAX (2), worst first), drawn as covers with a BookRating under each so the rating can be changed in place; and "favorites", the 5 best-rated books through the pure topRated(), src/lib/home-favorites.ts, shown with their stars; more blocks will be added). The blocks are the HOME_SECTIONS of src/shared/settings.ts; their order and visibility are AppSettings.homeSections, resolved by src/lib/home-sections.ts (a block added by a later release shows up at the end, shown), and AppSettings.homeEnabled off removes the sidebar entry and makes / redirect to /library; both are edited in settings/home.tsx. Each HomeSectionSetting also carries an optional count (HOME_SECTION_MIN_COUNT 3 to HOME_SECTION_MAX_COUNT 20, default 5, checked by isHomeSections()) and hideWhenEmpty (default per block in HOME_SECTION_HIDE_WHEN_EMPTY_DEFAULT: on for the blocks that had no empty state before); resolveHomeSections() fills in what a stored block lacks, so no migration is needed, and gives the blocks a ResolvedHomeSection. The selection helpers take that count, and BookCarousel (src/components/book-carousel.tsx) lays a block out as one non-wrapping row scrolled by arrows. A new block is an id in HOME_SECTIONS, a component in SECTION_VIEWS (src/routes/index.tsx) and its label under settings:home.sections. The library is /library; the active user's profile is /profile (see Users). src/routeTree.gen.ts is generated by @tanstack/router-plugin on npm start/build: never edit it by hand. It is committed, because npm run typecheck (and CI) need it without running Vite. validateSearch on a route needs no manual wiring in it. The reader route (/reader) accepts an optional book search param (a library entry id or a comic:pick-file token, never a path), used by the library page to open a specific book, and an optional list (a reading-list id, see Reading lists). The reading lists are /lists (lists/index.tsx) and /lists/$listId (lists/$listId.tsx, which sets remountDeps on its params: the router would otherwise keep the page mounted when only the list changes, and a rename in progress would be applied to the newly selected list).
Settings sections
src/routes/settings.tsx is a layout route (title + <Outlet/>); settings/reading.tsx, settings/appearance.tsx, settings/home.tsx, settings/library.tsx (folders, resynchronization), settings/metadata.tsx (lookup sources and API keys), settings/data.tsx and settings/about.tsx ("À propos") are the sections; settings/index.tsx only redirects to the first one. The section list is SETTINGS_SECTIONS (src/lib/settings-nav.ts), shared with the sidebar so the two can't drift. Adding a section means a file in src/routes/settings/, a line in SETTINGS_SECTIONS and its label under settings:sections in both locales. settings/appearance.tsx also holds the interface language picker.
In the sidebar (src/routes/__root.tsx), SettingsNav unfolds those sections: clicking "Paramètres" while folded unfolds and navigates; while unfolded it only folds (it preventDefault()s the Link). The unfolded state is transient component state. ReadingListsNav does the same for the reading lists, with one difference: /lists is a real page, so its label always navigates (and unfolds) and only the separate chevron button folds. Its lists come from useReadingLists() (src/hooks/use-reading-lists.ts), a slice of the data store: every write to the lists makes the main process send data:changed, so the sidebar and the /lists pages never show a stale name and no view has to signal anything. AppSettings.sidebarCollapsed (a persisted setting) switches the whole sidebar to an icon-only rail, which hides the sub-entries.
Renderer data store
See ADR 0013. DataStore (src/lib/data-store.ts, pure, unit-tested with a fake DataSource) holds the library, the reading lists, the achievements, the users and the settings; its single instance appData (src/lib/app-data.ts) is started by src/renderer.tsx before the first render (the settings are awaited, for the language; the other two load in the background and read null until then). Views never fetch these themselves: they use useLibrary(), useReadingLists(), useSettings() and useUsers() (src/hooks/), built on useSyncExternalStore, one subscription list per slice. What the hooks return is shared and immutable.
data:changed(DATA_CHANGE_CHANNELS,src/main/ipc/data-changes.ts) is pushed bybroadcastDataChange()to every window. The IPC handlers receive it as aNotifyDataChangeparameter and call it right after a write, so the repositories stay Electron-free. The payload (DataChange,src/shared/data-changes.ts) is ascopeplus, for the library, theupsertedentries andremovedids (rating, tags, progress, metadata, opening a book, removing a book) or nothing for a bulk write (folder scan, import, clearing), which reloads the slice; the reading lists always reload; a setting carries its writtenvalues(never an API key, which stays out of the store like it stays out ofsettings:get-all: the slice holdsPublicSettings), and an import reloads them all. Adding a write path means adding itsnotify(…)call, nothing in the views.applyLibraryChange()applies a targeted change and keeps thelastOpenedAt DESCorder oflibrary:list: an entry whose opening date didn't change is replaced in place, one that did (a book just opened) or a new one goes where its date puts it.- Optimistic writes:
patchEntry()(rating, tags) andupdateSetting()change the store at once, then persist. While such a write is in flight its key is pending and thedata:changedecho for it is ignored (the event reaches the renderer before the call's reply), so a slider being dragged is never moved back by an older value. If the write is refused (the call rejects), the slice is reloaded so the optimistic value doesn't outlive it, and the error is rethrown (the views ignore it). Other writes (list changes, removals, metadata saves) simply wait for their event. - Language: the store knows nothing about i18n;
keepInterfaceLanguageInSync()(src/hooks/use-settings.ts) subscribes to the settings slice and applies thelanguagesetting when it changes.
Achievements (renderer)
The rules are pure and tested (src/lib/achievements.ts): computeStats() counts finished books, library size, formats, folders, lists, ratings, tags, documented books and languages from the data store; ACHIEVEMENT_RULES gives each achievement its group, target and counter, and earnedFromStats() lists those reached. The few earned by an event (nightOwl, earlyBird, rightToLeft) have no counter: eventAchievements() decides them when a book is opened. useAchievementTracker() (root layout) sends the newly reached ones to achievements:unlock; useOpeningAchievements() (reader) does the same on each opened book; the achievements slice of the store receives data:changed with the new ones, and AchievementToasts announces what appears after the first load. The folder count comes from library:list-folders, re-read whenever the library changes. Both are counted from the library as it was when it last stayed unchanged for a second (useSettledValue(), src/hooks/use-settled-value.ts): every page turned in the reader saves the progress and so replaces the whole library, and counting 20 000 books (about 25 ms) plus an IPC call at each page would be a hiccup per turn for nothing, since no achievement is time-critical. Badges are inline SVG (AchievementIcon), the accent being --achievement (src/index.css). A new achievement is an id in ACHIEVEMENT_IDS, a rule, a drawing in AchievementIcon and its achievements:items.<id> texts in both locales.
Confirmations
Destructive actions are confirmed in an in-app modal, never with window.confirm() (a native window that ignores the app's theme and language). useConfirm() (src/components/confirm-dialog.tsx) returns confirm({ title, message, action }), which resolves true on confirmation and false on cancel, Escape or a click on the backdrop, and a dialog element to render once in the view; it is built on the shared Dialog. Used for removing a book from the library and deleting a reading list.
Library search & filters (src/lib/library-filter.ts)
The whole library is in the data store and is filtered client-side, in memory; the target is 20 000 books or more, and moving the filters into SQL is the next step beyond that, not needed yet. The logic is pure (unit-tested under Vitest's node environment, no DOM):
- search matches the title, the file path (so a series folder name finds its albums), the series and the credited people's names, normalized with
NFD+ diacritic stripping ("pokemon" matches "Pokémon"). Each entry's normalized text is cached in aWeakMapkeyed by the entry object (entries are replaced, never mutated, so it can't go stale), and the search text is normalized once per filtering, not once per entry; - read/unread is a tag filter:
Lu/À lireare the library page'sQUICK_TAGS.availableTags()keeps them pinned in front even before anything carries them; selecting several tags requires all of them; - the rating filter is a minimum (3 stars keeps 3–5).
AppSettings.libraryView (a LibraryView: full/medium/compact, or grid, persisted like any setting) picks how much each row shows, or the grid of covers (a reading list's readingListView stays a BookRowView, its picker not offering the grid); the toolbar's ViewModePicker writes it through useSettings(), and the virtualizer's estimated row height depends on it (rows are measured anyway). The parts of a row that depend on it live in src/components/book-row.tsx, shared with a reading list's page: ViewModePicker, BookDetails (cover, title, details and status line), BookRating and BookTags (which write the rating and tags themselves, optimistically through appData.patchEntry(); toggleTag() and QUICK_TAGS are in src/lib/reading-list.ts). Each page keeps its own row layout and actions around them.
Several formats of one book: editionGroups() (src/lib/library-editions.ts, pure and tested) links the entries that are the same book in different formats (editionKey(): series and volume when both are known, else the normalized title; comicFormat() tells the formats apart, two files of one format are never linked), computed from the whole library; collapseEditions() then keeps one row per book among the filtered entries, before sorting or grouping. It is display only: each file stays its own entry, with its own user_books row, and the row offers one open button per edition. No schema change.
AppSettings.libraryGroupBySeries (off by default, the toolbar's "Regrouper par série" toggle) groups the filtered entries by series: groupBySeries() (src/lib/library-groups.ts, pure and tested) turns them into a flat list of LibraryRows, a SeriesHeaderRow (series name, count, finished count through isFinished()) followed by the series' books, so the virtualizer still sees one list of rows of varying height. Series are matched case-insensitively on the trimmed name (seriesKey()), sorted with a numeric Intl.Collator, the books without a series last; within a series, books follow their volume in natural order, then their title. Which series are folded is page state (a Set of keys), not a setting; a folded series keeps only its heading row. With grouping off, ungroupedRows() lays out the entries as sortEntries() (src/lib/library-sort.ts, pure and tested) orders them, following AppSettings.librarySort (one of LIBRARY_SORTS, src/shared/settings.ts; the toolbar's SortPicker): lastOpened keeps the lastOpenedAt DESC order of the store, the others sort a copy by addedAt, releaseDate (compared as strings, so 2003 < 2003-05 < 2003-05-12; books without one last both ways) or title (numeric Intl.Collator), each both ways, ties going to the title. The grouped view ignores the setting (the picker is disabled), its order being part of the grouping.
In the grid, gridLines() (src/lib/library-groups.ts, pure and tested) cuts the same rows into CardsRows of as many books as fit the width (useGridColumns(), from a ResizeObserver on the scroll area, cards at least 150 px wide), a series heading keeping its own line, so the virtualizer still sees one list of lines of varying height; BookCard (src/components/library/book-card.tsx) draws a book.
The route (src/routes/library.tsx) keeps the page state and the virtualized rows; the toolbar and its parts (LibraryToolbar, SortPicker, RatingFilterPicker), the SelectionBar, the ScanProgressBar and the SeriesHeader are components of src/components/library/.
The library page (src/routes/library.tsx, at /library) draws only the rows near the viewport (@tanstack/react-virtual, which measures each row since their height varies with wrapping tags and the optional metadata line), inside its own scroll container below the toolbar; it filters with useDeferredValue so the search field never waits for the list, and scrolls back to the top when the filters change. A row scrolled out of view is unmounted, with its local state (a half-typed tag).
Folder scanning
library:add-folder opens a directory picker, then scanIntoLibrary() (src/main/services/library-scanner.ts) walks it recursively for supported files. Unreadable directories are skipped; symlinks are never followed (Dirent.isDirectory() is false for them, which also rules out cycles). Learning a page count means opening each file (in the decoder process, through inspect: the scanner only owns the walk and the database), which is the slow part: progress is pushed file by file over library:scan-progress. The renderer subscribes via library.onScanProgress() for the whole life of the library page, not around each call, because progress starts as soon as the directory is picked. A file that won't open only counts as failed.
The folder dialog opens in the previous scan's directory: library:add-folder stores it in the lastScanFolder setting (empty until a first scan) and passes it as defaultPath through existingDirectory() (src/main/ipc/dialogs.ts), which drops a directory that no longer exists, as the open-file dialog does with the last opened book's directory.
Adding a single file (library:add-file, "Ajouter un fichier") goes through the same path as one scanned file: the file dialog opens in the last opened book's directory, inspect in the decoder process, then register(), never touch(); it does not open the reader. The result (AddFileResult) says added, exists (left untouched) or error (translated message), and an added file makes the main process push a library reload.
Scanned files go through register(), never touch() (see the table above), and hasPath() lets the scanner skip known files without opening them. Each new file's cover thumbnail is cached by inspect, while its archive is still open (see Cover thumbnails). New rows get last_opened_at = added_at, which puts a fresh batch at the top of the list.
Library organization
See ADR 0015 ADR 0016 and ADR 0017. AppSettings.libraryOrganization (off/ask/always) and libraryOrganizationFolder decide whether added comics are moved into a folder the app lays out, and libraryStructure and libraryFileName how (organizedPaths(), src/main/services/library-organizer.ts, given an OrganizationTarget: the folder, the structure and the file name template).
Default layout (empty
libraryStructure): one subfolder per comic,<folder>/<name without extension> (<library id>)/<file name>, the name cut to fitMAX_NAME_BYTES.Levels: otherwise, one to three
LibraryLevels (isLibraryStructure(),src/shared/settings.ts: distinct,letter/yearfirst only,letterfollowed by another level).structureFolders()names one folder per level from the entry (yearfromreleaseDate,authorfrom the first writer else the first credit as "Last First",serieselse the title,albumthe title,letterthe first character of the next folder throughletterFolderName()), each cleaned bysafeFolderName(); a missing value giveslibrary:organizationUnknown. The file goes straight in the last folder under its original name, or<name> (<id>).<ext>when that is taken; both count as in place. The settings page edits it with three<select>s (levelChoices()/setLevel()/structureExample(),src/lib/library-structure.ts, pure), keeping an incomplete tree as a local draft. Changing it moves nothing:unorganizedIds()then lists the comics to move for "Organiser toute la bibliothèque…".File name: an empty
libraryFileNamekeeps the original name; otherwise it is a template of{series},{volume},{title},{year}and{author}fields (src/shared/file-name-template.ts:isFileNameTemplate()refuses/,\, a brace outside a known field and more than 200 characters;fillFileNameTemplate()substitutes).templatedStem()fills it from the entry (year and author as for the levels, a missing value givinglibrary:organizationUnknown), cleans it withsafeFolderName()and cuts it to leave room for the extension, which is always kept. The name is used in both layouts, including the per-comic subfolder of the default one. The settings page saves the field as it is typed while it is valid (an invalid one stays a local draft with its error) and showsfileNameExample(). Like the structure, changing it renames nothing until "Organiser toute la bibliothèque…".Who decides:
createLibraryOrganizer()(src/main/services/library-organization.ts, built once inmain.ts, withcreateResynchronizer()and the run lock: Electron-free, solibrary-organization.test.tscovers the tokens, the modes and the lock;src/main/ipc/library.tsonly registers the handlers that call it) is called bylibrary:add-file,library:add-folderand the resynchronizer with the entries just created (ScanSummary.addedEntries,ResyncSummary.addedEntries).planOrganization()(pure) moves everything withalways; withaskit moves only the comics already inside the organized folder and keeps the others behind a random token, returned asorganization.pending(OrganizationOutcome, part ofAddFileResult,ScanResultandResyncResult). The renderer asks (useOrganizationOffer(),src/hooks/use-organization-offer.ts, on the library page and Paramètres › Bibliothèque) and sends the token back throughlibrary:organize; tokens work once and only the last 20 are kept. The renderer never names a path: the folder is set bylibrary:pick-organization-folder(a dialog), andsettings:setrefuseslibraryOrganizationFolder(MAIN_PROCESS_SETTINGS,src/shared/settings.ts). The JSON import still restores it, like any setting.Moving:
organizeEntries()moves one comic at a time withmoveFile()(no overwrite; copy then delete across drives, the copy deleted again if the original can't be), thenLibraryRepository.updatePath(); if the database refuses the path, the file goes back. The subfolders of the organized folder left empty by a move are deleted, up to the organized folder itself; nothing outside it is. Only supported comics are moved (an entry naming any other file counts as failed and is not offered byunorganizedIds()). Moved entries lose their cached thumbnail (keyed by path) and the library slice reloads.The whole library:
library:count-unorganizedcounts the entries not in place yet (unorganizedIds(), which checks the file against itsorganizedPaths()), for the confirmation, andlibrary:organize-allmoves them, reporting overlibrary:scan-progress. It and the resynchronizer share aLibraryRunLock(organizer.lock): one run over the files at a time, since moving a file while a walk registers files could register it twice; the loser gets an error result.Watching: the chosen folder goes into
library_folders, andLibraryOrganizer.watchedFolders()adds it to the resynchronization's folders while the organization is on. A missing organized folder (unplugged drive) moves nothing and counts the comics as failed.
Page sizes (background measure)
LibraryEntry.avgPageWidth/avgPageHeight hold the simple average (rounded) of the pages' dimensions, null until measured. Opening every archive during an import would make it much slower, so it is a separate pass, run after the import and never awaited by it.
- Measuring: the decoder's
measurecall opens the file andmeasureArchive()(src/main/services/image-stats.ts) reads each page's size from its image header (readImageSize(), no decoding, so a page of absurd dimensions costs nothing); a PDF answers from its page boxes through the optionalComicArchive.pageSize(), at the scale it is rendered at. A page that can't be read, or whose format has no header reader (AVIF, BMP), is left out of the average. CBR pages still go throughreadPage(), which makes a CBR the slow case. - Scanner:
createImageStatsScanner()(image-stats-scanner.ts, in the main process) measures the entriesLibraryRepository.unmeasured()lists (avg_page_width IS NULL), one at a time, and writes the result withupdateImageStats().kick()is called afterlibrary:add-file,library:add-folder, a resynchronization, a JSON import, the first opening of a book, and at start-up; a call during a run does nothing, since the run looks for new entries again before it ends. A book that can't be measured keeps its columnsnulland is skipped until the next start, so it isn't reopened on everykick(). Updated entries are announced to the data store in batches (data:changed, at most every 500 ms);touch()andregister()never write these columns,upsert()(the import) does, a snapshot without them resetting them tonullso they are measured again. - Progress:
library:image-scan-progresspushes anImageScanProgress(running,processed,total) to every window, andlibrary:image-scan-statusreturns the current one, for a window that loaded after the start (useImageScan(),src/hooks/use-image-scan.ts). The root layout showsImageScanBarat the bottom of the window whilerunning(hidden in fullscreen reading);imageScanPercent()andformatPageSize()(src/lib/image-scan.ts) are the pure parts.
Resynchronization
library:add-folder also remembers the directory in library_folders (migration 3, LibraryFolderRepository), a table of the main process only: the renderer can list the folders (library:list-folders) and forget one (library:remove-folder) but never sets one, so it can't make the app walk an arbitrary path. library:resync (and, when AppSettings.resyncOnStartup is on, the start-up in main.ts, in the background) goes through createResynchronizer() (src/main/services/library-organization.ts), which allows one run at a time, calls resyncLibrary() (src/main/services/library-sync.ts), stores AppSettings.lastResyncAt and notifies the views (library reload, readingLists when something was removed, the settings value). Progress goes over library:scan-progress for a manual run. With the library organization on, it also walks the organized folder and hands the comics it added to the organizer, which may move them.
For each folder, resyncLibrary() runs scanIntoLibrary() (new files added) and then removes the entries under that folder whose file is gone. Because a removal loses the user's progress, rating and tags, it is conservative: a folder that isn't a readable directory is skipped (unreachable), and an entry is removed only when stat says ENOENT, not merely when the walk didn't see it (it skips the subfolders it can't read). Entries outside every folder (files added on their own) are checked afterwards, one by one: removed when stat says ENOENT and their parent directory is still a readable directory, so a vanished drive or folder keeps them; the files on disk are never touched. The removed entries' thumbnails are deleted like those of library:remove. The folder list is not exported, and data:clear-library keeps it.
Reader (src/routes/reader.tsx)
Layout of the code. The route (reader.tsx) only reads the search params, wires useComic(), useReaderFullscreen() and useNextInList(), and picks a reader. The pieces live in src/components/reader/: SinglePageReader and ContinuousReader (with ContinuousPage), and the parts both modes share (ReaderHeader, ReadingProgress, FullscreenButton, NextInListButton). Reader-specific hooks are in src/hooks/ (use-reader-fullscreen.ts, use-resume-scroll.ts). The page-turning rules are pure and tested in src/lib/reader-navigation.ts: directionalControls() binds advance/retreat to next/previous page for the reading direction, and disabledControls() says which click zone has nowhere to go (isPrevDisabled/isNextDisabled).
AppSettings.readingMode picks SinglePageReader or ContinuousReader. They are separate components, each owning its hooks, so switching modes mounts/unmounts cleanly instead of sharing refs and effects.
Opening and closing. useComic owns the archive's lifetime. openFile() isn't cancellable, so each call takes a request number and the hook keeps a mounted flag: a comic:open that answers after the reader was left, or after a newer open started, closes the archive it just got (comic.close(id)) instead of leaving it in ComicService until the app quits (a CBR stays in memory, a CBZ leaks a file descriptor). useComic({ loadPages: false }) skips the single-page fetch; the reader passes it in continuous mode, which loads its own pages, so the resume page isn't rendered twice (costly for PDFs).
Reading direction. AppSettings.readingDirection (ltr/rtl) swaps the two actions bound to the physical controls: advance is next in LTR and prev in RTL, retreat the opposite. useComic's next()/prev() always mean page index ±1. In single-page mode advance is bound to →, PageDown, Space and the right click zone; retreat to ←, PageUp and the left zone.
Single page. Zoom (fit, 50–200 %; a zoomed page uses m-auto in a scrolling flex container, so it is centered while smaller than the viewport and scrolls from its top-left corner once larger), optional AI upscaling (below), and wheel-to-turn-page. The wheel only turns the page when there is nothing left to scroll in that direction: always in "fit" zoom, and only at the top/bottom edge when zoomed in. AppSettings.scrollDirection (standard/inverted) decides whether scrolling down calls advance() or retreat(). createWheelPager() (src/lib/wheel-pager.ts, pure and unit-tested) collapses one trackpad swipe's many wheel events, including macOS's inertia tail, into one page turn: a gesture ends after ~200 ms without events, or when a delta jumps well above the previous one (inertia only decays). The pager is held in useState, because the wheel effect re-runs after every page turn (next/prev change identity each render) and a fresh pager would forget the gesture in progress. After a turn, SinglePageReader scrolls the zoomed page to its top (forward) or bottom (backward), as told by scrollEdgeAfterTurn(); the edge is applied once the new image's size is known, since pageUrl arrives after page changes. Panning events are fed to it too, so a swipe that pans to the edge doesn't turn the page with its leftover inertia.
Testing note: a JS-dispatched
WheelEventfires listeners but does not scroll the element. Verifying real scroll behavior needs CDP'sInput.dispatchMouseEventwithtype: 'mouseWheel'.
AI upscaling. useImageUpscaler (src/hooks/use-image-upscaler.ts) runs the page through an ESRGAN model (UpscalerJS on TensorFlow.js) in the renderer, when the page is displayed beyond its native resolution and the user enabled it. The model and TensorFlow.js are loaded with a dynamic import() on first use only; the weights are served from the app's own files (vite.renderer.config.mts copies them), never from a CDN, so it works offline. TensorFlow.js runs on the renderer's main thread, so inference is tiled in 64 px patches with awaitNextFrame (it yields to the browser between patches: the UI no longer freezes on a large page) and an AbortSignal aborted when the page changes or the option is unchecked. The upscaled pages (data URLs, tens of MB each) are kept in a 4-entry LRU cache (createLruCache(), src/lib/lru-cache.ts) keyed by the page, <archive id>:<page index> (useComic().pageKey, which names the page pageUrl really shows, since page moves on before the new image arrives), not by the page's blob: URL, which is made anew at each load: coming back to a page, or toggling the zoom, reuses the result instead of running the model again.
Continuous scroll. ContinuousReader renders one ContinuousPage per page, each with three IntersectionObservers: one with a large rootMargin (800 px) that lazily fetches the page, one with a wider one (3 000 px) that releases a loaded page once it is farther than that (image and blob: URL; its placeholder keeps the height the page had, so nothing below it moves), so the renderer's memory follows what is near the reader rather than every page read (a long PDF at ~200 DPI would otherwise hold gigabytes), one with rootMargin: '-50% 0px -50% 0px' (a line across the middle of the viewport) that reports the active page: the page crossing that line, since a visibility threshold would never be reached by a page taller than twice the viewport. It reports it for the header and for saving progress (debounced ~400 ms through createTrailingDebounce(), src/lib/trailing-debounce.ts, and flushed when the reader unmounts so a quick close still records the last page and the book as finished; it calls library.updateProgress directly, not useComic's goTo, which would also start a single-page fetch). A page that fails to load shows "Unreadable page" instead of its spinner. A page's image is only shown once decoded (the placeholder stays until then), so the page changes height in one step; when a page starting above the top of the viewport does, ContinuousPage adds the difference to the scroll position, except while the resumed page is pinned (useResumeScroll returns a ref saying so, the pinning already corrects it). The container has overflow-anchor: none, so the browser's own anchoring doesn't also move it. Without this, scrolling back up into the previous page after resuming a book let its image replace the placeholder and grow downward, leaving the reader too high in the page instead of at its bottom. Upscaling and RTL don't apply; the gap is AppSettings.pageSpacing px. On open, useResumeScroll scrolls to comic.resumePage in a layout effect (before the observers' first callbacks, so page 0 is never reported over the saved progress), then keeps it pinned with a ResizeObserver while preloaded pages above change height, until the user's first wheel/pointer/touch/key input. The header's zoom menu sets the width of the page column (WIDTH_OPTIONS, 50–200 % of the scroll area, 100 % = fit to width; local state, not persisted), the scroll container scrolling both ways; on a change the scroll position is restored as a fraction of the scrollable size so the reader stays on the same page. ContinuousReader is keyed by comic.id, so opening another book remounts it.
Progress and pace. useReadingPace (src/hooks/use-reading-pace.ts) computes the header's "X % · ~Y min" for both modes (ReadingProgress component). The pace is for the current session only (nothing persisted): when sessionKey (the archive's comic.id) changes, it resets by calling setState during render (React's "adjust state while rendering" pattern), so the first render of a new book is already right. The estimate stays hidden until MIN_ELAPSED_MINUTES has elapsed and at least one page was turned.
Theme. AppSettings.theme (system/light/dark) is applied by keepThemeInSync() (src/hooks/use-settings.ts, called by renderer.tsx before the first render): it toggles the dark class and color-scheme on <html>, which switch the tokens of src/index.css, from the pure isDarkTheme() (src/lib/theme.ts) and prefers-color-scheme while the setting is system. It is independent of the reader's background.
Background. AppSettings.readerBackground (#rrggbb, default black) is an inline backgroundColor on the page area of both modes; presets are READER_BACKGROUND_PRESETS (their names are translated under settings:appearance.backgrounds). The header keeps a fixed dark chrome, and page-area placeholders use a mid grey so they stay readable on any background.
Fullscreen. useFullscreen (src/hooks/use-fullscreen.ts) wraps the HTML Fullscreen API on document.documentElement: in Electron that makes the window itself fullscreen, and Chromium handles Escape, so no IPC is involved. The reader (useReaderFullscreen) toggles it with the header button or F/F11 and exits it when the comic is closed or the route unmounts. While fullscreen, the root layout hides the sidebar (without touching sidebarCollapsed) and ReaderHeader becomes an overlay shown when the mouse reaches the top edge.