diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md
index ac8ed98..66b8e3a 100644
--- a/docs/INSTALLATION.md
+++ b/docs/INSTALLATION.md
@@ -1,39 +1,26 @@
+
+
# Installation Guide
This guide walks through deploying coverage-tracker to your own Cloudflare account.
-Follow the steps in order — several decisions depend on values produced by earlier steps.
+Follow the sections in order — several decisions depend on values produced by earlier steps.
---
-## Prerequisites
-
-- A **Cloudflare account** (free tier is sufficient)
-- A **domain managed by Cloudflare** (DNS must be proxied through Cloudflare — see [Domain setup](#1-domain-setup) below)
-- A **GitHub account** (personal or org) with admin access to the repos you want to track
-- **Node.js** 18+ and **npm** installed locally
-- **Wrangler** authenticated: `npx wrangler login`
-
----
-
-## 1. Domain setup
-
-The Worker must be served from a domain whose DNS is managed by Cloudflare. This is required for both custom domain routing and Cloudflare Access.
+## Quick start
-If your domain's DNS is hosted elsewhere (e.g. Porkbun, Namecheap, Route 53):
+If you already run on Cloudflare, the whole setup is six moves. Each links to its full section below.
-1. Go to **Cloudflare dashboard → Websites → Add a domain**
-2. Enter your domain → select **Free** plan
-3. Cloudflare scans and imports your existing DNS records — review them
-4. In your registrar's control panel, replace the nameservers with the two Cloudflare nameservers shown
-5. Wait for propagation (usually minutes; Cloudflare emails you when active)
+1. Add your domain to Cloudflare (DNS proxied through Cloudflare).
+2. Create the D1 database and apply the migration.
+3. Create a **GitHub App** (webhooks + API access).
+4. Create a **GitHub OAuth App** (Cloudflare Access login).
+5. Configure Cloudflare Zero Trust, set secrets, and deploy the Worker.
+6. Install the GitHub App on your repos.
-You do **not** need to manually create a DNS record for the Worker subdomain — the deploy step handles that automatically.
+Or skip the manual route entirely — the **Deploy to Cloudflare** button provisions the Worker and D1 automatically. You still complete the GitHub App, Zero Trust, and secrets steps afterward.
----
-
-## 2. Clone and install
-
-```bash
+```bash file="clone & install"
git clone https://github.com/your-org/coverage-tracker
cd coverage-tracker
npm install
@@ -41,381 +28,227 @@ npm install
---
-## 3. Create the D1 database
-
-`wrangler.json` is already present in the repo. Create your D1 database:
-
-```bash
-npx wrangler d1 create coverage
-```
-
-Copy the `database_id` from the output and add it to the `d1_databases` entry in `wrangler.json`:
-
-```jsonc
-"d1_databases": [
- {
- "binding": "DB",
- "database_name": "coverage",
- "database_id": "paste-your-id-here", // ← add this line
- "migrations_dir": "migrations"
- }
-]
-```
-
-> **Note:** The committed `wrangler.json` intentionally omits `database_id` so that the
-> [Deploy to Cloudflare](https://deploy.workers.cloudflare.com/?url=https://github.com/CoverageTracker/coverage-tracker)
-> button can provision D1 automatically. For manual installs, add the field as shown above.
-> The custom domain is attached in the Cloudflare dashboard after first deploy (Step 11) —
-> no `routes` entry is needed.
-
----
-
-## 4. Apply the database migration
+## Prerequisites
-Apply to your remote D1 database:
+- A **Cloudflare account** (free tier is sufficient).
+- A **domain managed by Cloudflare** — DNS must be proxied through Cloudflare.
+- A **GitHub account** (personal or org) with admin access to the repos you want to track.
+- **Node.js 18+** and **npm** installed locally.
+- **Wrangler** authenticated: `npx wrangler login`.
-```bash
-npm run db:migrate:remote
-```
+If your domain's DNS lives elsewhere, add the domain in the Cloudflare dashboard, pick the Free plan, and replace the registrar's nameservers with the two Cloudflare provides. You do **not** need to create a DNS record for the Worker subdomain — the deploy step handles that.
---
-## 5. Create the GitHub App
-
-> **Important:** There are two separate GitHub integrations in this project:
-> - A **GitHub App** (this step) — for webhook events and API access
-> - A **GitHub OAuth App** (Step 8) — for Cloudflare Access dashboard login
->
-> Do not conflate them. Create them separately.
-
-Go to the account or org where you want to host the app:
-- **Personal:** GitHub → Settings → Developer settings → GitHub Apps → **New GitHub App**
-- **Org:** GitHub → Your org → Settings → Developer settings → GitHub Apps → **New GitHub App**
+## Domain & database
-### Required fields
+Create your D1 database, then wire its id into `wrangler.json` and apply the schema migration.
-| Field | Value |
-|-------|-------|
-| GitHub App name | Something globally unique, e.g. `your-coverage-tracker` |
-| Homepage URL | `https://coverage-tracker.yourdomain.com` |
-| Webhook → Active | ✓ checked |
-| Webhook URL | `https://coverage-tracker.yourdomain.com/webhooks/github` |
-| Webhook secret | Generate with `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"` — save this value |
-
-### Leave these blank / unchecked
-
-- **Callback URL** — not used (no OAuth user flow)
-- **Request user authorization (OAuth) during installation** — leave unchecked
-- **Setup URL** — leave blank
-
-### Permissions (Repository)
-
-| Permission | Level |
-|-----------|-------|
-| Metadata | Read-only *(auto-selected)* |
-| Checks | Read & write |
-
-No other permissions are needed.
-
-### Subscribe to events
-
-Check both:
-- **Installation target**
-- **Installation repositories**
-
-### Where can this app be installed?
-
-Select **Only on this account** for a private self-hosted instance.
-
-### After creating
-
-On the app's settings page, note:
-- **App ID** → you'll set this as `GITHUB_APP_ID`
-- **Client ID** → you'll set this as `GITHUB_APP_CLIENT_ID`
-
-Generate a private key: scroll to **Private keys → Generate a private key**.
-GitHub downloads a `.pem` file in PKCS#1 format. The Worker requires PKCS#8.
-
-**Convert the private key** (Node.js, no OpenSSL required):
-
-```bash
-node -e "
-const c = require('crypto'), fs = require('fs');
-const key = c.createPrivateKey(fs.readFileSync(process.argv[1], 'utf8'));
-process.stdout.write(key.export({ type: 'pkcs8', format: 'pem' }));
-" your-app.private-key.pem > private-key-pkcs8.pem
+```bash file="create D1"
+npx wrangler d1 create coverage
```
-Or as a one-liner piped directly into wrangler (no file written to disk):
+Copy the `database_id` from the output into the `d1_databases` entry of `wrangler.json`:
-```bash
-node -e "
-const c = require('crypto'), fs = require('fs');
-const key = c.createPrivateKey(fs.readFileSync(process.argv[1], 'utf8'));
-process.stdout.write(key.export({ type: 'pkcs8', format: 'pem' }));
-" your-app.private-key.pem | npx wrangler secret put GITHUB_APP_PRIVATE_KEY
+```jsonc file="wrangler.json"
+{
+ // ...
+ "d1_databases": [
+ {
+ "binding": "DB",
+ "database_name": "coverage",
+ "database_id": "paste-your-id-here", // ← add this line
+ "migrations_dir": "migrations"
+ }
+ ],
+ // ...
+}
```
----
-
-## 6. Set Wrangler secrets
+Then apply the migration to your remote database:
-Set the GitHub App credentials:
-
-```bash
-npx wrangler secret put GITHUB_APP_ID # numeric App ID, e.g. 1234567
-npx wrangler secret put GITHUB_APP_CLIENT_ID # starts with "Iv23..."
-npx wrangler secret put GITHUB_APP_PRIVATE_KEY # if not piped above
-npx wrangler secret put GITHUB_WEBHOOK_SECRET # the value you generated in step 5
+```bash file="migrate"
+npm run db:migrate:remote
```
-The two Cloudflare Access secrets (collect these in Steps 7–8 below, then come back):
-
-```bash
-npx wrangler secret put CF_ACCESS_TEAM_DOMAIN # e.g. myteam.cloudflareaccess.com
-npx wrangler secret put CF_ACCESS_AUD # AUD tag UUID from the Access app
-```
+> [!NOTE]
+> The committed `wrangler.json` intentionally omits `database_id` so the Deploy to Cloudflare button can provision D1 automatically. For manual installs, add the field as shown. The custom domain is attached in the Cloudflare dashboard after first deploy — no `routes` entry is needed.
---
-## 7. Set up Cloudflare Zero Trust
-
-Go to **Cloudflare dashboard → Zero Trust**.
-
-On first visit, choose a **team name** (e.g. `myteam`). This becomes `myteam.cloudflareaccess.com` — your `CF_ACCESS_TEAM_DOMAIN`.
+## GitHub App
----
-
-## 8. Create a GitHub OAuth App (for dashboard login)
+> [!WARNING]
+> **Two separate integrations**
+> The **GitHub App** (this step) handles webhook events and API access. The **GitHub OAuth App** (next section) handles dashboard login via Cloudflare Access. Create them separately — do not conflate them.
-This is separate from the GitHub App created in Step 5. Cloudflare Access uses it to authenticate you when you visit the dashboard.
+From the account or org that will host the app, go to **Settings → Developer settings → GitHub Apps → New GitHub App** and fill in:
-GitHub → Settings → Developer settings → **OAuth Apps → New OAuth App**
+
+ | Field | Value |
+
+ | GitHub App name | Globally unique, e.g. your-coverage-tracker |
+ | Homepage URL | https://coverage-tracker.yourdomain.com |
+ | Webhook → Active | checked |
+ | Webhook URL | …/webhooks/github |
+ | Webhook secret | Generate 32 random bytes — save this value:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" |
+
+
-| Field | Value |
-|-------|-------|
-| Application name | `coverage-tracker-access` (any name) |
-| Homepage URL | `https://coverage-tracker.yourdomain.com` |
-| Authorization callback URL | `https://myteam.cloudflareaccess.com/cdn-cgi/access/callback` |
+Leave **Callback URL**, **OAuth during installation**, and **Setup URL** blank. Under repository permissions set **Metadata: read-only** and **Checks: read & write** — nothing else. Subscribe to both **Installation target** and **Installation repositories** events. For a private instance, choose **Only on this account**.
-Save the **Client ID** and generate a **Client Secret** — you'll need both in the next step.
+### Convert the private key
----
+GitHub downloads a PKCS#1 `.pem`; the Worker requires PKCS#8. Convert it with Node — no OpenSSL needed:
-## 9. Add GitHub as an identity provider in Zero Trust
-
-Zero Trust → **Settings → Authentication → Add new** → select **GitHub**
+```bash file="convert key"
+node -e "const c=require('crypto'), fs=require('fs');
+const key=c.createPrivateKey(fs.readFileSync(process.argv[1],'utf8'));
+process.stdout.write(key.export({type:'pkcs8',format:'pem'}));" \
+ your-app.private-key.pem | npx wrangler secret put GITHUB_APP_PRIVATE_KEY
+```
-Enter the OAuth App's Client ID and Client Secret from Step 8.
+From the app's settings page, note the **App ID** and **Client ID** — you set these as secrets next.
---
-## 10. Create the Cloudflare Access application
+## Cloudflare Access
-Zero Trust → **Access → Applications → Add an application → Self-hosted**
+In **Zero Trust**, choose a team name (becomes `myteam.cloudflareaccess.com`). Then create a **GitHub OAuth App** for dashboard login with callback URL `https://myteam.cloudflareaccess.com/cdn-cgi/access/callback`, and add GitHub as an identity provider in **Settings → Authentication** using that OAuth App's client id and secret.
-| Field | Value |
-|-------|-------|
-| App name | `coverage-tracker` |
-| Session duration | Your preference (e.g. 24 hours) |
-| Identity providers | GitHub (the one added in Step 9) |
+You will create **two** Access applications for the same hostname. The Dashboard app redirects visitors through GitHub login and sets a `CF_Authorization` session cookie, which the Worker verifies on subsequent browser API calls; the API Bypass app is more specific (`/api`), so Cloudflare applies it to machine callers — CI OIDC, webhooks, health checks, badges — that cannot complete the browser OAuth flow.
-You will create **two** Access applications for the same hostname — one that protects the dashboard and one that lets machine callers reach the API without going through the OAuth flow.
+
+ | App | Path | Policy |
+
+ | Dashboard (Allow) | blank — whole host | Allow → your email. Copy the AUD tag → CF_ACCESS_AUD |
+ | API Bypass | /api | Bypass → Everyone |
+
+
-### Application 1 — Dashboard (Allow)
+> [!WARNING]
+> **Critical invariant**
+> Never put an Access *Allow* policy on `/api/*`. Machine callers (CI OIDC, webhooks, health) must reach the Worker unauthenticated at the edge — API auth is enforced in code. The bypass only removes the edge OAuth redirect; no `/api/*` route is left unprotected.
-This application protects the SvelteKit dashboard. When a user visits the dashboard, Cloudflare redirects them through GitHub login and sets a `CF_Authorization` session cookie. The Worker reads this cookie to verify identity on subsequent browser API calls.
-
-| Field | Value |
-|-------|-------|
-| App name | `coverage-tracker` |
-| Application domain | `coverage-tracker.yourdomain.com` |
-| Path | *(leave blank — protects the entire hostname)* |
-| Session duration | Your preference (e.g. 24 hours) |
-| Identity providers | GitHub (the one added in Step 9) |
+---
-Add a policy:
-- **Policy name:** Allow deployer
-- **Action:** Allow
-- **Rule:** Emails → `your-email@example.com`
+## Secrets
-After saving, open the application settings and copy the **Application Audience (AUD) Tag** — this is your `CF_ACCESS_AUD`.
+Set every value with `wrangler secret put` — secrets are never committed. `wrangler.json` references names only.
-### Application 2 — API Bypass
+```bash file="wrangler secrets"
+npx wrangler secret put GITHUB_APP_ID # numeric, e.g. 1234567
+npx wrangler secret put GITHUB_APP_CLIENT_ID # starts with "Iv23…"
+npx wrangler secret put GITHUB_APP_PRIVATE_KEY # if not piped earlier
+npx wrangler secret put GITHUB_WEBHOOK_SECRET # from the GitHub App step
+npx wrangler secret put CF_ACCESS_TEAM_DOMAIN # myteam.cloudflareaccess.com
+npx wrangler secret put CF_ACCESS_AUD # AUD tag UUID from Access app
+```
-The CI runner (OIDC), GitHub webhooks (HMAC), health checks, and public badge endpoints are machine callers that cannot complete the browser OAuth flow. Create a second application that bypasses Access for all `/api` paths. Because the bypass application is more specific than the root application, Cloudflare applies it to all requests under `/api` while the root application continues to protect the dashboard.
+> [!WARNING]
+> **Never set in production**
+> `DEV_BYPASS_SECRET` belongs only in `.dev.vars` for local dev. Setting it via `wrangler secret put` silently disables all Access JWT verification.
-| Field | Value |
-|-------|-------|
-| App name | `coverage-tracker-api` |
-| Application domain | `coverage-tracker.yourdomain.com` |
-| Path | `/api` |
-| Identity providers | *(none required)* |
+---
-Add a policy:
-- **Policy name:** Bypass machine callers
-- **Action:** Bypass
-- **Rule:** Everyone
+## Deploy the Worker
-> The Worker enforces its own auth in code for every `/api/*` route: GitHub Actions OIDC for
-> `/api/ci/coverage` and `/api/baseline/*`, HMAC for `/api/webhooks/github`, and
-> Cloudflare Access JWT (from the session cookie) for `/api/projects/*` and `/api/admin/*`.
-> No `/api/*` route is unprotected — the bypass only removes the edge OAuth redirect so
-> non-browser clients can reach the Worker.
+Make sure dashboard dependencies are installed, then deploy. The command applies pending D1 migrations and compiles the SvelteKit dashboard before uploading.
-Now go back and set the two Access secrets if you haven't already:
+```bash file="deploy"
+npm --prefix dashboard install
+npm run deploy
-```bash
-npx wrangler secret put CF_ACCESS_TEAM_DOMAIN
-npx wrangler secret put CF_ACCESS_AUD
+# Deployed coverage-tracker triggers
+# coverage-tracker.yourdomain.com (custom domain)
```
----
-
-## 11. Deploy the Worker
+If **Bot Fight Mode** or **Browser Integrity Check** is enabled on your zone, add WAF skip rules for the machine-caller routes (this is separate from the Access bypass — Bot Fight Mode fires before Access):
-```bash
-npm run deploy
+```bash file="WAF skip rules"
+CLOUDFLARE_API_TOKEN= ZONE_DOMAIN=yourdomain.com \
+ node scripts/setup-waf-rules.mjs
```
-Expected output:
-
-```
-Deployed coverage-tracker triggers
- coverage-tracker.yourdomain.com (custom domain)
-```
+---
-> **Note:** `npm run deploy` applies any pending D1 migrations and then compiles the SvelteKit
-> dashboard (via the `build.command` in `wrangler.json`) before uploading. Make sure
-> dashboard dependencies are installed (`npm --prefix dashboard install`) before running
-> this step.
+## Install & verify
-### Add WAF skip rules (if Bot Fight Mode is enabled)
+From the GitHub App's settings page → **Install App** → choose the account or org → select repos. This fires an `installation: created` webhook that populates the `owners` and `projects` tables.
-**This is separate from the Access Bypass application you created in Step 10.** The Access Bypass app removes the OAuth redirect for machine callers. Bot Fight Mode is a zone-level WAF feature that blocks non-browser HTTP clients entirely — it fires before Access and cannot be bypassed by Access policy alone.
+Confirm the webhook landed:
-If you have **Bot Fight Mode** or **Browser Integrity Check** enabled on your Cloudflare zone, run the provided script to add a WAF skip rule for `/api/ci/coverage` and `/api/webhooks/github`:
+```bash file="verify"
+npx wrangler d1 execute DB --remote \
+ --command "SELECT * FROM owners"
-```bash
-CLOUDFLARE_API_TOKEN= ZONE_DOMAIN=yourdomain.com \
- node scripts/setup-waf-rules.mjs
+npx wrangler d1 execute DB --remote \
+ --command "SELECT full_slug, default_branch, badge_enabled FROM projects"
```
-The script is idempotent — safe to re-run. It requires a token with **Zone → WAF → Edit** permission. If Bot Fight Mode is off, skip this step.
+One row per account in `owners` and one per repo in `projects` means the install is complete. `badge_enabled` is `0` by default — opt in per repo below.
----
+If `owners` is empty, the webhook was not received or failed before reaching the database — check the Worker logs with `npx wrangler tail`. If `owners` has rows but `projects` is empty, the App was likely installed with **All repositories** selected and the payload contained no repo list — trigger a manual resync (the installation id is the number at the end of the app's **Configure** URL):
-## 12. Install the GitHub App on your repos
-
-Go to your GitHub App's settings page → **Install App** → select the account or org → choose the repos you want to track (or all repos).
-
-This fires an `installation: created` webhook to your Worker, which populates the `owners` and `projects` tables.
+```bash file="resync"
+curl -X POST https://coverage-tracker.yourdomain.com/api/admin/resync \
+ -H "Cf-Access-Jwt-Assertion: " \
+ -H "Content-Type: application/json" \
+ -d '{"installationId": YOUR_INSTALLATION_ID}'
+```
-> **If the webhook returns 500:** Check the Worker logs with `npx wrangler tail`, fix the
-> issue, clear the failed delivery from the dedup table, then redeliver:
+> [!NOTE]
+> **If the webhook returns 500**
+> Fix the issue shown by `npx wrangler tail`, clear the failed delivery from the dedup table, then redeliver from GitHub App → **Advanced → Recent Deliveries → Redeliver**:
>
> ```bash
> npx wrangler d1 execute DB --remote \
> --command "DELETE FROM webhook_deliveries WHERE delivery_id = 'THE-DELIVERY-ID'"
> ```
->
-> Then go to GitHub App → Advanced → Recent Deliveries → Redeliver.
----
+With both tables populated, the Worker is ready to accept metrics — have CI produce a coverage report and let the reporting Action pick it up. See [Generating coverage reports](https://coveragetracker.dev/docs#generating-coverage-reports) for the per-language commands.
-## 13. Verify
+---
-Run these two queries to confirm the webhook was received and processed correctly.
+## Ingest from CI
-**Owners table** — should show the GitHub account or org the App was installed on:
+Add a workflow step that runs after your test suite and posts coverage to `/api/ci/coverage` using a GitHub Actions OIDC token. There is **no static ingest secret**: the Worker verifies the token signature and checks the `repository` claim against your registered projects, so only your repos can push data. Re-running CI for the same commit is a safe no-op.
-```bash
-npx wrangler d1 execute DB --remote --command "SELECT * FROM owners"
+```bash file="upload step"
+coverage-tracker upload ./lcov.info
```
-Expected output (one row per account):
-
-```
-┌────┬───────────┬───────────┬──────────────┬────────────┬─────────────────────┐
-│ id │ github_id │ login │ type │ avatar_url │ created_at │
-├────┼───────────┼───────────┼──────────────┼────────────┼─────────────────────┤
-│ 1 │ 128944512 │ YourOrg │ Organization │ https://… │ 2026-06-24 04:09:40 │
-└────┴───────────┴───────────┴──────────────┴────────────┴─────────────────────┘
-```
-
-If this table is empty, the webhook was not received or failed before reaching the database. Check the Worker logs with `npx wrangler tail`, then see the troubleshooting note in Step 12.
+The reporting Action accepts LCOV, Cobertura XML, JaCoCo XML, or Go's native coverage profile from any CI — Jest, Vitest, pytest-cov, go test, JaCoCo, SimpleCov. See [Generating coverage reports](https://coveragetracker.dev/docs#generating-coverage-reports) for the per-language commands. Trend history is append-only; PR jobs read baselines but never write.
---
-**Projects table** — should show one row per registered repo:
+## Status badges
-```bash
-npx wrangler d1 execute DB --remote \
- --command "SELECT full_slug, default_branch, badge_enabled FROM projects"
-```
+Badge numbers are opt-in per repo. Find the project id, enable it, then paste the snippet into your README. Available metrics: `coverage`, `complexity`, `duplication`.
-Expected output:
+```bash file="enable badge"
+# find the project id
+npx wrangler d1 execute DB --remote \
+ --command "SELECT id, full_slug FROM projects"
-```
-┌────────────────────────────────────┬────────────────┬───────────────┐
-│ full_slug │ default_branch │ badge_enabled │
-├────────────────────────────────────┼────────────────┼───────────────┤
-│ YourOrg/repo-one │ main │ 0 │
-├────────────────────────────────────┼────────────────┼───────────────┤
-│ YourOrg/repo-two │ main │ 0 │
-└────────────────────────────────────┴────────────────┴───────────────┘
+# enable the public badge endpoint
+curl -X PATCH …/api/admin/projects/1/badge \
+ -H "Cf-Access-Jwt-Assertion: " \
+ -d '{"enabled": true}'
```
-`badge_enabled` is `0` (off) by default for all repos — this is intentional. See **Next steps** for how to opt a repo into the public badge endpoint.
+Then drop the shields.io endpoint badge into your README:
-If the owners table has a row but projects is empty, the GitHub App was likely installed with **All repositories** selected but the webhook payload contained no repo list. Trigger a manual resync:
-
-```bash
-# Find your installation ID in the owners table output or in:
-# GitHub → Your org → Settings → Third-party Access → GitHub Apps → Configure
-# The installation ID is the number at the end of the URL.
-
-curl -X POST https://coverage-tracker.yourdomain.com/api/admin/resync \
- -H "Cf-Access-Jwt-Assertion: " \
- -H "Content-Type: application/json" \
- -d '{"installationId": YOUR_INSTALLATION_ID}'
+```md file="README.md"
+
```
-If both tables have rows, the installation is complete and the Worker is ready to accept metric ingestion from CI.
-
---
-## 14. Dashboard
-
-The SvelteKit dashboard in `dashboard/` is compiled by `wrangler deploy` automatically (via the `build.command` in `wrangler.json`) and served as static assets by the same Worker. There is no separate Cloudflare Pages project.
-
-**After first deploy**, navigate to `https://coverage-tracker.yourdomain.com` — Cloudflare Access will prompt you to log in with the identity provider you configured in Step 9. Once authenticated, the dashboard loads and shows all registered repos.
+## Dashboard
-If the dashboard returns a blank page or 404, check:
-- The SvelteKit build completed without errors (`npm --prefix dashboard run build` locally)
-- The `assets.directory` in `wrangler.json` points to `./dashboard/build`
-- The `run_worker_first: ["/api/*"]` setting is present (so non-API paths serve the SPA)
-
-The dashboard is served from the same domain and behind the same Cloudflare Access application you created in Step 10. No separate Pages project or additional Access app is needed.
-
----
+The SvelteKit dashboard is compiled by `wrangler deploy` automatically and served as static assets by the same Worker — there is no separate Pages project. After first deploy, visit `https://coverage-tracker.yourdomain.com`; Cloudflare Access prompts you to log in with the identity provider you configured. Once authenticated, the dashboard shows all registered repos and their per-metric, per-branch trend charts.
-## Next steps
-
-- **Ingest metrics from CI:** Add a workflow to your repos that posts coverage data to `/api/ci/coverage` using a GitHub Actions OIDC token. See the reporting Action docs (`.github/actions/report/`).
-- **Enable badges:** Opt individual repos into the public badge endpoint:
- ```bash
- # Find the project ID first
- npx wrangler d1 execute DB --remote --command "SELECT id, full_slug FROM projects"
-
- # Enable badge for a specific project
- curl -X PATCH https://coverage-tracker.yourdomain.com/api/admin/projects/1/badge \
- -H "Cf-Access-Jwt-Assertion: " \
- -H "Content-Type: application/json" \
- -d '{"enabled": true}'
- ```
- Then add the badge to your repo's README:
- ```markdown
- 
- ```
-- **Resync if repos drift:** If you add/remove repos from the GitHub App installation and the webhook is missed, trigger a manual resync via `POST /api/admin/resync` with `{"installationId": YOUR_INSTALLATION_ID}`.
+> [!NOTE]
+> **Blank page or 404?**
+> Check the SvelteKit build completed, that `assets.directory` points to `./dashboard/build`, and that `run_worker_first: ["/api/*"]` is set so non-API paths serve the SPA.
diff --git a/docs/generating-coverage-reports.md b/docs/generating-coverage-reports.md
new file mode 100644
index 0000000..244375e
--- /dev/null
+++ b/docs/generating-coverage-reports.md
@@ -0,0 +1,101 @@
+
+
+# Generating coverage reports
+
+The reporting Action does not run your tests or install coverage tools. Your CI step produces a report file (LCOV, Cobertura XML, JaCoCo XML, or Go's native coverage profile); the Action reads it. This page shows the command for each supported language. Coverage Tracker not deployed yet? Start with the [Installation Guide](https://github.com/CoverageTracker/coverage-tracker/blob/main/docs/INSTALLATION.md).
+
+Format is detected automatically from file content — you don't set it explicitly:
+
+- First line `mode: set|count|atomic` → Go coverage profile
+- Starts with `TN:` / `SF:` → LCOV
+- XML root `` → Cobertura
+- XML root `` → JaCoCo
+
+`coverage-tool` is only required when the format resolves to Cobertura — it's the generator name, used to correct for known differences between Cobertura writers (see the Cobertura quirks table below).
+
+## Coverage
+
+| Language | Tool | Format | Command | Default path |
+|---|---|---|---|---|
+| Go | `go tool cover` | native profile | `go test -coverprofile=coverage.out ./...` | `coverage.out` |
+| Python | coverage.py | LCOV | `coverage run -m pytest && coverage lcov -o coverage.lcov` | `coverage.lcov` |
+| JS/TS | Istanbul (nyc / vitest / jest) | LCOV | `vitest run --coverage --coverage.reporter=lcov` | `coverage/lcov.info` |
+| Rust | cargo-llvm-cov | LCOV | `cargo llvm-cov --lcov --output-path lcov.info` | `lcov.info` |
+| C/C++ | gcovr | LCOV | `gcovr --lcov -o coverage.lcov` | `coverage.lcov` |
+| C# | coverlet | LCOV | `dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=lcov` | `coverage.info` |
+| Java | JaCoCo | JaCoCo XML | `mvn test jacoco:report` (Maven) or `./gradlew jacocoTestReport` (Gradle) | `target/site/jacoco/jacoco.xml` / `build/reports/jacoco/test/jacocoTestReport.xml` |
+| Bash | kcov | Cobertura | `kcov --include-path=. coverage/ ./script.sh` | *dynamic — set `coverage-path`* |
+| Clojure | Cloverage | LCOV | `lein cloverage --lcov` | `target/coverage/lcov.info` |
+| Dart | Flutter test / `coverage` pkg | LCOV | `flutter test --coverage` | `coverage/lcov.info` |
+| Elixir | ExCoveralls | LCOV | `mix coveralls.lcov` | `cover/lcov.info` |
+| Erlang | covertool | Cobertura | `rebar3 do eunit, cover, covertool generate` | *dynamic — set `coverage-path`* |
+| Haskell | hpc + hpc-codecov | LCOV | `cabal test --enable-coverage && hpc-codecov cabal:all -f lcov -o lcov.info` | `lcov.info` |
+| Lua | LuaCov + `luacov-reporter-lcov` | LCOV | `luacov -r lcov` | `luacov.report.out` |
+| Perl | Devel::Cover + lcov's `perl2lcov` | LCOV | `cover -test && perl2lcov -o coverage.lcov` | `coverage.lcov` |
+| PHP | PHPUnit | Cobertura | `XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-cobertura=coverage.xml` | `coverage.xml` |
+| Ruby | SimpleCov + `simplecov-lcov` | LCOV | (configure `SimpleCov::Formatter::LcovFormatter` in `spec_helper.rb`) then `rspec` | `coverage/lcov.info` |
+
+> [!NOTE]
+> **Go is parsed directly**
+> Go is read from its native coverage profile — no LCOV/Cobertura conversion, so no accuracy loss.
+
+> [!WARNING]
+> **JaCoCo isn't Cobertura**
+> Java's JaCoCo XML is a different schema, not a dialect of Cobertura XML. It's parsed with its own module in the reporter, not the Cobertura path.
+
+## Automatic report discovery
+
+The "Default path" columns on this page are real probe targets, not just conventions. When `coverage-path` is unset, the Action probes these paths in a fixed order and uses the first hit:
+
+1. `coverage.out`
+2. `coverage/lcov.info`
+3. `lcov.info`
+4. `coverage.lcov`
+5. `coverage.info`
+6. `cover/lcov.info`
+7. `target/coverage/lcov.info`
+8. `target/site/jacoco/jacoco.xml`
+9. `build/reports/jacoco/test/jacocoTestReport.xml`
+10. `coverage.xml`
+11. `luacov.report.out`
+
+If nothing is found, the Action fails with an error that lists every path it probed. An explicit `coverage-path` input always wins over probing.
+
+> [!NOTE]
+> **kcov and covertool need an explicit path**
+> Their default output paths contain dynamic segments (the script name, the app name) — write the file wherever the tool puts it and point `coverage-path` at it.
+
+## Complexity and duplication (optional)
+
+Only coverage is required. Complexity and duplication are opt-in: set the path explicitly (`complexity-path` / `duplication-path`), or write the report to the default location below and the Action picks it up automatically.
+
+| Metric | Tool | Command | Default path |
+|---|---|---|---|
+| Complexity — Go | gocyclo | `gocyclo -avg ./... > gocyclo.txt` | `gocyclo.txt` |
+| Complexity — Python | Radon | `radon cc -j . > radon.json` | `radon.json` |
+| Complexity — everything else | [Lizard](https://github.com/terryyin/lizard) | `lizard --xml > lizard-report.xml` | `lizard-report.xml` |
+| Duplication — any language | [jscpd](https://github.com/kucherenko/jscpd) | `npx jscpd . --reporters json --output ./jscpd-report` | `jscpd-report/jscpd-report.json` |
+
+There is no `complexity-tool` input — Radon (JSON), gocyclo (plain text), and Lizard (XML) are recognized by content shape. When probing finds more than one complexity file, precedence is `radon.json` → `gocyclo.txt` → `lizard-report.xml`; an explicit `complexity-path` overrides probing entirely.
+
+> [!WARNING]
+> **Thresholds make reports mandatory**
+> If `max-complexity` or `max-duplication` is configured and no report file for that metric is found — neither at the input path nor at a default location — the Action **fails** with an actionable error. A metric is skipped silently only when it is both unconfigured and absent.
+
+> [!NOTE]
+> **Java gets complexity for free**
+> JaCoCo's `COMPLEXITY` counter is already in the coverage report — no separate step needed for Java. An explicit `complexity-path` (or a probed complexity file) overrides the JaCoCo-derived value.
+
+## Cobertura quirks
+
+Cobertura XML is a shared DTD, not an enforced spec — generators disagree on two things the reporter corrects for based on `coverage-tool`:
+
+| `coverage-tool` | Trust `branch-rate`? | Notes |
+|---|---|---|
+| `gocover-cobertura` | No — always `0` | Go's block-based coverage can't map to branches |
+| `kcov` | Yes | |
+| `covertool` | Yes | |
+| `phpunit` | Yes | |
+| `gcovr` | Yes | |
+
+If your `coverage-tool` isn't listed, the reporter treats `branch-rate` as trustworthy by default — open an issue if that's wrong for your generator.