zpcrweb (pronounced Zed-PCR web 🇨🇦) is a set of tools for reading and analyzed Bio-Rad CFX qPCR .zpcr files. It contains an isomorphic TypeScript library plus a cyberpunk-dark web app built on top of it.
Access it at https://zpcr.rbyers.ca.
I built this because:
- I just bought a used CFX thermocycler for my home lab and didn't have $2,800 extra budget for CFX Maestro.
- I'd rather not buy a dedicated Windows machine and install drivers (I mostly use a Mac).
- I prefer to really learn how the device works under the hood and want to customize my workflow with automation anyway.
However this application is nowhere near a complete replacement for CFX Master / Maestro, lots of features are missing and the analysis is likely less reliable and more error-prone. Use at your own risk! On the upside, it is highly hackable, feel free to fork, modify, file issues and submit PRs if you like. All code is written by AI agents and not necessarily human-reviewed, so there's likely some slop.
A .zpcr file is a ZIP archive written by a Bio-Rad CFX real-time PCR instrument. Inside it
holds one .Plateread binary file per PCR cycle (the raw fluorescence readings), a
RunInfo.xml metadata file, the run protocol/log, and per-dye calibration files. This
project decodes those files into well-typed data you can use from Node or the browser.
The .Plateread binary format was reverse-engineered and is documented in
plateread.md.
There is also limited support for .pltd, .prcl and .pcrd files as produced by CFX software, but they are encrypted and this repo does not include the key (DMCA compliance). However you can easily exact the key from a copy a CFX Master using an AI agent like Claude code.
To minimize the hassle of working with encrypted plate definition files, this package define a simple .plt.csv file format for plates which can be exported, modified and imported back and linked into a .zpcr file, documented in pltcsv.md.
A thermal protocol opens as a file in its own right, in either of the two forms one comes in: a
Bio-Rad .prcl — read directly, password permitting, and shown as its step list and its decrypted
XML — or this project's plain-text .prcl.txt, which is the one of the two that can also be
edited here, there being no writer for Bio-Rad's encrypted container.
A standalone .alf run report opens as a file in its own right too. That is what a protocol
with no PLATEREAD — an incubation, a reverse-transcription hold — produces: the instrument keeps
no run folder for one (usb.md §7.10), so there is no .zpcr to make, and the report is the whole
record of the run. Such a protocol is startable straight from a protocol file in the Instrument
view — either form, since what is typed at the instrument is the directive text — and its report
lands in the file bar when it finishes.
The app also opens Biomeme run exports (.bmrun, from the Franklin/Two3/Three9 handheld
devices) — a genuinely different instrument (a handful of tube positions, fluorescence
reported directly per dye rather than per optical channel) that shares no bytes with a CFX
.zpcr/.pcrd, but is decoded into the same Zpcr shape (see biomeme.ts and
ARCHITECTURE.md's "Three input formats"), so it opens in the same Curves view. Unlike a
CFX file, a Biomeme export carries the device's own baseline and Cq for every curve; the
Curves view offers a File ↔ Computed toggle for both, defaulting to the file's own numbers.
The reverse-engineered binary format docs, all of which live in docs/, are the
reference for anything in packages/core/src that touches raw bytes. There's also one algorithm doc,
calibration.md, for the color-separation math built on top of .Dcal, and threshold.md
for baseline/threshold/Cq.
| Doc | Covers |
|---|---|
icff.md |
"ICFF" — the small index container format underlying both .Plateread and .Dcal: a trailing footer points at an index of [name, offset, length] entries. Implemented by packages/core/src/icff.ts; locate the index via the footer, not by scanning for a known field name. |
plateread.md |
The .Plateread files inside a .zpcr — one per plate read (PCR cycle), holding the 6-channel × 108-well raw fluorescence table plus cycle number, block temperature and timestamp. Mixed endianness: metadata (version words, ICFF index) is big-endian; the WELLDATA/DARKDATA float arrays are little-endian. Implemented by packages/core/src/plateread.ts. |
alf.md |
The .alf run report the instrument writes at the end of every run — carried inside every .zpcr and fetchable over USB (usb.md §5.2). A *-delimited text file: run identity, the protocol as executed, an error summary, and one line per executed step with its setpoint, nominal hold and the wall-clock time that step began. Its error line is a one-way signal: an aborted run's report is byte-identical in shape to a completed one's (§6). The only per-step timing record the instrument produces, and 1:1 with the archive's .Plateread files. Implemented by packages/core/src/alf.ts, entry point parseAlf(bytes); zpcr.runReports() decodes every .alf entry in an archive, and alfThermalProfile() (§7.6) turns one into the run's block-temperature-against-time trace — ramp and hold split apart per step — which the app plots under the Protocol tab of a run, or of a standalone report opened on its own. |
dcal.md |
The .Dcal pure-dye calibration files — per-dye, per-plate-type fluorescence response across all 6 channels at 4 block temperatures, plus a matching empty-plate baseline; the only in-archive source of the channel→dye mapping (PRIMARYCHANNEL). Unencrypted ICFF container. Implemented by packages/core/src/dcal.ts, entry point parseDcal(bytes); zpcr.calibrations() decodes every .Dcal entry in an archive. |
calibration.md |
Channel→dye color separation — the algorithm that turns raw per-channel readings plus .Dcal calibration data into per-dye concentration estimates. Not a file format doc. §2.1 is which block temperature the matrix is sampled at — one per step where the block holds still, one per read where it moves, which is what makes a colour-separated melt ramp mean anything. Implemented by packages/core/src/calibration.ts (linear algebra in linalg.ts), entry points separateDyes() (one-shot) and the individual buildDyeResponseCurve/buildCalibrationMatrix/preprocessChannelReadings/separateChannels stages. |
threshold.md |
Baseline, threshold and Cq — how a per-dye amplification curve becomes a quantification cycle, or a reported non-amplification: baseline region selection, subtraction, threshold determination, the crossing rule, end-point RFU, and the app's controls over them. Not a file format doc. §1 states the problem; §3–§7 are the shipped algorithm, implemented by packages/core/src/baseline.ts (§3–§4, §7), packages/core/src/threshold.ts (§5–§6, entry point computeCq()) and packages/core/src/analysis.ts (computeCqTable(), the per-run entry point); §10 separates what is deliberately unimplemented from what is still unknown. Appendix A is the measurement against CFX Manager's own exported results for a committed sample — the Cq stage exactly (packages/core/test/cfxExport.test.ts), the baseline stage to within a cycle of window; Appendix B records the alternatives tried and how noisy curves broke them. |
melt.md |
Melt curves — the temperature ramp run after amplification, and the melting temperature (Tm) that identifies what was amplified. Not a file format doc. §1 states why the answer is a peak of the derivative rather than anything on the curve itself; §2 is the detection rule — a melt is a plate-read step whose reads sweep temperature, read from the reads alone, needing no .prcl, no MELT directive and no password; §3 the temperature axis, §4 the Savitzky–Golay smoothing and −dF/dT, §5 the peak and the one gate that keeps an empty well from being handed a confident Tm, §6 why a file that ships its own derivative (Biomeme) still produces one shared answer rather than a file-versus-computed toggle, §7 the two spaces a melt is read in — raw channels, and colour-separated dyes with the calibration matrix sampled at each read's own block temperature — and §8 what is deliberately not done. Implemented by packages/core/src/melt.ts (detection) and packages/core/src/meltAnalysis.ts (entry points computeMeltAnalysis() and meltCurvesFromFluor()). Appendix A is the measurement — 0.01 °C against 29.88 °C separating the two steps of one run, a 0.120 °C Tm spread across 27 replicates, agreement with the Biomeme device's own peak to 0.21 °C, and (§A.5) a 0.000 °C median shift between the two spaces; Appendix B records the signal-to-noise gate that looked obvious and does not work. |
pltd.md |
The .pltd plate-definition files — per-well fluorophores, target/gene, sample name and type, replicate, standard quantity. Encrypted + compressed XML container. Implemented by packages/core/src/pltd.ts, entry point parsePltd(bytes); zpcr.plates() decodes every plate in an archive. |
protocol.md |
The thermal-protocol language — every directive (METHOD, HOTLID, TEMP, GRAD, MELT, INC, RATE, EXT, BEEP, PLATEREAD, GOTO, …), what the instrument does with it, the 1-based step numbering GOTO counts in — steps count, modifiers don't — the PLATEREAD scan mask, the melt-curve idiom (§6) and how a protocol is typed at the instrument over USB (§7). Not a file format doc: it is the semantics shared by five carriers of the same text — .prcl/.pcrd's runDefinition, a .zpcr's ProtocolRunDefinition.txt and its .alf run report, .prcl.txt, and the USB command channel — whose differences §8 tabulates. Every rule is marked measured or stated; §9 collects what is still unknown, §10 is the editable form of the same language, and Appendix A is the step-numbering measurement, from the .alf execution log and live STATUS?. Implemented by packages/core/src/runDefinition.ts, entry point parseRunDefinition() (reading) and packages/core/src/protocolBuilder.ts, entry point ProtocolBuilder (writing — §10). |
prcl.md |
The .prcl thermal-cycling protocol files — lid/volume settings plus the ordered step list (hold, gradient, melt, goto, plate read), in the same encrypted-ZIP container as .pltd/.pcrd. The same protocol2 XML document .pcrd embeds. Implemented by packages/core/src/prcl.ts, entry point parsePrcl(bytes); parseProtocol2() is reused by pcrd.ts; zpcr.protocols() decodes every .prcl entry in an archive. §3.1 documents .prcl.txt, this project's own line-per-directive text form (formatRunDefinitionText/parseRunDefinitionText) — the one representation here that isn't reverse-engineered. Both forms also open as standalone files in the app (fileKind.ts's prcl and prcltxt). |
pcrd.md |
The .pcrd CFX Manager saved-experiment file — the whole run (plate setup, protocol, every plate read, RunInfo/runlog, plus analysis/UI state) as one large XML document, in the same encrypted-ZIP container as .pltd/.prcl. Implemented by packages/core/src/pcrd.ts, entry point parsePcrd(bytes), which decodes into the same Zpcr shape parseZpcr produces. |
zipcrypto.md |
The single-entry ZipCrypto-encrypted ZIP container shared by .pltd/.prcl and .pcrd: container variants, the fixed shared password, and the decrypt → inflate pipeline. Implemented by packages/core/src/zipcrypto.ts + inflate.ts. |
zpcrweb-json.md |
zpcrweb.json — the one entry this project writes into a .zpcr, holding the run's analysis parameters (thresholds, the auto-threshold multiplier, calibration normalization) so they travel with the file instead of sitting in one browser's IndexedDB — plus §1.1's experimentName, what the run is called, which no CFX format has a field for. Not reverse-engineered. Implemented by packages/core/src/zpcrwebSettings.ts (+ experiment.ts for the name's resolution and filename derivation); the app side is apps/web/src/state/analysisSettings.ts + analysisPersist.ts. |
pltcsv.md |
.plt.csv — this project's own plain-text plate format, a .pltd substitute since there's no encrypted-.pltd writer: one # key: value header block plus one CSV row per well, fluor columns labelled by dye name. §3.1's optional Vessel column is the one thing this format describes that no CFX format can — a block loaded with a mix of white and clear plastic, which the analysis then builds a separate calibration matrix for (calibration.md §3.1). Not reverse-engineered, not a CFX format. Implemented by packages/core/src/plateCsv.ts, entry points plateToCsv/parsePlateCsv; zpcr.ts's plates() reads a .plt.csv archive entry exactly like a .pltd one, and attachPlate.ts writes one in. §5 covers editing a plate (plateEdit.ts) and §5.1 the tab-separated block the app's plate editor copies and pastes, which interchanges with a spreadsheet (plateClipboard.ts). |
biomeme.md |
.bmrun — a Biomeme handheld device (Franklin/Two3/Three9) run export — the third input format, not a Bio-Rad format at all: no optical channels to unmix (fluorescence is per-dye already), and the device carries its own baseline/threshold/Cq alongside this library's. Self-describing JSON, not reverse-engineered. Implemented by packages/core/src/biomeme.ts, entry point parseBiomeme(bytes), which decodes into the same Zpcr shape parseZpcr/parsePcrd produce. |
usb.md |
The CFX96/C1000 instrument's own USB control protocol — not a file format: enumeration, the 5-byte application-layer frame, the ASCII command channel, the file upload/download mechanism a run is loaded and read back through, and (§7) the complete start-to-finish sequence of commands a run is made of, including §7.8 stopping a run in progress (measured against a live abort — a CANCEL sent in the ~6 s start window is accepted and ignored, and the abort is recorded only in the status register, never in the run's files) and §7.9 pausing. Reverse-engineered from USB captures (decoded with tools/usbpcap_decode.py), then implemented and driven against live hardware — §10 lists what the instrument corrected. Implemented by packages/core/src/usb/, entry point CfxDevice; driven by tools/cfx.mjs and the web app's Instrument view, which starts runs and follows them as they go. |
usb-traffic.md |
usb-traffic.bin — the USB traffic log the Instrument view records for a run it drove itself, and the second entry this project writes into a .zpcr. Compact binary records (payload bytes plus the three facts about a message that aren't in them) rather than the text they render to: 5.4× smaller stored and ~40× smaller once zipped, which is what makes attaching a session's whole wire log to a run cost single-digit KB. Not reverse-engineered. Implemented by packages/core/src/usbTraffic.ts — UsbTrafficRecorder writes, parseUsbTrafficLog reads, formatUsbTrafficLog renders the one text form there is; the app side is apps/web/src/state/useCfxDevice.ts (always recording) and the console's "save log" switch (whether the run's file keeps it). |
ARCHITECTURE.md |
Project-level design: isomorphic library goals, monorepo layout, input strategy. |
apps/web/ARCHITECTURE.md |
Web app design notes. |
icff.md, plateread.md, dcal.md, pltd.md, and prcl.md are marked fully decoded and
validated against the committed samples in samples/. The last of these to be interpreted was the
PLATEREAD operand — a scan mask, decoded in usb.md §3.1. pcrd.md's container, plate-read
data, and calibrationCollection are likewise fully decoded and cross-validated bit-for-bit
against the matching .zpcr; wellFactorsCollection is decoded too (it is the only source of the
per-well gain factors calibration.md §4.1 needs), and the remaining analysis-state subtrees
(dataAnalysisParameters, PersistedData, …) are mapped but not yet interpreted.
This is an npm-workspaces monorepo:
| Path | What |
|---|---|
packages/core |
@zpcrweb/core — the isomorphic parsing library |
apps/web |
@zpcrweb/web — the React web app (architecture) |
samples/ |
a committed sample .zpcr used by the test suite |
tools/ |
standalone CLIs and browser-automation scripts (index) |
- Decompresses a
.zpcrarchive in memory (viafflate). - Decodes every
.Platereadfile into the full 6-channel × 108-well fluorescence table (mean / std / min / max per well), plus cycle number, protocol step, timestamp, and every temperature the file carries (block, ambient, shuttle, sample, lid, and the fan set points) — extracted generically from the file's own schema, not a fixed list. - Parses
RunInfo.xmlinto typed run metadata (grid size, scan mask → channel count, serials, start time, …), with the full raw key/value map preserved. - Pivots the per-cycle data into well-centric amplification curves ready to plot.
- Decodes the
.alfrun report — the instrument's own log of what actually ran, one record per executed step, with the wall-clock time it began (and, derived from that, how long it took). - Recognises a run that stopped short —
runCompleteness()counts the plate reads the protocol's ownGOTOloops imply and compares them with what the archive holds. A cancelled run is marked as cancelled nowhere in its files (seeusb.md§7.8), so the read count is the only evidence there is. - Exposes a low-level archive API (
entries,bytes,text,hexDump) so any file — even ones we don't fully parse yet — can be inspected.
Node — read a file from disk:
import { zpcrFromFile } from "@zpcrweb/core";
const zpcr = await zpcrFromFile("20260720_FirstQualification.zpcr");
console.log(zpcr.metadata.baseSerialNumber); // "CT019138"
console.log(zpcr.reads.length); // 45
// well 3A (row A=0, col 3→index 2) on channel 2, last cycle
console.log(zpcr.reads.at(-1)!.wells[2]![0]![2]!.mean); // ~6852
// amplification curve for that well
const [curve] = zpcr.curves({ channel: 2 }).filter((c) => c.wellLabel === "A3");
console.log(curve.cycles, curve.mean);Browser — parse an uploaded file:
import { parseZpcr, zpcrFromBlob } from "@zpcrweb/core";
const file = input.files[0];
const zpcr = await zpcrFromBlob(file);
// or: const zpcr = parseZpcr(await file.arrayBuffer());| Export | Purpose |
|---|---|
parseZpcr(data) |
Parse raw bytes (Uint8Array | ArrayBuffer) → Zpcr |
zpcrFromFile(path) |
Node convenience: read + parse a file from disk |
zpcrFromBlob(blob) |
Browser convenience: read + parse a Blob/File |
Zpcr.metadata |
Typed RunMetadata from RunInfo.xml |
Zpcr.reads |
Ordered PlateRead[] (one per cycle) with .wells[channel][row][col] |
Zpcr.curves(opts) |
Well-centric WellCurve[] amplification curves |
Zpcr.darkCurves(step) |
Per-channel dark (LED-off) background across cycles |
Zpcr.temperatureCurves(step) |
Per-field TemperatureCurve[] (°C per cycle) |
Zpcr.ledCurves(step) |
Per-channel LedCurve[] — LED drive current (DAC counts per cycle) |
Zpcr.runReports() |
The archive's .alf run reports — the instrument's own per-step execution log |
runCompleteness(zpcr) |
Did the run finish its protocol? Expected vs. actual plate reads |
Zpcr.archive |
Low-level entries / bytes / text / hexDump access |
See ARCHITECTURE.md for design details and
packages/core/src/types.ts for the full typed surface.
A React + Vite web app for exploring .zpcr/.pcrd/Biomeme .bmrun files, in a
cyberpunk-dark theme. Drag-and-drop or click to load multiple files, switch between them, and
view each several ways:
- Overview — run metadata and thermal protocol.
- Curves — amplification curves for up to ~648 well/channel series (uPlot), with a channel bar and an 8×12 well matrix (row/column headers toggle whole rows/columns), Raw ↔ ΔRFU baselining, Linear ↔ Log scale, and a hover/tap tooltip showing mean/min/max/std.
- Raw files — a hex/ASCII (and text) viewer over every file in the archive.
The files you have open persist in IndexedDB across reloads, along with their view settings, so
a session reopens as you left it. Closing a file — the ✕ on its chip, or on its row in the Files tab
— is what removes it from the browser again; a file with edits that aren't on disk yet asks a second
time first. Clicking a row opens that file on the view it is for — curves for a run, the plate map
for a plate, the protocol for a protocol or a run report.
Everything that changes a reported number is written into the run's own file instead, so
it travels with it. Non-trivial logic lives in @zpcrweb/core —
see the web architecture notes.
The load button also offers Add folder…, which hands the app a directory and gets you out of the upload/download loop entirely. A file opened from that folder is read straight off your disk and written back to the same file when you change something — rename a run, drag a threshold — so there is no second copy in the browser to go stale, and nothing to remember to download. Change a file in another program and the app picks it up and refreshes on its own. Nothing in the folder is ever deleted by the app: removing a file from the app, or the folder itself, only forgets it.
The Files tab splits in two, scrolling independently: what this browser is holding on top, your folders underneath. The folders half is two columns — every folder's directory tree on the left, each under its own heading with its ↻ and ✕, and the files of whichever directory you've picked on the right (one above the other if the window is narrow). The two halves are a fixed 40/60 split, so expanding a folder never shoves the table around. It reads one directory at a time as you open it, so pointing the app at a folder holding years of runs costs nothing until you go looking. Newly-added files show up when you reopen the tab or press the folder's ↻.
When a directory holds more than one kind of file, the chips above the file list narrow it to a type — just the runs, just the plates — which is how you find the one file you want in a directory holding hundreds. Turning them all off gives the whole listing back.
Two limits worth knowing. Browsers don't tell a web page where a folder actually is, only what it's
called, so files are named by their path within the folder (runs/2026-07/a.zpcr) rather than by
a full disk path — and renaming one has to be done on disk, not in the app. And a run being recorded
live from an instrument is buffered in the browser until it finishes, then written to disk once,
rather than rewriting the whole archive after every cycle.
This needs the File System Access API, so it's Chromium-only today; the option simply isn't shown where it's unavailable.
The app can also read files straight out of a GitHub repository, which is how a link can point
somebody at a run in a lab's own repo without either of you sending files around. Open the app with
#github=<owner>/<repo> and that repository joins the Files view as another folder: the same tree,
listed one directory at a time as you open it, and the same checkbox that opens a file. Add
@<branch> (#github=owner/repo@main) to read it at a particular branch, tag or commit, and repeat
the key to bring in more than one repository.
The repository is remembered, so it is there on your next visit — with a ✕ that forgets it again —
and that is what makes the second half of such a link work: #github=owner/repo&file=runs/a.zpcr
names the file relative to the repository, so nobody has to write the repo's name twice. Once it
opens, the address bar holds the file's full name (owner/repo/runs/a.zpcr), which is the link to
copy for anyone who already has that repository.
Private repositories need a personal access token with read access to the repo's contents:
add #githubToken=<token> alongside the #github= key. It is kept in this browser and sent to
nobody but GitHub, and — like the decryption password below — the app takes it out of the address
bar the moment it reads it, so a URL copied afterwards carries no secret. A token also raises
GitHub's limit of 60 anonymous requests an hour, which browsing a large repository by hand can
otherwise reach. Public repositories need no token at all.
What you open is a copy. Nothing is committed back — an edit you make lives in this browser, as it would for a file you dropped in, and the repository is untouched. That is the one way this differs from a folder on your disk.
Below your own folders, the Files tab always ends with a samples folder: the example files that
ship with the app, the same ones in this repo's samples/ directory. Tick one to open it and it
behaves like any other file you loaded — you get a copy, so anything you change stays in your
browser and the example itself is untouched. It's filed under samples/<name>, the same way a file
out of a folder on your disk is named for its folder, so opening a sample can never displace a file
of the same name you loaded yourself. It can't be removed, and it's there on every browser,
including those that can't grant a folder on disk at all. The name samples is reserved for it: a
folder of your own picked under that name comes in as samples (2), since every folder in the list
needs a name of its own.
Nothing enumerates that list in source: vite.config.ts reads samples/ at build time and serves
what it finds, so adding a file to that directory (or deleting one) and rebuilding is all it takes.
Only formats the app can actually open are offered, by the same name filter a folder on your disk is
listed through — the .xml decodings and .zip exports beside them are not.
npm run dev -w @zpcrweb/web # start the dev server (http://localhost:5173)
npm run build -w @zpcrweb/web # production buildThe app is written against standard web APIs and intended to work on the latest version of any
major browser. In practice, development and manual testing happen almost entirely on
Chromium-based browsers (Chrome/Edge/etc.) — partly because the Instrument view's live USB
connection depends on WebUSB, and the disk-folder support above on the File System Access API,
neither of which anyone but Chromium implements, and partly just because that's
where the author tests. On first load, a non-Chromium browser gets a one-time dismissable
warning to that effect (components/BrowserWarningModal.tsx); everything except the Instrument
view is expected to work fine regardless.
#file=<name>&view=<overview|protocol|curves|plates|reference|calibration|raw|instrument|files|about>selects the active file and view. Every view the app can show is nameable here:filesis the open files and the folders (on disk, a GitHub repository, plus the bundled samples),instrumentthe USB panel, andaboutthe credits page — the last of these has no tab, and none of the three needs a file. A#file=naming a file that isn't open is looked for in the folders: a file's name is its folder and path (runs/2026-07/a.zpcr,samples/run.zpcr,owner/repo/runs/a.zpcr), so if that folder is one this browser has been granted — or the bundledsamples, or a repository it has been pointed at — the file is opened from it. Nothing is searched; only the directory the name points at is read. A folder's access usually has to be granted again after a page load, and the app says which folder a link is waiting on and takes you to the Files view to grant it; the file opens as soon as it can be read.#load=<url>fetches a file and loads it — the only key that can put a file into the app from somewhere it can't otherwise reach. It's consumed on load and replaced by the#file=the loaded file produces, so it never survives in the address bar. The welcome screen's "Load an example file" button is a link to one of these, pointing at a bundled sample (see "Sample files" below).#wells=<selector>enables a set of wells on the file the link points at, so a link can say "look at these wells" as well as which file and view —#file=run.zpcr&view=curves&wells=A1,C4-E8. A selector is a comma- (or space-) separated list of well labels and rectangular ranges, case-insensitive, withR1–R12addressing the reference row; unrecognized pieces are ignored. Like#load=it is consumed on arrival rather than kept in the address bar: it writes the selection into the file's own display settings, where clicking a well writes it too, so the selection persists and the user's next click replaces it.#github=<owner>/<repo>adds a GitHub repository as a folder, so a#file=in the same link is resolved against it —#github=RByers/MolBioLab&file=runs/a.zpcr.owner/repo@<ref>pins a branch, tag or commit, and the key may be repeated for more than one repository. Like#load=it is consumed on arrival rather than kept in the address bar, but what it produces persists: the repository stays in the Files view (with a ✕ to forget it), which is what lets the file's own full name —owner/repo/runs/a.zpcr, the name the app rewrites the hash to — resolve on the next reload. See "Working from a GitHub repository" above.#githubToken=<token>seeds a GitHub personal access token, which is what reaches a private repository (and raises the anonymous rate limit). Handled exactly like#cfxPassword=below — in the fragment because it is a secret, kept in this browser, and stripped from the address bar the moment it is read.#cfxPassword=<value>seeds the decryption password so encrypted files decrypt instead of sitting behind the prompt. URL-escape it — the password can contain characters like#.
All are hash keys in one query string (#cfxPassword=…&view=curves), parsed by
state/pltdPassword.ts, state/githubToken.ts, state/githubRepos.ts and state/urlHash.ts. The password is in the fragment because it's a
secret: fragments are never sent to the server, so they can't reach access logs, proxies, or a
Referer header — ?cfxPassword= would reach all three. The app strips the password from the
address bar the moment it reads it, so a URL copied afterwards can be shared safely. The legacy
?cfxPassword= query form still works but is deprecated; don't write new links with it. The same
reasoning is why #githubToken= is a fragment key with no query form at all.
Standalone scripts in tools/ — a CLI for the results table and for setting
up a run, live-instrument access over USB, and browser-automation checks for the web app:
node tools/zpcr.mjs samples/20260720_FirstQualification.zpcr results # results table as CSV
node tools/zpcr.mjs samples/20260720_FirstQualification.zpcr curves \
--wells B3 -o b3.png # curves as a PNG
node tools/zpcr.mjs 20260810_RVP.zpcr new --name "S190 RVP" \
--protocol Luna_qPCR.prcl.txt --plate S190.plt.csv # a new experiment
node tools/cfx.mjs info # talk to a live instrument
node tools/uishot.mjs # screenshot the web appzpcr.mjs new writes a pending experiment: a .zpcr holding the protocol, the experiment's
name and (optionally) the plate, with no plate reads in it yet. That is exactly what the web app's
"New experiment" creates, so the file opens there as a run waiting to be started — edit the
protocol in place if you like, then start it from the Instrument view. --plate may be left out
and attached later; --force overwrites an existing file, which the command otherwise refuses to
do.
See tools/README.md for what each script does; zpcr.mjs/cfx.mjs need a
built core first (npm run build).
npm install # install all workspaces
npm test # @zpcrweb/core Vitest suite
npm run build # build the library (ESM + CJS + .d.ts)
npm run typecheck # typecheck the library (core only)
npm run typecheck -w @zpcrweb/web # typecheck the web app
npm run dev -w @zpcrweb/web # web dev server → http://localhost:5173
# hot-reloads packages/core edits too (aliased to src, no tsup watch)
npm run build -w @zpcrweb/web # web production build (typechecks first)
npm run test:ui # browser assertions (needs Chrome, ~100s; not part of npm test)Local-only secrets (the CFX file decryption password) live in secrets.json, which is gitignored
and never committed — { "cfxPassword": "…" }. Tests load it via packages/core/test/secrets.ts;
only tests that explicitly exercise the decryption pipeline need it (describe.skipIf(!PW)
blocks) — everything else runs against the plaintext samples committed in samples/, where each
encrypted sample's decrypted payload sits beside it as <name>.xml, so the structural tests never
touch the crypto.
Two scripts drive a headless Chrome against the app, for two different jobs. They share
tools/harness.mjs (the CDP client and dev-server/Chrome plumbing), boot their own dev server and
browser on random ports (so they never collide with a server running on 5173), and load a sample
through the app's own file input.
tools/uishot.mjs — look at it. One command, ~5s:
node tools/uishot.mjs # Overview + Curves, default sample
node tools/uishot.mjs --views curves # one view, biggest and most legible
node tools/uishot.mjs --views overview,curves,plates,raw # four views in one sheet
node tools/uishot.mjs --file samples/20260720_Luna_noRT.pcrd --views overviewIt walks the requested views and writes one labelled contact-sheet PNG —
tools/.uishot/shot.png by default — tiling the views into a single image. It also reports
console errors, uncaught exceptions and failed page loads, which catch breakage a screenshot
can't show.
tools/uitest.mjs (npm run test:ui) — assert it. 406 browser assertions covering what
nothing else can catch: the two URL contracts — hash routing (deep links, back/forward,
unknown-file and invalid-view fallbacks) and password handling (stripped from both URL forms,
never leaked into the routing hash, an encrypted .pcrd still decrypting) — plus #load=, the
rule that every XML view uses the shared collapsible tree rather than a flat dump, the chart's
one-right-axis invariant (temperatures and LED currents can never both be on), the Calibration
view's default selection (only the run's in-use .Dcal files of the 28 it ships, the rest a click
away), and the rail chips' shared interaction contract (double-click solos, hovering a disabled
chip peeks at it only while hovered — including the Wells grid's row and column headers, which
peek at the whole row/column) plus the Reference view's overlay toggles and x-axis modes —
including that hiding the factory line doesn't break the ΔRFU baseline computed from the same
values, and that its min/max bands draw under that baseline but drop out (with a note) on the
column axis, where a point is a run mean with no spread of its own — and the Curves table's sort
contract (a header click re-orders, a second reverses, Well sorts by plate position rather than
label text, wells with no Cq stay at the bottom in both directions) and its Cq axis (every marker
at its own cycle, an empty axis where there is no Cq) and its End RFU column (sorts numerically,
and is a number of its own rather than a copy of ΔRFU) — and the rail's Cq range filter, whose top
stop is where the curves with no Cq live (an upper bound drops them, the lower handle parked
there leaves only them, and the handles clamp instead of crossing) — and that a .pcrd carrying a
hand-set threshold seeds it as a per-fluorophore override while a dye the file left on auto is
left alone (threshold.md §5.3: that one value is what makes the app reproduce CFX's own Cq) —
and the Overview tab's .prcl.txt download plus the Instrument view, which starts the one
experiment the file bar has selected (the bar meaning the same single selection it means
everywhere else) — a run that already holds results is refused a start and offered a clone instead,
Start appears only with an instrument attached, no name is collected on that tab at all, and the
protocol shown is the directives alone: the plain-English reading of each one belongs to the
Protocol tab, which is where it is asserted, save the PLATEREAD scan mask, whose channels and
sweep mode are a packed byte the text can't show and so stay on a sub-line of their own —
and the pending experiment flow both ways in: cloning a run yields a new file with the protocol
and plate and none of the results, under a bare-date name, with its experiment-name field required
and focused, and naming it renames the file to the <date>-<name> convention; "New experiment"
yields the same thing with neither half, offering Overview and Protocol (the latter an editor even
with nothing in it) but not the tabs it has nothing for —
and that a .prcl.txt is a document as
well as an input: it enables Overview, Protocol and Raw and nothing else, and its Overview reports
the protocol's own settings from the decode — and how a run is named: the file bar shows an
experiment name over a compact local timestamp rather than a file name, derived from the
filename's <date>_<time>_<serial>_<name> unless the format states one (Biomeme) or somebody
typed one, and a typed name has to survive a reload, which it can only do by reaching the
archive's own zpcrweb.json (clearing it reverts to the derived name rather than blanking it) —
plus a Biomeme run's Raw tab, which is its JSON document in the standalone (no file list) viewer —
and the .alf run report's decoded view, whose three most useful columns are things the file
never states: a step's wall-clock duration (differenced timestamps, so a 10 s hold reads as the
~22 s it occupied), its stage, and its plate read's index — which is asserted to be 1:1 with the
archive's own .Plateread entries, a claim spanning two file types that nothing else checks —
and the thermal profile that same report implies, plotted under a run's Protocol tab, where the
assertable part is the read numbering: all three numbers on a 3-read run, thinned to what fits on
a 45-read one but never losing the first or the last, and no section at all for a .pcrd, which
carries no report to plot — and a run that never reads the plate at all (an incubation or an
RT hold, whose protocol carries no PLATEREAD and which therefore writes no .Plateread files):
it opens on Overview with only Curves, Reference and Calibration greyed out, is not mistaken for a
run that stopped short, and neither view says anything about readings that don't exist —
and what happens when a file with unsaved edits is closed — an edited file (a rename is enough)
wears a dot and its ✕ arms into a waste bin that takes a second click, moving away disarms it,
neither state widens the chip, the flag survives a reload, and downloading the file puts it back to
closing on one click — and that the view bar is the same eight file views for every file, a tab
the file can't answer being disabled rather than dropped (ViewBar's enabled prop), including a
run still behind the password prompt, which greys out every file view rather than dropping the
strip — a claim about two files' headers matching that no single-file check can make — and the
model the app rests on: closing a file takes it out of IndexedDB then and there, so a reload comes
back holding exactly what was open and nothing else — plus the file
chip's icon, whose shape is what the file is (core's fileCategory, so the two plate encodings
draw alike) while its colour stays the encryption status, two claims a screenshot can only show
one at a time — and a GitHub repository as a folder, answered by the harness rather than by
GitHub (stubGithubApi) so the check owes nothing to a repository still existing: a
#github=owner/repo&file=runs/a.zpcr link resolving the bare name against the repository it names,
the token reaching a private one and being stripped from the address bar with it, the tree listing
one directory at a time and no more, a file opening as a copy with nothing to write back to, and
the ✕ forgetting the repository across a reload.
A screenshot can't show that the back button works, that a secret reached the address bar, that a hover put a curve back, or that eight rows are in the right order — and the core Vitest suite has no DOM.
Agent-facing guidance on when and how to run these — cost control, headless flags, sample loading
— lives in AGENTS.md.