# Architecture ## Overview SaiLoR is a single-codebase React app. It used to run as both an Electron desktop application and a static web SPA; **the web runtime is now discontinued** — `src/App.tsx`'s `isElectron()` gate shows a "use the desktop app" notice and blocks every project-opening UI before any of it can be reached, and the browser-only platform code (`src/platform/browser.ts`, `src/platform/idb.ts`) and the `?project=` server-deployment loader (`loadFromUrl` in `src/state/store.ts`) were deleted outright. SaiLoR is Electron-desktop-only now. See "SaiLoR is Electron-desktop-only" below for the reasoning, and [Operations](operations)/[Quickstart](quickstart) for what this means for running the app day to day. The app also changed how a project is stored on disk: `project.json` now holds only the schema and paper metadata, never annotation data — every reviewer's/consolidation's actual answers live in a sibling `annotations//` folder instead, one file per reviewer plus a consolidated file. See "Assembling and splitting a project on disk" below and [Data Model](data-model)'s "On-disk layout" for the full shape and why (git-merge conflicts between reviewers editing the same all-in-one file). The **PlatformAdapter** interface remains the architectural seam abstracting file I/O and PDF loading — it is still what `ElectronAdapter` implements — but with the web runtime discontinued, the seam now has one real implementation and one inert stand-in (`UnsupportedAdapter`) rather than two competing ones. See "Why the seam still exists" below for why it was not simply deleted along with the browser adapter. ## Platform Adapter Pattern The entire file-system and PDF-loading layer is abstracted behind a single interface: ``` src/platform/adapter.ts → PlatformAdapter interface src/platform/index.ts → getPlatform() singleton factory src/platform/electron.ts → ElectronAdapter — the only real implementation now src/platform/unsupported.ts → UnsupportedAdapter — inert stand-in outside Electron, see below ``` **`PlatformAdapter`** (`src/platform/adapter.ts`) defines these operations (the list below is a guided tour, not an exhaustive count, which would just go stale as the interface grows): - `getRecents()` — return the list of recently opened projects (`RecentEntry[]` with `id` + `name`) - `openRecent(id)` — re-open a project by its recent-entry id (the absolute file path) - `openProject()` — show an open dialog/picker, return JSON text + a `SaveHandle` - `saveProject(text, handle)` — write back to the handle's location; on Electron this is where the logical whole-project text gets split into `project.json` + `annotations/` files (see "Assembling and splitting a project on disk" below) - `rebasePdfPaths(pdfPaths, from, to)` — re-express PDF paths that were relative to `from`'s directory as relative to `to`'s. **"Save as" depends on this**: a paper's `pdf` is stored relative to the project file, so writing the old paths to a new location left every PDF pointing at nothing. `store.saveAs()` therefore picks the destination *first* (`pickProjectLocation`), rebases, and only then serializes and writes. Electron does the real path math via a `paths:rebase` IPC (`relative(dirname(to), resolve(dirname(from), rel))`). - `getPdfSource(pdfPath, projectHandle)` — resolve a paper's relative PDF path into a URL react-pdf can load. Re-asserts the main process's project directory from `projectHandle` first, so PDFs always resolve against the project actually being rendered (the project editor repoints that directory when picking a location). Three more exist for the **project editor** (see below): - `pickProjectLocation(suggestedName)` — ask where the project JSON should live; writes nothing. Returns a `ProjectLocation` (`handle`, `name`, and an absolute `path`). - `pickPdfs()` — pick PDFs to reference; returns `PickedPdf[]` (`name`, plus an absolute `path`). - `pickPdfFolder()` — pick a folder and return every PDF inside it, recursively, as `PickedPdf[]`, via a `pdf:pickFolder` IPC that walks the filesystem. - `pickReferenceFile()` — pick a single `.bib`/`.ris`/`.json` reference-manager export; returns `{ text, name }` or null if cancelled. Parsed by `src/model/references.ts`. - `relativePdfPaths(pdfs, location)` — the `pdf` values to store, **relative to the JSON's directory**, computed via IPC. - `absolutePdfPaths(pdfPaths, from)` — the inverse: absolute paths for values relative to `from`'s directory. Added for `startFromScreening`/`importFromScreening` (see "Screening" below) — a paper carried in from a screening project needs a real `sourcePath`, not just the relative path the source file stored, or `changeLocation` cannot re-derive it later. - `siblingProjectLocation(source, fileName)` — the location `fileName` would have if it sat next to `source`'s directory; writes and prompts nothing. What makes "save the new annotation project next to the screening JSON" the *default* rather than a dialog suggestion. One more is its own capability object rather than a flat method: `getGit(): GitPlatform | null` — git operations against the user's own git installation, or `null` where the runtime cannot reach one. `UnsupportedAdapter` (the non-Electron stand-in) always returns `null` here, same as the deleted `BrowserAdapter` did — the type-level shape that made git support unreachable outside Electron didn't need to change when the web runtime itself became unreachable. See "Git" below. **Project title.** A project JSON may set a top-level `title`; the app shows it wherever it would otherwise show the file name (toolbar, recents list, Open menu), falling back to the file name when absent. It is a first-class key on `Project` (not swallowed into `extra`, which would duplicate it on save) and is only written when non-empty. The project editor exposes it as a *Project title* field next to the JSON location. **Recents are re-read from disk, not trusted.** The title shown for a recent is stored on the entry, but that copy goes stale the moment the project is renamed elsewhere — most obviously by changing the *Project title* in the editor, where the old name would otherwise still be sitting in the list after closing it. So `checkRecents()` **re-reads each project's current title from the file** via a `project:peek` IPC that returns `{ exists, title }` per path in one round trip (parse failures are contained per file). The refreshed list is written back (`replaceRecents`, order preserved) so the fresh titles survive a restart, and `editorStore.save()` triggers a refresh so a rename shows up as soon as the editor is closed. A title removed from a file correctly reverts the entry to showing its file name. **A recent whose file has gone is kept, not forgotten.** `checkRecents()` (run on startup via `refreshRecents()`) re-tests each entry via `fs:exists` and sets a runtime-only `available` flag (never persisted). A missing project stays in the list, faded, badged *not found*, and unselectable, because the drive may come back; the user can still dismiss it with the ×. Opening one that has since vanished marks it unavailable rather than pruning it. Note the flag is deliberately tri-state: `undefined` means *not checked yet*, and only an explicit `false` greys an entry out, so nothing flickers on first paint. **Closing a project** (`requestCloseProject`) returns to the start screen, and when there are unsaved changes it first asks via `ClosePrompt` — the same three choices and wording as Electron's native quit dialog (Save / Don't Save / Cancel). A *failed* save keeps the project open rather than closing and losing the work. **Recents entries** carry `path` and `title` beyond `id`/`name`. The path is what lets a user tell two projects called `review.json` apart: the start screen shows it under the title (truncated from the *left*, since the tail is what differs — `direction: rtl` + `unicode-bidi: plaintext`, the latter so the leading `/` isn't reordered to the end), and the Open menu shows it as a hover tooltip. On the start screen each entry also carries a **✎** pen (`useEditorStore().startEditRecent(id)`) that opens the project straight into the schema editor instead of the annotation view — the same edit path as *Edit annotation JSON…*, but for a known recent rather than a file picker. Both places offer an **×** to drop an entry (`forgetRecent`), which in the menu deliberately does *not* close it, so several can be cleared in a row. The title is only known once the JSON is parsed, so `loadFromText` re-pushes the entry via `rememberProject()` — `pushRecent` dedupes by id, so this enriches the existing entry rather than duplicating it. Recent projects are managed by `src/platform/recents.ts` — up to 5 entries in `localStorage`. The entry `id` is the absolute file path. `getPlatform()` (`src/platform/index.ts`) returns a singleton: `ElectronAdapter` if `window.slr` exists (preload bridge), otherwise `UnsupportedAdapter`. Detection uses `isElectron()` which checks for the preload-bridged `window.slr` object. ### ElectronAdapter (`src/platform/electron.ts`) Delegates to `window.slr` (the preload bridge). File operations use IPC to the main process. PDFs are served via the custom `slr-file://project/` protocol — the main process resolves paths relative to the project directory. `setProjectDir` is called on open/save-as so the protocol knows the base directory. On open and save-as, the adapter pushes an entry to the recents list (`slr.recents.electron` localStorage key). `openRecent(id)` calls `bridge().openPath(id)` to read a file by absolute path; if the file no longer exists the entry is pruned from recents. **`saveProject(text, handle)` is where the on-disk split happens.** `text` is the logical whole-project JSON `serializeProject()` produced (the same shape as before this feature — the model layer never learned the split shape, see [Data Model](data-model)'s "Assembling and splitting on disk"). `ElectronAdapter.saveProject` re-parses it with `loadProject`, calls `splitProjectFiles()` to get `{ meta, files }`, and sends both over the `project:save` IPC, which `electron/main.ts` writes as `project.json` plus a reconciled `annotations/` folder. `openProject`/`openRecent` are symmetric on the read side: the IPC handler (`readProjectText`) reassembles a split project back into that same logical text before handing it to the renderer, or passes an old single-file project through untouched. See "Assembling and splitting a project on disk" below for the full mechanics. ### Why the seam still exists `PlatformAdapter` was built to abstract over two real runtimes; now it abstracts over one real runtime (`ElectronAdapter`) and one that refuses everything (`UnsupportedAdapter`, `src/platform/unsupported.ts`). It was not collapsed away because `App.tsx`'s `isElectron()` gate renders *after* React's hooks have already run for that pass — `useStore`, `useEditorStore`, and a few module-level reads (`getPlatform().getRecents()` at store creation, before `App` ever mounts) all still execute in a non-Electron runtime, and something has to answer those calls safely rather than throwing during module init. `UnsupportedAdapter` implements the full interface: every read-only query returns "nothing" (`[]`, `null`), every action throws `"SaiLoR for the web is discontinued — use the desktop app."` as a backstop in case anything is ever wired up to call one of these directly. In ordinary operation nothing reaches it, because the gate blocks the UI first — but the type system still requires a `PlatformAdapter` to exist before `App` can render at all, so something inert has to fill that slot. ### UnsupportedAdapter (`src/platform/unsupported.ts`) The non-Electron `PlatformAdapter` implementation used to be `BrowserAdapter` — a substantial piece of code covering three capability tiers (Chromium's File System Access API, a `webkitdirectory` `` fallback for other browsers, and a `?project=` server-fetch mode), plus an IndexedDB handle store (`src/platform/idb.ts`) to keep FSAPI handles alive across reloads. **All of it was deleted** when the web runtime was discontinued — `getPdfSource`'s folder-grant flow, the `?project=` server mode and its PDF-magic-number byte check, the IndexedDB abort/blocked handling, the opaque-id recents scheme FSAPI's pathless handles needed, all of it, along with the deleted-code paths that used to be reachable through them. What replaced it is `UnsupportedAdapter` (see "Why the seam still exists" above): every read-only method returns an empty/`null` result, every action-performing method throws `"SaiLoR for the web is discontinued — use the desktop app."` There is nothing else to document here — it is deliberately inert, a backstop rather than a feature. ## State Management The entire app state lives in a single Zustand store with immer middleware: **`src/state/store.ts`** → `useStore` ### State Shape | Field | Type | Purpose | |---|---|---| | `project` | `Project \| null` | The loaded, normalized project (schema + papers) | | `currentPaperId` | `string \| null` | Currently selected paper | | `saveHandle` | `SaveHandle \| null` | Where to write back — the opened file's absolute path (`kind: 'electron'`); `SaveHandle`'s `'fsapi'`/`'download'` kinds are unused dead branches left over from the deleted browser adapter | | `projectName` | `string` | Display name for title bar | | `dirty` | `boolean` | Unsaved changes flag; gates `beforeunload` guard | | `loadError` | `LoadError \| null` | Error overlay data | | `busy` | `boolean` | Disables toolbar buttons during async operations | | `sidebarCollapsed` | `boolean` | Paper list visibility. Driven by `SidebarToggle`, which renders inside the paper list's own header while it is open and in the toolbar once collapsed — otherwise the button would disappear with the pane it reopens. | | `pdfSelection` | `string` | Latest text selected in the PDF viewer (for "grab from PDF") | | `theme` | `Theme` (`'light' \| 'dark'`) | Current app theme (persisted in localStorage via `src/state/settings.ts`) | | `fontScale` | `number` | Current font scale factor (0.7–2.0, persisted in localStorage) | | `pdfZoom` | `number` | PDF zoom multiplier (0.4–3.0, session-only, default 1) | | `recents` | `RecentEntry[]` | Recently opened projects (max 5, from `platform.getRecents()`) | | `helpOpen` | `boolean` | Help dialog visibility | | `past` / `future` | `HistoryEntry[]` | Undo/redo stacks for annotation edits (session-only, capped at 100). Each entry is `{ project, paperId }`; thanks to immer's structural sharing, snapshots are cheap references | | `aiMarks` | `Record` | Fields the AI filled and the reviewer has not looked at yet, keyed `` `${paperId}::${canonicalPath}` `` for a single-reviewer project, or `` `${paperId}::${reviewer}::${canonicalPath}` `` for a multi-reviewer one (see "AI marks" below). Session-only: it lives *beside* the project, so `serializeProject` cannot see it | | `currentReviewer` | `string \| null` | Which reviewer's tree is shown/edited — `"1".."N"`, `"consolidation"`, or `null`. Always `null` for a single-reviewer project; also starts `null` for a multi-reviewer one until picked (see "Multiple reviewers & Consolidation" below). Persisted per project in `localStorage`; not an undo step, does not set `dirty` | | `consolidationTarget` | `{ path, name, index } \| null` | The field the Consolidation "compare" popup (`ConsolidationDialog`) is showing, or `null` when closed. Session-only | | `consolidationOverviewOpen` | `boolean` | Whether the project-wide `ConsolidationOverview` modal is open. Session-only | | `deferredConsolidations` | `Record` | Fields where the consolidator chose "Enter a different value" — waiting for a manually entered value. Keyed by `deferredConsolidationKey(paperId, canonicalPath)`. Session-only; cleared on project close/load | | `screeningFilter` | `'all' \| 'included' \| 'excluded' \| 'undecided'` | Which decisions the screening paper list shows. Screening projects only; session-only, resets on `closeProject`/`loadFromText` | | `screeningShowPdf` | `boolean` | Whether the middle pane shows the PDF instead of `ScreeningRecord`'s title+abstract. Session-only, see "Screening" above | | `screeningSummaryOpen` | `boolean` | Whether `ScreeningSummary` (the PRISMA-style counts modal) is open. Session-only | ### Key Actions - **`openProject()`** — delegates to `platform.openProject()`, then `loadFromText()`, refreshes `recents` - **`openRecent(id)`** — delegates to `platform.openRecent(id)`; on success → `loadFromText` + refreshes `recents`; on null → prunes recents and sets `loadError` - **`loadFromText(text, handle, name)`** — calls `loadProject(text)` from the model layer, sets state, selects first paper. (`loadFromUrl(url)`, the `?project=` server-deployment loader, was deleted along with the browser build — see "SaiLoR is Electron-desktop-only" in the Overview.) - **`save()` / `saveAs()`** — `serializeProject(project)` → delegate to platform, clears `dirty`, refreshes `recents` - **`selectPaper(id)`** — switches paper, clears `pdfSelection` - **`setFieldValue(path, name, index, value)`** — navigates the annotation tree via `containerAt()`, sets `inst.value`, marks dirty - **`addInstance(path, def)` / `removeInstance(path, name, index)`** — manages repeatable annotation instances, respects `max`/`min` - **`toggleTheme()` / `setTheme(theme)`** — flips or sets the app theme, applies via `applyTheme()` (sets `data-theme` attribute on ``) - **`increaseFont()` / `decreaseFont()` / `resetFont()`** — adjusts `fontScale` by ±0.1 (clamped to 0.7–2.0), applies via `applyFontScale()` (sets `--app-font-scale` CSS variable) - **`zoomInPdf()` / `zoomOutPdf()` / `resetPdfZoom()`** — adjusts `pdfZoom` by ±0.2 (clamped to 0.4–3.0, rounded to 2 decimals) or resets to 1; session-only, not persisted - **`applyAiSuggestions(suggestions)`** — writes the reviewer-approved AI proposals into the current paper as **one undo step**, and marks every field it wrote (see "AI-assisted annotation" below) - **`confirmAiMark(paperId, canonicalPath)`** — drops one AI mark; the reviewer clicked into that field (or its label) - **`undo()` / `redo()`** — swap the current project snapshot with one from the `past`/`future` stack (and switch to the affected paper). The mutating actions push a snapshot before applying; consecutive edits to the *same* field coalesce into one undo step (a module-level `lastFieldKey` tracks this), while add/remove/paper-switch reset it. History is cleared on project load. - **`setHelpOpen(open)`** — shows/hides the help dialog - **`selectReviewer(reviewer)`** — switches `currentReviewer` and persists the choice per project. A view switch: no undo step, no `dirty` - **`openConsolidation(path, name, index, returnToDisagreements?)` / `closeConsolidation()`** — open/close the compare popup for one field; when `returnToDisagreements` is set, `closeConsolidation` reopens the per-paper disagreement list - **`resolveConsolidationValue(path, name, index, value)`** — writes a chosen reviewer value, marks the field equal, and clears any deferral in one undo step - **`deferConsolidationValue(path, name, index)`** — marks a field as deferred (waiting for manual entry); `setFieldValue` auto-clears the deferral when a non-empty value is entered - **`setConsolidationOverviewOpen(open)`** — toggle the project-wide `ConsolidationOverview` modal - **`openAgreementFromOverview()` / `closeAgreement()`** — closes overview, opens agreement, restores overview on close - **`openDisagreementsFromOverview(paperId)` / `closeDisagreements()`** — selects paper, closes overview, opens per-paper disagreement list, restores overview on close - **`alignConsolidationNode(paperId, nodeName, coalesce)`** — match the reviewers' repeated entries under one node and write the result in (reorder + grow); see "Matching the reviewers' repeated entries" below - **`adoptUnanimousValues(paperId, coalesce)`** — fill the consolidated fields every reviewer answered the same way, marking each via `aiMarks`; runs after the matching for a paper - **`setScreeningDecision(decision, reason?)` / `setScreeningReason(reason)`** — screening-only field writes, routed through `currentTree` like every other write; see "Screening" below for the auto-advance and reason-clearing rules - **`adoptAllUnanimousScreening()`** — `adoptUnanimousValues` for every paper in one undo step; safe unscheduled (unlike the per-paper alignment scheduler) because a screening schema has nothing for `align.ts` to line up — see "Screening" below - **`adoptAllUnanimousAnnotations()`** — the ordinary-schema counterpart: aligns, then adopts, paper by paper, in one undo step, skipping any paper the consolidator has already partly answered; see "Batch-adopting across the whole project" above The `containerAt(root, path)` helper walks the annotation tree following `PathSeg[]` (name + index pairs) to reach the container for a given path. `currentTree(project, currentReviewer, paper, create?)` decides *which* tree that walk starts from — see "Multiple reviewers & Consolidation" below; `setFieldValue`, `addInstance`, `removeInstance`, and `applyAiSuggestions` all call it before touching anything. ## Component Tree ``` App (src/App.tsx) ├── Toolbar (src/components/Toolbar.tsx) │ Open ▾ dropdown (Open file… + recent projects) + Save ▾ dropdown (Save / Save as…) │ Font controls (A− A A+), theme toggle (☾/☀), help (?) │ Reviewer switch (multi-reviewer projects only), centered on the toolbar — Reviewer 1..N + Consolidation, hidden entirely for a single-reviewer project; pills at ≤5 reviewers, a dropdown above that; see "Multiple reviewers & Consolidation" below ├── [if project loaded: workspace — a CSS grid whose column widths come from resizable panes] │ ├── PaperList (src/components/PaperList.tsx) │ │ List of papers with search box (META / TAGS modes, see below); a dot showing the active reviewer's completeness — a conic-gradient partial fill, not just touched/untouched, see "Completeness dot" below (screening and Consolidation keep their own tri-state/binary markers); click to select │ ├── Splitter (src/components/Splitter.tsx) ×2 — drag handles between the panes │ ├── [screening project + PDF pane not toggled on: ScreeningRecord (src/components/ScreeningRecord.tsx)] │ │ Title/authors/DOI header + the abstract (or "No abstract recorded"); a "Read the PDF" button swaps to PdfViewer when paper.pdf !== '' │ ├── [otherwise, including the screening PDF toggle: PdfViewer (src/components/PdfViewer.tsx)] │ │ react-pdf Document+Page; ResizeObserver for width; zoom controls; multi-page navigation; jump history (back/forward); in-PDF search (Ctrl+F); text selection capture; empty state for paper.pdf === '' (screening only) │ ├── [screening project: ScreeningPanel (src/components/ScreeningPanel.tsx)] │ │ Include/Exclude decision buttons + a Reason ComboBox (disabled unless Exclude) + progress line; ◧ Summary always, ⚖ Agreement / ⚠ Disagreements in the Consolidation seat — see "Screening" below │ └── [otherwise: AnnotationPanel (src/components/AnnotationPanel.tsx)] │ ✦ AI button in the column header (opens AiDialog; disabled while busy, when the paper has no PDF, when the project forbids it, when no reviewer is picked yet, or — by default — always, until the hidden unlock; see "AI-assisted annotation" below) │ renders the tree `currentTree()` routes to for the active reviewer; prompts to pick a reviewer instead of the form when a multi-reviewer project has none selected yet │ Consolidation seat only: "☰ Overview" button (opens `ConsolidationOverview`) + "⚠ Disagreements" button (opens per-paper `DisagreementOverview`); builds a `ConsolidationVerdictsContext` map (per-field agree/disagree status) shared to all child `Field` components via React Context │ └── AnnotationNode (src/components/AnnotationNode.tsx) [recursive] │ └── Field (src/components/Field.tsx) │ Input control (text/number/checkbox/enum ComboBox) + ⧉ grab-from-PDF button + (Consolidation mode only) ⇄ compare button → opens ConsolidationDialog for that field; reads `useConsolidationFieldStatus(canonical)` from context to add `consolidation-agree`/`consolidation-disagree` CSS classes; checks `deferredConsolidations` for `consolidation-pending` visual state ├── [if no project: welcome screen with "Open project…" button, "New from screening…", and (if any) recent projects list] ├── AiDialog (src/components/AiDialog.tsx) │ Modal driven by useAiStore's phase: pick a target → Start → review table → Apply │ └── LlmSettingsDialog (src/components/LlmSettingsDialog.tsx) — manage LLM targets (stacks on top) ├── HelpDialog (src/components/HelpDialog.tsx) │ Modal overlay with app intro + keyboard shortcuts table ├── ValidationDialog (src/components/ValidationDialog.tsx) │ Modal overlay showing the results of "Validate", scoped to the active reviewer's own tree (or the consolidated one, for Consolidation) — see below ├── ConsolidationDialog (src/components/ConsolidationDialog.tsx) │ Modal overlay reachable only from Consolidation mode's ⇄ button: every reviewer's answer for one field, side by side; picking one calls `resolveConsolidationValue` (writes value + marks equal + clears deferral), or "Enter a different value" defers for manual entry ├── ConsolidationOverview (src/components/ConsolidationOverview.tsx) │ Project-wide modal for Consolidation's batch actions: lists all papers with ≥1 disagreement, houses "Adopt all unanimous" (with run progress), and opens Agreement / per-paper DisagreementOverview via return-to-flag navigation ├── ScreeningSummary (src/components/ScreeningSummary.tsx) │ Modal: progress + PRISMA-style include/exclude/reason counts for a screening project ├── ScreeningImportDialog (src/components/ScreeningImportDialog.tsx) │ Modal: the pre-commit summary for "New from screening…" / "Import from screening…", including "New from screening…"'s annotation/screening target-kind radio — see "Screening" below ├── DuplicateReviewDialog (src/components/DuplicateReviewDialog.tsx) │ Modal: reviewing the *probable* duplicates a reference import found — one row per pair, a merge/separate choice defaulting to undecided — see "Duplicate detection at import" below ├── GitCloneDialog (src/components/GitCloneDialog.tsx) │ Import-from-git modal, driven by useGitStore's clone.phase: setup (URL + destination) → cloning (spinner + elapsed seconds) → error (git's exact text, back to setup) → done (pick the project JSON, opened inside the clone) — Electron only, see "Git" above ├── GitDialog (src/components/GitDialog.tsx) │ Modal for the open project's own repository: changes + diff, a commit message, Commit, Pull, Push, and a branch switcher (a `` - `string` with `options` (enum) → `ComboBox` (`src/components/ComboBox.tsx`) — a filterable dropdown of allowed values; no free-text grab button - `string` without `options` → auto-expanding `