diff --git a/README.md b/README.md
index 34fe907..43e5441 100644
--- a/README.md
+++ b/README.md
@@ -111,6 +111,13 @@ cargo test # everything is offline; no keys needed
sudo install -m0755 target/release/daily-epub /usr/local/bin/
```
+The web UI uses Tailwind CSS v4, but Node is only a development dependency: the
+compiled stylesheet is committed and embedded in the Rust binary. After editing
+`src/web/tailwind.css`, a web template, or `src/web/static/app.js`, run
+`npm install` once and then `npm run css`. Commit both the source changes and
+`src/web/static/app.css`; `npm run css:check` verifies that the committed output
+is current.
+
### Commands
```
diff --git a/docs/plans/briefs/web-dashboard-v2/handoff-step2.md b/docs/plans/briefs/web-dashboard-v2/handoff-step2.md
new file mode 100644
index 0000000..3f96771
--- /dev/null
+++ b/docs/plans/briefs/web-dashboard-v2/handoff-step2.md
@@ -0,0 +1,151 @@
+# Step 2 handoff — Tailwind design system, theme toggle, reader pages
+
+Branch `v2-frontend`, worktree `/home/thallada/workspace/the-daily-epub-v2b`, one commit.
+Step 3 (dashboard restyle) and step 4 (TOC sidebar) build on what is described below.
+
+## Landed
+
+### Toolchain
+
+- `package.json` (root, `private`) pins `tailwindcss` and `@tailwindcss/cli` to `4.3.3` with
+ scripts `css`, `css:watch` and `css:check`. `package-lock.json` is committed.
+ `.gitignore` already carried `node_modules/` and `dev/`.
+- `src/web/tailwind.css` is the single source; `src/web/static/app.css` is the **committed,
+ minified build** and is still embedded with `include_str!`, so `cargo build`, `cargo test`
+ and the deployed binary never need Node.
+- `npm run css:check` rebuilds to a temp file and `diff -u`s it against the committed
+ `app.css`, exiting non-zero on drift (verified in both directions).
+- Workflow documented in `README.md` and `docs/runbooks/web-dashboard-rollout.md`
+ (`npm ci && npm run css:check` added to the rollout build step).
+
+### Assets
+
+- `web::static_asset` gained fixed match arms (no path joins) for `theme.js`,
+ `Newsreader.woff2` and `Newsreader-italic.woff2`. The asset tuple is now
+ `(&str, &'static [u8])` so text assets use `include_str!(...).as_bytes()` and the fonts use
+ `include_bytes!`; the SHA-256 ETag / 304 / long-cache path is unchanged and its test still
+ passes. URLs are flat: `/static/Newsreader.woff2`, not `/static/fonts/…`.
+- `@font-face` for both faces (`font-weight: 200 800`, `font-stretch: normal`,
+ `font-display: swap`, Google's latin `unicode-range`), plus a
+ `` for the regular face.
+ `document.fonts.check('600 48px Newsreader')` is true on a loaded page and the server log
+ shows no 404s.
+
+### Theme
+
+- `src/web/static/theme.js` runs synchronously in `
` (external file, CSP-safe): reads
+ `localStorage.theme`, sets or removes `document.documentElement.dataset.theme` before paint.
+- `app.js` drives `[data-theme-toggle]`: cycles **system → light → dark → system**, persists
+ (`removeItem` for system), updates the attribute, the SVG icon and the visible label, keeps
+ `aria-label="Theme: "`, and re-renders on `matchMedia` changes while in system mode.
+- Colours are semantic CSS variables on `:root`, duplicated into
+ `@media (prefers-color-scheme: dark) :root:not([data-theme="light"])` and
+ `:root[data-theme="dark"]`, exposed to Tailwind via `@theme inline`, with
+ `@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *))`.
+ `color-scheme` follows the theme.
+
+### Reader templates
+
+`layout.html` (ears row + centred masthead with the 1px rule above and the 3px double rule
+below + primary nav with `aria-current="page"` + admin nav row + flash + ``
++ footer + skip link), `issue_public.html`, `issue_full.html`, `issue_list.html`,
+`article.html`, `world.html`, `behind.html`, `login.html`, `account.html`, `error.html` and
+`_rating_widget.html` are all restyled with utilities. Every hook listed in the shared brief
+(`form.rating`, `button[data-label]`, `.active`, `aria-pressed`, `form[data-confirm]`,
+`details[id]`, `table[data-filter]`, `.table-filter`, `[data-refresh]`, `.scroll-x`, ids,
+names, actions) is intact.
+
+## What step 3 can use
+
+Tokens (`@theme inline`, so both a CSS variable and a Tailwind colour utility exist):
+
+| token | utilities |
+| --- | --- |
+| `--paper`, `--paper-2` | `bg-paper`, `bg-paper-2` |
+| `--ink`, `--ink-2`, `--muted` | `text-ink`, `text-ink-2`, `text-muted`, `bg-ink` |
+| `--rule`, `--rule-strong` | `border-rule`, `border-rule-strong` |
+| `--accent`, `--accent-hover` | `text-accent`, `border-accent`, `bg-accent`, `hover:text-accent-hover` |
+| `--loved`, `--good`, `--down`, `--warn` | `text-loved`, `text-good`, `text-down`, `text-warn` |
+| `--font-serif` / `--font-sans` / `--font-mono` | `font-serif`, `font-sans`, `font-mono` |
+
+Component classes in `@layer components`: `.btn`, `.btn-primary`, `.badge` + state modifiers
+(`selected loved good down excluded shortlisted admitted eligible cleared assessed triaged
+reason running requested ok degraded failed dry_run`), `.kv`, `.scroll-x`, `.prose-body`,
+`.rating`/`.rating-prompt`/`.rating-note`, `.flash`, `.error`, `.notice`, `.dashboard`,
+`.cards`/`.card`, `.muted`/`.meta`/`.num`, `.filters`, `.table-filter`, `.pager`/`.run-nav`/
+`.tabs`, `pre.block`/`pre.preview`/`pre.journal`, `.funnel`, `.spark` (+ `.s0`–`.s5`, `.line`).
+`@layer base` styles `table`/`thead`/`th`/`td` (sticky head on `bg-paper`, hairline rules,
+`hover:bg-paper-2`, tabular numerals) and all form controls.
+
+Repeated inline recipes step 3 will probably want: small label
+`font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted`; headline
+`font-semibold leading-[1.1] tracking-[-0.01em]`; reader measure
+`mx-auto max-w-[68ch] px-4 sm:px-6`; dashboard shell `.dashboard` (`max-w-7xl`, sans).
+
+## Deviations and fixes beyond the brief
+
+- **`.block` collided with Tailwind's `block` display utility.** The legacy component was
+ renamed to `pre.block`, which keeps both `
` dashboard usages working and
+ frees `class="block"` for real layout. Before this, the article prev/next cards rendered as
+ grey monospace boxes.
+- **`icon.hidden = …` does not work on SVG elements** (`hidden` is an `HTMLElement` IDL
+ attribute); the theme icon never changed. `app.js` now uses
+ `icon.toggleAttribute("hidden", …)`. `[hidden] { display:none !important }` stays in
+ `@layer base` so it beats display utilities.
+- `.prose-body` lists had no markers after Preflight; added `list-disc` / `list-decimal` and a
+ muted `::marker`.
+- The flash bar was flush to the viewport edge while the header used a gutter; `.flash` lost
+ its own `mx-auto max-w-7xl` and the layout wraps it in `mx-auto max-w-7xl px-4 sm:px-6`.
+- The article prev/next grid rendered an empty first cell when there is no previous article;
+ the placeholder `` is gone and the next card takes `sm:col-start-2`.
+- `examples/seed_dev_db.rs` now writes `profile_path` into the throwaway `./dev` directory and
+ seeds it from `data/profile.md`. Without this, saving on `/dashboard/profile` while running
+ the dev server **rewrites the repository's `data/profile.md`** and breaks
+ `curate::profile::tests::shipped_profile_and_opml_parse`. It also fixes a pre-existing
+ `clippy::manual_is_multiple_of` error in that example (`cargo clippy --all-targets
+ -D warnings` did not pass on this branch before).
+- `Page::new` for the Jobs and Users dashboard pages now passes `"jobs"` / `"users"` instead of
+ `"dashboard"`, so the admin nav highlights the right entry.
+- `FullEntry` gained `is_lead` so the full issue's lead story is set larger (`text-3xl` vs
+ `text-2xl`), matching what `issue_public.html` already did.
+- Fonts are served flat from `/static/.woff2` (the brief allowed either shape).
+- `issue_public.html` carries `{# step 1 adds summary/why here #}` at the point where the
+ backend step's summary/`why` markup should go; use the same treatment as `issue_full.html`
+ (summary as a body-size serif paragraph, then `border-l-2 border-accent pl-3 italic`).
+- The dashboard templates are deliberately unstyled beyond what the base/component layers give
+ them. They render without errors (all nine routes smoke-tested) but look plain — step 3.
+- The archive month label is still the raw `YYYY-MM` produced by `web::public`; changing it is
+ a backend concern and was left alone.
+
+## Verification
+
+- `npm run css` / `npm run css:check` — clean; drift is detected (exit 1) when `app.css` is
+ edited by hand.
+- `cargo fmt`, `cargo clippy --all-targets -- -D warnings` — clean.
+- `cargo test` — **475 passed, 0 failed** (439 lib + 9 + 2 + 3 + 4 + 7 + 9 + 2 integration,
+ one binary with 0 tests). Run outside the Codex sandbox, so the socket-binding tests ran too.
+ No test needed a cosmetic-class update.
+- Seeded dev server on `127.0.0.1:3599`. All of `/`, `/issues`, `/issues/2026-09-01` (legacy),
+ `/issues/2026-09-02`, `/issues/2026-09-02/articles/1`, `/…/world`, `/…/behind`, `/account`,
+ `/feed.xml` and the nine `/dashboard/*` routes return 200 with **no console errors and no
+ asset 404s**; `/issues/1999-01-01` returns the styled 404 page.
+- Theme toggle asserted with Playwright: system → light → dark → system, `localStorage.theme`
+ written/removed, `data-theme` set/removed, icon and label follow, state survives a reload.
+- Focus order checked: the skip link is the first tab stop and becomes visible; the toggle
+ shows the accent focus ring.
+
+### Screenshots
+
+`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/e963db53-510f-4312-ac87-460291a92781/scratchpad/step2-shots/`
+— 83 PNGs. `/`, `/issues`, `/issues/2026-09-02`, `/issues/2026-09-02/articles/1`, `/…/world`,
+`/…/behind`, `/account` and `/login` × {anonymous, reader, admin} × {light, dark} ×
+{1280px desktop, 390px mobile}, full page. Named
+`--[-mobile].png`. Plus `toggle-1..4.png` (the toggle cycle),
+`flash-light/dark.png`, `rating-active-light.png`, `focus-skiplink.png`, `focus-toggle.png`
+and `issues_1999-01-01-none-{light,dark}.png` (404 page).
+
+## Left for later
+
+- Step 1 adds `summary`/`why` to the public entries (marker comment in place).
+- Step 3 restyles `src/web/templates/dashboard/*` on top of this system.
+- Step 4 adds the collapsing table-of-contents sidebar to the signed-in issue pages.
diff --git a/docs/runbooks/web-dashboard-rollout.md b/docs/runbooks/web-dashboard-rollout.md
index 00efe03..b84d4e1 100644
--- a/docs/runbooks/web-dashboard-rollout.md
+++ b/docs/runbooks/web-dashboard-rollout.md
@@ -6,7 +6,15 @@ already be complete.
## 1. Build and install
+The deployed binary does not require Node. For UI development, edit
+`src/web/tailwind.css`, the templates, or `src/web/static/app.js`, then run
+`npm run css` and commit the regenerated `src/web/static/app.css` alongside the
+source. CI and release preparation should run `npm run css:check` to detect
+stylesheet drift.
+
```sh
+npm ci
+npm run css:check
cargo test
cargo build --release
sudo install -m0755 target/release/daily-epub /usr/local/bin/daily-epub
@@ -134,4 +142,3 @@ Candidate and assessment history is pruned after
`curation.ranking.telemetry_retention_days` (180 by default). Raise it now if the dashboard
should retain a longer article/run history, then reload `/dashboard/settings` and verify the
effective value. This changes future pruning only; it cannot restore rows already deleted.
-
diff --git a/examples/seed_dev_db.rs b/examples/seed_dev_db.rs
index 69c9960..9a4782c 100644
--- a/examples/seed_dev_db.rs
+++ b/examples/seed_dev_db.rs
@@ -371,13 +371,23 @@ async fn seed_near_misses(db: &Db, run_id: i64) -> anyhow::Result<()> {
fn write_config(dir: &Path) -> anyhow::Result {
let path = dir.join("config.toml");
let dir = dir.canonicalize()?;
+ // The profile lives inside the throwaway directory: saving it from
+ // /dashboard/profile must never rewrite the repository's data/profile.md.
+ let profile = dir.join("profile.md");
let toml = format!(
- "database_path = \"{db}\"\n\n[publish]\nepub_dir = \"{epubs}\"\nxtc_dir = \"{xtc}\"\n\n[server]\nbind = \"127.0.0.1:3599\"\npublic_url = \"http://127.0.0.1:3599\"\njobs_enabled = false\n",
+ "database_path = \"{db}\"\nprofile_path = \"{profile}\"\n\n[publish]\nepub_dir = \"{epubs}\"\nxtc_dir = \"{xtc}\"\n\n[server]\nbind = \"127.0.0.1:3599\"\npublic_url = \"http://127.0.0.1:3599\"\njobs_enabled = false\n",
db = dir.join("daily-epub.db").display(),
+ profile = profile.display(),
epubs = dir.join("epubs").display(),
xtc = dir.join("xtc").display(),
);
std::fs::write(&path, toml)?;
+ if !profile.exists() {
+ let shipped = Path::new("data/profile.md");
+ let body = std::fs::read_to_string(shipped)
+ .unwrap_or_else(|_| "# Reader profile\n\n## Who he is\n\nA developer.\n".to_string());
+ std::fs::write(&profile, body)?;
+ }
std::fs::create_dir_all(dir.join("epubs"))?;
std::fs::create_dir_all(dir.join("xtc"))?;
Ok(path)
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..8ed1554
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,313 @@
+{
+ "name": "the-daily-epub-v2b",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "devDependencies": {
+ "@tailwindcss/cli": "4.3.3",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "dev": true
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "dev": true
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@parcel/watcher": {
+ "version": "2.5.1",
+ "dev": true,
+ "hasInstallScript": true,
+ "dependencies": {
+ "detect-libc": "^1.0.3",
+ "is-glob": "^4.0.3",
+ "micromatch": "^4.0.5",
+ "node-addon-api": "^7.0.0"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher-linux-x64-glibc": "2.5.1",
+ "@parcel/watcher-linux-x64-musl": "2.5.1"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-glibc": {
+ "version": "2.5.1",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@parcel/watcher-linux-x64-musl": {
+ "version": "2.5.1",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@parcel/watcher/node_modules/detect-libc": {
+ "version": "1.0.3",
+ "dev": true,
+ "bin": {
+ "detect-libc": "bin/detect-libc.js"
+ }
+ },
+ "node_modules/@tailwindcss/cli": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz",
+ "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==",
+ "dev": true,
+ "dependencies": {
+ "@parcel/watcher": "2.5.1",
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "enhanced-resolve": "^5.24.1",
+ "mri": "^1.2.0",
+ "picocolors": "^1.1.1",
+ "tailwindcss": "4.3.3"
+ },
+ "bin": {
+ "tailwindcss": "dist/index.mjs"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "optionalDependencies": {
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "dev": true,
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "dev": true
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.2",
+ "dev": true,
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "dev": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "dev": true
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "dev": true
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "dev": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "dev": true
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "dev": true,
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "dev": true,
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "optionalDependencies": {
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "dev": true,
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ }
+ },
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "dev": true
+ },
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "dev": true
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "dev": true
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "dev": true
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "dev": true
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "dev": true
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "dev": true
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "dev": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e3e0fb0
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "private": true,
+ "scripts": {
+ "css": "tailwindcss -i src/web/tailwind.css -o src/web/static/app.css --minify",
+ "css:watch": "tailwindcss -i src/web/tailwind.css -o src/web/static/app.css --watch",
+ "css:check": "tmp=$(mktemp) && trap 'rm -f \"$tmp\"' EXIT && tailwindcss -i src/web/tailwind.css -o \"$tmp\" --minify && diff -u src/web/static/app.css \"$tmp\""
+ },
+ "devDependencies": {
+ "@tailwindcss/cli": "4.3.3",
+ "tailwindcss": "4.3.3"
+ }
+}
diff --git a/src/web/dashboard/jobs.rs b/src/web/dashboard/jobs.rs
index d4118f5..c1cc0a3 100644
--- a/src/web/dashboard/jobs.rs
+++ b/src/web/dashboard/jobs.rs
@@ -129,7 +129,7 @@ async fn jobs_template(
) -> Result {
let config = state.config();
let rows = jobs::list(&state.db, TABLE_ROWS).await.map_err(db_err)?;
- let mut page = Page::new("Jobs", viewer, "dashboard");
+ let mut page = Page::new("Jobs", viewer, "jobs");
page.flash = flash;
let today = config
.tz()
@@ -314,7 +314,7 @@ async fn show(
Ok(log) => (log, None),
Err(error) => (String::new(), Some(error)),
};
- let mut page = Page::new(format!("Job {id} · {}", row.name), viewer, "dashboard");
+ let mut page = Page::new(format!("Job {id} · {}", row.name), viewer, "jobs");
page.flash = take_flash(&session).await?;
Ok(Html(JobTemplate {
page,
diff --git a/src/web/dashboard/users.rs b/src/web/dashboard/users.rs
index 83724bb..7a9c138 100644
--- a/src/web/dashboard/users.rs
+++ b/src/web/dashboard/users.rs
@@ -56,7 +56,7 @@ async fn index(
open_sessions: row.open_sessions,
})
.collect();
- let mut page = Page::new("Users", viewer, "dashboard");
+ let mut page = Page::new("Users", viewer, "users");
page.flash = take_flash(&session).await?;
Ok(Html(UsersTemplate { page, users }))
}
diff --git a/src/web/issue.rs b/src/web/issue.rs
index d11af68..c4afc8c 100644
--- a/src/web/issue.rs
+++ b/src/web/issue.rs
@@ -639,6 +639,7 @@ struct FullEntry {
href: String,
source: String,
reading_minutes: i64,
+ is_lead: bool,
summary: String,
why: Option,
rating: Option,
@@ -771,6 +772,7 @@ pub async fn render_full(
href: article_href(date, pick.article.id),
source: pick.article.feed_title.clone(),
reading_minutes: pick.article.reading_minutes(),
+ is_lead: pick.is_lead,
summary: summary_for(&view.issue, pick)
.unwrap_or_default()
.to_string(),
@@ -1646,7 +1648,7 @@ mod tests {
assert_eq!(world.status(), StatusCode::OK);
let world = response_text(world).await;
assert!(world.contains("Something happened somewhere"));
- assert_eq!(world.matches("World Briefing").count(), 2); // page title + chapter heading
+ assert!(world.matches("World Briefing").count() >= 2); // page title + chapter heading
let behind = app
.oneshot(
@@ -1702,7 +1704,8 @@ mod tests {
.unwrap();
assert_eq!(issue.status(), StatusCode::OK);
let issue = response_text(issue).await;
- assert!(issue.contains("431 from n/a feeds"));
+ assert!(issue.contains("
431
"));
+ assert!(!issue.contains("n/a feeds"));
assert!(issue.contains("120"));
assert!(!issue.contains("0 from 0 feeds"));
assert!(issue.contains(&format!("/issues/{}/behind", source.meta.date)));
diff --git a/src/web/mod.rs b/src/web/mod.rs
index c0c5dd9..a328f8e 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -502,16 +502,34 @@ async fn static_asset(
axum::extract::Path(file): axum::extract::Path,
headers: axum::http::HeaderMap,
) -> Response {
- let asset = match file.as_str() {
- "app.css" => ("text/css; charset=utf-8", include_str!("static/app.css")),
+ let asset: (&str, &'static [u8]) = match file.as_str() {
+ "app.css" => (
+ "text/css; charset=utf-8",
+ include_str!("static/app.css").as_bytes(),
+ ),
"app.js" => (
"application/javascript; charset=utf-8",
- include_str!("static/app.js"),
+ include_str!("static/app.js").as_bytes(),
+ ),
+ "theme.js" => (
+ "application/javascript; charset=utf-8",
+ include_str!("static/theme.js").as_bytes(),
+ ),
+ "favicon.svg" => (
+ "image/svg+xml",
+ include_str!("static/favicon.svg").as_bytes(),
+ ),
+ "Newsreader.woff2" => (
+ "font/woff2",
+ include_bytes!("static/fonts/Newsreader.woff2"),
+ ),
+ "Newsreader-italic.woff2" => (
+ "font/woff2",
+ include_bytes!("static/fonts/Newsreader-italic.woff2"),
),
- "favicon.svg" => ("image/svg+xml", include_str!("static/favicon.svg")),
_ => return WebError::NotFound.into_response(),
};
- let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1.as_bytes())));
+ let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1)));
if headers
.get(header::IF_NONE_MATCH)
.and_then(|value| value.to_str().ok())
diff --git a/src/web/static/app.css b/src/web/static/app.css
index 88ac7dd..e619862 100644
--- a/src/web/static/app.css
+++ b/src/web/static/app.css
@@ -1,157 +1,2 @@
-:root { --bg:#fbfaf6; --fg:#171713; --muted:#68665f; --rule:#c9c5b9; --accent:#8b1e1e; --loved:#286a3b; --good:#34688a; --down:#943b35; color-scheme:light dark; }
-@media (prefers-color-scheme:dark) { :root { --bg:#171714; --fg:#eeeae0; --muted:#aaa69b; --rule:#4c4a44; --accent:#ef8c82; --loved:#75c58a; --good:#75aed0; --down:#e5867d; } }
-* { box-sizing:border-box; }
-body { margin:0 auto; padding:0 1rem; background:var(--bg); color:var(--fg); font:1rem/1.55 Georgia,serif; }
-a { color:var(--accent); }
-.masthead { max-width:72ch; margin:1.5rem auto .4rem; border-block:3px double var(--fg); padding:.45rem 0; text-align:center; font-size:2rem; font-weight:700; }
-.masthead a { color:inherit; text-decoration:none; }
-.primary,.admin,footer { max-width:72ch; margin:.7rem auto; text-align:center; color:var(--muted); }
-.admin { max-width:1200px; font-family:system-ui,sans-serif; }
-.flash { max-width:72rem; margin:1rem auto; padding:.75rem 1rem; border:1px solid var(--rule); }
-main { min-height:70vh; }
-.reading { max-width:72ch; margin:2rem auto; }
-.narrow { max-width:34rem; }
-.dateline,.stats,.byline,.comments,.strap,.meta,.social,.index-meta { color:var(--muted); }
-.issue section { border-top:1px solid var(--rule); margin-top:2rem; }
-.issue article { border-bottom:1px solid var(--rule); padding:.4rem 0 .8rem; }
-.issue .lead h3 { font-size:1.45rem; }
-.comments a,.downloads a { margin-right:.8rem; }
-.downloads { display:flex; flex-wrap:wrap; gap:.6rem; margin:1.5rem 0; }
-.button { border:1px solid currentColor; padding:.45rem .7rem; text-decoration:none; }
-.index-list { list-style:none; padding:0; }
-.index-entry { border-bottom:1px solid var(--rule); padding:.5rem 0 1rem; }
-.index-entry h3 { margin-bottom:.1rem; }
-.index-entry p { margin:.35rem 0; }
-.issue-chapters { border-block:1px solid var(--rule); margin:2rem 0; padding:1rem 0; text-align:center; font-size:1.15rem; }
-.colophon { max-width:none; margin:2rem 0; text-align:left; }
-.rule { border:0; border-top:1px solid var(--rule); margin:1.5rem 0; }
-.article-header h1 a { color:inherit; }
-.article-body img { display:block; max-width:100%; height:auto; margin:1rem auto; }
-.article-body pre { max-width:100%; overflow-x:auto; }
-.discussion { border-top:1px solid var(--rule); margin-top:2rem; padding-top:1rem; }
-.discussion blockquote { border-left:2px solid var(--rule); margin-left:.5rem; padding-left:1rem; }
-.discussion blockquote.reply { margin-left:1.5rem; }
-.comment-meta { color:var(--muted); font-size:.9rem; }
-.prev-next { display:grid; grid-template-columns:1fr 1fr; gap:1rem; margin:1.5rem 0; }
-.prev-next a:last-child { text-align:right; }
-form { display:grid; gap:.8rem; margin:1.5rem 0; }
-label { display:grid; gap:.25rem; }
-input,textarea,button { font:inherit; padding:.45rem; }
-.error { color:var(--down); }
-.dashboard { max-width:1200px; margin:2rem auto; font-family:system-ui,sans-serif; }
-.scroll-x { max-width:100%; overflow-x:auto; overscroll-behavior-inline:contain; }
-table { width:100%; border-collapse:collapse; font:0.9rem/1.35 system-ui,sans-serif; }
-th,td { border-bottom:1px solid var(--rule); padding:.35rem .5rem; text-align:left; }
-thead { position:sticky; top:0; background:var(--bg); }
-.badge { border:1px solid currentColor; border-radius:999px; padding:.1rem .45rem; }
-.badge.selected,.badge.loved { color:var(--loved); } .badge.good,.badge.assessed,.badge.triaged { color:var(--good); } .badge.down,.badge.excluded { color:var(--down); }
-.badge.shortlisted,.badge.admitted,.badge.eligible,.badge.cleared { color:var(--muted); }
-.kv { display:grid; grid-template-columns:minmax(10rem,1fr) 3fr; } .kv dt { color:var(--muted); }
-.funnel > * { background:var(--good); min-width:1px; margin:.2rem 0; }
-.spark { max-width:100%; height:auto; }
-.rating { display:flex; flex-wrap:wrap; align-items:center; gap:.3rem; }
-.rating button { border:1px solid var(--rule); background:transparent; color:var(--fg); cursor:pointer; }
-.rating button.active { background:var(--fg); color:var(--bg); border-color:var(--fg); }
-.rating button.clear { border:0; color:var(--muted); padding-inline:.25rem; text-decoration:underline; }
-.rating button.clear.active { background:transparent; color:var(--fg); font-weight:700; }
-.rating-prompt { margin-right:.25rem; }
-.rating-note { flex-basis:100%; }
-@media (max-width:40rem) { .masthead { font-size:1.55rem; } .kv { display:block; } }
-/* step 4: ratings and profile pages */
-.preference-summary { font-size:1.05rem; }
-.notice { border-left:3px solid var(--accent); padding:.4rem .8rem; }
-.how ol { padding-left:1.2rem; } .how li { margin:.4rem 0; }
-.tabs { margin:1rem 0; } .tabs a { padding:.3rem .7rem; border:1px solid var(--rule); text-decoration:none; } .tabs a.active { background:var(--fg); color:var(--bg); border-color:var(--fg); }
-form.filters { display:flex; flex-wrap:wrap; align-items:end; gap:.8rem; margin:1rem 0; } form.filters label { gap:.15rem; }
-form.inline { display:inline-flex; flex-wrap:wrap; align-items:center; gap:.6rem; margin:.8rem 0; }
-.contributions td { vertical-align:top; } .contributions .rating { margin-top:.3rem; font-size:.85rem; } .contributions .rating-prompt { display:none; }
-tr.superseded td { color:var(--muted); }
-.pager { display:flex; gap:1rem; align-items:center; margin:1rem 0; }
-.profile-grid { display:grid; grid-template-columns:3fr 2fr; gap:1.5rem; }
-.profile-form textarea { width:100%; font:.9rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; resize:vertical; }
-pre.preview { white-space:pre-wrap; overflow-wrap:anywhere; font:.85rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; border:1px solid var(--rule); padding:.6rem; max-height:28rem; overflow:auto; margin:0; }
-.versions pre.preview { max-height:6rem; border:0; padding:0; }
-.versions form { margin:0; }
-@media (max-width:60rem) { .profile-grid { display:block; } }
-/* step 3: dashboard reads (overview, runs, articles) */
-.dashboard h1 { font-size:1.5rem; margin:.5rem 0; }
-.dashboard h1 a { color:inherit; }
-.dashboard h2 { font-size:1.1rem; margin:1.75rem 0 .5rem; }
-.dashboard h3 { font-size:1rem; margin:.5rem 0; }
-.dashboard table td { vertical-align:top; }
-.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(20rem,1fr)); gap:1rem; margin:1rem 0; }
-.card { border:1px solid var(--rule); padding:.75rem 1rem; min-width:0; }
-.card h2 { margin-top:0; }
-.block { white-space:pre-wrap; overflow-wrap:anywhere; font:.85rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; margin:.5rem 0; }
-.muted { color:var(--muted); }
-.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
-.filters { display:flex; flex-wrap:wrap; gap:.5rem 1rem; align-items:end; margin:1rem 0; }
-.filters label { font-size:.85rem; }
-.filters input,.filters select { padding:.3rem; font-size:.9rem; }
-.run-nav { display:flex; flex-wrap:wrap; gap:1.5rem; margin:.5rem 0 1rem; }
-.funnel-cell { min-width:12rem; width:40%; }
-.funnel svg { display:block; }
-.funnel-table .reasons { color:var(--muted); font-size:.8rem; }
-.badge.reason,.badge.running,.badge.requested { color:var(--muted); }
-.badge.ok { color:var(--loved); } .badge.degraded { color:var(--good); } .badge.failed,.badge.dry_run { color:var(--down); }
-.pager { display:flex; flex-wrap:wrap; gap:1rem; margin:1rem 0; align-items:center; }
-details.signals summary { cursor:pointer; }
-details.signals summary a { display:inline; }
-.signals-body { margin:.5rem 0 .5rem 1rem; font-size:.85rem; }
-.signals-body table { width:auto; }
-.signals-body tr.muted td { color:var(--muted); }
-details.explain { margin:1rem 0; }
-.diff .before { color:var(--down); } .diff .after { color:var(--loved); }
-.picks,.jobs,.warnings { list-style:none; padding:0; }
-.picks li { border-bottom:1px solid var(--rule); padding:.5rem 0; }
-.budget meter { width:60%; max-width:14rem; height:.8rem; vertical-align:middle; margin-right:.5rem; }
-.table-filter { margin:.5rem 0; padding:.3rem; width:20rem; max-width:100%; }
-/* step 5: settings */
-.settings .card { border:1px solid var(--rule); padding:.75rem 1rem; margin:1rem 0; }
-.settings .card h2 { margin:.2rem 0 .6rem; font-size:1.1rem; font-family:ui-monospace,monospace; }
-.settings-form { display:block; }
-.setting { display:grid; grid-template-columns:minmax(14rem,1fr) 2fr; gap:.5rem 1rem; padding:.5rem 0; border-top:1px solid var(--rule); }
-.setting .help,.setting .default,.settings .meta { color:var(--muted); font-size:.85rem; margin:.2rem 0; }
-.setting-input input,.setting-input select,.setting-input textarea { width:100%; max-width:36rem; }
-.setting-input textarea { min-height:5rem; font-family:ui-monospace,monospace; }
-.setting-input input:disabled,.setting-input textarea:disabled { opacity:.6; }
-.setting .lines { white-space:pre-line; }
-.settings .banner { padding:.6rem .8rem; border:1px solid var(--down); margin:1rem 0; }
-.settings .reset,.settings .danger { font:inherit; font-size:.85rem; border:0; background:transparent; color:var(--accent); text-decoration:underline; padding:0; cursor:pointer; }
-.settings .actions { position:sticky; bottom:0; background:var(--bg); padding:.7rem 0; border-top:1px solid var(--rule); }
-.settings form.inline { display:none; }
-.settings .add-provider { max-width:36rem; }
-.history pre { margin:0; white-space:pre-wrap; font-size:.8rem; }
-@media (max-width:40rem) { .setting { display:block; } }
-/* step 6: jobs and stats */
-.sparklines { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1rem 1.5rem; margin:1rem 0; }
-.spark-figure { margin:0; min-width:0; } .spark-figure figcaption { font-size:.9rem; margin-bottom:.2rem; }
-.spark { display:block; width:100%; max-width:26rem; height:auto; border-bottom:1px solid var(--rule); }
-.spark .s0 { fill:var(--good); } .spark .s1 { fill:var(--loved); } .spark .s2 { fill:var(--down); } .spark .s3 { fill:var(--accent); } .spark .s4 { fill:var(--muted); } .spark .s5 { fill:var(--fg); }
-.spark .line { stroke:var(--accent); }
-.spark-axis { display:flex; justify-content:space-between; max-width:26rem; font-size:.75rem; }
-.spark-legend { list-style:none; padding:0; margin:.3rem 0 0; display:flex; flex-wrap:wrap; gap:.2rem .8rem; font-size:.8rem; }
-.spark-legend svg { vertical-align:middle; }
-.job-cards form.inline { margin:.4rem 0 0; } .job-cards label { display:inline-grid; }
-td.message,dd.message { overflow-wrap:anywhere; max-width:32rem; }
-pre.journal { max-height:40rem; }
-/* step 7: dark-mode and narrow-screen polish */
-.primary,.admin { display:flex; flex-wrap:wrap; justify-content:center; gap:.35rem 1rem; }
-.masthead { line-height:1.15; overflow-wrap:anywhere; }
-.scroll-x > table { min-width:max-content; }
-input,textarea,select,button { max-width:100%; border-color:var(--rule); background:var(--bg); color:var(--fg); }
-button { border-style:solid; }
-input:focus-visible,textarea:focus-visible,select:focus-visible,button:focus-visible,a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
-code,pre,.message { overflow-wrap:anywhere; }
-.prev-next a { overflow-wrap:anywhere; }
-@media (max-width:40rem) {
- body { padding-inline:.75rem; }
- .masthead { margin-top:.75rem; padding-inline:.25rem; }
- .primary,.admin { gap:.25rem .75rem; line-height:1.35; }
- .dashboard,.reading { margin-block:1.25rem; }
- .cards { grid-template-columns:minmax(0,1fr); }
- .card { padding:.65rem .75rem; }
- .prev-next { display:flex; flex-wrap:wrap; justify-content:space-between; }
- .rating { align-items:stretch; }
- .rating-prompt,.rating-note { flex-basis:100%; }
-}
+/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
+@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-serif:"Newsreader", "Iowan Old Style", "Palatino Linotype", Georgia, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;--spacing:.25rem;--container-md:28rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-medium:500;--font-weight-semibold:600;--tracking-normal:0em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, monospace;--color-paper:var(--paper);--color-paper-2:var(--paper-2);--color-ink:var(--ink)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])),[hidden]{display:none!important}html{background-color:var(--paper);color:var(--ink);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}body{background-color:var(--paper);--tw-leading:1.65;min-height:100vh;color:var(--ink);font-optical-sizing:auto;margin:0;font-family:Newsreader,Iowan Old Style,Palatino Linotype,Georgia,serif;font-size:1.0625rem;line-height:1.65}::selection{background:var(--accent)}@supports (color:color-mix(in lab, red, red)){::selection{background:color-mix(in oklab, var(--accent) 24%, transparent)}}a{color:var(--accent);-webkit-text-decoration-color:var(--rule);-webkit-text-decoration-color:var(--rule);text-decoration-color:var(--rule);text-underline-offset:4px;transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}@media (hover:hover){a:hover{color:var(--accent-hover)}}a:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--accent);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-offset-color:var(--paper);--tw-outline-style:none;outline-style:none}h1,h2,h3{color:var(--ink);font-optical-sizing:auto}button,input,select,textarea{font:inherit;border-radius:var(--radius-sm);border-style:var(--tw-border-style);border-width:1px;border-color:var(--rule);background-color:var(--paper);padding-inline:calc(var(--spacing) * 2.5);padding-block:calc(var(--spacing) * 1.5);color:var(--ink);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}:is(button,input,select,textarea)::placeholder{color:var(--muted)}:is(button,input,select,textarea):focus{border-color:var(--ink);--tw-outline-style:none;outline-style:none}:is(button,input,select,textarea):focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--accent);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-offset-color:var(--paper)}button{cursor:pointer}textarea{min-height:calc(var(--spacing) * 28)}table{border-collapse:collapse;width:100%;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:var(--leading-snug);line-height:var(--leading-snug);font-variant-numeric:tabular-nums}thead{z-index:10;background-color:var(--paper);position:sticky;top:0}th,td{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--rule);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * 2);text-align:left;vertical-align:top}th{--tw-font-weight:var(--font-weight-semibold);font-size:.72rem;font-weight:var(--font-weight-semibold);--tw-tracking:.1em;letter-spacing:.1em;color:var(--muted);text-transform:uppercase}tbody tr{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;transition-duration:.15s}@media (hover:hover){tbody tr:hover{background-color:var(--paper-2)}}code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important}}}@layer components{.btn,.button{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-sm);border-style:var(--tw-border-style);border-width:1px;border-color:var(--rule-strong);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--ink);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;background-color:#0000;justify-content:center;align-items:center;text-decoration-line:none;transition-duration:.15s;display:inline-flex}@media (hover:hover){:is(.btn,.button):hover{background-color:var(--ink);color:var(--paper)}}:is(.btn,.button):focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--accent);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-offset-color:var(--paper);--tw-outline-style:none;outline-style:none}.btn-primary{min-height:calc(var(--spacing) * 10);border-radius:var(--radius-sm);border-style:var(--tw-border-style);border-width:1px;border-color:var(--ink);background-color:var(--ink);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--paper);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.15s;justify-content:center;align-items:center;text-decoration-line:none;transition-duration:.15s;display:inline-flex}@media (hover:hover){.btn-primary:hover{border-color:var(--accent);background-color:var(--accent);color:var(--paper)}}.btn-primary:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--accent);--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-offset-color:var(--paper);--tw-outline-style:none;outline-style:none}.badge{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);border-radius:3.40282e38px;align-items:center;display:inline-flex}.badge.selected,.badge.loved,.badge.ok{color:var(--loved);background:var(--loved)}@supports (color:color-mix(in lab, red, red)){.badge.selected,.badge.loved,.badge.ok{background:color-mix(in oklab, var(--loved) 14%, transparent)}}.badge.good,.badge.assessed,.badge.triaged,.badge.degraded{color:var(--good);background:var(--good)}@supports (color:color-mix(in lab, red, red)){.badge.good,.badge.assessed,.badge.triaged,.badge.degraded{background:color-mix(in oklab, var(--good) 14%, transparent)}}.badge.down,.badge.excluded,.badge.failed,.badge.dry_run{color:var(--down);background:var(--down)}@supports (color:color-mix(in lab, red, red)){.badge.down,.badge.excluded,.badge.failed,.badge.dry_run{background:color-mix(in oklab, var(--down) 14%, transparent)}}.badge.shortlisted,.badge.admitted,.badge.eligible,.badge.cleared,.badge.reason,.badge.running,.badge.requested{color:var(--muted);background:var(--muted)}@supports (color:color-mix(in lab, red, red)){.badge.shortlisted,.badge.admitted,.badge.eligible,.badge.cleared,.badge.reason,.badge.running,.badge.requested{background:color-mix(in oklab, var(--muted) 14%, transparent)}}.kv{column-gap:calc(var(--spacing) * 5);row-gap:var(--spacing);grid-template-columns:minmax(9rem,1fr) 2fr;display:grid}.kv dt{color:var(--muted)}.kv dd{overflow-wrap:anywhere;min-width:0;margin:0}.scroll-x{overscroll-behavior-inline:contain;max-width:100%;overflow-x:auto}.scroll-x>table{min-width:max-content}.prose-body{--tw-leading:1.72;color:var(--ink);font-size:1.0625rem;line-height:1.72}.prose-body>*+*{margin-top:calc(var(--spacing) * 5)}.prose-body h2{margin-top:calc(var(--spacing) * 10);border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--rule);padding-top:calc(var(--spacing) * 4);font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-leading:1.15;--tw-font-weight:var(--font-weight-semibold);line-height:1.15;font-weight:var(--font-weight-semibold);--tw-tracking:-.01em;letter-spacing:-.01em}.prose-body h3{margin-top:calc(var(--spacing) * 8);font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height));--tw-leading:var(--leading-tight);line-height:var(--leading-tight);--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.prose-body a{color:var(--ink);text-decoration-line:underline;-webkit-text-decoration-color:var(--rule);-webkit-text-decoration-color:var(--rule);text-decoration-color:var(--rule);text-underline-offset:4px}@media (hover:hover){.prose-body a:hover{color:var(--ink);-webkit-text-decoration-color:var(--accent);-webkit-text-decoration-color:var(--accent);text-decoration-color:var(--accent)}}.prose-body img{margin-inline:auto;margin-block:calc(var(--spacing) * 7);border-radius:var(--radius-sm);text-align:center;max-width:100%;height:auto;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--muted);font-style:italic;display:block}.prose-body pre{border-radius:var(--radius-sm);background-color:var(--paper-2);max-width:100%;padding:calc(var(--spacing) * 4);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed);overflow-x:auto}.prose-body :not(pre)>code{border-radius:var(--radius-sm);background-color:var(--paper-2);padding-inline:var(--spacing);padding-block:calc(var(--spacing) * .5);font-size:.9em}.prose-body blockquote{margin-block:calc(var(--spacing) * 7);border-left-style:var(--tw-border-style);border-left-width:2px;border-color:var(--rule-strong);padding-left:calc(var(--spacing) * 5);color:var(--ink-2)}.prose-body ul{padding-left:calc(var(--spacing) * 6);list-style-type:disc}.prose-body ol{padding-left:calc(var(--spacing) * 6);list-style-type:decimal}.prose-body li::marker{color:var(--muted)}.prose-body li+li{margin-top:calc(var(--spacing) * 2)}.rating{margin-block:calc(var(--spacing) * 5);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));flex-wrap:wrap;align-items:center;display:flex}.rating-prompt{margin-right:calc(var(--spacing) * 3);--tw-tracking:.1em;letter-spacing:.1em;color:var(--muted);text-transform:uppercase;font-size:.72rem}.rating button[data-label]:not(.clear){min-height:calc(var(--spacing) * 10);border-color:var(--rule);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);color:var(--ink);background-color:#0000;border-radius:0;margin-left:-1px}.rating button[data-label]:not(.clear):first-child{border-top-left-radius:var(--radius-sm);border-bottom-left-radius:var(--radius-sm);margin-left:0}.rating button[data-label]:not(.clear):last-child{border-top-right-radius:var(--radius-sm);border-bottom-right-radius:var(--radius-sm)}@media (hover:hover){.rating button[data-label]:not(.clear):hover{z-index:10;border-color:var(--ink);background-color:var(--paper-2)}}.rating button[data-label]:not(.clear).active{z-index:10;border-color:var(--ink);background-color:var(--ink);color:var(--paper)}.rating button.clear{margin-left:calc(var(--spacing) * 2);min-height:calc(var(--spacing) * 10);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2);color:var(--muted);text-decoration-line:underline;-webkit-text-decoration-color:var(--rule);-webkit-text-decoration-color:var(--rule);text-decoration-color:var(--rule);text-underline-offset:4px;background-color:#0000;border-width:0}@media (hover:hover){.rating button.clear:hover{color:var(--ink)}}.rating button.clear.active{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);color:var(--ink)}.rating-note{margin-top:calc(var(--spacing) * 2);align-items:center;gap:calc(var(--spacing) * 2);color:var(--muted);flex-basis:100%;display:flex}.rating-note input{flex:1}.flash{margin-block:calc(var(--spacing) * 4);border-left-style:var(--tw-border-style);border-left-width:4px;border-color:var(--accent);background-color:var(--paper-2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.error{border-left-style:var(--tw-border-style);border-left-width:4px;border-color:var(--down);background-color:var(--paper-2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--down)}.notice{border-left-style:var(--tw-border-style);border-left-width:4px;border-color:var(--warn);background-color:var(--paper-2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--ink-2)}.dashboard{margin-inline:auto;margin-block:calc(var(--spacing) * 8);max-width:var(--container-7xl);padding-inline:calc(var(--spacing) * 4);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height));--tw-leading:var(--leading-normal);line-height:var(--leading-normal)}@media (min-width:40rem){.dashboard{padding-inline:calc(var(--spacing) * 6)}}.dashboard h1{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.dashboard h2{margin-top:calc(var(--spacing) * 7);font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.dashboard h3{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.cards{margin-block:calc(var(--spacing) * 4);gap:calc(var(--spacing) * 4);grid-template-columns:repeat(auto-fit,minmax(min(20rem,100%),1fr));display:grid}.card{border-style:var(--tw-border-style);border-width:1px;border-color:var(--rule);min-width:0;padding:calc(var(--spacing) * 4)}.muted,.meta{color:var(--muted)}.num{text-align:right;white-space:nowrap;--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.filters,form.filters,form.inline{margin-block:calc(var(--spacing) * 4);align-items:flex-end;gap:calc(var(--spacing) * 3);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));flex-wrap:wrap;display:flex}.filters label,form.filters label{gap:var(--spacing);display:grid}.table-filter{margin-block:calc(var(--spacing) * 2);width:calc(var(--spacing) * 80);max-width:100%;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.pager,.run-nav,.tabs{margin-block:calc(var(--spacing) * 4);align-items:center;gap:calc(var(--spacing) * 4);font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));flex-wrap:wrap;display:flex}pre.block,pre.preview,pre.journal{border-radius:var(--radius-sm);background-color:var(--paper-2);padding:calc(var(--spacing) * 3);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));white-space:pre-wrap;overflow-wrap:anywhere;overflow:auto}.funnel>*{margin-block:calc(var(--spacing) * .5);background-color:var(--good);min-width:1px}.spark{max-width:100%;height:auto}.spark .s0{fill:var(--good)}.spark .s1{fill:var(--loved)}.spark .s2{fill:var(--down)}.spark .s3{fill:var(--accent)}.spark .s4{fill:var(--muted)}.spark .s5{fill:var(--ink)}.spark .line{stroke:var(--accent)}@media (max-width:40rem){.kv{display:block}.kv dt{margin-top:calc(var(--spacing) * 2)}.rating{align-items:stretch}.rating-prompt,.rating-note{flex-basis:100%}.rating-prompt{margin-bottom:calc(var(--spacing) * 2)}}}@layer utilities{.collapse{visibility:collapse}.invisible{visibility:hidden}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.top-1{top:var(--spacing)}.top-3{top:calc(var(--spacing) * 3)}.top-4{top:calc(var(--spacing) * 4)}.top-20{top:calc(var(--spacing) * 20)}.top-40{top:calc(var(--spacing) * 40)}.top-120{top:calc(var(--spacing) * 120)}.left-3{left:calc(var(--spacing) * 3)}.z-50{z-index:50}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-0{margin:0}.mx-auto{margin-inline:auto}.my-4{margin-block:calc(var(--spacing) * 4)}.my-8{margin-block:calc(var(--spacing) * 8)}.my-12{margin-block:calc(var(--spacing) * 12)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-7{margin-top:calc(var(--spacing) * 7)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mt-16{margin-top:calc(var(--spacing) * 16)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.mb-9{margin-bottom:calc(var(--spacing) * 9)}.mb-10{margin-bottom:calc(var(--spacing) * 10)}.ml-1{margin-left:var(--spacing)}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-4{height:calc(var(--spacing) * 4)}.max-h-\[calc\(100vh-2rem\)\]{max-height:calc(100vh - 2rem)}.min-h-8{min-height:calc(var(--spacing) * 8)}.min-h-10{min-height:calc(var(--spacing) * 10)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-24{min-height:calc(var(--spacing) * 24)}.min-h-\[68vh\]{min-height:68vh}.w-4{width:calc(var(--spacing) * 4)}.w-64{width:calc(var(--spacing) * 64)}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[58ch\]{max-width:58ch}.max-w-\[68ch\]{max-width:68ch}.max-w-md{max-width:var(--container-md)}.grow{flex-grow:1}.-translate-y-20{--tw-translate-y:calc(var(--spacing) * -20);translate:var(--tw-translate-x) var(--tw-translate-y)}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.resize{resize:both}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-\[1fr_auto\]{grid-template-columns:1fr auto}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-1{row-gap:var(--spacing)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-b-\[3px\]{border-bottom-style:var(--tw-border-style);border-bottom-width:3px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-double{--tw-border-style:double;border-style:double}.border-accent{border-color:var(--accent)}.border-ink{border-color:var(--ink)}.border-rule{border-color:var(--rule)}.border-rule-strong{border-color:var(--rule-strong)}.border-transparent{border-color:#0000}.bg-accent{background-color:var(--accent)}.bg-ink{background-color:var(--ink)}.bg-paper{background-color:var(--paper)}.bg-paper-2{background-color:var(--paper-2)}.bg-transparent{background-color:#0000}.fill-none{fill:none}.stroke-current{stroke:currentColor}.p-0{padding:0}.p-4{padding:calc(var(--spacing) * 4)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-20{padding-block:calc(var(--spacing) * 20)}.pt-0{padding-top:0}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-7{padding-top:calc(var(--spacing) * 7)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-9{padding-bottom:calc(var(--spacing) * 9)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.font-sans{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}.font-serif{font-family:Newsreader,Iowan Old Style,Palatino Linotype,Georgia,serif}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[0\.68rem\]{font-size:.68rem}.text-\[0\.72rem\]{font-size:.72rem}.text-\[0\.95rem\]{font-size:.95rem}.leading-\[1\.1\]{--tw-leading:1.1;line-height:1.1}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[-0\.01em\]{--tw-tracking:-.01em;letter-spacing:-.01em}.tracking-\[-0\.02em\]{--tw-tracking:-.02em;letter-spacing:-.02em}.tracking-\[0\.1em\]{--tw-tracking:.1em;letter-spacing:.1em}.tracking-\[0\.08em\]{--tw-tracking:.08em;letter-spacing:.08em}.tracking-\[0\.12em\]{--tw-tracking:.12em;letter-spacing:.12em}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.text-accent{color:var(--accent)}.text-down{color:var(--down)}.text-good{color:var(--good)}.text-ink{color:var(--ink)}.text-ink-2{color:var(--ink-2)}.text-loved{color:var(--loved)}.text-muted{color:var(--muted)}.text-paper{color:var(--paper)}.text-warn{color:var(--warn)}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.decoration-rule{-webkit-text-decoration-color:var(--rule);-webkit-text-decoration-color:var(--rule);text-decoration-color:var(--rule)}.underline-offset-4{text-underline-offset:4px}.shadow,.shadow\/backfill,.shadow\/compatibility,.shadow\/dry,.shadow\/maintenance{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-accent{--tw-ring-color:var(--accent)}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-paper{--tw-ring-offset-color:var(--paper)}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.grayscale{--tw-grayscale:grayscale(100%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.group-hover\:text-accent:is(:where(.group):hover *){color:var(--accent)}.hover\:border-accent:hover{border-color:var(--accent)}.hover\:bg-paper-2:hover{background-color:var(--paper-2)}.hover\:text-accent:hover{color:var(--accent)}.hover\:text-accent-hover:hover{color:var(--accent-hover)}.hover\:text-ink:hover{color:var(--ink)}.hover\:decoration-accent:hover{-webkit-text-decoration-color:var(--accent);-webkit-text-decoration-color:var(--accent);text-decoration-color:var(--accent)}}.focus\:translate-y-0:focus{--tw-translate-y:0px;translate:var(--tw-translate-x) var(--tw-translate-y)}.focus\:border-ink:focus{border-color:var(--ink)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (min-width:40rem){.sm\:col-start-2{grid-column-start:2}.sm\:mt-12{margin-top:calc(var(--spacing) * 12)}.sm\:mt-14{margin-top:calc(var(--spacing) * 14)}.sm\:mt-16{margin-top:calc(var(--spacing) * 16)}.sm\:inline{display:inline}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-4{padding-block:calc(var(--spacing) * 4)}.sm\:pt-6{padding-top:calc(var(--spacing) * 6)}.sm\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:48rem){.md\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}}@media (min-width:80rem){.xl\:w-72{width:calc(var(--spacing) * 72)}}.\[\&_\.comment-meta\]\:font-sans .comment-meta{font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif}.\[\&_\.comment-meta\]\:text-sm .comment-meta{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.\[\&_\.comment-meta\]\:text-muted .comment-meta{color:var(--muted)}.\[\&_blockquote\.reply\]\:ml-6 blockquote.reply{margin-left:calc(var(--spacing) * 6)}.\[\&\>p\:first-child\]\:first-letter\:float-left>p:first-child:first-letter{float:left}.\[\&\>p\:first-child\]\:first-letter\:mt-1>p:first-child:first-letter{margin-top:var(--spacing)}.\[\&\>p\:first-child\]\:first-letter\:mr-2>p:first-child:first-letter{margin-right:calc(var(--spacing) * 2)}.\[\&\>p\:first-child\]\:first-letter\:font-serif>p:first-child:first-letter{font-family:Newsreader,Iowan Old Style,Palatino Linotype,Georgia,serif}.\[\&\>p\:first-child\]\:first-letter\:text-\[4\.6rem\]>p:first-child:first-letter{font-size:4.6rem}.\[\&\>p\:first-child\]\:first-letter\:leading-\[0\.72\]>p:first-child:first-letter{--tw-leading:.72;line-height:.72}.\[\&\>p\:first-child\]\:first-letter\:font-semibold>p:first-child:first-letter{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}}@font-face{font-family:Newsreader;font-style:normal;font-weight:200 800;font-stretch:100%;font-display:swap;src:url(/static/Newsreader.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Newsreader;font-style:italic;font-weight:200 800;font-stretch:100%;font-display:swap;src:url(/static/Newsreader-italic.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}:root{--paper:#f6f3ec;--paper-2:#ece7db;--ink:#1c1b18;--ink-2:#4f4b43;--muted:#7a7568;--rule:#d8d2c4;--rule-strong:#1c1b18;--accent:#a3231f;--accent-hover:#7c1a16;--loved:#2f6f46;--good:#2f6a8f;--down:#9c3f36;--warn:#8a6d1f;color-scheme:light}@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--paper:#151513;--paper-2:#1e1d1a;--ink:#ebe6da;--ink-2:#bfb9aa;--muted:#8f8a7d;--rule:#3a3833;--rule-strong:#ebe6da;--accent:#ef8c82;--accent-hover:#f7aaa2;--loved:#7fcb92;--good:#7fb8d8;--down:#ea8f84;--warn:#d9b45a;color-scheme:dark}}:root[data-theme=dark]{--paper:#151513;--paper-2:#1e1d1a;--ink:#ebe6da;--ink-2:#bfb9aa;--muted:#8f8a7d;--rule:#3a3833;--rule-strong:#ebe6da;--accent:#ef8c82;--accent-hover:#f7aaa2;--loved:#7fcb92;--good:#7fb8d8;--down:#ea8f84;--warn:#d9b45a;color-scheme:dark}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}
\ No newline at end of file
diff --git a/src/web/static/app.js b/src/web/static/app.js
index dfebedc..42b9b95 100644
--- a/src/web/static/app.js
+++ b/src/web/static/app.js
@@ -1,3 +1,44 @@
+const themeToggle = document.querySelector("[data-theme-toggle]");
+if (themeToggle) {
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
+ const readTheme = () => {
+ try {
+ const saved = localStorage.getItem("theme");
+ return saved === "light" || saved === "dark" ? saved : "system";
+ } catch (_) {
+ return "system";
+ }
+ };
+ const renderTheme = (theme) => {
+ themeToggle.setAttribute("aria-label", `Theme: ${theme}`);
+ themeToggle.querySelector("[data-theme-label]").textContent = theme[0].toUpperCase() + theme.slice(1);
+ themeToggle.querySelectorAll("[data-theme-icon]").forEach((icon) => {
+ // SVGElement has no `hidden` IDL attribute; toggle the content attribute.
+ icon.toggleAttribute("hidden", icon.getAttribute("data-theme-icon") !== theme);
+ });
+ };
+ const setTheme = (theme) => {
+ if (theme === "system") document.documentElement.removeAttribute("data-theme");
+ else document.documentElement.dataset.theme = theme;
+ try {
+ if (theme === "system") localStorage.removeItem("theme");
+ else localStorage.setItem("theme", theme);
+ } catch (_) {}
+ renderTheme(theme);
+ };
+ renderTheme(readTheme());
+ themeToggle.addEventListener("click", () => {
+ const current = readTheme();
+ setTheme(current === "system" ? "light" : current === "light" ? "dark" : "system");
+ });
+ media.addEventListener("change", () => {
+ if (readTheme() === "system") {
+ document.documentElement.removeAttribute("data-theme");
+ renderTheme("system");
+ }
+ });
+}
+
document.addEventListener("submit", (event) => {
const form = event.target;
if (form.matches("form.rating") && event.submitter) {
diff --git a/src/web/static/fonts/Newsreader-italic.woff2 b/src/web/static/fonts/Newsreader-italic.woff2
new file mode 100644
index 0000000..d8b3029
Binary files /dev/null and b/src/web/static/fonts/Newsreader-italic.woff2 differ
diff --git a/src/web/static/fonts/Newsreader.woff2 b/src/web/static/fonts/Newsreader.woff2
new file mode 100644
index 0000000..c6e5b0e
Binary files /dev/null and b/src/web/static/fonts/Newsreader.woff2 differ
diff --git a/src/web/static/fonts/OFL.txt b/src/web/static/fonts/OFL.txt
new file mode 100644
index 0000000..d2d4f40
--- /dev/null
+++ b/src/web/static/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
\ No newline at end of file
diff --git a/src/web/static/theme.js b/src/web/static/theme.js
new file mode 100644
index 0000000..5518ad0
--- /dev/null
+++ b/src/web/static/theme.js
@@ -0,0 +1,9 @@
+(() => {
+ try {
+ const theme = localStorage.getItem("theme");
+ if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
+ else document.documentElement.removeAttribute("data-theme");
+ } catch (_) {
+ document.documentElement.removeAttribute("data-theme");
+ }
+})();
diff --git a/src/web/tailwind.css b/src/web/tailwind.css
new file mode 100644
index 0000000..9e86238
--- /dev/null
+++ b/src/web/tailwind.css
@@ -0,0 +1,131 @@
+@import "tailwindcss";
+@source "../web/templates";
+@source "./static/app.js";
+
+@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
+
+@font-face {
+ font-family: "Newsreader";
+ font-style: normal;
+ font-weight: 200 800;
+ font-stretch: normal;
+ font-display: swap;
+ src: url("/static/Newsreader.woff2") format("woff2");
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: "Newsreader";
+ font-style: italic;
+ font-weight: 200 800;
+ font-stretch: normal;
+ font-display: swap;
+ src: url("/static/Newsreader-italic.woff2") format("woff2");
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+:root {
+ --paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#7a7568;
+ --rule:#d8d2c4; --rule-strong:#1c1b18; --accent:#a3231f; --accent-hover:#7c1a16;
+ --loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#8a6d1f; color-scheme:light;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme="light"]) {
+ --paper:#151513; --paper-2:#1e1d1a; --ink:#ebe6da; --ink-2:#bfb9aa; --muted:#8f8a7d;
+ --rule:#3a3833; --rule-strong:#ebe6da; --accent:#ef8c82; --accent-hover:#f7aaa2;
+ --loved:#7fcb92; --good:#7fb8d8; --down:#ea8f84; --warn:#d9b45a; color-scheme:dark;
+ }
+}
+
+:root[data-theme="dark"] {
+ --paper:#151513; --paper-2:#1e1d1a; --ink:#ebe6da; --ink-2:#bfb9aa; --muted:#8f8a7d;
+ --rule:#3a3833; --rule-strong:#ebe6da; --accent:#ef8c82; --accent-hover:#f7aaa2;
+ --loved:#7fcb92; --good:#7fb8d8; --down:#ea8f84; --warn:#d9b45a; color-scheme:dark;
+}
+
+@theme inline {
+ --color-paper:var(--paper); --color-paper-2:var(--paper-2); --color-ink:var(--ink);
+ --color-ink-2:var(--ink-2); --color-muted:var(--muted); --color-rule:var(--rule);
+ --color-rule-strong:var(--rule-strong); --color-accent:var(--accent);
+ --color-accent-hover:var(--accent-hover); --color-loved:var(--loved); --color-good:var(--good);
+ --color-down:var(--down); --color-warn:var(--warn);
+ --font-serif:"Newsreader", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
+ --font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+ --font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+@layer base {
+ [hidden] { display:none !important; }
+ html { @apply bg-paper text-ink antialiased; }
+ body { @apply m-0 min-h-screen bg-paper font-serif text-[1.0625rem] leading-[1.65] text-ink; font-optical-sizing:auto; }
+ ::selection { background:color-mix(in oklab, var(--accent) 24%, transparent); }
+ a { @apply text-accent decoration-rule underline-offset-4 transition-colors duration-150 hover:text-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
+ h1, h2, h3 { @apply text-ink; font-optical-sizing:auto; }
+ button, input, select, textarea { font:inherit; @apply rounded-sm border border-rule bg-paper px-2.5 py-1.5 text-ink transition-colors duration-150 placeholder:text-muted focus:border-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
+ button { @apply cursor-pointer; }
+ textarea { @apply min-h-28; }
+ table { @apply w-full border-collapse font-sans text-sm leading-snug; font-variant-numeric:tabular-nums; }
+ thead { @apply sticky top-0 z-10 bg-paper; }
+ th, td { @apply border-b border-rule px-2 py-2 text-left align-top; }
+ th { @apply text-[0.72rem] font-semibold uppercase tracking-[0.1em] text-muted; }
+ tbody tr { @apply transition-colors duration-150 hover:bg-paper-2; }
+ code, pre { @apply font-mono; }
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior:auto !important; transition-duration:0.01ms !important; } }
+}
+
+@layer components {
+ .btn, .button { @apply inline-flex min-h-10 items-center justify-center rounded-sm border border-rule-strong bg-transparent px-3 py-1.5 font-sans text-sm font-medium text-ink no-underline transition-colors duration-150 hover:bg-ink hover:text-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
+ .btn-primary { @apply inline-flex min-h-10 items-center justify-center rounded-sm border border-ink bg-ink px-3 py-1.5 font-sans text-sm font-medium text-paper no-underline transition-colors duration-150 hover:border-accent hover:bg-accent hover:text-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
+ .badge { @apply inline-flex items-center rounded-full px-2 py-0.5 font-sans text-xs font-medium; }
+ .badge.selected, .badge.loved, .badge.ok { color:var(--loved); background:color-mix(in oklab, var(--loved) 14%, transparent); }
+ .badge.good, .badge.assessed, .badge.triaged, .badge.degraded { color:var(--good); background:color-mix(in oklab, var(--good) 14%, transparent); }
+ .badge.down, .badge.excluded, .badge.failed, .badge.dry_run { color:var(--down); background:color-mix(in oklab, var(--down) 14%, transparent); }
+ .badge.shortlisted, .badge.admitted, .badge.eligible, .badge.cleared, .badge.reason, .badge.running, .badge.requested { color:var(--muted); background:color-mix(in oklab, var(--muted) 14%, transparent); }
+ .kv { @apply grid grid-cols-[minmax(9rem,1fr)_2fr] gap-x-5 gap-y-1; }
+ .kv dt { @apply text-muted; }
+ .kv dd { @apply m-0 min-w-0; overflow-wrap:anywhere; }
+ .scroll-x { @apply max-w-full overflow-x-auto; overscroll-behavior-inline:contain; }
+ .scroll-x > table { min-width:max-content; }
+ .prose-body { @apply text-[1.0625rem] leading-[1.72] text-ink; }
+ .prose-body > * + * { @apply mt-5; }
+ .prose-body h2 { @apply mt-10 border-t border-rule pt-4 text-2xl font-semibold leading-[1.15] tracking-[-0.01em]; }
+ .prose-body h3 { @apply mt-8 text-xl font-semibold leading-tight; }
+ .prose-body a { @apply text-ink underline decoration-rule underline-offset-4 hover:text-ink hover:decoration-accent; }
+ .prose-body img { @apply mx-auto my-7 block h-auto max-w-full rounded-sm text-center font-sans text-xs italic text-muted; }
+ .prose-body pre { @apply max-w-full overflow-x-auto rounded-sm bg-paper-2 p-4 text-sm leading-relaxed; }
+ .prose-body :not(pre) > code { @apply rounded-sm bg-paper-2 px-1 py-0.5 text-[0.9em]; }
+ .prose-body blockquote { @apply my-7 border-l-2 border-rule-strong pl-5 text-ink-2; }
+ .prose-body ul { @apply list-disc pl-6; }
+ .prose-body ol { @apply list-decimal pl-6; }
+ .prose-body li::marker { color:var(--muted); }
+ .prose-body li + li { @apply mt-2; }
+ .rating { @apply my-5 flex flex-wrap items-center font-sans text-sm; }
+ .rating-prompt { @apply mr-3 text-[0.72rem] uppercase tracking-[0.1em] text-muted; }
+ .rating button[data-label]:not(.clear) { @apply -ml-px min-h-10 rounded-none border-rule bg-transparent px-3 py-1.5 text-ink first:ml-0 first:rounded-l-sm last:rounded-r-sm hover:z-10 hover:border-ink hover:bg-paper-2; }
+ .rating button[data-label]:not(.clear).active { @apply z-10 border-ink bg-ink text-paper; }
+ .rating button.clear { @apply ml-2 min-h-10 border-0 bg-transparent px-2 text-muted underline decoration-rule underline-offset-4 hover:text-ink; }
+ .rating button.clear.active { @apply font-semibold text-ink; }
+ .rating-note { @apply mt-2 flex basis-full items-center gap-2 text-muted; }
+ .rating-note input { @apply flex-1; }
+ .flash { @apply my-4 border-l-4 border-accent bg-paper-2 px-4 py-3 font-sans text-sm; }
+ .error { @apply border-l-4 border-down bg-paper-2 px-4 py-3 font-sans text-sm text-down; }
+ .notice { @apply border-l-4 border-warn bg-paper-2 px-4 py-3 font-sans text-sm text-ink-2; }
+ .dashboard { @apply mx-auto my-8 max-w-7xl px-4 font-sans text-base leading-normal sm:px-6; }
+ .dashboard h1 { @apply text-2xl font-semibold; } .dashboard h2 { @apply mt-7 text-lg font-semibold; } .dashboard h3 { @apply font-semibold; }
+ .cards { @apply my-4 grid gap-4; grid-template-columns:repeat(auto-fit,minmax(min(20rem,100%),1fr)); }
+ .card { @apply min-w-0 border border-rule p-4; }
+ .muted, .meta { @apply text-muted; } .num { @apply whitespace-nowrap text-right tabular-nums; }
+ .filters, form.filters, form.inline { @apply my-4 flex flex-wrap items-end gap-3 font-sans text-sm; }
+ .filters label, form.filters label { @apply grid gap-1; }
+ .table-filter { @apply my-2 w-80 max-w-full font-sans text-sm; }
+ .pager, .run-nav, .tabs { @apply my-4 flex flex-wrap items-center gap-4 font-sans text-sm; }
+ pre.block, pre.preview, pre.journal { @apply overflow-auto whitespace-pre-wrap rounded-sm bg-paper-2 p-3 font-mono text-sm; overflow-wrap:anywhere; }
+ .funnel > * { @apply my-0.5 min-w-px bg-good; }
+ .spark { @apply h-auto max-w-full; }
+ .spark .s0 { fill:var(--good); } .spark .s1 { fill:var(--loved); } .spark .s2 { fill:var(--down); } .spark .s3 { fill:var(--accent); } .spark .s4 { fill:var(--muted); } .spark .s5 { fill:var(--ink); } .spark .line { stroke:var(--accent); }
+ @media (max-width:40rem) {
+ .kv { @apply block; } .kv dt { @apply mt-2; }
+ .rating { @apply items-stretch; } .rating-prompt, .rating-note { @apply basis-full; } .rating-prompt { @apply mb-2; }
+ }
+}
diff --git a/src/web/templates/account.html b/src/web/templates/account.html
index 888f289..a3ecec4 100644
--- a/src/web/templates/account.html
+++ b/src/web/templates/account.html
@@ -1 +1 @@
-{% extends "layout.html" %}{% block content %}
{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }} ({{ entry.domain }}) · {{ entry.reading_minutes }} min · {{ entry.word_count }} words
{% match entry.summary %}{% when Some with (summary) %}
{{ summary }}
{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}
Why it's here: {{ why }}
{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}
{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% if !entry.domain.is_empty() %} ({{ entry.domain }}){% endif %} · {{ entry.reading_minutes }} min
{% match entry.summary %}{% when Some with (summary) %}
{{ summary }}
{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}
Why it's here: {{ why }}
{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}
Discussion
{{ discussion|safe }}