Skip to content

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

A static site that does its work in the browser, plus two small functions that exist only to hold an API key.
This device: the user's browserThe tool pagesNext.js static export, ReactJournals · Match · ReviewFigures · WriteWorkersTeX Live (BusyTeX, WASM)Pyodide + figurelib.pypdf.jsOn-device workread · embed · rankformat & journal-rules checksLaTeX helpers, suggestionsBrowser storageOPFS: projects, last PDFcaches: index, model, enginelocalStorage: small settingsConsent notices: the only way outReviewConsent · FigureConsent: nothing is sent until a clickCloudflare Pagesthe static site, /index, /templates,the figure galleryPublic assetsR2: TeX engine and packsjsDelivr: Pyodide, ONNX runtimeHugging Face: the embedding modelPages Functions/api/review · /api/figurehold the key; keep no paperAnthropic APIClaudepublic filesengines, modelopt-in only
Solid arrows are bodyless GETs for public files. The dashed ones carry something of the user's, only after a notice, only for the AI review and Ask Claude.

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

Three rules govern every change. Each is held by a specific place in the code, and checked by a test.
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 by check_match.mjs and check_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.tsx and FigureConsent.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 by buildFigurePayload() in figureSchema.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

A hybrid ranker over a static index, its weights and its fit scale measured on held-out papers rather than chosen.
  1. Read the paper

    PDF text via pdf.js, DOCX via mammoth, or pasted text as a first-class entry.

    extract.ts

  2. 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

  3. Embed

    A small model (named in the manifest) turns the title and abstract into a 384-dimension vector, in the browser.

    embed.ts

  4. 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

  5. Rank

    Four signals (embedding similarity to each journal's closest centre, topic overlap, citations, a small activity prior), fused with fitted weights.

    rank.ts

  6. 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

A map-reduce over the paper, run by the browser, where every quote the user sees has been verified against the paper.
  1. 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

  2. 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

  3. 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

  4. 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

  5. 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

  6. 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

Language is split from drawing: a declarative spec is the one artifact, drawn deterministically on the device.
  1. 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

  2. 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

  3. 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

  4. 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

  5. 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

  6. 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

LaTeX in the browser, full screen, and the hub the other tools open inside.
  1. 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

  2. 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

  3. 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

  4. 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

  5. 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

  6. 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

The two AI features cost money per run, so they're paid in M coins and need an account. Nothing else does, and nothing from a paper is ever part of one.

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 readable ml_in cookie lets signed-out pages skip /api/me entirely.
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 in lib/accounts/coins.ts.
Paying for a review
review/start gets section ids and lengths (never text), charges reviewPrice and 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

One palette, one light, a handful of surfaces, shared by the tool pages, the workspace and these docs.

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

Routes own their pages and pieces; shared logic sits in lib/, one folder per feature; the server code is in functions/.
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

Selfchecks for pure logic, Playwright smokes for the flows, and CI on every push.

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

A static build to Cloudflare Pages, the Functions alongside it, D1 behind them.

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

What to check before approving anything, in the order it matters.
  1. 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.

  2. 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?

  3. 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.

  4. Server state

    Does a Function now keep anything beyond the daily counters and the account tables? Nothing from a paper, ever, for anyone.

  5. 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.

  6. Functions' imports

    Anything new imported by functions/ must be pure or isomorphic.

  7. Tests

    npm run check green; the smokes for the flows touched; a selfcheck for new pure logic, written first.

  8. The away colour

    Used only for what leaves the device. Nothing else may borrow it.

  9. 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.