From defd1797b8a18f895b5fe251b12e59f39061e442 Mon Sep 17 00:00:00 2001
From: Paula Ruiz-Rodriguez <50167687+Paururo@users.noreply.github.com>
Date: Sat, 17 Oct 2026 20:56:44 +0200
Subject: [PATCH] docs: replace the MkDocs site with a short website that gets
people running
The fourteen MkDocs pages become one landing page (an install line, a command builder for each pangenome tool, a one-minute example with the counts it gives, the example report to click through, the one setting that matters and the first questions people ask) and one filterable reference page, in plain HTML under docs/ with no build step. tools/check_site.py runs in the documentation workflow and fails on a dead link, an option the site shows that recore --help lacks, a wrong default, or an example run whose counts differ from the page's. The long-form pages stay as markdown rendered on GitHub, cleaned of MkDocs-only syntax, and the tutorial no longer says the report has to be served: it opens from disk in Chrome and in Safari's engine.
---
.github/CONTRIBUTING.md | 23 +-
.github/ISSUE_TEMPLATE/config.yml | 8 +-
.github/dependabot.yml | 31 -
.github/labeler.yml | 4 +-
.github/pull_request_template.md | 2 +-
.github/workflows/dependabot-auto-merge.yml | 10 +-
.github/workflows/dependency-review.yml | 2 +-
.github/workflows/docs.yml | 93 +-
.gitignore | 2 +-
CHANGELOG.md | 12 +
README.md | 36 +-
docs/.nojekyll | 0
docs/about/changelog.md | 5 -
docs/about/contributing.md | 24 +-
docs/assets/site.css | 1503 +++++++++++++++++++
docs/assets/site.js | 487 ++++++
docs/getting-started/inputs.md | 34 +-
docs/getting-started/installation.md | 71 -
docs/getting-started/quickstart.md | 156 --
docs/getting-started/tutorial.md | 25 +-
docs/guide/report.md | 62 +-
docs/how-it-works/limits.md | 20 +-
docs/index.html | 320 ++++
docs/index.md | 108 --
docs/reference.html | 256 ++++
docs/stylesheets/extra.css | 467 ------
mkdocs.yml | 161 --
requirements-docs.txt | 4 -
src/pipeline/output.rs | 10 +-
tools/check_site.py | 300 ++++
tools/make_logo_variants.py | 6 +-
31 files changed, 3041 insertions(+), 1201 deletions(-)
create mode 100644 docs/.nojekyll
delete mode 100644 docs/about/changelog.md
create mode 100644 docs/assets/site.css
create mode 100644 docs/assets/site.js
delete mode 100644 docs/getting-started/installation.md
delete mode 100644 docs/getting-started/quickstart.md
create mode 100644 docs/index.html
delete mode 100644 docs/index.md
create mode 100644 docs/reference.html
delete mode 100644 docs/stylesheets/extra.css
delete mode 100644 mkdocs.yml
delete mode 100644 requirements-docs.txt
create mode 100644 tools/check_site.py
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 6bc3f33..10effbd 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -95,13 +95,14 @@ a known fix.
These are exactly what continuous integration runs, and running them locally
first is the difference between one review round and three. The first three are
the CI workflow, in the order it runs them; the fourth belongs to the
-documentation workflow and only fires when the documentation changed:
+documentation workflow and only fires when the website or the code it describes
+changed:
```bash
cargo fmt --all -- --check
cargo clippy --locked --all-targets -- -D warnings
RECORE_REQUIRE_MINIMAP2=1 cargo test --locked --all-targets
-mkdocs build --strict # only if you touched docs/ or mkdocs.yml
+python3 tools/check_site.py --recore target/debug/recore --run-tutorial # docs/ or the CLI
```
Three things about that list are worth explaining, because they are not the
@@ -180,7 +181,7 @@ python3 -m http.server 8000 --directory out/
| `tests/integration.rs` | the end-to-end run, the one that needs minimap2 |
| `benchmarks/` | Python benchmarks and the tutorial dataset generator |
| `tools/` | `make_logo_variants.py`, which generates the logo variants and the report's dark-mode colour overrides |
-| `docs/` | the MkDocs Material site |
+| `docs/` | the website (plain HTML, no build step) and the long-form markdown pages |
| `.github/workflows/` | CI, documentation build, release builds |
Recore is a Rust command-line tool and nothing else. There is no desktop
@@ -234,15 +235,13 @@ you anything tends not to get run.
## Documentation
-```bash
-pip install -r requirements-docs.txt
-mkdocs serve
-```
-
-The documentation workflow builds with `mkdocs build --strict`, which turns a
-broken internal link or a page missing from the `nav` into a build failure
-instead of a dead link somebody finds six months later. Adding a page means
-adding it to `nav` in `mkdocs.yml`.
+The website is plain HTML in `docs/` (`index.html`, `reference.html` and
+`assets/`), with no build step: open `docs/index.html` to see a change. The
+documentation workflow runs `tools/check_site.py`, which fails on a dead link or
+anchor, an option `recore --help` does not have, an option missing from the
+reference, a default that differs from the binary's, or an example run whose
+counts differ from the page's, instead of a stale page somebody finds six
+months later. The long-form pages are markdown under `docs/` and read on GitHub.
`docs/assets/example-report.html` is the tutorial run's own output, linked from
the [Run report](../docs/guide/report.md) page. If you change the viewer, that
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index c08511e..7e1e318 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -3,10 +3,10 @@
# put them is worse than an unstructured issue.
blank_issues_enabled: true
-# The documentation links point at the markdown files in this repository rather
-# than at the built site. The MkDocs site exists and CI builds it on every push
-# to main, but GitHub Pages needs a public repository or a paid plan, and this
-# repository is private on the free plan, so the published URL would be a dead
+# The documentation links point at the files in this repository rather than at
+# a published site. The website in docs/ is plain HTML that GitHub Pages can
+# serve as it is, but Pages needs a public repository or a paid plan, and this
+# repository is private on the free plan, so a published URL would be a dead
# link on the busiest page in the tracker. GitHub renders these files in place,
# so the links work today and can be swapped for the site URL the day Pages is
# available.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 0a1eb9e..d733d02 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -64,37 +64,6 @@ updates:
patterns: ["*"]
update-types: ["major"]
- # The documentation site's build dependencies.
- #
- # The directory is "/" and not "/docs" because that is where
- # `requirements-docs.txt` actually lives. Dependabot finds it by reading the
- # requirements-shaped `.txt` files in the directory rather than by matching a
- # fixed filename, so the name being unusual costs nothing, but the directory
- # being wrong would cost everything: a `pip` entry pointed at a directory with
- # no requirements file in it does not fail, it simply never opens anything.
- #
- # This is the one ecosystem whose pull requests are checked by the docs build
- # as well as by CI, because `requirements-docs.txt` is in the path filter of
- # .github/workflows/docs.yml. The pin is `>=9.6,<10`, so mkdocs-material 10
- # arrives as a major that widens that range, and a major of the theme is
- # exactly the change that turns `mkdocs build --strict` red over a renamed
- # option. That one waits for a person; the 9.x releases do not.
- - package-ecosystem: pip
- directory: "/"
- schedule:
- interval: monthly
- open-pull-requests-limit: 2
- labels: ["dependencies", "documentation"]
- commit-message:
- prefix: "docs"
- groups:
- docs-routine:
- patterns: ["*"]
- update-types: ["minor", "patch"]
- docs-major:
- patterns: ["*"]
- update-types: ["major"]
-
# The actions the workflows themselves pin. These go stale silently: a
# deprecated runner image, or a Node version dropped from an action, is only
# noticed when a job starts warning, or fails.
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 3d2e5db..24ceb08 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -15,7 +15,7 @@
# viewer the JavaScript and CSS that ship inside the HTML report
# tests the test suites
# benchmarks the Python benchmark harnesses and the numbers they produce
-# documentation the MkDocs site, the README, the changelog, the figures
+# documentation the website and docs, the README, the changelog, the figures
# packaging what a release is made of: crate metadata, lockfile, licence
# ci everything under .github, plus the repository's own scripts
#
@@ -118,10 +118,8 @@ documentation:
- any-glob-to-any-file:
- "docs/**"
- "assets/**"
- - "mkdocs.yml"
- "README.md"
- "CHANGELOG.md"
- - "requirements-docs.txt"
# No CITATION.cff and no archived record to keep in step with either, so unlike
# other repositories in the lab there is nothing else to list here. The lockfile
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index c70e742..c87154c 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -41,5 +41,5 @@ here iterates the whole matrix rather than the visible window.
- [ ] `RECORE_REQUIRE_MINIMAP2=1 cargo test --locked --all-targets` passes.
- [ ] New behaviour has a test, or I have said above why it does not.
- [ ] `CHANGELOG.md` has an entry under `## [Unreleased]`, if a user would notice this.
-- [ ] Documentation under `docs/` is updated if the change is user-visible, and `mkdocs build --strict` passes.
+- [ ] Documentation under `docs/` is updated if the change is user-visible, and `python3 tools/check_site.py --recore target/debug/recore --run-tutorial` passes.
- [ ] If I touched the report: it still loads nothing from the network, it still draws only the visible window, and I regenerated `docs/assets/example-report.html` if the viewer changed.
diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml
index 140d5cb..2ee64bc 100644
--- a/.github/workflows/dependabot-auto-merge.yml
+++ b/.github/workflows/dependabot-auto-merge.yml
@@ -119,18 +119,18 @@ jobs:
# CI / Build, test, clippy, fmt
# rustfmt, clippy -D warnings, and the test suite
# with the end-to-end run made mandatory.
- # Documentation / Build mkdocs build --strict.
+ # Documentation / Build tools/check_site.py: the website against the code.
#
- # The docs workflow is filtered by path, so on most of these pull requests
- # it never runs and never appears in the list. An absent check is read as
+ # The docs workflow is filtered by path, so on some of these pull requests
+ # (an action bump) it never runs and never appears in the list. An absent check is read as
# satisfied, not as pending, because the alternative is a cargo bump
# sitting here until the timeout waiting for a job that was never going to
# start. Reading it that way is safe because both workflows hang off the
# same pull_request event: by the time CI has finished, minutes later,
# a docs run that was ever going to happen has long since registered
# itself as queued. And it does happen on the pull requests where it
- # matters, since requirements-docs.txt and docs.yml are both in that
- # workflow's path filter.
+ # matters, since Cargo.lock and docs.yml are both in that workflow's path
+ # filter.
#
# If the same check ever shows up twice for one commit, both copies are
# read: pending if either is pending, green only if every one is green.
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index 8eb9440..c4e74d5 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -11,7 +11,7 @@ name: Dependency review
# already in the tree is somebody else's conversation, and Dependabot is the one
# having it.
#
-# What it can see here is Cargo.lock and requirements-docs.txt. The lockfile is
+# What it can see here is Cargo.lock. The lockfile is
# committed, so the comparison is against the exact transitive versions the
# build actually uses rather than whatever the ranges in Cargo.toml would
# resolve to today. There is deliberately no package.json to review: the report
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 56b4e4e..3fecd4a 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -1,12 +1,20 @@
name: Documentation
-# Build the MkDocs Material site and publish it to GitHub Pages (gh-pages branch).
-# After the first successful run, set Settings -> Pages -> Source to "Deploy from
-# a branch" and pick the gh-pages branch (root).
+# Checks the website in docs/ against the code it describes.
#
-# Deploys on a push to main, so the published site tracks main and does not
-# redeploy on every push to a working branch. To publish on demand from any
-# branch, use the "Run workflow" button (workflow_dispatch).
+# The site is plain HTML with no build step (docs/index.html, reference.html and
+# assets/), so there is nothing to build or deploy: GitHub Pages can serve the
+# folder as it is, with Settings -> Pages -> "Deploy from a branch" -> main ->
+# /docs, once the repository is public or the organisation is on a paid plan.
+# What can still go wrong is drift, and tools/check_site.py fails on it: a dead
+# link or anchor, an option the pages show that `recore --help` does not have,
+# an option or a default the reference gets wrong, and an example run whose
+# counts are not the ones the page promises. That last part runs the "Try it"
+# commands for real, which is why this needs minimap2 and a build.
+#
+# The workflow and job names are what the Dependabot auto-merge gate waits for
+# ("Documentation" / "Build"); tools/check_automerge_gate.py fails CI if they
+# drift apart.
on:
push:
@@ -14,72 +22,45 @@ on:
- main
paths:
- "docs/**"
- - "mkdocs.yml"
- - "CHANGELOG.md"
- - "requirements-docs.txt"
+ - "src/**"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "benchmarks/make_tutorial_dataset.py"
+ - "tools/check_site.py"
- ".github/workflows/docs.yml"
pull_request:
paths:
- "docs/**"
- - "mkdocs.yml"
- - "CHANGELOG.md"
- - "requirements-docs.txt"
+ - "src/**"
+ - "Cargo.toml"
+ - "Cargo.lock"
+ - "benchmarks/make_tutorial_dataset.py"
+ - "tools/check_site.py"
- ".github/workflows/docs.yml"
workflow_dispatch:
permissions:
- contents: write
+ contents: read
concurrency:
group: docs-${{ github.ref }}
cancel-in-progress: true
+env:
+ CARGO_TERM_COLOR: always
+
jobs:
build:
name: Build
runs-on: ubuntu-latest
- timeout-minutes: 15
+ timeout-minutes: 20
steps:
- uses: actions/checkout@v7
-
- - uses: actions/setup-python@v7
- with:
- python-version: "3.x"
-
- - name: Install dependencies
- run: pip install -r requirements-docs.txt
-
- # --strict turns broken internal links and missing nav entries into
- # errors, so a pull request that breaks one fails here instead of
- # shipping a dead link.
- - name: Build
- run: mkdocs build --strict
-
- deploy:
- name: Deploy
- needs: build
- if: github.event_name != 'pull_request'
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@v7
- with:
- fetch-depth: 0
-
- - uses: actions/setup-python@v7
- with:
- python-version: "3.x"
-
- - name: Cache MkDocs build
- uses: actions/cache@v6
- with:
- key: mkdocs-material-${{ github.sha }}
- path: .cache
- restore-keys: |
- mkdocs-material-
-
- - name: Install dependencies
- run: pip install -r requirements-docs.txt
-
- - name: Build and deploy
- run: mkdocs gh-deploy --force --strict
+ - uses: dtolnay/rust-toolchain@stable
+ - uses: Swatinem/rust-cache@v2
+ - name: Install minimap2
+ run: sudo apt-get update && sudo apt-get install -y minimap2
+ - name: Build recore
+ run: cargo build --locked
+ - name: The website matches the code
+ run: python3 tools/check_site.py --recore target/debug/recore --run-tutorial
diff --git a/.gitignore b/.gitignore
index b8bfaa3..ba8528e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,7 +14,7 @@ __pycache__/
.DS_Store
Thumbs.db
-# Docs site (mkdocs build output)
+# Output of the MkDocs site this repository used to build
/site/
/.cache/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4736b05..905117f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -88,6 +88,18 @@ and Recore uses semantic versioning.
### Changed
+- The documentation site is now a short website instead of fourteen MkDocs
+ pages: an install line, a command builder that writes the run for your
+ pangenome tool and the files you have, a one-minute example with the counts it
+ should give, the example report to click through, the one setting that
+ matters, and the first questions people ask. A reference page lists every
+ option, output file, cell state and rescue type, with a filter. It is plain
+ HTML in `docs/` with no build step, so it opens from a clone and GitHub Pages
+ can serve the folder as it is. `tools/check_site.py` runs in CI and fails if a
+ link or anchor is dead, if the site shows an option `recore --help` does not
+ have, if the reference misses an option or gets a default wrong, or if the
+ example run no longer gives the counts the page promises. The long-form pages
+ stay as markdown, rendered on GitHub, and MkDocs is gone from the repository.
- `publication_status` in `orthology_evidence.tsv` follows `matrix_action`.
Evidence the matrix policy or a quality floor kept out reads `held_out_policy`
or `held_out_quality` instead of a `corrected...` value, and a strict
diff --git a/README.md b/README.md
index 055eac7..4db86ca 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
**Post-hoc rescue, audit, and quality control of pangenome presence/absence matrices.**
**Pure Rust · one external dependency · Linux and macOS**
-[Documentation](docs/) · [Install](#installation) · [Quick start](#quick-start) · [Tutorial](docs/getting-started/tutorial.md) · [Citation](#citation)
+[Documentation](#documentation) · [Install](#installation) · [Quick start](#quick-start) · [Tutorial](docs/getting-started/tutorial.md) · [Citation](#citation)
@@ -47,15 +47,15 @@ Needs [Rust 1.85+](https://www.rust-lang.org/tools/install) to build and
[minimap2](https://github.com/lh3/minimap2) on `PATH` to run.
```bash
-git clone https://github.com/PathoGenOmics-Lab/recore.git
-cd recore
-cargo build --release # binary at target/release/recore
+conda install -c bioconda minimap2 # or: brew install minimap2 / sudo apt install minimap2
+cargo install --git https://github.com/PathoGenOmics-Lab/recore --locked
+recore --version
```
-Tagged releases also ship Linux and macOS binaries on the
-[Releases](https://github.com/PathoGenOmics-Lab/recore/releases) page.
-[Installation](docs/getting-started/installation.md) covers conda, MMseqs2 and
-checking the install.
+Or build a clone with `cargo build --release` (the binary lands at
+`target/release/recore`). `--nsc-engine mmseqs2` also needs
+[MMseqs2](https://github.com/soedinglab/MMseqs2) on `PATH`; the default engine
+is built in.
## Quick start
@@ -95,13 +95,20 @@ with eleven false absences planted in it and walks through the run.
## Documentation
-Every page is a markdown file in this repository and reads here on GitHub.
+The quickest way in is the website in [`docs/`](docs/index.html): an install
+line, a command builder for your pangenome tool, a one-minute example, the
+example report to click through, and one reference page with every option,
+output file and cell state. It is plain HTML with no build step, so open
+`docs/index.html` from a clone; once the repository is public, GitHub Pages can
+serve it straight from `main` and `/docs`. `tools/check_site.py` keeps it
+honest in CI: every option it shows exists, the defaults match `recore --help`,
+and the example run gives the numbers the page promises.
+
+The long versions are markdown files in this repository and read here on GitHub.
| Start here | |
|---|---|
| [Tutorial](docs/getting-started/tutorial.md) | a full run on a generated dataset with known answers |
-| [Installation](docs/getting-started/installation.md) | requirements, from source, pre-built binaries |
-| [Quick start](docs/getting-started/quickstart.md) | the run most people want, and what to read first |
| [Inputs](docs/getting-started/inputs.md) | what each input is, and how the names have to match |
| Reference | |
@@ -122,13 +129,6 @@ Every page is a markdown file in this repository and reads here on GitHub.
Version history is in [CHANGELOG.md](CHANGELOG.md). Benchmark design and
reproduction is in [benchmarks/README.md](benchmarks/README.md).
-> [!NOTE]
-> The same pages build into a searchable site with an example report you can
-> open (`pip install -r requirements-docs.txt && mkdocs serve`). It is deployed
-> to `gh-pages` on every push to `main`, but GitHub Pages needs the repository
-> to be public, or the organisation to be on a paid plan, so
-> is not served yet.
-
If the documentation does not answer it, ask in
[Discussions](https://github.com/PathoGenOmics-Lab/recore/discussions). Issues
are for something that crashed, refused to run, or is plainly wrong.
diff --git a/docs/.nojekyll b/docs/.nojekyll
new file mode 100644
index 0000000..e69de29
diff --git a/docs/about/changelog.md b/docs/about/changelog.md
deleted file mode 100644
index 0536cbb..0000000
--- a/docs/about/changelog.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-title: Changelog
----
-
---8<-- "CHANGELOG.md"
diff --git a/docs/about/contributing.md b/docs/about/contributing.md
index ff79682..2fbb602 100644
--- a/docs/about/contributing.md
+++ b/docs/about/contributing.md
@@ -57,13 +57,26 @@ python3 -m http.server 8000 --directory out/
## Documentation
+The website is `docs/index.html` and `docs/reference.html`, styled by
+`docs/assets/site.css` and driven by `docs/assets/site.js`: plain files with no
+build step, so opening `docs/index.html` in a browser shows a change. Keep it
+short. It is there to get someone running in two minutes; the details belong in
+the markdown pages it links to.
+
+Check it the way CI does:
+
```bash
-pip install -r requirements-docs.txt
-mkdocs serve
+cargo build
+python3 tools/check_site.py --recore target/debug/recore --run-tutorial
```
-`docs/assets/example-report.html` is the report the [Run report](../guide/report.md)
-page links to. It is the tutorial run's own output, so regenerate it whenever the
+That fails on a dead link or anchor, an option the site mentions that
+`recore --help` does not have, an option missing from the reference, a default
+that differs from the binary's, an em-dash, and an example run whose counts
+differ from the ones the page shows.
+
+`docs/assets/example-report.html` is the report the website and the
+[Run report](../guide/report.md) page link to. It is the tutorial run's own output, so regenerate it whenever the
viewer changes:
```bash
@@ -84,9 +97,6 @@ one source file, so regenerate those together rather than editing either by hand
python3 tools/make_logo_variants.py
```
-CI builds with `mkdocs build --strict`, so a broken internal link or a page
-missing from the nav fails the build rather than shipping a dead link.
-
## Reporting a problem
Open an issue at
diff --git a/docs/assets/site.css b/docs/assets/site.css
new file mode 100644
index 0000000..0a16fc8
--- /dev/null
+++ b/docs/assets/site.css
@@ -0,0 +1,1503 @@
+/* Recore website. One stylesheet, no build step, no remote assets: the site
+ opens from a clone as well as from GitHub Pages. The cell colours are the
+ report's, so the page teaches the report's visual language. */
+
+:root {
+ --bg: #fbfbfa;
+ --bg-elev: #ffffff;
+ --bg-subtle: #f3f4f6;
+ --ink: #16171d;
+ --ink-soft: #3d3f48;
+ --muted: #5f616b;
+ --line: #e4e5e9;
+ --line-strong: #cfd1d7;
+ --brand-a: #02a79e;
+ --brand-b: #0265ba;
+ --brand-c: #3912c0;
+ --grad-a: #02a79e;
+ --grad-b: #0265ba;
+ --grad-c: #3912c0;
+ --accent: #0b5cc4;
+ --accent-hover: #0849a0;
+ --accent-soft: #e8f0fc;
+ --code-bg: #12141b;
+ --code-ink: #e6e8ef;
+ --code-muted: #8b90a0;
+ --code-flag: #7cc4ff;
+ --code-value: #f4c27a;
+ --present: #b3bbc7;
+ --rescue: #0f6fd6;
+ --nsc: #e07b00;
+ --kept: #c01048;
+ --absent: #eef0f3;
+ --absent-stroke: #d2d5db;
+ --ok: #0f7a52;
+ --radius: 12px;
+ --radius-sm: 8px;
+ --shadow: 0 1px 2px rgba(16, 18, 27, 0.06), 0 8px 24px rgba(16, 18, 27, 0.06);
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, "Helvetica Neue", Arial, sans-serif;
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
+ color-scheme: light;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --bg: #0c0d10;
+ --bg-elev: #15171c;
+ --bg-subtle: #1b1d23;
+ --ink: #f3f4f6;
+ --ink-soft: #d6d8de;
+ --muted: #a2a5b0;
+ --line: #262830;
+ --line-strong: #383b45;
+ --grad-a: #35d4c8;
+ --grad-b: #5aa8ff;
+ --grad-c: #a493ff;
+ --accent: #5aa2ff;
+ --accent-hover: #86baff;
+ --accent-soft: #14243b;
+ --code-bg: #090a0d;
+ --present: #5d6676;
+ --rescue: #4b9bff;
+ --nsc: #ff9a2e;
+ --kept: #ff4f80;
+ --absent: #1f2128;
+ --absent-stroke: #454856;
+ --ok: #3fcf8e;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
+ color-scheme: dark;
+ }
+}
+
+:root[data-theme="dark"] {
+ --bg: #0c0d10;
+ --bg-elev: #15171c;
+ --bg-subtle: #1b1d23;
+ --ink: #f3f4f6;
+ --ink-soft: #d6d8de;
+ --muted: #a2a5b0;
+ --line: #262830;
+ --line-strong: #383b45;
+ --grad-a: #35d4c8;
+ --grad-b: #5aa8ff;
+ --grad-c: #a493ff;
+ --accent: #5aa2ff;
+ --accent-hover: #86baff;
+ --accent-soft: #14243b;
+ --code-bg: #090a0d;
+ --present: #5d6676;
+ --rescue: #4b9bff;
+ --nsc: #ff9a2e;
+ --kept: #ff4f80;
+ --absent: #1f2128;
+ --absent-stroke: #454856;
+ --ok: #3fcf8e;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
+ color-scheme: dark;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+ scroll-padding-top: 76px;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font: 17px/1.55 var(--font);
+ -webkit-font-smoothing: antialiased;
+}
+
+a {
+ color: var(--accent);
+ text-decoration-thickness: 1px;
+ text-underline-offset: 3px;
+}
+
+a:hover {
+ color: var(--accent-hover);
+}
+
+:focus-visible {
+ outline: 3px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+code,
+kbd,
+pre {
+ font-family: var(--mono);
+ font-size: 0.88em;
+}
+
+:not(pre) > code {
+ background: var(--bg-subtle);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 0.08em 0.38em;
+ overflow-wrap: anywhere;
+}
+
+h1,
+h2,
+h3 {
+ line-height: 1.15;
+ letter-spacing: -0.02em;
+ margin: 0;
+}
+
+h2 {
+ font-size: clamp(1.6rem, 3.4vw, 2.2rem);
+ font-weight: 750;
+}
+
+h3 {
+ font-size: 1.08rem;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+}
+
+p {
+ margin: 0;
+}
+
+.skip {
+ position: absolute;
+ left: -999px;
+ top: 8px;
+ background: var(--bg-elev);
+ padding: 8px 12px;
+ border-radius: 8px;
+ z-index: 100;
+}
+
+.skip:focus {
+ left: 8px;
+}
+
+.wrap {
+ max-width: 1120px;
+ margin: 0 auto;
+ padding: 0 24px;
+}
+
+/* ── Header ── */
+
+.top {
+ position: sticky;
+ top: 0;
+ z-index: 50;
+ background: color-mix(in srgb, var(--bg) 86%, transparent);
+ -webkit-backdrop-filter: saturate(1.4) blur(10px);
+ backdrop-filter: saturate(1.4) blur(10px);
+ border-bottom: 1px solid var(--line);
+}
+
+.top .wrap {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ height: 60px;
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ flex: none;
+}
+
+.brand img {
+ height: 34px;
+ width: auto;
+ display: block;
+}
+
+.logo-dark {
+ display: none !important;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .logo-light {
+ display: none !important;
+ }
+
+ :root:not([data-theme="light"]) .logo-dark {
+ display: block !important;
+ }
+}
+
+:root[data-theme="dark"] .logo-light {
+ display: none !important;
+}
+
+:root[data-theme="dark"] .logo-dark {
+ display: block !important;
+}
+
+.nav {
+ display: flex;
+ gap: 4px;
+ margin-left: auto;
+ align-items: center;
+}
+
+.nav a {
+ color: var(--ink-soft);
+ text-decoration: none;
+ font-size: 0.93rem;
+ padding: 7px 10px;
+ border-radius: 8px;
+}
+
+.nav a:hover,
+.nav a[aria-current="page"] {
+ color: var(--ink);
+ background: var(--bg-subtle);
+}
+
+.icon-btn {
+ display: inline-grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ background: var(--bg-elev);
+ color: var(--ink-soft);
+ cursor: pointer;
+ flex: none;
+}
+
+.icon-btn:hover {
+ color: var(--ink);
+ border-color: var(--line-strong);
+}
+
+.icon-btn svg {
+ width: 18px;
+ height: 18px;
+}
+
+@media (max-width: 760px) {
+ .nav .nav-optional {
+ display: none;
+ }
+}
+
+/* ── Buttons ── */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 11px 18px;
+ border-radius: 10px;
+ font-weight: 650;
+ font-size: 0.98rem;
+ text-decoration: none;
+ border: 1px solid transparent;
+ cursor: pointer;
+ line-height: 1.2;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: #fff;
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+ color: #fff;
+}
+
+:root[data-theme="dark"] .btn-primary,
+:root[data-theme="dark"] .btn-primary:hover {
+ color: #0a1220;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .btn-primary,
+ :root:not([data-theme="light"]) .btn-primary:hover {
+ color: #0a1220;
+ }
+}
+
+.btn-ghost {
+ background: var(--bg-elev);
+ color: var(--ink);
+ border-color: var(--line-strong);
+}
+
+.btn-ghost:hover {
+ color: var(--ink);
+ border-color: var(--ink-soft);
+}
+
+/* ── Hero ── */
+
+.hero {
+ padding: 64px 0 40px;
+}
+
+.hero .wrap {
+ display: grid;
+ grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
+ gap: 48px;
+ align-items: center;
+}
+
+.eyebrow {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.85rem;
+ font-weight: 650;
+ color: var(--muted);
+ margin-bottom: 18px;
+}
+
+.eyebrow .dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, var(--brand-a), var(--brand-c));
+}
+
+.hero h1 {
+ font-size: clamp(2.3rem, 5.4vw, 3.9rem);
+ font-weight: 800;
+ letter-spacing: -0.035em;
+ line-height: 1.02;
+}
+
+.hero h1 .grad {
+ background: linear-gradient(100deg, var(--grad-a) 0%, var(--grad-b) 45%, var(--grad-c) 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+.lead {
+ font-size: 1.16rem;
+ color: var(--ink-soft);
+ margin-top: 20px;
+ max-width: 34em;
+}
+
+.actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 28px;
+}
+
+.reads {
+ margin-top: 22px;
+ font-size: 0.9rem;
+ color: var(--muted);
+}
+
+.reads b {
+ color: var(--ink-soft);
+ font-weight: 600;
+}
+
+/* The animated matrix: genes across, genomes down. */
+
+.demo {
+ background: var(--bg-elev);
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 22px 22px 18px;
+ box-shadow: var(--shadow);
+}
+
+.demo-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 12px;
+ font-size: 0.82rem;
+ color: var(--muted);
+ margin-bottom: 14px;
+}
+
+.demo-step {
+ font-weight: 650;
+ color: var(--ink-soft);
+}
+
+.grid {
+ --cols: 16;
+ display: grid;
+ grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
+ gap: 4px;
+ position: relative;
+}
+
+.cell {
+ aspect-ratio: 1;
+ border-radius: 4px;
+ background: var(--present);
+ transition: background-color 0.35s ease, box-shadow 0.35s ease, transform 0.35s ease, opacity 0.35s ease;
+}
+
+.cell.gap {
+ background: var(--absent);
+ box-shadow: inset 0 0 0 1px var(--absent-stroke);
+}
+
+.cell.scan {
+ box-shadow: inset 0 0 0 2px var(--accent);
+ transform: scale(1.08);
+}
+
+.cell.rescued {
+ background: var(--rescue);
+ box-shadow: none;
+ transform: scale(1);
+}
+
+.cell.kept {
+ background: var(--absent);
+ box-shadow: inset 0 0 0 2px var(--kept);
+ background-image: repeating-linear-gradient(135deg, transparent 0 3px, color-mix(in srgb, var(--kept) 45%, transparent) 3px 4.5px);
+ transform: scale(1);
+}
+
+.cell.searched {
+ transform: scale(1);
+}
+
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 16px;
+ margin-top: 16px;
+ font-size: 0.82rem;
+ color: var(--muted);
+}
+
+.legend span {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+}
+
+.chip {
+ width: 12px;
+ height: 12px;
+ border-radius: 3px;
+ display: inline-block;
+ flex: none;
+}
+
+.chip.present {
+ background: var(--present);
+}
+
+.chip.rescued {
+ background: var(--rescue);
+}
+
+.chip.kept {
+ box-shadow: inset 0 0 0 2px var(--kept);
+ background-image: repeating-linear-gradient(135deg, transparent 0 2px, color-mix(in srgb, var(--kept) 45%, transparent) 2px 3px);
+}
+
+.chip.absent {
+ background: var(--absent);
+ box-shadow: inset 0 0 0 1px var(--absent-stroke);
+}
+
+.chip.nsc {
+ background: var(--nsc);
+}
+
+.chip.notsearched {
+ background: var(--absent);
+ box-shadow: inset 0 0 0 1px var(--absent-stroke);
+ background-image: repeating-linear-gradient(45deg, transparent 0 2px, var(--absent-stroke) 2px 3px);
+}
+
+.tally {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ margin-top: 14px;
+ border-top: 1px solid var(--line);
+ padding-top: 14px;
+}
+
+.tally div {
+ font-size: 0.8rem;
+ color: var(--muted);
+ line-height: 1.3;
+}
+
+.tally b {
+ display: block;
+ font-size: 1.5rem;
+ color: var(--ink);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.02em;
+}
+
+.tally .t-rescued b {
+ color: var(--rescue);
+}
+
+.tally .t-kept b {
+ color: var(--kept);
+}
+
+@media (max-width: 900px) {
+ .hero .wrap {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 36px;
+ }
+
+ .hero {
+ padding-top: 40px;
+ }
+}
+
+/* ── Sections ── */
+
+.section {
+ padding: 72px 0;
+ border-top: 1px solid var(--line);
+}
+
+.section-head {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 4px 16px;
+ align-items: baseline;
+ margin-bottom: 30px;
+}
+
+.num {
+ grid-row: span 2;
+ display: inline-grid;
+ place-items: center;
+ width: 38px;
+ height: 38px;
+ border-radius: 50%;
+ font-weight: 750;
+ font-size: 0.95rem;
+ color: #fff;
+ background: linear-gradient(135deg, #0274b8, var(--brand-c));
+ align-self: start;
+ margin-top: 2px;
+}
+
+.section-head p {
+ color: var(--muted);
+ font-size: 1.04rem;
+}
+
+.cols-2 {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 20px;
+}
+
+@media (max-width: 820px) {
+ .cols-2 {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.card {
+ background: var(--bg-elev);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 22px;
+}
+
+.card h3 {
+ margin-bottom: 8px;
+}
+
+.card p {
+ color: var(--ink-soft);
+ font-size: 0.97rem;
+}
+
+.step-label {
+ font-size: 0.8rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+ margin-bottom: 10px;
+}
+
+.note {
+ font-size: 0.9rem;
+ color: var(--muted);
+ margin-top: 12px;
+}
+
+/* ── Code ── */
+
+.code {
+ position: relative;
+ background: var(--code-bg);
+ color: var(--code-ink);
+ border-radius: var(--radius-sm);
+ border: 1px solid color-mix(in srgb, var(--code-bg) 80%, var(--line-strong));
+}
+
+.code pre {
+ margin: 0;
+ padding: 16px 64px 16px 18px;
+ overflow-x: auto;
+ line-height: 1.6;
+ font-size: 0.86rem;
+ tab-size: 2;
+}
+
+.code .p {
+ color: var(--code-muted);
+ user-select: none;
+}
+
+.code .c {
+ color: var(--code-muted);
+}
+
+.code .f {
+ color: var(--code-flag);
+}
+
+.code .v {
+ color: var(--code-value);
+}
+
+.copy {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.16);
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--code-ink);
+ font: 600 0.78rem var(--font);
+ padding: 6px 10px;
+ border-radius: 7px;
+ cursor: pointer;
+}
+
+.copy:hover {
+ background: rgba(255, 255, 255, 0.14);
+}
+
+.copy.done {
+ color: #8ef0bf;
+ border-color: rgba(142, 240, 191, 0.4);
+}
+
+.out {
+ margin-top: 10px;
+ background: var(--bg-subtle);
+ border: 1px dashed var(--line-strong);
+ border-radius: var(--radius-sm);
+ padding: 12px 16px;
+ font-family: var(--mono);
+ font-size: 0.85rem;
+ color: var(--ink-soft);
+ white-space: pre;
+ overflow-x: auto;
+}
+
+.out .ok {
+ color: var(--ok);
+ font-weight: 700;
+}
+
+/* ── Tabs ── */
+
+.tabs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 10px;
+ flex-wrap: wrap;
+}
+
+.tab {
+ border: 1px solid var(--line);
+ background: var(--bg-elev);
+ color: var(--ink-soft);
+ font: 600 0.86rem var(--font);
+ padding: 6px 12px;
+ border-radius: 8px;
+ cursor: pointer;
+}
+
+.tab[aria-selected="true"] {
+ background: var(--ink);
+ border-color: var(--ink);
+ color: var(--bg);
+}
+
+.tabpanel[hidden] {
+ display: none;
+}
+
+/* ── Command builder ── */
+
+.builder {
+ display: grid;
+ grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
+ gap: 28px;
+ align-items: start;
+}
+
+@media (max-width: 900px) {
+ .builder {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.q {
+ margin-bottom: 22px;
+}
+
+.q-title {
+ font-weight: 700;
+ margin-bottom: 10px;
+ font-size: 0.98rem;
+}
+
+.seg {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.seg button {
+ border: 1px solid var(--line-strong);
+ background: var(--bg-elev);
+ color: var(--ink);
+ font: 600 0.9rem var(--font);
+ padding: 8px 13px;
+ border-radius: 9px;
+ cursor: pointer;
+}
+
+.seg button[aria-pressed="true"] {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+
+:root[data-theme="dark"] .seg button[aria-pressed="true"] {
+ color: #0a1220;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .seg button[aria-pressed="true"] {
+ color: #0a1220;
+ }
+}
+
+.opt {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 2px 10px;
+ align-items: start;
+ padding: 11px 12px;
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ background: var(--bg-elev);
+ margin-bottom: 8px;
+ cursor: pointer;
+}
+
+.opt:hover {
+ border-color: var(--line-strong);
+}
+
+.opt input[type="checkbox"],
+.opt input[type="radio"] {
+ width: 18px;
+ height: 18px;
+ margin: 2px 0 0;
+ accent-color: var(--accent);
+}
+
+.opt b {
+ font-weight: 650;
+ font-size: 0.95rem;
+}
+
+.opt small {
+ grid-column: 2;
+ color: var(--muted);
+ font-size: 0.84rem;
+ line-height: 1.4;
+}
+
+.opt .inline-input {
+ grid-column: 2;
+ margin-top: 6px;
+}
+
+.inline-input,
+.num-input {
+ font: 0.9rem var(--mono);
+ padding: 6px 9px;
+ border: 1px solid var(--line-strong);
+ border-radius: 7px;
+ background: var(--bg);
+ color: var(--ink);
+ width: 100%;
+ max-width: 220px;
+}
+
+.num-input {
+ width: 84px;
+}
+
+.row-inline {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.explain {
+ margin: 14px 0 0;
+ padding: 0;
+ list-style: none;
+ display: grid;
+ gap: 6px;
+ font-size: 0.88rem;
+}
+
+.explain li {
+ display: grid;
+ grid-template-columns: minmax(150px, max-content) 1fr;
+ gap: 12px;
+ color: var(--ink-soft);
+}
+
+.explain code {
+ white-space: nowrap;
+ justify-self: start;
+}
+
+@media (max-width: 560px) {
+ .explain li {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 2px;
+ }
+}
+
+.tree {
+ margin-top: 14px;
+ background: var(--bg-elev);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 12px 14px;
+ font: 0.83rem/1.65 var(--mono);
+ color: var(--ink-soft);
+ white-space: pre;
+ overflow-x: auto;
+}
+
+.tree .hl {
+ color: var(--accent);
+ font-weight: 700;
+}
+
+.tree .cm {
+ color: var(--muted);
+}
+
+.tool-note {
+ margin-top: 10px;
+ font-size: 0.86rem;
+ color: var(--muted);
+}
+
+.tool-note:empty {
+ display: none;
+}
+
+/* ── Output cards ── */
+
+.files {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px;
+}
+
+@media (max-width: 980px) {
+ .files {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 520px) {
+ .files {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.file {
+ background: var(--bg-elev);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.file .ico {
+ width: 34px;
+ height: 34px;
+ border-radius: 9px;
+ display: grid;
+ place-items: center;
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+
+.file .ico svg {
+ width: 19px;
+ height: 19px;
+}
+
+.file code {
+ font-size: 0.78rem;
+ word-break: break-all;
+ background: none;
+ border: none;
+ padding: 0;
+ color: var(--ink);
+ font-weight: 600;
+}
+
+.file p {
+ font-size: 0.9rem;
+ color: var(--muted);
+ line-height: 1.45;
+}
+
+/* ── Report preview ── */
+
+.report-frame {
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ overflow: hidden;
+ box-shadow: var(--shadow);
+ background: var(--bg-elev);
+}
+
+.report-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--line);
+ font-size: 0.82rem;
+ color: var(--muted);
+}
+
+.report-bar i {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--line-strong);
+ display: inline-block;
+}
+
+.report-bar span {
+ margin-left: 6px;
+ font-family: var(--mono);
+}
+
+.report-bar a {
+ margin-left: auto;
+ font-weight: 600;
+ font-size: 0.85rem;
+}
+
+.report-stage {
+ position: relative;
+}
+
+.report-stage:not(.live) iframe {
+ pointer-events: none;
+}
+
+.report-cover {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: end center;
+ padding-bottom: 28px;
+ border: 0;
+ background: linear-gradient(to bottom, transparent 60%, color-mix(in srgb, var(--bg) 65%, transparent));
+ cursor: pointer;
+ font: inherit;
+}
+
+.report-cover span {
+ background: var(--accent);
+ color: #fff;
+ padding: 11px 18px;
+ border-radius: 10px;
+ font-weight: 650;
+ box-shadow: var(--shadow);
+}
+
+:root[data-theme="dark"] .report-cover span {
+ color: #0a1220;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .report-cover span {
+ color: #0a1220;
+ }
+}
+
+.report-stage.live .report-cover {
+ display: none;
+}
+
+.report-mobile {
+ display: none;
+ padding: 32px 20px;
+ text-align: center;
+ color: var(--ink-soft);
+}
+
+@media (max-width: 700px) {
+ .report-stage {
+ display: none;
+ }
+
+ .report-mobile {
+ display: grid;
+ gap: 14px;
+ justify-items: center;
+ }
+}
+
+.report-frame iframe {
+ display: block;
+ width: 100%;
+ height: 720px;
+ border: 0;
+ background: #fff;
+}
+
+.hints {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+ margin-top: 18px;
+}
+
+.hints div {
+ font-size: 0.92rem;
+ color: var(--ink-soft);
+}
+
+.hints b {
+ display: block;
+ color: var(--ink);
+ margin-bottom: 2px;
+}
+
+@media (max-width: 760px) {
+ .report-frame iframe {
+ height: 560px;
+ }
+
+ .hints {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* ── Policies ── */
+
+.policies {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 14px;
+}
+
+@media (max-width: 820px) {
+ .policies {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.policy {
+ position: relative;
+ background: var(--bg-elev);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 20px;
+}
+
+.policy.recommended {
+ border: 2px solid var(--accent);
+ padding: 19px;
+}
+
+.badge {
+ position: absolute;
+ top: -11px;
+ left: 18px;
+ background: var(--accent);
+ color: #fff;
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ padding: 3px 9px;
+ border-radius: 999px;
+}
+
+:root[data-theme="dark"] .badge {
+ color: #0a1220;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) .badge {
+ color: #0a1220;
+ }
+}
+
+.policy h3 code {
+ font-size: 1.02rem;
+ background: none;
+ border: none;
+ padding: 0;
+}
+
+.policy p {
+ color: var(--ink-soft);
+ font-size: 0.93rem;
+ margin-top: 8px;
+}
+
+.policy .meter {
+ display: flex;
+ gap: 3px;
+ margin-top: 14px;
+}
+
+.policy .meter i {
+ flex: 1;
+ height: 8px;
+ border-radius: 3px;
+ background: var(--absent);
+ box-shadow: inset 0 0 0 1px var(--absent-stroke);
+}
+
+.policy .meter i.on {
+ background: var(--rescue);
+ box-shadow: none;
+}
+
+.policy .meter-label {
+ font-size: 0.8rem;
+ color: var(--muted);
+ margin-top: 6px;
+}
+
+/* ── FAQ ── */
+
+.faq {
+ display: grid;
+ gap: 10px;
+ max-width: 860px;
+}
+
+.faq details {
+ background: var(--bg-elev);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 0 16px;
+}
+
+.faq summary {
+ cursor: pointer;
+ padding: 14px 0;
+ font-weight: 650;
+ list-style: none;
+ display: flex;
+ justify-content: space-between;
+ gap: 14px;
+}
+
+.faq summary > span {
+ flex: 1;
+}
+
+.faq summary::-webkit-details-marker {
+ display: none;
+}
+
+.faq summary::after {
+ content: "+";
+ color: var(--muted);
+ font-weight: 500;
+ font-size: 1.2rem;
+ line-height: 1;
+}
+
+.faq details[open] summary::after {
+ content: "\2212";
+}
+
+.faq .ans {
+ padding: 0 0 16px;
+ color: var(--ink-soft);
+ font-size: 0.95rem;
+ display: grid;
+ gap: 10px;
+}
+
+/* ── Footer ── */
+
+.foot {
+ border-top: 1px solid var(--line);
+ padding: 40px 0 56px;
+ font-size: 0.9rem;
+ color: var(--muted);
+}
+
+.foot .wrap {
+ display: grid;
+ grid-template-columns: 1.4fr 1fr 1fr;
+ gap: 28px;
+}
+
+.foot h3 {
+ font-size: 0.85rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--ink-soft);
+ margin-bottom: 10px;
+}
+
+.foot ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 6px;
+}
+
+.foot blockquote {
+ margin: 0;
+ color: var(--ink-soft);
+}
+
+@media (max-width: 760px) {
+ .foot .wrap {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* ── Reference page ── */
+
+.ref-hero {
+ padding: 48px 0 8px;
+}
+
+.ref-hero h1 {
+ font-size: clamp(2rem, 4.6vw, 3rem);
+ font-weight: 800;
+ letter-spacing: -0.03em;
+}
+
+.ref-hero p {
+ color: var(--muted);
+ margin-top: 10px;
+ font-size: 1.06rem;
+}
+
+.ref-nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 22px;
+}
+
+.ref-nav a {
+ text-decoration: none;
+ border: 1px solid var(--line);
+ background: var(--bg-elev);
+ color: var(--ink-soft);
+ font-size: 0.9rem;
+ font-weight: 600;
+ padding: 7px 12px;
+ border-radius: 9px;
+}
+
+.ref-nav a:hover {
+ color: var(--ink);
+ border-color: var(--line-strong);
+}
+
+.ref-section {
+ padding: 48px 0 8px;
+}
+
+.ref-section h2 {
+ margin-bottom: 8px;
+}
+
+.ref-section > .wrap > p {
+ color: var(--muted);
+ margin-bottom: 18px;
+}
+
+.filter {
+ width: 100%;
+ max-width: 420px;
+ font: 0.95rem var(--font);
+ padding: 10px 14px;
+ border: 1px solid var(--line-strong);
+ border-radius: 10px;
+ background: var(--bg-elev);
+ color: var(--ink);
+ margin-bottom: 18px;
+}
+
+.table-wrap {
+ overflow-x: auto;
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ background: var(--bg-elev);
+}
+
+table {
+ border-collapse: collapse;
+ width: 100%;
+ font-size: 0.92rem;
+}
+
+th,
+td {
+ text-align: left;
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--line);
+ vertical-align: top;
+}
+
+th {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--muted);
+ background: var(--bg-subtle);
+ font-weight: 700;
+}
+
+tr:last-child td {
+ border-bottom: none;
+}
+
+td code {
+ white-space: nowrap;
+}
+
+td.wrap-code code {
+ white-space: normal;
+}
+
+.group-row td {
+ background: var(--bg-subtle);
+ font-weight: 700;
+ color: var(--ink-soft);
+ font-size: 0.85rem;
+}
+
+.def {
+ font-family: var(--mono);
+ font-size: 0.84rem;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.state {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.yes {
+ color: var(--ok);
+ font-weight: 700;
+}
+
+.no-results {
+ padding: 16px;
+ color: var(--muted);
+ display: none;
+}
+
+.deep {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+}
+
+@media (max-width: 860px) {
+ .deep {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+.deep a {
+ display: block;
+ text-decoration: none;
+ background: var(--bg-elev);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 14px 16px;
+ color: var(--ink);
+}
+
+.deep a:hover {
+ border-color: var(--accent);
+}
+
+.deep a b {
+ display: block;
+ margin-bottom: 2px;
+}
+
+.deep a span {
+ font-size: 0.86rem;
+ color: var(--muted);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+
+ .cell {
+ transition: none;
+ }
+}
diff --git a/docs/assets/site.js b/docs/assets/site.js
new file mode 100644
index 0000000..d5f0192
--- /dev/null
+++ b/docs/assets/site.js
@@ -0,0 +1,487 @@
+/* Recore website: theme, tabs, copy buttons, the command builder, the
+ animated matrix and the reference filter. Plain JavaScript, no build step. */
+(function () {
+ "use strict";
+
+ var reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+ /* ── Theme ── */
+
+ function systemDark() {
+ return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
+ }
+
+ function currentTheme() {
+ var set = document.documentElement.dataset.theme;
+ if (set === "light" || set === "dark") return set;
+ return systemDark() ? "dark" : "light";
+ }
+
+ document.querySelectorAll("[data-theme-toggle]").forEach(function (button) {
+ button.addEventListener("click", function () {
+ var next = currentTheme() === "dark" ? "light" : "dark";
+ document.documentElement.dataset.theme = next;
+ try {
+ localStorage.setItem("recore-theme", next);
+ } catch (e) {
+ /* private mode: the choice lasts for this page only */
+ }
+ });
+ });
+
+ /* ── Tabs ── */
+
+ document.querySelectorAll('[role="tablist"]').forEach(function (list) {
+ var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
+ function select(tab) {
+ tabs.forEach(function (t) {
+ var on = t === tab;
+ t.setAttribute("aria-selected", on ? "true" : "false");
+ t.tabIndex = on ? 0 : -1;
+ var panel = document.getElementById(t.getAttribute("aria-controls"));
+ if (panel) panel.hidden = !on;
+ });
+ }
+ tabs.forEach(function (tab, i) {
+ tab.tabIndex = tab.getAttribute("aria-selected") === "true" ? 0 : -1;
+ tab.addEventListener("click", function () {
+ select(tab);
+ });
+ tab.addEventListener("keydown", function (event) {
+ var step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
+ if (!step) return;
+ event.preventDefault();
+ var next = tabs[(i + step + tabs.length) % tabs.length];
+ select(next);
+ next.focus();
+ });
+ });
+ });
+
+ /* ── Copy buttons ── */
+
+ function plainText(node) {
+ var clone = node.cloneNode(true);
+ clone.querySelectorAll(".p").forEach(function (prompt) {
+ prompt.remove();
+ });
+ return clone.textContent.replace(/\s+$/, "") + "\n";
+ }
+
+ function copyText(text) {
+ if (navigator.clipboard && window.isSecureContext) {
+ return navigator.clipboard.writeText(text);
+ }
+ return new Promise(function (resolve, reject) {
+ var area = document.createElement("textarea");
+ area.value = text;
+ area.setAttribute("readonly", "");
+ area.style.position = "fixed";
+ area.style.opacity = "0";
+ document.body.appendChild(area);
+ area.select();
+ try {
+ document.execCommand("copy") ? resolve() : reject();
+ } catch (e) {
+ reject(e);
+ }
+ area.remove();
+ });
+ }
+
+ function addCopy(button, source) {
+ button.addEventListener("click", function () {
+ copyText(plainText(source)).then(
+ function () {
+ button.textContent = "Copied";
+ button.classList.add("done");
+ setTimeout(function () {
+ button.textContent = "Copy";
+ button.classList.remove("done");
+ }, 1600);
+ },
+ function () {
+ button.textContent = "Select and copy";
+ }
+ );
+ });
+ }
+
+ document.querySelectorAll("[data-copy-from]").forEach(function (button) {
+ var source = document.querySelector(button.getAttribute("data-copy-from"));
+ if (source) addCopy(button, source);
+ });
+
+ // Every other code block gets a button too.
+ document.querySelectorAll(".code").forEach(function (block) {
+ if (block.querySelector(".copy")) return;
+ var pre = block.querySelector("pre");
+ if (!pre) return;
+ var button = document.createElement("button");
+ button.type = "button";
+ button.className = "copy";
+ button.textContent = "Copy";
+ block.appendChild(button);
+ addCopy(button, pre);
+ });
+
+ /* ── Command builder ── */
+
+ var TOOLS = [
+ { id: "panaroo", label: "Panaroo", p: "gene_presence_absence.csv", r: "pan_genome_reference.fa", note: "" },
+ { id: "roary", label: "Roary", p: "gene_presence_absence.csv", r: "pan_genome_reference.fa", note: "" },
+ {
+ id: "ppanggolin",
+ label: "PPanGGOLiN",
+ p: "matrix.csv",
+ r: "all_nucleotide_families.fasta",
+ note: "matrix.csv comes from ppanggolin all (or write_pangenome --csv), the FASTA from ppanggolin fasta --gene_families all."
+ },
+ {
+ id: "panta",
+ label: "PanTA",
+ p: "gene_presence_absence.csv",
+ r: "representative_clusters_nucl.fasta",
+ note: ""
+ },
+ {
+ id: "pirate",
+ label: "PIRATE",
+ p: "PIRATE.gene_families.tsv",
+ r: "representative_sequences.ffn",
+ note: "Give Recore PIRATE's own table, not the output of PIRATE_to_roary.pl."
+ }
+ ];
+
+ var builder = document.querySelector("[data-command]");
+ if (builder) {
+ var toolBox = document.querySelector("[data-tools]");
+ var toolNote = document.querySelector("[data-tool-note]");
+ var explain = document.querySelector("[data-explain]");
+ var layout = document.querySelector("[data-layout]");
+ var state = { tool: "panaroo" };
+
+ TOOLS.forEach(function (tool) {
+ var button = document.createElement("button");
+ button.type = "button";
+ button.textContent = tool.label;
+ button.setAttribute("aria-pressed", tool.id === state.tool ? "true" : "false");
+ button.addEventListener("click", function () {
+ state.tool = tool.id;
+ toolBox.querySelectorAll("button").forEach(function (b) {
+ b.setAttribute("aria-pressed", b === button ? "true" : "false");
+ });
+ render();
+ });
+ toolBox.appendChild(button);
+ });
+
+ document.querySelectorAll("[data-opt], [data-opt-value]").forEach(function (input) {
+ input.addEventListener("input", render);
+ input.addEventListener("change", render);
+ });
+
+ function opt(name) {
+ var box = document.querySelector('[data-opt="' + name + '"]');
+ return !!(box && box.checked);
+ }
+
+ function value(name, fallback) {
+ var input = document.querySelector('[data-opt-value="' + name + '"]');
+ var v = input ? input.value.trim() : "";
+ return v || fallback;
+ }
+
+ function esc(text) {
+ return text.replace(/&/g, "&").replace(//g, ">");
+ }
+
+ function shellWord(text) {
+ return /^[A-Za-z0-9_.\/:@%+=,-]+$/.test(text) ? text : "'" + text.replace(/'/g, "'\\''") + "'";
+ }
+
+ function render() {
+ var tool = TOOLS.filter(function (t) {
+ return t.id === state.tool;
+ })[0];
+ var threads = String(Math.max(1, Math.min(256, parseInt(value("threads", "8"), 10) || 8)));
+ var ref = shellWord(value("ref", "H37Rv"));
+ var lines = [];
+ var notes = [];
+ function line(parts) {
+ lines.push(
+ parts
+ .map(function (part) {
+ return part.charAt(0) === "-"
+ ? '' + esc(part) + ""
+ : '' + esc(part) + "";
+ })
+ .join(" ")
+ );
+ }
+ line(["-p", tool.p]);
+ notes.push(["-p -r", "the matrix and the representative sequences " + tool.label + " wrote"]);
+ line(["-r", tool.r]);
+ line(["-g", "genomes/"]);
+ notes.push(["-g genomes/", "one assembly per genome, named like the matrix columns"]);
+ if (opt("gff")) {
+ line(["-a", "annotations/", "--synteny"]);
+ notes.push(["-a --synteny", "the GFFs " + tool.label + " was run on; each gene's neighbours are checked"]);
+ }
+ if (opt("tree")) {
+ line(["--tree", "species.nwk"]);
+ notes.push(["--tree", "a rooted Newick tree, tips named like the matrix columns"]);
+ }
+ if (opt("ref")) {
+ line(["--reference-genome", ref]);
+ notes.push(["--reference-genome", "a column of the matrix, or its AS identifier"]);
+ }
+ if (opt("report")) {
+ line(["--write-html-report"]);
+ notes.push(["--write-html-report", "writes recore_report.html"]);
+ }
+ line(["-t", threads, "-o", "recore_output/"]);
+ notes.push(["-t -o", "threads, and the folder the results go to"]);
+
+ builder.innerHTML =
+ "recore \\\n" +
+ lines
+ .map(function (l, i) {
+ return " " + l + (i < lines.length - 1 ? " \\" : "");
+ })
+ .join("\n");
+
+ explain.innerHTML = notes
+ .map(function (n) {
+ return "
" + esc(n[0]) + "" + esc(n[1]) + "
";
+ })
+ .join("");
+
+ toolNote.textContent = tool.note;
+
+ var rows = [
+ ["your_project/", ""],
+ ["├── " + tool.p, "-p"],
+ ["├── " + tool.r, "-r"],
+ ["├── genomes/", "-g"],
+ ["│ ├── L1_AS00101.fasta", "one per matrix column"],
+ ["│ └── …", ""]
+ ];
+ if (opt("gff")) {
+ rows.push(["├── annotations/", "-a"]);
+ rows.push(["│ ├── L1_AS00101.gff", "same names"]);
+ rows.push(["│ └── …", ""]);
+ }
+ if (opt("tree")) rows.push(["├── species.nwk", "--tree"]);
+ var last = rows.length - 1;
+ rows[last][0] = rows[last][0].replace(/^├/, "└");
+ var width = rows.reduce(function (w, r) {
+ return Math.max(w, r[0].length);
+ }, 0);
+ layout.innerHTML = rows
+ .map(function (r) {
+ var pad = new Array(width - r[0].length + 4).join(" ");
+ var name = esc(r[0]).replace(/(L1_AS00101\.\w+)/, '$1');
+ return name + (r[1] ? pad + '# ' + esc(r[1]) + "" : "");
+ })
+ .join("\n");
+ }
+
+ render();
+ }
+
+ /* ── The animated matrix ── */
+
+ var grid = document.querySelector("[data-demo-grid]");
+ if (grid) {
+ var COLS = 16;
+ var ROWS = 10;
+ // Shaped like the example run: two genes falsely absent from three
+ // genomes and one from two (put back), a pseudogene across one lineage
+ // (kept out), a gene really missing from eight genomes and a few
+ // scattered true absences.
+ var OUTCOME = {};
+ function mark(kind, col, rows) {
+ rows.forEach(function (r) {
+ OUTCOME[r * COLS + col] = kind;
+ });
+ }
+ mark("rescued", 3, [1, 4, 7]);
+ mark("rescued", 8, [2, 5, 6]);
+ mark("rescued", 12, [0, 9]);
+ mark("kept", 5, [3, 4, 5]);
+ mark("absent", 14, [0, 1, 2, 3, 4, 5, 6, 7]);
+ mark("absent", 10, [2]);
+ mark("absent", 1, [6]);
+ mark("absent", 6, [9]);
+ mark("absent", 15, [4]);
+
+ grid.style.setProperty("--cols", COLS);
+ var cells = [];
+ for (var i = 0; i < ROWS * COLS; i++) {
+ var cell = document.createElement("span");
+ cell.className = "cell";
+ grid.appendChild(cell);
+ cells.push(cell);
+ }
+
+ var stepLabel = document.querySelector("[data-demo-step]");
+ var tallies = {
+ rescued: document.querySelector('[data-tally="rescued"]'),
+ kept: document.querySelector('[data-tally="kept"]'),
+ absent: document.querySelector('[data-tally="absent"]')
+ };
+ var counts;
+ var generation = 0;
+ var visible = true;
+
+ function setTally() {
+ Object.keys(tallies).forEach(function (k) {
+ if (tallies[k]) tallies[k].textContent = String(counts[k]);
+ });
+ }
+
+ function reset() {
+ counts = { rescued: 0, kept: 0, absent: 0 };
+ cells.forEach(function (c, idx) {
+ c.className = OUTCOME[idx] ? "cell gap" : "cell";
+ });
+ setTally();
+ if (stepLabel) stepLabel.textContent = "Input matrix";
+ }
+
+ function finalState() {
+ counts = { rescued: 0, kept: 0, absent: 0 };
+ cells.forEach(function (c, idx) {
+ var kind = OUTCOME[idx];
+ c.className = !kind ? "cell" : kind === "absent" ? "cell gap" : "cell " + kind;
+ if (kind) counts[kind] += 1;
+ });
+ setTally();
+ if (stepLabel) stepLabel.textContent = "After Recore";
+ }
+
+ function wait(ms, gen) {
+ return new Promise(function (resolve) {
+ function tick() {
+ if (gen !== generation) return;
+ if (!visible || document.hidden) {
+ setTimeout(tick, 250);
+ return;
+ }
+ resolve();
+ }
+ setTimeout(tick, ms);
+ });
+ }
+
+ function run() {
+ var gen = ++generation;
+ reset();
+ var chain = wait(1400, gen).then(function () {
+ if (stepLabel) stepLabel.textContent = "Searching the assemblies";
+ });
+ for (var col = 0; col < COLS; col++) {
+ (function (col) {
+ chain = chain.then(function () {
+ var gaps = [];
+ for (var r = 0; r < ROWS; r++) {
+ var idx = r * COLS + col;
+ if (OUTCOME[idx]) gaps.push(idx);
+ }
+ gaps.forEach(function (idx) {
+ cells[idx].classList.add("scan");
+ });
+ return wait(gaps.length ? 360 : 90, gen).then(function () {
+ gaps.forEach(function (idx) {
+ var kind = OUTCOME[idx];
+ cells[idx].classList.remove("scan");
+ if (kind !== "absent") {
+ cells[idx].classList.remove("gap");
+ cells[idx].classList.add(kind);
+ }
+ counts[kind] += 1;
+ });
+ setTally();
+ });
+ });
+ })(col);
+ }
+ chain
+ .then(function () {
+ if (stepLabel) stepLabel.textContent = "Done";
+ return wait(4200, gen);
+ })
+ .then(function () {
+ if (gen === generation) run();
+ });
+ }
+
+ if (reduceMotion) {
+ finalState();
+ } else {
+ if ("IntersectionObserver" in window) {
+ new IntersectionObserver(function (entries) {
+ visible = entries[0].isIntersecting;
+ }).observe(grid);
+ }
+ run();
+ }
+ }
+
+ /* ── Embedded report ── */
+
+ // The report takes the mouse wheel for zooming, so it only takes the mouse
+ // after a click, and gives it back when the pointer leaves: otherwise the
+ // page stops scrolling wherever the report is under the pointer.
+ document.querySelectorAll("[data-report-stage]").forEach(function (stage) {
+ var cover = stage.querySelector(".report-cover");
+ var frame = stage.querySelector("iframe");
+ if (!cover || !frame) return;
+ cover.addEventListener("click", function () {
+ stage.classList.add("live");
+ try {
+ frame.focus();
+ } catch (e) {
+ /* nothing to focus yet */
+ }
+ });
+ stage.addEventListener("mouseleave", function () {
+ stage.classList.remove("live");
+ });
+ });
+
+ /* ── Reference filter ── */
+
+ document.querySelectorAll("[data-filter]").forEach(function (input) {
+ var table = document.querySelector(input.getAttribute("data-filter"));
+ if (!table) return;
+ var empty = document.querySelector(input.getAttribute("data-filter-empty"));
+ input.addEventListener("input", function () {
+ var query = input.value.trim().toLowerCase();
+ var rows = Array.prototype.slice.call(table.tBodies[0].rows);
+ var shown = 0;
+ var group = null;
+ var groupHasRows = false;
+ function closeGroup() {
+ if (group) group.hidden = !groupHasRows;
+ }
+ rows.forEach(function (row) {
+ if (row.classList.contains("group-row")) {
+ closeGroup();
+ group = row;
+ groupHasRows = false;
+ return;
+ }
+ var match = !query || row.textContent.toLowerCase().indexOf(query) !== -1;
+ row.hidden = !match;
+ if (match) {
+ shown += 1;
+ groupHasRows = true;
+ }
+ });
+ closeGroup();
+ if (empty) empty.style.display = shown ? "none" : "block";
+ });
+ });
+})();
diff --git a/docs/getting-started/inputs.md b/docs/getting-started/inputs.md
index 2c516aa..1661ffd 100644
--- a/docs/getting-started/inputs.md
+++ b/docs/getting-started/inputs.md
@@ -57,29 +57,19 @@ fails in seconds rather than after an hour of minimap2:
## Choosing the optional inputs
-!!! tip "Use GFFs whenever you have them"
+> **Use GFFs whenever you have them.** Without annotations Recore can still
+> detect full-length sequence evidence and some partial or deletion evidence,
+> but it cannot reliably distinguish `SPLIT`, `START_VARIANT` and `PARALOG` by
+> annotation overlap, and synteny validation is not available at all.
- Without annotations Recore can still detect full-length sequence evidence and
- some partial or deletion evidence, but it cannot reliably distinguish
- `SPLIT`, `START_VARIANT` and `PARALOG` by annotation overlap, and synteny
- validation is not available at all.
+- `--synteny`: Use it with GFFs whenever paralogy or multi-locus clusters are plausible. It
+ is the check that keeps a paralogous hit from becoming a presence call.
-`--synteny`
+- `--multi-allele`: Use it when clusters have divergent alleles, so a diverged true allele is not
+ mistaken for a different gene.
-: Use it with GFFs whenever paralogy or multi-locus clusters are plausible. It
- is the check that keeps a paralogous hit from becoming a presence call.
+- `--min-prevalence`: `0.0` for exhaustive audits where every cluster matters. A higher value such
+ as `0.05` cuts rare-cluster noise in large publication runs.
-`--multi-allele`
-
-: Use it when clusters have divergent alleles, so a diverged true allele is not
- mistaken for a different gene.
-
-`--min-prevalence`
-
-: `0.0` for exhaustive audits where every cluster matters. A higher value such
- as `0.05` cuts rare-cluster noise in large publication runs.
-
-`--preset`
-
-: `asm5` for assemblies within about 5% divergence, which is faster. `asm20`
- for up to 20%, which is safer across a broader species range.
+- `--preset`: `asm5` for assemblies within about 5% divergence, which is faster. `asm20`
+ for up to 20%, which is safer across a broader species range.
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
deleted file mode 100644
index 65f7024..0000000
--- a/docs/getting-started/installation.md
+++ /dev/null
@@ -1,71 +0,0 @@
-# Installation
-
-## Requirements
-
-| Requirement | Version | Why |
-|-------------|---------|-----|
-| [Rust](https://www.rust-lang.org/tools/install) | 1.85 or newer | building from source |
-| [minimap2](https://github.com/lh3/minimap2) | any recent release, on `PATH` | every sequence search |
-| [MMseqs2](https://github.com/soedinglab/MMseqs2) | optional | alignment-based nucleotide similarity clustering with `--nsc-engine mmseqs2` |
-
-minimap2 is not optional. Recore shells out to it for the search, and a
-run fails immediately if it is not on `PATH`.
-
-## From source
-
-```bash
-git clone https://github.com/PathoGenOmics-Lab/recore.git
-cd recore
-cargo build --release
-```
-
-The binary lands at `target/release/recore`. Put it somewhere on your `PATH`, or
-call it by path:
-
-```bash
-target/release/recore --help
-```
-
-## Pre-built binaries
-
-Tagged releases build Linux and macOS binaries through GitHub Actions. Download
-them from [Releases](https://github.com/PathoGenOmics-Lab/recore/releases).
-
-## With conda for the dependencies
-
-Recore itself is a static binary, but conda is a convenient way to get minimap2
-and MMseqs2 without touching the system:
-
-```bash
-conda create -n recore -c bioconda -c conda-forge minimap2 mmseqs2
-conda activate recore
-```
-
-## Check the install
-
-```bash
-recore --version
-minimap2 --version
-```
-
-If `--nsc-engine mmseqs2` is going to be used, check that too:
-
-```bash
-mmseqs version
-```
-
-Asking for `--nsc-engine mmseqs2` without MMseqs2 on `PATH` is an error, not a
-silent downgrade. The default engine is `minhash`, which is built in and needs
-nothing installed.
-
-## Building the documentation
-
-This site is MkDocs Material. To preview it while editing:
-
-```bash
-pip install -r requirements-docs.txt
-mkdocs serve
-```
-
-`mkdocs build --strict` is what CI runs, and it turns a broken internal link into
-a failed build.
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
deleted file mode 100644
index 4ac14b7..0000000
--- a/docs/getting-started/quickstart.md
+++ /dev/null
@@ -1,156 +0,0 @@
-# Quick start
-
-If you would rather see a full run on data with known answers before pointing
-Recore at your own, start with the [tutorial](tutorial.md).
-
-## The run most people want
-
-A publication-grade correction pass, with FASTA assemblies and GFF annotations:
-
-```bash
-recore \
- -p gene_presence_absence.csv \
- -r pan_genome_reference.fa \
- -g genomes/ \
- -a annotations/ \
- --synteny \
- --multi-allele \
- --nscluster \
- --nsc-engine mmseqs2 \
- --nsc-min-seq-id 0.9 \
- --nsc-coverage 0.8 \
- --preset asm5 \
- --min-prevalence 0.05 \
- --matrix-policy balanced \
- --reference-genome AS00146 \
- --write-html-report \
- -t 8 \
- -o recore_output/
-```
-
-Every flag is described in [Parameters](../guide/parameters.md). The four that
-change what a run means, rather than how fast it is:
-
-`--matrix-policy`
-
-: Which accepted evidence reaches the binary matrix. `balanced` is the default
- recommendation. See [Matrix policies](../guide/matrix-policies.md).
-
-`--synteny`
-
-: Validates candidate loci against their flanking genes. Turn it on whenever
- paralogy or multi-locus clusters are plausible, which is most of the time.
-
-`--reference-genome`
-
-: Asks whether each cluster is genuinely absent from a chosen reference, or
- whether it looks like a reference-derived allele or fragment.
-
-`--write-html-report`
-
-: Writes `recore_report.html`, a self-contained viewer for the whole run. See
- [Run report](../guide/report.md).
-
-## What to read first
-
-| Read this | For |
-|-----------|-----|
-| `gene_presence_absence_recore.csv` | the corrected binary matrix, for downstream tools |
-| `gene_presence_absence_recore.Rtab` | the same matrix as 1/0, for pyseer and Rtab readers |
-| `gene_presence_absence_recore_states.csv` | audit and figures, one biological state per cell |
-| `orthology_evidence.tsv` | publication wording and manual review |
-| `recore_report.html` | the whole run in one page, offline |
-| `reference_genome_audit.tsv` | whether clusters are truly absent from a reference |
-| `evolutionary_events.tsv` | what kind of event best explains each cluster |
-
-## Other run profiles
-
-=== "Evolutionary and mobile-element audit"
-
- When the question is not only "is the gene present?" but "what kind of event
- explains the signal?".
-
- ```bash
- recore \
- -p gene_presence_absence.csv \
- -r pan_genome_reference.fa \
- -g genomes/ \
- -a annotations/ \
- --synteny --multi-allele --nscluster \
- --matrix-policy balanced \
- --reference-genome AS00146 \
- --tree species.tree \
- --mobile-element-reference mobile_elements.fa \
- --mobile-element-min-identity 90 \
- --mobile-element-min-coverage 70 \
- --mobile-element-window 500 \
- -t 8 \
- -o recore_evolutionary_audit/
- ```
-
- `evolutionary_events.tsv` then keeps matrix correction, phylogenetic pattern,
- reference similarity, local context, NSC redundancy, and nearby
- mobile-element support in one row per cluster.
-
- `--tree` also unlocks the phylogeny pane of the HTML report.
-
-=== "Without GFF annotations"
-
- ```bash
- recore \
- -p gene_presence_absence.csv \
- -r pan_genome_reference.fa \
- -g genomes/ \
- --matrix-policy balanced \
- -t 8 \
- -o recore_output/
- ```
-
- Full-length sequence evidence is still rescued, but without annotations
- Recore cannot reliably tell `SPLIT`, `START_VARIANT` and `PARALOG` apart by
- annotation overlap, and synteny validation is unavailable.
-
-=== "Exhaustive reference audit"
-
- ```bash
- recore \
- -p gene_presence_absence.csv \
- -r pan_genome_reference.fa \
- -g genomes/ \
- -a annotations/ \
- --synteny --multi-allele \
- --reference-genome AS00146 \
- --min-prevalence 0.0 \
- --matrix-policy balanced \
- -t 8 \
- -o recore_reference_audit/
- ```
-
- `--min-prevalence 0.0` stops rare clusters being filtered out, which is what
- you want when the point is to classify every cluster against a reference
- background.
-
-=== "Built-in NSC engine"
-
- Without MMseqs2 installed, use the built-in MinHash engine:
-
- ```bash
- recore \
- -p gene_presence_absence.csv \
- -r pan_genome_reference.fa \
- -g genomes/ \
- --nscluster \
- --nsc-engine minhash \
- -o recore_output/
- ```
-
-## Reporting the run
-
-The wording that matches what `balanced` actually does:
-
-> We used Recore with the balanced matrix policy, which rescues full-length
-> false absences while retaining partial or disrupted homologues as multistate
-> audit-only evidence.
-
-`orthology_evidence.tsv` carries per-cluster wording guidance for the cases that
-need a sentence of their own.
diff --git a/docs/getting-started/tutorial.md b/docs/getting-started/tutorial.md
index 6646518..7ced3ea 100644
--- a/docs/getting-started/tutorial.md
+++ b/docs/getting-started/tutorial.md
@@ -3,8 +3,9 @@
A complete run on a small dataset with known problems planted in it, so you can
check that what Recore reports is what is actually there.
-You need [minimap2 on `PATH` and a built binary](installation.md). Nothing is
-downloaded: the dataset is generated by a script in the repository.
+You need minimap2 on `PATH` and Recore installed (see
+[Installation](../../README.md#installation)). Nothing is downloaded: the
+dataset is generated by a script in the repository.
## 1. Build the dataset
@@ -192,21 +193,13 @@ nothing was quietly upgraded. The thirteen states are listed in
## 6. Open the report
-```bash
-python3 -m http.server 8000 --directory tutorial_out/
-```
-
-Then open .
-
-!!! warning "Serve it, do not double-click it"
-
- Over `file://` some browsers block the inline script and you get an empty
- workspace with no error. From a real results directory on a cluster this
- does not arise; on a laptop, serve it.
+Open `tutorial_out/recore_report.html` in a browser. It is one self-contained
+file, so double-clicking it works (checked in Chrome and in Safari's engine),
+and so does copying it off a cluster.
Things to try, in order:
-1. **Press ++cmd+k++ and type `Rv0009`.** The matrix jumps to that column. The
+1. **Press Cmd+K and type `Rv0009`.** The matrix jumps to that column. The
three L2 genomes are the only ones not marked present, and the inspector
shows the mechanism.
2. **Set tip colour to lineage** from the sliders icon in the phylogeny header.
@@ -214,9 +207,9 @@ Things to try, in order:
looks like.
3. **Find `Rv1180`.** Only the three L2 tips carry it. This one is a genuine
clade acquisition rather than an artefact, and the inspector says so.
-4. **Press ++s++** to open the synteny band. The flanking genes are stable
+4. **Press S** to open the synteny band. The flanking genes are stable
across genomes, which is why the `Rv0006` locus could be validated.
-5. **Press ++question++** for the rest of the shortcuts.
+5. **Press ?** for the rest of the shortcuts.
The [Run report](../guide/report.md) page covers the viewer properly.
diff --git a/docs/guide/report.md b/docs/guide/report.md
index 54d586c..ff65c05 100644
--- a/docs/guide/report.md
+++ b/docs/guide/report.md
@@ -21,7 +21,7 @@ tree pane only lists the genomes, and a notice above the workspace says so.
Ancestral readings, tree colouring by subtree and rerooting need a real tree.
If the workspace cannot start (a browser that cannot hold the embedded data, for
-instance), the report says why in place of the workspace, and ++d++ still opens
+instance), the report says why in place of the workspace, and D still opens
the run details.
## The workspace
@@ -40,13 +40,13 @@ browser for the next report you open.
## Finding things
-Press ++cmd+k++ (or ++ctrl+k++, or ++slash++) to open the find field. It searches
+Press Cmd+K (or Ctrl+K, or /) to open the find field. It searches
clusters **and** genomes at once, ranking prefix matches on the name above name
matches above annotation matches. Enter opens the result: a cluster becomes the
selected column, a genome scrolls the panes to its tip and opens its card.
The arrow buttons beside the field step through the visible columns one at a
-time, and ++left++ / ++right++ do the same from the keyboard. ++shift++ with them
+time, and ← / → do the same from the keyboard. Shift with them
jumps ten.
## The tree
@@ -58,7 +58,7 @@ shows topology only. A scale bar under the pane gives the phylogram its units.
**Root.** Every ancestral reading in the report depends on where the tree is
rooted, so it is an explicit choice: the L8 outgroup when one is present, the
-tree as supplied, or the midpoint of the longest tip-to-tip path. ++alt++ and a
+tree as supplied, or the midpoint of the longest tip-to-tip path. Alt and a
click on any tip roots on that genome instead.
**Tip colour.** By the selected cluster's state, by MTBC lineage, by mechanism,
@@ -133,12 +133,12 @@ per lineage, how many genomes were corrected or had evidence kept out, out of
the lineage's genomes.
**Zoom.** The slider in each pane header drives its own axis, in equal ratios
-from end to end. A plain wheel always scrolls. ++cmd++ (or ++ctrl++) and the
-wheel zoom columns, which is also what a trackpad pinch does; ++alt++ and the
+from end to end. A plain wheel always scrolls. Cmd (or Ctrl) and the
+wheel zoom columns, which is also what a trackpad pinch does; Alt and the
wheel zoom rows. Each notch is a factor of 1.25 and the cell under the pointer
-stays under the pointer. Press ++z++ to fit the data back to the window.
+stays under the pointer. Press Z to fit the data back to the window.
-Press ++z++ to see the whole matrix at once. When that makes cells smaller than
+Press Z to see the whole matrix at once. When that makes cells smaller than
a pixel, the matrix is drawn as density: each pixel shows the share of present
cells it covers, between the absent and present colours, and is tinted blue
where any cell in it was corrected (crimson where evidence was kept out), more
@@ -156,7 +156,7 @@ pointer moves on, so a genome found from the find field is still easy to spot.
## Comparing two genomes
Two genomes far apart in the tree are far apart in the matrix. **Pin to
-compare** in the genome card, or ++p++, keeps a genome's row in a band under the
+compare** in the genome card, or P, keeps a genome's row in a band under the
column header, aligned with every column whatever the scroll; its name sits
level with it in the tree pane's header, marked **A** or **B**, and its tip gets
a blue ring. Pinning a third genome unpins the oldest. Hover a cell of the band
@@ -172,7 +172,7 @@ count takes the filter off again.
## Synteny
-Press ++s++ or use the layers button to show the synteny band. It draws the top
+Press S or use the layers button to show the synteny band. It draws the top
unique five-neighbour blocks for the selected cluster, at most
10 per cluster, with gene arrows keeping strand and proportional length.
@@ -190,7 +190,7 @@ This needs `cluster_synteny_blocks.tsv`, which means GFFs were loaded.
## Run details
-++d++ or the info button opens the run details. They start with what you need
+D or the info button opens the run details. They start with what you need
to write the run up:
- **Using these results**: a methods paragraph built from the run's own
@@ -228,36 +228,36 @@ The SVG carries every colour and font as a plain attribute, with no stylesheet
or CSS variables, so illustration software and PDF tools draw it as the page
does.
-++cmd+p++ prints the figure described above for the whole matrix (in the light
+Cmd+P prints the figure described above for the whole matrix (in the light
palette, whatever the screen is in), then the run details as a document with
every candidate. The live panes are swapped for the figure only while printing.
## Keyboard shortcuts
-Press ++question++ for this list in the report itself. A switch at its top turns
+Press ? for this list in the report itself. A switch at its top turns
the one-letter shortcuts off, for example when they fire by accident with speech
-input; ++cmd+k++ and the arrows keep working, and the choice is remembered in
+input; Cmd+K and the arrows keep working, and the choice is remembered in
that browser.
| Keys | Action |
|------|--------|
-| ++cmd+k++ / ++ctrl+k++ / ++slash++ | find a cluster or genome |
-| ++left++ ++right++ | previous / next cluster |
-| ++shift++ + ++left++ ++right++ | jump ten clusters |
-| ++s++ | show or hide the synteny panel |
-| ++d++ | open the run details |
-| ++e++ | export the view |
-| ++f++ | fullscreen |
-| ++t++ | switch theme |
-| ++c++ | phylogram or cladogram |
-| ++r++ | reroot at the midpoint |
-| ++z++ | fit the matrix to the window |
-| ++p++ | pin or unpin the open genome, to compare two |
-| ++cmd++ / ++ctrl++ + wheel, or pinch | zoom columns under the pointer |
-| ++alt++ + wheel | zoom rows under the pointer |
-| ++shift++ + wheel | scroll sideways |
-| ++alt++ + click a tip | root the tree on that genome |
-| ++esc++ | close whatever is open |
+| Cmd+K / Ctrl+K / / | find a cluster or genome |
+| ←→ | previous / next cluster |
+| Shift + ←→ | jump ten clusters |
+| S | show or hide the synteny panel |
+| D | open the run details |
+| E | export the view |
+| F | fullscreen |
+| T | switch theme |
+| C | phylogram or cladogram |
+| R | reroot at the midpoint |
+| Z | fit the matrix to the window |
+| P | pin or unpin the open genome, to compare two |
+| Cmd / Ctrl + wheel, or pinch | zoom columns under the pointer |
+| Alt + wheel | zoom rows under the pointer |
+| Shift + wheel | scroll sideways |
+| Alt + click a tip | root the tree on that genome |
+| Esc | close whatever is open |
## Scale
diff --git a/docs/how-it-works/limits.md b/docs/how-it-works/limits.md
index f414fb9..a6afacd 100644
--- a/docs/how-it-works/limits.md
+++ b/docs/how-it-works/limits.md
@@ -147,21 +147,15 @@ cache directory can be deleted at any time; the next run simply searches again.
Three Python utilities ship in `benchmarks/` for checking a run rather than
trusting it:
-`self_benchmark.py`
+- `self_benchmark.py`: Masks known-present cells, reruns Recore, and estimates dataset-specific
+ masked-positive recall plus score calibration bins. This measures the tool on
+ *your* data rather than on a synthetic set.
-: Masks known-present cells, reruns Recore, and estimates dataset-specific
- masked-positive recall plus score calibration bins. This measures the tool on
- *your* data rather than on a synthetic set.
+- `compare_runs.py`: Compares two output directories and labels every matrix call unchanged, new,
+ lost or changed. Use it when changing a policy or a threshold.
-`compare_runs.py`
-
-: Compares two output directories and labels every matrix call unchanged, new,
- lost or changed. Use it when changing a policy or a threshold.
-
-`recore_report.py`
-
-: Builds a compact Markdown or HTML report for an output directory that was
- generated without `--write-html-report`.
+- `recore_report.py`: Builds a compact Markdown or HTML report for an output directory that was
+ generated without `--write-html-report`.
The synthetic benchmark itself, which creates known false absences, true
absences and partial pseudogenes and then computes precision, recall and F1 from
diff --git a/docs/index.html b/docs/index.html
new file mode 100644
index 0000000..f3cdb52
--- /dev/null
+++ b/docs/index.html
@@ -0,0 +1,320 @@
+
+
+
+
+
+Recore: find the genes your pangenome lost
+
+
+
+
+
+
+Skip to the install
+
+
+
Recore searches every empty cell of a pangenome matrix in the assemblies, puts back the genes that are really there, and keeps the evidence for each one.
Hover a cellfor the evidence behind it; click it for the whole row.
+
Search a genein the box at the top, or press ? for every shortcut.
+
Follow the treeon the left: genomes are drawn in phylogeny order.
+
+
+
+
+
+
+
+ 6
+
One setting that matters
+
--matrix-policy picks which evidence reaches the corrected matrix. All of it stays in the audit files.
+
+
+
+
strict
+
Only genes tied to the same locus by the annotation or by their neighbours.
+
+
fewest changes
+
+
+ Default
+
balanced
+
Adds full-length genes found by sequence. Pseudogenes and partial copies stay out. Use it for papers.
+
+
what the tool is for
+
+
+
sensitive
+
Also puts back partial and disrupted copies. For exploring, read with the states file.
+
+
most changes
+
+
+
+
+
+
+
+
+ ?
+
If something looks off
+
The questions people ask first.
+
+
+
+ It says minimap2 was not found
+
Recore runs minimap2 for every search. Install it (step 1) and check that minimap2 --version works in the same terminal.
+
+
+ It finds no genome files, or no GFF matches a genome
+
Name each file after its column in the matrix: column L1_AS00101 reads genomes/L1_AS00101.fasta and annotations/L1_AS00101.gff. FASTA can end in .fasta, .fa or .fna, GFF in .gff or .gff3, gzipped or not.
+
+
+ It rescued nothing
+
Read the Gaps: line of the log. Genes in fewer than 10% of genomes are not searched; --min-prevalence 0 searches them all. Check the file names (above), and use --preset asm20 for divergent species.
+
+
+ Will it change my matrix or my GFFs?
+
No. It reads them and writes everything new into the output folder, recore_output/ unless you pass -o.
+
+
+ How long does it take, and how much memory?
+
800 genomes of 4.9 Mb take about 20 seconds on a laptop with 8 threads and about half a gigabyte of memory, which grows by about 0.5 MB per genome. The measurements.
+
+
+ Can I try another policy without searching again?
+
Yes. Add --search-cache cache/ to both runs: the second one aligns only what changed.
+
+
+ How do I describe it in a paper?
+
For the default policy: We used Recore with the balanced matrix policy, which rescues full-length false absences while retaining partial or disrupted homologues as multistate audit-only evidence.
-Post-hoc rescue, audit, and quality control of pangenome presence/absence
-matrices. Recovers false absences with sequence evidence, and keeps the evidence
-trail that explains every corrected cell.
-
-
-
-[Start here](getting-started/tutorial.md){ .md-button .md-button--primary }
-[See a real report](assets/example-report.html){ .md-button target=_blank }
-
-
-
-
-Pangenome pipelines can mark a gene as absent even when the sequence is still
-present in the assembly. These false absences inflate the accessory genome,
-shrink the core genome, and create lineage-specific signals that are artefacts
-of graph cleaning rather than biology.
-
-Recore searches every absent cell against the genome assemblies with minimap2,
-classifies the evidence, validates orthology against annotation and synteny when
-they are available, and writes both:
-
-- a **Panaroo-compatible corrected binary matrix** for downstream analyses;
-- a **multistate evidence matrix** for audit, plotting, and manuscript review.
-
-The central design choice is conservative. Recore separates *sequence evidence*
-from *clean binary presence*: full-length evidence can be corrected in the binary
-matrix, while partial homology, pseudogenes, paralog traps, and
-incompatible-context hits stay visible in audit outputs instead of silently
-becoming presence calls.
-
-
-
-- :material-download: **[Install](getting-started/installation.md)**
-
- Build from source or take a tagged binary. Needs Rust and minimap2.
-
-- :material-school: **[Tutorial](getting-started/tutorial.md)**
-
- A full run on a generated dataset with known problems planted in it.
-
-- :material-rocket-launch: **[Quick start](getting-started/quickstart.md)**
-
- One command for a publication-grade correction pass, and what to read first.
-
-- :material-tune: **[Matrix policies](guide/matrix-policies.md)**
-
- Which evidence reaches the binary matrix, and how to word it in a paper.
-
-- :material-chart-box: **[Run report](guide/report.md)**
-
- The self-contained HTML viewer: phylogeny, matrix and synteny in one page.
-
-- :material-table: **[Output files](guide/outputs.md)**
-
- Every table Recore writes, column by column, with coordinate conventions.
-
-- :material-dna: **[How it works](how-it-works/algorithm.md)**
-
- Each pipeline phase, the data structures behind it, and the complexity.
-
-
-
-## What a run gives you
-
-```bash
-recore \
- -p gene_presence_absence.csv \
- -r pan_genome_reference.fa \
- -g genomes/ \
- -a annotations/ \
- --synteny --multi-allele --nscluster \
- --matrix-policy balanced \
- --reference-genome AS00146 \
- --write-html-report \
- -t 8 -o recore_output/
-```
-
-| Read this first | For |
-|-----------------|-----|
-| `gene_presence_absence_recore.csv` | the corrected binary matrix, for downstream tools |
-| `gene_presence_absence_recore.Rtab` | the same matrix as 1/0, for pyseer and Rtab readers |
-| `gene_presence_absence_recore_states.csv` | audit and figures, one biological state per cell |
-| `orthology_evidence.tsv` | publication wording and manual review |
-| `recore_report.html` | a self-contained viewer for the whole run |
-| `evolutionary_events.tsv` | acquisition, loss, IS interruption, pseudogene or paralog per cluster |
-
-The full list, with every column, is in [Output files](guide/outputs.md).
-
-## What Recore does not claim
-
-Recore is not a pangenome caller and it does not replace one. It cannot invent
-evidence that is absent from the assembly sequence, it does not decide that a
-partial homologue is a functional gene, and it does not force an ancestral story
-onto an ambiguous phylogenetic pattern. Everything it declines to correct is
-still written down. See [Limits and comparisons](how-it-works/limits.md).
-
-## Citation
-
-> Ruiz-Rodriguez P, et al. (2026). Recore: post-hoc rescue and quality control
-> of pangenome presence/absence matrices. *In preparation.*
diff --git a/docs/reference.html b/docs/reference.html
new file mode 100644
index 0000000..5649829
--- /dev/null
+++ b/docs/reference.html
@@ -0,0 +1,256 @@
+
+
+
+
+
+Recore reference: options, files and states
+
+
+
+
+
+
+Skip to the options
+
+
+
+ self.blocks: dict[str, str] = {}
+ self.option_rows: list[list[str]] = []
+ self._code_depth = 0
+ self._code_open: list[tuple[str, list[str]]] = []
+ self._capture: list[tuple[str, int, list[str]]] = []
+ self._depth = 0
+ self._row: list[str] | None = None
+ self._cell: list[str] | None = None
+ self._in_options = False
+
+ def handle_starttag(self, tag, attrs):
+ a = dict(attrs)
+ self._depth += 1
+ if "id" in a:
+ self.ids.add(a["id"])
+ for key in ("href", "src"):
+ if key in a and a[key] is not None:
+ self.links.append((tag, a[key]))
+ remote = lambda url: url and re.match(r"^(https?:)?//", url)
+ if tag == "link" and "stylesheet" in (a.get("rel") or "") and remote(a.get("href")):
+ self.remote_assets.append(a["href"])
+ if tag in ("script", "img", "iframe") and remote(a.get("src")):
+ self.remote_assets.append(a["src"])
+ if tag in ("code", "pre"):
+ self._code_depth += 1
+ self._code_open.append((tag, []))
+ if tag == "pre" and a.get("data-copy-source"):
+ self._capture.append((a["data-copy-source"], self._depth, []))
+ if tag == "div" and "out" in (a.get("class") or "").split():
+ self._capture.append(("out", self._depth, []))
+ if tag == "table" and a.get("id") == "options-table":
+ self._in_options = True
+ if self._in_options and tag == "tr":
+ self._row = []
+ if self._in_options and tag in ("td", "th") and self._row is not None:
+ self._cell = []
+ if tag == "br" and self._capture:
+ self._capture[-1][2].append("\n")
+
+ def handle_endtag(self, tag):
+ if tag in ("code", "pre"):
+ self._code_depth = max(0, self._code_depth - 1)
+ if self._code_open and self._code_open[-1][0] == tag:
+ kind, parts = self._code_open.pop()
+ self.code_elems.append((kind, "".join(parts)))
+ if self._capture and self._capture[-1][1] == self._depth and tag in ("pre", "div"):
+ name, _, parts = self._capture.pop()
+ self.blocks[name] = "".join(parts)
+ if self._in_options and tag in ("td", "th") and self._cell is not None and self._row is not None:
+ self._row.append("".join(self._cell).strip())
+ self._cell = None
+ if self._in_options and tag == "tr" and self._row is not None:
+ self.option_rows.append(self._row)
+ self._row = None
+ if tag == "table":
+ self._in_options = False
+ self._depth -= 1
+
+ def handle_data(self, data):
+ if self._code_depth:
+ self.code_text.append(data)
+ for _, parts in self._code_open:
+ parts.append(data)
+ for _, _, parts in self._capture:
+ parts.append(data)
+ if self._cell is not None:
+ self._cell.append(data)
+
+
+def parse(path: Path) -> Page:
+ page = Page()
+ page.feed(path.read_text(encoding="utf-8"))
+ return page
+
+
+def help_options(recore: str) -> tuple[set[str], set[str], dict[str, str]]:
+ """Long options, short options and defaults that `recore --help` prints."""
+ out = subprocess.run([recore, "--help"], check=True, capture_output=True, text=True).stdout
+ longs = set(re.findall(r"(? list[str]:
+ problems = []
+ for name, page in pages.items():
+ for url in page.remote_assets:
+ problems.append(f"{name}: loads {url} from another host; the site must work offline")
+ for tag, url in page.links:
+ if re.match(r"^(https?:|mailto:)", url):
+ continue
+ target, _, anchor = url.partition("#")
+ if not target:
+ if anchor and anchor not in page.ids:
+ problems.append(f"{name}: link to #{anchor}, which no element on the page has")
+ continue
+ path = SITE / target
+ if not path.exists():
+ problems.append(f"{name}: <{tag}> points at {target}, which does not exist")
+ continue
+ if anchor and target in pages and anchor not in pages[target].ids:
+ problems.append(f"{name}: link to {target}#{anchor}, which no element there has")
+ return problems
+
+
+def check_flags(pages: dict[str, Page], recore: str) -> list[str]:
+ problems = []
+ longs, shorts, defaults = help_options(recore)
+ mentioned_long: dict[str, str] = {}
+ mentioned_short: dict[str, str] = {}
+
+ def note(text: str, where: str) -> None:
+ for flag in re.findall(r"(? list[str]:
+ problems = []
+ for path in [SITE / p for p in PAGES] + SCRIPTS:
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
+ if "\u2014" in line:
+ problems.append(f"{path}:{number}: em-dash")
+ return problems
+
+
+def check_tutorial(pages: dict[str, Page], recore: str) -> list[str]:
+ """Run the Try-it commands as the page shows them and compare the counts."""
+ index = pages["index.html"]
+ script = index.blocks.get("try")
+ promised = index.blocks.get("out")
+ if not script or not promised:
+ return ["index.html: no Try-it block or no expected output block to check"]
+ want = {
+ key: re.search(rf"^{key}:\s+(\d+)", promised, flags=re.M)
+ for key in ("Rescued", "Matrix-corrected")
+ }
+ if not all(want.values()):
+ return ["index.html: the expected output does not show Rescued and Matrix-corrected counts"]
+ commands = []
+ for line in script.replace("\\\n", " ").splitlines():
+ line = line.strip()
+ if line.startswith("$ "):
+ line = line[2:]
+ if not line:
+ continue
+ if line.startswith("curl ") and DATASET_SCRIPT.name in line:
+ commands.append(["cp", str(DATASET_SCRIPT.resolve()), "."])
+ continue
+ words = line.split()
+ if words[0] == "recore":
+ words[0] = str(Path(recore).resolve())
+ commands.append(words)
+ with tempfile.TemporaryDirectory() as work:
+ for words in commands:
+ run = subprocess.run(words, cwd=work, capture_output=True, text=True)
+ if run.returncode != 0:
+ return [f"Try it: `{' '.join(words)}` failed:\n{run.stderr[-2000:]}"]
+ summary = Path(work, "tutorial_out", "summary.txt")
+ if not summary.exists():
+ return ["Try it: the commands ran but wrote no tutorial_out/summary.txt"]
+ text = summary.read_text()
+ problems = []
+ for key, match in want.items():
+ got = re.search(rf"^{key}:\s+(\d+)", text, flags=re.M)
+ if not got or got.group(1) != match.group(1):
+ problems.append(
+ f"Try it: the page promises {key}: {match.group(1)}, the run gave "
+ f"{got.group(1) if got else 'nothing'}"
+ )
+ return problems
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("--recore", default="target/debug/recore", help="the recore binary to read --help from")
+ parser.add_argument("--run-tutorial", action="store_true", help="also run the Try-it commands (needs minimap2)")
+ args = parser.parse_args()
+
+ pages = {name: parse(SITE / name) for name in PAGES}
+ problems = check_links(pages) + check_flags(pages, args.recore) + check_dashes()
+ if args.run_tutorial:
+ if not shutil.which("minimap2"):
+ problems.append("Try it: minimap2 is not on PATH")
+ else:
+ problems += check_tutorial(pages, args.recore)
+ if problems:
+ print("The website does not match the code:", file=sys.stderr)
+ for problem in problems:
+ print(f" {problem}", file=sys.stderr)
+ return 1
+ checked = "links, options, defaults, dashes" + (", and the Try-it run" if args.run_tutorial else "")
+ print(f"docs/: {checked} all agree with the code")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/make_logo_variants.py b/tools/make_logo_variants.py
index a5827ca..36ae4f4 100644
--- a/tools/make_logo_variants.py
+++ b/tools/make_logo_variants.py
@@ -31,8 +31,8 @@
# carrying a second copy of a 30 KB asset. These markers bracket the generated
# rules so regenerating cannot drift from the dark file.
REPORT_CSS = ROOT / "src" / "pipeline" / "report_assets" / "report.css"
-# MkDocs only serves what lives under docs/, so the two variants are copied
-# there rather than kept in a second place by hand.
+# The website only serves what lives under docs/, so the two variants are
+# copied there rather than kept in a second place by hand.
DOCS_ASSETS = ROOT / "docs" / "assets"
CSS_BEGIN = "/* >>> generated by tools/make_logo_variants.py */"
CSS_END = "/* <<< generated */"
@@ -195,7 +195,7 @@ def main() -> int:
print(f"{REPORT_CSS.relative_to(ROOT)} {rules:>6} generated rules")
print(f"{LIGHT.relative_to(ROOT)} {len(light):>6} bytes")
print(f"{DARK.relative_to(ROOT)} {len(dark):>6} bytes")
- print(f"{DOCS_ASSETS.relative_to(ROOT)}/ {'':>6} both copied for MkDocs")
+ print(f"{DOCS_ASSETS.relative_to(ROOT)}/ {'':>6} both copied for the website")
print()
print(f"{'ink':>16} {'lifted to':>9} {'was':>5} {'now':>5}")
for before, after in sorted(mapping.items()):