# TexLite design This document records the design goals and the implementation choices that shape TexLite. It complements the short [README](README.md) and the [operations guide](OPERATIONS.md), which covers installation, configuration, and day-to-day operation. ## Design goals - Use the host's TeX Live/LaTeX installation so it can be updated independently. - Keep the deployment small: one Node.js process, SQLite, local files, and no Redis, MongoDB, reverse proxy, or bundled LaTeX image for the default localhost setup. - Keep project source, compile output, history, and credentials under one configurable data directory so that a complete backup is straightforward. - Provide useful real-time collaboration for a small trusted group rather than emulate a distributed Overleaf deployment. - Prefer durable, explicit boundaries over silently accepting a possibly stale or mixed-time source tree. ## Architecture | Area | Implementation | | --- | --- | | Browser UI | React, Vite, CodeMirror, PDF.js | | Localization | Frontend JSON resources plus a server-side error-code catalog, selected from `Accept-Language` | | Editor language features | CodeMirror LaTeX syntax/folding, auto-pairs, project completion index, optional Vim mode | | Writing assistance | Optional host `harper-cli` plus opt-in host `chktex`; Harper provides spelling/grammar suggestions, ChkTeX provides LaTeX writing diagnostics, and the project dictionary is stored by the server | | API and static server | Fastify, WebSocket | | Collaboration | Yjs, y-websocket, awareness messages, y-codemirror.next | | Database | SQLite through better-sqlite3, foreign keys, WAL mode, and `synchronous=NORMAL` | | Files | Local project directories under the configured data directory | | Compilation | Host `latexmk` and a configured LaTeX engine | | Formatting | Browser-side `tex-fmt` WASM package and `bibtex-tidy` | | Git backup | Optional host Git and the GitHub REST API | | Process management | Foreground `serve`, or the bundled PM2 lifecycle commands | | External tool terminals | Administrator opt-in; adapter-based host CLIs, optional `node-pty`, lazy xterm.js UI, independent task working copies | ## Localization The browser owns interface copy through English (`en`), Simplified Chinese (`zh`), and Traditional Chinese (`zh-Hant`) JSON resources in `src/client/locales`. The shared locale resolver preserves existing `zh` preferences and recognizes `zh-TW`, `zh-HK`, and `zh-MO` as Traditional Chinese; an explicit `Hans` or `Hant` script takes precedence over the region. Saved choices take precedence over browser preferences. The active locale also drives the HTML language and localized dates and relative times. API errors use stable codes rather than route-local human text. The server resolves those codes through `src/server/i18n.ts` using the request's `Accept-Language` header; the browser API helper always supplies its active language for both JSON requests and ZIP uploads. The server honors language preference weights and uses matching Traditional Chinese messages; unmatched or non-browser requests fall back to English. This keeps direct API clients usable while allowing the React client to retain its more contextual local translations. New user-correctable server failures must use `apiError()` for an immediate response or `httpError()` for a thrown response. Do not place translated text in route, filesystem, Git, or compiler-control code. Operational logs and raw LaTeX output remain in their original form, so they can be searched and diagnosed without changing behavior by browser language. Traditional Chinese is a separately maintained catalog, not a runtime character conversion. Use consistent terminology such as 專案, 檔案, 原始碼, 儲存庫, and 批註 (collaborative comments). Tests check catalog keys, interpolation variables, rich-text tags, language detection, persistence, and API headers. ### Project catalog The homepage places All projects, Owned by me and Shared with me tabs below the page title. Ownership is filtered on the server before pagination (32 projects per page), preserving the existing list/grid views, search, private tags, sorting and per-user archive filter. Each response includes counts for all three categories under the current search and tag conditions, including both active and archived projects, independently of the selected category, archive filter and page; zero counts are not displayed. Pagination totals still follow the archive filter. Archiving/unarchiving preserves tab counts; deletion reduces them. Administrators only see and count projects they own or that were explicitly shared with them. Changing category resets pagination. The browser caches rows together with the category, filters, counts and page, so returning from a workspace restores the same view before revalidation. Obsolete requests are aborted and ignored, and rows from a previous category are not shown while its replacement is loading. Successful catalog mutations revalidate the currently selected view. Grid cards use a paper illustration containing the main document's title and opening abstract text. Unavailable fields use neutral bars; genuinely empty documents show blank paper. Grid catalog requests opt into bounded metadata reads for every visible project, including tagged projects. The server reads at most the first 64 KiB, extracts only complete title/abstract constructs, and limits each field to 240 Unicode characters. Reads are asynchronous, coalesced and cached by file metadata, with at most 128 entries; includes are not traversed and no TeX is executed. Unread mentions precede unresolved-comment badges at the thumbnail's top right; private tags sit at its bottom left. Single-line titles and relative-modification metadata occupy fixed caption slots, with owner/creation/modification details in the title tooltip. The open, mention and action-menu buttons are independent keyboard targets. List layout and its labels remain unchanged. ### Citation library The citation library is stored in SQLite and is independent of project source files. A `.bib` editor tab parses complete BibTeX entries locally, so saving a reference preserves the author's original formatting; importing inserts the selected entry at the current editor cursor and requires project write access. Each user owns a private library. Citation entries and color tags are scoped to the owning user; other users cannot list, search, import, edit, or delete them. The browser's Lezer BibTeX parser derives the display venue (biblatex `journaltitle`, `journal` or `booktitle`) together with title, authors and year (`year`, or the year of biblatex's `date`); the client submits it on save. Values have `@string` macros defined earlier in the same source expanded, and a `.bib` import fills a `crossref` child's missing fields from a parent in the same file; the saved BibTeX stays as written. Schema migration 21 stores the venue, so citation cards read the field instead of reparsing BibTeX on every render. Rows created before that migration, or saved before `journaltitle` and `date` were read, keep a null venue or year until they are edited or saved again; their cards derive both from the stored BibTeX (memoized per entry) so they do not disappear, but library search matches the stored columns only. Folder import re-parses every entry, so imported rows always carry them. Stored title, author and venue columns are TeX as written. Cards and hover previews show them through `src/shared/bibtexText.ts`, which drops protective braces and converts common accents, special letters and TeX punctuation while keeping math verbatim; DOI and URL are shown unchanged. The homepage keeps Projects as the default view and exposes the citation library as a separate management page beside the other management controls; the project workspace keeps a smaller `.bib` import dialog for in-context writing. Library results are server-filtered and paginated with 60 entries per page by default (the API accepts a bounded page-size override). Migration 18 adds private, single-level citation folders and a many-to-many entry/folder relation. A folder represents a paper or research topic; the same entry can belong to several folders without copying its BibTeX. All references and Unfiled are built-in views. Folder counts are library-wide, independent of search, tag filters and pagination. Existing references remain unfiled on upgrade. By default, folder deletion removes only placements, not entries or their tags. The delete confirmation offers an unchecked option to also delete references exclusive to that folder, including their notes. References linked to other folders are kept. `DELETE .../collections/:id?deleteReferences=true` performs both deletions in one authenticated, user-scoped SQLite transaction; omitting the flag (or using `false`) retains references. Tag definitions and project `.bib` files are never removed by this operation. Each user-created folder holds at most 500 references, enforced inside the server's placement transactions for new saves and folder reassignment. A full folder still permits edits and retaining existing memberships. Failed additions leave content, notes, tags, revisions and other memberships unchanged. Existing oversized folders are preserved and may shrink, but cannot gain new entries. All references and Unfiled are views, not folders, and have no 500-entry cap; the private library's total size is not limited by folder capacity. The dashboard page and project import dialog share folder navigation. Project workflows use a separate controller: My library entries only offer Import into the current file, and current-file entries only offer Save into the library. These are compact icon-only actions with accessible names, action/state tooltips and the shared saved-check feedback. Tag filters combine with folders, search and pagination, but project dialogs do not expose tag or entry management controls. The workspace's compact entry is labelled Citation; the homepage keeps Citation library. Saving from the current file only adds entries whose key is not in the library yet, into a chosen destination folder; an entry already there shows as saved and is never changed from the project dialog. A bounded, debounced key lookup returns only key/id/revision metadata, never library text. The project dialog opens a `.bib` file on its Current file tab, and any other file on My library. It reads the current file once per opening: edits made while it stays open are neither re-parsed nor re-compared, and reopening shows them. My library is paginated by the server; Current file pages the parsed entries in the browser (60 per page) with a local search and a library-status filter, while the key lookup still covers the whole file so the counts are exact. The folder sidebar filters My library only and is hidden on Current file, whose save destination is a separate selector; entering Current file without a destination offers the folder being browsed. Equality ignores platform line endings and outer whitespace, not differences inside an entry. Unchanged entries cannot be updated; changed ones still need the last-read revision. Source/file switches abort stale comparisons and ignore late save feedback. This is an explicit copy into/out of a `.bib`, not automatic bidirectional sync. Folder assignment uses the entry revision to reject concurrent stale changes. Only the owning user can manage, filter or export a folder, including when another account is an administrator. Export streams every member's stored BibTeX in citation-key order, without the list's search or pagination limits, as a UTF-8 `.bib` download. Export is available only in each folder's overflow menu, not as a separate list-toolbar button. No external citation service is involved. Migration 19 adds private Markdown notes to each library entry, initially empty. Add and edit forms share three accessible tabs: BibTeX, Notes, and Notes preview. Switching tabs preserves both drafts; preview renders the current unsaved notes. The BibTeX tab loads a compact CodeMirror editor on demand, reusing the project's Lezer BibTeX parser and syntax palettes. It keeps local undo history across tab and theme changes; external form updates do not echo back as user edits. Saving temporarily makes the editor read-only. The form adds no collaboration, lint, completion or spelling services, and Tab still leaves the input normally. Save is disabled until either field changes. Notes are limited to 64 KB of UTF-8 text and use the same revision check as BibTeX edits. Saving updates from a project's `.bib` without a `notes` field preserves the existing notes. Notes never enter the project's `.bib` or the ordinary `.bib` export. The entry menu's Show/Hide notes action expands or collapses notes directly below that entry, not in a modal. Multiple entries may stay expanded independently, and ordinary refreshes keep their expansion state while displaying the latest saved notes. Each panel has a fixed header/close control and an internally scrolling body capped at 320px or 45vh, whichever is smaller. The Markdown renderer loads on demand, with GFM tables/lists, HTML disabled and default safe-URL handling; notes retain the site's light/dark palette. The same renderer serves the form's preview tab. `remark-math` parses `$...$` inline math and `$$...$$` display math; React components render its math nodes using the editor's KaTeX version, with trusted commands disabled and bounded expansion/size. Code spans and ordinary fenced code remain literal, and malformed formulas do not break the rest of a note. A scoped npm override keeps micromark's transitive KaTeX on the same version as the application's renderer. Export BibTeX with notes, also in the folder menu, downloads one portable `*.texlite-citations.json` file (`format: texlite-citation-folder`, `version: 1`). It contains entries' BibTeX, display metadata and Markdown notes, but no folder name, user IDs, tags or database memberships. The Import row at the top of the sidebar's folder list accepts a single `.bib` file or TexLite `.json` export, by dropping it or browsing for it, with a preview before submission. Its clickable info explains naming, replacement and tag handling. The import form requires a destination folder name and defaults to the selected filename without its extension or the export-specific `.texlite-citations` JSON suffix, up to 80 characters. The user can edit it before import; any name embedded in a JSON file is ignored. A `.bib` import uses empty notes; JSON preserves the exported notes. The destination name is sent only in the import request, separately from the portable file content. Both paths validate the chosen file and its BibTeX in the browser; the server independently enforces authentication, shapes and byte limits. Imports and both export formats support at most 500 entries per transfer. Imports and JSON-with-notes exports also have a 16 MB limit; oversized exports fail instead of downloading a partial file. Import is one SQLite transaction: it creates a new private folder, appending `-1`, `-2`, etc. when the name is already used. Matching citation keys replace that user's BibTeX and notes and increment the entry revision, retaining existing tags and other folder placements. Incoming tag fields are ignored, even for direct API requests. Repeated keys within a file use the last entry. Other users' libraries are untouched. Malformed input or a database failure leaves no partially imported folder. Entry management actions live in an accessible, portalled overflow menu, with deletion separated from ordinary actions and still requiring confirmation. Copy BibTeX remains a direct button, preserves the full entry text and shows a two-second result on the button. It falls back to selection-based copying for HTTP/LAN browsers without usable Clipboard API access. Save/import remain direct actions in project dialogs. Library rows use compact spacing and a single-line title with the full title available on hover. Library entries are checked in the browser before a mutation request is sent, using the same Lezer BibTeX grammar as the editor (`bibtex/entries.ts`). That grammar alone decides what an entry is, where it begins and ends, and whether the text is valid: a syntax error anywhere makes the whole document invalid, so the library never accepts what the editor underlines, and never silently skips a damaged entry. A single-entry write must be exactly one citeable entry with nothing before or after it. `bibtex-tidy` only formats, and is bundled with the formatter worker alone. Citation keys are unique per user without regard to letter case. Creating an existing key is rejected; updating an entry or its tags requires the revision last read by the client, so concurrent browser sessions report a conflict instead of silently overwriting newer content. Entry-text and tag-only mutations use separate endpoints. `POST /api/citations` only creates entries, including saves from a project's `.bib`; it never replaces an existing key, and the key lookup reports only which keys exist. The server retains only transport-level size and field-shape limits; it does not reparse BibTeX syntax. TexLite is intentionally a single-instance application. The collaboration rooms, project mutation queues, compile coordinator, and SQLite database are process-local. Startup acquires an atomic `.texlite.lock` directory in the data directory and installs its owner record atomically; a live second process is rejected, while a stale lock from a dead process can be recovered without deleting a lock that is still being initialized. Cluster mode and multiple application processes sharing one data directory are not supported. ## Configuration and startup Update reminders use a single server-side npm `latest` check, with a 24-hour success interval, one-hour failure backoff, five-second timeout and shared in-flight request. Listening starts the background checker; authenticated administrator dashboard requests can also refresh a stale result. The SQLite cache survives restarts. Per-administrator dismissals suppress that release and older releases, and cascade on account deletion. Both tables are introduced by schema migration 5. `updates.enabled: false` disables checks and reminders. The dashboard polls the local cached endpoint every 30 minutes and when made visible, without adding requests to the writing workspace. The browser never contacts the registry. Only the version response is consumed; no project data or credentials are sent. Installation detection selects npm or Docker help, and administrators can switch the displayed method. Docker help updates the launcher with `git pull --ff-only` before image pull/container recreation; it explicitly notes that images can lag behind npm. Updates remain manual. The npm package includes the built frontend, PDF worker, Adobe CMaps, formatter WASM, fonts, and generated Lucide SVGs. Browser libraries and build tools are development dependencies; the published installation needs only the server's runtime dependencies. Source builds require development dependencies. A dependency check covers emitted imports from server and shared modules, including dynamic imports, while release testing installs the tarball outside the checkout with `--omit=dev` as described in [NPM_TESTING.md](NPM_TESTING.md). Production builds also emit gzip (level 6) and Brotli (quality 5) variants of JS, MJS, CSS and WASM assets under `assets/`, including bundled Worker scripts. Compression uses Node's built-in zlib with bounded concurrency, applies only to files of at least 2 KiB, and keeps a variant only when it saves at least 10%. PDFs, archives, images, fonts, packed CMaps and the runtime-injected HTML are excluded. Fastify negotiates the precompressed variants through `Accept-Encoding`, sets `Vary: Accept-Encoding`, and preserves original MIME types and fingerprinted cache policies. Original files remain available for clients without compression support and for older builds without variants. No request-time compression or additional runtime dependency is required; the trade-off is a modest increase in build time and distribution size. The npm package keeps configuration outside the package installation. The effective configuration is selected in this order: 1. `--config PATH`; 2. `TEXLITE_CONFIG`; 3. `$XDG_CONFIG_HOME/texlite/texlite.config.json`; 4. `~/.config/texlite/texlite.config.json`. The data directory defaults to `$XDG_DATA_HOME/texlite` or `~/.local/share/texlite`, and can be changed with `storage.dataDir` or `TEXLITE_DATA_DIR`. Relative configured paths are resolved relative to the configuration file. The effective defaults and accepted ranges are documented in the [operations guide](OPERATIONS.md) and are also available through `texlite config`. `server.basePath` mounts the complete application below an origin path while retaining one distributable frontend build. Fastify scopes API, WebSocket, static-resource, and SPA routes to that prefix; the generated HTML declares the runtime base for relative Vite assets and client URL helpers. Browser-side collaboration drafts and layout preferences include the mount point in their storage namespace so two instances under one origin cannot share local state. `texlite init` creates the configuration when necessary and creates the first administrator. New configuration files contain only explicit initialization choices: a supplied site name/contact email and host, port, base-path or data directory overrides. Accepting all defaults can produce an empty JSON object; omitted options use runtime defaults and can follow future default changes. Existing configuration files are never automatically rewritten or stripped. The server refuses to start without at least one active administrator; public registration is not enabled. Configuration values are validated before environment checks, database opening, or binding the HTTP listener. Validation covers paths, limits, engines, timeout/queue settings, URLs, and cross-field constraints such as the default engine being present in the allowed-engine list. Startup checks Node.js, `latexmk`, and every configured LaTeX engine. `texlite requirements` performs a configuration-free preflight over the default host commands, requiring Node.js, `latexmk`, and at least one supported LaTeX engine. `texlite doctor` also reports optional host tools in a requirement/status/version table: Git, TeXcount, bibliography/index tools, ChkTeX, Harper CLI, and `harper-ls`. A host without Git can run the editor and compiler; Git is checked on demand when an owner opens or uses Git integration. Formatting is also optional: the browser loads the bundled npm `tex-fmt` WASM module and `bibtex-tidy` only when formatting is requested. TexLite never installs or updates TeX packages. `texlite serve` runs in the foreground and is suitable for debugging, Docker, or systemd. `start`, `status`, `stop`, `restart`, and `logs` use the bundled PM2 dependency. Managed startup waits for both PM2 and the HTTP health probe; status has a colored systemctl-style view and a `--json` form for scripts. Managed processes use fixed `logs/stdout.log` and `logs/stderr.log` paths under the configured data directory. An internal asynchronous copy/truncate rotator checks at startup (after the instance lock) and every minute, rotates at 10 MiB, and retains three uncompressed backups for each stream. Checks do not overlap and shutdown waits for any active rotation before releasing the instance lock. Copy failures leave the original log intact. Copy/truncate can lose concurrent diagnostic messages, and this is not a strict disk quota or an audit trail. No external PM2 module is installed; old numbered PM2 logs, daemon logs and other applications' logs are untouched. Existing processes adopt these paths on `texlite restart`; foreground output remains caller-managed. PM2 7.0.3 currently declares `js-yaml@4.3.0`, so dependency audits may report the upstream GHSA-5p4m-2wfm-xmqj advisory. TexLite invokes PM2 through its JavaScript API and does not load user- or project-supplied YAML configuration; deployments requiring a zero-advisory dependency tree can instead supervise `texlite serve` with systemd or Docker until PM2 updates that dependency. Expired login sessions are pruned at startup and periodically while the process is running, rather than merely being ignored during authentication. Administrators can open the System status view (or authenticated `GET /api/health/metrics`) for in-memory uptime, resource, queue, collaboration, event-loop, and recent latency summaries. These metrics intentionally exclude source text, passwords, tokens, and comment content. Event-loop latency uses Node's interval histogram with a shared 20 ms sampling resolution. The API subtracts that interval before rounding P50, P95 and Max to 0.1 ms, clamping timer jitter below the baseline to zero. These are estimated excess scheduling delays, not exact per-callback latency measurements. Empty histograms report `null` (displayed as a dash). The window is cumulative since service startup; reading metrics does not reset it or affect another viewer. Memory uses a storage-style segmented bar: total used memory, service and TeX process shares, other usage, and available capacity. Process shares are RSS estimates, not exclusive allocations. If they exceed measured usage because of shared pages or sampling skew, their visual proportions are fitted inside the used region; raw values are preserved and other usage is never negative. Unavailable readings remain unavailable rather than being interpreted as zero; without a container limit, only the used-memory breakdown is drawn. Collection uses only Node.js and OS facilities: Linux `/proc`, macOS `ps`, and Windows PowerShell/CIM. Linux containers use cgroup v1/v2 usage and visible ancestor limits; an unset/unreadable limit is not replaced with host capacity. Container usage includes page cache and other cgroup-accounted memory; host usage uses Linux `MemAvailable` where available, otherwise OS free memory. The TeX sample matches a fixed set of common engine and auxiliary-tool names, including Perl-hosted latexmk and native Harper CLI writing checks, rather than tracking only TexLite's own jobs. The existing UI labels remain unchanged. It is limited to processes visible to the service, normally the current container in Docker. It does not inspect the Docker socket or other containers. These are current estimates, not peaks or exclusive allocations; shared pages may be counted in multiple RSS values; host usage and process totals are not additive. Resource probes run only on authenticated administrator metrics requests, share in-flight work and a two-second cache, and never run inside the compile path. External probes have time/output limits; unsupported or failed sampling is reported as unavailable rather than zero. No additional npm dependency is used. CPU uses the same segmented presentation, with **TexLite + TeX**, other work, and idle capacity adding up to 100%. It compares cumulative OS and process CPU timers between requests, not load averages or lifetime-average `ps %cpu`. The first request (or a gap longer than 15 seconds) primes a fresh window. Host percentages cover all logical CPUs; Linux container percentages use cgroup CPU usage and the smaller of visible affinity and ancestor quota capacities, including fractional CPU quotas. Unavailable container accounting never falls back to host-wide busy percentages. TeX CPU timers are gathered in the same process scan as memory, with start identities guarding against PID reuse. Native counter units are preserved: Linux process ticks are compared to aggregate ticks, and macOS/Windows process times are converted to microseconds. There is no background sampler. These interval estimates can miss short-lived TeX processes that exit between requests; such work can appear under other CPU usage. This is a lightweight status indicator, not per-process accounting. ## Collaboration and source persistence User avatars default to the first visible character of the display name (or username). Users can select a DiceBear Characters style from their user menu. The server generates the SVG locally with `@dicebear/core` and `@dicebear/styles`, seeded by the username. The selected style and SVG are stored together in the user row; returning to initials clears both values. Existing users retain initials after migration. Style selection uses a dropdown and previews only the chosen style; previews do not change the saved avatar. Startup applies separate, append-only migrations for style selection (version 6) and SVG storage (version 7), including databases that already recorded the earlier style-only version. Previously saved styles without an SVG are generated and stored automatically on first use. Version 8 invalidates older user-ID-seeded SVGs without clearing style choices; the first read regenerates them with the username. Avatar URLs carry a render version so browsers do not keep displaying the older cached images. Style definitions are loaded on demand; ordinary avatar reads return the stored SVG without generating it again. Authenticated image responses use private browser caching and ETags. Previews are restricted to the current user and never save a selection. There are no requests to DiceBear's public API and no browser-side generator. Shared avatar rendering covers the account menu, comments, replies, participant filters, shared members and collaboration sessions, with an initial fallback while loading or after a failed image request. Avatar changes refresh active presence and affected comment lists without restarting collaborative editing. Authentication and per-message collaboration queries exclude the SVG column. Each open project has one Yjs room. Awareness data provides active-session avatars, user names, permissions, file paths, cursor colors, and the ten-session project limit. Ordinary Yjs edits remain concurrent; source-tree replacement operations temporarily enter maintenance, notify/close collaborators, and rotate the collaboration epoch so an old offline draft cannot overwrite a checkout or history restore. Browser IndexedDB retains unsent updates across a transient disconnect. The browser session boundary creates `ProjectCollaboration` inside an Effect. Rendering never opens IndexedDB or installs collaboration listeners. Cleanup defers destruction by one microtask: an immediate StrictMode/Fast Refresh setup reclaims the same live instance, preserving editor bindings and local workspace state; a real unmount closes the provider, persistence and Yjs document after descendant cleanup. Changing the user or project destroys the old instance and creates a new one. The controller mounts only after its session exists and remounts only when that session is replaced. The collaboration hook owns subscriptions; the session boundary owns resources. Ordinary file, permission and UI updates retain the current session, and leaving never deletes the persisted offline draft. A client DOM regression test exercises StrictMode replay, identity changes and cleanup. Workspace connection status distinguishes opening the socket (`connecting`), an open socket awaiting document synchronization (`syncing`), a synchronized connection (`connected`), and an actual connection failure (`disconnected`). Only a synchronized connection enables editing. Connection labels overlay save progress rather than overwrite it, so a successful reconnect cannot leave an old offline label behind. The retained-draft notice is shown on disconnect only when there are pending local edits or a recoverable browser draft. Client DOM tests cover startup, synchronization, failed connections, retries and subscription cleanup without changing or deleting the draft. Read permission is intentionally different from edit permission: read-only members cannot modify source files, but can view the project and add or reply to source comments. Comments are anchored to source offsets and selected text, can be resolved, edited, deleted, and replied to. If an author is removed, the record remains visible as “Deleted User”. The workspace Share button shows a superscript count of explicitly shared read/edit members, excluding the owner and hiding zero. This is unrelated to online presence. Initial counts come with project metadata; successful share form loads and member changes update the badge without reloading the workspace. Owners can also create **read-only links** from a separate tab in Share. These are anonymous reading capabilities, not read-only members: visitors can browse source files, images and the current compiled PDF, but cannot comment, edit, compile, inspect history/Git or use agents. They do not join Yjs, consume a collaborator slot or appear in presence/member counts. The reader has no live editing subscription; Refresh fetches the latest saved files and published PDF. Its appearance uses the normal workspace's topbar frame, layout menu, outline, file-icon/tree styles, editor font defaults and PDF header. Files/source/PDF panels can be resized, the file tree can be collapsed, and the outline stays bound to the configured main document (including local inputs). Share, Git, Terminal, snapshots, comments and compile remain visible but disabled; none mount authenticated workflows. Reader settings change only browser language and theme. Narrow screens start with the editor-only layout. Migration 15 adds `project_view_links` with a project foreign key (cascade on deletion), label, SHA-256 token hash and creation/expiry timestamps. Revoking a link deletes its row and removes it from the owner's list. Migration 16 removes previously revoked records and the obsolete revocation column; active and expired links are preserved. Repeated owner revocation requests are idempotent. A link defaults to seven days; the owner can explicitly choose no expiry. Each project can have only one unexpired link, including a link with no expiry. An immediate database transaction checks for an active link before inserting, so concurrent creates cannot bypass this rule. Copying always returns the same address without changing the token or expiry. Create is hidden while an active link exists and becomes available after expiry or revocation. A 256-bit independent random token travels in the `/share/:id#token` URL fragment, not the HTTP page URL. The browser exchanges it in a POST body for an HttpOnly, SameSite=Strict cookie scoped to that link's base-path-aware `/api/shared/:id` namespace, then removes the fragment. No login session or user record is created, and no token is persisted in browser storage. Migration 17 adds AES-256-GCM encrypted tokens for repeated owner copying, while the SHA-256 hash remains the guest authorization check. Ciphertexts are bound to the project/link ids using authenticated associated data; the independent `view-link-token.key` is generated with mode 0600 in the data directory. Only the actual owner's no-store management response returns recoverable link addresses. Guest responses never contain a token, hash or ciphertext. Each link row displays its address and a reusable Copy button with brief success feedback. The sharing dialog no longer displays the administrator-created-users notice. Link management offers Copy and Delete. To replace a link, delete it and create a new one. Existing hash-only links stay valid, but cannot be copied because the old secret cannot be reconstructed from its hash. Missing keys or corrupt ciphertext likewise leave deletion controls accessible; delete and recreate to regain copying. Back up the key with the database. Every guest metadata/source/image/PDF request, including HEAD and byte ranges, checks link existence, token hash and expiry in the database. Guest responses are private/no-store, no-referrer and noindex; uploaded SVGs have a sandbox CSP. PDF URLs are pinned to the current published run to avoid mixing Range bytes; older compile versions and output/log directories are not exposed. A changed published run requires Refresh. Existing authenticated project, compilation, Agent and WebSocket routes accept only normal account sessions, never view tokens. Anonymous Yjs updates are rejected before joining a room. Revocation takes effect on the next server request. The viewer also rechecks access every 30 seconds while visible and on returning to the tab, and clears source/PDF at expiry or on rejection. Revocation cannot erase content already copied or downloaded. Sharing deliberately exposes the source tree: owners should remove project-local secrets before giving a link to anyone. Only the current owner can list, create or revoke links; transfer keeps existing links under the new owner's control. Links never grant member-management or ownership rights. The owner-only share form uses one editable user combobox: opening it lists available users, and typing filters by display name or username. Candidates exclude the owner and existing members. The popup is at most 240 px tall, bounded by the dialog's available space, with internal scrolling and keyboard navigation. Filtering uses the loaded active-user directory without per-keypress requests. Typing clears an old selection but retains the permission draft; only Add member grants access. Loads are aborted when the dialog closes or changes project, and member mutations are guarded against duplicate submissions. Live anchor updates replay the actual Yjs text deltas against their saved source baseline. Agent disk imports retain an inferred-diff flag even when their changes are transported as Yjs operations. HTTP replacements, recovered state without a delta chain, and chains that do not reconstruct the final source also use a before/after diff. Unlike actual edits, inferred partial matches require contiguous meaningful text, sufficient retention relative to both ranges, and matching context (or a substantially intact quote). Exact quotes and whitespace-only formatting remain recognizable; scattered shared letters must not stretch a discussion across unrelated replacement prose. Partial edits retain the discussion; fully erased selections preserve their last attached quote and surrounding context for recovery. Recovery requires the original quote to be formed by the current insertion, replacement or an interior deletion, together with matching context. An unchanged duplicate elsewhere cannot inherit the discussion. A selection covering the entire file can recover only when that exact file content returns. Source persistence and anchor persistence have separate baselines. If a source write succeeds but its anchor transaction fails, the room retains the old anchor baseline and all subsequent edit batches. The save receipt reports the failure, and the existing save timer retries without requiring further typing or rewriting an unchanged source file. Idle cleanup retains rooms with pending anchor writes; successful retries notify comment views. This bookkeeping is in memory and does not provide atomic database/filesystem crash recovery. Resolving a comment records the acting user's id and the server timestamp in `resolved_by` and `resolved_at`. Repeating an already-resolved update or editing the comment does not replace that attribution. Reopening clears both fields; resolving again records the new action. The resolved discussion shows the user and time; deleting that user retains the timestamp and displays “Deleted User”. Schema migration 4 adds nullable columns without guessing metadata for existing resolved comments. This stores the current resolution, not an activity log. ### Suggested changes A source comment can optionally carry one replacement suggestion. The note remains the discussion's primary content; an empty note becomes “Suggested change” in the author's language. Ordinary comments have no reviewer. Only replacement suggestions require a reviewer. Read-only members may propose changes, but only the assigned reviewer with current write access may accept or reject them. The comment author cannot be their own reviewer, even if they own the project. Enabling an edit defaults its reviewer to the project owner when they are not the author; otherwise the author must choose another writable member. The reviewer stays fixed after submission; there is no change-reviewer control. The author may edit the replacement while it is still pending and no accepted update is being persisted. An empty replacement means deleting the selected passage. Saving a changed replacement renews the review notification without changing the original source, reviewer or discussion. Identical-save retries do not renew it again. Suggestions are collapsed attachments, not a separate discussion list. Opening one shows an inline source diff, compacting unchanged context without hiding edits. Preview differences are word-oriented and never supply source offsets or patches to the acceptance endpoint. Existing comment authors attach suggestions through the comment's more-actions menu. The new-comment modal places read-only original source first, keeps the optional edit switch off by default, and presents the reviewer at the bottom only while the edit switch is enabled. Turning it off submits an ordinary comment without reviewer metadata or a review request. Its live status describes the actual payload: comment, edit, or both. Opening unchanged edit controls does not create a suggestion. Review requests reuse personal unread notifications with a distinct `review` kind; an explicit mention of the same reviewer does not create a duplicate notification. Reading a review request does not decide it. Accepting or rejecting clears that review request, but leaves the discussion open and preserves other mention notifications. A discussion with a pending suggestion cannot be resolved. Edits and review decisions include the replacement text the user actually saw as a compare-and-swap precondition. The server compares it under the project write lock; acceptance checks again after cold-room preparation. A stale save cannot overwrite a newer draft, and a stale decision cannot accept or reject an unseen replacement. Conflicts trigger a discussion refresh without retrying the write or discarding typed edits. Decided suggestions and journaled pending applications cannot be edited. This content-based precondition needs no schema version or extra database field. Creation binds the selection to a source hash. Acceptance runs in the project write queue, flushes preceding edits, checks current reviewer permissions and requires the anchored source to match the saved original exactly. Edits outside the passage can move its anchor; changed or lost source cannot be patched fuzzily. Acceptance applies a targeted Yjs delete/insert, leaving unrelated content and connected collaborators intact. The accepting reviewer is the source-edit author. Rejection does not change source. Deleting a discussion after acceptance does not undo the applied change. Schema migration 12 adds a suggestion table and the notification kind without changing existing comments. Migration 14 reverses the comment-level reviewer layout introduced by development migration 13: it preserves suggestion assignments, decisions, recovery receipts and notifications while removing plain-comment reviewer assignments and their review requests. Before applying a change, the service persists its Yjs base and journals the exact CRDT update and replacement-range positions in SQLite. An interrupted application can replay that same update idempotently; it becomes accepted only after source/state/anchor persistence succeeds. A missing recovery base fails explicitly rather than guessing and duplicating the replacement. This recovery receipt is separate from ordinary anchor-save bookkeeping and from the comment's resolution attribution. The source-pin button opens an independent current-comment view: only that Open thread in the active source file is shown and highlighted, without status, scope, participant, unread or sorting controls. Saved review filters do not affect it. Resolving or deleting the thread leaves a short empty-state message and a View all comments action, rather than selecting a different discussion. The top Comments button opens project-wide review or switches a current view to it; when already in full-review mode it retains its open/close behavior. View all comments preserves the source target and saved sorting/participant preferences. File switches, new comments and @-notification navigation leave current-comment mode. Both modes share the same workspace draft store and reply expansion preferences. The dashboard badge and workspace toolbar both count unresolved top-level discussions across the project, including `.bib` files and non-root sources. File and project comment responses include the same small project summary; editor decorations still receive only active-file comments. This avoids fetching all discussion bodies merely to show a count. A current-file/project selector appears for roots with includes or discussions outside the active file. Hiding that selector never forces project review back to the active file while its summary is loading. Newer summary responses take precedence over late responses from another scope. File-comment refresh failures retain the last successfully loaded same-file discussion, display an error with a file-specific retry action, and are not treated as a successful empty response. An explicit access rejection clears cached contents instead. Source-navigation readiness requires a successful load; loading and error states are separate, so a failure never leaves an indefinite loading indicator. Full file review uses the same error handling. The full comment drawer sorts top-level threads by source position by default, or by creation time, newest first. Project review follows the configured main document's expanded reading order: notes before an `\input`/`\include`, notes inside that source (recursively), then notes after the include. Static subfile and import-package paths share the outline's path resolution. Each file uses its first occurrence; cycles are ignored. Files outside the graph, missing sources and work beyond the bounded scan fall back to file path and source position after the reachable discussions. The additional include-order scan visits at most 200 files, parses files up to 3 MiB and processes at most 32 MiB of source; it reuses contents already read to calculate comment line numbers. It does not expand macros or evaluate TeX conditionals. Response-only ranks preserve this order through local filters and previous/next navigation; no database or anchor changes are needed. Switching editor files does not change the root, while changing the configured main document notifies open reviewers. Tree/settings changes also reload an open project review, with obsolete requests cancelled; merely switching editor tabs does not reload that resource. File review still sorts within the current file; time sorting remains global. Open/Resolved tab counts describe all top-level discussions in the selected file/project scope, independent of participant and unread filters. The Unread badge and displayed list still intersect those filters with the selected status. Resolved review initially renders 50 top-level discussions and exposes a Load more action for another 50 only while more remain. There are no displayed batch counts or end-of-list message. Replies do not consume this limit. Filtering and sorting apply before slicing; counts and previous/next navigation still use the complete matching queue. Navigation and an active inline composer expand the window as needed. Ordinary data refreshes retain the window; scope, file (in file review), sorting and filter changes reset it. This is browser-side presentation only: the API still returns the full resource, including replies, and Open discussions remain unlimited. No additional request is made by Load more. Comment server state is owned by a workspace/user-scoped TanStack Query client, loaded with the workspace rather than the login/dashboard bundle. File lists, project review, project totals, personal unread mentions and mention candidates have separate project-qualified keys. No cache is persisted to disk or shared between accounts. Drafts and review presentation (filters, navigation, expanded replies and the resolved window) remain independent of this query cache. All comment/reply writes return a shared typed acknowledgement with the actual persisted object or deleted id and project totals. One cache updater applies these changes to all relevant loaded lists, without creating partial lists or overwriting live source anchors, reading-order ranks or unrelated replies. Reply upserts are idempotent by reply id. Acknowledged writes complete locally before background reconciliation: a failed GET must not make a successful reply invisible or invite a duplicate submission. Mutation requests are not retried automatically. Delete failures remain in their confirmation dialogs. Pre-mutation reads cannot overwrite the acknowledgement; retrying review also refreshes source decorations and unread notifications. Cross-list project summary responses are ordered by request start rather than completion. Temporary refresh errors retain these confirmed states and cached unread notifications, rather than implying that discussions or notifications disappeared. Resolving removes the existing thread mentions locally; reopening does not restore notifications already read. Transient reads retry at most twice with bounded backoff; access rejections are not retried and rejected resources are not displayed. Final read errors expose the existing retry action, including errors that affect only unread mentions. Mention candidates use the same cache; failure is never cached as a successful empty result, and focusing/retyping a mention can retry. Stale candidates can refresh on focus. Query defaults also reconcile stale data on browser focus and refetch on network reconnection, without continuous polling. Connected collaborators, including read-only reviewers, receive comment revisions over Yjs and automatically reload comments and personal unread mentions (up to 100). Mark-read/read-all changes reuse that revision signal so another session for the same account updates too; the signal contains no personal notification data. Repeated mark-read clicks share the pending request and dashboard counts use the server's actual changed count, not the number of clicks. Current scope/status/participant filters and the resolved display window still apply. Disconnected clients need to reconnect; failed comment reads expose the existing retry action instead of requiring a page reload. Replies keep their original chronological order. Long inactive discussions initially show only the latest two replies with an earlier-replies toggle. Explicit expand/collapse choices are kept locally by thread id for the workspace lifetime and survive file/filter/sidebar changes and refreshes of comment data. Reading focus expands a discussion unless deliberately collapsed; active forms, notification targets and older unread or participant-matching replies always remain visible. Notification targets are expanded during render, before the drawer's scroll effect looks up the reply DOM. Opening a form or marking a mention read keeps the discussion expanded to avoid a content jump when the action completes. Folding never alters review counts, participant matching or source anchors. Participant filtering is multi-select: a thread matches any selected top-level author by default. Unchecking “Top-level comments only” also includes reply authors; clearing the participant selection restores the top-level-only default. It combines with the existing file/project scope, resolution status, and an independent unread-@-me toggle. The unread toggle intersects with Open or Resolved rather than forming a third resolution state; reading all mentions in a thread removes it from that filtered queue. Only matching authors' display names and usernames are highlighted; the thread/reply surfaces and the rest of the discussion stay unchanged. Explicit source or mention navigation can still reveal a thread outside the active filters; changing filters clears that temporary focus. Sorting and filtering run locally on the already-loaded comment resource, without additional server requests. Resolving or reopening a thread clears its temporary navigation reveal after the status changes, including changes from another coauthor. It then leaves the current status queue with a brief fade/slide and collapse; the outgoing card is inert and does not count toward review navigation. Failed requests leave the thread visible, changing filters cancels exits, and reduced-motion preferences skip the movement. Opening or focusing an inline composer activates its thread without navigating to or focusing the source editor. Ordinary data refreshes preserve that active discussion and do not scroll to an older source-navigation target. Reply, comment-edit and reply-edit composers share request feedback and a workspace-owned synchronous submission guard for button clicks and Ctrl/Cmd+Enter. While a request is pending, the form shows Sending/Saving and disables inputs, cancel and conflicting actions in that thread. Request errors appear beside the composer, preserving its input for retry; only success closes the form. Drafts, errors and in-flight state live in a project-workspace memory store, not in thread components, so file/filter/sidebar changes and resolution-driven unmounts do not discard them or allow duplicate submissions. Draft updates notify only the corresponding thread. Restored forms do not steal editor focus. Explicit Cancel or successful submission removes that form's draft; switching between reply editors preserves each reply's text. New-comment drafts are kept per file together with their original selection and source hash. Reopening one restores that source binding, with an explicit action to adopt the newly selected source instead; the server's source-revision validation still applies. Closing the new-comment dialog preserves its draft, while Cancel discards it. Nothing is written to browser storage or the server; leaving the project or refreshing the page clears this workspace's drafts. The participant selector uses a non-modal, anchored Radix popover so it does not push the discussion down. Selections apply immediately without closing the popover; outside interaction or Escape dismisses it. Its trigger summarizes the selected authors and provides a separate clear action. A top-level-only filter remains visible in the toolbar after dismissal; clearing the participant filter also resets that restriction. Open/Resolved and previous/next navigation appear beneath the drawer title. A separator distinguishes the toolbar below: participants, unread @ mentions, then neutral-colored source-position/newest-first sorting. Source excerpts use monospaced, single-line truncated blocks with a green edge; replies use a thin gray indentation rule. Author-only edit/delete actions live in each comment or reply's header menu, with deletion still requiring confirmation. Clicking unresolved annotated source or its trailing marker opens and scrolls the corresponding thread into view, temporarily revealing it across review filters without marking mentions as read. This drawer-only navigation does not select the annotated source range or take over the editor caret; dragging to select text and modifier-click citation navigation keep their normal behavior. Resolved and orphaned threads have no source markers. Source comment ranges follow partial insertions, deletions and replacements, updating the quoted excerpt to the current text rather than requiring exact equality with the initial selection. Full deletion or whole-range replacement detaches the thread without discarding its last attached quote and surrounding context. A later undo or restoration can reattach the complete last attached quote by contextual matching; partial quote restoration and unrelated duplicate text alone are not sufficient. Collaborative persistence replays actual Yjs edit ranges, so common characters in a whole-range replacement do not falsely count as retained source. Non-collaborative writes use a before/after text diff because they do not carry editor operations. These existing database fields remain durable across restarts, without a schema change. The server and editor share the range/context rules. Live editor anchors retain detached state through undo and supply current offsets for thread-to-source navigation, even before a save. Comment responses include a response-only SHA-256 source revision, computed once per file. An authoritative server anchor can correct a misleading live mapping only at that exact revision, including when its HTTP response arrives before the Yjs source update. Stale saved anchors cannot replace a live mapping, reactivate a deleted selection at a duplicate occurrence, or overwrite undo recovery. Results for a different revision remain pending; a view-owned 200 ms trailing check retries after document updates, coalesces typing, and is cancelled on editor destruction. Failed checks do not poll or reschedule themselves. Browser recovery searches are bounded around the hint and newly inserted fragments; document-change mapping never flattens/hashes the file for revision reconciliation. Incoming comment responses may hash the current source once to apply matching results immediately. The collaboration service uses a versioned handshake and a versioned epoch marker. When a browser from an incompatible release connects, it is forced to reload before it can decode or send source updates. Protocol-only migrations preserve the browser's offline draft; source-tree replacements still clear the draft because the server tree is authoritative. The wire contract is defined in the dependency-free `src/shared/collaborationProtocol.ts` module. It assigns stable message numbers for Yjs sync/awareness, flush receipts, the protocol handshake, maintenance and permission notices, compile-state metadata, and formatter leases, and currently uses protocol version `3`. The module also owns the shared compile-state and save-receipt shapes plus their status validators; client and server modules re-export these types so existing imports remain valid. Binary encoding and decoding remain next to the transport code on each side, which keeps field order and established packet bytes unchanged. A wire-incompatible change must bump the shared version and provide an intentional migration or reload path. The collaboration service persists dirty text with atomic temporary-file writes and returns a receipt containing `revision`, `persistedAt`, `ok`, and failed paths. Failed writes remain dirty and are retried in the background. Every ordinary source operation must accept a successful receipt: the coordinator returns `409 SOURCE_FLUSH_FAILED` with `failedPaths` instead of proceeding with a stale disk tree. This applies to Git checkout/restore, history restore, file/folder writes, moves/deletes, project deletion, and consistent reads. A flush requested while a snapshot barrier is active is deferred until the barrier closes, so the browser does not receive a false failure for an edit that was intentionally held in memory. If a collaborative text edit exceeds the configured limit, it is restored to the last durable content and the flush receipt identifies the rejected path. The workspace toolbar exposes project snapshots as a single button. Selecting source text reveals comment and selection-history actions in the line-number column; their tooltips remain enabled independently of the interface-tooltip preference. Opening selection history captures the file, selected range, and source together, including before the lazily loaded dialog mounts. Diff calculation uses the public functional API from `@sanity/diff-match-patch`: semantic cleanup for selection history and anchor fallbacks, and efficiency cleanup for formatter edits. Cleanup functions return new arrays; callers use those results. A shared cleanup guard falls back to the original diff if cleanup splits a UTF-16 surrogate pair, keeping Unicode characters intact in rendered pieces and formatter/anchor boundaries. Offsets remain JavaScript UTF-16 offsets for CodeMirror/Yjs and do not use the library's UTF-8 patch-index conversion. Project snapshot comparisons encode complete lines in a small local module, using non-surrogate BMP tokens to preserve line-oriented hunks without importing package internals. Inputs that exceed token capacity retain the remaining tail as one token, allowing coarser but lossless comparisons of unusually large files. Recovery snapshots are created only for actual file additions, changes, or deletions. Settings-only changes update the persistence baseline without adding a timeline entry; effective compiler settings are captured with the next file change. Older zero-file-change entries are excluded from timeline pagination and previous-version comparisons, but are not automatically purged. New snapshots record UTF-8 text by default; PDFs, images, archives, fonts and other binary attachments are excluded. Known resource types are skipped without reading their contents; other files must pass the existing UTF-8/text-content check. Writers can manage per-file inclusions/exclusions from Project snapshots. These shared, path-based choices are stored in SQLite, survive clearing history, and affect future recording only. Including a file captures its current contents; excluding a file does not create a deletion entry or remove existing recovery data. Missing paths with overrides remain visible so their choices can be reset. Each new manifest captures its tracking policy. Whole-project restoration keeps current files outside that snapshot's scope, restores its managed files and removes managed files created later. The replacement tree is fully staged before the live source tree is swapped. Legacy manifests without a tracking policy retain their full-tree restoration behavior and historical binary contents; upgrading does not rewrite or discard old snapshots. Before a restore, its safety checkpoint also captures currently excluded files that will be overwritten or deleted (including attachments affected by legacy restores), without changing project tracking settings. Failure to save that checkpoint aborts restoration. Explicitly included non-text attachments are shown as a compact change notice without decoding a text diff. Automatic recording and text comparison share the same file-kind and UTF-8 content classifier, rather than separate extension lists (including Windows batch scripts and text with unfamiliar extensions). New manifest entries cache this classification from the bytes already read during recording. Snapshot details expose it for changed files, including deleted files when the previous snapshot is available. Older snapshots are classified on demand within the text-preview size limit without rewriting their manifests; missing objects and oversized previews retain their normal comparison errors. Source and ordinary text/data formats support bounded UTF-8 comparisons, including file deletions; invalid or oversized text is reported as unavailable, not as unchanged. After a source write succeeds, connection-originated Yjs text deltas are also stored as ordered edit segments. Each segment belongs to exactly one user, one file, and one operation type (`edit` or `format`). The edit records store only positional replacements with complete deleted and inserted text, rather than per-edit whole-file snapshots. Browser and agent edits use the same lossless span encoding; only displayed passage previews are shortened. Older truncated records remain readable but cannot reconstruct affected earlier passages. The records live in SQLite separately from recovery snapshots and are retained as the newest 20,000 project segments (`editHistory.maxSegments`) within an independent 64 MB serialized payload budget (`editHistory.maxStorageMB`). Both settings are optional; omitted values use these defaults. Explicit existing limits are preserved. Selection-history reads stream records and stop when the requested display windows are complete, rather than loading all retained payloads at once. This budget never shares or reduces the 128 MB recovery-snapshot budget. A too-large individual edit burst is omitted, and retention removes the oldest records; both deliberately create a visible history boundary rather than presenting an unsafe attribution. When selection history is viewed, adjacent records affecting the selection are consolidated into one editing session while no pause between them exceeds five minutes, up to a 30-minute span; a longer continuous session is split so earlier states stay visible. Coding-agent edits and direct edits never share a session entry, so an agent's change can be compared on its own. When later edits only moved the selection boundary and are folded into the state they left unchanged, the entry keeps the agent flag of the older change that wrote the text. Raw per-user records remain separate, while the timeline lists every contributor to that session. Each entry maps the selected passage backward through the durable edit chain and presents the resulting selected content, a semantic character-level comparison with the preceding older state, contributors, and latest edit time in reverse chronological order. Long unchanged runs are compacted around the local rewrite; truncated passage previews are never compared. Consecutive windows whose fully reconstructed selected content is identical are coalesced, so the timeline only shows effective text states. If a file was replaced, restored, imported, or reached an edit-record retention boundary, mapping stops instead of guessing across the gap. Yjs state persistence is reserved for source and HTTP-originated text updates. Ephemeral metadata such as compile status, file-list revisions, and comment or dictionary invalidation markers is broadcast live and reconstructed from the database/source tree after restart; metadata-only events therefore do not cause another full synchronous Yjs state rewrite. During room recovery the server removes old markers and validates any queued/running compile state against `compile_runs`. The collaboration handshake also sends a small server-authoritative compile-state snapshot, so a stale browser IndexedDB entry cannot make a read-only workspace appear permanently busy. Each collaboration message refreshes the account record before applying an update. Disabling a user, deleting the account, or changing project membership therefore takes effect for an already-open socket rather than relying only on the next reconnect. Binary uploads also publish a source-tree event so other open workspaces refresh their file lists even though binary files are not Yjs text objects. Project file types are recognized by common suffixes and a small set of known extensionless names such as `LICENSE`, `README` and `latexmkrc`. Unknown types use the generic file icon and remain downloadable instead of guessing their contents. File-tree refreshes read only directory entries and file metadata, never source contents. Recognized text can use the collaborative editor up to the existing 5 MiB limit, or the configured upload limit if lower. Opening a text file validates its full contents through the existing text API, without returning a duplicate source body already supplied by Yjs. Room hydration and the text API reject binary controls and invalid UTF-8 instead of silently replacing bytes. Known images (including SVG) and PDFs retain their resource previews up to 10 MiB. Larger files and unsupported types show a download prompt rather than opening in the editor. Generic text uses CodeMirror language support for JSON, YAML, Shell, TOML, JavaScript/TypeScript, Python, HTML/CSS/XML, Markdown, INI, Perl/latexmkrc, Dockerfile and CMake where recognized, otherwise plain text. LaTeX semantic highlighting, auto-pairs, completion and reference navigation stay scoped to LaTeX source; BibTeX and BST retain their dedicated languages. Auxiliary text languages load together on the first recognized non-LaTeX file, not when entering a TeX/BibTeX/BST workspace or opening plain text. Detection is synchronous and independent of the parser bundle. The import is shared across files and projects in the current browser page; failures can be retried. The editor starts as plain text and installs the language through a CodeMirror compartment without replacing its document, selection, undo history or Yjs binding. Late results are ignored after file/instance changes or unmount, and a load failure leaves editing and saving available with a nonblocking retry notice. Writing checks remain limited to prose-oriented files rather than running Harper on newly editable configuration and program files. `ProjectMutationCoordinator` has two related controls: | Operation class | Examples | Coordination behavior | | --- | --- | --- | | Serialized source operation | File writes, settings, Git metadata | Waits for queued operations, waits for a ready room, flushes the room, then runs while holding the per-project queue. | | Exclusive source replacement | Git checkout/restore, history restore, project deletion, path move | Waits for active compilation, flushes successfully, enters maintenance, performs the replacement, and resets the collaboration epoch. | | Consistent source read | Archive/download, raw source, history comparison, outline, completion index, Git diff/history | Uses the queue and a short snapshot barrier. The barrier blocks disk autosaves while an asynchronous scan/copy runs, then validates the deferred flush before returning. | | Background compilation | `latexmk` on an immutable snapshot | Holds a compile reservation but not the ordinary source queue, so editing, source reads, and the retained PDF can continue. | | Compile-state cleanup | Cache/artifact recovery | Waits for active compiles and serializes output removal without disconnecting collaborators. | | Published PDF read | PDF.js/range requests | Reads an immutable published bundle directly and does not wait for cold Yjs-room initialization; a concurrent cleanup may produce a normal 404. | | Cold file-list read | Project file tree | Uses the queue and a short barrier, but does not wait for a cold Yjs-room hydration; a concurrent cleanup is treated as a normal missing entry. | The source tree is checked with `lstat`-based path walks. ZIP imports, project duplication, Git checkout, file listing, and source resolution reject symbolic links (except that deletion may address a final link itself without following its target). This prevents a project path from escaping its source directory. Project ZIP import and export use `@zip.js/zip.js` on the server with Node's native compression streams. The public custom-codec entry point avoids loading browser workers or WASM; entries are processed sequentially with backpressure. Imports inspect all accepted entry metadata before writing, reject unsafe or duplicate paths and symbolic links, and enforce the 1,000-entry limit, configured single-file limit and total expanded-size limit (the larger of 200 MiB and the single-file limit). Streamed output is independently byte-counted and CRC-checked. Import directory creation and main-document reads are asynchronous; each import caches directories it has created to avoid repeated filesystem calls for siblings. The uploaded compressed ZIP remains in memory, while expanded files go directly to disk. Exports exclude `.git`, preserve empty folders, and finish in a private temporary ZIP under the consistent-read barrier before the HTTP download starts. ## Editor, files, and navigation The file-tree overflow menu opens project-wide search in a lazy-loaded modal. Search is literal, with case-sensitive and whole-word options, and groups source previews by file. Selecting a match opens that file at its line and column. The authenticated endpoint checks project access and reads under the existing consistent-read barrier, flushing server-received collaborative edits first. Only shared-classifier text paths with valid UTF-8 text content are scanned; binary and unknown types are excluded. Searches are bounded to 500 files, 32 MiB total and 500 matches, with an explicit incomplete-results notice. Per-file limits match collaborative-file limits. Case-sensitive and whole-word matching default to on. Writers can preview literal replacements (red deletions and green insertions only for a nonempty replacement), replace one exact match, or confirm replacement across all displayed results. Each occurrence is a separate row labelled with its line and column; only that occurrence is highlighted in its surrounding source preview. Read-only members only see search. Truncated results disable replacement until the search is narrowed. Each target includes its searched source hash and exact UTF-16 ranges. The server validates ranges against the search pattern, rejecting duplicates, overlaps and locations that are not complete matches before editing anything. The write queue flushes current drafts, validates every target before mutation, then applies exact attributed Yjs edits in one synchronous transaction. Stale sources and active formatting leases reject the operation; normal persistence updates anchors, edit history, snapshots and collaborators without a reload. After replacement, the modal immediately revalidates results in the background without clearing the current list. Stable file/location keys preserve scroll and collapsed groups; replacement remains disabled until fresh hashes arrive. A failed refresh keeps the previous results visible but non-replaceable. New search criteria still clear obsolete results and debounce for 350 ms. Success feedback sits in the footer so it does not shift the result list. Workspace resizing uses `react-resizable-panels` v4 (`Group`, `Panel`, `Separator`). Constraints use explicit percentage strings; numbers in v4 would mean pixels. The editor and preview share the space remaining after the file panel and optional drawer. Each visible panel combination keeps its own browser layout using `useDefaultLayout` and a storage adapter scoped to the deployment base path. The adapter validates current v4 per-combination records and does not convert v2 layout arrays. Missing, unsupported or corrupt records use default sizes; older browser records are left untouched. Blocked browser storage also falls back to usable defaults. File-panel collapse state follows `onResize`. Source comments sit at the left edge of the preview region, immediately beside the editor. They do not add an outer resizable panel or change the editor/file widths. A 760 px preview-container breakpoint switches between an inline review column and a drawer over the PDF; closing it restores the preview. A right-edge separator resizes either presentation from 280 to 640 px (360 px by default), capped by the container and reserving at least 320 px for an inline PDF. Its browser-local preference is scoped to the deployment, not stored in the project. Arrow/Home/End keys resize it too; double-click restores the default width. The PDF stays mounted, and a stable portal host preserves review filters and reply forms across responsive changes. Editor-only mode falls back to an adjacent resizable context pane; settings keep their existing right-hand pane. Source annotations use small outlined comment icons with the same toggle, keyboard activation and tooltip behavior as the former pins. SyncTeX arrows are portalled outside the group and anchored to the separator, so v4's document-level drag capture does not consume their pointer/keyboard actions. Observers and window listeners are removed when the separator unmounts. The editor is CodeMirror-based and provides LaTeX syntax highlighting, folding, auto-pairs (including `\begin{...}`/`\end{...}`), indentation, Vim mode when explicitly enabled, and completion items from built-in LaTeX plus project `.tex`, `.sty`, and `.cls` definitions and BibTeX labels. Completion indexes and outlines are metadata-keyed and coalesced; a source-tree change invalidates the corresponding cache. The live editor and server project index share the pure symbol extractor in `src/shared/latexSymbols.ts` and the completion data types in `src/shared/latexCompletion.ts`. It extracts command argument snippets, environments and theorems, packages, classes, source-file references, labels, and in-file `\bibitem` definitions using the same comment/literal masking and reference rules. The editor owns its document-identity cache and gives current unsaved definitions precedence over server candidates. The server owns file I/O, metadata caches, built-in candidates, source attribution, and root-scoped BibTeX selection; it does not promote in-file `\bibitem` entries to project-wide citations. This extractor does not expand TeX macros or load the editor parser. Opening `.tex`, `.bib`, `.sty`, or `.cls` files in editor tabs is an optional per-user/per-project preference and is off by default. The active tab is highlighted and keyboard accessible. PDF/SyncTeX synchronization uses the selected root's compiled PDF and accepts both the root and included `.tex` sources. Opening a source tab does not change the compile root; SyncTeX decides whether a source location has a corresponding position in that PDF. LaTeX editor highlighting and structural folding use an internal Lezer LR parser derived from a pinned Overleaf lezer-latex revision, replacing the legacy `stex` stream mode. Grammar sources, local compatibility changes, and build instructions live in `src/client/latex/README.md`; attribution is recorded in `THIRD_PARTY_NOTICES.md`. The parser recognizes command arguments, math, sections, and environments, with incremental tree reuse during editing. Editor colors are centralized in `src/client/editorThemes.ts`. Light is the default and retains the existing LaTeX and generic BibTeX/BST syntax colors; the optional dark palette draws on One Dark's blue-gray surfaces with clear prose, subdued cool-gray comments and quieter line numbers. One browser-local light/dark/system preference controls the whole application and editor. The dashboard user popover groups identity, GitHub integration, language, theme and sign-out controls. Explicit light/dark selections remain unchanged; system mode follows OS color-scheme changes while the page is open. New browsers default to System; existing explicit Light/Dark choices are preserved. Missing or blocked browser storage follows the system too, with Light as the fallback when media queries are unavailable. The pre-paint script uses the same resolution rules. It is stored in localStorage (scoped to the deployment base path), never in the database or tied to a user/project. Other tabs follow storage changes, and the HTML bootstrap applies the preference before the first paint. Root-level semantic variables cover the dashboard, workspace and portaled dialogs, while PDF paper, images and existing dark diff/log views retain their original content colors. CodeMirror's dark facet changes through the existing appearance compartment, without recreating the editor, document, selection, undo history or Yjs binding. Generic BibTeX/BST highlighting adapts CodeMirror's shipped tag rules to the same palette variables. Editor popups, completion, search, Vim status, diagnostics, source comments and gutter actions use those variables too. Comments and brackets have independent color roles. In dark mode, comments on the active line, inside a selection or under a search highlight are brighter to remain readable. Selection marks use the existing language tree's comment tags, restricted to the intersection of the selection and visible ranges; they do not introduce another source scanner or force parsing. LaTeX has a semantic palette distinct from generic BibTeX/BST syntax: bright blue ordinary commands, teal structure commands and environment names, ochre reference keys, rose special characters, gray comments and brackets, and purple math delimiters and commands. Ordinary math contents and numbers retain the prose color. Within math, known reference, text, declaration and layout commands retain ordinary command styling rather than inheriting purple. Generic custom math macros keep the math accent; this lightweight classification does not expand TeX definitions or attempt to prove the semantics of arbitrary user macros. Sectioning commands, `\begin`/`\end`, and the document-start landmark `\documentclass` use heavier command weight; other configuration and inclusion commands retain ordinary weight. Math regions have no background tint. Visible syntax-tree decorations accent math commands and emphasize TODO/FIXME inside comments; literal content uses a neutral background. Cursor-local, real matching environment pairs use the same pale teal accent as matching brackets. The white editor uses ink-colored prose, quiet line numbers, a pale blue active line and selection, and pale yellow search matches. LaTeX text-color contrast on both editor backgrounds is checked by regression tests against the WCAG relative-luminance formula (at least 4.5:1); line numbers are intentionally quieter, non-content decoration. Pairing accents use a theme-specific green foreground, also checked against their tint. Dark syntax also retains contrast on selection and search accents; auxiliary controls and popup text have contrast coverage for both themes. Reference keys are colored without a standing diagnostic-like underline; only the pointed-at key gains an underline while Ctrl/Cmd is held. Modifier release, mouse exit, document/viewport updates and unmount clear this temporary affordance. They update after document, viewport, selection, or syntax-tree changes without forcing parsing or introducing another full-source regular-expression lexer. Local changes preserve TexLite's literal forms, spaced environment commands, internal macro names, and macro parameters. Incomplete source is recovered for editor use; recovery nodes are not treated as compiler errors. Environment folding requires a real, matching close rather than an inferred recovery node. Common argument whitespace and starred definitions are supported. Environment folding compares parsed names, ignoring comments in name groups; multiline headings and opening names remain visible when folding their bodies. Math delimiter commands such as `\langle` and `\rangle` use the math palette. Basic syntax-tree indentation adds one configured level inside ordinary environments and multiline groups, aligns closing markers, leaves the document body at its margin, and preserves raw literal content. This is an editing aid, not a replacement for tex-fmt. Iosevka loads real 400 and 600 weights so structural emphasis does not depend on synthetic bold or locally installed fonts. Completion triggering uses a shared cursor-context adapter over the syntax tree. It identifies comments, literal bodies, math, and environment/reference/ file/package/class arguments without repeatedly reading the document prefix. It permits at most five milliseconds of parser catch-up and defers completion if context remains unavailable. Known argument nodes are queried directly; the compatibility reader is limited to a window of 8,192 UTF-16 code units of tree-identified commands and retains the shared reference classifier for multi-argument citations, reference ranges, and optional labels. Incomplete end names in specialized environments also use this bounded reader when an adjacent recovered marker identifies the command. Environment completion recognizes multiline begin/end arguments and reuses existing closes without taking the close of a balanced outer environment. Reference navigation marks have a view-local prefix cache. Editing invalidates marks from the earliest changed line onward and retains unaffected visible marks with current position metadata; immediate syntax colors still come from Lezer. Reference scans are coalesced after 120 ms of input inactivity, and viewport reflow cannot bypass that delay. Stable documents reuse existing prefix coverage across scrolls. Pending scans are cancelled on disable/unmount, and their results apply only to the same immutable document. Ctrl/Cmd-click revalidates retained references against the current source when a scan is pending, including edits to later lines of multiline citation arguments. Hovering a citation key after a short stationary delay can preview the resolved BibTeX entry's key, type, title, authors, year, venue, DOI/URL and source location. Author lists, here and on citation library cards, follow biblatex's default truncation (`maxnames=3`, `minnames=1`): longer lists, and lists ending in `and others`, show the first author followed by "et al." and, when known, the author count (`citationAuthors.ts`). `src/shared/bibtexNames.ts` splits names at brace-depth-0 `and` on the TeX source; each shown name is then converted to readable text. The per-user, per-project editor preference defaults on. The workspace editor renders all CodeMirror tooltips (hover previews, completion, diagnostics) in a zero-size fixed layer in `
` instead of the isolated editor host, so a neighbouring PDF panel cannot cover a preview near the editor's right edge. The layer copies the editor font and sits at z-index 85: above workspace panels, below menus, drawers and dialogs, preserving why `.editor-host` is isolated. Editors inside dialogs keep their tooltips local. Obsolete requests are aborted when their tooltip closes. The server resolves only the main-document-aware target and reuses a metadata-keyed source snapshot across keys, so a project's LaTeX sources and directives are not re-read for every lookup. The snapshot cache is bounded by project count and bytes, and an unref'd timer aimed at the earliest expiry frees each entry when its 15-second lifetime ends, so an idle project's sources do not stay resident until another project displaces them. Each `.bib` file gets a separate key -> line/column index, not its content, reused while the file's size and modification time are unchanged, so an unchanged bibliography is neither re-read nor re-scanned on every hover; it is bounded by file count and bytes and freed after five minutes without use. Both caches share `boundedExpiringCache.ts`. The target also carries a `revision` (size and modification time) of the file that defines it, omitted when that metadata is unknown so nothing derived from it is reused. The browser then fetches the target `.bib` file and parses it with the editor's Lezer BibTeX grammar in a dedicated, lazily created metadata worker, caching the parsed entry map per project file for as long as the server reports the same revision. The bounded cache is shared by the whole page, so remounting the editor for another file or reconfiguring it after a preference change keeps it. Concurrent hovers share one fetch-and-parse per file revision; closing a tooltip only stops waiting, so a load that finishes after its hover moved on still fills the cache, and only the newest revision started for a path may do so. A shared download still cannot hang: it is aborted after 30 seconds, or 10 seconds after its last waiting hover closed unless another hover joins first. Parsing is not aborted; the metadata worker receives one request at a time and times out each parse after 30 seconds of its own work, so a huge file that times out fails alone and queued parses continue on a fresh worker. When the file cannot be loaded or parsed the hover still shows the target's location with a short notice, and a file that parses without the entry (for example one the editor reads as a syntax error) is reported as unreadable rather than unavailable. Hover never changes the source or opens the entry, so Ctrl/Cmd-click remains navigation. Freshness comes from the server, not from a timer. The resolve and file requests are both consistent reads that flush live Yjs edits to disk first, and every hover re-resolves its target, so a "not found" answer is never cached and an entry added or edited moments ago is visible on the next hover. Only the fetch-and-parse of an unchanged `.bib` file is reused; a changed revision, a missing revision, or a failed load is never cached and simply refetches. Automatic pairing applies its existing line-local insertion predicates before materializing a whole-document string. Only actual candidates need the full literal-state and matching-end checks; ordinary input and skipped non-candidate braces bypass that work without changing pairing or undo semantics. Completion candidates and insertion behavior, reference navigation, project include graphs, root detection, outline generation, and host-tool diagnostics retain their existing scanner and service boundaries; editor context queries do not replace these workflows or perform TeX macro expansion. Each project collaboration object owns one Yjs undo manager per source file. Editor tab remounts therefore preserve Ctrl/Cmd+Z and Vim undo history without leaving obsolete CodeMirror/Yjs observers behind. Managers are released when the collaboration object is destroyed or loses edit permission. Vim support is imported only when enabled, with one shared module initialization per browser page. Its custom undo/redo mappings are registered once and use per-editor history callbacks rather than global project state. Enabling or disabling Vim reconfigures a compartment without recreating the editor; appearance changes do not reinstall it or reset Insert/Visual mode. Pending loads cannot enable Vim after it was disabled, the file/instance changed, or the editor unmounted. Mode listeners are removed on disable and cleanup. The status bar distinguishes loading, active mode and failure; unsuccessful loads leave standard editing available with a retry action. TexLite probes the optional host `harper-cli` command at startup. Harper uses its native LaTeX-aware parser and returns safe replacement suggestions. The project dictionary is kept in SQLite and filters project-specific spelling results in the browser. If Harper is absent or fails, CodeMirror enables the browser's built-in English spellchecker until it retries. A project owner may also enable the optional host `chktex` compiler setting. ChkTeX starts only after two seconds of editor idle time, coalesces identical source inputs, and uses a process-wide queue capped at eight checks across projects and users. Its LaTeX writing diagnostics use a blue-gray dotted underline and never replace the compile diagnostics; the setting is off by default. Both writing-check endpoints require a page-local UUID `clientId` and a positive safe-integer `sequence` with each source request. Missing identifiers return HTTP 409 `CLIENT_UPDATE_REQUIRED` with a localized save-and-reload hint; malformed identifiers return HTTP 400. There is no session-cookie lane fallback. The server always scopes lanes by project, authenticated user, client and file. It never forces a browser reload or discards local edits for this error. Formatting is independent from the editor's local appearance. A user can manually format a selection or enable the per-user/per-project “format before compile” preference. The browser uses bundled `tex-fmt` WASM for `.tex`, `.cls`, and `.sty`, and `bibtex-tidy` for `.bib`; the editor settings also provide a per-user/per-project TOML options string passed to `tex-fmt`. Each formatter and its text-diff calculation run in a separate, lazily loaded Web Worker, so opening a project does not wait for them and formatting does not block the editor UI. The BibTeX worker does not load tex-fmt or its WASM runtime. Both workers use the same Unicode-safe edit calculation. BibTeX syntax and size errors retain their localized messages; a failed or timed-out worker is discarded and recreated on the next format attempt, with a 30-second request timeout. Citation-library form validation remains in the browser's main thread. A formatter failure reports an error but does not prevent compilation. There is no silent Prettier fallback. Missing, non-string or oversized stored TOML values use `wrap = false`; an explicitly saved empty string uses tex-fmt's own defaults. The browser no longer guesses whether an empty value came from an older default or requires a migration marker. Before a formatter computes a replacement it acquires a short-lived, per-file lease from the live Yjs room. The lease is held only for the format/snapshot, final Yjs apply, and a durability flush; ordinary typing and compilation are not blocked. A second formatter for the same path waits in a bounded FIFO queue, and the server grants it only after the first session's update has been received and flushed. Leases carry an expiring random token, renew before the final apply, and are released automatically on timeout, permission loss, or WebSocket disconnect. Because the lease is process-local, it is deliberately scoped to TexLite's single-instance deployment; it is not a distributed lock. If a source edit arrives while a formatter is working, the client discards the stale replacement rather than applying offsets to newer text. Project duplication flushes the live source room and copies the tree under a short read barrier. Uploading a replacement text file also re-anchors existing source comments against the old and new contents before notifying collaborators. The outline follows `\input`, `\include`, `\subfile`, and import-package references with the same path rules used by citation navigation and completion. It traverses included files depth-first at their source position, so headings remain in reading order rather than document-graph breadth-first order. The source and PDF panes expose explicit SyncTeX arrows, and PDF double-click can request the corresponding source location. Search and replace is project-wide, staged as one serialized operation, and records one history version. Structured compile diagnostics resolve project-relative file names and line numbers; the raw `latexmk` transcript remains available. ## Compilation and retained output The project setting supplies the default root document. Project settings list and accept only `.tex` files containing a real `\documentclass` declaration; an imported project with exactly one `.tex` file may use that file as a compatibility fallback. The server enforces the same rule for compile and preview routes. A browser session may select another detected root, and only the currently selected root is compiled. Compile state, logs, retained PDF, artifacts, outline, and SyncTeX are keyed by root, so collaborators working on different roots do not replace each other's result or compile notification. The supported direct-upgrade baseline is TexLite 0.2.0. Compilation artifacts use the per-root v2 manifest introduced in that release: `output/.texlite/targets/