From 4fe648b674bb46026b7f200a68d228dd7c5378bb Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:39:31 +0100 Subject: [PATCH] Move the website to its own repository (ludovic111/horizon-site) playhorizon.dev now deploys from the private ludovic111/horizon-site, which holds website/'s history. The files its build still reads from here move out of website/: - the design tokens to crates/horizon-theme/tokens.css (xtask tokens writes them, tokens_css_is_up_to_date checks them); - the roadmap's JSON to roadmap/roadmap.json (tools/roadmap.py build); - the benchmark fixtures to crates/horizon-telemetry-server/tests/fixtures/benchmarks/ (the site keeps a copy); - the web icons to docs/media/brand/web/ (xtask icons). pr_ready loses its website area and the tokens special case; rust_changes, the roadmap's links and every doc that pointed into website/ follow. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 1 - .gitattributes | 2 +- AGENTS.md | 3 +- CONTRIBUTING.md | 4 +- ROADMAP.md | 12 +- crates/horizon-server/panel/panel.css | 2 +- crates/horizon-telemetry-server/README.md | 2 +- .../horizon-telemetry-server/src/classes.rs | 2 +- .../horizon-telemetry-server/src/summary.rs | 6 +- .../tests}/fixtures/benchmarks/README.md | 5 +- .../tests}/fixtures/benchmarks/expected.json | 0 .../tests}/fixtures/benchmarks/items.jsonl | 0 .../horizon-telemetry-server/tests/summary.rs | 9 +- crates/horizon-theme/src/lib.rs | 16 +- .../src => crates/horizon-theme}/tokens.css | 0 docs/agent-control.md | 2 +- docs/api/README.md | 2 +- docs/design-system.md | 8 +- docs/media/banner.html | 8 +- docs/media/brand/README.md | 9 +- docs/media/brand/social/card.html | 6 +- .../media/brand/web}/apple-touch-icon.png | Bin .../media/brand/web}/favicon-32.png | Bin .../media/brand/web}/favicon.svg | 0 docs/telemetry.md | 2 +- docs/versioning.md | 2 +- launcher/src/backend.rs | 2 +- packaging/macos/background.html | 6 +- roadmap/README.md | 4 +- {website/data => roadmap}/roadmap.json | 12 +- roadmap/roadmap.toml | 12 +- tests/test_ci_rust_changes.py | 4 +- tests/test_pr_ready.py | 2 +- tools/ci/rust_changes.py | 7 +- tools/pr_ready.py | 18 +- tools/roadmap.py | 15 +- website/.gitignore | 3 - website/Caddyfile | 73 -- website/Dockerfile | 20 - website/LEGAL.md | 88 -- website/README.md | 153 --- website/build.mjs | 554 ----------- .../desktop/horizon-desktop-rd12.json | 774 --------------- .../desktop/horizon-desktop-rd32.json | 774 --------------- .../desktop/vanilla-desktop-rd12.json | 723 -------------- .../desktop/vanilla-desktop-rd32.json | 723 -------------- website/data/benchmarks/fleet.json | 73 -- .../macmini/horizon-macmini-rd12.json | 758 -------------- .../macmini/horizon-macmini-rd32.json | 758 -------------- .../macmini/vanilla-macmini-rd12.json | 707 ------------- .../macmini/vanilla-macmini-rd32.json | 707 ------------- .../thinkpad/horizon-thinkpad-rd12.json | 606 ----------- .../thinkpad/vanilla-thinkpad-r1-rd12.json | 371 ------- .../thinkpad/vanilla-thinkpad-r2-rd12.json | 371 ------- website/docs/agents.md | 126 --- website/docs/apis.md | 34 - website/docs/content-mods.md | 124 --- website/docs/contributing.md | 56 -- website/docs/faq.md | 75 -- website/docs/hosting.md | 162 --- website/docs/how-horizon-is-built.md | 39 - website/docs/index.md | 34 - website/docs/install.md | 52 - website/docs/make-a-mod.md | 88 -- website/docs/mod-code.md | 190 ---- website/docs/mod-manifest.md | 60 -- website/docs/modpacks.md | 52 - website/docs/mods.md | 64 -- website/docs/multiplayer.md | 43 - website/docs/nav.json | 10 - website/docs/performance.md | 74 -- website/docs/publish-a-mod.md | 57 -- website/docs/settings.md | 70 -- website/docs/test-a-mod.md | 59 -- website/docs/troubleshooting.md | 55 - website/package-lock.json | 642 ------------ website/package.json | 19 - website/public/benchmarks.json | 523 ---------- website/public/fonts/Geist-Medium.woff2 | Bin 46512 -> 0 bytes website/public/fonts/Geist-Regular.woff2 | Bin 45288 -> 0 bytes website/public/fonts/Geist-SemiBold.woff2 | Bin 46508 -> 0 bytes website/public/fonts/GeistMono-Regular.woff2 | Bin 50408 -> 0 bytes website/public/fonts/OFL-Geist.txt | 93 -- website/public/fonts/OFL-PixelifySans.txt | 93 -- website/public/fonts/PixelifySans-Bold.woff2 | Bin 27276 -> 0 bytes .../public/fonts/PixelifySans-Medium.woff2 | Bin 27308 -> 0 bytes .../public/fonts/PixelifySans-SemiBold.woff2 | Bin 27796 -> 0 bytes .../public/media/docs/servers-backups.webp | Bin 26474 -> 0 bytes website/public/media/docs/servers-create.webp | Bin 23592 -> 0 bytes .../public/media/docs/servers-dashboard.webp | Bin 35428 -> 0 bytes website/public/media/docs/servers-invite.webp | Bin 40448 -> 0 bytes website/public/media/launcher-800.webp | Bin 9498 -> 0 bytes website/public/media/launcher.webp | Bin 18696 -> 0 bytes website/public/media/nether-800.webp | Bin 31114 -> 0 bytes website/public/media/nether.webp | Bin 115484 -> 0 bytes website/public/media/night-800.webp | Bin 9182 -> 0 bytes website/public/media/night.webp | Bin 36558 -> 0 bytes website/public/media/scene-dark.png | Bin 757 -> 0 bytes website/public/media/scene-light.png | Bin 758 -> 0 bytes website/public/media/settings-800.webp | Bin 20210 -> 0 bytes website/public/media/settings.webp | Bin 40646 -> 0 bytes website/public/media/stone-dark.png | Bin 1400 -> 0 bytes website/public/media/stone-light.png | Bin 1413 -> 0 bytes website/public/media/sunset-800.webp | Bin 20878 -> 0 bytes website/public/media/sunset.webp | Bin 78340 -> 0 bytes website/public/media/survival-800.webp | Bin 19912 -> 0 bytes website/public/media/survival.webp | Bin 71260 -> 0 bytes website/public/media/underwater-800.webp | Bin 19864 -> 0 bytes website/public/media/underwater.webp | Bin 75120 -> 0 bytes website/public/media/village-800.webp | Bin 48818 -> 0 bytes website/public/media/village.webp | Bin 219600 -> 0 bytes website/public/og.html | 30 - website/public/og.png | Bin 50427 -> 0 bytes website/public/robots.txt | 4 - website/railway.json | 14 - website/scripts/art.mjs | 151 --- website/scripts/assets.mjs | 60 -- website/scripts/benchmarks.mjs | 240 ----- website/scripts/benchmarks.test.mjs | 91 -- website/scripts/shots.mjs | 99 -- website/serve.mjs | 42 - website/src/404.html | 27 - website/src/bench.js | 167 ---- website/src/benchmarks-page.js | 108 -- website/src/benchmarks.html | 105 -- website/src/chrome.js | 90 -- website/src/contributors.js | 39 - website/src/docs-page.js | 5 - website/src/docs.html | 55 - website/src/estimate.js | 110 -- website/src/estimate.test.mjs | 101 -- website/src/hardware.js | 88 -- website/src/hub-page.js | 270 ----- website/src/hub.html | 170 ---- website/src/hub.js | 457 --------- website/src/hub.test.mjs | 159 --- website/src/index.html | 216 ---- website/src/main.js | 265 ----- website/src/markdown.js | 203 ---- website/src/markdown.test.mjs | 25 - website/src/mods-page.js | 146 --- website/src/mods-submit.js | 80 -- website/src/mods.html | 37 - website/src/mods.js | 249 ----- website/src/mods.test.mjs | 110 -- website/src/privacy.html | 72 -- website/src/releases-page.js | 78 -- website/src/releases.html | 49 - website/src/releases.js | 156 --- website/src/releases.test.mjs | 13 - website/src/style.css | 937 ------------------ website/src/versions.js | 59 -- website/src/versions.test.mjs | 21 - xtask/src/main.rs | 14 +- 154 files changed, 97 insertions(+), 16630 deletions(-) rename {website/scripts => crates/horizon-telemetry-server/tests}/fixtures/benchmarks/README.md (72%) rename {website/scripts => crates/horizon-telemetry-server/tests}/fixtures/benchmarks/expected.json (100%) rename {website/scripts => crates/horizon-telemetry-server/tests}/fixtures/benchmarks/items.jsonl (100%) rename {website/src => crates/horizon-theme}/tokens.css (100%) rename {website/public => docs/media/brand/web}/apple-touch-icon.png (100%) rename {website/public => docs/media/brand/web}/favicon-32.png (100%) rename {website/public => docs/media/brand/web}/favicon.svg (100%) rename {website/data => roadmap}/roadmap.json (99%) delete mode 100644 website/.gitignore delete mode 100644 website/Caddyfile delete mode 100644 website/Dockerfile delete mode 100644 website/LEGAL.md delete mode 100644 website/README.md delete mode 100644 website/build.mjs delete mode 100644 website/data/benchmarks/desktop/horizon-desktop-rd12.json delete mode 100644 website/data/benchmarks/desktop/horizon-desktop-rd32.json delete mode 100644 website/data/benchmarks/desktop/vanilla-desktop-rd12.json delete mode 100644 website/data/benchmarks/desktop/vanilla-desktop-rd32.json delete mode 100644 website/data/benchmarks/fleet.json delete mode 100644 website/data/benchmarks/macmini/horizon-macmini-rd12.json delete mode 100644 website/data/benchmarks/macmini/horizon-macmini-rd32.json delete mode 100644 website/data/benchmarks/macmini/vanilla-macmini-rd12.json delete mode 100644 website/data/benchmarks/macmini/vanilla-macmini-rd32.json delete mode 100644 website/data/benchmarks/thinkpad/horizon-thinkpad-rd12.json delete mode 100644 website/data/benchmarks/thinkpad/vanilla-thinkpad-r1-rd12.json delete mode 100644 website/data/benchmarks/thinkpad/vanilla-thinkpad-r2-rd12.json delete mode 100644 website/docs/agents.md delete mode 100644 website/docs/apis.md delete mode 100644 website/docs/content-mods.md delete mode 100644 website/docs/contributing.md delete mode 100644 website/docs/faq.md delete mode 100644 website/docs/hosting.md delete mode 100644 website/docs/how-horizon-is-built.md delete mode 100644 website/docs/index.md delete mode 100644 website/docs/install.md delete mode 100644 website/docs/make-a-mod.md delete mode 100644 website/docs/mod-code.md delete mode 100644 website/docs/mod-manifest.md delete mode 100644 website/docs/modpacks.md delete mode 100644 website/docs/mods.md delete mode 100644 website/docs/multiplayer.md delete mode 100644 website/docs/nav.json delete mode 100644 website/docs/performance.md delete mode 100644 website/docs/publish-a-mod.md delete mode 100644 website/docs/settings.md delete mode 100644 website/docs/test-a-mod.md delete mode 100644 website/docs/troubleshooting.md delete mode 100644 website/package-lock.json delete mode 100644 website/package.json delete mode 100644 website/public/benchmarks.json delete mode 100644 website/public/fonts/Geist-Medium.woff2 delete mode 100644 website/public/fonts/Geist-Regular.woff2 delete mode 100644 website/public/fonts/Geist-SemiBold.woff2 delete mode 100644 website/public/fonts/GeistMono-Regular.woff2 delete mode 100644 website/public/fonts/OFL-Geist.txt delete mode 100644 website/public/fonts/OFL-PixelifySans.txt delete mode 100644 website/public/fonts/PixelifySans-Bold.woff2 delete mode 100644 website/public/fonts/PixelifySans-Medium.woff2 delete mode 100644 website/public/fonts/PixelifySans-SemiBold.woff2 delete mode 100644 website/public/media/docs/servers-backups.webp delete mode 100644 website/public/media/docs/servers-create.webp delete mode 100644 website/public/media/docs/servers-dashboard.webp delete mode 100644 website/public/media/docs/servers-invite.webp delete mode 100644 website/public/media/launcher-800.webp delete mode 100644 website/public/media/launcher.webp delete mode 100644 website/public/media/nether-800.webp delete mode 100644 website/public/media/nether.webp delete mode 100644 website/public/media/night-800.webp delete mode 100644 website/public/media/night.webp delete mode 100644 website/public/media/scene-dark.png delete mode 100644 website/public/media/scene-light.png delete mode 100644 website/public/media/settings-800.webp delete mode 100644 website/public/media/settings.webp delete mode 100644 website/public/media/stone-dark.png delete mode 100644 website/public/media/stone-light.png delete mode 100644 website/public/media/sunset-800.webp delete mode 100644 website/public/media/sunset.webp delete mode 100644 website/public/media/survival-800.webp delete mode 100644 website/public/media/survival.webp delete mode 100644 website/public/media/underwater-800.webp delete mode 100644 website/public/media/underwater.webp delete mode 100644 website/public/media/village-800.webp delete mode 100644 website/public/media/village.webp delete mode 100644 website/public/og.html delete mode 100644 website/public/og.png delete mode 100644 website/public/robots.txt delete mode 100644 website/railway.json delete mode 100644 website/scripts/art.mjs delete mode 100644 website/scripts/assets.mjs delete mode 100644 website/scripts/benchmarks.mjs delete mode 100644 website/scripts/benchmarks.test.mjs delete mode 100644 website/scripts/shots.mjs delete mode 100644 website/serve.mjs delete mode 100644 website/src/404.html delete mode 100644 website/src/bench.js delete mode 100644 website/src/benchmarks-page.js delete mode 100644 website/src/benchmarks.html delete mode 100644 website/src/chrome.js delete mode 100644 website/src/contributors.js delete mode 100644 website/src/docs-page.js delete mode 100644 website/src/docs.html delete mode 100644 website/src/estimate.js delete mode 100644 website/src/estimate.test.mjs delete mode 100644 website/src/hardware.js delete mode 100644 website/src/hub-page.js delete mode 100644 website/src/hub.html delete mode 100644 website/src/hub.js delete mode 100644 website/src/hub.test.mjs delete mode 100644 website/src/index.html delete mode 100644 website/src/main.js delete mode 100644 website/src/markdown.js delete mode 100644 website/src/markdown.test.mjs delete mode 100644 website/src/mods-page.js delete mode 100644 website/src/mods-submit.js delete mode 100644 website/src/mods.html delete mode 100644 website/src/mods.js delete mode 100644 website/src/mods.test.mjs delete mode 100644 website/src/privacy.html delete mode 100644 website/src/releases-page.js delete mode 100644 website/src/releases.html delete mode 100644 website/src/releases.js delete mode 100644 website/src/releases.test.mjs delete mode 100644 website/src/style.css delete mode 100644 website/src/versions.js delete mode 100644 website/src/versions.test.mjs diff --git a/.dockerignore b/.dockerignore index 417a1867ca..458330c933 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,6 @@ target/ .local/ **/node_modules/ -website/ benchmarks/ packs/ mods/ diff --git a/.gitattributes b/.gitattributes index 358aeae7ab..f9e1922d78 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,5 @@ # where Git for Windows' core.autocrlf would check them out with CRLF. docs/launcher-commands.md text eol=lf docs/horizon-ctl-commands.md text eol=lf -website/src/tokens.css text eol=lf +crates/horizon-theme/tokens.css text eol=lf docs/api/*-reference.md text eol=lf diff --git a/AGENTS.md b/AGENTS.md index dcd4ca5953..e1fa9e7fea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles), -`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`. +`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build +reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main. ## Build and test fast diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 21951c0835..03f3bd97bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,8 +4,8 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are welcome on [Discord](https://discord.gg/HcTeMZxe5E). -This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and -release them. Anything else (videos, Discord and other community material) lives elsewhere. +This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them. +The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere. **Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next ([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or diff --git a/ROADMAP.md b/ROADMAP.md index 4a2b906431..9b82f4fac7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,7 +134,7 @@ Better tools for mod makers: a test runner that needs no game window, templates `cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable. -More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md) +More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod) @@ -310,7 +310,7 @@ Horizon should run well on small machines: integrated graphics, the Steam Deck, Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page. -More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md) +More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance) @@ -600,7 +600,9 @@ You can port a mod to Horizon with your own AI agent on your own computer. Horiz
Technical -A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off. +A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off. + +More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
@@ -936,7 +938,9 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
Technical -`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark ` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711). +`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark ` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711). + +More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
diff --git a/crates/horizon-server/panel/panel.css b/crates/horizon-server/panel/panel.css index 7946b5f8fd..994f5eac6d 100644 --- a/crates/horizon-server/panel/panel.css +++ b/crates/horizon-server/panel/panel.css @@ -1,4 +1,4 @@ -/* The Horizon server panel. Colours and spacing are horizon-theme's tokens (website/src/tokens.css). */ +/* The Horizon server panel. Colours and spacing are horizon-theme's tokens (crates/horizon-theme/tokens.css). */ :root { --page: #fafafc; --plate-soft: #f3f3f8; diff --git a/crates/horizon-telemetry-server/README.md b/crates/horizon-telemetry-server/README.md index a401a72b08..c364e20c4c 100644 --- a/crates/horizon-telemetry-server/README.md +++ b/crates/horizon-telemetry-server/README.md @@ -30,7 +30,7 @@ The totals are grouped per version, OS, GPU vendor and render distance. Each gro ## Benchmarks summary -`src/summary.rs` makes it with the same rules as `website/scripts/benchmarks.mjs`, which bakes the page's fallback copy; both are tested against the same fixtures (`website/scripts/fixtures/benchmarks/`). +`src/summary.rs` makes it with the same rules as the website's `scripts/benchmarks.mjs` (ludovic111/horizon-site), which bakes the page's fallback copy; both are tested against the same fixtures (`tests/fixtures/benchmarks/` here, copied in the site's repository). - Only reports that compare: a version 1 `horizon-benchmark-report` of the standard flight over the four scenes, VSync off, no frame cap, on screen, from Horizon or plain vanilla, naming its GPU and CPU. - A group (GPU, CPU, render distance, game) is published only with **at least 3 different installs**. Each install counts once: the median of its runs, then the median across installs. diff --git a/crates/horizon-telemetry-server/src/classes.rs b/crates/horizon-telemetry-server/src/classes.rs index fce2826fdb..e39154b57a 100644 --- a/crates/horizon-telemetry-server/src/classes.rs +++ b/crates/horizon-telemetry-server/src/classes.rs @@ -1,6 +1,6 @@ //! The hardware classes the public output breaks results down by. Coarse on purpose: a class //! holds many machines, and a published cell still needs `MIN_INSTALLS` different installs. -//! `website/scripts/benchmarks.mjs` has the same functions, tested on the same fixtures. +//! The website's `scripts/benchmarks.mjs` (ludovic111/horizon-site) has the same functions, tested on the same fixtures. //! //! The raw values (the exact memory, the driver, the screen) stay in the database for the //! retention period and are never published. diff --git a/crates/horizon-telemetry-server/src/summary.rs b/crates/horizon-telemetry-server/src/summary.rs index 05f94f5a03..deb3564dda 100644 --- a/crates/horizon-telemetry-server/src/summary.rs +++ b/crates/horizon-telemetry-server/src/summary.rs @@ -1,7 +1,7 @@ //! `GET /v1/benchmarks/summary`: players' benchmark uploads, grouped for the website's Benchmarks page. //! -//! The same rules as `website/scripts/benchmarks.mjs` (which builds the page's baked copy), checked against the -//! same fixtures (`website/scripts/fixtures/benchmarks/`): +//! The same rules as the website's `scripts/benchmarks.mjs` (ludovic111/horizon-site; it builds the page's baked +//! copy), checked against the same fixtures (`tests/fixtures/benchmarks/`, which the site's test keeps a copy of): //! //! - Only reports that compare: `horizon-benchmark-report` version 1, kind `game`, Horizon or plain vanilla, the //! standard flight over the four scenes, VSync off, no frame cap, drawn on a screen, with a GPU and CPU name. @@ -198,7 +198,7 @@ struct Group { versions: BTreeSet, } -/// Players' groups of at least `min_installs` installs, as `website/scripts/benchmarks.mjs` makes them. +/// Players' groups of at least `min_installs` installs, as the website's `scripts/benchmarks.mjs` makes them. pub fn groups(uploads: &[Upload], min_installs: usize) -> Vec { let mut by: BTreeMap<(String, String, i64, &str), Group> = BTreeMap::new(); for u in uploads { diff --git a/website/scripts/fixtures/benchmarks/README.md b/crates/horizon-telemetry-server/tests/fixtures/benchmarks/README.md similarity index 72% rename from website/scripts/fixtures/benchmarks/README.md rename to crates/horizon-telemetry-server/tests/fixtures/benchmarks/README.md index 562646a6fc..51ebb9aac5 100644 --- a/website/scripts/fixtures/benchmarks/README.md +++ b/crates/horizon-telemetry-server/tests/fixtures/benchmarks/README.md @@ -1,7 +1,8 @@ # Benchmark summary fixtures -Shared by the website's aggregator test (`website/scripts/benchmarks.test.mjs`) and the telemetry server's -`GET /v1/benchmarks/summary` test: the same `benchmark` items in, the same groups out. +Used by the telemetry server's `GET /v1/benchmarks/summary` test (`tests/summary.rs`) and, as a copy, by the +website's aggregator test (`scripts/benchmarks.test.mjs` in ludovic111/horizon-site, which checks its copy against +this one on main): the same `benchmark` items in, the same groups out. Change both together. - `items.jsonl`: one stored item per line (`install_id`, `day`, `version`, `data`: a benchmark report). Group RTX 4080 + i7-13700KF at render distance 12 has three installs (one with two runs); the same install at 32, diff --git a/website/scripts/fixtures/benchmarks/expected.json b/crates/horizon-telemetry-server/tests/fixtures/benchmarks/expected.json similarity index 100% rename from website/scripts/fixtures/benchmarks/expected.json rename to crates/horizon-telemetry-server/tests/fixtures/benchmarks/expected.json diff --git a/website/scripts/fixtures/benchmarks/items.jsonl b/crates/horizon-telemetry-server/tests/fixtures/benchmarks/items.jsonl similarity index 100% rename from website/scripts/fixtures/benchmarks/items.jsonl rename to crates/horizon-telemetry-server/tests/fixtures/benchmarks/items.jsonl diff --git a/crates/horizon-telemetry-server/tests/summary.rs b/crates/horizon-telemetry-server/tests/summary.rs index 3232b4f87a..88ae2b283a 100644 --- a/crates/horizon-telemetry-server/tests/summary.rs +++ b/crates/horizon-telemetry-server/tests/summary.rs @@ -1,5 +1,5 @@ -//! `GET /v1/benchmarks/summary` against the website's fixtures (website/scripts/fixtures/benchmarks/): the same -//! uploads must give the groups `website/scripts/benchmarks.mjs` gives. +//! `GET /v1/benchmarks/summary` against the shared fixtures (tests/fixtures/benchmarks/): the same uploads must +//! give the groups the website's `scripts/benchmarks.mjs` (ludovic111/horizon-site) gives on its copy of them. use std::sync::Arc; use std::sync::atomic::AtomicU32; @@ -15,10 +15,7 @@ use http_body_util::BodyExt; use serde_json::Value; use tower::ServiceExt; -const FIXTURES: &str = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../website/scripts/fixtures/benchmarks" -); +const FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/benchmarks"); fn items() -> Vec { std::fs::read_to_string(format!("{FIXTURES}/items.jsonl")) diff --git a/crates/horizon-theme/src/lib.rs b/crates/horizon-theme/src/lib.rs index ca4704ead1..b41536e5e2 100644 --- a/crates/horizon-theme/src/lib.rs +++ b/crates/horizon-theme/src/lib.rs @@ -1,6 +1,6 @@ //! Horizon's design tokens, the one source of truth for the launcher (`launcher/src/theme.rs`), -//! the game's on-screen keyboard (`horizon-client`'s `ui_theme`) and the website (`website/src/tokens.css`, -//! generated from [`css`] by `cargo run -p xtask -- tokens`). Change a value here and all three +//! the game's on-screen keyboard (`horizon-client`'s `ui_theme`) and the website (`tokens.css` in this crate, +//! generated from [`css`] by `cargo run -p xtask -- tokens`, which the site's build reads from main). Change a value here and all three //! follow. The system is described in `docs/design-system.md`. //! //! Cool neutrals in two appearances and one violet accent; Geist and Geist Mono only, bundled @@ -528,7 +528,7 @@ pub fn contrast(a: u32, b: u32) -> f32 { (x.max(y) + 0.05) / (x.min(y) + 0.05) } -/// `website/src/tokens.css`: every token as a CSS custom property, the light palette by +/// `tokens.css`: every token as a CSS custom property, the light palette by /// default, the dark one when the system asks for it or the page sets `data-theme="dark"`. pub fn css() -> String { let mut s = String::new(); @@ -771,15 +771,15 @@ mod tests { assert!((bezier([0., 0., 1., 1.], 0.3) - 0.3).abs() < 1e-3); } - /// `website/src/tokens.css` is what [`css`] writes; regenerate it with - /// `cargo run -p xtask -- tokens`. + /// `tokens.css` (beside Cargo.toml, read by the website's build) is what [`css`] writes; + /// regenerate it with `cargo run -p xtask -- tokens`. #[test] - fn website_tokens_are_up_to_date() { - let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../website/src/tokens.css"); + fn tokens_css_is_up_to_date() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tokens.css"); let written = std::fs::read_to_string(path).unwrap_or_default(); assert!( written == css(), - "website/src/tokens.css is out of date: run `cargo run -p xtask -- tokens`" + "crates/horizon-theme/tokens.css is out of date: run `cargo run -p xtask -- tokens`" ); } } diff --git a/website/src/tokens.css b/crates/horizon-theme/tokens.css similarity index 100% rename from website/src/tokens.css rename to crates/horizon-theme/tokens.css diff --git a/docs/agent-control.md b/docs/agent-control.md index 903a48b2f1..8573bb7115 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -3,7 +3,7 @@ A player's own AI agent (Claude Code, Codex, Cursor, any MCP client) or a script can do everything a player does with the mouse and keyboard, in the launcher and in the game. This page covers developers: the surfaces, the full inventory, and the rules every new command follows. -Players read [Control Horizon with your AI agent](../website/docs/agents.md) +Players read [Control Horizon with your AI agent](https://playhorizon.dev/docs/agents) (playhorizon.dev/docs/agents). The command lists are generated, so they can't drift: diff --git a/docs/api/README.md b/docs/api/README.md index ef3649105f..ce28dc2629 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -53,4 +53,4 @@ Every page has the same parts: **Overview** (what you can build), **Guide** (whe The website shows these pages at [playhorizon.dev/docs/api](https://playhorizon.dev/docs/api), built from this folder, and a summary for players and authors at [playhorizon.dev/docs/apis](https://playhorizon.dev/docs/apis) -(`website/docs/apis.md`). +(in the website's repository, ludovic111/horizon-site). diff --git a/docs/design-system.md b/docs/design-system.md index 66767c0b78..0b171d4c26 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -8,8 +8,8 @@ Horizon's own settings are pages built from vanilla's widgets, the way mods add The tokens live in one place, `crates/horizon-theme`. The launcher (`launcher/src/theme.rs`) and the game (`crates/horizon-client/src/ui_theme.rs`) read them as Rust constants, and the website reads them as CSS custom properties generated from the same crate -(`cargo run -p xtask -- tokens` writes `website/src/tokens.css`; a test fails when the file is -out of date). Change a value in the crate and all three follow. +(`cargo run -p xtask -- tokens` writes `crates/horizon-theme/tokens.css`, which the website's build +reads from main; a test fails when the file is out of date). Change a value in the crate and all three follow. ## Where it comes from @@ -140,7 +140,7 @@ Geist (400, 500, 600) and Geist Mono (400), bundled under the SIL Open Font Lice landscape banner with the hero, the version picker and Play; Mods holds the mod, resource pack and modpack tabs; Versions and Patch notes are lists of panels; Settings is grouped panels of rows. Dark by default, Light or System in Settings (Appearance). -- **Website** (`website/`): the Blocks look below. The same page structure as before (what it +- **Website** (ludovic111/horizon-site): the Blocks look below. The same page structure as before (what it is, screenshots, parity, launcher, patch notes, community), headings in Pixelify Sans, lists as stacks of panels, the stone texture behind the page and Horizon's pixel landscape (a dawn or a sunset) behind the hero. Light and dark follow the system, with a toggle. A giant wordmark in @@ -177,7 +177,7 @@ of the main action. The game's menus keep `LIGHT` and `DARK`. (`TILE_SHADES`), never Minecraft's stone; the landscape is a banded sky, a sun cut in slices like the mark, hills and square trees (a sunset in dark, a dawn in light). The launcher bakes them in `launcher/src/art.rs` at the window's scale so each texel covers whole device pixels; the website - draws them in `website/scripts/art.mjs`. + draws them in its `scripts/art.mjs`. - **Icons**: Pixelarticons (MIT), 24 unit pixel SVGs in `crates/horizon-theme/icons/pixel/`, tinted with the text colour. - **Contrast**: every text colour meets WCAG AA (4.5:1) on the panels, the page and the lightest diff --git a/docs/media/banner.html b/docs/media/banner.html index 47037a7e23..2f727e8bdf 100644 --- a/docs/media/banner.html +++ b/docs/media/banner.html @@ -1,10 +1,10 @@ + Rendered with Playwright (see docs/media/brand/README.md). --> - - -
- -
HorizonPLAYHORIZON.DEV
-

The game you know, rebuilt in Rust.

-

Minecraft: Java Edition 26.3, checked against vanilla by differential harnesses. Unofficial; not associated with Mojang or Microsoft.

-
- - diff --git a/website/public/og.png b/website/public/og.png deleted file mode 100644 index 1b9f270e71fa3d2a577a91c5e4317bc6591467a1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50427 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 Pt&ysVnhH=ktEm45s;;fB diff --git a/website/public/robots.txt b/website/public/robots.txt deleted file mode 100644 index 7cd2cc71e5..0000000000 --- a/website/public/robots.txt +++ /dev/null @@ -1,4 +0,0 @@ -User-agent: * -Allow: / - -Sitemap: https://playhorizon.dev/sitemap.xml diff --git a/website/railway.json b/website/railway.json deleted file mode 100644 index 3bbe9dfb37..0000000000 --- a/website/railway.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "DOCKERFILE", - "dockerfilePath": "Dockerfile", - "watchPatterns": ["/website/**", "/README.md", "/launcher/links.json", "/docs/api/**"] - }, - "deploy": { - "healthcheckPath": "/", - "healthcheckTimeout": 60, - "restartPolicyType": "ON_FAILURE", - "restartPolicyMaxRetries": 5 - } -} diff --git a/website/scripts/art.mjs b/website/scripts/art.mjs deleted file mode 100644 index 160d649148..0000000000 --- a/website/scripts/art.mjs +++ /dev/null @@ -1,151 +0,0 @@ -// Horizon's pixel art for the website, drawn in code like the launcher's (launcher/src/art.rs): -// the stone tile behind the pages and the landscape behind the hero, in both appearances. -// Each texel is one pixel of the PNG; the CSS scales them up with `image-rendering: pixelated`. -// Written by `npm run assets` (or `node scripts/art.mjs`) into public/media/. -import { readFile } from "node:fs/promises"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import sharp from "sharp"; - -const site = join(dirname(fileURLToPath(import.meta.url)), ".."); -const repo = join(site, ".."); - -// The same small fixed random sequence as art.rs (xorshift), so the art is the same every time. -function rng(seed) { - let x = seed >>> 0; - return () => { - x = (x ^ (x << 13)) >>> 0; - x = (x ^ (x >>> 17)) >>> 0; - x = (x ^ (x << 5)) >>> 0; - return Math.fround((x >>> 8) / (1 << 24)); - }; -} - -const channels = (c) => [(c >> 16) & 255, (c >> 8) & 255, c & 255]; -const shade = (c, by) => channels(c).reduce((out, v) => (out << 8) | Math.min(255, Math.max(0, v + by)), 0); -const mix = (a, b, t) => { - const [x, y] = [channels(a), channels(b)]; - return x.reduce((out, v, i) => (out << 8) | Math.round(v + (y[i] - v) * t), 0); -}; - -// `n` by `n` texels of stone: the page colour in a few close shades, with darker flecks. -export function stone(page, shades, n) { - const s = shades.map((by) => shade(page, by)); - const next = rng(0x9e3779b9); - const out = Array.from({ length: n * n }, () => { - const v = next(); - return s[v < 0.5 ? 0 : v < 0.78 ? 1 : v < 0.93 ? 2 : 3]; - }); - for (let k = 0; k < Math.floor((n * n) / 50); k++) { - const x = Math.floor(next() * n); - const y = Math.floor(next() * n); - const len = 1 + Math.floor(next() * 3); - for (let i = 0; i < len; i++) { - out[y * n + ((x + i) % n)] = s[4]; - out[((y + 1) % n) * n + ((x + i + 1) % n)] = s[4]; - } - } - return out; -} - -export const SUNSET = { - sky: [0x0a0f2c, 0x26206a, 0x4b2a7e, 0x7c3a88, 0xc4527a, 0xee6e5a, 0xffb25e], - sun: [0xfff6cf, 0xffd272, 0xff9a44], - far: 0x4a2f7e, mid: 0x2b1f5e, near: 0x151a45, top: 0x3d2f80, trees: 0x0e1236, -}; -export const DAWN = { - sky: [0x8d7fd8, 0xa596e2, 0xbcaeea, 0xd2c4f0, 0xe7d2ee, 0xf6dce4, 0xffe6d6], - sun: [0xffffff, 0xfff1d0, 0xffd9a8], - far: 0xa898dc, mid: 0x8676c8, near: 0x6a5ab4, top: 0x9a8ae0, trees: 0x4c3e94, -}; - -// A blocky landscape, `w` by `h` texels: banded sky, a sun half set behind the far hills and cut -// into slices like Horizon's mark, flat clouds, three ridges of hills and a few square trees. -export function scene(p, seed, w, h) { - const next = rng((Math.imul(seed, 0x2545f491) | 1) >>> 0); - const out = new Array(w * h).fill(0); - const horizon = Math.floor((h * 62) / 100); - const band = horizon / p.sky.length; - for (let y = 0; y < h; y++) { - const f = y / band; - const i = Math.min(Math.floor(f), p.sky.length - 1); - const dither = f - i > 0.88 && i + 1 < p.sky.length; - for (let x = 0; x < w; x++) out[y * w + x] = dither && (x + y) % 2 === 1 ? p.sky[i + 1] : p.sky[i]; - } - const rect = (x, y, rw, rh, c) => { - for (let yy = Math.max(y, 0); yy < Math.min(y + rh, h); yy++) - for (let xx = Math.max(x, 0); xx < Math.min(x + rw, w); xx++) out[yy * w + xx] = c; - }; - for (let k = 0; k < 4; k++) { - const cx = Math.floor(next() * w); - const cy = Math.floor(horizon * (0.12 + next() * 0.4)); - const cw = 10 + Math.floor(next() * 14); - const under = p.sky[Math.min(Math.floor(cy / band) + 1, p.sky.length - 1)]; - const c = mix(under, 0xffffff, 0.12); - rect(cx, cy, cw, 2, c); - rect(cx + 3, cy - 2, Math.max(cw - 6, 0), 2, c); - } - const r = Math.floor(h * 0.2); - const [cx, cy] = [Math.floor(w * 0.68), horizon - Math.floor(h * 0.11)]; - for (let dy = -r; dy <= 0; dy++) { - const half = Math.round(Math.sqrt(r * r - dy * dy)); - const t = (dy + r) / r; - const c = p.sun[t < 0.4 ? 0 : t < 0.75 ? 1 : 2]; - if ((cy + dy) % 5 !== 0 || dy < -r * 0.6) rect(cx - half, cy + dy, half * 2, 1, c); - } - const ridge = (base, amp, rough) => { - let [y, v] = [base, 0]; - return Array.from({ length: w }, (_, x) => { - if (x % 2 === 0) { - v = (v + (next() - 0.5) * rough) * 0.86; - y += v + (base - y) * 0.06; - } - return Math.max(Math.round(Math.min(Math.max(y, base - amp), base + amp)), 0); - }); - }; - const layers = [ - [p.far, horizon - h * 0.1, h * 0.12, 3.2, null], - [p.mid, horizon + h * 0.02, h * 0.09, 2.6, null], - [p.near, horizon + h * 0.18, h * 0.07, 2.2, p.top], - ]; - let near = []; - for (const [c, base, amp, rough, top] of layers) { - const hs = ridge(base, amp, rough); - hs.forEach((y, x) => { - for (let yy = Math.min(y, h); yy < h; yy++) out[yy * w + x] = c; - if (top !== null && y < h) out[y * w + x] = top; - }); - near = hs; - } - for (let k = 0; k < Math.floor(w / 26); k++) { - const tx = 4 + Math.floor(next() * (w - 10)); - const ty = near[tx]; - rect(tx - 2, ty - 8, 5, 5, p.trees); - rect(tx - 1, ty - 10, 3, 2, p.trees); - rect(tx, ty - 3, 1, 3, p.trees); - } - return out; -} - -const png = (texels, w, h, file) => - sharp(Buffer.from(texels.flatMap(channels)), { raw: { width: w, height: h, channels: 3 } }) - .png({ compressionLevel: 9, palette: true }) - .toFile(file); - -// The page colours and the stone's shades come from crates/horizon-theme, like tokens.css. -export async function writeArt(media) { - const theme = await readFile(join(repo, "crates/horizon-theme/src/lib.rs"), "utf8"); - const page = (name) => parseInt(theme.match(new RegExp(`pub const ${name}: Blocks = Blocks \\{\\s*page: 0x([0-9a-f]{6})`))[1], 16); - const shades = JSON.parse(`[${theme.match(/TILE_SHADES: \[i16; 5\] = \[([^\]]*)\]/)[1]}]`); - const texels = Number(theme.match(/TILE_TEXELS: usize = (\d+)/)[1]) * 4; - await png(stone(page("BLOCKS_DARK"), shades, texels), texels, texels, join(media, "stone-dark.png")); - await png(stone(page("BLOCKS_LIGHT"), shades, texels), texels, texels, join(media, "stone-light.png")); - const [w, h] = [240, 72]; - await png(scene(SUNSET, 3, w, h), w, h, join(media, "scene-dark.png")); - await png(scene(DAWN, 3, w, h), w, h, join(media, "scene-light.png")); -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - await writeArt(join(site, "public", "media")); - console.log("pixel art written to public/media/"); -} diff --git a/website/scripts/assets.mjs b/website/scripts/assets.mjs deleted file mode 100644 index 92b197e7ea..0000000000 --- a/website/scripts/assets.mjs +++ /dev/null @@ -1,60 +0,0 @@ -// Copies the launcher's fonts and the repo's screenshots into public/, draws the pixel art, -// converted for the web (woff2, webp in two widths). Run it after any of them change: -// `npm run assets`. Its output is committed, because Railway builds only website/. -import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import sharp from "sharp"; -import wawoff2 from "wawoff2"; -import { writeArt } from "./art.mjs"; - -const site = join(dirname(fileURLToPath(import.meta.url)), ".."); -const repo = join(site, ".."); -const pub = join(site, "public"); - -const fonts = [ - "Geist-Regular.ttf", - "Geist-Medium.ttf", - "Geist-SemiBold.ttf", - "GeistMono-Regular.ttf", - "PixelifySans-Medium.ttf", - "PixelifySans-SemiBold.ttf", - "PixelifySans-Bold.ttf", -]; - -// [source, name]; every image becomes media/.webp and media/-800.webp. -export const images = [ - ["docs/media/sunset.jpg", "sunset"], - ["docs/media/village.jpg", "village"], - ["docs/media/survival.jpg", "survival"], - ["docs/media/nether.jpg", "nether"], - ["docs/media/night.jpg", "night"], - ["docs/media/underwater.jpg", "underwater"], - ["docs/media/settings-simple.png", "settings"], - ["docs/media/launcher-play.png", "launcher"], -]; - -await mkdir(join(pub, "fonts"), { recursive: true }); -await mkdir(join(pub, "media"), { recursive: true }); - -for (const font of fonts) { - const ttf = await readFile(join(repo, "crates/horizon-theme/fonts", font)); - await writeFile(join(pub, "fonts", font.replace(".ttf", ".woff2")), await wawoff2.compress(ttf)); -} -for (const licence of ["OFL-Geist.txt", "OFL-PixelifySans.txt"]) { - await copyFile(join(repo, "crates/horizon-theme/fonts", licence), join(pub, "fonts", licence)); -} - -for (const [source, name] of images) { - const input = join(repo, source); - await sharp(input).resize({ width: 1600, withoutEnlargement: true }).webp({ quality: 82 }).toFile(join(pub, "media", `${name}.webp`)); - await sharp(input).resize({ width: 800 }).webp({ quality: 78 }).toFile(join(pub, "media", `${name}-800.webp`)); -} - -// The stone tile and the hero's landscape, drawn in code (scripts/art.mjs). -await writeArt(join(pub, "media")); - -// The icons (favicon.svg, favicon-32.png, apple-touch-icon.png) come from Horizon's mark: -// `cargo run -p xtask -- icons` writes them with the rest of the brand (docs/media/brand). - -console.log("assets written to public/"); diff --git a/website/scripts/benchmarks.mjs b/website/scripts/benchmarks.mjs deleted file mode 100644 index 724834ea77..0000000000 --- a/website/scripts/benchmarks.mjs +++ /dev/null @@ -1,240 +0,0 @@ -// Benchmark results for the site: benchmark reports (docs/benchmark-report.md) in, the summary the Benchmarks page -// reads out (public/benchmarks.json, and the telemetry server's GET /v1/benchmarks/summary in the same shape). -// -// node scripts/benchmarks.mjs --ours fleet.json [--telemetry items.jsonl] [-o public/benchmarks.json] -// -// --ours lists our own runs (tools/perf_bench.py reports, Horizon and vanilla, one machine each); --telemetry holds -// players' `benchmark` items as the server stores them ({ install_id, day, version, data: } per line). -// Players' results are grouped by GPU, CPU, render distance and game, and a group is published only with at least -// MIN_INSTALLS installs, so nobody can be picked out. install_id is used to count installs and to weigh each install -// once, and never leaves this script; neither do the day, OS version, driver, the exact RAM and VRAM, or the screen. -// A group also says how its installs split by hardware class (RAM, VRAM, drive, OS, integrated or discrete GPU), -// each class only with at least MIN_INSTALLS installs. The classes are the server's (src/classes.rs). -import { readFile, writeFile } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -export const FORMAT = "horizon-benchmark-summary"; -export const MIN_INSTALLS = 3; -export const SCENES = ["village", "forest", "ocean", "mountain"]; -export const FIELDS = ["avg_fps", "low_1pct_fps", "p95_ms", "world_ms", "chunk_load_ms", "rss_peak_mb", "rss_steady_mb"]; - -const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null); -const finite = (x) => typeof x === "number" && Number.isFinite(x); -export function median(xs) { - const v = xs.filter(finite).sort((a, b) => a - b); - if (!v.length) return null; - const m = v.length >> 1; - return v.length % 2 ? v[m] : (v[m - 1] + v[m]) / 2; -} -// fps to integers, milliseconds to a tenth under 10 and to integers above, megabytes to integers. -export function round(field, x) { - if (!finite(x)) return null; - if (field.endsWith("_ms")) return x < 10 ? Math.round(x * 10) / 10 : Math.round(x); - return Math.round(x); -} -const trim = (s) => (typeof s === "string" ? s.replace(/\(R\)|\(TM\)|\(tm\)/g, "").replace(/\s+/g, " ").trim() : ""); -const gameOf = (app) => (app?.name === "horizon-client" ? "horizon" : app?.name === "minecraft" && !(app.mods || []).some((m) => m.name !== "fabric-loader") ? "vanilla" : null); - -// --- Hardware classes: coarse, so that a class holds many machines --------------------------- - -export const DIMENSIONS = ["ram", "vram", "storage", "os", "gpu_kind"]; -export function ramClass(gb) { - if (!(gb > 0)) return null; - return gb <= 5 ? "4 GB or less" : gb <= 10 ? "8 GB" : gb <= 20 ? "16 GB" : gb <= 40 ? "32 GB" : "64 GB or more"; -} -export function gpuKindClass(s) { - return s === "integrated" || s === "IntegratedGpu" ? "integrated" : s === "discrete" || s === "DiscreteGpu" ? "discrete" : null; -} -// A GPU's own memory in MiB; an integrated GPU shares the system's. -export function vramClass(mb, kind) { - if (gpuKindClass(kind) === "integrated") return "shared"; - if (!(mb > 0)) return null; - for (const [max, name] of [[2560, "2 GB or less"], [4608, "4 GB"], [6656, "6 GB"], [8704, "8 GB"], [12800, "12 GB"], [16896, "16 GB"]]) if (mb <= max) return name; - return "more than 16 GB"; -} -const oneOf = (list) => (s) => (list.includes(s) ? s : null); -export const storageClass = oneOf(["nvme", "ssd", "hdd"]); -export const osClass = oneOf(["linux", "macos", "windows"]); -// A report's classes, in DIMENSIONS order. -export function classesOf(report) { - const hw = report?.hardware || {}; - const kind = hw.gpu?.kind; - return [ramClass(finite(hw.ram_mb) ? hw.ram_mb / 1024 : 0), vramClass(hw.gpu?.vram_mb, kind), storageClass(hw.storage), osClass(hw.os), gpuKindClass(kind)]; -} -// Per dimension, the classes with at least minInstalls installs, most first; dimensions without one are left out. -export function breakdowns(classes, minInstalls) { - const out = {}; - DIMENSIONS.forEach((dim, i) => { - const cells = [...classes[i]].map(([c, set]) => ({ class: c, installs: set.size })).filter((c) => c.installs >= minInstalls); - cells.sort((a, b) => b.installs - a.installs || (a.class < b.class ? -1 : a.class > b.class ? 1 : 0)); - if (cells.length) out[dim] = cells; - }); - return out; -} - -// Why a report can't be compared with the others, or null when it can: the standard flight over the four scenes, -// VSync off, no frame cap, drawn on a screen. -export function rejection(report) { - if (!report || report.format !== "horizon-benchmark-report" || report.version !== 1) return "not a version 1 benchmark report"; - if (report.kind !== "game") return "not a game run"; - if (!gameOf(report.app)) return "not Horizon or plain vanilla"; - const c = report.conditions || {}; - if (!(c.scenario === "standard" || c.scenario === "flight" || String(c.scenario || "").startsWith("perf_bench:"))) return "not the standard flight"; - if (c.vsync !== false) return "VSync on or unknown"; - if (c.fps_cap != null) return "frame rate capped"; - if (c.offscreen === true) return "offscreen"; - if (!finite(c.render_distance)) return "no render distance"; - const phases = new Set((report.frames || []).map((f) => f.phase)); - if (!SCENES.every((s) => phases.has(s))) return "not every scene"; - if (!trim(report.hardware?.gpu?.name) || !trim(report.hardware?.cpu)) return "no GPU or CPU name"; - return null; -} - -// One report's figures: frame figures averaged over every flight (scene and repeat), the rest from its metrics. -export function figures(report) { - const frames = report.frames || []; - const m = report.metrics || {}; - const metric = (name) => (finite(m[name]?.mean) ? m[name].mean : null); - const loads = Object.keys(m).filter((k) => /^chunks\.[^.]+\.load_ms$/.test(k)).map(metric).filter(finite); - const avg = (key) => mean(frames.map((f) => f[key]).filter(finite)); - return { - avg_fps: avg("avg_fps"), - low_1pct_fps: avg("low_1pct_fps"), - p95_ms: avg("p95_ms"), - world_ms: metric("startup.world_ms"), - chunk_load_ms: mean(loads), - rss_peak_mb: metric("memory.rss_peak_mb"), - rss_steady_mb: metric("memory.rss_steady_mb"), - cpu_p50_ms: avg("cpu_p50_ms"), - gpu_p50_ms: avg("gpu_p50_ms"), - }; -} - -const roundAll = (fig, fields) => Object.fromEntries(fields.map((f) => [f, round(f, fig[f])])); - -// Players' items -> groups of at least minInstalls installs. Each install counts once: its median first, then the -// median across installs. -export function aggregatePlayers(items, { minInstalls = MIN_INSTALLS } = {}) { - const groups = new Map(); - for (const item of items) { - const report = item?.data; - if (!item?.install_id || rejection(report)) continue; - const key = JSON.stringify([trim(report.hardware.gpu.name), trim(report.hardware.cpu), report.conditions.render_distance, gameOf(report.app)]); - if (!groups.has(key)) groups.set(key, { installs: new Map(), reports: 0, versions: new Set(), classes: DIMENSIONS.map(() => new Map()) }); - const g = groups.get(key); - g.reports++; - if (item.version) g.versions.add(item.version); - if (!g.installs.has(item.install_id)) g.installs.set(item.install_id, []); - g.installs.get(item.install_id).push(figures(report)); - classesOf(report).forEach((c, i) => { - if (!c) return; - if (!g.classes[i].has(c)) g.classes[i].set(c, new Set()); - g.classes[i].get(c).add(item.install_id); - }); - } - const out = []; - for (const [key, g] of groups) { - if (g.installs.size < minInstalls) continue; - const [gpu, cpu, render_distance, game] = JSON.parse(key); - const perInstall = [...g.installs.values()].map((runs) => Object.fromEntries(FIELDS.map((f) => [f, median(runs.map((r) => r[f]))]))); - const medians = Object.fromEntries(FIELDS.map((f) => [f, round(f, median(perInstall.map((r) => r[f])))])); - out.push({ gpu, cpu, render_distance, game, source: "players", installs: g.installs.size, reports: g.reports, versions: [...g.versions].sort(byVersionDesc).slice(0, 3), medians, breakdowns: breakdowns(g.classes, minInstalls) }); - } - // Most installs first, then by GPU, CPU, render distance and game, compared as plain strings (as the server does). - const cmp = (x, y) => (x < y ? -1 : x > y ? 1 : 0); - return out.sort((a, b) => b.installs - a.installs || cmp(a.gpu, b.gpu) || cmp(a.cpu, b.cpu) || a.render_distance - b.render_distance || cmp(a.game, b.game)); -} - -// "v0.2.10" after "v0.2.9", dated tags by their numbers; good enough to list the newest three. -function byVersionDesc(a, b) { - const n = (v) => (v.match(/\d+/g) || []).map(Number); - const [x, y] = [n(a), n(b)]; - for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? -1) !== (y[i] ?? -1)) return (y[i] ?? -1) - (x[i] ?? -1); - return 0; -} - -// Our own runs: one row per machine, game and render distance, with what was measured and how. An entry holds one -// report (its runs are the report's repeats) or several (one game each); figures are medians over the reports. -export function ourRows(runs) { - const rows = []; - for (const { report, reports, machine, note, version, capped_fps } of runs) { - const list = reports || [report]; - for (const r of list) { - const why = rejection(r); - if (why) throw new Error(`${machine}: ${r?.label || "report"} can't be published: ${why}`); - } - const [first] = list; - const c = first.conditions; - if (list.some((r) => r.conditions.render_distance !== c.render_distance || gameOf(r.app) !== gameOf(first.app) || r.app.version !== first.app.version)) - throw new Error(`${machine}: reports of different games, versions or render distances in one entry`); - const figs = list.map(figures); - const fig = Object.fromEntries(Object.keys(figs[0]).map((k) => [k, median(figs.map((f) => f[k]))])); - const scenes = Object.fromEntries( - SCENES.map((s) => { - const fs = list.flatMap((r) => r.frames.filter((f) => f.phase === s)); - const loads = list.map((r) => r.metrics?.[`chunks.${s}.load_ms`]?.mean); - return [s, { avg_fps: round("avg_fps", mean(fs.map((f) => f.avg_fps))), low_1pct_fps: round("low_1pct_fps", mean(fs.map((f) => f.low_1pct_fps))), chunk_load_ms: round("chunk_load_ms", median(loads)) }]; - }), - ); - rows.push({ - gpu: trim(first.hardware.gpu.name), - cpu: trim(first.hardware.cpu), - render_distance: c.render_distance, - game: gameOf(first.app), - source: "us", - machine, - measured: list.map((r) => (r.created || "").slice(0, 10)).sort().at(-1), - // A release build reports its crate version; the entry can name the release it was. - version: version || first.app.version, - backend: first.hardware.gpu.backend || null, - os: first.hardware.os || null, - window: c.window || null, - repeats: list.reduce((n, r) => n + (r.conditions.repeats || 1), 0), - load_avg_1m: Math.max(...list.map((r) => r.conditions.load_avg_1m ?? 0)), - ...(note ? { note } : {}), - // The frame rate the display held this game to (a present mode VSync off doesn't lift): its average is a floor. - ...(capped_fps ? { capped_fps } : {}), - // A split of 0 means the backend gave no timing (Metal has no GPU timer here): unknown, not free. - medians: { ...roundAll(fig, FIELDS), cpu_p50_ms: fig.cpu_p50_ms > 0 ? round("cpu_p50_ms", fig.cpu_p50_ms) : null, gpu_p50_ms: fig.gpu_p50_ms > 0 ? round("gpu_p50_ms", fig.gpu_p50_ms) : null }, - scenes, - }); - } - return rows; -} - -export function summary({ ours = [], players = [], generated = new Date() } = {}) { - return { format: FORMAT, version: 1, generated: generated.toISOString().replace(/\.\d+Z$/, "Z"), min_installs: MIN_INSTALLS, groups: [...ours, ...players] }; -} - -// --- The command line ---------------------------------------------------------------------- - -async function main(argv) { - const arg = (name) => { - const i = argv.indexOf(name); - return i >= 0 ? argv[i + 1] : null; - }; - const site = resolve(dirname(fileURLToPath(import.meta.url)), ".."); - const out = arg("-o") || resolve(site, "public/benchmarks.json"); - let ours = []; - if (arg("--ours")) { - // [{ "machine": "Mac mini", "reports": ["path/to/report.json", ...], "version": "v…", "note": "..." }], paths - // relative to the list; "version" names the release when the report only has the crate version. - const listPath = resolve(arg("--ours")); - const list = JSON.parse(await readFile(listPath, "utf8")); - const load = async (path) => JSON.parse(await readFile(resolve(dirname(listPath), path), "utf8")); - const runs = []; - for (const entry of list) runs.push({ ...entry, reports: await Promise.all((entry.reports || [entry.report]).map(load)), report: undefined }); - ours = ourRows(runs); - } - let players = []; - if (arg("--telemetry")) { - const text = await readFile(resolve(arg("--telemetry")), "utf8"); - players = aggregatePlayers(text.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l))); - } - const result = summary({ ours, players }); - await writeFile(out, JSON.stringify(result, null, 1) + "\n"); - console.log(`${out}: ${ours.length} rows measured by us, ${players.length} player groups`); -} - -if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(process.argv.slice(2)); diff --git a/website/scripts/benchmarks.test.mjs b/website/scripts/benchmarks.test.mjs deleted file mode 100644 index 87aec484b8..0000000000 --- a/website/scripts/benchmarks.test.mjs +++ /dev/null @@ -1,91 +0,0 @@ -// node --test (npm test in website/). The fixtures are shared with the telemetry server's summary test. -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { aggregatePlayers, median, osClass, ourRows, ramClass, rejection, round, storageClass, summary, vramClass, gpuKindClass } from "./benchmarks.mjs"; - -const fixture = (name) => new URL(`./fixtures/benchmarks/${name}`, import.meta.url); -const items = readFileSync(fixture("items.jsonl"), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)); -const expected = JSON.parse(readFileSync(fixture("expected.json"), "utf8")); - -test("players' items give the expected groups", () => { - const s = summary({ players: aggregatePlayers(items) }); - assert.equal(s.format, "horizon-benchmark-summary"); - assert.equal(s.min_installs, expected.min_installs); - assert.deepEqual(s.groups, expected.groups); -}); - -test("nothing that identifies an install is published", () => { - const text = JSON.stringify(aggregatePlayers(items, { minInstalls: 1 })); - for (const secret of ["a1", "a2", "install_id", "secret-os-version", "secret-driver", "20394", "ram_mb", "2560", "16376", "32768", "vram_mb"]) assert.ok(!text.includes(secret), secret); -}); - -test("a group needs three installs, not three runs", () => { - const one = items.filter((i) => i.install_id === "a1"); - assert.equal(aggregatePlayers([...one, ...one, ...one]).length, 0); - assert.equal(aggregatePlayers(items, { minInstalls: 2 }).length, 2); -}); - -test("hardware classes are coarse, as the server's", () => { - assert.equal(ramClass(15.6), "16 GB"); - assert.equal(ramClass(31), "32 GB"); - assert.equal(ramClass(64), "64 GB or more"); - assert.equal(ramClass(0), null); - assert.equal(vramClass(16376, "discrete"), "16 GB"); - assert.equal(vramClass(8192, "DiscreteGpu"), "8 GB"); - assert.equal(vramClass(0, "integrated"), "shared"); - assert.equal(vramClass(0, ""), null); - assert.equal(storageClass("nvme"), "nvme"); - assert.equal(storageClass("tape"), null); - assert.equal(osClass("windows"), "windows"); - assert.equal(gpuKindClass("virtual"), null); -}); - -test("a breakdown cell needs three installs too", () => { - const [group] = aggregatePlayers(items); - assert.equal(group.breakdowns.ram, undefined); // 32 GB has two installs, 16 GB one - assert.equal(group.breakdowns.storage, undefined); - const [all] = aggregatePlayers(items, { minInstalls: 1 }); - assert.deepEqual(all.breakdowns.ram, [{ class: "32 GB", installs: 2 }, { class: "16 GB", installs: 1 }]); - assert.deepEqual(all.breakdowns.storage, [{ class: "nvme", installs: 2 }, { class: "ssd", installs: 1 }]); -}); - -test("only comparable runs count", () => { - const reasons = items.map((i) => rejection(i.data)); - assert.deepEqual(reasons.filter(Boolean), ["VSync on or unknown", "offscreen", "frame rate capped", "not every scene", "not the standard flight", "not a version 1 benchmark report"]); -}); - -test("medians and rounding", () => { - assert.equal(median([3, 1, 2]), 2); - assert.equal(median([4, 1, 2, 3]), 2.5); - assert.equal(median([null, NaN]), null); - assert.equal(round("p95_ms", 3.46), 3.5); - assert.equal(round("world_ms", 4512.4), 4512); - assert.equal(round("avg_fps", 99.5), 100); -}); - -test("our runs keep the machine and how it was measured", () => { - const [row] = ourRows([{ machine: "Desktop", report: items[0].data }]); - assert.equal(row.source, "us"); - assert.equal(row.machine, "Desktop"); - assert.equal(row.game, "horizon"); - assert.equal(row.medians.avg_fps, 415); - assert.equal(row.scenes.ocean.avg_fps, 420); - assert.throws(() => ourRows([{ machine: "x", report: items[4].data }]), /VSync/); - // Several one-game reports: medians over them. - const [both] = ourRows([{ machine: "Desktop", reports: [items[0].data, items[1].data] }]); - assert.equal(both.medians.avg_fps, 515); - assert.equal(both.repeats, 2); - assert.throws(() => ourRows([{ machine: "x", reports: [items[0].data, items[9].data] }]), /different/); -}); - -test("a display cap on our run is kept, and the comparison says at least", async () => { - const [row] = ourRows([{ machine: "Mac", report: items[0].data, capped_fps: 144, version: "v1" }]); - assert.equal(row.capped_fps, 144); - assert.equal(row.version, "v1"); - const { compareRows, METRICS } = await import("../src/bench.js"); - const fps = METRICS[0]; - const h = { capped_fps: 144, medians: { avg_fps: 144 } }; - assert.equal(compareRows(fps, h, { medians: { avg_fps: 120 } }).text, "at least 1.2x faster"); - assert.equal(compareRows(fps, h, { medians: { avg_fps: 200 } }).wins, "none"); -}); diff --git a/website/scripts/shots.mjs b/website/scripts/shots.mjs deleted file mode 100644 index c0b86f40d2..0000000000 --- a/website/scripts/shots.mjs +++ /dev/null @@ -1,99 +0,0 @@ -// Full-page screenshots of the built site at every width that matters, plus the social -// preview image. Run `npm run build` first; `npm run shots` serves dist/ and writes -// shots/*.png (gitignored: full-page PNGs are too heavy for the repo), 390-menu.png (the open -// menu) and 1440-hero.png, and public/og.png (rebuild after). It also checks each width for -// horizontal scroll, small tap targets and line lengths. `SHOTS=dir` writes them elsewhere. -import { chromium } from "playwright"; -import { spawn } from "node:child_process"; -import { mkdir } from "node:fs/promises"; -import { join, dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const site = join(dirname(fileURLToPath(import.meta.url)), ".."); -const port = 4199; -const url = `http://localhost:${port}/`; -const server = spawn(process.execPath, [join(site, "serve.mjs")], { env: { ...process.env, PORT: String(port) }, stdio: "ignore" }); -await new Promise((r) => setTimeout(r, 400)); - -const widths = [360, 390, 430, 768, 1024, 1180, 1250, 1280, 1366, 1440, 1680, 1920]; -const sizes = [...widths.map((w) => [w, w < 900 ? 800 : 900]), [1250, 700]]; -const only = process.argv.slice(2); -const out = process.env.SHOTS ? resolve(process.env.SHOTS) : join(site, "shots"); - -await mkdir(out, { recursive: true }); -const browser = await chromium.launch(); -let problems = 0; - -for (const [width, height] of sizes) { - const name = height === 700 ? `${width}x${height}` : `${width}`; - if (only.length && !only.includes(name)) continue; - const touch = width < 900; - const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1, hasTouch: touch, isMobile: touch }); - await page.goto(url, { waitUntil: "networkidle" }); - // Reveal every section, as scrolling would, and let the fades finish. - await page.evaluate(async () => { - for (let y = 0; y < document.body.scrollHeight; y += 400) { - scrollTo({ top: y, behavior: "instant" }); - await new Promise((r) => setTimeout(r, 30)); - } - scrollTo({ top: 0, behavior: "instant" }); - }); - await page.waitForTimeout(800); - const report = await page.evaluate((touch) => { - const out = []; - const doc = document.documentElement; - if (doc.scrollWidth > innerWidth) out.push(`horizontal scroll: ${doc.scrollWidth} > ${innerWidth}`); - for (const el of document.querySelectorAll("body *")) { - const r = el.getBoundingClientRect(); - if (r.width && r.right > innerWidth + 1 && !el.closest(".lightbox, .menu, .hero-media, .footer-word")) { - out.push(`overflows: ${el.tagName.toLowerCase()}.${el.className} right ${Math.round(r.right)}`); - } - } - if (touch) { - for (const el of document.querySelectorAll("a, button, summary")) { - if (el.closest(".menu, .lightbox, .skip") || el.closest(".prose, .footer-cols, .fact p, .system-detail, .download-meta, .meta")) continue; - const r = el.getBoundingClientRect(); - if (r.width && r.height < 44) out.push(`small target: ${el.textContent.trim().slice(0, 30)} (${Math.round(r.height)}px)`); - } - } - // Characters per line of the longest running-text paragraphs. - const lengths = [...document.querySelectorAll(".prose p, .lede, .hero-line, .fact p, .features p, .system-detail, .count-label, .footer-cols p")] - .filter((p) => p.textContent.length > 160) - .map((p) => { - const lh = parseFloat(getComputedStyle(p).lineHeight); - const n = Math.round(p.textContent.length / Math.max(1, Math.round(p.getBoundingClientRect().height / lh))); - return [n, `${p.parentElement.id || p.parentElement.className}>${p.className || p.tagName}`]; - }) - .sort((a, b) => a[0] - b[0]); - const [lo, hi] = [lengths[0], lengths[lengths.length - 1]]; - out.push(`chars per line: ${lo[0]} (${lo[1]})–${hi[0]} (${hi[1]})`); - return out.slice(0, 12); - }, touch); - const issues = report.filter((l) => !l.startsWith("chars")); - problems += issues.length; - console.log(`${name}: ${issues.length ? issues.join("; ") : "ok"} · ${report.find((l) => l.startsWith("chars"))}`); - await page.screenshot({ path: join(out, `${name}.png`), fullPage: height !== 700 }); - if (width === 1440) await page.screenshot({ path: join(out, "1440-hero.png") }); - if (width === 390) { - await page.click(".menu-button"); - await page.waitForTimeout(800); - await page.screenshot({ path: join(out, "390-menu.png") }); - } - await page.close(); -} - -if (!only.length || only.includes("og")) { - const page = await browser.newPage({ viewport: { width: 1200, height: 630 } }); - await page.goto(`${url}og.html`, { waitUntil: "networkidle" }); - await page.waitForTimeout(700); - await page.screenshot({ path: join(site, "public/og.png") }); - await page.close(); -} - -await browser.close(); -server.kill(); - -if (problems) { - console.log(`${problems} problems`); - process.exitCode = 1; -} diff --git a/website/serve.mjs b/website/serve.mjs deleted file mode 100644 index 553a3ff9b6..0000000000 --- a/website/serve.mjs +++ /dev/null @@ -1,42 +0,0 @@ -// A dependency-free static server for dist/, for local checks (`npm run dev`). -// Railway uses Caddy instead (see Dockerfile). -import { createServer } from "node:http"; -import { readFile, stat } from "node:fs/promises"; -import { join, extname, normalize } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = join(fileURLToPath(new URL(".", import.meta.url)), "dist"); -const types = { ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "text/javascript", ".webp": "image/webp", ".png": "image/png", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8", ".json": "application/json", ".xml": "application/xml" }; -const port = Number(process.env.PORT) || 4173; - -createServer(async (req, res) => { - let path = normalize(decodeURIComponent(new URL(req.url, "http://x").pathname)).replace(/^(\.\.[/\\])+/, ""); - if (path === "/docs/api/") { - res.writeHead(301, { Location: "/docs/api" }); - return res.end(); - } - if (path.endsWith("/")) path += "index.html"; - // The server guide is part of the docs now, as the Caddyfile redirects it. - if (path === "/server" || path === "/server.html") { - res.writeHead(301, { Location: "/docs/hosting" }); - return res.end(); - } - // The roadmap page became the hub's Roadmap tab. - if (path === "/roadmap") { - res.writeHead(301, { Location: "/hub#roadmap" }); - return res.end(); - } - if (path === "/docs") path = "/docs/index.html"; - // /privacy, /docs/ and /docs/api/ are .html files, as the Caddyfile rewrites them. - if (!extname(path)) path += ".html"; - let file = join(root, path); - let status = 200; - try { - if (!(await stat(file)).isFile()) throw 0; - } catch { - file = join(root, "404.html"); - status = 404; - } - res.writeHead(status, { "Content-Type": types[extname(file)] || "application/octet-stream" }); - res.end(await readFile(file)); -}).listen(port, () => console.log(`http://localhost:${port}`)); diff --git a/website/src/404.html b/website/src/404.html deleted file mode 100644 index 3ac1e017a9..0000000000 --- a/website/src/404.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - Not found · Horizon - - - - - - - - - -
-
-

404Not found

-

Not here.

-

This page doesn't exist. The rest of Horizon is one page away.

- -

Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

-
-
- - - diff --git a/website/src/bench.js b/website/src/bench.js deleted file mode 100644 index 40185c79da..0000000000 --- a/website/src/bench.js +++ /dev/null @@ -1,167 +0,0 @@ -// The Benchmarks page's tables, from the summary (public/benchmarks.json, or the telemetry server's -// GET /v1/benchmarks/summary in the same shape). Shared by build.mjs (baked) and benchmarks-page.js (live). -import { escape } from "./markdown.js"; - -export const METRICS = [ - { key: "avg_fps", label: "Average FPS", kind: "fps", better: "high" }, - { key: "low_1pct_fps", label: "1% low FPS", kind: "fps", better: "high", hint: "the slowest 1% of frames, as FPS" }, - { key: "p95_ms", label: "Frame time, 95th percentile", kind: "ms", better: "low" }, - { key: "world_ms", label: "Start to in-world", kind: "ms", better: "low", hint: "process start to the spawn area loaded and drawn" }, - { key: "chunk_load_ms", label: "Chunk load after a teleport", kind: "ms", better: "low", hint: "the four scenes' average" }, - { key: "rss_steady_mb", label: "Memory while playing", kind: "mb", better: "low" }, - { key: "rss_peak_mb", label: "Memory, peak", kind: "mb", better: "low" }, -]; - -export function format(kind, x) { - if (x == null || !Number.isFinite(x)) return "–"; - if (kind === "fps") return x >= 100 ? Math.round(x).toLocaleString("en") : x.toFixed(x >= 10 ? 0 : 1); - if (kind === "ms") return x >= 1000 ? `${(x / 1000).toFixed(x >= 10000 ? 0 : 1)} s` : `${x < 10 ? x.toFixed(1) : Math.round(x)} ms`; - if (kind === "mb") return x >= 1000 ? `${(x / 1024).toFixed(1)} GB` : `${Math.round(x)} MB`; - return String(x); -} - -// "2.4x faster", "1.3x slower", "2.0x less" (memory), from Horizon's side. -export function compare(metric, horizon, java) { - if (horizon == null || java == null || !horizon || !java) return null; - const ratio = metric.better === "high" ? horizon / java : java / horizon; - if (Math.abs(ratio - 1) < 0.05) return { text: "about the same", wins: "tie" }; - const word = metric.kind === "mb" ? (ratio > 1 ? "less" : "more") : ratio > 1 ? "faster" : "slower"; - const r = ratio > 1 ? ratio : 1 / ratio; - return { text: `${r.toFixed(r >= 10 ? 0 : 1)}x ${word}`, wins: ratio > 1 ? "horizon" : "java" }; -} - -// The game's average frame rate sat at a display cap (`capped_fps`): the real one is at least that. -export const capped = (row) => Boolean(row?.capped_fps && row.medians?.avg_fps >= row.capped_fps * 0.97); - -// compare() between two rows, saying "at least" where Horizon's average sat at a cap. -export function compareRows(metric, h, j) { - const c = compare(metric, h?.medians?.[metric.key], j?.medians?.[metric.key]); - if (!c || metric.key !== "avg_fps" || !capped(h)) return c; - return c.wins === "horizon" ? { ...c, text: `at least ${c.text}` } : { text: "capped, can't tell", wins: "none" }; -} - -export const cell = (metric, row) => { - const v = format(metric.kind, row?.medians?.[metric.key]); - return metric.key === "avg_fps" && capped(row) ? `${v} (cap)` : v; -}; - -const machines = (groups) => { - const by = new Map(); - for (const g of groups.filter((g) => g.source === "us")) { - if (!by.has(g.machine)) by.set(g.machine, []); - by.get(g.machine).push(g); - } - return by; -}; - -const pick = (rows, game, rd) => rows.find((r) => r.game === game && r.render_distance === rd); - -// One machine we measured: Horizon and Java side by side at each render distance. -export function machineHtml(name, rows) { - const first = rows[0]; - const distances = [...new Set(rows.map((r) => r.render_distance))].sort((a, b) => a - b); - const horizon = rows.find((r) => r.game === "horizon"); - const java = rows.find((r) => r.game === "vanilla"); - const head = distances.map((rd) => `Render distance ${rd}`).join(""); - const sub = distances.map(() => `HorizonJavaHorizon against Java`).join(""); - const body = METRICS.map((m) => { - const cells = distances - .map((rd) => { - const h = pick(rows, "horizon", rd); - const j = pick(rows, "vanilla", rd); - const c = compareRows(m, h, j); - return `${cell(m, h)}${cell(m, j)}${c ? escape(c.text) : ""}`; - }) - .join(""); - return `${escape(m.label)}${m.hint ? `${escape(m.hint)}` : ""}${cells}`; - }).join(""); - const javaWins = METRICS.flatMap((m) => - distances.filter((rd) => compareRows(m, pick(rows, "horizon", rd), pick(rows, "vanilla", rd))?.wins === "java").map((rd) => `${m.label.toLowerCase()} at ${rd}`), - ); - // Per scene too: an average that Horizon wins can hide a scene where Java is faster. - const sceneName = { village: "the village", forest: "the forest", ocean: "the ocean", mountain: "the mountain" }; - for (const rd of distances) { - const h = pick(rows, "horizon", rd); - const j = pick(rows, "vanilla", rd); - for (const [scene, js] of Object.entries(j?.scenes || {})) { - const hs = h?.scenes?.[scene]; - if (!hs?.avg_fps || !js?.avg_fps || (h.capped_fps && hs.avg_fps >= h.capped_fps * 0.97)) continue; - if (js.avg_fps > hs.avg_fps * 1.05) javaWins.push(`average FPS over ${sceneName[scene] || scene} at ${rd} (${format("fps", js.avg_fps)} against ${format("fps", hs.avg_fps)})`); - } - } - const facts = [ - ["CPU", first.cpu], - ["GPU", first.gpu], - ["Horizon", horizon ? `${horizon.version} · ${horizon.backend || "?"}` : "not measured"], - ["Java", java ? `${java.version} (vanilla) · ${java.backend || "?"}` : "not measured"], - ["Measured", `${first.measured}${first.repeats ? ` · ${first.repeats} runs of each` : ""}${first.window ? ` · ${first.window.join("x")} window` : ""}`], - ]; - return `
-
-

Measured by us

-

${escape(name)}

-
${facts.map(([k, v]) => `
${k}
${escape(String(v))}
`).join("")}
- ${rows.find((r) => r.note) ? `

${escape(rows.find((r) => r.note).note)}

` : ""} -
-
- ${head}${sub} - ${body} -
- ${javaWins.length ? `

Where Java is ahead: ${escape(javaWins.join("; "))}.

` : `

Horizon is ahead on every figure here.

`} -
`; -} - -export function machinesHtml(summary) { - const by = machines(summary?.groups || []); - if (!by.size) return `

No measurements are published yet.

`; - return [...by].map(([name, rows]) => machineHtml(name, rows)).join("\n"); -} - -// The hardware breakdowns (the server's classes), in words. -export const DIMENSIONS = [ - { key: "ram", label: "Memory", word: (c) => `${c} RAM` }, - { key: "vram", label: "Graphics memory", word: (c) => (c === "shared" ? "shared graphics memory" : `${c} graphics memory`) }, - { key: "storage", label: "Drive", word: (c) => ({ nvme: "NVMe", ssd: "SSD", hdd: "hard disk" })[c] || c }, - { key: "os", label: "System", word: (c) => ({ linux: "Linux", macos: "macOS", windows: "Windows" })[c] || c }, - { key: "gpu_kind", label: "Graphics", word: (c) => (c === "integrated" ? "integrated GPU" : c === "discrete" ? "separate GPU" : c) }, -]; - -// A group's breakdowns in one line: "32 GB RAM (3) · NVMe (3) · Linux (3)". Empty without any. -export function breakdownLine(breakdowns) { - return DIMENSIONS.flatMap((d) => (breakdowns?.[d.key] || []).map((c) => `${d.word(c.class)} (${c.installs})`)).join(" · "); -} - -// GET /v1/aggregate's breakdowns: players' play sessions per hardware class. Empty without any. -export function hardwareHtml(aggregate) { - const b = aggregate?.breakdowns || {}; - const dims = DIMENSIONS.filter((d) => b[d.key]?.length); - if (!dims.length) return ""; - const rows = dims - .flatMap((d) => - b[d.key].map( - (c, i) => - `${i === 0 ? `${escape(d.label)}` : ""}${escape(d.word(c.class))}${c.installs}${Math.round(c.hours).toLocaleString("en")}${format("ms", c.p50_ms)}${format("ms", c.p99_ms)}`, - ), - ) - .join(""); - return `
- - ${rows} -
ByClassInstallsHours playedFrame time, medianFrame time, 99th percentile
`; -} - -// Players' groups: one row each, at least `min_installs` installs. -export function playersHtml(summary) { - const groups = (summary?.groups || []).filter((g) => g.source === "players"); - if (!groups.length) - return `

No player results yet. A GPU and CPU pair shows here once at least ${summary?.min_installs || 3} different installs have shared a benchmark with it.

`; - const rows = groups - .map( - (g) => `${escape(g.gpu)}${escape(g.cpu)}${g.render_distance}${g.game === "vanilla" ? "Java" : "Horizon"}${["avg_fps", "low_1pct_fps", "p95_ms", "world_ms", "rss_steady_mb"].map((k) => `${format(METRICS.find((m) => m.key === k).kind, g.medians?.[k])}`).join("")}From ${g.installs} players${breakdownLine(g.breakdowns) ? `${escape(breakdownLine(g.breakdowns))}` : ""}`, - ) - .join(""); - return `
- - ${rows} -
GPU and CPURender distanceGameAverage FPS1% lowp95To in-worldMemorySource
`; -} diff --git a/website/src/benchmarks-page.js b/website/src/benchmarks-page.js deleted file mode 100644 index 5d05848e33..0000000000 --- a/website/src/benchmarks-page.js +++ /dev/null @@ -1,108 +0,0 @@ -// The Benchmarks page: players' results from the telemetry server (the baked summary when it doesn't answer) -// and the hardware picker (a measurement when there is one, else an estimate from src/estimate.js). -import { escape } from "./markdown.js"; -import { METRICS, cell, compareRows, format, hardwareHtml, playersHtml } from "./bench.js"; -import { estimate, measured, memoryNotes } from "./estimate.js"; -import { findCpu, findGpu } from "./hardware.js"; -import { setUpMenu, setUpTheme } from "./chrome.js"; - -const TELEMETRY = "https://telemetry.playhorizon.dev"; -const baked = JSON.parse(document.getElementById("bench-data").textContent); -let groups = baked.groups || []; - -async function live() { - try { - const res = await fetch(`${TELEMETRY}/v1/benchmarks/summary`, { signal: AbortSignal.timeout(3000) }); - if (!res.ok) throw new Error(String(res.status)); - const summary = await res.json(); - if (summary.format !== "horizon-benchmark-summary" || summary.version !== 1 || !Array.isArray(summary.groups)) throw new Error("format"); - // Our own rows stay the baked ones; players' come from the server. - const players = summary.groups.filter((g) => g.source === "players" && g.installs >= (summary.min_installs || 3)); - groups = [...groups.filter((g) => g.source === "us"), ...players]; - document.getElementById("players-table").innerHTML = playersHtml({ ...summary, groups: players }); - document.querySelector("[data-players-source]").textContent = `Live from the telemetry server, aggregated ${summary.generated.slice(0, 16).replace("T", " ")} UTC.`; - render(); - } catch { - // The baked results stay; the line under the heading already says when they were made. - } -} - -// Players' play sessions per hardware class (last 30 days); the section stays hidden until there are some. -async function hardware() { - try { - const res = await fetch(`${TELEMETRY}/v1/aggregate?days=30`, { signal: AbortSignal.timeout(3000) }); - if (!res.ok) return; - const html = hardwareHtml(await res.json()); - if (!html) return; - document.getElementById("players-hardware-table").innerHTML = html; - document.getElementById("players-hardware").hidden = false; - } catch { - // Nothing to show. - } -} - -const picker = document.getElementById("picker"); -const out = document.getElementById("pick-result"); - -const range = (kind, e) => { - if (!e) return "–"; - if (e.atLeast) return `at least ${format(kind, e.low)}`; - return e.low != null ? `${format(kind, e.low)} to ${format(kind, e.high)}` : format(kind, e.value); -}; - -function sourceBadge(row) { - return row.source === "us" ? `Measured by us · ${escape(row.machine)}` : `From ${row.installs} players`; -} - -function measuredTable(h, j) { - return `${METRICS.map((m) => { - const c = compareRows(m, h, j); - return ``; - }).join("")}
HorizonJavaHorizon against Java
${escape(m.label)}${cell(m, h)}${cell(m, j)}${c ? escape(c.text) : ""}
`; -} - -function estimateTable(h, j) { - return `${METRICS.map( - (m) => ``, - ).join("")}
Horizon (estimate)Java (estimate)
${escape(m.label)}${range(m.kind, h?.[m.key])}${range(m.kind, j?.[m.key])}
`; -} - -function render() { - const gpu = findGpu(picker.gpu.value); - const cpu = findCpu(picker.cpu.value); - const rd = Number(picker.rd.value); - const ramGb = Number(picker.ram.value) || null; - if (!gpu || !cpu) return; - // Horizon's and Java's notes, each once (the players' hardware is the same for both). - const notesHtml = (...lists) => { - const notes = [...new Set(lists.flat())]; - return notes.length ? `
    ${notes.map((n) => `
  • ${escape(n)}
  • `).join("")}
` : ""; - }; - const mh = measured(groups, gpu, cpu, rd, "horizon"); - const mj = measured(groups, gpu, cpu, rd, "vanilla"); - if (mh.length || mj.length) { - const h = mh[0]; - const j = mj[0]; - out.innerHTML = `

${[h, j].filter(Boolean).map(sourceBadge).join(" ")}

${measuredTable(h, j)}${notesHtml(...[h, j].filter(Boolean).map((row) => memoryNotes(row, gpu, ramGb)))}${ - h && h.source === "us" && h.measured ? `

Measured ${escape(h.measured)} with Horizon ${escape(h.version)}. The full table is under Measured by us.

` : "" - }`; - return; - } - const h = estimate(groups, gpu, cpu, rd, "horizon", { ramGb }); - const j = estimate(groups, gpu, cpu, rd, "vanilla", { ramGb }); - if (!h && !j) { - out.innerHTML = `

Nothing measured at render distance ${rd} to estimate from yet.

`; - return; - } - const base = (h || j).base; - const f = (h || j).factors; - const times = (x) => `${x >= 1 ? x.toFixed(x >= 10 ? 0 : 1) : x.toFixed(2)}x`; - out.innerHTML = `

Estimate From ${escape(base.source === "us" ? base.machine : `${base.gpu} with ${base.cpu}`)} (${escape(base.gpu)}, ${escape(base.cpu)}): GPU factor ${times(f.gpu)}, CPU factor ${times(f.cpu)}. How

${estimateTable(h, j)}${notesHtml(h?.notes || [], j?.notes || [])}

Ranges, not measurements: the real figure depends on drivers, the graphics API and cooling too. Memory is the measured machine's.

`; -} - -setUpTheme(); -setUpMenu(); -picker.addEventListener("change", render); -render(); -live(); -hardware(); diff --git a/website/src/benchmarks.html b/website/src/benchmarks.html deleted file mode 100644 index 41e270c5b2..0000000000 --- a/website/src/benchmarks.html +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - Benchmarks: Horizon against Java Edition · Horizon - - - - - - - - - - - - - - - - - - - - -{{SITE_NAV}} -
-
-

BenchmarksHorizon against Java Edition 26.3

-

Same machine, same world, same flight.

-

We run Horizon and vanilla Minecraft: Java Edition 26.3 one after the other on the same computer: a fresh world from the same seed, the same settings, and the same four flights over a village, a forest, the ocean and a mountain. Every figure comes from the same code for both games. Where Java does better, the tables say so.

- -
-

Measured by us

-

The project's own machines, at render distance 12 (vanilla's default) and 32. VSync off, no frame cap, a 1280x720 window, simulation distance 12, everything else at vanilla's defaults. Each figure is the median over the runs; the frame figures are averaged over the four flights.

-
-{{MACHINES}} -
-
- -
-

Your hardware

-

Pick a graphics card, a processor and, if you know it, your memory. If we or at least three players have measured that pair, you see what was measured; otherwise an estimate from the nearest measured machine, as a range.

-
- - - -
Render distance - - -
-
-

The picker needs JavaScript; the tables above and the method below work without it.

-
- -
-

From players

-

Players who allow benchmark uploads in the launcher's data collection settings share the report of horizon-client --benchmark standard. Results are grouped by graphics card, processor and render distance, and a group shows only once at least 3 different installs are in it, so no one can be picked out. Only medians are published, with how the group's installs split by memory, graphics memory, drive and system in coarse classes (each class only with at least 3 installs too): no install ID, date, driver, exact memory or screen.

-

{{PLAYERS_SOURCE}}

-
-{{PLAYERS}} -
-
- - - -
-

How the estimates work

-
-

An estimate starts from the measured machine closest to the one you picked, then scales its figures with a small table of specifications we wrote ourselves (no benchmark database):

-
    -
  • GPU factor: the geometric mean of the two graphics cards' ratio of FP32 shader throughput (TFLOPS) and of memory bandwidth (GB/s), as their makers state them.
  • -
  • CPU factor: the ratio of the two processors' single-thread scores: top boost clock times a rough factor per chip generation (Zen 4 = 1).
  • -
  • Frame rate: when the measured run split each frame into CPU and GPU time, the slower side is scaled by its factor; otherwise by the geometric mean of both. Whatever the split, the real figure lies between the measured one scaled by the smaller and by the larger factor; we widen that by 15% for the roughness of the table, and show it as the range.
  • -
  • Load times are CPU work (generating, lighting and meshing chunks), scaled by the CPU factor. Memory is not scaled: it follows the world and the settings, not the speed.
  • -
  • Memory size: when players' results say how much memory and graphics memory their machines have, a measured group whose players have neither yours counts as further away, and the estimate says what they had. With your memory picked, it also says when the game's peak use is a large share of it.
  • -
-

Estimates ignore drivers, the graphics API, thermal limits and the operating system, which can matter as much as the chip. Treat them as a rough idea, and run horizon-client --benchmark standard on your own machine for the real figure.

-
-
- The specification table -
{{SPEC_TABLE}}
-
-
- -
-

How we measure

-
-

tools/perf_bench.py starts each game from a fresh world (seed 1234, creative), waits until every chunk around the spawn is loaded and drawn, then for each scene teleports, waits for the chunks, holds for 2 seconds and flies a straight 192-block path in 12 seconds. Every frame is recorded. Vanilla runs as players get it (your own client, the official launcher's Java options, Java 25), driven by a small development-only mod that only calls the game's own code; nothing of Minecraft is shipped. The full method and its caveats are in docs/benchmark.md, and every raw report is in the repository.

-

Measure your own computer with horizon-client --benchmark standard --render-distance 12: it writes a report to the benchmarks folder of the game folder.

-
-
-

Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

-
-
-{{SITE_FOOTER}} - - - diff --git a/website/src/chrome.js b/website/src/chrome.js deleted file mode 100644 index d11eb33704..0000000000 --- a/website/src/chrome.js +++ /dev/null @@ -1,90 +0,0 @@ -// What every page with the site's top bar shares: the light and dark switch and the phone menu. -// Used by Home, Releases, Benchmarks and Docs. -const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; - -// The page follows the system; the toggle picks the other appearance and remembers it. -export function setUpTheme() { - const root = document.documentElement; - const button = document.querySelector(".theme-toggle"); - if (!button) return; - const label = button.querySelector("[data-theme-label]"); - const system = matchMedia("(prefers-color-scheme: dark)"); - const dark = () => (root.dataset.theme ? root.dataset.theme === "dark" : system.matches); - const show = () => (label.textContent = dark() ? "Light" : "Dark"); - button.addEventListener("click", () => { - root.dataset.theme = dark() ? "light" : "dark"; - try { - localStorage.setItem("theme", root.dataset.theme); - } catch {} - show(); - }); - system.addEventListener("change", show); - show(); -} - -// The phone menu: a full-screen sheet; the page behind it is inert while it is open. -export function setUpMenu() { - const bar = document.querySelector(".nav"); - const button = document.querySelector(".menu-button"); - const menu = document.getElementById("menu"); - const content = document.querySelector("[data-menu-content]") || document.getElementById("main"); - if (!bar || !button || !menu) return; - const label = button.querySelector(".menu-label"); - const items = [...menu.querySelectorAll("nav a")]; - items.forEach((a, i) => a.style.setProperty("transition-delay", `${60 + i * 35}ms`)); - let closing; - const setOpen = (open) => { - clearTimeout(closing); - button.setAttribute("aria-expanded", String(open)); - label.textContent = open ? "Close" : "Menu"; - bar.classList.toggle("is-menu", open); - content.inert = open; - document.body.style.overflow = open ? "hidden" : ""; - if (open) { - menu.hidden = false; - requestAnimationFrame(() => requestAnimationFrame(() => menu.classList.add("is-open"))); - items[0].focus({ preventScroll: true }); - } else { - menu.classList.remove("is-open"); - closing = setTimeout(() => (menu.hidden = true), reducedMotion ? 0 : 320); - } - }; - button.addEventListener("click", () => setOpen(button.getAttribute("aria-expanded") !== "true")); - items.forEach((a) => a.addEventListener("click", () => setOpen(false))); - addEventListener("keydown", (e) => { - if (e.key === "Escape" && button.getAttribute("aria-expanded") === "true") { - setOpen(false); - button.focus(); - } - }); - matchMedia("(min-width: 1280px)").addEventListener("change", (e) => e.matches && setOpen(false)); -} - -// GitHub answers, cached for ten minutes in the tab. -export async function cached(key, load) { - try { - const hit = JSON.parse(sessionStorage.getItem(key) || "null"); - if (hit && Date.now() - hit.at < 10 * 60 * 1000) return hit.value; - } catch {} - const value = await load(); - try { - sessionStorage.setItem(key, JSON.stringify({ at: Date.now(), value })); - } catch {} - return value; -} - -export async function get(url, as = "json") { - const res = await fetch(url, { headers: as === "json" ? { Accept: "application/vnd.github+json" } : {} }); - if (!res.ok) throw new Error(`${res.status}`); - return as === "json" ? res.json() : res.text(); -} - -export const slim = (r) => ({ - tag_name: r.tag_name, - name: r.name, - published_at: r.published_at, - html_url: r.html_url, - prerelease: r.prerelease, - body: r.body || "", - assets: (r.assets || []).map((a) => ({ name: a.name, browser_download_url: a.browser_download_url, size: a.size })), -}); diff --git a/website/src/contributors.js b/website/src/contributors.js deleted file mode 100644 index 12bce9457d..0000000000 --- a/website/src/contributors.js +++ /dev/null @@ -1,39 +0,0 @@ -// The contributors: GitHub's list for the repository, without bots, the owner first. Shared by -// build.mjs (baked into the page) and main.js (refreshed from GitHub). -import { escape } from "./markdown.js"; - -const OWNER = "ludovic111"; -const AVATARS = "https://avatars.githubusercontent.com/"; - -// Reduces GitHub's contributor objects to what the page shows; bots (type Bot, or a login -// ending in [bot]) and anything without a login or a profile address are dropped. -export function contributorsFrom(list) { - if (!Array.isArray(list)) return []; - return list - .filter((c) => c && typeof c.login === "string" && c.login && c.type !== "Bot" && !/\[bot\]$/i.test(c.login)) - .map((c) => ({ - login: c.login, - html_url: typeof c.html_url === "string" ? c.html_url : `https://github.com/${c.login}`, - avatar_url: typeof c.avatar_url === "string" ? c.avatar_url : "", - contributions: Number(c.contributions) || 0, - })) - .sort((a, b) => (b.login === OWNER) - (a.login === OWNER) || b.contributions - a.contributions); -} - -// GitHub serves avatars at any size: 96 px is twice what the page draws, for sharp screens. -export function avatar(url) { - if (!url.startsWith(AVATARS)) return ""; - const u = new URL(url); - u.searchParams.set("s", "96"); - return u.href; -} - -export function contributorsHtml(list) { - return list - .map((c) => { - const src = avatar(c.avatar_url); - const count = `${c.contributions.toLocaleString("en-US")} ${c.contributions === 1 ? "commit" : "commits"}`; - return `
  • ${src ? `` : ``}${escape(c.login)}${count}
  • `; - }) - .join(""); -} diff --git a/website/src/docs-page.js b/website/src/docs-page.js deleted file mode 100644 index 99da35187a..0000000000 --- a/website/src/docs-page.js +++ /dev/null @@ -1,5 +0,0 @@ -// The Docs pages share the site's theme switch and phone menu. -import { setUpMenu, setUpTheme } from "./chrome.js"; - -setUpTheme(); -setUpMenu(); diff --git a/website/src/docs.html b/website/src/docs.html deleted file mode 100644 index 56ba89c096..0000000000 --- a/website/src/docs.html +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - {{DOC_TITLE}} · Horizon docs - - - - - - - - - - - - - - - - - - - - -{{SITE_NAV}} - -
    -
    - Docs menu - -
    - - -
    -

    {{DOC_GROUP}}

    -

    {{DOC_TITLE}}

    -

    {{DOC_LEDE}}

    -{{DOC_TOC}} -
    -{{DOC_BODY}} -
    - -

    Edit this page on GitHub · Spotted something wrong? Open an issue or ask on Discord.

    -

    Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

    -
    -
    - - - diff --git a/website/src/estimate.js b/website/src/estimate.js deleted file mode 100644 index ccd053cc02..0000000000 --- a/website/src/estimate.js +++ /dev/null @@ -1,110 +0,0 @@ -// Estimates for hardware nobody has measured yet, from the nearest measured machine and the spec table -// (src/hardware.js). The method, which the Benchmarks page explains: -// -// - GPU factor: the geometric mean of the two GPUs' FP32 throughput ratio and memory bandwidth ratio. -// - CPU factor: the ratio of the two CPUs' single-thread scores (boost clock x generation factor). -// - Frames: when the measured run split each frame into CPU and GPU time, the slower of the two is scaled by its -// own factor; otherwise the frame rate is scaled by the geometric mean of both factors. Whatever the split, the -// true figure lies between the measured one scaled by the smaller and by the larger factor, widened by 15% for -// the roughness of the table: that is the range shown. -// - World and chunk load times are CPU work (generation, lighting, meshing): scaled by the CPU factor. -// - Memory is not scaled: it depends on the world and settings, not on speed. -// - Memory size: players' groups say how their installs split by RAM and by the GPU's own memory (breakdowns). A -// base whose players have neither the picked RAM nor the picked GPU's memory class counts as further away, and -// the estimate says what the base's players had, and when the game's peak memory is a large share of the RAM. -import { findCpu, findGpu, ramClass, singleThread, vramClass } from "./hardware.js"; - -const MARGIN = 0.15; - -export function factors(fromGpu, fromCpu, toGpu, toCpu) { - const gpu = Math.sqrt((toGpu.tflops / fromGpu.tflops) * (toGpu.gbps / fromGpu.gbps)); - const cpu = singleThread(toCpu) / singleThread(fromCpu); - return { gpu, cpu }; -} - -const distance = (f) => Math.abs(Math.log(f.gpu)) + Math.abs(Math.log(f.cpu)); -// Added to the distance per memory class (RAM, VRAM) the base's players don't have: about a 20% speed gap. -const MEMORY_MISMATCH = 0.2; - -// The classes a row's installs have in a breakdown, or null when it has none (our own rows, older summaries). -const classesIn = (row, dim) => (row.breakdowns?.[dim]?.length ? row.breakdowns[dim].map((c) => c.class) : null); - -function mismatch(row, gpu, ramGb) { - let d = 0; - const vram = classesIn(row, "vram"); - if (vram && !vram.includes(vramClass(gpu))) d += MEMORY_MISMATCH; - const ram = classesIn(row, "ram"); - if (ram && ramGb && !ram.includes(ramClass(ramGb))) d += MEMORY_MISMATCH; - return d; -} - -// What to say about memory: the base's players' RAM and VRAM when they differ from the pick, and the game's peak -// memory against the picked RAM. -export function memoryNotes(row, gpu, ramGb) { - const notes = []; - const list = (dim) => row.breakdowns[dim].map((c) => `${c.class} (${c.installs})`).join(", "); - const vram = classesIn(row, "vram"); - if (vram && gpu && !vram.includes(vramClass(gpu))) notes.push(`The measured players' graphics cards have ${list("vram")} of their own memory; this one has ${gpu.vram_gb ? `${gpu.vram_gb} GB` : "none (it shares the system's)"}.`); - const ram = classesIn(row, "ram"); - if (ram && ramGb && !ram.includes(ramClass(ramGb))) notes.push(`The measured players have ${list("ram")} of memory, not ${ramGb} GB.`); - const peak = row.medians?.rss_peak_mb; - if (ramGb && peak && peak / 1024 > ramGb * 0.4) - notes.push(`${row.game === "vanilla" ? "Java" : "Horizon"} used up to ${(peak / 1024).toFixed(1)} GB here: with ${ramGb} GB, close other programs, or the system may swap and stutter.`); - return notes; -} - -// The rows (benchmarks.json groups) usable as a base: a known GPU and CPU. -function known(rows) { - return rows.map((row) => ({ row, gpu: findGpu(row.gpu), cpu: findCpu(row.cpu) })).filter((r) => r.gpu && r.cpu); -} - -// The measured row for exactly this GPU and CPU, players' first when there are some (more installs), or null. -export function measured(rows, gpu, cpu, rd, game) { - const hits = known(rows).filter((r) => r.gpu === gpu && r.cpu === cpu && r.row.render_distance === rd && r.row.game === game); - hits.sort((a, b) => (b.row.installs || 0) - (a.row.installs || 0)); - return hits.map((r) => r.row); -} - -// The nearest measured machine for a target GPU and CPU, for one game and render distance. -export function nearest(rows, gpu, cpu, rd, game, { ramGb } = {}) { - let best = null; - for (const r of known(rows)) { - if (r.row.render_distance !== rd || r.row.game !== game) continue; - const f = factors(r.gpu, r.cpu, gpu, cpu); - const d = distance(f) + mismatch(r.row, gpu, ramGb); - if (!best || d < best.d) best = { ...r, f, d }; - } - return best; -} - -export function estimate(rows, gpu, cpu, rd, game, { ramGb } = {}) { - const base = nearest(rows, gpu, cpu, rd, game, { ramGb }); - if (!base) return null; - const m = base.row.medians; - const { gpu: g, cpu: c } = base.f; - const lo = Math.min(g, c) * (1 - MARGIN); - const hi = Math.max(g, c) * (1 + MARGIN); - let scale = Math.sqrt(g * c); - if (m.cpu_p50_ms && m.gpu_p50_ms) { - const worst = Math.max(m.cpu_p50_ms, m.gpu_p50_ms); - scale = worst / Math.max(m.cpu_p50_ms / c, m.gpu_p50_ms / g); - } - scale = Math.min(hi, Math.max(lo, scale)); - const fps = (x) => (x == null ? null : { value: x * scale, low: x * lo, high: x * hi }); - // A base held to its display's refresh rate gives only a floor for the average. - const avg = fps(m.avg_fps); - if (avg && base.row.capped_fps && m.avg_fps >= base.row.capped_fps * 0.97) Object.assign(avg, { value: null, high: null, atLeast: true }); - const time = (x, f) => (x == null ? null : { value: x / f, low: x / (f * (1 + MARGIN)), high: x / (f * (1 - MARGIN)) }); - return { - base: base.row, - factors: base.f, - avg_fps: avg, - low_1pct_fps: fps(m.low_1pct_fps), - p95_ms: m.p95_ms == null ? null : { value: m.p95_ms / scale, low: m.p95_ms / hi, high: m.p95_ms / lo }, - world_ms: time(m.world_ms, c), - chunk_load_ms: time(m.chunk_load_ms, c), - rss_steady_mb: m.rss_steady_mb == null ? null : { value: m.rss_steady_mb }, - rss_peak_mb: m.rss_peak_mb == null ? null : { value: m.rss_peak_mb }, - notes: memoryNotes(base.row, gpu, ramGb), - }; -} diff --git a/website/src/estimate.test.mjs b/website/src/estimate.test.mjs deleted file mode 100644 index d46f9c4b5a..0000000000 --- a/website/src/estimate.test.mjs +++ /dev/null @@ -1,101 +0,0 @@ -// node --test website/src/ (npm test in website/) -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { estimate, factors, measured, nearest } from "./estimate.js"; -import { CPUS, GPUS, findCpu, findGpu } from "./hardware.js"; - -const row = (gpu, cpu, medians, extra = {}) => ({ gpu, cpu, render_distance: 12, game: "horizon", source: "us", medians, ...extra }); -const mac = row("Apple M4 Pro", "Apple M4 Pro", { avg_fps: 500, low_1pct_fps: 100, p95_ms: 3, world_ms: 4000, chunk_load_ms: 1000, rss_peak_mb: 1500, rss_steady_mb: 1200 }); -const laptop = row("Mesa Intel HD Graphics 620 (KBL GT2)", "Intel Core i7-7500U CPU @ 2.70GHz", { avg_fps: 100, low_1pct_fps: 20, p95_ms: 15, world_ms: 12000, chunk_load_ms: 5000, rss_peak_mb: 1400, rss_steady_mb: 1100, cpu_p50_ms: 4, gpu_p50_ms: 9 }); - -test("every reported name of our machines is in the table", () => { - assert.equal(findGpu("Apple M4 Pro").name, "Apple M4 Pro (20-core GPU)"); - assert.equal(findGpu("Mesa Intel HD Graphics 620 (KBL GT2)").name, "Intel HD Graphics 620"); - assert.equal(findGpu("NVIDIA GeForce RTX 4080").name, "NVIDIA GeForce RTX 4080"); - assert.equal(findGpu("NVIDIA GeForce RTX 4080 SUPER"), null); - assert.equal(findCpu("13th Gen Intel Core i7-13700KF").name, "Intel Core i7-13700K / KF"); - assert.equal(findCpu("Intel Core i7-7500U CPU @ 2.70GHz").name, "Intel Core i7-7500U"); - for (const g of GPUS) assert.equal(findGpu(g.name), g, g.name); - for (const c of CPUS) assert.equal(findCpu(c.name), c, c.name); -}); - -test("an exact match is a measurement, not an estimate", () => { - assert.deepEqual(measured([mac, laptop], findGpu("Apple M4 Pro"), findCpu("Apple M4 Pro"), 12, "horizon"), [mac]); - assert.deepEqual(measured([mac], findGpu("Apple M4 Pro"), findCpu("Apple M4 Pro"), 32, "horizon"), []); -}); - -test("the nearest machine is the base, and the range holds the estimate", () => { - const gpu = findGpu("Intel Iris Xe Graphics (96 EU)"); - const cpu = findCpu("Intel Core i5-1135G7"); - assert.equal(nearest([mac, laptop], gpu, cpu, 12, "horizon").row, laptop); - const e = estimate([mac, laptop], gpu, cpu, 12, "horizon"); - const f = factors(findGpu(laptop.gpu), findCpu(laptop.cpu), gpu, cpu); - assert.ok(f.gpu > 1 && f.cpu > 1); - for (const key of ["avg_fps", "low_1pct_fps", "p95_ms", "world_ms", "chunk_load_ms"]) { - const { value, low, high } = e[key]; - assert.ok(low <= value && value <= high, key); - } - // GPU-bound frames (9 ms GPU, 4 ms CPU) follow the GPU factor. - assert.ok(Math.abs(e.avg_fps.value - 100 * Math.min(f.gpu, 9 / (4 / f.cpu))) < 1e-9); - assert.ok(Math.abs(e.world_ms.value - 12000 / f.cpu) < 1e-9); - assert.equal(e.rss_steady_mb.value, 1100); -}); - -test("no base, no estimate", () => { - assert.equal(estimate([mac], findGpu("Apple M4"), findCpu("Apple M4"), 32, "horizon"), null); - assert.equal(estimate([mac], findGpu("Apple M4"), findCpu("Apple M4"), 12, "vanilla"), null); -}); - -test("a base held to its display's refresh rate gives only a floor for the average", () => { - const held = { ...mac, capped_fps: 144, medians: { ...mac.medians, avg_fps: 144 } }; - const e = estimate([held], findGpu("NVIDIA GeForce RTX 4060"), findCpu("AMD Ryzen 5 5600X"), 12, "horizon"); - assert.equal(e.avg_fps.atLeast, true); - assert.equal(e.avg_fps.high, null); - assert.ok(e.avg_fps.low > 0); - // The lows sit under the cap: still a range. - assert.ok(e.low_1pct_fps.high > e.low_1pct_fps.low); -}); - -test("a release title of issue numbers falls back to its notes", async () => { - const { parts } = await import("./releases.js"); - const r = { tag_name: "v2026.10.22.07", name: "Horizon 2026.10.22.07: #1255, mob pushes, fix, #1222", body: "### Fixed\n- Fix #1222: single player runs in the frame again (#1257, fixes #1222)\n- Mob pushes: one list per mob (#1205)\n" }; - assert.equal(parts(r).line, "Single player runs in the frame again and 1 more"); - assert.equal(parts({ ...r, name: "Horizon 2026.10.22.05: Hoppers, mob tick, server tick" }).line, "Hoppers, mob tick, server tick"); -}); - -test("players' memory classes steer the base and the notes", async () => { - const { memoryNotes } = await import("./estimate.js"); - const { ramClass, vramClass } = await import("./hardware.js"); - assert.equal(vramClass(findGpu("NVIDIA GeForce RTX 4080")), "16 GB"); - assert.equal(vramClass(findGpu("NVIDIA GeForce RTX 3080")), "12 GB"); - assert.equal(vramClass(findGpu("Apple M4 Pro")), "shared"); - assert.equal(ramClass(16), "16 GB"); - // Two equally near groups: the one whose players have the picked GPU's memory class wins. - const players = (vram, ram, fps) => - row("NVIDIA GeForce RTX 4070", "AMD Ryzen 5 7600X", { avg_fps: fps, rss_peak_mb: 3600 }, { source: "players", installs: 3, breakdowns: { vram: [{ class: vram, installs: 3 }], ram: [{ class: ram, installs: 3 }] } }); - const a = players("8 GB", "16 GB", 400); - const b = { ...players("12 GB", "32 GB", 500), cpu: "AMD Ryzen 7 7800X3D" }; - const gpu = findGpu("NVIDIA GeForce RTX 4070 Ti"); - const cpu = findCpu("AMD Ryzen 5 7600X"); - assert.equal(nearest([a, b], gpu, cpu, 12, "horizon").row, b, "12 GB like the 4070 Ti, though the CPU differs"); - assert.equal(nearest([a, b], gpu, cpu, 12, "horizon", { ramGb: 16 }).row, a, "both classes count"); - assert.deepEqual(memoryNotes(b, gpu, 32), []); - const notes = memoryNotes(a, gpu, 8); - assert.equal(notes.length, 3); - assert.match(notes[0], /8 GB \(3\) of their own memory; this one has 12 GB/); - assert.match(notes[1], /16 GB \(3\) of memory, not 8 GB/); - assert.match(notes[2], /Horizon used up to 3\.5 GB/); - // Our own rows have no breakdowns: only the peak memory note. - assert.deepEqual(memoryNotes(mac, gpu, 32), []); - assert.equal(estimate([a], gpu, cpu, 12, "horizon", { ramGb: 8 }).notes.length, 3); -}); - -test("breakdowns read as words, and the hardware table shows only with data", async () => { - const { breakdownLine, hardwareHtml } = await import("./bench.js"); - assert.equal(breakdownLine({ ram: [{ class: "32 GB", installs: 3 }], storage: [{ class: "nvme", installs: 4 }], gpu_kind: [{ class: "discrete", installs: 3 }] }), "32 GB RAM (3) · NVMe (4) · separate GPU (3)"); - assert.equal(breakdownLine(undefined), ""); - assert.equal(hardwareHtml({ breakdowns: {} }), ""); - const html = hardwareHtml({ breakdowns: { os: [{ class: "linux", installs: 5, sessions: 9, hours: 4.5, p50_ms: 6.2, p99_ms: 14 }] } }); - assert.match(html, /Linux/); - assert.match(html, /6\.2 ms/); -}); diff --git a/website/src/hardware.js b/website/src/hardware.js deleted file mode 100644 index bf4c8bbbdc..0000000000 --- a/website/src/hardware.js +++ /dev/null @@ -1,88 +0,0 @@ -// The hardware the Benchmarks page can estimate for, from specifications only (our own table, no benchmark -// database): each GPU's FP32 shader throughput in TFLOPS and its memory bandwidth in GB/s as its maker states them, -// its own memory in GB (vram_gb; 0 for a GPU that shares the system's: integrated, Apple silicon), -// and each CPU's single-thread score = its top boost clock (GHz) x our rough per-generation factor (instructions per -// clock, Zen 4 = 1). The factors are round numbers, so estimates are ranges, and the page says so. -// `match` finds a reported name (the GPU and CPU strings in benchmark reports) in this table. - -export const GPUS = [ - { name: "NVIDIA GeForce RTX 4090", tflops: 82.6, gbps: 1008, vram_gb: 24, match: /RTX 4090/i }, - { name: "NVIDIA GeForce RTX 4080", tflops: 48.7, gbps: 717, vram_gb: 16, match: /RTX 4080(?! SUPER)/i }, - { name: "NVIDIA GeForce RTX 4070 Ti", tflops: 40.1, gbps: 504, vram_gb: 12, match: /RTX 4070 Ti/i }, - { name: "NVIDIA GeForce RTX 4070", tflops: 29.1, gbps: 504, vram_gb: 12, match: /RTX 4070(?! Ti)/i }, - { name: "NVIDIA GeForce RTX 4060 Ti", tflops: 22.1, gbps: 288, vram_gb: 8, match: /RTX 4060 Ti/i }, - { name: "NVIDIA GeForce RTX 4060", tflops: 15.1, gbps: 272, vram_gb: 8, match: /RTX 4060(?! Ti)/i }, - { name: "NVIDIA GeForce RTX 3080", tflops: 29.8, gbps: 760, vram_gb: 10, match: /RTX 3080(?! Ti)/i }, - { name: "NVIDIA GeForce RTX 3070", tflops: 20.3, gbps: 448, vram_gb: 8, match: /RTX 3070(?! Ti)/i }, - { name: "NVIDIA GeForce RTX 3060", tflops: 12.7, gbps: 360, vram_gb: 12, match: /RTX 3060(?! Ti)/i }, - { name: "NVIDIA GeForce RTX 3050", tflops: 9.1, gbps: 224, vram_gb: 8, match: /RTX 3050/i }, - { name: "NVIDIA GeForce RTX 2060", tflops: 6.5, gbps: 336, vram_gb: 6, match: /RTX 2060(?! SUPER)/i }, - { name: "NVIDIA GeForce GTX 1660 SUPER", tflops: 5.0, gbps: 336, vram_gb: 6, match: /GTX 1660 SUPER/i }, - { name: "NVIDIA GeForce GTX 1060 6GB", tflops: 4.4, gbps: 192, vram_gb: 6, match: /GTX 1060/i }, - { name: "NVIDIA GeForce GTX 1050 Ti", tflops: 2.1, gbps: 112, vram_gb: 4, match: /GTX 1050 Ti/i }, - { name: "AMD Radeon RX 7900 XTX", tflops: 61.4, gbps: 960, vram_gb: 24, match: /RX 7900 XTX/i }, - { name: "AMD Radeon RX 7800 XT", tflops: 37.3, gbps: 624, vram_gb: 16, match: /RX 7800 XT/i }, - { name: "AMD Radeon RX 7600", tflops: 21.8, gbps: 288, vram_gb: 8, match: /RX 7600(?! XT)/i }, - { name: "AMD Radeon RX 6700 XT", tflops: 13.2, gbps: 384, vram_gb: 12, match: /RX 6700 XT/i }, - { name: "AMD Radeon RX 6600", tflops: 8.9, gbps: 224, vram_gb: 8, match: /RX 6600(?! XT)/i }, - { name: "AMD Radeon RX 580", tflops: 6.2, gbps: 256, vram_gb: 8, match: /RX 580/i }, - { name: "AMD Radeon 780M (integrated)", tflops: 8.6, gbps: 89, vram_gb: 0, match: /780M/i }, - { name: "Steam Deck (AMD Van Gogh)", tflops: 1.6, gbps: 88, vram_gb: 0, match: /VanGogh|Van Gogh|Custom GPU 0405/i }, - { name: "Intel Arc A770", tflops: 19.7, gbps: 560, vram_gb: 16, match: /Arc.*A770/i }, - { name: "Intel Iris Xe Graphics (96 EU)", tflops: 2.1, gbps: 68, vram_gb: 0, match: /Iris\(?R?\)? Xe/i }, - { name: "Intel HD Graphics 620", tflops: 0.4, gbps: 34, vram_gb: 0, match: /\bU?HD Graphics 620\b/i }, - { name: "Apple M1", tflops: 2.6, gbps: 68, vram_gb: 0, match: /^Apple M1$/i }, - { name: "Apple M2", tflops: 3.6, gbps: 100, vram_gb: 0, match: /^Apple M2$/i }, - { name: "Apple M3", tflops: 4.1, gbps: 100, vram_gb: 0, match: /^Apple M3$/i }, - { name: "Apple M4", tflops: 4.3, gbps: 120, vram_gb: 0, match: /^Apple M4$/i }, - { name: "Apple M1 Pro (16-core GPU)", tflops: 5.2, gbps: 200, vram_gb: 0, match: /^Apple M1 Pro$/i }, - { name: "Apple M4 Pro (20-core GPU)", tflops: 8.5, gbps: 273, vram_gb: 0, match: /^Apple M4 Pro$/i }, - { name: "Apple M4 Max (40-core GPU)", tflops: 17.0, gbps: 546, vram_gb: 0, match: /^Apple M4 Max$/i }, -]; - -// ghz: top single-core boost clock; ipc: our per-generation factor (Zen 4 = 1). -export const CPUS = [ - { name: "Intel Core i7-7500U", ghz: 3.5, ipc: 0.62, match: /i7-7500U/i }, - { name: "Intel Core i5-7200U", ghz: 3.1, ipc: 0.62, match: /i5-7200U/i }, - { name: "Intel Core i5-8250U", ghz: 3.4, ipc: 0.62, match: /i5-8250U/i }, - { name: "Intel Core i5-9400F", ghz: 4.1, ipc: 0.62, match: /i5-9400/i }, - { name: "Intel Core i7-9700K", ghz: 4.9, ipc: 0.62, match: /i7-9700K/i }, - { name: "Intel Core i5-10400", ghz: 4.3, ipc: 0.62, match: /i5-10400/i }, - { name: "Intel Core i5-1135G7", ghz: 4.2, ipc: 0.78, match: /i5-1135G7/i }, - { name: "Intel Core i5-12400", ghz: 4.4, ipc: 0.95, match: /i5-12400/i }, - { name: "Intel Core i5-13600K", ghz: 5.1, ipc: 0.95, match: /i5-13600K/i }, - { name: "Intel Core i7-13700K / KF", ghz: 5.4, ipc: 0.95, match: /i7-13700K/i }, - { name: "Intel Core i9-14900K", ghz: 6.0, ipc: 0.95, match: /i9-14900K/i }, - { name: "AMD Ryzen 5 3600", ghz: 4.2, ipc: 0.72, match: /Ryzen 5 3600/i }, - { name: "AMD Ryzen 5 5600X", ghz: 4.6, ipc: 0.88, match: /Ryzen 5 5600X?\b/i }, - { name: "AMD Ryzen 7 5800X3D", ghz: 4.5, ipc: 0.88, match: /5800X3D/i }, - { name: "AMD Ryzen 5 7600X", ghz: 5.3, ipc: 1.0, match: /Ryzen 5 7600/i }, - { name: "AMD Ryzen 7 7800X3D", ghz: 5.0, ipc: 1.0, match: /7800X3D/i }, - { name: "AMD Ryzen 9 7950X", ghz: 5.7, ipc: 1.0, match: /Ryzen 9 7950X(?!3D)/i }, - { name: "AMD Ryzen 7 9700X", ghz: 5.5, ipc: 1.12, match: /Ryzen 7 9700X/i }, - { name: "AMD Ryzen 7 7840U / HS", ghz: 5.1, ipc: 1.0, match: /7840(U|HS)/i }, - { name: "Steam Deck (AMD Zen 2, 4 cores)", ghz: 3.5, ipc: 0.72, match: /AMD Custom APU 0405|Van ?Gogh/i }, - { name: "Apple M1", ghz: 3.2, ipc: 1.25, match: /^Apple M1$/i }, - { name: "Apple M2", ghz: 3.5, ipc: 1.28, match: /^Apple M2$/i }, - { name: "Apple M3", ghz: 4.05, ipc: 1.38, match: /^Apple M3$/i }, - { name: "Apple M4", ghz: 4.4, ipc: 1.5, match: /^Apple M4$/i }, - { name: "Apple M4 Pro", ghz: 4.5, ipc: 1.5, match: /^Apple M4 Pro$/i }, -]; - -export const singleThread = (cpu) => cpu.ghz * cpu.ipc; -export const findGpu = (name) => GPUS.find((g) => g.name === name || g.match.test(name || "")) || null; -export const findCpu = (name) => CPUS.find((c) => c.name === name || c.match.test(name || "")) || null; - -// The telemetry server's hardware classes (crates/horizon-telemetry-server/src/classes.rs), which players' -// results are broken down by: memory and a GPU's own memory in coarse steps. -export function ramClass(gb) { - if (!(gb > 0)) return null; - return gb <= 5 ? "4 GB or less" : gb <= 10 ? "8 GB" : gb <= 20 ? "16 GB" : gb <= 40 ? "32 GB" : "64 GB or more"; -} -export function vramClass(gpu) { - if (!gpu) return null; - if (!gpu.vram_gb) return "shared"; - const mb = gpu.vram_gb * 1024; - for (const [max, name] of [[2560, "2 GB or less"], [4608, "4 GB"], [6656, "6 GB"], [8704, "8 GB"], [12800, "12 GB"], [16896, "16 GB"]]) if (mb <= max) return name; - return "more than 16 GB"; -} diff --git a/website/src/hub-page.js b/website/src/hub-page.js deleted file mode 100644 index 9115c5ca47..0000000000 --- a/website/src/hub-page.js +++ /dev/null @@ -1,270 +0,0 @@ -// The hub page (/hub): its tabs (one per #hash, so /hub#roadmap and /hub# can be linked), the -// Simple / Technical switch (remembered in localStorage), the area filter, and the live state of every -// linked issue and pull request, refreshed from GitHub's public API. Without JavaScript, every tab reads -// one after the other with what was baked at build time. -import { cached, get, setUpMenu, setUpTheme } from "./chrome.js"; -import { REPO, boardFilterHtml, boardFrom, boardHtml, journalFrom, journalHtml, overviewHtml, roadmapHtml, slimIssue } from "./hub.js"; - -const root = document.documentElement; -const data = JSON.parse(document.getElementById("hub-data").textContent); -const tabs = [...document.querySelectorAll('.hub-tabs [role="tab"]')]; -const pill = document.querySelector(".hub-tabs .tab-pill"); -let area = "all"; - -// --- Tabs ------------------------------------------------------------------------------------------------------ - -const panelOf = (tab) => document.getElementById(tab.getAttribute("aria-controls")); -const place = (tab) => { - pill.style.width = `${tab.offsetWidth}px`; - pill.style.height = `${tab.offsetHeight}px`; - pill.style.transform = `translate(${tab.offsetLeft}px, ${tab.offsetTop}px)`; -}; -function choose(tab, { focus = false, push = false } = {}) { - for (const t of tabs) { - const on = t === tab; - t.setAttribute("aria-selected", String(on)); - t.tabIndex = on ? 0 : -1; - panelOf(t).classList.toggle("is-active", on); - } - place(tab); - if (focus) tab.focus(); - if (push && location.hash !== `#${panelOf(tab).id}`) history.replaceState(null, "", `#${panelOf(tab).id}`); -} -// "#roadmap" opens its tab; "#" or "#help-wanted" opens the tab holding it and scrolls there. -function follow() { - const target = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1))); - const tab = tabs.find((t) => target && (panelOf(t) === target || panelOf(t).contains(target))) || tabs[0]; - choose(tab); - if (target && target !== panelOf(tab)) { - if (target.dataset.area && area !== "all" && target.dataset.area !== area) filter("all"); - requestAnimationFrame(() => target.scrollIntoView({ block: "start" })); - } -} -tabs.forEach((tab, i) => { - tab.addEventListener("click", () => choose(tab, { push: true })); - tab.addEventListener("keydown", (e) => { - const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key]; - if (step) choose(tabs[(i + step + tabs.length) % tabs.length], { focus: true, push: true }); - }); -}); -// Links inside the page ("Help wanted", an item of the overview) switch tabs through the hash. -addEventListener("hashchange", follow); -addEventListener("resize", () => place(tabs.find((t) => t.getAttribute("aria-selected") === "true"))); -document.fonts?.ready.then(() => place(tabs.find((t) => t.getAttribute("aria-selected") === "true"))); - -// --- Simple / Technical ---------------------------------------------------------------------------------------- - -const detailButtons = [...document.querySelectorAll("[data-detail]")]; -function detail(mode) { - if (mode === "dev") root.dataset.detail = "dev"; - else delete root.dataset.detail; - detailButtons.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.detail === mode))); -} -detailButtons.forEach((b) => - b.addEventListener("click", () => { - detail(b.dataset.detail); - try { - localStorage.setItem("hub:detail", b.dataset.detail); - } catch {} - }), -); -detail(root.dataset.detail === "dev" ? "dev" : "plain"); - -// --- Area filter ----------------------------------------------------------------------------------------------- - -const areaButtons = [...document.querySelectorAll("[data-area-filter]")]; -function filter(to) { - area = to; - areaButtons.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.areaFilter === area))); - for (const section of document.querySelectorAll("[data-roadmap] .hub-section")) { - let shown = 0; - for (const item of section.querySelectorAll(".hub-item")) { - item.hidden = area !== "all" && item.dataset.area !== area; - if (!item.hidden) shown++; - } - section.hidden = shown === 0; - } -} -areaButtons.forEach((b) => b.addEventListener("click", () => filter(b.dataset.areaFilter))); - -// --- The board: a roadmap item's cards, and the long columns' "Show more" ---------------------------------------- - -const boardSelect = document.querySelector("[data-board-item]"); -function showItem() { - const want = boardSelect.value; - for (const card of document.querySelectorAll("[data-board] .hub-card")) { - card.hidden = want === "-" ? !card.dataset.item : want ? card.dataset.item !== want : false; - } - for (const column of document.querySelectorAll("[data-board] .hub-column")) { - column.querySelector("[data-count]").textContent = column.querySelectorAll(".hub-card:not([hidden])").length; - // A filtered column shows all its cards. - column.classList.toggle("is-open", Boolean(want) || column.classList.contains("was-opened")); - } -} -boardSelect?.addEventListener("change", showItem); -document.querySelector("[data-board]")?.addEventListener("click", (e) => { - const button = e.target.closest("[data-more-button]"); - if (!button) return; - button.closest(".hub-column").classList.add("is-open", "was-opened"); - button.remove(); -}); - -// --- Live state from GitHub ------------------------------------------------------------------------------------ - -// The open issues and pull requests, and those closed or merged lately: four requests at most, kept ten minutes. -async function loadLive() { - const list = []; - for (let page = 1; page <= 3; page++) { - const batch = await get(`https://api.github.com/repos/${REPO}/issues?state=open&per_page=100&page=${page}`); - list.push(...batch.map(slimIssue)); - if (batch.length < 100) break; - } - const closed = await get(`https://api.github.com/repos/${REPO}/issues?state=closed&sort=updated&direction=desc&per_page=100`); - return list.concat(closed.map(slimIssue)); -} -const loadReleases = () => get(`https://api.github.com/repos/${REPO}/releases?per_page=40`); - -async function refresh() { - const fresh = await cached("horizon:hub-live", loadLive).catch(() => null); - if (!fresh) return; - // What the build knew about older links stays; GitHub's answer wins for the rest. - const live = new Map([...data.live, ...fresh].map((i) => [i.n, i])); - document.querySelector("[data-overview]").innerHTML = overviewHtml(data.roadmap, live); - document.querySelector("[data-roadmap]").innerHTML = roadmapHtml(data.roadmap, live); - filter(area); - showVotes(); - const board = boardFrom(fresh, data.roadmap); - const chosen = boardSelect.value; - boardSelect.innerHTML = boardFilterHtml(board, data.roadmap); - boardSelect.value = [...boardSelect.options].some((o) => o.value === chosen) ? chosen : ""; - document.querySelector("[data-board]").innerHTML = boardHtml(board, data.roadmap); - showItem(); - const releases = await cached("horizon:hub-releases", loadReleases).catch(() => null); - if (releases) document.querySelector("[data-journal]").innerHTML = journalHtml(journalFrom(releases, fresh, data.roadmap), data.roadmap); - const note = document.querySelector("[data-live-note]"); - if (note) note.textContent = "Pull request and issue states are live from GitHub."; -} - -// --- Votes, bug reports and suggestions: the hub's server (crates/horizon-telemetry-server) -------------------- - -const saved = (key, fallback) => { - try { - return JSON.parse(localStorage.getItem(key)) ?? fallback; - } catch { - return fallback; - } -}; -const save = (key, value) => { - try { - localStorage.setItem(key, JSON.stringify(value)); - } catch {} -}; -const post = (path, body) => fetch(`${data.api}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); - -// This browser's random id (one vote per item per browser) and the items it voted for. Nothing else identifies it. -function voter() { - let id = saved("hub:voter", ""); - if (typeof id !== "string" || id.length < 16) { - id = crypto.randomUUID ? crypto.randomUUID() : [...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, "0")).join(""); - save("hub:voter", id); - } - return id; -} -const mine = new Set(saved("hub:voted", [])); -let counts = null; // { item: votes } once the server answered; until then the buttons stay hidden - -function showVotes() { - if (!counts) return; - for (const button of document.querySelectorAll("[data-vote]")) { - const id = button.dataset.vote; - button.hidden = false; - button.setAttribute("aria-pressed", String(mine.has(id))); - button.querySelector(".hub-vote-label").textContent = mine.has(id) ? "Wanted" : "Want it"; - button.querySelector("[data-votes]").textContent = counts[id] ? String(counts[id]) : ""; - } -} - -async function vote(id) { - const up = !mine.has(id); - const apply = (on, step) => { - if (on) mine.add(id); - else mine.delete(id); - counts[id] = Math.max(0, (counts[id] || 0) + step); - save("hub:voted", [...mine]); - showVotes(); - }; - apply(up, up ? 1 : -1); - try { - const res = await post("/hub/vote", { item: id, voter: voter(), up }); - if (!res.ok) throw new Error(res.status); - counts[id] = (await res.json()).votes; - showVotes(); - } catch { - apply(!up, up ? -1 : 1); - } -} -document.addEventListener("click", (e) => { - const button = e.target.closest("[data-vote]"); - if (button && counts) vote(button.dataset.vote); -}); - -const LABELS = { title: "the title", did: "what you did", happened: "what happened", expected: "what you expected", version: "the version", system: "OS, CPU, GPU", text: "your idea", name: "your name" }; -// The server's answer in words: "did: 3 to 2000 characters" becomes "Check what you did: 3 to 2000 characters." -function trouble(code, answer) { - if (code === 429) return "Too many sent from your connection: try again in a few minutes."; - const said = typeof answer.error === "string" ? answer.error : ""; - const field = said.match(/^(\w+): (.+)$/); - if (code === 400 && field) return `Check ${LABELS[field[1]] || field[1]}: ${field[2]}.`; - return said ? `${said[0].toUpperCase()}${said.slice(1)}.` : "That didn't go through: try again in a moment."; -} - -function setUpForm(form, path, thanks) { - const status = form.querySelector("[data-status]"); - form.hidden = false; - form.parentElement.querySelector("[data-closed]").hidden = true; - form.addEventListener("submit", async (e) => { - e.preventDefault(); - if (!form.reportValidity()) return; - const button = form.querySelector('[type="submit"]'); - button.disabled = true; - status.textContent = "Sending…"; - try { - const res = await post(path, Object.fromEntries(new FormData(form))); - const answer = await res.json().catch(() => ({})); - if (res.ok) { - thanks(status, answer); - // A new report starts with the version filled in again; the name stays for the next one. - for (const field of form.querySelectorAll("textarea, input:not([name='version']):not([name='name'])")) field.value = ""; - } else status.textContent = trouble(res.status, answer); - } catch { - status.textContent = "Couldn't reach the server: try again, or use GitHub or Discord."; - } - button.disabled = false; - }); -} - -// The forms and the votes show once the server answers; without it the page points to GitHub and Discord. -async function connect() { - try { - // Revalidated: the server keeps the counts a minute, a browser's copy would miss this browser's own vote. - const res = await fetch(`${data.api}/hub/votes`, { cache: "no-cache" }); - if (!res.ok) return; - counts = (await res.json()).votes || {}; - } catch { - return; - } - showVotes(); - setUpForm(document.querySelector('[data-form="report"]'), "/hub/report", (status, answer) => { - const issue = typeof answer.issue === "string" && answer.issue.match(/^https:\/\/github\.com\/ludovic111\/horizon\/issues\/(\d+)$/); - status.innerHTML = issue ? `Thanks! It's issue #${issue[1]} on GitHub: follow it there.` : "Thanks! It will be on GitHub in a few minutes."; - }); - setUpForm(document.querySelector('[data-form="suggest"]'), "/hub/suggest", (status) => { - status.textContent = "Thanks! The maintainers read every suggestion."; - }); -} - -setUpTheme(); -setUpMenu(); -connect(); -follow(); -refresh().then(() => data.roadmap.items.some((i) => `#${i.id}` === location.hash) && follow()); diff --git a/website/src/hub.html b/website/src/hub.html deleted file mode 100644 index 167ffc15e4..0000000000 --- a/website/src/hub.html +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - Hub · Horizon - - - - - - - - - - - - - - - - - - - - - -{{SITE_NAV}} -
    -
    -

    HubNow, next and how to help

    -

    Where Horizon is going

    -

    What is being done, what comes next, what shipped and where you can help. No dates: things are Now, Next or Later. Read it in plain words, or switch to the technical picture.

    -
    -
    - - - - - - -
    -
    - - -
    -
    - -
    -{{HUB_OVERVIEW}} -
    - -
    -
    -{{HUB_AREAS}} -
    -
    -{{HUB_ROADMAP}} -
    -

    The roadmap lives in the repository: ROADMAP.md, generated from roadmap/roadmap.toml. Pull requests carry the roadmap:<id> label of their item.

    -
    - -
    -
    -

    Every open issue and pull request, placed by its state on GitHub, as on Kira's work board in Discord.

    - -
    -
    -{{HUB_BOARD}} -
    -
    - -
    -

    Every release with what it changed, grouped by roadmap item in Technical, and a summary of each week. Also as journal.json.

    -
    -{{HUB_JOURNAL}} -
    -
    - -
    -
    -

    Good first tasks

    -

    Each one is a small difference from vanilla, with an issue that says how to see it.

    -

    Each links its issue; the technical text says where in the code and in vanilla to start.

    -
      {{HUB_HELP_WANTED}}
    -
    -
    -

    Ways to help

    -
    -
    -

    Tell us what differs

    -

    Something that doesn't play like vanilla is a bug. Say what you did, what happened and what vanilla does, with your Horizon version and system.

    -

    Report a bugSuggest something

    -
    -
    -

    Take a task

    -

    Pick a good first task or an open issue. On Discord, /claim 1234 tells everyone you are on issue 1234, so nobody starts the same work; /unclaim frees it.

    -

    Open issues

    -
    -
    -

    Bring your AI agent

    -

    On Discord, /agent connect gives your coding agent a personal token for Kira's MCP server: it reads the work board, claims tasks and posts its progress in your name.

    -

    Join the Discord

    -
    -
    -

    Benchmark your computer

    -

    Run the built-in benchmark and share the report: every new machine makes the performance work more accurate. No coding needed.

    -

    How to

    -
    -
    -
    -
    -

    Report a bug

    -

    One bug per report: something that breaks, or doesn't play like vanilla. It becomes an issue on GitHub, public, and you get its link to follow it. Search the open bugs first.

    - -

    Report it on GitHub or in #horizon-bugs on Discord, where Kira turns your post into an issue.

    -
    -
    -

    Suggest something

    -

    An idea for Horizon, or about something on the roadmap. The maintainers read every one, and the good ones become roadmap items. Nothing you send here is published.

    - -

    Post it in #horizon-ideas on Discord, or send it to Kira with /suggest.

    -
    -
    -

    The rules

    -
      -
    1. Vanilla is the specification. Wherever Minecraft 26.3 has the feature, Horizon does exactly what it does: read vanilla's code, then match it.
    2. -
    3. Small pull requests, proven. One change each, under about 500 lines, with the block python3 tools/pr_ready.py prints: formatting, Clippy, tests and the harnesses your change can affect.
    4. -
    5. Say who wrote it. If an AI agent wrote or posts it, the pull request says so, and commits keep its co-author line.
    6. -
    7. Never commit Mojang's files. No game jars, assets, decompiled code or prepared resources, and nothing taken from other mods' code.
    8. -
    -

    The contributing guideCONTRIBUTING.md

    -
    -
    -

    Contributors

    -

    Everyone with commits in Horizon. On Discord, /link-github <your login> credit:True adds your Discord name next to yours.

    -
      {{HUB_CREDIT}}
    -

    All contributors on GitHub

    -
    -
    -

    Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

    -
    -
    -{{SITE_FOOTER}} - - - diff --git a/website/src/hub.js b/website/src/hub.js deleted file mode 100644 index f11668d8c9..0000000000 --- a/website/src/hub.js +++ /dev/null @@ -1,457 +0,0 @@ -// The hub (/hub): the roadmap from website/data/roadmap.json (generated from roadmap/roadmap.toml by -// tools/roadmap.py), with each item's issues and pull requests in their live GitHub state. Shared by build.mjs -// (baked into the page) and hub-page.js (refreshed from GitHub in the browser). Every item says the same thing -// twice: `plain` (.hub-plain) for players and `dev` (.hub-dev) for contributors; the page's Simple / Technical -// switch shows one of them. -import { escape, inline } from "./markdown.js"; -import { parts, plainItem, sections } from "./releases.js"; -import { isGame, isLauncher } from "./versions.js"; -import { avatar } from "./contributors.js"; - -export const REPO = "ludovic111/horizon"; -const issueUrl = (n) => `https://github.com/${REPO}/issues/${n}`; -const fileUrl = (path) => `https://github.com/${REPO}/blob/main/${path}`; - -// The issues a pull request closes when merged: "Fixes #12", "closes #3, resolves #4" (as Kira's board reads them). -export function fixesIn(text) { - const out = new Set(); - for (const m of String(text ?? "").matchAll(/\b(?:fix(?:e[sd])?|close[sd]?|resolve[sd]?)\s*:?\s+((?:#\d+(?:\s*(?:,|and)\s*)?)+)/gi)) { - for (const n of m[1].matchAll(/#(\d+)/g)) out.add(Number(n[1])); - } - return [...out].sort((a, b) => a - b); -} - -// A GitHub issue or pull request (from /issues, which lists both) reduced to what the hub shows. -export function slimIssue(raw) { - const pr = Boolean(raw.pull_request); - const merged = pr && raw.pull_request.merged_at; - return { - n: raw.number, - title: String(raw.title || ""), - kind: pr ? "pr" : "issue", - state: merged ? "merged" : raw.state === "open" ? "open" : raw.state_reason === "not_planned" ? "dropped" : "closed", - draft: Boolean(raw.draft), - user: raw.user?.login || "", - labels: (raw.labels || []).map((l) => (typeof l === "string" ? l : l.name)).filter(Boolean), - assigned: (raw.assignees || []).length > 0, - fixes: pr ? fixesIn(raw.body) : [], - at: merged || raw.closed_at || raw.updated_at || "", - url: raw.html_url || issueUrl(raw.number), - }; -} - -const number = (link) => Number((String(link).match(/^#(\d+)$/) || [])[1]) || null; - -// "#12 in review", "#13 merged": a link's state, when GitHub told us. -function linkState(issue) { - if (!issue) return null; - if (issue.kind === "pr") return issue.state === "open" ? (issue.draft ? "in progress" : "in review") : issue.state; - return issue.state === "open" ? "open" : "closed"; -} - -function linkHtml(link, live) { - const n = number(link); - if (!n) { - const url = link.startsWith("https://") ? link : fileUrl(link); - return `${escape(link.replace(/^https:\/\//, ""))}`; - } - const issue = live.get(n); - const state = linkState(issue); - const label = issue ? `${issue.kind === "pr" ? "Pull request" : "Issue"} #${n}: ${issue.title}` : `#${n}`; - return `#${n}${state ? ` ${state}` : ""}`; -} - -// The technical text with its #N and the item's own paths as links. -function devHtml(item) { - let text = item.dev.replace(/(^|[^\w[/`])#(\d+)\b/g, (_, before, n) => `${before}[#${n}](${issueUrl(n)})`); - for (const link of item.links || []) { - if (!number(link) && !link.startsWith("https://")) text = text.replace(link, `[${link}](${fileUrl(link)})`); - } - return inline(text); -} - -// "2 pull requests in review · 3 merged · 1 issue open", from the item's links. -export function progress(item, live) { - const known = (item.links || []).map(number).filter(Boolean).map((n) => live.get(n)).filter(Boolean); - const pulls = known.filter((i) => i.kind === "pr"); - const count = (list, word) => (list.length ? `${list.length} ${word}` : ""); - const parts = [ - count(pulls.filter((p) => p.state === "open" && !p.draft), pulls.filter((p) => p.state === "open" && !p.draft).length === 1 ? "pull request in review" : "pull requests in review"), - count(pulls.filter((p) => p.state === "open" && p.draft), "in progress"), - count(pulls.filter((p) => p.state === "merged"), "merged"), - count(known.filter((i) => i.kind === "issue" && i.state === "open"), known.filter((i) => i.kind === "issue" && i.state === "open").length === 1 ? "issue open" : "issues open"), - ].filter(Boolean); - return parts.join(" · "); -} - -const areaName = (roadmap, id) => roadmap.areas.find((a) => a.id === id)?.name || id; - -// An item's vote button: hidden until the page has the counts from the hub's server (hub-page.js). -export function voteHtml(item) { - return ``; -} - -export function itemHtml(item, roadmap, live) { - const done = item.status === "shipped"; - const state = done ? "" : progress(item, live); - return `
    -

    ${escape(areaName(roadmap, item.area))}${done ? `${escape(item.shipped_in)}` : ""}${item.ongoing ? `Ongoing` : ""}${done ? "" : voteHtml(item)}

    -

    ${escape(item.title)}

    -

    ${inline(item.plain)}

    -

    ${devHtml(item)}

    - ${state ? `

    ${escape(state)}

    ` : ""} - -
    `; -} - -const SECTION_LEDE = { - now: "Being worked on, with pull requests open or just merged.", - next: "Decided, and up after what is being done now.", - later: "Decided, further out.", - "help-wanted": "Good first contributions: a clear vanilla behaviour, an issue with the steps, a place to start.", - shipped: "Out in a release. The journal has every change.", -}; - -// The Roadmap tab: one section per status, the items in the file's order (areas by priority). -export function roadmapHtml(roadmap, live = new Map()) { - const order = new Map(roadmap.areas.map((a, i) => [a.id, i])); - return roadmap.statuses - .map(({ id, name }) => { - const items = roadmap.items.filter((i) => i.status === id); - if (!items.length) return ""; - // Shipped stays newest first; the others go by the areas' priority. - if (id !== "shipped") items.sort((a, b) => order.get(a.area) - order.get(b.area)); - return `
    -

    ${escape(name)}

    ${escape(SECTION_LEDE[id] || "")}

    -
    -${items.map((item) => itemHtml(item, roadmap, live)).join("\n")} -
    -
    `; - }) - .join("\n"); -} - -// The area filter's buttons: every area that has an item. -export function areaFilterHtml(roadmap) { - const used = roadmap.areas.filter((a) => roadmap.items.some((i) => i.area === a.id)); - return [``] - .concat(used.map((a) => ``)) - .join("\n"); -} - -// The Overview tab: where Horizon is, the 3 to 5 things in focus, what is next, what shipped last, how to help. -export function overviewHtml(roadmap, live = new Map()) { - const o = roadmap.overview; - const focus = roadmap.items.filter((i) => i.focus); - const next = roadmap.items.filter((i) => i.status === "next"); - const shipped = roadmap.items.filter((i) => i.status === "shipped").slice(0, 3); - const row = (item, i, meta) => `
  • ${String(i + 1).padStart(2, "0")}${escape(item.title)}${inline(item.plain)}${inline(item.dev)}${escape(meta(item))}
  • `; - return `
    -

    ${inline(o.today_plain)}

    -

    ${inline(o.today_dev)}

    -
    -
    -

    Being done now

    -
      ${focus.map((item, i) => row(item, i, (x) => progress(x, live))).join("")}
    -
    -
    -

    Next

    -
      ${next.map((item, i) => row(item, i, (x) => areaName(roadmap, x.area))).join("")}
    -
    -
    -

    Recently shipped

    -
      ${shipped.map((item, i) => row(item, i, (x) => `In ${x.shipped_in}`)).join("")}
    -
    -
    -

    How to help

    -

    ${inline(o.help_plain)}

    -

    ${inline(o.help_dev)}

    -

    ContributeHelp wantedReport a bugSuggest something

    -
    `; -} - -// The Contribute tab's list of good first tasks. -export function helpWantedHtml(roadmap) { - return roadmap.items - .filter((i) => i.status === "help-wanted") - .map((item, i) => { - const first = (item.links || []).map(number).find(Boolean); - const href = first ? issueUrl(first) : `#${item.id}`; - return `
  • ${String(i + 1).padStart(2, "0")}${escape(item.title)}${inline(item.plain)}${inline(item.dev)}${first ? `#${first}` : ""}
  • `; - }) - .join(""); -} - -// --- The work board -------------------------------------------------------------------------------------------- - -export const COLUMNS = [ - { id: "todo", name: "To do", about: "Open and free: pick one, and say so with /claim on Discord." }, - { id: "progress", name: "In progress", about: "Claimed, assigned, or a draft pull request." }, - { id: "review", name: "In review", about: "A pull request is ready for review." }, - { id: "done", name: "Done", about: "Closed or merged in the last seven days." }, -]; -const DONE_DAYS = 7; - -// The roadmap item an issue or pull request moves: its roadmap: label, else the item linking it, else (for a -// pull request) the item linking an issue it fixes. -export function itemOf(issue, roadmap) { - const label = issue.labels.find((l) => l.startsWith("roadmap:")); - const ids = new Set(roadmap.items.map((i) => i.id)); - if (label && ids.has(label.slice(8))) return label.slice(8); - const linking = (n) => roadmap.items.find((i) => (i.links || []).includes(`#${n}`))?.id; - return linking(issue.n) || issue.fixes.map(linking).find(Boolean) || null; -} - -// The board from GitHub's open issues and pull requests and those closed lately, placed as Kira's work board places -// them: an issue goes to review when an open, ready pull request fixes it, to progress when a draft one does or it is -// claimed or assigned; a pull request that fixes no open issue is a card of its own. -export function boardFrom(list, roadmap, now = Date.now()) { - const columns = Object.fromEntries(COLUMNS.map((c) => [c.id, []])); - const open = list.filter((i) => i.state === "open"); - const openIssues = new Set(open.filter((i) => i.kind === "issue").map((i) => i.n)); - const fixing = (n) => open.filter((p) => p.kind === "pr" && p.fixes.includes(n)); - const card = (i, prs = []) => ({ n: i.n, kind: i.kind, title: i.title, user: i.user, url: i.url, item: itemOf(i, roadmap) || prs.map((p) => itemOf(p, roadmap)).find(Boolean) || null, prs: prs.map((p) => p.n) }); - for (const i of open) { - if (i.kind === "issue") { - const prs = fixing(i.n); - const claimed = i.assigned || i.labels.some((l) => /^(in progress|claimed)$/i.test(l)); - const column = prs.some((p) => !p.draft) ? "review" : prs.length || claimed ? "progress" : "todo"; - columns[column].push(card(i, prs)); - } else if (!i.fixes.some((n) => openIssues.has(n))) { - columns[i.draft ? "progress" : "review"].push(card(i)); - } - } - const since = now - DONE_DAYS * 864e5; - const seen = new Set(); - for (const i of list) { - if (seen.has(i.n) || !["merged", "closed"].includes(i.state) || !(Date.parse(i.at) >= since)) continue; - seen.add(i.n); - columns.done.push(card(i)); - } - columns.done.sort((a, b) => b.n - a.n); - return columns; -} - -const SHOWN = 20; // cards per column before "Show more" - -function cardHtml(c, roadmap, more) { - const item = c.item && roadmap.items.find((i) => i.id === c.item); - return `
  • ${c.kind === "pr" ? "PR" : "Issue"} #${c.n}${c.prs.length ? ` · ${c.prs.map((n) => `PR #${n}`).join(", ")}` : ""}${escape(c.title)}${item ? `${escape(item.title)}` : ""}${c.user ? `${escape(c.user)}` : ""}
  • `; -} - -export function boardHtml(columns, roadmap) { - return COLUMNS.map(({ id, name, about }) => { - const cards = columns[id]; - return `
    -

    ${escape(name)}${cards.length}

    -

    ${escape(about)}

    -
      ${cards.map((c, i) => cardHtml(c, roadmap, i >= SHOWN)).join("")}
    - ${cards.length > SHOWN ? `` : ""} -
    `; - }).join("\n"); -} - -// The board's roadmap filter: every item that has a card. -export function boardFilterHtml(columns, roadmap) { - const used = new Set(Object.values(columns).flat().map((c) => c.item).filter(Boolean)); - return [``, ``] - .concat(roadmap.items.filter((i) => used.has(i.id)).map((i) => ``)) - .join(""); -} - -// --- The journal ----------------------------------------------------------------------------------------------- - -export const JOURNAL_URL = "https://playhorizon.dev/hub#journal"; -const ENTRIES = 40; - -// The journal, newest first: each release with its changes grouped by roadmap item, the weekly summaries -// (roadmap/journal/), and what merged since the newest game release. releases: GitHub's release objects; list: the -// hub's issues and pull requests (their roadmap: labels place changes the roadmap doesn't link). -export function journalFrom(releases, list, roadmap) { - const prs = new Map(list.filter((i) => i.kind === "pr").map((i) => [i.n, i])); - const placed = (numbers) => { - for (const n of numbers) { - const known = prs.get(n); - const id = known ? itemOf(known, roadmap) : roadmap.items.find((i) => (i.links || []).includes(`#${n}`))?.id; - if (id) return id; - } - return null; - }; - const ours = releases.filter((r) => !r.draft && (isGame(r.tag_name) || isLauncher(r.tag_name))); - const entries = ours.map((r) => { - const changes = sections(r.body) - .filter((x) => /^(new|what's new|fixed)/i.test(x.title)) - .flatMap((x) => x.items.map((text) => ({ section: /^fixed/i.test(x.title) ? "fixed" : "new", text }))) - .map((c) => { - const numbers = [...c.text.matchAll(/#(\d+)/g)].map((m) => Number(m[1])); - return { ...c, text: plainItem(c.text), prs: numbers, item: placed(numbers) }; - }); - return { - id: r.tag_name, - kind: isLauncher(r.tag_name) ? "launcher" : "release", - date: r.published_at || "", - title: r.name || r.tag_name, - plain: parts(r).line, - url: r.html_url, - changes, - shipped: roadmap.items.filter((i) => i.status === "shipped" && i.shipped_in === r.tag_name).map((i) => i.id), - }; - }); - // Merged but in no release's notes yet: a release is cut from main a while after its last merge, so the newest - // releases' notes, not their dates, say what they hold. Pull requests players don't see (skip-release) never are. - const games = ours.filter((r) => isGame(r.tag_name)).map((r) => r.published_at).sort().reverse(); - const since = games[Math.min(4, games.length - 1)] || ""; - const noted = new Set(entries.flatMap((e) => e.changes.flatMap((c) => c.prs))); - const pending = list - .filter((i) => i.kind === "pr" && i.state === "merged" && i.at > since && !noted.has(i.n) && !i.labels.includes("skip-release")) - .sort((a, b) => b.at.localeCompare(a.at)); - if (pending.length) { - entries.push({ - id: "next-release", - kind: "merged", - date: pending[0].at, - title: "Merged, in the next release", - plain: `${pending.length} change${pending.length === 1 ? "" : "s"} merged since the last release.`, - url: `https://github.com/${REPO}/pulls?q=is%3Apr+is%3Amerged`, - changes: pending.map((p) => ({ section: "merged", text: p.title, prs: [p.n], item: itemOf(p, roadmap) })), - shipped: [], - }); - } - for (const w of roadmap.journal || []) { - // A week's summary sits after its last day's releases. - const end = new Date(Date.parse(w.date) + 7 * 864e5 - 1).toISOString(); - entries.push({ id: `week-${w.week}`, kind: "week", date: end, title: w.title, plain: w.plain, dev: w.dev, url: `${JOURNAL_URL.replace("#journal", "")}#week-${w.week}`, changes: [], shipped: [] }); - } - return entries.sort((a, b) => b.date.localeCompare(a.date)).slice(0, ENTRIES); -} - -const HUB = "https://playhorizon.dev/hub"; -const FEED = 300; - -// /data/journal.json, which Kira reads for #updates and X (kira's src/journal.js): one flat entry per merged pull -// request, release, shipped roadmap item and weekly summary, newest first. An entry's id never changes: Kira posts -// each id once. Shipped items carry `post` (at most 240 characters, tools/roadmap.py checks) and `announce`. -export function journalJson(releases, list, roadmap, generated = new Date().toISOString()) { - const items = new Map(roadmap.items.map((i) => [i.id, i])); - const ours = releases.filter((r) => !r.draft && (isGame(r.tag_name) || isLauncher(r.tag_name))); - const published = new Map(ours.map((r) => [r.tag_name, r.published_at || ""])); - const merges = list - .filter((i) => i.kind === "pr" && i.state === "merged") - .map((pr) => ({ pr, item: itemOf(pr, roadmap) })) - // What players see, and anything that moves a roadmap item. - .filter(({ pr, item }) => item || !pr.labels.includes("skip-release")) - .map(({ pr, item }) => ({ - id: `pr-${pr.n}`, - kind: "merge", - date: pr.at, - title: pr.title, - url: pr.url, - number: pr.n, - ...(item ? { item, item_title: items.get(item)?.title } : {}), - })); - const released = ours.map((r) => ({ - id: `release-${r.tag_name}`, - kind: "release", - date: r.published_at || "", - title: r.name || r.tag_name, - plain: parts(r).line, - url: r.html_url, - tag: r.tag_name, - })); - const shipped = roadmap.items - .filter((i) => i.status === "shipped") - .map((i) => ({ - id: `shipped-${i.id}`, - kind: "shipped", - date: published.get(i.shipped_in) || generated, - title: i.title, - plain: i.plain, - url: `${HUB}#${i.id}`, - item: i.id, - item_title: i.title, - shipped_in: i.shipped_in, - post: i.post || i.plain, - announce: Boolean(i.announce), - })); - // A week's summary is dated at the end of its week, after that week's releases. - const weekly = (roadmap.journal || []).map((w) => ({ - id: `weekly-${w.week}`, - kind: "weekly", - date: new Date(Date.parse(w.date) + 7 * 864e5 - 1000).toISOString(), - title: w.title, - plain: w.plain, - dev: w.dev, - url: `${HUB}#week-${w.week}`, - })); - const entries = [...merges, ...released, ...shipped, ...weekly].sort((a, b) => b.date.localeCompare(a.date) || a.id.localeCompare(b.id)); - return { format: "horizon-journal", version: 2, generated, url: `${HUB}#journal`, entries: entries.slice(0, FEED) }; -} - -const KIND = { release: "Game", launcher: "Launcher", merged: "Merged", week: "This week" }; - -function changesHtml(entry, roadmap) { - if (!entry.changes.length) return ""; - const line = (c) => `
  • ${inline(c.text)}${c.prs.length ? ` ${c.prs.map((n) => `#${n}`).join(" ")}` : ""}
  • `; - // Simple: what is new, then how many fixes. Technical: every change, under the roadmap item it moves. - const added = entry.changes.filter((c) => c.section !== "fixed"); - const fixes = entry.changes.length - added.length; - const simple = `
    ${added.length ? `
      ${added.map((c) => `
    • ${inline(c.text)}
    • `).join("")}
    ` : ""}${fixes ? `

    ${added.length ? "And " : ""}${fixes} fix${fixes === 1 ? "" : "es"}.

    ` : ""}
    `; - const groups = new Map(); - for (const c of entry.changes) groups.set(c.item || "", [...(groups.get(c.item || "") || []), c]); - const order = [...groups.keys()].sort((a, b) => (a === "") - (b === "")); - const dev = `
    ${order - .map((id) => { - const item = roadmap.items.find((i) => i.id === id); - return `

    ${item ? `${escape(item.title)}` : "Other changes"}

      ${groups.get(id).map(line).join("")}
    `; - }) - .join("")}
    `; - return simple + dev; -} - -export function journalHtml(entries, roadmap) { - return entries - .map((e) => { - const shipped = e.shipped.map((id) => roadmap.items.find((i) => i.id === id)).filter(Boolean); - const items = [...new Set(e.changes.map((c) => c.item).filter(Boolean))].map((id) => roadmap.items.find((i) => i.id === id)).filter(Boolean); - return `
    - -

    ${e.url ? `${escape(e.title)}` : escape(e.title)}

    - ${e.kind === "week" ? `

    ${inline(e.plain)}

    ${inline(e.dev.replace(/(^|[^\w[/`])#(\d+)\b/g, (_, b, n) => `${b}[#${n}](${issueUrl(n)})`))}

    ` : ""} - ${shipped.length ? `

    Shipped: ${shipped.map((i) => `${escape(i.title)}`).join(", ")}

    ` : ""} - ${items.length ? `

    ${items.map((i) => `${escape(i.title)}`).join("")}

    ` : ""} - ${changesHtml(e, roadmap)} -
    `; - }) - .join("\n"); -} - -// --- Contribute: the suggestion form's items and the contributors ------------------------------------------------ - -// The suggestion form's "About" choices: the items still to come. -export function suggestItemsHtml(roadmap) { - const open = roadmap.items.filter((i) => i.status !== "shipped"); - return [``] - .concat(open.map((i) => ``)) - .join(""); -} - -// The contributors, from GitHub (contributors.js's list), with the Discord name of those who linked their accounts -// on Kira and agreed to be credited (Kira's /contributors.json: { contributors: [{ github, discord }] }). -export function creditFrom(github, kira) { - const discord = new Map( - (Array.isArray(kira?.contributors) ? kira.contributors : []) - .filter((c) => c && typeof c.github === "string" && typeof c.discord === "string") - .map((c) => [c.github.toLowerCase(), c.discord]), - ); - return github.map((c) => ({ ...c, discord: discord.get(c.login.toLowerCase()) || "" })); -} - -export function creditHtml(list) { - return list - .map((c) => { - const src = avatar(c.avatar_url); - const count = `${c.contributions.toLocaleString("en-US")} ${c.contributions === 1 ? "commit" : "commits"}`; - return `
  • ${src ? `` : ``}${escape(c.login)}${count}${c.discord ? ` · ${escape(c.discord)} on Discord` : ""}
  • `; - }) - .join(""); -} diff --git a/website/src/hub.test.mjs b/website/src/hub.test.mjs deleted file mode 100644 index 4213d8a873..0000000000 --- a/website/src/hub.test.mjs +++ /dev/null @@ -1,159 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { boardFrom, boardHtml, creditFrom, creditHtml, fixesIn, itemOf, journalFrom, journalHtml, journalJson, overviewHtml, progress, roadmapHtml, slimIssue, suggestItemsHtml } from "./hub.js"; - -const roadmap = JSON.parse(readFileSync(new URL("../data/roadmap.json", import.meta.url), "utf8")); - -const pr = (number, extra = {}) => ({ number, title: `PR ${number}`, state: "open", pull_request: { merged_at: null }, user: { login: "a" }, labels: [], assignees: [], body: "", ...extra }); -const issue = (number, extra = {}) => ({ number, title: `Issue ${number}`, state: "open", user: { login: "b" }, labels: [{ name: "bug" }], assignees: [], ...extra }); - -test("pull requests and issues reduce to their hub state", () => { - assert.deepEqual(fixesIn("Fixes #12, #13 and closes #4. Mentions #9."), [4, 12, 13]); - const merged = slimIssue(pr(5, { state: "closed", pull_request: { merged_at: "2030-01-01T00:00:00Z" }, body: "Fixes #3" })); - assert.equal(merged.kind, "pr"); - assert.equal(merged.state, "merged"); - assert.deepEqual(merged.fixes, [3]); - assert.equal(slimIssue(issue(6, { state: "closed", state_reason: "not_planned" })).state, "dropped"); - assert.deepEqual(slimIssue(issue(7)).labels, ["bug"]); -}); - -test("an item's progress counts its pull requests and issues", () => { - const live = new Map([ - [1, slimIssue(pr(1))], - [2, slimIssue(pr(2, { draft: true }))], - [3, slimIssue(pr(3, { state: "closed", pull_request: { merged_at: "2030-01-01T00:00:00Z" } }))], - [4, slimIssue(issue(4))], - ]); - const item = { links: ["#1", "#2", "#3", "#4", "#99", "docs/x.md"] }; - assert.equal(progress(item, live), "1 pull request in review · 1 in progress · 1 merged · 1 issue open"); - assert.equal(progress(item, new Map()), ""); -}); - -test("the roadmap renders every item once, with both texts, in its status's section", () => { - const html = roadmapHtml(roadmap); - for (const item of roadmap.items) { - assert.equal(html.split(`id="${item.id}"`).length, 2, item.id); - } - assert.ok(html.indexOf('id="now"') < html.indexOf('id="next"')); - assert.ok(html.includes('class="hub-plain"') && html.includes('class="hub-dev"')); - // Shipped items say which release had them. - assert.ok(html.includes(`>${roadmap.items.find((i) => i.status === "shipped").shipped_in}<`)); -}); - -test("an item is its own anchor (hub#, as Kira links it) and takes no other id of the page", () => { - const week = { week: "2030-01", date: "2029-12-31", title: "A week", plain: "p", dev: "d" }; - const release = { tag_name: "v0.2.2", name: "0.2.2", published_at: "2030-01-02T00:00:00Z", html_url: "u", body: "" }; - const journal = journalHtml(journalFrom([release], [], { ...roadmap, journal: [week] }), roadmap); - const board = boardHtml(boardFrom([], roadmap), roadmap); - const page = readFileSync(new URL("./hub.html", import.meta.url), "utf8") + roadmapHtml(roadmap) + overviewHtml(roadmap) + board + journal; - const ids = [...page.matchAll(/ id="([^"]+)"/g)].map((m) => m[1]); - assert.deepEqual(ids.filter((id, i) => ids.indexOf(id) !== i), []); - for (const item of roadmap.items) assert.ok(ids.includes(item.id), item.id); -}); - -test("the overview lists the focus items and never nests links", () => { - const html = overviewHtml(roadmap); - for (const item of roadmap.items.filter((i) => i.focus)) assert.ok(html.includes(`href="#${item.id}"`), item.id); - for (const row of html.match(/
  • [\s\S]*?<\/li>/g)) assert.equal(row.split(" { - const now = Date.parse("2030-01-10T00:00:00Z"); - const list = [ - issue(1), - issue(2), - issue(3, { assignees: [{ login: "x" }] }), - pr(10, { body: "Fixes #1" }), - pr(11, { body: "Fixes #2", draft: true }), - pr(12, { labels: [{ name: `roadmap:${roadmap.items[0].id}` }] }), - pr(13, { draft: true }), - pr(14, { state: "closed", pull_request: { merged_at: "2030-01-09T00:00:00Z" } }), - pr(15, { state: "closed", pull_request: { merged_at: "2029-12-01T00:00:00Z" } }), - ].map(slimIssue); - const board = boardFrom(list, roadmap, now); - const ids = (column) => board[column].map((c) => c.n); - assert.deepEqual(ids("todo"), []); - assert.deepEqual(ids("progress"), [2, 3, 13]); - assert.deepEqual(ids("review"), [1, 12]); - assert.deepEqual(ids("done"), [14]); - assert.deepEqual(board.review[0].prs, [10]); - assert.equal(board.review[1].item, roadmap.items[0].id); - assert.equal(itemOf(slimIssue(pr(16, { body: "Fixes #1438" })), roadmap), "end-city-banners"); -}); - -test("the journal groups a release's changes by roadmap item and lists what awaits a release", () => { - const releases = [ - { tag_name: "v0.2.2", name: "Horizon 0.2.2: Rich presence", published_at: "2030-01-02T00:00:00Z", html_url: "u2", body: "### New\n- Discord Rich Presence (#1787)\n\n### Fixed\n- A fix (#5, fixes #4)\n" }, - { tag_name: "launcher@0.2.1", name: "Horizon launcher 0.2.1", published_at: "2030-01-01T00:00:00Z", html_url: "u1", body: "### Fixed\n- Window (#6)\n" }, - ]; - const list = [ - pr(1788, { state: "closed", pull_request: { merged_at: "2030-01-03T00:00:00Z" } }), - pr(7, { state: "closed", pull_request: { merged_at: "2030-01-03T00:00:00Z" }, labels: [{ name: "skip-release" }] }), - ].map(slimIssue); - const entries = journalFrom(releases, list, { ...roadmap, journal: [] }); - assert.deepEqual(entries.map((e) => e.id), ["next-release", "v0.2.2", "launcher@0.2.1"]); - assert.deepEqual(entries[0].changes.map((c) => [c.prs, c.item]), [[[1788], "discord-presence"]]); - assert.deepEqual(entries[1].changes.map((c) => [c.section, c.text, c.item]), [["new", "Discord Rich Presence", "discord-presence"], ["fixed", "A fix", null]]); - const html = journalHtml(entries, roadmap); - assert.ok(html.includes('href="#discord-presence"')); - assert.ok(html.includes("And 1 fix.")); -}); - -test("journal.json is the flat feed Kira reads: merges, releases, shipped items, weeks, ids that never change", () => { - const releases = [ - { tag_name: "v0.2.48", name: "Horizon 0.2.48", published_at: "2030-01-02T00:00:00Z", html_url: "https://github.com/ludovic111/horizon/releases/tag/v0.2.48", body: "### New\n- Packs (#1)\n" }, - { tag_name: "nightly-1", name: "Nightly", published_at: "2030-01-03T00:00:00Z", html_url: "u", body: "" }, - ]; - const list = [ - pr(1788, { state: "closed", pull_request: { merged_at: "2030-01-04T00:00:00Z" }, html_url: "https://github.com/ludovic111/horizon/pull/1788" }), - pr(7, { state: "closed", pull_request: { merged_at: "2030-01-04T00:00:00Z" }, labels: [{ name: "skip-release" }] }), - pr(8, { state: "closed", pull_request: { merged_at: "2030-01-04T00:00:00Z" }, labels: [{ name: "skip-release" }, { name: "roadmap:lean-code" }] }), - pr(9), - ].map(slimIssue); - const week = { week: "2030-01", date: "2029-12-31", title: "A week", plain: "p", dev: "d" }; - const feed = journalJson(releases, list, { ...roadmap, journal: [week] }, "2030-01-05T00:00:00Z"); - const byId = new Map(feed.entries.map((e) => [e.id, e])); - // A merged pull request, with its roadmap item; skip-release ones only when they move an item; open ones never. - assert.deepEqual(byId.get("pr-1788"), { id: "pr-1788", kind: "merge", date: "2030-01-04T00:00:00Z", title: "PR 1788", url: "https://github.com/ludovic111/horizon/pull/1788", number: 1788, item: "discord-presence", item_title: "Discord Rich Presence" }); - assert.ok(!byId.has("pr-7") && byId.get("pr-8").item === "lean-code" && !byId.has("pr-9")); - // Game and launcher releases only. - assert.equal(byId.get("release-v0.2.48").tag, "v0.2.48"); - assert.ok(!byId.has("release-nightly-1")); - // Every shipped item, dated by its release when the feed has it, with its post and the announce flag. - const shipped = roadmap.items.filter((i) => i.status === "shipped"); - for (const item of shipped) { - const e = byId.get(`shipped-${item.id}`); - assert.equal(e.kind, "shipped"); - assert.equal(e.url, `https://playhorizon.dev/hub#${item.id}`); - assert.ok(e.post.length <= 240 && typeof e.announce === "boolean", item.id); - } - assert.equal(byId.get("shipped-server-resource-packs").date, "2030-01-02T00:00:00Z"); - assert.equal(byId.get("weekly-2030-01").date, "2030-01-06T23:59:59.000Z"); - // Newest first; every entry has what Kira needs, and links only where Kira shows them. - assert.deepEqual(feed.entries.map((e) => e.date), feed.entries.map((e) => e.date).sort().reverse()); - for (const e of feed.entries) { - assert.ok(e.id && ["merge", "release", "weekly", "shipped"].includes(e.kind) && e.date && e.title, e.id); - assert.match(e.url, /^https:\/\/(github\.com|playhorizon\.dev)\//, e.id); - } -}); - -test("items still to come take votes and suggestions; shipped ones don't", () => { - const html = roadmapHtml(roadmap); - for (const item of roadmap.items) { - assert.equal(html.includes(`data-vote="${item.id}"`), item.status !== "shipped", item.id); - assert.equal(suggestItemsHtml(roadmap).includes(`value="${item.id}"`), item.status !== "shipped", item.id); - } - // Hidden until the hub's server answers. - assert.ok(html.includes('aria-pressed="false" hidden>')); -}); - -test("the contributors carry the Discord name of those who agreed on Kira", () => { - const github = [{ login: "Alex", html_url: "https://github.com/Alex", avatar_url: "", contributions: 3 }, { login: "sam", html_url: "https://github.com/sam", avatar_url: "", contributions: 1 }]; - const kira = { contributors: [{ github: "alex", discord: "Alex " }, { github: 5 }, null] }; - const credit = creditFrom(github, kira); - assert.deepEqual(credit.map((c) => c.discord), ["Alex ", ""]); - const html = creditHtml(credit); - assert.ok(html.includes("3 commits · Alex <b> on Discord") && html.includes("1 commit")); - assert.deepEqual(creditFrom(github, "not json").map((c) => c.discord), ["", ""]); -}); diff --git a/website/src/index.html b/website/src/index.html deleted file mode 100644 index 3776733c6c..0000000000 --- a/website/src/index.html +++ /dev/null @@ -1,216 +0,0 @@ - - - - - - Horizon: a Rust rewrite for Minecraft: Java Edition 26.3 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -{{SITE_NAV}} - -
    -
    -
    -

    Minecraft: Java Edition 26.3Rust

    -

    Horizon: The game you know, rebuilt in Rust.

    -

    The Minecraft: Java Edition 26.3 client and its game logic, rewritten in Rust and checked against the vanilla game by {{HARNESSES}} differential harnesses.

    - -

    For Linux x86-64, macOS on Apple Silicon and Windows x64.

    -
    -
    -
    -
      -
    1. 01Matches vanilla{{PERCENT}}
    2. -
    3. 02Differential harnesses{{HARNESSES}}
    4. -
    5. 03Drawn by Horizon5 shots
    6. -
    7. 04Recently addedReleases
    8. - -
    -

    Unofficial. Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

    -
    - -
    -
    -

    01What it is

    -

    Horizon rebuilds Minecraft: Java Edition 26.3 in Rust, one subsystem at a time, and holds every piece to the same inputs as the vanilla game until the outputs are identical.

    -
    -
    - -

    Your own copy

    -

    Install Java Edition with the official launcher and play 26.3 once. Horizon builds its resources from those files on your machine, and never downloads or shares game files.

    -
    -
    - -

    Native on three platforms

    -

    Linux, macOS on Apple Silicon and Windows. Worlds come from a Rust port of the vanilla generators, drawn with vanilla's block models, lighting, sky and fog.

    -
    -
    - -

    Open source

    -

    Horizon's code is MIT. It contains no Minecraft code, assets or data; those belong to Mojang and Microsoft under the Minecraft EULA.

    -
    -
    -
    -
    - -
    -
    -
    -

    02In game

    -

    Drawn by Horizon

    -

    Captured in the Rust client itself, with vanilla assets from the player's own copy and no mods or shaders.

    -
    -
    -
    01A plains village
    -
    02Moonrise
    -
    03A crimson forest in the Nether
    -
    04A coral reef
    -
    05Survival at dusk
    -
    -
    -
    - -
    -
    -

    03Parity

    -
    -
    - -

    {{HARNESSES}} differential harnesses drive the same inputs through Horizon and through the vanilla client and server, and compare what comes out.

    -
    -
    -

    {{PARITY_HEADLINE}}

    -
    {{PARITY_INTRO}}
    -

    Last audited {{AUDITED}}{{PERCENT}} of what the harnesses comparefrom the README on main

    -
    -
    -
      - {{PARITY_CELLS}} -
    -

    Each subsystem scores 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified against vanilla by differential tests).

    -
    -
    - -
    -
    -
    -

    04The launcher

    -

    One small app, then play

    -

    The launcher installs Horizon, prepares it from your copy of the game and keeps both up to date.

    -
      -
    • One-click install

      Pick a release and it installs the build for your platform into its own folder, then prepares the resources from your Java Edition 26.3 install.

    • -
    • Updates on its own

      It looks for a new Horizon release when it starts and every four hours, and updates itself from the launcher's own releases.

    • -
    • Client or server

      Start the client, or host a server from the Servers page: create it, start it, watch players and the console, change its settings and keep backups.

    • -
    • Logs and crash reports

      The Settings page shows where the game's logs and crash reports are, and horizon-launcher logs prints them.

    • -
    • Modpacks and packs

      The Modpacks page reads a Modrinth or CurseForge pack and carries over the mods Horizon can run. The Mods page installs resource packs and modpacks from Modrinth.

    • -
    -
    - Download the launcher -

    {{LAUNCHER_META}}

    -
    -
    - -
    -
    - - - -
    -
    -
    The launcher's Play page: the version, Client and Server, the version picker and Play, the prepared resources and the Minecraft folder as a numbered list
    -
    Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics and Advanced Graphics
    -
    -

    The launcher, and Horizon's graphics settings in the game: vanilla's menus, with Horizon's pages added the way mods add theirs.

    -
    -
    -
    - -
    -
    -
    -

    05Releases

    -

    Recently added

    -

    New versions come out many times a day, as changes land, and the launcher installs each one once it has passed its test. The newest, and what they added:

    -
    -
    - {{RECENT}} -
    -
    -
    - -
    -
    -

    06Community

    -

    Built in the open

    - -
    -

    Contributors

    -

    Everyone who has committed to Horizon on GitHub, with how many commits.

    -
      {{CONTRIBUTORS}}
    -
    -
    -
    -
    - -
    -
    - - - -
    - -
    - - -
    - - -
    - - - - diff --git a/website/src/main.js b/website/src/main.js deleted file mode 100644 index 72e8577a5a..0000000000 --- a/website/src/main.js +++ /dev/null @@ -1,265 +0,0 @@ -// The page's behaviour: the download buttons for this platform, the latest releases, the -// links and contributors, how many play now, the navigation, the menus' tabs, the lightbox -// and the motion. -// Everything degrades to the baked-in content when GitHub can't be reached. -import { escape } from "./markdown.js"; -import { recentHtml } from "./releases.js"; -import { contributorsFrom, contributorsHtml } from "./contributors.js"; -import { isGame, isLauncher as isLauncherTag, launcherVersion, newestFirst } from "./versions.js"; -import { cached, get, setUpMenu, setUpTheme, slim } from "./chrome.js"; - -const REPO = "ludovic111/horizon"; -const RELEASES_PAGE = `https://github.com/${REPO}/releases?q=launcher&expanded=true`; -const baked = JSON.parse(document.getElementById("baked").textContent); -const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches; - -// --- GitHub -------------------------------------------------------------------------- - -const isLauncher = (r) => isLauncherTag(r.tag_name) && !r.prerelease; - -// The newest 100 releases (GitHub's most per page). The newest game release that is not a pre-release (the old -// Alpha channel's, docs/release-flow.md) and the newest launcher release can be older than all of them. The game -// release is then GitHub's "latest" (pre-releases and launcher releases never are); the launcher release is looked up by its -// tag (the newest launcher@ tag with a published release, or launcher-v before 0.2: docs/versioning.md). -async function loadReleases() { - const list = (await get(`https://api.github.com/repos/${REPO}/releases?per_page=100`)).filter((r) => !r.draft).map(slim); - if (!list.some((r) => isGame(r.tag_name) && !r.prerelease)) { - const latest = await get(`https://api.github.com/repos/${REPO}/releases/latest`).catch(() => null); - if (latest && !latest.draft) list.push(slim(latest)); - } - if (list.some(isLauncher)) return list; - let tags = []; - for (const prefix of ["launcher@", "launcher-v"]) { - if (tags.length) break; - const refs = await get(`https://api.github.com/repos/${REPO}/git/matching-refs/tags/${prefix}`).catch(() => []); - tags = refs.map((r) => r.ref.replace("refs/tags/", "")).filter(isLauncherTag).sort(newestFirst); - } - for (const tag of tags.slice(0, 3)) { - const release = await get(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`).catch(() => null); - if (release && !release.draft) return [...list, slim(release)]; - } - return list; -} - -const releases = cached("horizon:releases", loadReleases).catch(() => baked.releases); - -async function setUpContributors() { - let list = baked.contributors || []; - try { - list = contributorsFrom(await cached("horizon:contributors", () => get(`https://api.github.com/repos/${REPO}/contributors?per_page=100`))); - } catch {} - if (list.length) document.getElementById("contributors-list").innerHTML = contributorsHtml(list); - // With nobody to show, the block and its menu entries go, as an empty link's card does. - document.querySelectorAll("[data-contributors]").forEach((el) => (el.hidden = !list.length)); -} - -// --- Download ------------------------------------------------------------------------ - -function platform() { - const ua = navigator.userAgent; - const hint = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || ""; - if (/iPhone|iPad|iPod|Android/i.test(ua) || (navigator.maxTouchPoints > 1 && /Mac/.test(hint))) return null; - if (/Win/i.test(hint) || /Windows/.test(ua)) return { key: "windows-x64", label: "Windows x64" }; - if (/Mac/i.test(hint) || /Mac OS X/.test(ua)) return { key: "macos-arm64", label: "macOS on Apple Silicon" }; - if (/Linux|X11/i.test(hint) || /Linux/.test(ua)) return { key: "linux-x86_64", label: "Linux x86-64" }; - return null; -} - -async function setUpDownload() { - const os = platform(); - const list = await releases; - const launcher = list.filter(isLauncher).sort((a, b) => newestFirst(a.tag_name, b.tag_name))[0]; - if (!launcher) return; - const version = launcherVersion(launcher.tag_name); - const asset = os && launcher.assets.find((a) => a.name.includes(os.key)); - const others = `Other platforms`; - document.querySelectorAll("[data-download]").forEach((a) => (a.href = asset ? asset.browser_download_url : launcher.html_url || RELEASES_PAGE)); - const mb = asset && asset.size ? ` · ${(asset.size / 1e6).toFixed(0)} MB` : ""; - const meta = asset - ? `For ${escape(os.label)} · launcher ${escape(version)}${mb} · ${others}` - : `Launcher ${escape(version)} for Linux x86-64, macOS on Apple Silicon and Windows x64. It runs on a computer, not on phones or tablets.`; - document.querySelectorAll("[data-download-meta]").forEach((p) => (p.innerHTML = meta)); -} - -// --- Recently added and the links from main --------------------------------- - -// "Recently added": the newest game releases. -async function setUpRecent() { - const html = recentHtml((await releases).filter((r) => isGame(r.tag_name))); - if (html) document.getElementById("recent-releases").innerHTML = html; -} - -async function setUpLinks() { - let links = baked.links; - try { - const remote = await cached("horizon:links", () => get(`https://raw.githubusercontent.com/${REPO}/main/launcher/links.json`, "text").then(JSON.parse)); - links = { ...links }; - for (const key of Object.keys(links)) if (typeof remote[key] === "string") links[key] = remote[key].trim(); - } catch {} - document.querySelectorAll("[data-link]").forEach((a) => { - const url = links[a.dataset.link]; - // An empty address hides its card, as the launcher hides its button. - if (!url) a.hidden = true; - else if (/^https:\/\//.test(url)) a.href = url; - }); -} - -// --- Playing now --------------------------------------------------------------------- - -const TELEMETRY = "https://telemetry.playhorizon.dev"; - -// How many play right now, from the games' anonymous "I'm playing" pings (Privacy explains them). The -// server rounds it and makes it again at most every 15 seconds; the row stays hidden while nobody plays -// or the server doesn't answer. -async function setUpLive() { - const row = document.querySelector("[data-live]"); - if (!row) return; - const update = async () => { - try { - const res = await fetch(`${TELEMETRY}/v1/live`, { signal: AbortSignal.timeout(3000) }); - if (!res.ok) throw new Error(String(res.status)); - const { playing } = await res.json(); - if (!Number.isInteger(playing) || playing < 1) throw new Error("nobody"); - row.querySelector("[data-live-count]").textContent = playing.toLocaleString("en"); - row.hidden = false; - // It shows up after the others came in, so it skips their entrance. - row.classList.add("is-visible"); - } catch { - row.hidden = true; - } - }; - await update(); - setInterval(() => document.visibilityState === "visible" && update(), 60_000); -} - -// --- Navigation ---------------------------------------------------------------------- - -function setUpNav() { - // The home sections use the same root-relative links in every page's header. - const links = [...document.querySelectorAll('.nav-links a[href^="/#"]')]; - const sections = links.map((a) => document.querySelector(a.hash)); - - let current = null; - const update = () => { - const line = innerHeight * 0.4; - let index = -1; - sections.forEach((s, i) => { - if (s.getClientRects().length && s.getBoundingClientRect().top <= line) index = i; - }); - if (index !== current) { - current = index; - links.forEach((a, i) => (i === index ? a.setAttribute("aria-current", "true") : a.removeAttribute("aria-current"))); - } - }; - onScroll(update); - update(); -} - -// One rAF-throttled scroll listener for everything that follows the scroll. -const scrollers = []; -function onScroll(fn) { - scrollers.push(fn); -} -let queued = false; -const run = () => ((queued = false), scrollers.forEach((fn) => fn())); -addEventListener("scroll", () => !queued && ((queued = true), requestAnimationFrame(run)), { passive: true }); -addEventListener("resize", () => requestAnimationFrame(run)); - -// --- The menus' tabs ------------------------------------------------------------------ - -function setUpTabs() { - const list = document.querySelector(".tabs"); - const tabs = [...list.querySelectorAll('[role="tab"]')]; - const pill = list.querySelector(".tab-pill"); - const selected = () => tabs.find((t) => t.getAttribute("aria-selected") === "true"); - const place = (tab) => { - pill.style.width = `${tab.offsetWidth}px`; - pill.style.height = `${tab.offsetHeight}px`; - pill.style.transform = `translate(${tab.offsetLeft}px, ${tab.offsetTop}px)`; - }; - const choose = (tab, focus) => { - tabs.forEach((t) => { - const on = t === tab; - t.setAttribute("aria-selected", String(on)); - t.tabIndex = on ? 0 : -1; - const panel = document.getElementById(t.getAttribute("aria-controls")); - panel.classList.toggle("is-active", on); - panel.inert = !on; - panel.setAttribute("aria-hidden", String(!on)); - }); - place(tab); - if (focus) tab.focus(); - }; - tabs.forEach((tab, i) => { - tab.addEventListener("click", () => choose(tab)); - tab.addEventListener("keydown", (e) => { - const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key]; - if (step) choose(tabs[(i + step + tabs.length) % tabs.length], true); - if (e.key === "Home") choose(tabs[0], true); - if (e.key === "End") choose(tabs[tabs.length - 1], true); - }); - }); - choose(selected()); - addEventListener("resize", () => place(selected())); - document.fonts?.ready.then(() => place(selected())); -} - -function setUpLightbox() { - const dialog = document.querySelector(".lightbox"); - const big = dialog.querySelector("img"); - const caption = dialog.querySelector(".lightbox-caption"); - document.querySelectorAll(".zoom").forEach((button) => { - button.addEventListener("click", () => { - const img = button.querySelector("img"); - big.src = img.currentSrc.replace("-800.webp", ".webp"); - big.alt = img.alt; - caption.textContent = img.alt; - dialog.showModal(); - }); - }); - dialog.addEventListener("click", (e) => { - if (e.target === dialog) dialog.close(); - }); -} - -// --- Motion --------------------------------------------------------------------------- - -function setUpReveal() { - // Siblings that enter together come in one after another. - const stagger = (els, step) => els.forEach((el, i) => el.style.setProperty("--d", `${i * step}ms`)); - document.querySelectorAll(".facts, .features, .cards, .mosaic, .hero-index").forEach((group) => stagger([...group.children], 40)); - document.querySelectorAll(".hero .container, .section-head, .parity-count, .parity-copy, .launcher-copy").forEach((group) => stagger([...group.querySelectorAll(":scope > .reveal")], 60)); - stagger([...document.querySelectorAll(".system")], 40); - - const count = document.querySelector("[data-digits]"); - count.innerHTML = [...count.textContent].map((c) => `${escape(c)}`).join(""); - - const items = document.querySelectorAll(".reveal, .reveal-media, .systems"); - if (reducedMotion || !("IntersectionObserver" in window)) { - items.forEach((el) => el.classList.add("is-visible")); - return; - } - const observer = new IntersectionObserver( - (entries) => - entries.forEach((e) => { - if (e.isIntersecting) { - e.target.classList.add("is-visible"); - observer.unobserve(e.target); - } - }), - { rootMargin: "0px 0px -10% 0px" }, - ); - items.forEach((el) => observer.observe(el)); -} - -setUpReveal(); -setUpTheme(); -setUpNav(); -setUpMenu(); -setUpTabs(); -setUpLightbox(); -setUpDownload(); -setUpRecent(); -setUpLinks(); -setUpContributors(); -setUpLive(); diff --git a/website/src/markdown.js b/website/src/markdown.js deleted file mode 100644 index 1735f73041..0000000000 --- a/website/src/markdown.js +++ /dev/null @@ -1,203 +0,0 @@ -// A small Markdown renderer for release notes, the README and the docs: headings, paragraphs, -// lists (nested by indentation), fenced code, quotes, rules, tables, and inline code, bold, -// italic and links. Everything is escaped first, so its output never carries raw HTML. -// Shared by build.mjs (Node) and main.js (browser). - -const REPO_BLOB = "https://github.com/ludovic111/horizon/blob/main/"; - -export function escape(text) { - return text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); -} - -function href(url, site) { - url = url.trim(); - if (/^(https?:|mailto:)/i.test(url)) return url; - // The docs pages (`site`) link each other and their own headings; the README's anchors have no target here. - if (site && (/^\/(?!\/)/.test(url) || url.startsWith("#"))) return url; - if (url.startsWith("#")) return null; - if (/^[a-z]+:/i.test(url)) return null; // javascript: and the like - return REPO_BLOB + url.replace(/^\.?\//, ""); -} - -export function inline(text, site = false) { - const codes = []; - let out = text.replace(/`([^`]+)`/g, (_, code) => { - codes.push(code); - return `\u0000${codes.length - 1}\u0000`; - }); - const links = []; - out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => { - links.push([label, url]); - return `\u0001${links.length - 1}\u0001`; - }); - out = escape(out) - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/(^|[^\w*])\*([^*\s][^*]*)\*/g, "$1$2") - .replace(/(^|[^\w])_([^_\s][^_]*)_(?!\w)/g, "$1$2") - .replace(/<(https?:\/\/[^\s&]+)>/g, (_, u) => `${u}`); - out = out.replace(/\u0001(\d+)\u0001/g, (_, i) => { - const [label, url] = links[i]; - const target = href(url, site); - // The label's code spans are already placeholders: put them back for its own pass. - const body = inline(label.replace(/\u0000(\d+)\u0000/g, (_, c) => `\`${codes[c]}\``), site); - return target ? `${body}` : body; - }); - return out.replace(/\u0000(\d+)\u0000/g, (_, i) => `${escape(codes[i])}`); -} - -// A table: a row of cells, a rule row (`| --- | :-: |`), then body rows, until a line that isn't a row. -const ROW = /^\s*\|.*\|\s*$/; -const RULE = /^\s*\|(\s*:?-+:?\s*\|)+\s*$/; - -/** A row's cells: split on `|` outside code spans; `\|` is a literal bar. */ -function cells(row) { - const s = row.trim().slice(1, -1); - const out = [""]; - let code = false; - for (let i = 0; i < s.length; i++) { - if (s[i] === "\\" && s[i + 1] === "|") out[out.length - 1] += s[++i]; - else if (s[i] === "|" && !code) out.push(""); - else { - if (s[i] === "`") code = !code; - out[out.length - 1] += s[i]; - } - } - return out.map((c) => c.trim()); -} - -function table(lines, start, site) { - const row = (line, tag) => `${cells(line).map((c) => `<${tag}>${inline(c, site)}`).join("")}`; - let i = start + 2; - const body = []; - while (i < lines.length && ROW.test(lines[i])) body.push(row(lines[i++], "td")); - return [`
    ${row(lines[start], "th")}${body.join("")}
    `, i]; -} - -const indentOf = (line) => line.match(/^ */)[0].length; -const ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; - -function list(lines, start, site) { - // Collects the items at this list's indentation; deeper lines belong to the item above. - const base = indentOf(lines[start]); - const ordered = /\d/.test(lines[start].match(ITEM)[2]); - const items = []; - let i = start; - while (i < lines.length) { - const line = lines[i]; - if (!line.trim()) { - const next = lines.slice(i + 1).find((l) => l.trim()); - if (next && ITEM.test(next) && indentOf(next) >= base) { - i++; - continue; - } - break; - } - const m = line.match(ITEM); - if (m && indentOf(line) === base) { - items.push({ text: [m[3]], children: [] }); - } else if (m && indentOf(line) > base) { - const [html, end] = list(lines, i, site); - items[items.length - 1].children.push(html); - i = end; - continue; - } else if (indentOf(line) > base && items.length) { - items[items.length - 1].text.push(line.trim()); - } else { - break; - } - i++; - } - const tag = ordered ? "ol" : "ul"; - const body = items.map((it) => `
  • ${inline(it.text.join(" "), site)}${it.children.join("")}
  • `).join(""); - return [`<${tag}>${body}`, i]; -} - -const slug = (text) => - text - .toLowerCase() - .replace(/`/g, "") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - -/** - * Renders Markdown; `shift` moves headings down (a `#` becomes `h{1 + shift}`). - * `site` is for the docs pages: links to the site's own pages and anchors stay as they are, `##` and `###` - * headings get an id (and are pushed onto `headings`, for a table of contents), and a line holding only - * `![caption](/media/…)` becomes a figure. - */ -export function markdown(source, { shift = 0, site = false, headings = null } = {}) { - const lines = source.replace(/\r\n?/g, "\n").split("\n"); - const out = []; - let i = 0; - while (i < lines.length) { - const line = lines[i]; - if (!line.trim() || /^\s*<\/?[a-z][^>]*>\s*$/i.test(line)) { - i++; // blank lines and lone HTML tags (the README's layout tables) are skipped - continue; - } - let m; - if ((m = line.match(/^(#{1,6})\s+(.*?)\s*#*$/))) { - const level = Math.min(6, m[1].length + shift); - let id = ""; - if (site && (m[1].length === 2 || m[1].length === 3)) { - const base = slug(m[2]); - id = base; - for (let n = 2; headings && headings.some((h) => h.id === id); n++) id = `${base}-${n}`; - headings?.push({ level: m[1].length, text: m[2].replace(/`/g, ""), id }); - } - out.push(`${inline(m[2], site)}`); - i++; - } else if (site && (m = line.match(/^!\[([^\]]*)\]\((\/media\/[\w./-]+)\)\s*$/))) { - out.push(`
    ${escape(m[1])}
    ${inline(m[1], true)}
    `); - i++; - } else if ((m = line.match(/^\s*(```|~~~)/))) { - const fence = m[1]; - const code = []; - i++; - while (i < lines.length && !lines[i].trim().startsWith(fence)) code.push(lines[i++]); - i++; - out.push(`
    ${escape(code.join("\n"))}
    `); - } else if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { - out.push("
    "); - i++; - } else if (ROW.test(line) && RULE.test(lines[i + 1] || "")) { - const [html, end] = table(lines, i, site); - out.push(html); - i = end; - } else if (ITEM.test(line)) { - const [html, end] = list(lines, i, site); - out.push(html); - i = end; - } else if (line.startsWith(">")) { - const quote = []; - while (i < lines.length && lines[i].startsWith(">")) quote.push(lines[i++].replace(/^>\s?/, "")); - out.push(`
    ${markdown(quote.join("\n"), { shift, site })}
    `); - } else { - const para = []; - while ( - i < lines.length && - lines[i].trim() && - !/^(#{1,6}\s|>|\s*```|\s*~~~)/.test(lines[i]) && - !ITEM.test(lines[i]) - ) { - para.push(lines[i++].trim()); - } - out.push(`

    ${inline(para.join(" "), site)}

    `); - } - } - return out.join("\n"); -} - -/** The body of the section titled `title` (any heading level), up to the next heading at its level or above. */ -export function section(source, title) { - const lines = source.replace(/\r\n?/g, "\n").split("\n"); - const level = (l) => (l.match(/^(#{1,6})\s/) || [, ""])[1].length; - const start = lines.findIndex((l) => level(l) > 0 && l.replace(/^#+/, "").trim() === title); - if (start < 0) return null; - const depth = level(lines[start]); - const end = lines.findIndex((l, i) => i > start && level(l) > 0 && level(l) <= depth); - return lines - .slice(start + 1, end < 0 ? undefined : end) - .join("\n") - .trim(); -} diff --git a/website/src/markdown.test.mjs b/website/src/markdown.test.mjs deleted file mode 100644 index 171348e454..0000000000 --- a/website/src/markdown.test.mjs +++ /dev/null @@ -1,25 +0,0 @@ -// node --test src/: the Markdown the docs pages and release notes are rendered from. -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { markdown } from "./markdown.js"; - -test("a table renders with its header, cells and inline Markdown", () => { - const html = markdown("| Way in | Status |\n| --- | :-: |\n| Rust | `cargo horizon-mod` |\n| Lua | planned \\| soon |\n\nAfter."); - assert.equal( - html, - '
    ' + - "
    Way inStatus
    Rustcargo horizon-mod
    Luaplanned | soon
    \n

    After.

    ", - ); -}); - -test("a bar inside a code span stays in its cell", () => { - assert.match(markdown("| a | b |\n|---|---|\n| `x | y` | z |"), /x \| y<\/code><\/td>z<\/td>/); -}); - -test("rows without a rule line are a paragraph", () => { - assert.equal(markdown("| a | b |\n| c | d |"), "

    | a | b | | c | d |

    "); -}); - -test("a link whose label is code keeps it", () => { - assert.equal(markdown("See [`AGENTS.md`](/docs/agents).", { site: true }), '

    See AGENTS.md.

    '); -}); diff --git a/website/src/mods-page.js b/website/src/mods-page.js deleted file mode 100644 index 335e863bd2..0000000000 --- a/website/src/mods-page.js +++ /dev/null @@ -1,146 +0,0 @@ -// /mods and /mods/: the registry's list (kind filter, search, order) and a project's page. The pages are baked -// from the index at build time; this fetches the published index again so a project merged since shows at once, -// the ratings (crates/horizon-mod-registry), and on a project's page the signed-in player's own rating to change. -import { API, INDEX_URL, KINDS, RATINGS, SORTS, listHtml, matches, projectHtml, rateHtml, ratingOf, readIndex, sorted, starsHtml } from "./mods.js"; -import { cached, setUpMenu, setUpTheme } from "./chrome.js"; -import { escape } from "./markdown.js"; - -const root = document.getElementById("mods"); -const baked = readIndex(JSON.parse(document.getElementById("mods-index").textContent || "null")) || []; -let projects = baked; -// Every project's ratings, once the service answered. -let ratings = null; -let kind = "all"; -let text = ""; -let order = "downloads"; -// On a project's page: the signed-in account ({login, csrf}) or null, and its own rating of the project. -let me; -let mine = null; - -const id = (location.pathname.match(/^\/mods\/([a-z0-9_]+)\/?$/) || [])[1]; - -function showList() { - const list = root.querySelector("#mod-list"); - if (!list) return; - const shown = sorted(projects.filter((p) => matches(p, kind, text)), order, ratings); - list.innerHTML = listHtml(shown, ratings); - root.querySelector("[data-mod-count]").textContent = `${shown.length} of ${projects.length}`; - root.querySelectorAll("[data-kind-filter]").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.kindFilter === kind))); -} - -function showRate(p, message = "") { - const rate = root.querySelector("#mod-rate"); - if (!rate || me === undefined) return; - rate.innerHTML = rateHtml(p, me, mine) + (message ? `

    ${escape(message)}

    ` : ""); -} - -function showProject() { - const p = projects.find((x) => x.id === id); - const page = root.querySelector(".mod-page, .mod-missing"); - if (!page) return; - if (p) { - page.outerHTML = projectHtml(p, ratings ? ratingOf(ratings, p.id) : undefined); - document.title = `${p.name} · Horizon`; - showRate(p); - } else { - page.outerHTML = `

    Not in the registry

    No project is called ${id} here. It may not be published yet, or it was taken down.

    Every mod
    `; - } -} - -function render() { - if (id) showProject(); - else showList(); -} - -// The signed-in account and its own rating, for the page's rating control. -async function signIn() { - try { - const r = await fetch(`${API}/me`, { credentials: "same-origin" }); - me = r.ok ? await r.json() : null; - if (me) { - const own = await fetch(`${API}/${id}/rating`, { credentials: "same-origin" }); - mine = own.ok ? (await own.json()).stars : null; - } - } catch { - me = null; - } - const p = projects.find((x) => x.id === id); - if (p) showRate(p); -} - -async function rate(stars) { - const p = projects.find((x) => x.id === id); - if (!p || !me) return; - root.querySelectorAll(".mod-star").forEach((b) => (b.disabled = true)); - let message = ""; - try { - const r = await fetch(`${API}/${id}/rating`, { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json", "X-CSRF-Token": me.csrf }, - body: JSON.stringify({ stars }), - }); - const json = await r.json().catch(() => ({})); - if (r.ok) { - mine = json.stars; - ratings = { ...ratings, [id]: { average: json.average, count: json.count } }; - const line = root.querySelector("[data-mod-rating]"); - if (line) line.innerHTML = `${starsHtml(ratingOf(ratings, id))} · `; - } else { - message = json.error || `The service answered ${r.status}.`; - } - } catch { - message = "The service didn't answer. Try again in a minute."; - } - showRate(p, message); -} - -if (id) { - root.addEventListener("click", (e) => { - const star = e.target.closest?.("[data-stars]"); - if (star) rate(Number(star.dataset.stars)); - }); - signIn(); -} else { - root.querySelectorAll("[data-kind-filter]").forEach((b) => - b.addEventListener("click", () => { - kind = b.dataset.kindFilter; - showList(); - }), - ); - root.querySelector("#mod-search")?.addEventListener("input", (e) => { - text = e.target.value; - showList(); - }); - root.querySelector("#mod-sort")?.addEventListener("change", (e) => { - order = e.target.value; - showList(); - }); - const query = new URLSearchParams(location.search); - if (KINDS.some(([k]) => k === query.get("kind"))) kind = query.get("kind"); - if (SORTS.some(([k]) => k === query.get("sort"))) order = query.get("sort"); - const select = root.querySelector("#mod-sort"); - if (select) select.value = order; -} - -setUpTheme(); -setUpMenu(); -render(); -cached("horizon:mod-index", () => fetch(INDEX_URL).then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status}`))))) - .then((index) => { - const fresh = readIndex(index); - if (fresh) { - projects = fresh; - render(); - } - }) - .catch(() => {}); -fetch(RATINGS) - .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status}`)))) - .then((json) => { - if (json && typeof json.projects === "object") { - ratings = json.projects; - render(); - } - }) - .catch(() => {}); diff --git a/website/src/mods-submit.js b/website/src/mods-submit.js deleted file mode 100644 index c1024495b9..0000000000 --- a/website/src/mods-submit.js +++ /dev/null @@ -1,80 +0,0 @@ -// /mods/submit: signs in with GitHub through the registry's service, then sends the page and the file. The service -// checks the file and answers with the pull request it opened, or with what to fix. -import { API, entryFrom } from "./mods.js"; -import { setUpMenu, setUpTheme } from "./chrome.js"; -import { escape } from "./markdown.js"; - -const auth = document.getElementById("submit-auth"); -const form = document.getElementById("submit-form"); -const result = document.getElementById("submit-result"); -let csrf = ""; -// The form's own controls by name (form.id and form.name are the form's attributes). -const control = (name) => form.elements.namedItem(name); - -function signedOut(message) { - auth.innerHTML = `

    ${message}

    Sign in with GitHub

    It only reads your public profile: your GitHub name goes on the pull request and in the project's owners.

    `; - form.hidden = true; -} - -async function start() { - let me; - try { - const r = await fetch(`${API}/me`, { credentials: "same-origin" }); - if (r.status === 401) return signedOut("Sign in to submit: reviewers and updates go through your GitHub account."); - if (!r.ok) throw new Error(String(r.status)); - me = await r.json(); - } catch { - auth.innerHTML = `

    The form isn't open right now. You can open a pull request by hand.

    `; - return; - } - csrf = me.csrf; - auth.innerHTML = `

    Signed in as ${escape(me.login)}.

    `; - document.getElementById("sign-out").addEventListener("click", async () => { - await fetch(`${API}/auth/logout`, { method: "POST", credentials: "same-origin" }); - signedOut("Signed out."); - }); - form.hidden = false; -} - -// A file named -. fills in the two fields and the kind. -form.querySelector("#f-file").addEventListener("change", (e) => { - const name = e.target.files[0]?.name || ""; - const m = name.match(/^([a-z0-9_]{1,64})-([A-Za-z0-9][A-Za-z0-9._-]*)\.(hmod|hpack|zip)$/); - const kind = { hmod: "mod", hpack: "modpack", zip: "resourcepack" }[name.split(".").pop()]; - if (kind) control("kind").value = kind; - if (m) { - if (!control("id").value) control("id").value = m[1]; - if (!control("version").value) control("version").value = m[2]; - } -}); - -form.addEventListener("submit", async (e) => { - e.preventDefault(); - const send = form.querySelector("#f-send"); - const values = Object.fromEntries(["id", "kind", "name", "summary", "description", "authors", "license", "source", "icon", "key", "version", "game", "changelog"].map((k) => [k, control(k).value])); - const sig = form.querySelector("#f-signature").files[0]; - values.signature = sig ? await sig.text() : ""; - const body = new FormData(); - body.append("entry", JSON.stringify(entryFrom(values))); - body.append("file", form.querySelector("#f-file").files[0]); - send.disabled = true; - result.innerHTML = `

    Checking the file and opening the pull request…

    `; - try { - const r = await fetch(`${API}/submit`, { method: "POST", body, credentials: "same-origin", headers: { "X-CSRF-Token": csrf } }); - const json = await r.json().catch(() => ({})); - if (r.ok && /^https:\/\/github\.com\//.test(json.pull_request || "")) { - result.innerHTML = `

    Submitted. Follow the review in its pull request: the checks' report is there, and reviewers answer there.

    `; - form.reset(); - } else { - result.innerHTML = `

    ${escape(json.error || `The service answered ${r.status}.`)}

    `; - } - } catch { - result.innerHTML = `

    The service didn't answer. Try again in a minute.

    `; - } finally { - send.disabled = false; - } -}); - -setUpTheme(); -setUpMenu(); -start(); diff --git a/website/src/mods.html b/website/src/mods.html deleted file mode 100644 index 0c0e5eea2f..0000000000 --- a/website/src/mods.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - {{MODS_TITLE}} · Horizon - - - - - - - - - - - - - - - - - - - - -{{SITE_NAV}} -
    -
    -{{MODS_MAIN}} -

    Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

    -
    -
    -{{SITE_FOOTER}} - - - diff --git a/website/src/mods.js b/website/src/mods.js deleted file mode 100644 index 28c31b9fe4..0000000000 --- a/website/src/mods.js +++ /dev/null @@ -1,249 +0,0 @@ -// Horizon's registry on the site (/mods): the list with its kind filter and search, and a page per project. -// Shared by build.mjs (baked into the pages) and mods-page.js (refreshed from the published index). -// The index's format is docs/api/mod-index.md in the repository. -import { markdown, escape } from "./markdown.js"; - -export const INDEX_URL = "https://ludovic111.github.io/horizon-mods/index.json"; -const REGISTRY = "https://github.com/ludovic111/horizon-mods"; -export const SUBMIT = "/mods/submit"; -// The submission form's service (crates/horizon-mod-registry), behind the site's own address. -export const API = "/api/mods"; -// Every project's ratings: {projects: {: {average, count}}}. -export const RATINGS = `${API}/ratings`; -export const SCHEMA = 1; - -// The kinds the site shows, in the filter's order. Kinds a newer index adds are left out until the site knows them. -export const KINDS = [ - ["mod", "Mods"], - ["modpack", "Modpacks"], - ["resourcepack", "Resource packs"], - ["shader", "Shader packs"], -]; -const KIND_NAMES = { mod: "Mod", modpack: "Modpack", resourcepack: "Resource pack", shader: "Shader pack" }; - -// What a capability lets a mod do, as the game and the launcher say it (horizon_mods::Capability::describe). -export const CAPABILITIES = { - "game-read": "see your position, health and the time", - chat: "show you chat messages", - hud: "draw text on your screen", - storage: "save its own settings to disk", - registries: "add blocks and items to your worlds", -}; - -// The index's projects this site can show, or null when it isn't an index it reads. -export function readIndex(index) { - if (!index || typeof index !== "object" || index.schema > SCHEMA || !Array.isArray(index.projects)) return null; - const known = new Set(KINDS.map(([k]) => k)); - return index.projects.filter((p) => known.has(p.kind) && /^[a-z0-9_]{1,64}$/.test(p.id) && p.versions?.length); -} - -const https = (url) => (typeof url === "string" && url.startsWith("https://") ? url : ""); -const size = (bytes) => (bytes >= 1 << 20 ? `${(bytes / (1 << 20)).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`); -const trust = (p) => - p.key ? `Signed` : `Unsigned`; -const initial = (p) => escape((p.name || p.id).trim().charAt(0).toUpperCase()); -const icon = (p) => - https(p.icon) ? `` : ``; - -// A count as the launcher writes it: 999, 12.3k, 4.1M. -export function count(n) { - if (n < 1000) return String(n); - if (n < 1e6) return `${(n / 1e3).toFixed(1)}k`; - return `${(n / 1e6).toFixed(1)}M`; -} - -const downloads = (p) => { - const n = Number.isSafeInteger(p.downloads) && p.downloads > 0 ? p.downloads : 0; - return `${count(n)} download${n === 1 ? "" : "s"}`; -}; - -// A project's ratings from the service's answer, or null when it has none. -export function ratingOf(ratings, id) { - const r = ratings?.[id]; - return r && Number.isFinite(r.average) && Number.isSafeInteger(r.count) && r.count > 0 ? r : null; -} - -// Five stars, the mean's nearest whole number of them filled, and the mean and count beside. -export function starsHtml(r) { - if (!r) return `No ratings yet`; - const on = Math.min(5, Math.max(0, Math.round(r.average))); - const mean = r.average.toFixed(1); - return `Rated ${mean} out of 5 ${mean} (${count(r.count)})`; -} - -// The list's orders: the most downloaded first (the default), the best rated, or by name. -export const SORTS = [ - ["downloads", "Most downloaded"], - ["rating", "Best rated"], - ["name", "Name"], -]; - -export function sorted(projects, by, ratings) { - const avg = (p) => ratingOf(ratings, p.id)?.average || 0; - const votes = (p) => ratingOf(ratings, p.id)?.count || 0; - const name = (a, b) => a.name.localeCompare(b.name); - const order = { - downloads: (a, b) => (b.downloads || 0) - (a.downloads || 0) || name(a, b), - rating: (a, b) => avg(b) - avg(a) || votes(b) - votes(a) || name(a, b), - name, - }[by] || (() => 0); - return [...projects].sort(order); -} - -export function matches(p, kind, text) { - if (kind !== "all" && p.kind !== kind) return false; - const hay = `${p.id} ${p.name} ${p.summary} ${(p.authors || []).join(" ")}`.toLowerCase(); - return text.toLowerCase().split(/\s+/).filter(Boolean).every((w) => hay.includes(w)); -} - -// The list: a row per project; its kind, ratings (once `ratings` is known), downloads and newest version on the right. -export function listHtml(projects, ratings = null) { - if (!projects.length) return `

    Nothing here yet. Submit yours.

    `; - return projects - .map( - (p) => ` - ${icon(p)} - ${escape(p.name)}${escape(p.summary || "")} - ${escape(KIND_NAMES[p.kind] || p.kind)}${ratings ? `${starsHtml(ratingOf(ratings, p.id))} · ` : ""}${downloads(p)}${escape(p.versions[0].version)} · ${escape((p.authors || []).join(", "))} -`, - ) - .join("\n"); -} - -function installHtml(p, v) { - const file = escape(v.file.name); - const cli = `horizon-launcher mods install --source horizon ${p.id}`; - const where = { - mod: `put it in horizon-mods/ beside options.txt in the game folder, then turn it on in the game: Options > Mods`, - resourcepack: `put it in resourcepacks/ beside options.txt, then turn it on in Options > Resource Packs`, - modpack: `a modpack lists registry mods: install them one by one, or wait for the launcher to install modpacks`, - shader: `Horizon's own shader packs aren't installed yet`, - }[p.kind]; - return `
      -
    1. In the launcher: Mods, pick Horizon beside Modrinth, find ${escape(p.name)} and press Install. It checks the file's checksum${p.key ? " and signature" : ""} first.
    2. -
    3. From a terminal or an agent: ${escape(cli)}
    4. -
    5. By hand: download ${file} (${size(v.file.size)}), check its SHA-256 below, and ${where}.
    6. -
    `; -} - -// GitHub's rule for a login: letters, digits and single dashes, at most 39. -const LOGIN = /^(?!-)(?!.*--)[A-Za-z0-9-]{1,39}(? LOGIN.test(o)); - if (!owners.length) return ""; - const links = owners - .map((o) => `@${o}`) - .join(" "); - return `

    Published by ${links}

    `; -} - -// The rating control under a project's ratings: five stars to press when signed in (`mine`, the account's own -// rating, pressed), a sign-in link when not, a note for the project's owners. -export function rateHtml(p, me, mine) { - if (!me) return `Sign in with GitHub to rate`; - if ((p.owners || []).some((o) => o.toLowerCase() === String(me.login).toLowerCase())) return `

    You publish it: players rate it.

    `; - const stars = [1, 2, 3, 4, 5] - .map((n) => ``) - .join(""); - return `${mine ? "Your rating" : "Rate it"}${stars}`; -} - -// One project's page; `rating` is its ratings (null: none yet), left out when they aren't known. -export function projectHtml(p, rating) { - const v = p.versions[0]; - const caps = v.capabilities || []; - const capsHtml = caps.length - ? `
      ${caps.map((c) => `
    • May ${escape(CAPABILITIES[c] || c)}
    • `).join("")}
    ` - : `

    It asks for nothing beyond writing its own log.

    `; - const requires = (v.requires || []).length - ? `

    Its mods

    ` - : ""; - const versions = p.versions - .map((x) => `
  • ${escape(x.version)} ${escape(x.published || "")} · for ${escape(x.game)}${x.api ? ` · API ${escape(x.api)}` : ""}${x.changelog ? `
    ${markdown(x.changelog)}
    ` : ""}
  • `) - .join(""); - return `
    -
    ${icon(p)}

    ${escape(KIND_NAMES[p.kind] || p.kind)}${escape((p.authors || []).join(", "))}

    ${ownersHtml(p)} -

    ${escape(p.name)}

    ${escape(p.summary || "")}

    -

    ${trust(p)} ${escape(p.license)} ${escape(v.version)}${v.side ? ` ${escape(v.side)}` : ""}

    -

    ${rating === undefined ? "" : `${starsHtml(rating)} · `}${downloads(p)}

    -
    - ${p.description ? `
    ${markdown(p.description)}
    ` : ""} - ${p.kind === "mod" ? `

    What it may do

    ${capsHtml}

    Mods run in a sandbox with no file, network or clock access. The game refuses a mod whose code uses more than this list.

    ` : ""} - ${requires} -

    Install

    - ${installHtml(p, v)} -

    File

    -
    Name
    ${escape(v.file.name)}
    Size
    ${size(v.file.size)}
    SHA-256
    ${escape(v.file.sha256)}
    ${p.key ? `
    Signing key
    ${escape(p.key)}
    ` : ""}
    -

    Versions

    -
      ${versions}
    - -
    `; -} - -// /mods/submit: the form. Its script (mods-submit.js) signs in, then sends the page and the file to the service, which -// checks the file and opens the pull request the reviewers read. -const field = (name, label, input, hint = "") => - ``; -const text = (name, attrs = "") => ``; - -export function submitHtml() { - return `

    ModsSubmit

    -

    Submit to the registry

    -

    A mod, a modpack of registry mods, or a resource pack made for Horizon. The form checks the file and opens a pull request; a reviewer reads it, its source and the checks' report, and merging publishes it to this site and the launcher.

    -

    First time? Publish a mod walks through building, checking and signing the file, and Make a mod starts from the beginning.

    -
    -

    The form needs JavaScript. You can also open a pull request by hand.

    -
    - `; -} - -// The service's "entry" from the form's values (strings), as it reads it. -export function entryFrom(v) { - const optional = (x) => (x && x.trim() ? x.trim() : undefined); - return { - id: v.id.trim(), - kind: v.kind, - name: v.name.trim(), - summary: v.summary.trim(), - description: v.description || "", - authors: (v.authors || "").split(",").map((a) => a.trim()).filter(Boolean), - license: v.license.trim(), - source: v.source.trim(), - icon: optional(v.icon), - key: optional(v.key), - signature: optional(v.signature), - version: v.version.trim(), - game: v.game.trim(), - changelog: v.changelog || "", - }; -} diff --git a/website/src/mods.test.mjs b/website/src/mods.test.mjs deleted file mode 100644 index d94cc3e078..0000000000 --- a/website/src/mods.test.mjs +++ /dev/null @@ -1,110 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { CAPABILITIES, count, entryFrom, listHtml, matches, projectHtml, rateHtml, ratingOf, readIndex, sorted, starsHtml, submitHtml } from "./mods.js"; - -const project = (over = {}) => ({ - id: "coords_hud", kind: "mod", name: "Coordinates HUD", summary: "Where you are.", authors: ["Horizon"], - license: "MIT", source: "https://example.org/src", - versions: [{ version: "0.1.0", api: "0.1", side: "client", capabilities: ["game-read", "hud"], game: "26.3", - file: { name: "coords_hud-0.1.0.hmod", url: "https://github.com/ludovic111/horizon-mods/releases/download/x/coords_hud-0.1.0.hmod", size: 438, sha256: "e".repeat(64) } }], - ...over, -}); - -test("reads schema 1, keeps the kinds it knows and refuses a newer schema", () => { - const index = { schema: 1, projects: [project(), project({ id: "map", kind: "world" }), project({ id: "Bad id" })] }; - assert.deepEqual(readIndex(index).map((p) => p.id), ["coords_hud"]); - assert.equal(readIndex({ schema: 2, projects: [project()] }), null); - assert.equal(readIndex(null), null); -}); - -test("filters by kind and every word", () => { - const p = project(); - assert.ok(matches(p, "all", "coordinates horizon")); - assert.ok(matches(p, "mod", "")); - assert.ok(!matches(p, "modpack", "")); - assert.ok(!matches(p, "all", "zoom")); -}); - -test("escapes what authors wrote and only links https addresses", () => { - const evil = project({ name: "", source: "javascript:alert(1)", summary: '">' }); - const html = listHtml([evil]) + projectHtml(evil); - assert.ok(!html.includes(" - - -
    -
    -

    PrivacyData collection

    -

    Nothing leaves without your yes.

    -

    The Horizon launcher can send a few things that help make the game faster and steadier. It asks first, each kind has its own switch, and every switch is off until you turn it on.

    -
    -

    What can be sent

    -

    Only the kinds you allow, from the launcher's first-run question or Settings → Data collection:

    -
      -
    • Performance statistics, after each play session of a minute or more. These are the frame-time and tick-time percentiles and a 14-bucket histogram (never a recording of each frame), the peak memory, how long the game took to start and to load the world, and the settings that change performance (render and simulation distance, frame limit, VSync, fullscreen, graphics mode).
    • -
    • Crash reports, when the game crashes. They hold the error, where it happened in Horizon's code, your graphics adapter and settings. They hold no log, no world, no server and no resource packs.
    • -
    • Bug reports that you write and send yourself. The end of the game's log goes with one only if you add it after reading it, and lines of chat are removed first.
    • -
    • Benchmark uploads of the benchmarks you run. They are shown publicly on the Benchmarks page, only as medians of groups of at least 3 different installs with the same graphics card, processor and render distance; never one by one, and never with your install ID, the day, your OS version, driver, exact memory or screen.
    • -
    -

    Each one also carries the Horizon version, the day it was made (not the time), and a description of your machine, because that is what explains how fast the game runs on it:

    -
      -
    • the operating system, its version and the processor architecture;
    • -
    • the processor model, its cores and threads, and its base and boost clock where the system gives them;
    • -
    • the memory, rounded to the gigabyte;
    • -
    • the graphics card the game draws with: its model, maker, whether it is built into the processor or separate, its own memory, its driver version and the graphics interface (Vulkan, Metal, DirectX 12);
    • -
    • the kind of drive the game folder is on (NVMe, SSD or hard disk);
    • -
    • the screen the game opened on: its size in pixels, refresh rate and scaling;
    • -
    • whether the computer is a laptop or a desktop, and whether it runs on battery.
    • -
    -

    No serial number, computer name, user name, network address or file path is read. Each item also carries a random install ID. The launcher makes that ID the first time you allow something; it says nothing about you and exists only so that your data can be deleted.

    -

    Never sent: your Microsoft or Minecraft account name or UUID, world names, server or LAN addresses, chat, and your user name or home folder in file paths. Before anything is kept, those are replaced with placeholders, along with e-mail and IP addresses. The server does not store the IP address requests come from.

    -

    The launcher shows exactly what would be sent: Settings → Data collection → View what would be sent, or horizon-launcher data show. Every field is listed in docs/telemetry.md.

    - -

    The "I'm playing" ping

    -

    The game sends an anonymous "I'm playing" ping, so the launcher can show how many people play Horizon right now. It is sent once a minute while you play, whatever the switches say.

    -

    It holds: a random number made when the game starts and forgotten when it closes, the Horizon version, your operating system (Linux, macOS or Windows), and whether you have a world open to LAN or to friends. It does not hold: your account, your install ID, world or server names, your hardware or settings, or anything you do in the game. The random number changes every time you start the game, so two of your sessions can't be linked to each other or to you.

    -

    The server keeps the pings in memory only, never on disk, and not with the address they came from (that is used only to stop abuse, as below). A session counts while it pinged in the last two minutes and is then forgotten. What it shows is the number playing now, per version and with a world open, rounded.

    - -

    Why

    -

    To find what is slow or crashes on which machines, and fix it. The ping shows players, and Horizon, how many people play right now. Benchmark uploads also show other players how Horizon runs on hardware like theirs. Nothing is sold, shared for advertising, or used to identify you.

    - -

    On what basis

    -

    For the switches: your consent (Article 6(1)(a) of the EU's General Data Protection Regulation). It is given kind by kind, recorded on your computer with the version of the question you answered and when, and withdrawn at any time with the same switches. Withdrawing deletes what was waiting to be sent. When what is collected changes, the launcher asks again, and nothing new is sent until you answer. The ping holds nothing that identifies you; the address it comes from is used only to limit how often one address may send, kept in memory for an hour at most and never stored.

    - -

    Where and how long

    -

    Items wait on your computer (telemetry/ in the game folder) and are sent compressed when the launcher starts or closes, to Horizon's own server, telemetry.playhorizon.dev, hosted by Railway in the European Union (Amsterdam, the Netherlands). The server keeps them for 180 days, then deletes them. What stays after that are anonymous totals per day, Horizon version, operating system, GPU maker and render distance (for example "120 sessions on macOS with Apple GPUs at render distance 12"). They hold no install ID, and groups too small to be anonymous are never shown. Published results also show how players split by memory size (in steps: 8, 16, 32 GB…), graphics memory, drive type, operating system and built-in or separate graphics, each class only once it holds at least 3 different installs; the exact values, the driver, the clocks and the screen are never published.

    - -

    Deleting your data

    -

    Settings → Data collection → Delete my data, or horizon-launcher data delete. The server deletes everything sent under your install ID, your share of the totals is recomputed, anything waiting on your computer is removed, and you get a new install ID. You can also turn everything off, or delete the telemetry/ folder.

    - -

    Your rights and contact

    -

    Horizon is made by Ludovic Marie, who is responsible for this data. To ask for a copy of what was sent under your install ID, have it corrected or deleted, or ask anything about it, write through lu4ovic.com (give your install ID, shown in Settings → Data collection). You can also complain to the data protection authority where you live.

    -

    This website itself sets no cookies and has no analytics; its footer says what it loads from GitHub.

    -
    - -

    Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

    -
    -
    - - - diff --git a/website/src/releases-page.js b/website/src/releases-page.js deleted file mode 100644 index ace84f63dc..0000000000 --- a/website/src/releases-page.js +++ /dev/null @@ -1,78 +0,0 @@ -// The Releases page: the list baked at build time, refreshed from GitHub when a newer release is out, and the -// channel filter. Without JavaScript the baked list reads as it is. -import { releasesPageHtml } from "./releases.js"; -import { isGame, isLauncher, newestFirst } from "./versions.js"; -import { cached, get, setUpMenu, setUpTheme, slim } from "./chrome.js"; - -const REPO = "ludovic111/horizon"; -const list = document.getElementById("release-list"); -const counter = document.querySelector("[data-release-count]"); -const buttons = [...document.querySelectorAll("[data-filter]")]; -let filter = "all"; - -const ours = (r) => !r.draft && (isGame(r.tag_name) || isLauncher(r.tag_name)); - -// Every release, a page of 100 at a time (GitHub's most), up to 500. -async function loadAll() { - const all = []; - for (let page = 1; page <= 5; page++) { - const batch = await get(`https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`); - all.push(...batch.filter(ours).map(slim)); - if (batch.length < 100) break; - } - return all; -} - -function apply() { - const entries = [...list.querySelectorAll(".release-entry")]; - let shown = 0; - for (const el of entries) { - const on = filter === "all" || el.dataset.channel === filter; - el.hidden = !on; - if (on) shown++; - } - counter.textContent = `${shown} release${shown === 1 ? "" : "s"}`; - buttons.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.filter === filter))); -} - -async function refresh() { - // One small request first: the full list only when GitHub has something newer than the page. - const newest = await cached("horizon:releases-newest", () => get(`https://api.github.com/repos/${REPO}/releases?per_page=5`)).catch(() => null); - if (!newest) return; - const top = newest.filter(ours).map((r) => r.tag_name).sort(newestFirst)[0]; - if (!top || top === list.dataset.newest) return; - const all = await cached("horizon:releases-all", loadAll).catch(() => null); - if (!all || !all.length) return; - const open = new Set([...list.querySelectorAll(".release-entry[open]")].map((el) => el.id)); - list.innerHTML = releasesPageHtml(all); - list.dataset.newest = top; - // Keep what the reader opened open. - open.forEach((id) => document.getElementById(id)?.setAttribute("open", "")); - apply(); -} - -// A link to one release (/releases#release-v0.2.1) opens it. -function openTarget() { - const el = location.hash && document.getElementById(location.hash.slice(1)); - if (el && el.classList.contains("release-entry")) { - if (el.hidden) { - filter = "all"; - apply(); - } - el.open = true; - el.scrollIntoView(); - } -} - -buttons.forEach((b) => - b.addEventListener("click", () => { - filter = b.dataset.filter; - apply(); - }), -); -addEventListener("hashchange", openTarget); -setUpTheme(); -setUpMenu(); -apply(); -openTarget(); -refresh().then(openTarget); diff --git a/website/src/releases.html b/website/src/releases.html deleted file mode 100644 index 989164653d..0000000000 --- a/website/src/releases.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - Releases · Horizon - - - - - - - - - - - - - - - - - - - - - -{{SITE_NAV}} -
    -
    -

    ReleasesEvery version, newest first

    -

    What's new in Horizon

    -

    Each release lists what it adds and fixes, then its full notes and its downloads. New versions come out many times a day as changes land, and the launcher installs each one once it has passed its test. The launcher has its own releases.

    -
    - - - -
    -

    {{RELEASE_COUNT}}

    -
    -{{RELEASE_LIST}} -
    -

    The releases on GitHub

    -

    Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

    -
    -
    -{{SITE_FOOTER}} - - diff --git a/website/src/releases.js b/website/src/releases.js deleted file mode 100644 index d4932e7f76..0000000000 --- a/website/src/releases.js +++ /dev/null @@ -1,156 +0,0 @@ -// The release notes on the site: the home page's "Recently added" block and the Releases page (every release, -// newest first). Shared by build.mjs (baked into the pages) and the pages' scripts (refreshed from GitHub). -// Tags: the game's vX.Y.Z and the launcher's launcher@X.Y.Z from 0.2, vYYYY.MM.DD[.N] and launcher-vX.Y.Z before -// (src/versions.js). One channel (docs/release-flow.md, "One channel"): a release is the game's or the launcher's. -import { markdown, inline, escape } from "./markdown.js"; -import { isLauncher, launcherVersion, newestFirst } from "./versions.js"; - -const REPO = "ludovic111/horizon"; - -export const channel = (r) => (isLauncher(r.tag_name) ? "Launcher" : "Game"); -const anchor = (r) => `release-${r.tag_name.replace(/[^\w.-]/g, "-")}`; - -// "Horizon 0.2.3: Cave culling, weather" (before 0.2, "Horizon 2026.10.13: …") reads as the version and its line. -export function parts(r) { - const version = launcherVersion(r.tag_name) ?? r.tag_name.replace(/^v/, ""); - const name = (r.name || "").trim(); - let line = name.includes(": ") ? name.slice(name.indexOf(": ") + 2) : name.replace(/^Horizon( launcher)?\s*/i, "").replace(version, "").trim(); - if (junk(line)) line = fromNotes(r.body) || line; - return { version, line: line.charAt(0).toUpperCase() + line.slice(1), date: (r.published_at || "").slice(0, 10) }; -} - -// A title made of issue numbers and "fix" ("#1255, mob pushes, fix, #1222") says nothing. -const junk = (line) => { - const bits = line.split(",").map((b) => b.trim()).filter(Boolean); - return bits.length > 1 && bits.filter((b) => /^(#\d+|fix(es)?|fixed)$/i.test(b)).length * 2 >= bits.length; -}; - -// The first item that isn't a bare "Fix #123:" prefix, as the line: "Mob pushes: one list per mob… and 3 more fixes". -function fromNotes(body) { - const s = sections(body); - const items = [...s.filter((x) => /^new|what's new/i.test(x.title)), ...s.filter((x) => /^fixed/i.test(x.title))].flatMap((x) => x.items).map(plainItem); - const clean = items.map((i) => i.replace(/^(fix|fixes)\s+#\d+:\s*/i, "").replace(/^\w+\([^)]*\):\s*/, "")).filter(Boolean); - if (!clean.length) return ""; - const first = clean[0].replace(/[.]$/, ""); - return clean.length > 1 ? `${first} and ${clean.length - 1} more` : first; -} - -// The notes' "### Heading" sections and their top-level items, in order. -export function sections(body) { - const out = []; - let current = null; - for (const raw of (body || "").replace(/\r\n?/g, "\n").split("\n")) { - const heading = raw.match(/^#{2,3}\s+(.+?)\s*$/); - if (heading) out.push((current = { title: heading[1], items: [] })); - else if (current && /^[-*]\s+/.test(raw)) current.items.push(raw.replace(/^[-*]\s+/, "").trim()); - } - return out; -} - -// An item without its pull request references: "Riptide (#123, fixes #45)" -> "Riptide". -export const plainItem = (item) => item.replace(/\s*\((?:#\d+|fixes #\d+|[^()]*#\d+[^()]*)\)\s*/g, " ").replace(/\s+/g, " ").trim(); - -// A game release's notes open with an HTML block sending people to the launcher (PLAYERS in tools/release_train.py): -// GitHub's latest release is the game's. This site has its own download button, so it leaves the block out. -export const notesText = (body) => (body || "").replace(/^

    Want to play\?[^\n]*(?:\r?\n)*/, ""); - -// The notes' first sentence, for a release with no title line and no sections ("Rebuilt from the latest main."). -const firstLine = (body) => (notesText(body).split(/\r?\n/).find((l) => l.trim() && !/^#/.test(l)) || "").trim().replace(/[.:]$/, ""); - -const count = (n, one, many) => `${n} ${n === 1 ? one : many}`; - -// What a release changes, in a line: "12 new, 30 fixed, 2 known issues". -export function overview(r) { - const s = sections(r.body); - const by = (re) => s.filter((x) => re.test(x.title)).reduce((n, x) => n + x.items.length, 0); - const added = by(/^new|what's new/i); - const fixed = by(/^fixed/i); - const known = by(/^known/i); - const bits = []; - if (added) bits.push(count(added, "new feature", "new features")); - if (fixed) bits.push(count(fixed, "fix", "fixes")); - if (known) bits.push(count(known, "known issue", "known issues")); - return { added, fixed, known, text: bits.join(", ") }; -} - -export function highlights(r, n = 3) { - const added = sections(r.body).filter((x) => /^new|what's new/i.test(x.title)).flatMap((x) => x.items); - return added.slice(0, n).map(plainItem); -} - -const byTag = (a, b) => newestFirst(a.tag_name, b.tag_name); -const badge = (r) => `${channel(r)}`; - -// The home page: the newest releases on a line each, and what the newest ones added. -export function recentHtml(list, { rows = 5, picks = 3 } = {}) { - const sorted = [...list].sort(byTag); - const game = sorted.filter((r) => !isLauncher(r.tag_name)); - if (!game.length) return ""; - const lines = game - .slice(0, rows) - .map((r) => { - const p = parts(r); - return `
  • ${escape(p.version)}${escape(p.line || overview(r).text || firstLine(r.body) || "Release notes")}${badge(r)}
  • `; - }) - .join(""); - // What landed last: the "New" items of the newest releases with the highest pull request numbers (a release's - // notes list everything since the release before it, oldest first). - const pr = (item) => Math.max(0, ...[...item.matchAll(/#(\d+)/g)].map((m) => Number(m[1]))); - const seen = new Set(); - const picked = game - .slice(0, rows) - .flatMap((r) => sections(r.body).filter((x) => /^new|what's new/i.test(x.title)).flatMap((x) => x.items).map((raw) => ({ raw, item: plainItem(raw), r }))) - .filter(({ item }) => !seen.has(item) && seen.add(item)) - .sort((a, b) => pr(b.raw) - pr(a.raw)) - .slice(0, picks); - const picks_ = picked - .map(({ item, r }) => `
  • ${inline(item)}

    ${escape(parts(r).version)}
  • `) - .join(""); - return `
      ${lines}
    - ${picks_ ? `

    New lately

      ${picks_}
    ` : ""} -

    Every release, with its full notes

    `; -} - -const ASSET_LABELS = [ - [/linux-x86_64/, "Linux x86-64"], - [/macos-arm64/, "macOS, Apple Silicon"], - [/macos-x86_64/, "macOS, Intel"], - [/windows-x64/, "Windows x64"], -]; -function assetLabel(name) { - if (/^SHA256SUMS/.test(name)) return name.endsWith(".sig") ? "Checksums signature" : "Checksums"; - const os = ASSET_LABELS.find(([re]) => re.test(name)); - const what = /server/.test(name) ? "Server" : /launcher/.test(name) ? "Launcher" : /smoke-test/.test(name) ? "Smoke test" : "Game"; - return os ? `${what}, ${os[1]}` : name; -} -const size = (bytes) => (bytes >= 1e6 ? `${(bytes / 1e6).toFixed(0)} MB` : bytes >= 1e3 ? `${(bytes / 1e3).toFixed(0)} kB` : `${bytes} B`); - -function releaseHtml(r, open) { - const p = parts(r); - const o = overview(r); - const top = highlights(r, 4); - const downloads = (r.assets || []) - .map((a) => `
  • ${escape(assetLabel(a.name))}${escape(a.name)} · ${size(a.size || 0)}
  • `) - .join(""); - return `
    - ${escape(p.version)}${badge(r)}${escape(p.line)} -
    - ${o.text || top.length ? `
    - ${o.text ? `

    ${escape(o.text)}

    ` : ""} - ${top.length ? `
      ${top.map((t) => `
    • ${inline(t)}
    • `).join("")}
    ` : ""} -
    ` : ""} -
    ${markdown(notesText(r.body)) || "

    No notes for this release.

    "}
    - ${downloads ? `

    Downloads

      ${downloads}
    ` : ""} -

    This release on GitHub

    -
    -
    `; -} - -// The Releases page: every release, newest first; the newest few open. -export function releasesPageHtml(list, { open = 3 } = {}) { - const sorted = [...list].sort(byTag); - if (!sorted.length) return ""; - return sorted.map((r, i) => releaseHtml(r, i < open)).join("\n"); -} - -export const releasesUrl = `https://github.com/${REPO}/releases`; diff --git a/website/src/releases.test.mjs b/website/src/releases.test.mjs deleted file mode 100644 index acd1a0965d..0000000000 --- a/website/src/releases.test.mjs +++ /dev/null @@ -1,13 +0,0 @@ -// node --test src/: the release notes as the site shows them. -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { notesText } from "./releases.js"; - -const players = '

    Want to play? Download the Horizon launcher, not the files below

    It installs this version.

    '; - -test("the block sending people to the launcher is left out, the notes stay", () => { - assert.equal(notesText(`${players}\n\n### Fixed\n- A fix (#1)\n`), "### Fixed\n- A fix (#1)\n"); - assert.equal(notesText(`${players}\r\n\r\nRebuilt from the latest main.\r\n`), "Rebuilt from the latest main.\r\n"); - assert.equal(notesText("

    Other HTML

    \n\n### New\n- A thing (#2)\n"), "

    Other HTML

    \n\n### New\n- A thing (#2)\n"); - assert.equal(notesText(undefined), ""); -}); diff --git a/website/src/style.css b/website/src/style.css deleted file mode 100644 index b78a6a8b0e..0000000000 --- a/website/src/style.css +++ /dev/null @@ -1,937 +0,0 @@ -/* Horizon's design language (docs/design-system.md), set as a page. The tokens come from - crates/horizon-theme through src/tokens.css, which build.mjs puts before this file: the - launcher's Blocks palettes, type, spacing and motion. The base below lays out the page; the - "Blocks" section at the end gives it the launcher's blocky, Minecraft-like look. */ - -@font-face { font-family: "Geist"; src: url("/fonts/Geist-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; } -@font-face { font-family: "Geist"; src: url("/fonts/Geist-Medium.woff2") format("woff2"); font-weight: 500; font-display: swap; } -@font-face { font-family: "Geist"; src: url("/fonts/Geist-SemiBold.woff2") format("woff2"); font-weight: 600; font-display: swap; } -@font-face { font-family: "Geist Mono"; src: url("/fonts/GeistMono-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; } - -:root { - --g: clamp(var(--gutter-narrow), 4.5vw, var(--gutter)); - --max: 1440px; - --nav: 72px; -} - -*, *::before, *::after { box-sizing: border-box; } - -html { - background: var(--page); - color: var(--ink); - font: 400 var(--text-body) / var(--text-body-line) var(--font); - font-synthesis: none; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - text-rendering: optimizeLegibility; - scroll-behavior: smooth; - scroll-padding-top: calc(var(--nav) + 16px); - transition: background-color var(--move) var(--ease-move), color var(--move) var(--ease-move); -} -body { margin: 0; min-height: 100vh; overflow-x: clip; } -img { display: block; max-width: 100%; height: auto; } -h1, h2, h3 { margin: 0; font-weight: var(--text-heading-weight); } -p { margin: 0; } -ol, ul { margin: 0; padding: 0; list-style: none; } -::selection { background: var(--accent); color: var(--on-accent); } - -a { color: var(--accent); text-decoration: none; transition: color var(--quick) var(--ease-quick); } -a:hover { color: var(--ink); } -[hidden] { display: none !important; } -/* Links inside running text are underlined, so they don't rely on colour alone. */ -p a:not(.button):not(.link-arrow), li > p a, .prose a { - color: inherit; text-decoration: underline; text-decoration-color: var(--rule); - text-decoration-thickness: 1px; text-underline-offset: 3px; - transition: text-decoration-color var(--quick) var(--ease-quick); -} -p a:not(.button):not(.link-arrow):hover, li > p a:hover, .prose a:hover { text-decoration-color: currentColor; } -:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: var(--radius); } - -code { - font: 0.86em/1.5 var(--mono); - background: var(--plate-soft); - border-radius: 4px; - padding: 1px 5px; - overflow-wrap: anywhere; -} - -.sr-only { - position: absolute; width: 1px; height: 1px; overflow: hidden; - clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; -} -.skip { - position: fixed; left: var(--g); top: 12px; z-index: 50; padding: 8px 14px; - background: var(--ink); color: var(--page); border-radius: var(--radius-pill); - transform: translateY(-200%); -} -.skip:focus { transform: none; } - -.container { width: 100%; max-width: var(--max); margin: 0 auto; padding: 0 var(--g); } - -/* --- Type ----------------------------------------------------------------------------- */ - -.display { - font-size: var(--text-display); line-height: var(--text-display-line); - letter-spacing: var(--text-display-tracking); font-weight: var(--text-display-weight); - text-wrap: balance; -} -.title { - font-size: var(--text-title); line-height: var(--text-title-line); - letter-spacing: var(--text-title-tracking); font-weight: var(--text-title-weight); - text-wrap: balance; -} -.lede { max-width: var(--measure); color: var(--muted); text-wrap: pretty; } -.kicker, .meta, .download-meta, .eyebrow, .row-index, .row-meta, .disclaimer { - font: var(--text-micro-weight) var(--text-micro) / var(--text-micro-line) var(--mono); - letter-spacing: var(--text-micro-tracking); - color: var(--muted); -} -.kicker { display: flex; gap: 16px; text-transform: uppercase; margin-bottom: 24px; } -.kicker span { color: var(--ink); } -.meta { margin-top: 16px; } -.download-meta { margin-top: 12px; } -.download-meta a { color: inherit; } - -/* --- Buttons --------------------------------------------------------------------------- */ - -.button { - display: inline-flex; align-items: center; justify-content: center; gap: 8px; - height: var(--control); padding: 0 16px; border: 0; border-radius: var(--radius-pill); - font: 500 var(--text-label) / 1 var(--font); cursor: pointer; white-space: nowrap; - transition: background-color var(--quick) var(--ease-quick), color var(--quick) var(--ease-quick), opacity var(--quick) var(--ease-quick); -} -.button.large { height: var(--control-large); padding: 0 22px; font-size: var(--text-ui); } -.button.small { height: 30px; padding: 0 14px; } -.button.primary { background: var(--accent); color: var(--on-accent); } -.button.primary:hover { color: var(--on-accent); opacity: 0.88; } -.button.secondary { background: var(--plate-soft); color: var(--ink); } -.button.secondary:hover { background: var(--plate); color: var(--ink); } -.button.ghost { background: transparent; color: var(--muted); } -.button.ghost:hover { background: var(--plate-soft); color: var(--ink); } -.button:active { opacity: 0.85; } - -.link-arrow { display: inline-flex; gap: 8px; color: var(--accent); font-weight: 500; } -.link-arrow span { transition: transform var(--quick) var(--ease-quick); } -.link-arrow:hover span { transform: translateX(3px); } - -/* --- Navigation ------------------------------------------------------------------------- */ - -.nav { - position: fixed; inset: 0 0 auto; z-index: 20; height: var(--nav); max-width: 100vw; - background: color-mix(in srgb, var(--page) 88%, transparent); - -webkit-backdrop-filter: blur(16px) saturate(1.2); backdrop-filter: blur(16px) saturate(1.2); - box-shadow: 0 1px 0 var(--rule); - transition: background-color var(--move) var(--ease-move), box-shadow var(--move) var(--ease-move); -} -.nav-inner { display: flex; align-items: center; gap: 24px; height: 100%; max-width: var(--max); margin: 0 auto; padding: 0 var(--g); } -.nav-mark { display: inline-flex; align-items: center; gap: 10px; color: var(--ink); font-weight: 500; font-size: var(--text-label); } -.nav-mark:hover { color: var(--ink); } -.mark { width: 18px; height: 18px; } -.mark-stem { fill: var(--ink); } -.mark-low { fill: var(--ink); opacity: 0.36; } -.mark-line { fill: var(--accent); } -.nav-links { display: flex; gap: 2px; margin-left: auto; white-space: nowrap; } -.nav-links a, .nav-github, .theme-toggle { - display: inline-flex; align-items: center; height: 32px; padding: 0 12px; - border-radius: var(--radius-pill); color: var(--muted); font-size: var(--text-label); - transition: background-color var(--quick) var(--ease-quick), color var(--quick) var(--ease-quick); -} -.nav-links a:hover, .nav-github:hover, .theme-toggle:hover { color: var(--ink); background: var(--plate-soft); } -.nav-links a[aria-current] { color: var(--ink); background: var(--plate-soft); } -.nav-end { display: flex; align-items: center; gap: 2px; } -.theme-toggle { - border: 0; background: none; cursor: pointer; - font: var(--text-micro) / 1 var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; -} -.nav-end .button { margin-left: 8px; } -.menu-button { - display: none; align-items: center; gap: 10px; height: 44px; padding: 0 4px 0 14px; margin-left: 4px; - border: 0; background: none; color: var(--ink); font: 500 var(--text-label) / 1 var(--font); cursor: pointer; -} -.menu-icon { position: relative; width: 18px; height: 10px; } -.menu-icon::before, .menu-icon::after { - content: ""; position: absolute; left: 0; right: 0; height: 1.5px; border-radius: 1px; background: currentColor; - transition: transform var(--move) var(--ease-move), top var(--move) var(--ease-move); -} -.menu-icon::before { top: 0; } -.menu-icon::after { top: 8px; } -.is-menu .menu-icon::before { top: 4px; transform: rotate(45deg); } -.is-menu .menu-icon::after { top: 4px; transform: rotate(-45deg); } - -.menu { - position: fixed; inset: var(--nav) 0 0; z-index: 19; display: flex; flex-direction: column; max-width: 100vw; - justify-content: space-between; gap: 24px; overflow-y: auto; padding: 24px var(--g) 32px; background: var(--page); - opacity: 0; transition: opacity var(--move) var(--ease-move); -} -.menu.is-open { opacity: 1; } -.menu nav { display: flex; flex-direction: column; flex-shrink: 0; } -.menu nav a { - display: flex; align-items: baseline; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--rule); - color: var(--ink); font-size: var(--text-title); line-height: var(--text-title-line); - letter-spacing: var(--text-title-tracking); font-weight: 500; - opacity: 0; transform: translateY(var(--rise)); - transition: opacity var(--enter) var(--ease-enter), transform var(--enter) var(--ease-enter); -} -.menu.is-open nav a { opacity: 1; transform: none; } -.menu-foot { display: flex; flex-direction: column; gap: 16px; flex-shrink: 0; } -.menu-foot p { font-size: var(--text-caption); color: var(--muted); } - -@media (max-width: 1279px) { - .nav-links, .nav-github { display: none; } - .menu-button { display: inline-flex; } - .nav-end { margin-left: auto; } -} -@media (max-width: 520px) { - .nav-end .button { display: none; } -} - -/* --- Index lists: the signature component ------------------------------------------------ */ - -.index { border-top: 1px solid var(--rule); } -.row { position: relative; border-bottom: 1px solid var(--rule); } -.row > a, .row.card { - position: relative; display: grid; grid-template-columns: var(--index) 1fr auto; align-items: baseline; - column-gap: 16px; padding: 18px 0; color: var(--ink); -} -.row-index { transition: color var(--quick) var(--ease-quick); } -.row-name { font-size: var(--text-title); line-height: var(--text-title-line); letter-spacing: var(--text-title-tracking); font-weight: 500; } -.row-meta { text-align: right; } -/* Hover: a soft plate fades in behind the row and reaches past it; the index turns to ink. */ -.row > a::before, .row.card::before { - content: ""; position: absolute; inset: 0 calc(-1 * var(--plate-bleed)); z-index: -1; - border-radius: var(--radius-plate); background: var(--plate-soft); opacity: 0; - transition: opacity var(--quick) var(--ease-quick); -} -.row > a, .row.card { z-index: 0; } -.row > a:hover::before, .row.card:hover::before { opacity: 1; } -.row > a:hover .row-index, .row.card:hover .row-index { color: var(--ink); } -.row > a:hover, .row.card:hover { color: var(--ink); } - -/* --- Hero -------------------------------------------------------------------------------- */ - -.hero { padding: calc(var(--nav) + clamp(48px, 9vw, 120px)) 0 clamp(64px, 8vw, 112px); } -.hero-title { max-width: 14ch; margin-bottom: 20px; } -.hero .lede { font-size: var(--text-body); } -.hero-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 32px; } -.hero-index { margin-top: clamp(48px, 7vw, 88px); } -.hero .disclaimer { margin-top: 24px; } - -/* --- Sections ------------------------------------------------------------------------------- */ - -.section { padding: clamp(72px, 10vw, 144px) 0; } -.section-head { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px 48px; align-items: end; margin-bottom: 48px; } -.section-head .kicker { grid-column: 1 / -1; margin-bottom: 0; } -.section .title { font-size: var(--text-display); line-height: var(--text-display-line); letter-spacing: var(--text-display-tracking); } - -.statement { - max-width: 28ch; font-size: var(--text-title); line-height: 1.15; - letter-spacing: var(--text-title-tracking); font-weight: 500; text-wrap: balance; -} -.facts { margin-top: 56px; } -.fact, .features li { - display: grid; grid-template-columns: var(--index) minmax(0, 18rem) minmax(0, 1fr); column-gap: 16px; - align-items: baseline; padding: 20px 0; -} -.fact h3, .features h3 { font-size: var(--text-heading); line-height: var(--text-heading-line); letter-spacing: var(--text-heading-tracking); } -.fact p, .features p { max-width: var(--measure); color: var(--muted); } - -/* Screenshots: no frames, the images themselves. */ -.mosaic { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; } -.tile { margin: 0; grid-column: span 2; } -.tile-wide { grid-column: span 4; } -.tile-tall { grid-column: span 2; } -.tile-wide, .tile-tall { grid-row: span 1; } -.zoom { display: block; width: 100%; padding: 0; border: 0; background: var(--plate-soft); border-radius: var(--radius-plate); overflow: hidden; cursor: zoom-in; } -.zoom img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; transition: transform var(--enter) var(--ease-enter); } -.zoom:hover img { transform: scale(1.02); } -figcaption { display: flex; gap: 12px; margin-top: 10px; font-size: var(--text-caption); color: var(--muted); } -figcaption span { font-family: var(--mono); font-size: var(--text-micro); letter-spacing: var(--text-micro-tracking); padding-top: 1px; } - -/* Parity: the count, then every subsystem as an index row. */ -.parity-hero { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; align-items: end; margin-bottom: 64px; } -.count { font-size: clamp(96px, 16vw, 220px); line-height: 0.85; letter-spacing: -0.05em; font-weight: 500; } -.count .digits span { display: inline-block; } -.count-label { margin-top: 20px; max-width: 32ch; color: var(--muted); } -.parity-copy .title { margin-bottom: 16px; } -.prose { color: var(--ink-soft); max-width: var(--measure); } -.prose > * + * { margin-top: 12px; } -.systems { border-top: 1px solid var(--rule); } -.system { - display: grid; grid-template-columns: var(--index) minmax(0, 1fr) auto; column-gap: 16px; row-gap: 6px; - padding: 20px 0; border-bottom: 1px solid var(--rule); position: relative; -} -.system-head { display: contents; } -.system-index { grid-column: 1; grid-row: 1; font: var(--text-micro) / var(--text-micro-line) var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); padding-top: 0.6em; } -.system h3 { grid-column: 2; grid-row: 1; font-size: var(--text-heading); line-height: var(--text-heading-line); letter-spacing: var(--text-heading-tracking); } -.system-state { - grid-column: 3; grid-row: 1; display: inline-flex; align-items: center; gap: 6px; padding-top: 0.5em; - font: var(--text-micro) / var(--text-micro-line) var(--mono); letter-spacing: var(--text-micro-tracking); - text-transform: uppercase; color: var(--muted); -} -.system.is-full .system-state { color: var(--success); } -.check { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 1.6; } -.system-detail { grid-column: 2 / 4; max-width: 72ch; font-size: var(--text-caption); line-height: 1.6; color: var(--muted); } -.gap { color: var(--ink); font-weight: 500; } -.legend { max-width: 80ch; } - -/* The launcher: copy and features on the left, the menus on the right. */ -.launcher-grid { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: clamp(32px, 5vw, 80px); align-items: start; } -.launcher-copy .title { margin-bottom: 16px; } -.launcher-copy .features { margin-top: 40px; } -.features li { grid-template-columns: var(--index) minmax(0, 1fr); row-gap: 4px; } -.features li p { grid-column: 2; font-size: var(--text-ui); } -.launcher-actions { margin-top: 32px; } -.screens { position: sticky; top: calc(var(--nav) + 24px); } -.tabs { position: relative; display: inline-flex; padding: 2px; gap: 2px; margin-bottom: 16px; border-radius: var(--radius-pill); background: var(--plate-soft); } -.tabs button { - position: relative; z-index: 1; height: var(--segment); padding: 0 14px; border: 0; background: none; - border-radius: var(--radius-pill); color: var(--muted); font: 500 var(--text-label) / 1 var(--font); cursor: pointer; - transition: color var(--quick) var(--ease-quick); -} -.tabs button:hover { color: var(--ink); } -.tabs button[aria-selected="true"] { color: var(--page); } -.tab-pill { position: absolute; top: 0; left: 0; border-radius: var(--radius-pill); background: var(--ink); transition: transform var(--move) var(--ease-move), width var(--move) var(--ease-move); } -.screen { position: relative; aspect-ratio: 16 / 10; border-radius: var(--radius-plate); overflow: hidden; background: var(--plate-soft); } -.screen-panel { position: absolute; inset: 0; opacity: 0; transition: opacity var(--move) var(--ease-move); } -.screen-panel.is-active { opacity: 1; } -.screen-panel img { width: 100%; height: 100%; object-fit: cover; } -.screen-caption { margin-top: 12px; font-size: var(--text-caption); color: var(--muted); } - -/* Patch notes. */ -.eyebrow { text-transform: uppercase; } -.prose h2, .prose h3, .prose h4 { font-size: var(--text-heading); line-height: var(--text-heading-line); color: var(--ink); margin-top: 28px; } -.prose ul, .prose ol { padding-left: 18px; list-style: disc; } -.prose li + li { margin-top: 6px; } -.prose li::marker { color: var(--muted); } -.prose strong { color: var(--ink); font-weight: 500; } -.prose pre { padding: 16px; border-radius: var(--radius-plate); background: var(--plate-soft); overflow-x: auto; } -.prose pre code { background: none; padding: 0; } -.release { border-top: 1px solid var(--rule); } -.release:last-of-type { border-bottom: 1px solid var(--rule); } -.release summary { - display: grid; grid-template-columns: minmax(0, 12rem) minmax(0, 1fr) auto; gap: 16px; align-items: baseline; - padding: 18px 0; cursor: pointer; list-style: none; -} -.release summary::-webkit-details-marker { display: none; } -.release-tag { font-size: var(--text-heading); font-weight: 500; letter-spacing: var(--text-heading-tracking); } -.release-sub { color: var(--muted); font-size: var(--text-ui); } -.release time { font: var(--text-micro) / 1 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); } -.release[open] summary .release-tag { color: var(--accent); } -.release .prose { padding: 0 0 24px 12rem; margin-left: 16px; font-size: var(--text-ui); } -.release-more { margin-top: 24px; } - -/* Community. */ -.cards { margin-top: 48px; } -.row.card { grid-template-columns: var(--index) minmax(0, 1fr) minmax(0, 1.4fr) auto; } -.card-title { font-size: var(--text-title); line-height: var(--text-title-line); letter-spacing: var(--text-title-tracking); font-weight: 500; } -.card-text { color: var(--muted); } -.card-arrow { color: var(--muted); transition: transform var(--quick) var(--ease-quick), color var(--quick) var(--ease-quick); } -.card:hover .card-arrow { color: var(--accent); transform: translateX(4px); } -.contributors { margin-top: 96px; } -.contributors-title { font-size: var(--text-heading); line-height: var(--text-heading-line); } -.contributors-text { margin-top: 8px; color: var(--muted); } -.contributors-list { margin-top: 24px; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0 24px; border-top: 1px solid var(--rule); } -.contributor { display: grid; grid-template-columns: 32px 1fr; column-gap: 12px; align-items: center; padding: 14px 0; border-bottom: 1px solid var(--rule); color: var(--ink); } -.contributor-avatar { grid-row: span 2; width: 32px; height: 32px; border-radius: 50%; background: var(--plate); } -.contributor-name { font-weight: 500; } -.contributor-count { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); } -.contributor:hover .contributor-name { color: var(--accent); } - -/* --- Footer: small print, then the giant faint wordmark --------------------------------- */ - -.footer { padding: clamp(72px, 9vw, 120px) 0 0; overflow: hidden; } -.footer-disclaimer { max-width: 24ch; font-size: var(--text-heading); line-height: var(--text-heading-line); letter-spacing: var(--text-heading-tracking); font-weight: 500; } -.footer-cols { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 32px; margin-top: 40px; } -.footer-cols p { font-size: var(--text-caption); line-height: 1.7; color: var(--muted); } -.footer-bar { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-top: 56px; font-size: var(--text-caption); color: var(--muted); } -.footer-links { display: flex; flex-wrap: wrap; gap: 20px; } -.footer-links a { color: var(--muted); } -.footer-links a:hover { color: var(--ink); } -.footer-word { - box-sizing: border-box; width: 100%; margin: 24px auto 0; padding: 0 var(--g); max-width: var(--max); - font-size: var(--text-wordmark); line-height: var(--text-wordmark-line); letter-spacing: var(--text-wordmark-tracking); - font-weight: var(--text-wordmark-weight); color: var(--plate); white-space: nowrap; user-select: none; - translate: -0.05em 0.12em; -} -:root[data-theme="dark"] .footer-word { color: var(--plate-soft); } -@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .footer-word { color: var(--plate-soft); } } - -/* --- Lightbox ---------------------------------------------------------------------------- */ - -.lightbox { width: min(1400px, 94vw); max-height: 92vh; padding: 0; border: 0; border-radius: var(--radius-plate); background: var(--surface); color: var(--ink); } -.lightbox::backdrop { background: color-mix(in srgb, var(--page) 86%, transparent); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); } -.lightbox img { width: 100%; } -.lightbox form { position: absolute; top: 12px; right: 12px; } -.lightbox-caption { padding: 14px 16px; font-size: var(--text-caption); color: var(--muted); } - -/* --- 404 --------------------------------------------------------------------------------- */ - -.lost { min-height: 100vh; display: flex; flex-direction: column; justify-content: space-between; } -.lost .hero { padding-top: clamp(96px, 16vh, 200px); } - -/* --- Motion: content arrives with a fade and a short rise, a few tens of ms apart --------- */ - -.js .reveal, .js .reveal-media { - opacity: 0; transform: translateY(var(--rise)); filter: blur(4px); - transition: opacity var(--enter) var(--ease-enter) var(--d, 0ms), transform var(--enter) var(--ease-enter) var(--d, 0ms), filter var(--enter) var(--ease-enter) var(--d, 0ms); -} -.js .reveal.is-visible, .js .reveal-media.is-visible { opacity: 1; transform: none; filter: none; } -.js .systems .system { opacity: 0; transform: translateY(var(--rise)); transition: opacity var(--enter) var(--ease-enter) var(--d, 0ms), transform var(--enter) var(--ease-enter) var(--d, 0ms); } -.js .systems.is-visible .system { opacity: 1; transform: none; } -@media (prefers-reduced-motion: reduce) { - html { scroll-behavior: auto; } - .js .reveal, .js .reveal-media, .js .systems .system { transform: none; filter: none; transition: opacity 200ms linear; } - .menu nav a { transform: none; } -} - -/* --- Touch: every target at least 44 px high ------------------------------------------- */ - -@media (pointer: coarse) { - .button, .button.large, .button.small, .tabs button { height: 44px; } - .nav-mark, .theme-toggle, .nav-github { min-height: 44px; } - .link-arrow, .footer-links a { display: inline-flex; align-items: center; min-height: 44px; } -} - -/* --- Narrow screens ------------------------------------------------------------------------ */ - -@media (max-width: 900px) { - .section-head, .parity-hero, .launcher-grid { grid-template-columns: minmax(0, 1fr); } - .section-head { gap: 16px; } - .screens { position: static; } - .fact { grid-template-columns: var(--index) minmax(0, 1fr); row-gap: 6px; } - .fact p { grid-column: 2; } - .mosaic { grid-template-columns: minmax(0, 1fr); } - .tile, .tile-wide, .tile-tall { grid-column: auto; } - .row.card { grid-template-columns: var(--index) minmax(0, 1fr) auto; row-gap: 4px; } - .card-text { grid-column: 2; grid-row: 2; font-size: var(--text-ui); } - .card-arrow { grid-row: 1; grid-column: 3; } - .release summary { grid-template-columns: minmax(0, 1fr) auto; } - .release-sub { grid-column: 1 / -1; grid-row: 2; } - .release .prose { padding-left: 0; margin-left: 0; } - .footer-cols { grid-template-columns: minmax(0, 1fr); } - .footer-bar { flex-direction: column; align-items: flex-start; } - .system { grid-template-columns: var(--index) minmax(0, 1fr); } - .system-state { grid-column: 2; grid-row: 2; padding-top: 0; } - .system-detail { grid-column: 2; } - .row > a { grid-template-columns: 28px 1fr auto; padding: 16px 0; } - :root { --index: 28px; } -} - -/* The privacy page: one long read under the hero type. */ -.privacy h1 { margin-bottom: 16px; } -.privacy .prose { margin: 40px 0; } -.privacy .disclaimer { margin-top: 32px; } - -/* --- Documentation (/docs): a left navigation and one page at a time --------------------- */ - -.docs { - display: grid; grid-template-columns: 248px minmax(0, 1fr); gap: clamp(32px, 6vw, 96px); - max-width: var(--max); margin: 0 auto; padding: calc(var(--nav) + 48px) var(--g) 0; align-items: start; -} -.docs-nav-wrap { position: sticky; top: calc(var(--nav) + 24px); max-height: calc(100vh - var(--nav) - 48px); overflow-y: auto; padding-bottom: 24px; } -.docs-nav-wrap summary { display: none; } -.docs-nav-group { margin: 28px 0 8px; font: var(--text-micro) / var(--text-micro-line) var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; color: var(--muted); } -.docs-nav-group:first-child { margin-top: 0; } -.docs-nav li a { display: block; padding: 6px 12px; margin-left: -12px; border-radius: var(--radius); color: var(--ink-soft); font-size: var(--text-ui); } -.docs-nav li a:hover { background: var(--plate-soft); color: var(--ink); } -.docs-nav li a[aria-current] { background: var(--plate-soft); color: var(--ink); font-weight: 500; } -.docs-main { min-width: 0; padding-bottom: 48px; } -.docs-main .title { margin-bottom: 16px; } -.docs-main .lede { max-width: 64ch; font-size: var(--text-heading); line-height: 1.45; } -.docs-toc { margin-top: 28px; max-width: 72ch; } -.docs-toc p { font: var(--text-micro) / var(--text-micro-line) var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; color: var(--muted); margin-bottom: 8px; } -.docs-toc ul { display: flex; flex-wrap: wrap; gap: 6px; } -.docs-toc a { display: block; padding: 4px 12px; border-radius: var(--radius-pill); background: var(--plate-soft); color: var(--ink-soft); font-size: var(--text-label); } -.docs-toc a:hover { background: var(--plate); color: var(--ink); } -.docs-prose { max-width: 72ch; margin-top: 40px; font-size: var(--text-body); } -.docs-prose > * + * { margin-top: 14px; } -.docs-prose h2 { margin-top: 56px; padding-top: 24px; border-top: 1px solid var(--rule); font-size: clamp(24px, 2.4vw, 30px); line-height: 1.2; letter-spacing: var(--text-title-tracking); scroll-margin-top: 80px; } -.docs-prose h2:first-child { margin-top: 0; padding-top: 0; border-top: 0; } -.docs-prose h3 { margin-top: 32px; scroll-margin-top: 80px; } -.docs-prose ol { list-style: decimal; } -.docs-prose pre { font: 0.86em/1.6 var(--mono); } -.docs-prose blockquote { padding: 2px 0 2px 16px; border-left: 2px solid var(--accent); color: var(--ink-soft); } -.docs-prose blockquote > * + * { margin-top: 8px; } -.docs-prose figure { margin: 28px 0; } -.docs-prose figure img { width: 100%; border-radius: var(--radius-plate); box-shadow: 0 0 0 1px var(--rule); } -.docs-prose figcaption { margin-top: 10px; font-size: var(--text-caption); color: var(--muted); } -.docs-pager { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; max-width: 72ch; margin-top: 56px; } -.docs-pager a { display: flex; flex-direction: column; gap: 2px; padding: 14px 16px; border-radius: var(--radius-plate); box-shadow: 0 0 0 1px var(--rule); color: var(--ink); font-weight: 500; } -.docs-pager a:hover { background: var(--plate-soft); color: var(--ink); } -.docs-pager a span { font: var(--text-micro) / var(--text-micro-line) var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; color: var(--muted); font-weight: 400; } -.docs-pager-next { grid-column: 2; text-align: right; } -.docs-edit { margin-top: 32px; font-size: var(--text-caption); color: var(--muted); } -.docs-edit a { color: inherit; text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 3px; } -.docs-main .disclaimer { margin-top: 24px; } - -@media (max-width: 900px) { - .docs { grid-template-columns: minmax(0, 1fr); gap: 0; padding-top: calc(var(--nav) + 12px); } - .docs-nav-wrap { position: static; max-height: none; overflow: visible; margin-bottom: 24px; border-bottom: 1px solid var(--rule); } - .docs-nav-wrap summary { display: flex; align-items: center; min-height: 44px; cursor: pointer; font-weight: 500; list-style: none; } - .docs-nav-wrap summary::-webkit-details-marker { display: none; } - .docs-nav-wrap summary::after { content: "+"; margin-left: auto; color: var(--muted); } - .docs-nav-wrap[open] summary::after { content: "\2212"; } - .docs-pager { grid-template-columns: minmax(0, 1fr); } - .docs-pager-next { grid-column: 1; } -} -@media (pointer: coarse) { - .docs-nav li a, .docs-toc a { display: flex; align-items: center; min-height: 44px; } -} - -/* --- Blocks: the launcher's look on the page (docs/design-system.md, "Blocks") ------------- - Pixel type for headings and controls, square panels with a dark outline, a light bevel on top - and a darker lip below, the violet face for the main action, sunk wells, a stone texture - behind the page and Horizon's pixel landscape behind the hero. The art is drawn in code - (scripts/art.mjs, like launcher/src/art.rs); nothing in it comes from Minecraft. */ - -@font-face { font-family: "Pixelify Sans"; src: url("/fonts/PixelifySans-Medium.woff2") format("woff2"); font-weight: 500; font-display: swap; } -@font-face { font-family: "Pixelify Sans"; src: url("/fonts/PixelifySans-SemiBold.woff2") format("woff2"); font-weight: 600; font-display: swap; } -@font-face { font-family: "Pixelify Sans"; src: url("/fonts/PixelifySans-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; } - -:root { - --stone: url("/media/stone-light.png"); - --scene: url("/media/scene-light.png"); - --sky: #8d7fd8; - --panel-edges: inset 0 var(--block-bevel) 0 var(--bevel), inset 0 calc(-1 * var(--block-bevel)) 0 var(--lip); - --radius: 0px; --radius-plate: 0px; --radius-pill: 0px; -} -:root[data-theme="dark"] { --stone: url("/media/stone-dark.png"); --scene: url("/media/scene-dark.png"); --sky: #0a0f2c; } -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { --stone: url("/media/stone-dark.png"); --scene: url("/media/scene-dark.png"); --sky: #0a0f2c; } -} - -/* The stone: 64 texels of the page colour in close shades, each texel 4 px. */ -body { position: relative; isolation: isolate; } -body::before { - content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none; - background: var(--stone) 0 0 / 256px 256px repeat; image-rendering: pixelated; -} - -/* Pixel type for what names and what you act on; paragraphs stay in Geist. */ -.display, .title, .statement, .row-name, .card-title, .release-tag, .count, -.fact h3, .features h3, .system h3, .contributors-title, .footer-disclaimer, .footer-word, -.nav-mark, .docs-prose h2, .docs-prose h3, .prose h2, .prose h3, .prose h4, .menu nav a { - font-family: var(--pixel); font-weight: 600; letter-spacing: 0; -} -.display, .count, .footer-word { font-weight: 700; } -.nav-mark { font-size: 18px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; } -code { border-radius: 0; background: var(--well); box-shadow: 0 0 0 1px var(--outline); } - -/* Buttons: blocks with an outline, a bevel and a lip; they sink a pixel when pressed. */ -.button { - border: var(--block-outline) solid var(--outline); border-radius: 0; - font: 500 var(--text-ui) / 1 var(--pixel); - box-shadow: inset 0 var(--block-bevel) 0 var(--button-bevel), inset 0 calc(-1 * var(--block-lip)) 0 var(--lip); - padding-bottom: 2px; -} -.button.large { font-size: 18px; } -.button.primary { background: var(--face); color: var(--on-face); box-shadow: inset 0 var(--block-bevel) 0 var(--face-bevel), inset 0 calc(-1 * var(--block-lip)) 0 var(--face-lip); } -.button.primary:hover { opacity: 1; filter: brightness(1.08); } -.button.secondary { background: var(--button); } -.button.secondary:hover { background: var(--button); filter: brightness(1.06); } -.button.ghost { border-color: transparent; box-shadow: none; } -.button:active { opacity: 1; transform: translateY(1px); box-shadow: inset 0 var(--block-bevel) 0 var(--lip); } -:focus-visible { border-radius: 0; } - -/* The top bar: the sidebar colour with an outline under it, tabs with an accent underline. */ -.nav { background: var(--side); -webkit-backdrop-filter: none; backdrop-filter: none; box-shadow: 0 var(--block-outline) 0 var(--outline); } -.nav-links a, .nav-github, .theme-toggle { border-radius: 0; font-family: var(--pixel); font-weight: 500; font-size: 15px; } -.nav-links a:hover, .nav-github:hover, .theme-toggle:hover { background: var(--raised); } -.nav-links a[aria-current] { background: var(--raised); box-shadow: inset 0 -3px 0 var(--accent); } -.theme-toggle { text-transform: none; letter-spacing: 0; } -.menu { background: var(--side); } -.menu nav a { border-bottom: 0; } - -/* Lists become stacks of panels. */ -.index, .systems, .contributors-list { border-top: 0; display: grid; gap: 8px; } -.row, .system, .release, .release:last-of-type, .contributor { border-bottom: 0; border-top: 0; } -.row > a, .row.card, .fact.row, .features li, .system, .release, .contributor, .docs-pager a { - background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); - border-radius: 0; -} -.row > a, .row.card, .fact.row, .features li, .system { padding-left: 20px; padding-right: 20px; } -.row > a::before, .row.card::before { display: none; } -.row > a:hover, .row.card:hover, .contributor:hover, .docs-pager a:hover { background: var(--raised); } -.row > a:hover .row-index { color: var(--accent); } -.row-name { font-size: clamp(20px, 2.2vw, 28px); } -.release { padding: 0 20px; } -.release + .release { margin-top: 8px; } -.release .prose { padding-left: 12rem; } -.contributor { padding: 12px 14px; } -.contributors-list { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; } -.contributor-avatar { border-radius: 0; box-shadow: 0 0 0 2px var(--outline); image-rendering: pixelated; } -.system.is-full .system-state { color: var(--success); } - -/* Screenshots and the launcher's screens: square, outlined. */ -.zoom, .screen, .lightbox, .prose pre, .docs-prose figure img { border-radius: 0; box-shadow: 0 0 0 var(--block-outline) var(--outline); } -.tabs { border-radius: 0; background: var(--well); box-shadow: 0 0 0 var(--block-outline) var(--outline); } -.tabs button { border-radius: 0; font-family: var(--pixel); font-weight: 500; font-size: 15px; } -.tabs button[aria-selected="true"] { color: var(--on-face); } -.tab-pill { border-radius: 0; background: var(--face); box-shadow: inset 0 var(--block-bevel) 0 var(--face-bevel), inset 0 calc(-1 * var(--block-bevel)) 0 var(--face-lip); } - -/* The hero: Horizon's landscape under a veil of the page colour, the words on the left. */ -.hero { - position: relative; isolation: isolate; overflow: hidden; - background: var(--scene) 70% 100% / clamp(1200px, 135vw, 2400px) auto no-repeat, var(--sky); image-rendering: pixelated; - border-bottom: var(--block-outline) solid var(--outline); -} -.hero::before { - content: ""; position: absolute; inset: 0; z-index: -1; - background: - linear-gradient(90deg, color-mix(in srgb, var(--page) 92%, transparent) 0%, color-mix(in srgb, var(--page) 78%, transparent) 45%, color-mix(in srgb, var(--page) 0%, transparent) 78%), - linear-gradient(180deg, color-mix(in srgb, var(--page) 0%, transparent) 55%, color-mix(in srgb, var(--page) 70%, transparent) 100%); -} -.hero .kicker span, .hero .kicker { color: var(--ink-soft); } -.hero .lede { color: var(--ink-soft); } -.hero { padding-bottom: clamp(160px, 18vw, 280px); } -.hero-after { padding-top: 40px; } -.hero-after .hero-index { margin-top: 0; max-width: 860px; } -.hero-after .disclaimer { margin-top: 24px; } -@media (max-width: 900px) { - .hero::before { background: color-mix(in srgb, var(--page) 84%, transparent); } -} - -/* Documentation. */ -.docs-nav li a { border-radius: 0; } -.docs-nav li a[aria-current] { background: var(--raised); box-shadow: inset 4px 0 0 var(--accent); } -.docs-toc a { border-radius: 0; background: var(--button); box-shadow: 0 0 0 var(--block-outline) var(--outline); } -.docs-pager a { box-shadow: var(--panel-edges); } -.docs-prose h2 { border-top: var(--block-outline) solid var(--outline); } -.docs-prose blockquote { border-left: 4px solid var(--accent); background: var(--panel); padding: 10px 16px; box-shadow: 0 0 0 var(--block-outline) var(--outline); } - -/* The footer: the giant wordmark in pixels. */ -.footer { background: var(--side); border-top: var(--block-outline) solid var(--outline); margin-top: 48px; } -.footer-word { color: var(--raised); letter-spacing: 0.02em; } -:root[data-theme="dark"] .footer-word { color: var(--panel); } -@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .footer-word { color: var(--panel); } } - -/* --- Releases and Benchmarks pages, and the home page's "Recently added" ------------------ */ - -/* Pixelify Sans' "fi" ligature draws as "A" ("8 Axes"): no common ligatures anywhere. */ -:root { font-variant-ligatures: no-common-ligatures; } - -.page-main { padding-top: calc(var(--nav) + clamp(40px, 6vw, 80px)); } -.page-title { max-width: 18ch; margin-bottom: 20px; } -.page-main > .container > .lede { font-size: var(--text-body); } -.page-main .disclaimer { margin-top: 48px; } - -/* Release and source badges: small square tags. */ -.badge { - display: inline-flex; align-items: center; height: 22px; padding: 0 8px; margin-right: 10px; vertical-align: 2px; - font: 500 12px / 1 var(--pixel); letter-spacing: 0.02em; text-transform: uppercase; white-space: nowrap; - border: var(--block-outline) solid var(--outline); background: var(--well); color: var(--ink-soft); -} -.badge-game { background: color-mix(in srgb, var(--success) 22%, var(--panel)); color: var(--ink); } -.badge-launcher { background: color-mix(in srgb, var(--accent) 20%, var(--panel)); color: var(--ink); } -.badge-us { background: color-mix(in srgb, var(--accent) 20%, var(--panel)); color: var(--ink); } -.badge-players { background: color-mix(in srgb, var(--success) 22%, var(--panel)); color: var(--ink); } -.badge-estimate { background: color-mix(in srgb, var(--warn) 24%, var(--panel)); color: var(--ink); } - -/* Home: the newest releases on a line each, then what they added. */ -.recent-list .row > a { grid-template-columns: 8.5rem minmax(0, 13rem) minmax(0, 1fr) auto; align-items: baseline; } -.recent-line { color: var(--muted); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.recent-list .row-name { font-size: clamp(18px, 1.8vw, 22px); } -.recent-list .badge { margin-right: 0; } -.recent-picks { margin-top: 32px; } -.recent-picks .eyebrow { margin-bottom: 12px; } -.highlights { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } -.highlight { - display: flex; flex-direction: column; justify-content: space-between; gap: 16px; padding: 18px 20px; - background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); -} -.highlight p { color: var(--ink); } -.highlight-from { font: var(--text-micro) / 1 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); } -.highlight-from:hover { color: var(--accent); } - -/* Releases: a filter, then every release as a panel that opens. */ -.release-filter { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 32px; } -.release-filter .button[aria-pressed="true"], .hub-detail .button[aria-pressed="true"], .button.hub-vote[aria-pressed="true"] { background: var(--face); color: var(--on-face); box-shadow: inset 0 var(--block-bevel) 0 var(--face-bevel), inset 0 calc(-1 * var(--block-lip)) 0 var(--face-lip); } -.release-count { margin: 16px 0 12px; } -.release-list { display: grid; gap: 8px; } -.release-list .release + .release { margin-top: 0; } -.release-entry summary { grid-template-columns: minmax(0, 11rem) minmax(0, 1fr) auto; } -.release-entry .release-sub { display: flex; align-items: baseline; flex-wrap: wrap; row-gap: 4px; } -.release-detail { padding: 0 0 24px 11rem; margin-left: 16px; display: grid; gap: 20px; max-width: calc(11rem + 16px + var(--measure) + 120px); } -.release-detail .prose { padding: 0; margin: 0; font-size: var(--text-ui); max-width: none; } -.release-overview { padding: 16px 18px; background: var(--well); box-shadow: 0 0 0 var(--block-outline) var(--outline); } -.release-counts { font-family: var(--pixel); font-weight: 600; color: var(--ink); margin-bottom: 8px; } -.release-overview ul { list-style: square; padding-left: 18px; color: var(--ink-soft); font-size: var(--text-ui); } -.release-overview li + li { margin-top: 4px; } -.release-downloads ul { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; margin-top: 8px; } -.release-downloads li { display: flex; flex-direction: column; gap: 2px; padding: 10px 12px; background: var(--button); box-shadow: 0 0 0 var(--block-outline) var(--outline); } -.release-downloads li a { font-weight: 500; } -.release-downloads li span { font: var(--text-micro) / 1.4 var(--mono); color: var(--muted); overflow-wrap: anywhere; } -.release-github { font-size: var(--text-caption); } -.release-entry:target { box-shadow: var(--panel-edges), 0 0 0 3px var(--accent); } - -/* Benchmarks. */ -.bench-section { margin-top: clamp(64px, 8vw, 112px); } -.bench-section > .title { margin-bottom: 16px; } -.bench-section > .lede { margin-bottom: 32px; } -.machines { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; } -.machine { min-width: 0; padding: 24px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); } -.machine-head { display: grid; gap: 10px; margin-bottom: 20px; } -.machine-name { font-family: var(--pixel); font-weight: 600; font-size: var(--text-title); line-height: var(--text-title-line); } -.machine-facts { display: flex; flex-wrap: wrap; gap: 6px 28px; margin: 0; } -.machine-facts div { display: flex; gap: 8px; min-width: 0; } -.machine-facts dt { font: var(--text-micro) / 1.6 var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; color: var(--muted); } -.machine-facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; font-size: var(--text-caption); color: var(--ink-soft); } -.machine-note { font-size: var(--text-caption); color: var(--muted); max-width: 90ch; } -/* relative: the screen-reader-only labels inside are absolute and would escape the scroll box otherwise. */ -.table-wrap { position: relative; overflow-x: auto; } -.bench-table { width: 100%; border-collapse: collapse; font-size: var(--text-ui); } -.bench-table th, .bench-table td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--rule); vertical-align: baseline; } -.bench-table thead th { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; color: var(--muted); font-weight: 400; } -.bench-table thead tr:first-child th { color: var(--ink); } -.bench-table tbody th { font-weight: 500; color: var(--ink); } -.bench-table td.num { font-variant-numeric: tabular-nums; white-space: nowrap; } -.bench-table td.wins { color: var(--ink); font-weight: 600; } -.bench-table td.vs { font-size: var(--text-caption); white-space: nowrap; } -.md-table { width: 100%; border-collapse: collapse; font-size: var(--text-ui); } -.md-table th, .md-table td { padding: 8px 12px 8px 0; text-align: left; border-bottom: 1px solid var(--rule); vertical-align: baseline; } -.md-table th { font-weight: 500; color: var(--ink); } -.md-table td code, .md-table th code { overflow-wrap: anywhere; } -.vs-horizon { color: var(--success); } -.vs-java { color: var(--danger); } -.vs-tie, .vs-none { color: var(--muted); } -.capped { font: var(--text-micro) / 1 var(--mono); color: var(--warn); cursor: help; } -.metric-hint { display: block; font: var(--text-micro) / 1.4 var(--mono); color: var(--muted); font-weight: 400; } -.java-wins { margin-top: 16px; font-size: var(--text-caption); color: var(--ink-soft); } -.picker { display: flex; flex-wrap: wrap; gap: 16px 24px; align-items: end; padding: 20px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); } -.picker label { display: grid; gap: 6px; } -.picker label > span, .picker legend { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); text-transform: uppercase; color: var(--muted); } -.picker select { - min-width: min(320px, 80vw); height: var(--control); padding: 0 10px; font: inherit; font-size: var(--text-ui); color: var(--ink); - background: var(--well); border: var(--block-outline) solid var(--outline); border-radius: 0; -} -.picker-rd { display: flex; gap: 16px; align-items: center; border: 0; padding: 0; margin: 0; } -.picker-rd legend { margin-bottom: 6px; } -.picker-rd label { display: inline-flex; align-items: center; gap: 6px; font-family: var(--pixel); font-weight: 500; height: var(--control); } -.picker-rd input { accent-color: var(--accent); width: 18px; height: 18px; } -.pick-result { margin-top: 16px; padding: 20px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); } -.pick-head { margin-bottom: 12px; font-size: var(--text-caption); color: var(--ink-soft); line-height: 1.8; } -.pick-result .meta { margin-top: 12px; } -.pick-notes { margin: 12px 0 0; padding-left: 20px; font-size: var(--text-caption); color: var(--ink-soft); line-height: 1.6; } -.players-hardware th[scope="rowgroup"] { vertical-align: top; } -.empty { color: var(--muted); } -.prose-section .prose { max-width: 80ch; } -.spec-table { margin-top: 24px; } -.spec-table summary { cursor: pointer; font-family: var(--pixel); font-weight: 600; } -.spec-table .bench-table { margin-top: 16px; max-width: 760px; } - -@media (max-width: 900px) { - .recent-list .row > a { grid-template-columns: minmax(0, 1fr) auto; row-gap: 4px; } - .recent-list .row-index { grid-column: 1; grid-row: 1; } - .recent-list .row-name { grid-column: 1; grid-row: 2; } - .recent-line { grid-column: 1 / -1; grid-row: 3; white-space: normal; } - .recent-list .row-meta { grid-column: 2; grid-row: 1; } - .highlights { grid-template-columns: minmax(0, 1fr); } - .release-entry summary { grid-template-columns: minmax(0, 1fr) auto; } - .release-detail { padding-left: 0; margin-left: 0; } - .machine { padding: 16px; } -} - -/* --- The hub (/hub): tabs, the Simple / Technical switch, roadmap items as panels ------------------- */ - -.hub-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px; margin-top: 32px; } -.hub-tabs { margin-bottom: 0; } -.hub-detail { display: flex; gap: 8px; } -/* A link to a tab (/hub#roadmap) lands with the tabs in view under the top bar. */ -.hub-panel { margin-top: 40px; scroll-margin-top: calc(var(--nav) + 120px); } -.js .hub-panel:not(.is-active) { display: none; } -/* Simple shows `plain`, Technical shows `dev`; without the switch (no JavaScript) Simple reads. */ -.hub .hub-dev { display: none; } -:root[data-detail="dev"] .hub .hub-dev { display: block; } -:root[data-detail="dev"] .hub .hub-plain { display: none; } -.hub-lead { max-width: 60ch; font-size: var(--text-heading); line-height: var(--text-heading-line); color: var(--ink); } -.hub-block { margin-top: 56px; } -.hub-block > .title { margin-bottom: 16px; } -.hub-intro { margin-bottom: 16px; color: var(--ink-soft); max-width: var(--measure); } -.hub-rows .row > a { grid-template-columns: var(--index) minmax(0, 1fr) auto; align-items: baseline; } -.hub-row-body { display: grid; gap: 4px; min-width: 0; } -.hub-row-title { font-size: clamp(18px, 1.8vw, 22px); font-weight: 500; color: var(--ink); } -.hub-row-body .hub-plain, .hub-row-body .hub-dev { color: var(--ink-soft); font-size: var(--text-ui); max-width: 80ch; } -.hub-rows .row-meta { max-width: 16rem; } -.hub-section { margin-top: 48px; } -.hub-section-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 20px; } -.hub-items { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); gap: 8px; margin-top: 16px; } -.hub-item { - display: flex; flex-direction: column; gap: 10px; padding: 18px 20px; scroll-margin-top: calc(var(--nav) + 24px); - background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); -} -.hub-item:target { box-shadow: var(--panel-edges), 0 0 0 3px var(--accent); } -.hub-item-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } -.hub-item-meta .badge { margin-right: 0; } -.hub-ongoing { font: var(--text-micro) / 1 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); } -.hub-item-title { font-size: var(--text-heading); line-height: var(--text-heading-line); } -.hub-item .hub-plain, .hub-item .hub-dev { font-size: var(--text-ui); color: var(--ink-soft); } -.hub-progress { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--ink); } -.hub-links { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 4px; } -.hub-link { - display: inline-flex; gap: 6px; align-items: baseline; padding: 3px 7px; font: var(--text-micro) / 1.3 var(--mono); - color: var(--ink-soft); background: var(--well); box-shadow: 0 0 0 var(--block-outline) var(--outline); -} -.hub-link span { color: var(--muted); } -.hub-link:hover { color: var(--accent); } -.hub-link.is-merged { background: color-mix(in srgb, var(--success) 18%, var(--well)); } -.hub-link.is-in-review { background: color-mix(in srgb, var(--accent) 18%, var(--well)); } -.hub-link.is-in-progress { background: color-mix(in srgb, var(--warn) 22%, var(--well)); } -.hub-link.is-merged span, .hub-link.is-in-review span, .hub-link.is-in-progress span { color: var(--ink); } -.hub-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 8px; } -.hub-cards .highlight { justify-content: flex-start; } -.hub-cards h3 { font-size: var(--text-heading); line-height: var(--text-heading-line); } -.hub-cards p { color: var(--ink-soft); font-size: var(--text-ui); } -.hub-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } -.hub-cards .hub-actions { margin-top: auto; } -.hub-rules { list-style: decimal; padding-left: 20px; max-width: var(--measure); } -.hub-rules li + li { margin-top: 10px; } -.hub-rules li::marker { color: var(--muted); font-family: var(--mono); } -.hub-source { margin-top: 40px; max-width: var(--measure); } -.hub-board-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 24px; } -.hub-board-head .hub-intro { margin-bottom: 0; } -.hub-select { display: inline-flex; align-items: center; gap: 10px; } -.hub-select select { - height: 32px; max-width: 22rem; padding: 0 10px; font: var(--text-label) / 1 var(--font); color: var(--ink); - background: var(--well); border: 0; box-shadow: 0 0 0 var(--block-outline) var(--outline); -} -.hub-board { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 24px; align-items: start; } -.hub-column { padding: 14px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); } -.hub-column h3 { display: flex; justify-content: space-between; align-items: baseline; font-size: var(--text-heading); } -.hub-count { font: var(--text-micro) / 1 var(--mono); color: var(--muted); } -.hub-column > .meta { margin: 6px 0 12px; } -.hub-cards-list { display: grid; gap: 6px; } -.js .hub-column:not(.is-open) .hub-card[data-more] { display: none; } -.hub-card a { display: grid; gap: 4px; padding: 10px 12px; background: var(--well); box-shadow: 0 0 0 var(--block-outline) var(--outline); color: var(--ink); } -.hub-card a:hover { box-shadow: 0 0 0 var(--block-outline) var(--accent); } -.hub-card-ref, .hub-card-meta { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); } -.hub-card-title { font-size: var(--text-label); line-height: 1.4; overflow-wrap: anywhere; } -.hub-card-meta { display: flex; flex-wrap: wrap; gap: 4px 10px; } -.hub-card-item { color: var(--accent); } -.hub-more { margin-top: 10px; width: 100%; justify-content: center; } -.hub-journal { display: grid; gap: 8px; margin-top: 24px; max-width: 56rem; } -.hub-entry { display: grid; gap: 10px; padding: 18px 20px; background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); scroll-margin-top: calc(var(--nav) + 24px); } -.hub-entry-week { background: color-mix(in srgb, var(--accent) 8%, var(--panel)); } -.hub-entry-meta { display: flex; align-items: center; gap: 10px; font: var(--text-micro) / 1 var(--mono); color: var(--muted); } -.hub-entry-meta .badge { margin-right: 0; } -.hub-entry-title { font-size: var(--text-heading); line-height: var(--text-heading-line); } -.hub-entry-title a { color: var(--ink); } -.hub-entry-title a:hover { color: var(--accent); } -.hub-entry .hub-plain, .hub-entry .hub-dev { font-size: var(--text-ui); color: var(--ink-soft); } -.hub-entry-items { display: flex; flex-wrap: wrap; gap: 6px; } -.hub-shipped { font-size: var(--text-ui); color: var(--ink); } -.hub-changes { list-style: square; padding-left: 18px; font-size: var(--text-ui); color: var(--ink-soft); } -.hub-changes li + li { margin-top: 4px; } -.hub-ref { font: var(--text-micro) / 1 var(--mono); } -.hub-group { margin-top: 8px; font: 500 var(--text-label) / 1.4 var(--font); } -.hub-group:first-child { margin-top: 0; } -@media (max-width: 1100px) { .hub-board { grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 720px) { - .hub-board { grid-template-columns: minmax(0, 1fr); } - .hub-rows .row > a { grid-template-columns: var(--index) minmax(0, 1fr); } - .hub-rows .row-meta { grid-column: 2; text-align: left; } -} - -/* --- Hub: votes, the report and suggestion forms, the contributors ---------------------- */ -.hub-vote { margin-left: auto; } -.hub-vote-count { margin-left: -2px; } -.hub-vote-count:empty { display: none; } -.hub-form { display: grid; gap: 16px; max-width: 46rem; margin-top: 8px; } -.hub-form-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } -.hub-field { display: grid; gap: 6px; font-size: var(--text-label); color: var(--ink-soft); } -.hub-field input, .hub-field textarea, .hub-field select { - width: 100%; min-height: 36px; padding: 8px 10px; font: var(--text-ui) / 1.4 var(--font); color: var(--ink); - background: var(--well); border: 0; box-shadow: 0 0 0 var(--block-outline) var(--outline); -} -.hub-field textarea { resize: vertical; } -.hub-field input:focus-visible, .hub-field textarea:focus-visible, .hub-field select:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; } -.hub-field input::placeholder, .hub-field textarea::placeholder { color: var(--muted); } -.hub-trap { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; } -.hub-form-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; } -.hub-form-foot [data-status] { color: var(--ink); } -.hub-form-foot .button:disabled { opacity: 0.4; cursor: default; } -.hub-form-closed { color: var(--ink-soft); } -@media (max-width: 720px) { - .hub-form-pair { grid-template-columns: minmax(0, 1fr); } -} - -/* The registry (/mods): a search and the kind filter, rows as panels, then a page per project. */ -.mod-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 24px; } -.mod-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; margin-top: 32px; } -.mod-tools .release-filter { margin-top: 0; } -.mod-search { - height: 36px; min-width: min(320px, 100%); padding: 0 12px; font: var(--text-ui) / 1 var(--sans); color: var(--ink); - background: var(--well); border: var(--block-outline) solid var(--outline); border-radius: 0; -} -.mod-search:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; } -.mod-list { display: grid; gap: 8px; } -.mod-row { - display: grid; grid-template-columns: 48px minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 14px 20px; - background: var(--panel); border: var(--block-outline) solid var(--outline); box-shadow: var(--panel-edges); color: var(--ink); text-decoration: none; -} -.mod-row:hover { background: var(--raised); } -.mod-row:hover .mod-name { color: var(--accent); } -.mod-icon { - display: grid; place-items: center; width: 48px; height: 48px; flex: none; image-rendering: pixelated; - font: 600 22px / 1 var(--pixel); color: var(--ink); background: var(--well); box-shadow: 0 0 0 var(--block-outline) var(--outline); -} -.mod-text { display: grid; gap: 4px; min-width: 0; } -.mod-name { font: 500 var(--text-heading) / 1.2 var(--pixel); } -.mod-summary { color: var(--muted); font-size: var(--text-ui); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.mod-side { display: grid; justify-items: end; gap: 6px; } -.mod-side .badge { margin-right: 0; } -.mod-version { font: var(--text-micro) / 1.4 var(--mono); letter-spacing: var(--text-micro-tracking); color: var(--muted); } -.mod-empty a { color: var(--accent); } -.mod-sort { width: auto; } -.mod-stats { font-size: var(--text-ui); color: var(--muted); white-space: nowrap; } -.mod-stars { color: color-mix(in srgb, var(--muted) 45%, transparent); letter-spacing: 0.08em; } -.mod-stars .on { color: var(--warn); } -.mod-page-stats { margin: 12px 0 0; } -.mod-rate { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 12px; } -.mod-rate:empty { display: none; } -.mod-rate .meta, .mod-rate .mod-error { margin: 0; flex-basis: 100%; } -.mod-rate-label { font-size: var(--text-ui); color: var(--muted); } -.mod-rate-stars { display: inline-flex; } -.mod-star { - padding: 0 3px; border: 0; background: none; cursor: pointer; font-size: 26px; line-height: 1; - color: color-mix(in srgb, var(--muted) 45%, transparent); - transition: color var(--quick) var(--ease-quick); -} -/* The rating's stars are lit; hovering lights a star and those before it. */ -.mod-star.lit, .mod-rate-stars:hover .mod-star { color: var(--warn); } -.mod-rate-stars .mod-star:hover ~ .mod-star { color: color-mix(in srgb, var(--muted) 45%, transparent); } -.mod-star:disabled { cursor: progress; } -.badge-signed { background: color-mix(in srgb, var(--success) 22%, var(--panel)); color: var(--ink); } -.badge-unsigned { background: color-mix(in srgb, var(--warn) 24%, var(--panel)); color: var(--ink); } -.mod-page { display: grid; gap: 16px; max-width: calc(var(--measure) + 240px); } -.mod-page .heading { margin-top: 24px; } -.mod-head { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 24px; align-items: start; } -.mod-owners { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 0; } -.mod-owner { display: inline-flex; align-items: center; gap: 6px; } -.mod-owner img { border-radius: 50%; } -.mod-head .mod-icon { width: 96px; height: 96px; font-size: 44px; } -.mod-head .page-title { margin-top: 0; } -.mod-badges { margin-top: 16px; } -.mod-caps, .mod-steps, .mod-versions { display: grid; gap: 8px; padding-left: 20px; font-size: var(--text-ui); } -.mod-caps li::marker, .mod-steps li::marker { color: var(--accent); } -.mod-file { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 16px; font-size: var(--text-ui); } -.mod-file dt { color: var(--muted); } -.mod-file code, .mod-steps code { font-family: var(--mono); font-size: 13px; overflow-wrap: anywhere; } -.mod-links { display: flex; flex-wrap: wrap; gap: 24px; margin-top: 24px; } -/* /mods/submit */ -.mod-form { display: grid; gap: 24px; max-width: var(--measure); margin-top: 32px; } -.mod-form fieldset { display: grid; gap: 16px; margin: 0; padding: 0; border: 0; } -.mod-form legend { padding: 0; margin-bottom: 8px; } -.mod-field { display: grid; gap: 6px; } -.mod-label { font-size: var(--text-ui); font-weight: 500; } -.mod-form .mod-search { width: 100%; } -.mod-form textarea.mod-search { height: auto; padding: 10px 12px; line-height: 1.5; resize: vertical; } -.mod-check { display: flex; gap: 10px; align-items: flex-start; font-size: var(--text-ui); } -.mod-auth { margin-top: 24px; } -.mod-error { color: var(--danger); font-size: var(--text-ui); } -.js .no-script { display: none; } -@media (max-width: 640px) { - .mod-row { grid-template-columns: 40px minmax(0, 1fr); } - .mod-side { grid-column: 2; justify-items: start; } - .mod-icon { width: 40px; height: 40px; } - .mod-head { grid-template-columns: 1fr; } -} diff --git a/website/src/versions.js b/website/src/versions.js deleted file mode 100644 index b2483b4ed5..0000000000 --- a/website/src/versions.js +++ /dev/null @@ -1,59 +0,0 @@ -// Horizon's release tags (docs/versioning.md). From 0.2 the game is vX.Y.Z and the launcher launcher@X.Y.Z; -// before, vYYYY.MM.DD[.N] and launcher-vX.Y.Z. Every 0.2 tag is newer than every tag from before. Shared by -// build.mjs and main.js, and the same order as the launcher's and tools/release_train.py's. - -const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; -// Semver's majors stay below this; a date tag's first number is a year. -const FIRST_YEAR = 1000; - -const semver = (version) => { - const m = SEMVER.exec(version); - return m && Number(m[1]) < FIRST_YEAR ? m : null; -}; - -export const isGame = (tag) => /^v\d{4}\.\d{1,2}\.\d{1,2}(\.\d+)?$/.test(tag) || (tag.startsWith("v") && !!semver(tag.slice(1))); - -// The version in a launcher tag (launcher@0.2.0, launcher-v0.16.0), or null. -export function launcherVersion(tag) { - if (tag.startsWith("launcher@") && semver(tag.slice(9))) return tag.slice(9); - const old = /^launcher-v(\d+\.\d+\.\d+)$/.exec(tag); - return old ? old[1] : null; -} - -export const isLauncher = (tag) => launcherVersion(tag) !== null; - -// [line, numbers, pre-release]: tags from before 0.2 by their numbers, then 0.2 tags in semver's order. -function key(tag) { - const version = tag.startsWith("launcher@") ? tag.slice(9) : tag.startsWith("v") ? tag.slice(1) : null; - const m = version !== null && semver(version); - if (!m) return [0, (tag.match(/\d+/g) || []).map(Number), []]; - const pre = m[4] ? m[4].split(".").map((id) => (/^\d+$/.test(id) ? [0, Number(id)] : [1, id])) : null; - return [1, [m[1], m[2], m[3]].map(Number), pre]; -} - -const cmpList = (a, b, cmp) => { - for (let i = 0; i < Math.min(a.length, b.length); i++) { - const d = cmp(a[i], b[i]); - if (d) return d; - } - return a.length - b.length; -}; -const cmpNumbers = (a, b) => { - const strip = (l) => { const c = [...l]; while (c.length && c[c.length - 1] === 0) c.pop(); return c; }; - return cmpList(strip(a), strip(b), (x, y) => x - y); -}; -const cmpId = ([ka, va], [kb, vb]) => ka - kb || (ka ? (va < vb ? -1 : va > vb ? 1 : 0) : va - vb); - -// Negative when tag a is older than tag b. -export function compareTags(a, b) { - const [la, na, pa] = key(a); - const [lb, nb, pb] = key(b); - if (la !== lb) return la - lb; - const d = cmpNumbers(na, nb); - if (d || !la) return d; - // A release follows its pre-releases. - if (!pa || !pb) return (pa ? -1 : 0) - (pb ? -1 : 0); - return cmpList(pa, pb, cmpId); -} - -export const newestFirst = (a, b) => compareTags(b, a); diff --git a/website/src/versions.test.mjs b/website/src/versions.test.mjs deleted file mode 100644 index eed334429f..0000000000 --- a/website/src/versions.test.mjs +++ /dev/null @@ -1,21 +0,0 @@ -// node --test website/src/ (npm test in website/) -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { compareTags, isGame, isLauncher, launcherVersion, newestFirst } from "./versions.js"; - -test("0.2 tags follow every tag from before", () => { - const order = ["v2026.10.19.15", "v2026.10.21", "v0.2.0-beta.1", "v0.2.0", "v0.2.1-beta", "v0.2.1-beta.2", - "v0.2.1-beta.11", "v0.2.1-rc.1", "v0.2.1", "v0.2.9", "v0.2.10", "v0.3.0-1", "v0.3.0-alpha", "v0.3.0", "v1.0.0"]; - assert.deepEqual([...order].reverse().sort(compareTags), order); - assert.deepEqual(["launcher-v0.16.0", "launcher@0.2.0", "launcher-v0.17.0", "launcher@0.2.1"].sort(newestFirst), - ["launcher@0.2.1", "launcher@0.2.0", "launcher-v0.17.0", "launcher-v0.16.0"]); - assert.equal(compareTags("v2026.10.19", "v2026.10.19.0"), 0); -}); - -test("game and launcher tags never mix", () => { - for (const tag of ["v0.2.0", "v0.3.0-rc.1", "v2026.10.21", "v2026.10.19.15"]) assert.ok(isGame(tag), tag); - for (const tag of ["launcher@0.2.0", "launcher-v0.16.0", "v0.2", "vnext", "v2026.10.21-x"]) assert.ok(!isGame(tag), tag); - assert.equal(launcherVersion("launcher@0.2.0"), "0.2.0"); - assert.equal(launcherVersion("launcher-v0.16.0"), "0.16.0"); - assert.ok(!isLauncher("v0.2.0") && !isLauncher("launcher-vnext")); -}); diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2682b1f826..b71910da4d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -10,7 +10,7 @@ //! (`cargo build --release -p horizon-client --no-default-features`, without sound or controllers) //! as horizon-engine, with horizon-prepare and horizon-server from beside it and the same sources. //! -//! `tokens`: writes `website/src/tokens.css`, the design tokens of `crates/horizon-theme` as CSS +//! `tokens`: writes `crates/horizon-theme/tokens.css`, the design tokens of `crates/horizon-theme` as CSS //! custom properties (a horizon-theme test fails when the file is out of date). //! //! `icons`: renders the icon masters in `docs/media/brand` (with `rsvg-convert`, from librsvg) @@ -79,9 +79,9 @@ fn git(repo: &Path, args: &[&str]) -> Result, String> { } fn tokens() -> Result<(), String> { - let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../website/src/tokens.css"); + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../crates/horizon-theme/tokens.css"); fs::write(&path, horizon_theme::css()).map_err(|e| format!("{}: {e}", path.display()))?; - println!("wrote website/src/tokens.css"); + println!("wrote crates/horizon-theme/tokens.css"); Ok(()) } @@ -195,7 +195,7 @@ fn icons() -> Result<(), String> { &repo.join("crates/horizon-theme/icons/horizon-64.png"), &render(&square, (64, 64))?, )?; - // Avatars and the website's icons. + // Avatars, and the website's icons (horizon-site copies docs/media/brand/web/ into its public/). let avatar = brand.join("avatar.svg"); for (svg, size, out) in [ ( @@ -203,15 +203,15 @@ fn icons() -> Result<(), String> { 500, "docs/media/brand/social/github-avatar-500.png", ), - (&avatar, 180, "website/public/apple-touch-icon.png"), - (&square, 32, "website/public/favicon-32.png"), + (&avatar, 180, "docs/media/brand/web/apple-touch-icon.png"), + (&square, 32, "docs/media/brand/web/favicon-32.png"), (&square, 1024, "docs/media/brand/icon-1024.png"), (&macos, 1024, "docs/media/brand/icon-macos-1024.png"), ] { write(&repo.join(out), &render(svg, (size, size))?)?; } write( - &repo.join("website/public/favicon.svg"), + &repo.join("docs/media/brand/web/favicon.svg"), &fs::read(&square).map_err(|e| format!("{}: {e}", square.display()))?, )?; println!("wrote packaging/icons, the window icon, the avatars and the website's icons");