How MargaLink is built.
A tour for developers and reviewers: what runs where, why, and how to check a change keeps it that way. The reasoning in full lives in docs/ARCHITECTURE.md; using the tools is in the guide.
The shape of it
No server for the work
The web app is a Next.js static export on Cloudflare Pages. No server does the work on a paper. The journal index is built offline by the Python pipeline (pipeline/) and shipped as static files (web/public/index/); the browser downloads it once and does every match itself. That isn't an optimisation on top of a server design: a server that ranks journals necessarily sees the paper, so keeping “the paper never leaves the device” absolute meant taking the server out of the path.
The exceptions are Cloudflare Pages Functions: functions/api/review.ts and functions/api/figure.ts, for the two features that need a language model (the browser must never hold the Anthropic API key), and the account and payment Functions beside them, on a D1 database that holds accounts and M coins, never anything from a paper.
Heavy things run in workers
LaTeX compiles in a Web Worker running TeX Live compiled to WebAssembly (BusyTeX). Figures draw in a worker running Python (Pyodide) with public/figurelib.py. PDF text comes from pdf.js. The embedding model runs through transformers.js. Their files are public and versioned: the TeX engine and packs on an R2 bucket, Pyodide and the ONNX runtime from jsDelivr, the model from Hugging Face.
Privacy, in code
- 1 · The paper is never uploaded for matching or format checks
- Reading (
extract.ts), embedding (embed.ts), ranking (rank.ts,match.ts), the format check (formatCheck.ts) and the journal-rules check (rulesCheck.ts) run in the browser. Their only requests are bodyless GETs for public files. The workspace reads its own compiled PDF the same way. Checked bycheck_match.mjsandcheck_write.mjs: no request carries a body. - 2 · Nothing from a paper is stored on a server
- Both AI Functions keep nothing from a request: they validate, charge, call Claude, gate the answer and return it. Server state, all in D1, is the daily limits per feature (for the service and for each account,
dailyCaps.ts), accounts, the coin ledger and a running review's ticket (section ids and lengths). Writing projects live in the browser's Origin Private File System (projectStore.ts). - 3 · Anything that sends text out is opt-in, behind a plain notice
- Two features, each with its own notice:
ReviewConsent.tsxandFigureConsent.tsx. A review starts only from the notice's confirm (or Resume/Retry of a run already confirmed). Ask Claude's payload can only be built bybuildFigurePayload()infigureSchema.ts, whose selfcheck plants sentinels in cells and typed text and proves none leave.
What the two exceptions send
The review: the paper's text (author lines stripped, best effort), one request per section chunk, then one over the extracted claims, never the text again. Sections the user marks Don't send are never in any request.
Ask Claude: column names and inferred types, the row count, the request text, the current figure spec with typed text blanked and groups as #n; category labels only when a separate box is ticked. Never a cell value or a traceback.
Matching
Read the paper
PDF text via pdf.js, DOCX via mammoth, or pasted text as a first-class entry.extract.ts
Decide what to read
The title, the real abstract, keywords and the reference list; not the first few thousand characters, which are mostly authors.matchQuery.ts
Embed
A small model (named in the manifest) turns the title and abstract into a 384-dimension vector, in the browser.embed.ts
Topics and citations
The paper's likely research topics, and which journals its own references cite (a name counts only where a journal sits in a reference).topics.ts · references.ts
Rank
Four signals (embedding similarity to each journal's closest centre, topic overlap, citations, a small activity prior), fused with fitted weights.rank.ts
Say how good a match is
A calibrated fit: "Fit 78" is as close as 78% of real paper→journal pairings. An uncalibrated build shows raw similarity.rank.ts · manifest.json
The index
Built offline: 1–4 centre embeddings per journal (k-means over its recent papers, so a broad journal is several clusters), metadata, a recent-topic profile, alternate names, and the topic table. Shipped as int8 rather than float (about 1 point of accuracy for a quarter of the size); every file stays under Cloudflare's 25 MB cap. Journals whose papers don't cohere are dropped at build time, with reasons. web/scripts/eval/eval_match.ts runs the same rank.ts over each journal's newest (never indexed) papers, fits the weights and the fit scale, and writes both, with the measured accuracy, into the manifest.
Only the 2,000 most-published journals get a prerendered page (/journal/[id]): Cloudflare Pages caps a deployment at 20,000 files. Every other journal is fully searchable and matchable; its details expand in place. journalUrl.ts's isPrerendered() is the one place that decides.
The AI review
Prepare, on the device
Author lines stripped; the text normalised once (NFKC, ligatures, hyphenation, quotes) so quotes come back in the alphabet the check reads.review.ts
Find the sections
From the document's own headings (Word heading styles; a PDF's fonts), a word list otherwise. The user can fix the outline and mark sections Don't send.headingHints.ts · reviewSections.ts
Extract, per chunk
≤16k-character chunks, up to 3 at a time: bounded lists of quantitative claims, each with a verbatim quote.reviewOrchestrator.ts → /api/review
Verify every quote
On the server, each quote is checked against that chunk only; a quote that isn't there is dropped. A truncated pass is retried once asking for fewer claims.reviewGrounding.ts
Cross-check the ledger
One pass over the claims ledger (never the text) finds inconsistencies and writes the prioritised summary. It can only cite ledger ids; unknown ids are dropped.reviewPasses.ts
Assemble
Findings with their verified quotes, and coverage: what was reviewed, what failed (retryable alone), what the depth skipped.reviewTypes.ts
Why it's built this way
An early single-call version broke on long papers: thinking and the JSON answer shared one token budget, long text was cut, and one dropped stream lost everything. It also showed three model failure modes the design now defends against: numbers attributed to the wrong section (the abstract is its own labelled block), “inconsistencies” that reconciled on arithmetic (reconciliation is required before reporting), and quotes that appear nowhere (every quote is verified).
Depth decides which section kinds are read, the claims cap per chunk and the effort of the cross-check; verification is the same at every depth. The Function checks exact request shapes and caps, applies the daily pass cap before calling upstream, streams from Anthropic (long non-streaming calls time out at the edge) and keeps nothing between requests. Papers over 400,000 characters are refused before the notice.
Figures
Read the data
Every sheet of an XLSX, CSV as UTF-8 or Windows-1252; the header row, decimal and thousands marks guessed; missing markers, strict numbers, type overrides, wide→long.spreadsheet.ts
A figure is a spec
Panels × roles × overlays × statistics × annotations × journal style. Templates make one, the editor edits one, Claude returns one, a recipe saves one.figureSpec.ts · figureTemplates.ts
Draw it in a worker
Pyodide runs figurelib.py on every change (debounced, stale renders dropped); exports at the journal width. SciPy loads only for a test.figureRunner.ts · figureWorker.mjs · figurelib.py
Ask Claude (opt-in)
Only buildFigurePayload() builds what's sent: schema, request, the scrubbed spec. The Function re-validates it, then gates the answer.figureSchema.ts → /api/figure
Gate the answer
A spec must validate, fit the columns, and use no label it wasn't given. A tweak must define customize() and pass the allowlist.figureSpec.ts · figurePrompt.ts
Run a tweak, fenced
Only after the user reads it and clicks Run; the worker first preloads what it needs, then replaces every network API and code-from-string path with throwing stubs.figureWorker.mjs · check_figure_sandbox.mjs
Why labels are opt-in and tracebacks never leave
A category label is a value: a site name, a patient ID. A Python traceback can quote a cell verbatim. So labels go only when ticked (and are listed in the notice), and render errors are shown as prose built from an error code and column names, with the traceback behind a “stays on this device” disclosure.
The writing workspace
Projects in the browser
Each project is a folder in the Origin Private File System, with the last PDF; saves are debounced; zips for backup and import.projectStore.ts · zip.ts
Edit
CodeMirror with the stex mode, a formatting bar, suggestions from the project's .bib keys and labels, an outline following \input, diagnostics in the gutter.LatexEditor.tsx · EditorFormatBar.tsx · latexCompletions.ts · texSource.ts
Compile
A worker runs BusyTeX (pdfLaTeX or XeLaTeX); packs chosen from the \usepackage lines; a missing file retries once with every pack; a 90 s deadline once TeX runs.texRunner.ts · texEngine.ts · texWorker.js
Read the compiled PDF
Match, Review and Checks read the last PDF through the same extraction as an upload, so they say Compile first until there is one.Workspace.tsx · extract.ts
Tools as windows
Native <dialog>s over the workspace; each body a dynamic import. The tools' state lives in hooks mounted by the workspace (the same hooks the tool pages use), so a closed window keeps its results.useMatch · useReview · useFigures · useChecks
Back into the paper
Insert into paper writes the figure's PDF and data-free recipe to figures/; review citations jump to their line; the target journal is kept in the project's meta.FiguresWindow.tsx · ReviewWindow.tsx
Engine hosting
The engine (about 30 MB of WASM) and its data packs (about 110 MB basic) are too big for Pages' per-file cap, so they live on a public R2 bucket under a release-dated prefix, every object immutable. Packs are cached by the engine's own loader in IndexedDB. scripts/ops/publish_busytex.sh uploads an allowlist with pinned sizes and a total cap; the bucket's CORS allows only our origins.
Accounts and M coins
How it holds together
- Signing in
- Google (OIDC code flow with PKCE, scope openid email) or a one-time email link whose token rides in the #fragment and is spent only on confirm. A session is an opaque token in an HttpOnly
__Host-cookie, stored only as its sha256; a readableml_incookie lets signed-out pages skip/api/meentirely. - The ledger
- Append-only; the balance is SUM(delta). A debit is one conditional INSERT, so two can't spend the last coins, and UNIQUE(kind, ref) makes every credit, debit and refund idempotent. SQL lives in
lib/accounts/ledger.ts, prices inlib/accounts/coins.ts. - Paying for a review
review/startgets section ids and lengths (never text), chargesreviewPriceand issues a ticket bound to them for two hours. Every pass is claimed before Claude is called: a section that came back isn't sent again, and each gets at most four tries. When the ticket expires, what it didn't deliver is refunded, sections weighed by length.- Ask Claude
- 1 coin per call, refunded on any answer that isn't usable.
- Payments
- Paddle as merchant of record. Paddle.js loads only on Buy; the webhook checks the signature, records each event id with its effects, credits packs, keeps Pro subscriptions in order, and takes back refunds and chargebacks. Pro's monthly coins are granted lazily from
/api/me; there's no scheduler.
The same three rules
The account tables hold an email address, Google's id for it, hashed sessions, coin history, Paddle references and, for a running review, section ids and lengths. Deleting an account cascades through all of it (cancelling Pro at Paddle first); only a one-way fingerprint that pays the welcome bonus once per address stays, for 12 months.
The clay design system
Paper
#f0efea
the desk
White
#fbfaf6
sheets: what you read
Ink
#1b1f27
text
Teal (accent)
#2c5f6f
actions, the current thing
Teal pale
#cfe0e1
Match bead
Sand
#ecdcc0
Review bead
Clay soft
#f1d2c2
Figures bead
Away
#a15a3f
only: leaves the device
app/clay.css: in the components layer, so a Tailwind utility on the same element wins
- .clay
- A raised slab: matte gradient, a highlight on top, a warm shadow under. Trays, steps, cards.
- .clay-well
- Pressed in: the current item, fields' surroundings, notes.
- .sheet
- A white paper sheet on the desk, for what you read: sources, PDFs, results, reviews.
- .clay-btn · .clay-primary
- A pill that lifts on hover and presses in on click; the primary one is teal (one per screen).
- .clay-ghost · .clay-chip · .clay-key
- Flat until touched (tray items); small tinted actions inside cards; keycaps (⌘K, shortcuts).
- .clay-input · .clay-field · .clay-select
- Text fields and selects pressed into the clay, a teal ring on focus.
- .clay-card
- A choice; aria-pressed / aria-checked / data-selected press it in and tint it teal.
- .bead · .grip · .desk · .clay-window
- Icon beads (each tool has a tint), the split handle, the full-screen workspace background, dialogs.
Rules of thumb
Shadows are warm (rgba(58, 44, 28, …)) and fall from one light at the top left. Things you read are sheets; things you touch are clay. The away colour is spent only on what would leave the device. The homepage keeps its own stylesheet (_landing/home.css) and 3D clay desk.
Where things live
- web/src/app/
- Routes. Each tool's page is JSX over a hook in its _components/ (useMatch, useReview, useFigures) that the workspace reuses. The homepage, its intro and its 3D scenes live in _landing/.
- web/src/components/
- Shared pieces by concern: layout/ (the tray, footer, logo), ui/ (Dialog, Step, the drop zone), account/, journals/, checks/, review/ and figures/ (the consent notices, result panels), docs/ (these pages' blocks).
- web/src/lib/
- Framework-agnostic logic by feature: paper/, match/, journals/, checks/, review/, figures/, write/, accounts/, ai/. Each selfcheck sits beside its file; a relative lib import carries .ts (Node runs the selfchecks natively).
- web/functions/api/
- review.ts and figure.ts (the AI features), and the account, sign-in and payment Functions. They may import from src/lib/ only modules that are pure or isomorphic (no window, localStorage or fs).
- web/migrations/
- The D1 schema: accounts and the coin ledger, payments, subscriptions. Applied with wrangler d1 migrations apply; the selfchecks apply them to node:sqlite.
- web/public/
- The index, templates, the figure gallery, the TeX and figure workers, figurelib.py, fonts, the guide's screenshots.
- pipeline/
- The offline Python (uv) pipeline: fetch from OpenAlex, enrich from DOAJ and NLM, k-means centres, quality filters, build the index.
- web/scripts/
- smoke/ (the Playwright checks), e2e/ (the account Functions on a local D1), eval/ (the ranker's evaluation), docs/ (the guide's screenshots), ops/ (the index download, the TeX engine upload).
Start with CLAUDE.md (the three rules), then src/lib/match/rank.ts, then docs/ARCHITECTURE.md's folder map, one line per file.
Tests
Commands
- npm run check
- Typecheck, lint, and every *.selfcheck.ts (plain Node, no framework): grounding, payload sentinels, the ranker's drift guard, LaTeX helpers…
- web/ · CI
- npm run smoke
- The Playwright checks below, against a running dev server.
- web/ · local
- e2e_accounts.mjs
- The account Functions for real, on a fresh local D1 under wrangler pages dev: an email sign-in, a signed Paddle webhook, a paid review, a 402, sign out.
- web/ · after a build
- uv run selfcheck.py
- figurelib.py under CPython with Pyodide's library versions; and the pipeline's own checks.
- web/figurelib, pipeline · CI
The smokes (web/scripts/smoke/check_*.mjs)
- check_match
- What is read from a real PDF, topics, fit badges, a why panel, the correction and paste paths, and not one request with a body.
- check_filters · check_format
- Filters really re-rank; the format check on a multi-page PDF.
- check_journals_browse · check_journal_page
- Browsing without the vector file; a result opens a real journal page.
- check_review
- Against a mocked /api/review: consent naming the request count and price, one charge and the ticket on every pass, per-pass progress, a failed section, Retry, grounded citations, cancel, the capacity stop, too few coins, signed out.
- check_account
- No account request while signed out; the email link and its confirm step; Google's popup; the account page; a pack and Pro through a stubbed Paddle.js; the portal.
- check_figures · check_figure_sandbox
- A messy spreadsheet read right, templates, editors, recipes, a mocked Ask Claude; tweaks that try every way out fail with zero requests.
- check_write
- Compile, diagnostics, backups, files, the hub windows (a mocked review, the figure window), the formatting bar, suggestions, the outline, views, a failed engine download.
- check_keyboard · check_intro · check_homepage
- The dropzone by keyboard; the first-visit intro; the homepage.
- check_docs
- The guide and this page: every screenshot exists, every marker sits on its image, no broken anchors.
Deploying
npm run deploy
Builds the static export, removes one oversized WASM file Next copies in (the ONNX runtime is loaded from a CDN instead; Pages rejects files over 25 MB), then wrangler pages deploy. The AI Functions need ANTHROPIC_API_KEY (the Pages dashboard, or web/.dev.vars locally); their daily limits are counted in D1, like everything the account Functions keep: the D1 databases in wrangler.toml, their migrations applied, and the Google, Resend and Paddle secrets listed in CLAUDE.md. The index must be in place first (npm run fetch-index, or the pipeline); the TeX engine is published separately to R2 (scripts/ops/publish_busytex.sh).
Reviewing a change
New requests
Does anything new call fetch, XHR or a worker import? Every new request must be a bodyless GET for a public file, or go through one of the two notices.
Consent paths
Can the review or Ask Claude start without a click on its notice? Is any new default on? Does the notice still say exactly what's sent?
What's in a payload
Does anything add a field to what's sent? The review's pass requests and Ask Claude's payload have exact shapes, checked on both sides.
Server state
Does a Function now keep anything beyond the daily counters and the account tables? Nothing from a paper, ever, for anyone.
Coins
Does anything change a balance outside ledger.ts? Every coin in or out is one ledger row with a unique ref, charged before the upstream call and refunded if it fails.
Functions' imports
Anything new imported by functions/ must be pure or isomorphic.
Tests
npm run check green; the smokes for the flows touched; a selfcheck for new pure logic, written first.
The away colour
Used only for what leaves the device. Nothing else may borrow it.
Docs
ARCHITECTURE.md's folder map and sections, the privacy page, and this page and the guide still true, with the guide's screenshots re-made with guide_shots.mjs if a screen changed.