Merge pull request #44 from ludovic111/website

Website: playhorizon.dev, in the launcher's design language
This commit is contained in:
Ludovic Marie
2026-10-13 21:01:55 +02:00
committed by GitHub
57 changed files with 3017 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
shots/
+42
View File
@@ -0,0 +1,42 @@
# Serves dist/ on Railway's $PORT. Fingerprinted CSS and JS are cached for a year,
# media for a week, and the page itself is always revalidated. The site lives at
# https://playhorizon.dev: Railway's own *.up.railway.app address redirects there, so the
# page is indexed once. Railway's healthcheck (Host: healthcheck.railway.app) is still served.
{
admin off
auto_https off
persist_config off
}
:{$PORT:8080} {
root * /srv
@railway header_regexp Host ^[^.]+\.up\.railway\.app(:[0-9]+)?$
redir @railway https://playhorizon.dev{uri} permanent
encode zstd gzip
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
X-Frame-Options DENY
Permissions-Policy "interest-cohort=(), browsing-topics=(), camera=(), microphone=(), geolocation=()"
Content-Security-Policy "default-src 'self'; script-src 'self' {$CSP_SCRIPT}; style-src 'self' {$CSP_STYLE}; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.github.com https://raw.githubusercontent.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
-Server
}
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
@media path /media/* /fonts/* /og.png /favicon* /apple-touch-icon.png
header @media Cache-Control "public, max-age=604800"
@pages path / /index.html /404.html
header @pages Cache-Control "no-cache"
@legal path /LEGAL.md /fonts/*.txt
header @legal Content-Type "text/plain; charset=utf-8"
file_server {
hide .csp-script .csp-style og.html
}
handle_errors {
rewrite * /404.html
file_server
}
}
+18
View File
@@ -0,0 +1,18 @@
# Railway builds this with the service's root directory set to `website`.
# Stage 1 renders dist/ from src/ and the README on main (no npm dependencies needed).
FROM node:22-alpine AS build
WORKDIR /site
COPY build.mjs ./
COPY src ./src
COPY public ./public
COPY LEGAL.md ./
ARG GITHUB_TOKEN
RUN node build.mjs
# Stage 2 serves it with Caddy on $PORT.
FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /site/dist /srv
ENV PORT=8080
# The Content-Security-Policy allows the page's inline script and style by the hashes the build wrote.
CMD ["sh", "-c", "CSP_SCRIPT=\"$(cat /srv/.csp-script)\" CSP_STYLE=\"$(cat /srv/.csp-style)\" exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile"]
+76
View File
@@ -0,0 +1,76 @@
# The website's assets and their licences
Every file the site serves, where it comes from and the terms it is used under. The build
copies this file to `/LEGAL.md` on the site.
Horizon is not an official Minecraft product. It is not approved by or associated with
Mojang or Microsoft. Minecraft is a trademark of Mojang Synergies AB.
## Code
| File | Source | Licence |
| --- | --- | --- |
| `src/*.html`, `src/*.css`, `src/*.js`, `build.mjs`, `serve.mjs`, `scripts/*`, `Caddyfile`, `Dockerfile` | Written for this site | MIT, as the rest of Horizon ([LICENSE](https://github.com/ludovic111/horizon/blob/main/LICENSE)) |
The site loads no third-party code at runtime: no frameworks, CDNs, analytics, trackers or
cookies. The only requests to other hosts are the browser's calls to `api.github.com`
(releases and patch notes) and `raw.githubusercontent.com` (the README's roadmap and
`launcher/links.json`), both on Horizon's own repository.
## Fonts
| File | Source | Licence |
| --- | --- | --- |
| `public/fonts/Geist-Regular.woff2`, `Geist-Medium.woff2`, `Geist-SemiBold.woff2`, `GeistMono-Regular.woff2` | Geist and Geist Mono, from `crates/horizon-theme/fonts` (converted from TTF to WOFF2) | SIL Open Font License 1.1, shipped as `public/fonts/OFL-Geist.txt` |
| `public/fonts/InstrumentSerif-Regular.woff2` | Instrument Serif, from `crates/horizon-theme/fonts` (converted from TTF to WOFF2) | SIL Open Font License 1.1, shipped as `public/fonts/OFL-InstrumentSerif.txt` |
The fonts are self-hosted; nothing is fetched from a font service.
## Icons and the social preview
| File | Source | Licence |
| --- | --- | --- |
| `public/favicon.svg`, `public/favicon-32.png`, `public/apple-touch-icon.png` | Horizon's logo, `docs/media/logo.svg` (an original drawing of a sunset over blocks) | MIT, Horizon's original work |
| `public/og.png` | Rendered by `scripts/shots.mjs` from `public/og.html`: the Instrument Serif wordmark over `public/media/hero-1200.webp` | MIT for the layout; the fonts and the hero as listed here |
No Minecraft logo or wordmark appears anywhere on the site, in images or as text styling.
GitHub and Discord appear only as plain text links, without their logos.
## The hero
| File | Source | Licence |
| --- | --- | --- |
| `public/media/hero.webp`, `hero-1200.webp`, `hero-portrait.webp` | `docs/media/sunset.jpg`, darkened slightly and, for phones, cropped by `scripts/assets.mjs` | A screenshot of Horizon's client, as listed under Screenshots |
The grain over the hero is drawn by the stylesheet (an SVG noise filter inlined as a data URI),
not an image file. The launcher's own background (`launcher/assets/background.jpg`, from
backgrounds.supply) is **not** used: its licence is unconfirmed. For the same reason the
launcher's screenshots, which show it, are not on the site; the launcher section shows
Horizon's own menus instead. No image from backgrounds.supply or prismal is used anywhere.
## Screenshots
All captured in Horizon's Rust client, with vanilla resources prepared from the maintainer's
own copy of Minecraft: Java Edition 26.3, and no mods or shaders. Converted to WebP at 1600
and 800 pixels wide by `scripts/assets.mjs`.
| Files in `public/media/` | Source | What it shows |
| --- | --- | --- |
| `menu-title*.webp` | `docs/media/menu-title.png` | Horizon's own title screen (no Minecraft logo or splash text) |
| `menu-worlds*.webp` | `docs/media/menu-worlds.png` | Horizon's own world list |
| `menu-options*.webp` | `docs/media/menu-options.png` | Horizon's own options screen |
| `menu-pause*.webp` | `docs/media/menu-pause.png` | Horizon's own pause screen |
| `settings*.webp` | `docs/media/settings-simple.png` | Horizon's own Video settings, Simple view |
| `hero*.webp`, `sunset*.webp`, `village*.webp`, `survival*.webp`, `nether*.webp`, `night*.webp`, `underwater*.webp` | `docs/media/*.jpg` | In-game views; none shows a Mojang logo or splash text |
Horizon's interface in these images is Horizon's original work (MIT). The block, mob and
item textures and the HUD sprites drawn in them belong to Mojang and Microsoft; they are
shown as screenshots of the game, as the
[Minecraft Usage Guidelines](https://www.minecraft.net/en-us/usage-guidelines) allow for
fan websites about the game, with the disclaimer they require. The site offers no game
files: players need their own legally obtained copy of Minecraft: Java Edition.
## Text
The parity table and the roadmap are generated from Horizon's README, and the patch notes
come from Horizon's GitHub releases: Horizon's own text, MIT.
+95
View File
@@ -0,0 +1,95 @@
# Horizon's website
Live at **https://playhorizon.dev**.
One static page in the launcher's design language rather than its layout: the palette and
violet accent of `crates/horizon-theme`, Instrument Serif for display type and Geist for the
rest, 1px dividers and short, eased motion. A full-viewport hero over Horizon's own sunset,
then a scroll through what it is, the screenshots, the parity grid, the launcher, the patch
notes, the roadmap and the community links. Plain HTML, CSS and JavaScript; no framework and
no runtime dependencies.
## Layout
```
build.mjs renders dist/ (no dependencies)
serve.mjs a tiny static server for local checks
src/index.html the page, with {{placeholders}} build.mjs fills
src/404.html the not-found page
src/style.css the design tokens and every style
src/main.js download buttons, patch notes, roadmap, links, nav and menu, tabs, lightbox, motion
src/markdown.js a small Markdown renderer (escapes everything), shared by build and page
src/releases.js the patch notes' markup, shared by build and page
public/ fonts, screenshots, the hero, icons, og.png, robots.txt, sitemap.xml: copied as is
scripts/assets.mjs rebuilds public/ from the repo (fonts to WOFF2, images to WebP)
scripts/shots.mjs Playwright screenshots at every width, and og.png
Dockerfile, Caddyfile, railway.json the Railway service
LEGAL.md every asset, its source and licence
shots/ (gitignored) full-page screenshots at 360–1920 px, a 1250×700 window, the open phone menu
```
## What comes from where
- **Built in** (`build.mjs`): the parity table, its score, harness count and audit date,
and the roadmap, all from `README.md`; the Discord and Sponsor links from
`launcher/links.json`; the latest releases, when GitHub answers. Locally these come from
the checkout; on Railway, which builds only this folder, from `main` on GitHub. The build
fails if the README has no parity table or roadmap, so a broken README never deploys.
- **Fetched by the page**: the releases (the download button picks the newest
`launcher-v*` release's asset for the visitor's OS, and the three latest game releases'
notes are rendered), the roadmap from the raw README on `main`, and `links.json`. Each
falls back to what was built in, and the download button to the releases page.
## Run it locally
```sh
cd website
node build.mjs && node serve.mjs # http://localhost:4173
```
Asset and screenshot scripts need the dev dependencies (sharp, wawoff2, Playwright):
```sh
npm install
GITHUB_TOKEN=$(gh auth token) node build.mjs # optional token: bakes the releases past the API rate limit
npm run assets # after a font, screenshot or logo changes in the repo
npm run shots # shots/*.png (gitignored; SHOTS=dir to write elsewhere) and public/og.png
```
The screenshots are not committed: full-page PNGs at a dozen widths weigh about 17 MB.
Running text stays under 80 characters a line; on 360–430 px phones the narrowest paragraphs
fall to about 26–35, which is what that width allows. `npm run shots` also reports horizontal overflow, tap targets under 44 px on touch widths
and characters per line, for 360 to 1920 px wide and a 1250×700 window.
## Railway
The service deploys the `website` branch and answers on https://playhorizon.dev. Its
`*.up.railway.app` address redirects there with a 301 (the Caddyfile), so search engines index
one URL; the page also names it as canonical, in its Open Graph tags and in `sitemap.xml`.
Railway's healthcheck reaches `$PORT` under its own host name and is served normally.
Create a service from the GitHub repo with **root directory `website`**. `railway.json`
selects the Dockerfile: a Node stage runs `build.mjs` (reading the README from `main`), and
a Caddy stage serves `dist/` on `$PORT` with compression, cache headers, a strict
Content-Security-Policy and the 404 page. Its watch patterns also redeploy when the README
or `launcher/links.json` change on main. No variables are needed; an optional
`GITHUB_TOKEN` (read-only, public repos) lets the build bake the releases even when
GitHub's anonymous rate limit is hit.
## The hero
`public/media/hero*.webp` is Horizon's sunset over a savanna river valley
(`docs/media/sunset.jpg`), darkened a little by `scripts/assets.mjs`, with a portrait crop for
phones. The launcher's prismal background is not used, and the launcher's screenshots are left
out because they show it: its licence (backgrounds.supply) is unconfirmed. The launcher section
shows Horizon's own menus and settings instead; see LEGAL.md.
## Rules the site keeps
- The disclaimer, "Not an official Minecraft product. Not approved by or associated with
Mojang or Microsoft.", is in the hero, the phone menu, the footer and the 404 page.
- No Minecraft logo or wordmark; "Minecraft" appears only descriptively ("for Minecraft:
Java Edition 26.3"). No game files are offered or linked.
- Parity is worded as the README words it: 100% of what the harnesses compare.
- No cookies, analytics, trackers or third-party fonts and scripts.
- Motion stops under `prefers-reduced-motion`; nothing hijacks the scroll.
+186
View File
@@ -0,0 +1,186 @@
// Builds the site into dist/: src/index.html with the README's parity table, roadmap and
// the community links baked in, plus public/ and fingerprinted CSS and JS. No dependencies.
//
// Railway builds only website/, so the README and links.json come from the repo when they
// are next to it (a local checkout) and from main on GitHub otherwise. The latest releases
// are baked in when GitHub answers, so the page reads well before (or without) JavaScript.
import { readFile, writeFile, mkdir, rm, cp } from "node:fs/promises";
import { existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { markdown, inline, section, escape } from "./src/markdown.js";
import { releasesHtml } from "./src/releases.js";
const site = dirname(fileURLToPath(import.meta.url));
const dist = join(site, "dist");
const REPO = "ludovic111/horizon";
const RAW = `https://raw.githubusercontent.com/${REPO}/main/`;
const DEFAULT_LINKS = { discord: "https://discord.gg/HcTeMZxe5E", sponsor: "https://github.com/sponsors/ludovic111" };
async function repoFile(path) {
const local = join(site, "..", path);
if (existsSync(local)) return [await readFile(local, "utf8"), "the checkout"];
const res = await fetch(RAW + path, { signal: AbortSignal.timeout(15000) });
if (!res.ok) throw new Error(`${path}: ${res.status} from GitHub`);
return [await res.text(), "main on GitHub"];
}
// The README: required, a site with a stale or missing parity table is worse than no deploy.
const [readme, readmeFrom] = await repoFile("README.md");
let links = { ...DEFAULT_LINKS };
try {
const parsed = JSON.parse((await repoFile("launcher/links.json"))[0]);
for (const key of Object.keys(links)) if (typeof parsed[key] === "string") links[key] = parsed[key].trim();
} catch (e) {
console.warn(`links.json: ${e.message}; using the built-in links`);
}
let releases = [];
try {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=30`, {
// An optional token (a Railway variable, or `gh auth token` locally) lifts the rate limit.
headers: { Accept: "application/vnd.github+json", ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}) },
signal: AbortSignal.timeout(15000),
});
if (res.ok) releases = (await res.json()).filter((r) => !r.draft);
else console.warn(`releases: ${res.status} from GitHub; the page will load them itself`);
} catch (e) {
console.warn(`releases: ${e.message}; the page will load them itself`);
}
// --- The parity table ------------------------------------------------------------------
const status = section(readme, "Project status") ?? "";
const parityHeading = (status.match(/^###\s+(Java parity:\s*[\d.]+%)/m) || [])[1];
if (!parityHeading) throw new Error("README: no '### Java parity: N%' heading under 'Project status'");
const percent = parityHeading.match(/[\d.]+%/)[0];
const parityBody = section(readme, parityHeading) ?? "";
const rows = parityBody
.split("\n")
.filter((l) => /^\|/.test(l) && !/^\|\s*-/.test(l))
.map((l) =>
l
.replace(/^\|/, "")
.replace(/\|\s*$/, "")
.split("|")
.map((c) => c.trim()),
);
const [, ...body] = rows; // the first row is the header
if (body.length < 5) throw new Error("README: the parity table has fewer than five rows");
const harnesses = (readme.match(/all (\d[\d,]*) (?:parity )?harnesses/) || [])[1];
if (!harnesses) throw new Error("README: no 'all N harnesses' count");
const audited = (parityBody.match(/Last audited\s+(\d{4}-\d{2}-\d{2})/) || [])[1] ?? "";
const intro = status.split(/^###/m)[0].trim();
const scoreLabel = (s) => ({ 0: "Absent", 0.25: "Started", 0.5: "Playable", 0.75: "Mostly complete", 1: "Complete" })[Number(s)] ?? s;
const check = `<svg class="check" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7"/></svg>`;
const parityCells = body
.map(([name, score, gaps], i) => {
// "None: what matches" reads as the evidence; anything else is a gap and says so.
const none = gaps.match(/^None:\s*(.*)$/);
const full = Number(score) === 1;
const detail = none ? inline(none[1].charAt(0).toUpperCase() + none[1].slice(1)) : `<span class="gap">Gaps:</span> ${inline(gaps)}`;
const state = full ? `${check}Matches vanilla` : `${escape(score)} · ${escape(scoreLabel(score))}`;
return `<li class="system${full ? " is-full" : ""}">
<p class="system-head"><span class="system-index">${String(i + 1).padStart(2, "0")}</span><span class="system-state">${state}</span></p>
<h3>${inline(name)}</h3>
<p class="system-detail">${detail}</p>
</li>`;
})
.join("\n");
const matching = body.filter(([, score]) => Number(score) === 1).length;
const parityHeadline = matching === body.length ? "Every subsystem matches vanilla." : `${matching} of ${body.length} subsystems match vanilla.`;
// --- The roadmap and the latest notes -------------------------------------------------
const roadmap = section(readme, "Roadmap");
if (!roadmap) throw new Error("README: no Roadmap section");
const isGame = (tag) => /^v\d{4}\.\d{1,2}\.\d{1,2}/.test(tag);
const isLauncher = (tag) => tag.startsWith("launcher-v");
const game = releases.filter((r) => isGame(r.tag_name)).slice(0, 3);
const launcher = releases.find((r) => isLauncher(r.tag_name) && !r.prerelease);
// --- The page --------------------------------------------------------------------------
const fingerprint = (text) => createHash("sha256").update(text).digest("hex").slice(0, 10);
await rm(dist, { recursive: true, force: true });
await mkdir(join(dist, "assets"), { recursive: true });
await cp(join(site, "public"), dist, { recursive: true });
await cp(join(site, "LEGAL.md"), join(dist, "LEGAL.md"));
// The page's modules, fingerprinted; each import of a shared module is rewritten to its name.
const names = {};
const rewrite = (code) => code.replace(/"\.\/(\w+\.js)"/g, (all, file) => (names[file] ? `"./${names[file]}"` : all));
for (const file of ["markdown.js", "releases.js", "main.js"]) {
const code = rewrite(await readFile(join(site, "src", file), "utf8"));
names[file] = `${file.replace(".js", "")}.${fingerprint(code)}.js`;
await writeFile(join(dist, "assets", names[file]), code);
}
const jsName = names["main.js"];
const css = await readFile(join(site, "src/style.css"), "utf8");
// The stylesheet is inlined into the page, so the first paint needs no second request.
const cssName = `style.${fingerprint(css)}.css`;
await writeFile(join(dist, "assets", cssName), css);
const inlineCss = css.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s*\n\s*/g, "\n").trim();
const baked = {
links,
releases: releases.slice(0, 12).map((r) => ({
tag_name: r.tag_name,
name: r.name,
published_at: r.published_at,
html_url: r.html_url,
prerelease: r.prerelease,
body: isGame(r.tag_name) ? r.body : "",
assets: r.assets.map((a) => ({ name: a.name, browser_download_url: a.browser_download_url, size: a.size })),
})),
};
const notesHtml =
releasesHtml(game) ||
`<p class="lede">The release notes could not be loaded here.</p><a class="button secondary" href="https://github.com/${REPO}/releases">Read them on GitHub</a>`;
const values = {
CSS: `/assets/${cssName}`,
STYLE: inlineCss,
JS: `/assets/${jsName}`,
PERCENT: percent,
HARNESSES: harnesses,
AUDITED: audited,
PARITY_INTRO: markdown(intro.replace("The table below is the project's own audit", "Below is the project's own audit")),
PARITY_CELLS: parityCells,
PARITY_HEADLINE: parityHeadline,
SUBSYSTEMS: String(body.length),
ROADMAP: markdown(roadmap, { shift: 1 }),
NOTES: notesHtml,
LAUNCHER_META: `${launcher ? `Launcher ${escape(launcher.tag_name.replace("launcher-v", ""))} for` : "For"} Linux x86-64, macOS on Apple Silicon and Windows x64.`,
DISCORD: escape(links.discord || DEFAULT_LINKS.discord),
SPONSOR: escape(links.sponsor || DEFAULT_LINKS.sponsor),
BAKED: JSON.stringify(baked).replace(/</g, "\\u003c"),
YEAR: String(new Date().getUTCFullYear()),
};
for (const page of ["index.html", "404.html"]) {
let html = await readFile(join(site, "src", page), "utf8");
html = html.replace(/\{\{(\w+)\}\}/g, (all, key) => {
if (!(key in values)) throw new Error(`${page}: unknown placeholder ${all}`);
return values[key];
});
await writeFile(join(dist, page), html);
}
// The page's inline script (it marks the page as scripted before CSS applies) and its
// inlined stylesheet: the Content-Security-Policy in the Caddyfile allows them by hash.
const indexHtml = await readFile(join(dist, "index.html"), "utf8");
const hashes = (re) => [...new Set([...indexHtml.matchAll(re)].map((m) => `'sha256-${createHash("sha256").update(m[1]).digest("base64")}'`))].join(" ");
await writeFile(join(dist, ".csp-script"), hashes(/<script>([\s\S]*?)<\/script>/g));
await writeFile(join(dist, ".csp-style"), hashes(/<style>([\s\S]*?)<\/style>/g));
console.log(
`built dist/ from ${readmeFrom}: ${body.length} subsystems, ${percent}, ${harnesses} harnesses, ` +
`${game.length} game releases baked${launcher ? `, launcher ${launcher.tag_name}` : ""}`,
);
+642
View File
@@ -0,0 +1,642 @@
{
"name": "horizon-website",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "horizon-website",
"devDependencies": {
"playwright": "^1.56.0",
"sharp": "^0.34.4",
"wawoff2": "^2.0.1"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/wawoff2": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/wawoff2/-/wawoff2-2.0.1.tgz",
"integrity": "sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"woff2_compress.js": "bin/woff2_compress.js",
"woff2_decompress.js": "bin/woff2_decompress.js"
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "horizon-website",
"private": true,
"type": "module",
"description": "horizon's website: a static page in the launcher's design",
"scripts": {
"build": "node build.mjs",
"start": "node serve.mjs",
"dev": "node build.mjs && node serve.mjs",
"assets": "node scripts/assets.mjs",
"shots": "node scripts/shots.mjs"
},
"devDependencies": {
"sharp": "^0.34.4",
"wawoff2": "^2.0.1",
"playwright": "^1.56.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+62
View File
@@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<title>Horizon</title>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0A0F2C"/>
<stop offset="0.38" stop-color="#26206A"/>
<stop offset="0.62" stop-color="#7C3A88"/>
<stop offset="0.78" stop-color="#EE6E5A"/>
<stop offset="0.9" stop-color="#FFB25E"/>
</linearGradient>
<linearGradient id="sun" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFF6CF"/>
<stop offset="0.5" stop-color="#FFD272"/>
<stop offset="1" stop-color="#FF9A44"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.6" r="0.55">
<stop offset="0" stop-color="#FFC874" stop-opacity="0.6"/>
<stop offset="0.6" stop-color="#FF8A5C" stop-opacity="0.12"/>
<stop offset="1" stop-color="#FF8A5C" stop-opacity="0"/>
</radialGradient>
<linearGradient id="far" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#4A2F7E"/>
<stop offset="1" stop-color="#2B1F5E"/>
</linearGradient>
<linearGradient id="near" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#151A45"/>
<stop offset="1" stop-color="#080B22"/>
</linearGradient>
<clipPath id="tile"><rect width="512" height="512" rx="112"/></clipPath>
<clipPath id="sunCut"><rect x="0" y="0" width="512" height="352"/></clipPath>
<clipPath id="sunDisc"><circle cx="256" cy="300" r="136"/></clipPath>
</defs>
<g clip-path="url(#tile)">
<rect width="512" height="512" fill="url(#sky)"/>
<ellipse cx="256" cy="320" rx="300" ry="230" fill="url(#glow)"/>
<g clip-path="url(#sunCut)">
<circle cx="256" cy="300" r="136" fill="url(#sun)"/>
<g clip-path="url(#sunDisc)">
<rect x="100" y="262" width="312" height="8" fill="#EE6E5A" opacity="0.5"/>
<rect x="100" y="284" width="312" height="11" fill="#EE6E5A" opacity="0.62"/>
<rect x="100" y="310" width="312" height="14" fill="#EE6E5A" opacity="0.74"/>
</g>
</g>
<path fill="url(#far)" d="M0 320H64V288H128V320H352V288H416V256H512V512H0Z"/>
<path fill="url(#near)" d="M0 384H64V352H192V384H320V352H384V320H448V352H512V512H0Z"/>
<g fill="#FFFFFF" opacity="0.07">
<rect x="64" y="352" width="4" height="160"/>
<rect x="192" y="352" width="4" height="160"/>
<rect x="320" y="352" width="4" height="160"/>
<rect x="384" y="320" width="4" height="192"/>
<rect x="448" y="320" width="4" height="192"/>
<rect x="0" y="448" width="512" height="4"/>
</g>
<rect x="0" y="384" width="64" height="4" fill="#FFB25E" opacity="0.25"/>
<rect x="64" y="352" width="128" height="4" fill="#FFB25E" opacity="0.35"/>
<rect x="192" y="384" width="128" height="4" fill="#FFB25E" opacity="0.25"/>
<rect x="320" y="352" width="64" height="4" fill="#FFB25E" opacity="0.35"/>
<rect x="384" y="320" width="64" height="4" fill="#FFB25E" opacity="0.45"/>
<rect x="448" y="352" width="64" height="4" fill="#FFB25E" opacity="0.35"/>
</g>
<rect x="2" y="2" width="508" height="508" rx="110" fill="none" stroke="#FFFFFF" stroke-opacity="0.1" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<!-- The social preview, rendered to og.png by scripts/shots.mjs. -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="robots" content="noindex">
<style>
@font-face { font-family: "Instrument Serif"; src: url("/fonts/InstrumentSerif-Regular.woff2"); }
@font-face { font-family: "Geist"; src: url("/fonts/Geist-Regular.woff2"); }
html, body { margin: 0; width: 1200px; height: 630px; overflow: hidden; background: #111113; color: #e8e8ea; font-family: "Geist"; }
.bg { position: absolute; inset: 0; background:
linear-gradient(180deg, rgba(17,17,19,.55) 0%, rgba(17,17,19,0) 30%),
linear-gradient(180deg, rgba(17,17,19,0) 35%, rgba(17,17,19,.7) 68%, #111113 100%),
linear-gradient(180deg, rgba(62,40,120,.22), rgba(62,40,120,0) 50%),
url("/media/hero-1200.webp") center 40% / cover; }
main { position: absolute; left: 0; right: 0; top: 232px; text-align: center; }
p.eyebrow { margin: 0 0 14px; font-size: 22px; color: rgba(232,232,234,.75); }
h1 { margin: 0; font: 400 196px/0.86 "Instrument Serif"; letter-spacing: -0.035em; color: #f5f5f6; }
p.line { margin: 26px auto 0; font-size: 28px; line-height: 1.4; color: rgba(232,232,234,.85); max-width: 860px; }
footer { position: absolute; left: 72px; right: 72px; bottom: 40px; display: flex; justify-content: space-between; font-size: 17px; color: #b8b8be; border-top: 1px solid rgba(255,255,255,.1); padding-top: 18px; }
</style>
</head>
<body>
<div class="bg"></div>
<main>
<h1>Horizon</h1>
<p class="line">A Rust rewrite for Minecraft: Java Edition 26.3,<br>checked against vanilla by differential harnesses.</p>
</main>
<footer><span>Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.</span><span>playhorizon.dev</span></footer>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 585 KiB

+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://playhorizon.dev/sitemap.xml
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://playhorizon.dev/</loc></url>
</urlset>
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile",
"watchPatterns": ["/website/**", "/README.md", "/launcher/links.json"]
},
"deploy": {
"healthcheckPath": "/",
"healthcheckTimeout": 60,
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 5
}
}
+67
View File
@@ -0,0 +1,67 @@
// Copies the launcher's fonts and the repo's screenshots into public/,
// 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";
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",
"InstrumentSerif-Regular.ttf",
];
// The launcher's screenshots are left out: they show its background, whose licence is
// unconfirmed (see LEGAL.md). [source, name]; every image becomes media/<name>.webp and media/<name>-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/menu-title.png", "menu-title"],
["docs/media/menu-worlds.png", "menu-worlds"],
["docs/media/menu-options.png", "menu-options"],
["docs/media/menu-pause.png", "menu-pause"],
["docs/media/settings-simple.png", "settings"],
];
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-InstrumentSerif.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 hero: Horizon's sunset over a savanna river valley, darkened and cooled a little so the
// wordmark and the violet accent sit on it, in a landscape and a portrait crop.
const hero = () => sharp(join(repo, "docs/media/sunset.jpg")).modulate({ brightness: 0.82, saturation: 0.86 }).linear(1.04, -6);
await hero().webp({ quality: 74 }).toFile(join(pub, "media/hero.webp"));
await hero().resize({ width: 1200 }).webp({ quality: 72 }).toFile(join(pub, "media/hero-1200.webp"));
await hero().extract({ left: 470, top: 0, width: 840, height: 1080 }).resize({ width: 720 }).webp({ quality: 72 }).toFile(join(pub, "media/hero-portrait.webp"));
// Icons from the project's logo.
await copyFile(join(repo, "docs/media/logo.svg"), join(pub, "favicon.svg"));
await sharp(join(repo, "docs/media/logo.svg")).resize(180).png().toFile(join(pub, "apple-touch-icon.png"));
await sharp(join(repo, "docs/media/logo.svg")).resize(32).png().toFile(join(pub, "favicon-32.png"));
console.log("assets written to public/");
+102
View File
@@ -0,0 +1,102 @@
// 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);
// A full-page capture draws the fixed bar once, at the top, over the hero: show it as the
// hero sees it, not in its scrolled state.
await page.evaluate(() => document.querySelector(".nav").classList.remove("is-scrolled"));
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;
}
+25
View File
@@ -0,0 +1,25 @@
// 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" };
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.endsWith("/")) path += "index.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}`));
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Not found · Horizon</title>
<meta name="robots" content="noindex">
<meta name="theme-color" content="#111113">
<meta name="color-scheme" content="dark">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="{{CSS}}">
</head>
<body class="lost">
<main id="main" class="hero">
<div class="hero-media" aria-hidden="true">
<div class="hero-parallax">
<picture>
<source media="(orientation: portrait)" srcset="/media/hero-portrait.webp">
<img src="/media/hero-1200.webp" width="1200" height="675" alt="">
</picture>
</div>
<div class="hero-veil"></div>
</div>
<div class="hero-content">
<p class="eyebrow hero-eyebrow">404</p>
<h1 class="hero-title">Not here</h1>
<p class="hero-line">This page doesn't exist. The rest of Horizon is one page away.</p>
<div class="hero-actions"><a class="button primary large" href="/">Back to Horizon</a></div>
<p class="disclaimer">Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.</p>
</div>
</main>
</body>
</html>
+259
View File
@@ -0,0 +1,259 @@
<!doctype html>
<html lang="en" class="no-js">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Horizon: a Rust rewrite for Minecraft: Java Edition 26.3</title>
<meta name="description" content="Horizon is an unofficial Rust rewrite of the Minecraft: Java Edition 26.3 client and game logic, checked against vanilla by {{HARNESSES}} differential harnesses. Not an official Minecraft product.">
<meta name="theme-color" content="#111113">
<meta name="color-scheme" content="dark">
<link rel="canonical" href="https://playhorizon.dev/">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Horizon">
<meta property="og:title" content="Horizon">
<meta property="og:description" content="An unofficial Rust rewrite for Minecraft: Java Edition 26.3, checked against vanilla by {{HARNESSES}} differential harnesses.">
<meta property="og:url" content="https://playhorizon.dev/">
<meta property="og:image" content="https://playhorizon.dev/og.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Horizon, in Instrument Serif, over a sunset above a river valley rendered by Horizon">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://playhorizon.dev/og.png">
<link rel="preconnect" href="https://api.github.com" crossorigin>
<link rel="preconnect" href="https://raw.githubusercontent.com" crossorigin>
<link rel="preload" href="/fonts/InstrumentSerif-Regular.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/Geist-Regular.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/media/hero-portrait.webp" as="image" type="image/webp" media="(orientation: portrait)" fetchpriority="high">
<link rel="preload" href="/media/hero.webp" as="image" type="image/webp" media="(orientation: landscape)" imagesrcset="/media/hero-1200.webp 1200w, /media/hero.webp 1920w" imagesizes="100vw" fetchpriority="high">
<style>{{STYLE}}</style>
<script>document.documentElement.classList.replace("no-js", "js")</script>
<script type="module" src="{{JS}}"></script>
</head>
<body>
<a class="skip" href="#main">Skip to the content</a>
<header class="nav" id="top">
<div class="nav-inner">
<a class="nav-mark" href="#top" aria-label="Horizon, back to the top">Horizon</a>
<nav class="nav-links" id="nav-links" aria-label="Sections">
<a href="#about">About</a>
<a href="#screenshots">In game</a>
<a href="#parity">Parity</a>
<a href="#launcher">Launcher</a>
<a href="#patch-notes">Notes</a>
<a href="#next">Roadmap</a>
<a href="#community">Community</a>
</nav>
<div class="nav-end">
<a class="nav-github" href="https://github.com/ludovic111/horizon">GitHub</a>
<a class="button primary small" data-download href="https://github.com/ludovic111/horizon/releases?q=launcher-v&amp;expanded=true">Download</a>
<button class="menu-button" type="button" aria-expanded="false" aria-controls="menu"><span class="menu-label">Menu</span><span class="menu-icon" aria-hidden="true"></span></button>
</div>
</div>
</header>
<div class="menu" id="menu" hidden>
<nav aria-label="Sections">
<a href="#about">About</a>
<a href="#screenshots">In game</a>
<a href="#parity">Parity</a>
<a href="#launcher">Launcher</a>
<a href="#patch-notes">Patch notes</a>
<a href="#next">Roadmap</a>
<a href="#community">Community</a>
</nav>
<div class="menu-foot">
<a class="button secondary large" href="https://github.com/ludovic111/horizon">GitHub</a>
<p>Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.</p>
</div>
</div>
<main id="main">
<section class="hero" aria-labelledby="hero-title">
<div class="hero-media" aria-hidden="true">
<div class="hero-parallax">
<picture>
<source media="(orientation: portrait)" srcset="/media/hero-portrait.webp" width="720" height="926">
<img src="/media/hero.webp" srcset="/media/hero-1200.webp 1200w, /media/hero.webp 1920w" sizes="100vw" width="1920" height="1080" alt="" fetchpriority="high" decoding="async">
</picture>
</div>
<div class="hero-veil"></div>
<div class="grain"></div>
</div>
<div class="hero-content">
<h1 class="hero-title" id="hero-title"><span class="sr-only">Horizon</span><span class="letters" aria-hidden="true"><span>H</span><span>o</span><span>r</span><span>i</span><span>z</span><span>o</span><span>n</span></span></h1>
<p class="hero-line">The Minecraft: Java Edition 26.3 client and its game logic, rewritten in Rust and checked against the vanilla game by {{HARNESSES}} differential harnesses.</p>
<div class="hero-actions">
<a class="button primary large magnetic" id="download" data-download href="https://github.com/ludovic111/horizon/releases?q=launcher-v&amp;expanded=true">Download the launcher</a>
<a class="button ghost large magnetic" href="https://github.com/ludovic111/horizon">View on GitHub</a>
</div>
<p class="download-meta" data-download-meta>For Linux x86-64, macOS on Apple Silicon and Windows x64.</p>
</div>
<div class="hero-foot">
<p class="disclaimer"><span class="disclaimer-tag">Unofficial</span> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.</p>
<a class="scroll-cue" href="#about">Scroll<span aria-hidden="true"></span></a>
</div>
</section>
<section id="about" class="section about" aria-labelledby="about-title">
<div class="container">
<p class="kicker reveal"><span>01</span>What it is</p>
<h2 id="about-title" class="statement"><span class="fill">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.</span></h2>
<div class="facts">
<div class="fact reveal">
<h3>Your own copy</h3>
<p>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.</p>
</div>
<div class="fact reveal">
<h3>Native on three platforms</h3>
<p>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.</p>
</div>
<div class="fact reveal">
<h3>Open source</h3>
<p>Horizon's code is <a href="https://github.com/ludovic111/horizon/blob/main/LICENSE">MIT</a>. It contains no Minecraft code, assets or data; those belong to Mojang and Microsoft under the <a href="https://www.minecraft.net/en-us/eula">Minecraft EULA</a>.</p>
</div>
</div>
</div>
</section>
<section id="screenshots" class="section shots" aria-labelledby="shots-title">
<div class="container">
<div class="section-head">
<p class="kicker reveal"><span>02</span>In game</p>
<h2 id="shots-title" class="title reveal">Drawn by Horizon</h2>
<p class="lede reveal">Captured in the Rust client itself, with vanilla assets from the player's own copy and no mods or shaders.</p>
</div>
<div class="mosaic">
<figure class="tile tile-wide reveal-media"><button type="button" class="zoom" aria-label="Enlarge: a plains village"><img loading="lazy" decoding="async" src="/media/village-800.webp" srcset="/media/village-800.webp 800w, /media/village.webp 1600w" sizes="(min-width: 1000px) 800px, 92vw" width="1600" height="900" alt="A plains village with farms, an iron golem and animals"></button><figcaption><span>01</span>A plains village</figcaption></figure>
<figure class="tile tile-tall reveal-media"><button type="button" class="zoom" aria-label="Enlarge: moonrise over a village"><img loading="lazy" decoding="async" src="/media/night-800.webp" srcset="/media/night-800.webp 800w, /media/night.webp 1600w" sizes="(min-width: 1000px) 400px, 92vw" width="1600" height="900" alt="Moonrise over a torch-lit village and river"></button><figcaption><span>02</span>Moonrise</figcaption></figure>
<figure class="tile reveal-media"><button type="button" class="zoom" aria-label="Enlarge: a crimson forest"><img loading="lazy" decoding="async" src="/media/nether-800.webp" srcset="/media/nether-800.webp 800w, /media/nether.webp 1600w" sizes="(min-width: 1000px) 400px, 92vw" width="1600" height="900" alt="A crimson forest in the Nether with a zombified piglin"></button><figcaption><span>03</span>A crimson forest in the Nether</figcaption></figure>
<figure class="tile reveal-media"><button type="button" class="zoom" aria-label="Enlarge: salmon in a river"><img loading="lazy" decoding="async" src="/media/underwater-800.webp" srcset="/media/underwater-800.webp 800w, /media/underwater.webp 1600w" sizes="(min-width: 1000px) 400px, 92vw" width="1600" height="900" alt="Salmon swimming in a river"></button><figcaption><span>04</span>Salmon in a river</figcaption></figure>
<figure class="tile reveal-media"><button type="button" class="zoom" aria-label="Enlarge: survival at dusk"><img loading="lazy" decoding="async" src="/media/survival-800.webp" srcset="/media/survival-800.webp 800w, /media/survival.webp 1600w" sizes="(min-width: 1000px) 400px, 92vw" width="1600" height="900" alt="Survival at dusk: a zombie attacks near the village, with the hotbar and chat"></button><figcaption><span>05</span>Survival at dusk</figcaption></figure>
</div>
</div>
</section>
<section id="parity" class="section parity" aria-labelledby="parity-title">
<div class="container">
<p class="kicker reveal"><span>03</span>Parity</p>
<div class="parity-hero">
<div class="parity-count">
<p class="count reveal" aria-hidden="true"><span class="digits" data-digits>{{HARNESSES}}</span></p>
<p class="count-label reveal"><span class="sr-only">{{HARNESSES}} </span>differential harnesses drive the same inputs through Horizon and through the vanilla client and server, and compare what comes out.</p>
</div>
<div class="parity-copy">
<h2 id="parity-title" class="title reveal">{{PARITY_HEADLINE}}</h2>
<div class="prose reveal">{{PARITY_INTRO}}</div>
<p class="meta reveal">Last audited {{AUDITED}}<span aria-hidden="true"> · </span>{{PERCENT}} of what the harnesses compare<span aria-hidden="true"> · </span>from the README on main</p>
</div>
</div>
<ol class="systems" aria-label="The {{SUBSYSTEMS}} subsystems">
{{PARITY_CELLS}}
</ol>
<p class="meta legend">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).</p>
</div>
</section>
<section id="launcher" class="section launcher" aria-labelledby="launcher-title">
<div class="container launcher-grid">
<div class="launcher-copy">
<p class="kicker reveal"><span>04</span>The launcher</p>
<h2 id="launcher-title" class="title reveal">One small app, then play</h2>
<p class="lede reveal">The launcher installs Horizon, prepares it from your copy of the game and keeps both up to date.</p>
<ul class="features">
<li class="reveal"><h3>One-click install</h3><p>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.</p></li>
<li class="reveal"><h3>Updates on its own</h3><p>It looks for a new Horizon release when it starts and every four hours, and updates itself from the launcher's own releases.</p></li>
<li class="reveal"><h3>Client or server</h3><p>Start the client, or a dedicated server in a folder you choose, with a live console.</p></li>
<li class="reveal"><h3>Your choice of menus</h3><p>Play with Horizon's own menus, shown here, or vanilla's classic screens.</p></li>
</ul>
<div class="launcher-actions reveal">
<a class="button primary large magnetic" data-download href="https://github.com/ludovic111/horizon/releases?q=launcher-v&amp;expanded=true">Download the launcher</a>
<p class="download-meta" data-download-meta>{{LAUNCHER_META}}</p>
</div>
</div>
<div class="screens reveal-media">
<div class="tabs" role="tablist" aria-label="Horizon's menus">
<span class="tab-pill" aria-hidden="true"></span>
<button type="button" role="tab" id="tab-title" aria-controls="screen-title" aria-selected="true">Title</button>
<button type="button" role="tab" id="tab-worlds" aria-controls="screen-worlds" aria-selected="false" tabindex="-1">Worlds</button>
<button type="button" role="tab" id="tab-settings" aria-controls="screen-settings" aria-selected="false" tabindex="-1">Settings</button>
<button type="button" role="tab" id="tab-pause" aria-controls="screen-pause" aria-selected="false" tabindex="-1">Pause</button>
</div>
<div class="screen">
<div class="screen-panel is-active" role="tabpanel" id="screen-title" aria-labelledby="tab-title"><img loading="lazy" decoding="async" src="/media/menu-title-800.webp" srcset="/media/menu-title-800.webp 800w, /media/menu-title.webp 1600w" sizes="(min-width: 1000px) 720px, 92vw" width="1600" height="1000" alt="Horizon's title screen: a sidebar, the Horizon title, Singleplayer and Multiplayer buttons and the last world played, over a blurred frozen landscape"></div>
<div class="screen-panel" role="tabpanel" id="screen-worlds" aria-labelledby="tab-worlds"><img loading="lazy" decoding="async" src="/media/menu-worlds-800.webp" srcset="/media/menu-worlds-800.webp 800w, /media/menu-worlds.webp 1600w" sizes="(min-width: 1000px) 720px, 92vw" width="1600" height="1000" alt="The Singleplayer page: three worlds with their dates and game modes, and Create world and Play buttons"></div>
<div class="screen-panel" role="tabpanel" id="screen-settings" aria-labelledby="tab-settings"><img loading="lazy" decoding="async" src="/media/settings-800.webp" srcset="/media/settings-800.webp 800w, /media/settings.webp 1600w" sizes="(min-width: 1000px) 720px, 92vw" width="1600" height="1000" alt="The Video settings in their Simple view: a preset, render distance, field of view, framerate, VSync and GUI scale, each with what it costs"></div>
<div class="screen-panel" role="tabpanel" id="screen-pause" aria-labelledby="tab-pause"><img loading="lazy" decoding="async" src="/media/menu-pause-800.webp" srcset="/media/menu-pause-800.webp 800w, /media/menu-pause.webp 1600w" sizes="(min-width: 1000px) 720px, 92vw" width="1600" height="1000" alt="The pause screen of the world Glacier Pass, with Back to game, Open to LAN and Save and quit"></div>
</div>
<p class="screen-caption">Horizon's own menus, in the game. The launcher opens them, or vanilla's.</p>
</div>
</div>
</section>
<section id="patch-notes" class="section notes" aria-labelledby="notes-title">
<div class="container">
<div class="section-head">
<p class="kicker reveal"><span>05</span>Patch notes</p>
<h2 id="notes-title" class="title reveal">What changed</h2>
</div>
<div class="releases reveal" id="releases" aria-live="polite">
{{NOTES}}
</div>
</div>
</section>
<section id="next" class="section next" aria-labelledby="next-title">
<div class="container next-grid">
<div class="next-head">
<p class="kicker reveal"><span>06</span>What's next</p>
<h2 id="next-title" class="title reveal">With parity as the base</h2>
<p class="lede reveal">The roadmap from the README, as it stands on main.</p>
</div>
<div class="prose roadmap reveal" id="roadmap">{{ROADMAP}}</div>
</div>
</section>
<section id="community" class="section community" aria-labelledby="community-title">
<div class="container">
<p class="kicker reveal"><span>07</span>Community</p>
<h2 id="community-title" class="title reveal">Built in the open</h2>
<div class="cards">
<a class="card reveal" data-card data-link="discord" href="{{DISCORD}}"><span class="card-title">Discord</span><span class="card-text">Questions, bug reports and screenshots.</span><span class="card-arrow" aria-hidden="true">→</span></a>
<a class="card reveal" data-card data-link="sponsor" href="{{SPONSOR}}"><span class="card-title">Sponsor</span><span class="card-text">Sponsors on GitHub fund the next release.</span><span class="card-arrow" aria-hidden="true">→</span></a>
<a class="card reveal" data-card href="https://github.com/ludovic111/horizon"><span class="card-title">Source</span><span class="card-text">Issues and pull requests are welcome.</span><span class="card-arrow" aria-hidden="true">→</span></a>
</div>
</div>
</section>
</main>
<footer class="footer">
<div class="container">
<p class="footer-disclaimer">Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.</p>
<div class="footer-cols">
<p>Horizon is made by <a href="https://lu4ovic.com">Ludovic Marie</a>, who is responsible for it and for this site; reach him through <a href="https://lu4ovic.com/#contact">lu4ovic.com</a>. Horizon contains no Minecraft code, assets or data, and this site offers no game files: you need your own legally obtained copy of Minecraft: Java Edition. Minecraft is a trademark of Mojang Synergies AB.</p>
<p>Horizon's original work is under the <a href="https://github.com/ludovic111/horizon/blob/main/LICENSE">MIT licence</a>. The fonts are Geist and Instrument Serif, under the SIL Open Font License (<a href="/fonts/OFL-Geist.txt">Geist</a>, <a href="/fonts/OFL-InstrumentSerif.txt">Instrument Serif</a>). Every asset on this site is listed in <a href="/LEGAL.md">LEGAL.md</a>.</p>
<p>Privacy: this site sets no cookies, has no analytics or trackers and loads nothing from third parties except GitHub. To show the latest releases, patch notes, roadmap and links, your browser asks <code>api.github.com</code> and <code>raw.githubusercontent.com</code> for them; <a href="https://docs.github.com/site-policy/privacy-policies/github-general-privacy-statement">GitHub's privacy statement</a> covers those requests.</p>
</div>
<div class="footer-bar">
<span class="footer-mark">Horizon</span>
<nav class="footer-links" aria-label="Elsewhere"><a href="https://github.com/ludovic111/horizon">GitHub</a><a href="https://github.com/ludovic111/horizon/releases">Releases</a><a href="https://github.com/ludovic111/horizon/blob/main/LICENSE">Licence</a><a href="/LEGAL.md">Assets</a></nav>
</div>
</div>
<p class="footer-word" aria-hidden="true">Horizon</p>
</footer>
<dialog class="lightbox" aria-label="Screenshot">
<form method="dialog"><button class="button ghost close" type="submit">Close</button></form>
<img alt="">
<p class="lightbox-caption"></p>
</dialog>
<script type="application/json" id="baked">{{BAKED}}</script>
</body>
</html>
+318
View File
@@ -0,0 +1,318 @@
// The page's behaviour: the download buttons for this platform, the latest releases, the
// roadmap and links from main, the navigation, the menus' tabs, the lightbox and the motion.
// Everything degrades to the baked-in content when GitHub can't be reached.
import { markdown, escape, section } from "./markdown.js";
import { releasesHtml } from "./releases.js";
const REPO = "ludovic111/horizon";
const RELEASES_PAGE = `https://github.com/${REPO}/releases?q=launcher-v&expanded=true`;
const baked = JSON.parse(document.getElementById("baked").textContent);
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
const finePointer = matchMedia("(hover: hover) and (pointer: fine)").matches;
// --- GitHub --------------------------------------------------------------------------
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;
}
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();
}
const releases = cached("horizon:releases", () =>
get(`https://api.github.com/repos/${REPO}/releases?per_page=30`).then((list) =>
list
.filter((r) => !r.draft)
.map((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 })),
})),
),
).catch(() => baked.releases);
// --- 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.find((r) => r.tag_name.startsWith("launcher-v") && !r.prerelease);
if (!launcher) return;
const version = launcher.tag_name.replace("launcher-v", "");
const asset = os && launcher.assets.find((a) => a.name.includes(os.key));
const others = `<a href="${escape(launcher.html_url)}">Other platforms</a>`;
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));
}
// --- Patch notes, the roadmap and the links from main ---------------------------------
const isGame = (tag) => /^v\d{4}\.\d{1,2}\.\d{1,2}/.test(tag);
async function setUpNotes() {
const list = (await releases).filter((r) => isGame(r.tag_name));
if (list.length) document.getElementById("releases").innerHTML = releasesHtml(list.slice(0, 3));
}
async function setUpRoadmap() {
try {
const readme = await cached("horizon:readme", () => get(`https://raw.githubusercontent.com/${REPO}/main/README.md`, "text"));
const roadmap = section(readme, "Roadmap");
if (roadmap) document.getElementById("roadmap").innerHTML = markdown(roadmap, { shift: 1 });
} catch {}
}
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;
});
}
// --- Navigation ----------------------------------------------------------------------
function setUpNav() {
const bar = document.querySelector(".nav");
const links = [...document.querySelectorAll(".nav-links a")];
const sections = links.map((a) => document.querySelector(a.getAttribute("href")));
let current = null;
const update = () => {
bar.classList.toggle("is-scrolled", scrollY > 24);
const line = innerHeight * 0.4;
let index = -1;
sections.forEach((s, i) => {
if (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();
// The phone menu: a full-screen sheet; the page behind it is inert while it is open.
const button = document.querySelector(".menu-button");
const menu = document.getElementById("menu");
const main = document.getElementById("main");
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);
main.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: 1120px)").addEventListener("change", (e) => e.matches && setOpen(false));
}
// 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").forEach((group) => stagger([...group.children], 90));
document.querySelectorAll(".section-head, .parity-count, .parity-copy, .next-head, .launcher-copy").forEach((group) => stagger([...group.querySelectorAll(":scope > .reveal")], 70));
stagger([...document.querySelectorAll(".system")], 45);
const count = document.querySelector("[data-digits]");
count.innerHTML = [...count.textContent].map((c) => `<span>${escape(c)}</span>`).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));
}
// The statement lights up in reading order as it scrolls through the view.
function setUpStatement() {
const fill = document.querySelector(".fill");
if (reducedMotion) return;
const block = fill.parentElement;
const update = () => {
const r = block.getBoundingClientRect();
if (r.bottom < -200 || r.top > innerHeight + 200) return;
const p = Math.min(1, Math.max(0, (innerHeight * 0.85 - r.top) / (innerHeight * 0.4 + r.height)));
fill.style.setProperty("--p", (p * 104).toFixed(1));
};
onScroll(update);
update();
}
// The hero's landscape moves slower than the page, and its text drifts up as it leaves.
function setUpParallax() {
if (reducedMotion) return;
const hero = document.querySelector(".hero");
const layer = hero.querySelector(".hero-parallax");
const content = hero.querySelector(".hero-content");
onScroll(() => {
const y = scrollY;
if (y > hero.offsetHeight) return;
layer.style.transform = `translate3d(0, ${(y * 0.3).toFixed(1)}px, 0)`;
content.style.transform = `translate3d(0, ${(y * -0.08).toFixed(1)}px, 0)`;
content.style.opacity = String(Math.max(0, 1 - y / (hero.offsetHeight * 0.7)).toFixed(3));
});
}
// Buttons lean a little towards the pointer.
function setUpMagnetic() {
if (reducedMotion || !finePointer) return;
document.querySelectorAll(".magnetic").forEach((el) => {
el.addEventListener("pointermove", (e) => {
const r = el.getBoundingClientRect();
const x = (e.clientX - r.left - r.width / 2) * 0.18;
const y = (e.clientY - r.top - r.height / 2) * 0.3;
el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`;
});
el.addEventListener("pointerleave", () => (el.style.transform = ""));
});
}
setUpReveal();
setUpStatement();
setUpParallax();
setUpMagnetic();
setUpNav();
setUpTabs();
setUpLightbox();
setUpDownload();
setUpNotes();
setUpRoadmap();
setUpLinks();
+146
View File
@@ -0,0 +1,146 @@
// A small Markdown renderer for release notes and the README: headings, paragraphs,
// lists (nested by indentation), fenced code, quotes, rules, 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
}
function href(url) {
url = url.trim();
if (/^(https?:|mailto:)/i.test(url)) return url;
if (url.startsWith("#")) return null; // README anchors have no target here
if (/^[a-z]+:/i.test(url)) return null; // javascript: and the like
return REPO_BLOB + url.replace(/^\.?\//, "");
}
export function inline(text) {
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, "<strong>$1</strong>")
.replace(/(^|[^\w*])\*([^*\s][^*]*)\*/g, "$1<em>$2</em>")
.replace(/(^|[^\w])_([^_\s][^_]*)_(?!\w)/g, "$1<em>$2</em>")
.replace(/&lt;(https?:\/\/[^\s&]+)&gt;/g, (_, u) => `<a href="${u}">${u}</a>`);
out = out.replace(/\u0001(\d+)\u0001/g, (_, i) => {
const [label, url] = links[i];
const target = href(url);
const body = inline(label);
return target ? `<a href="${escape(target)}">${body}</a>` : body;
});
return out.replace(/\u0000(\d+)\u0000/g, (_, i) => `<code>${escape(codes[i])}</code>`);
}
const indentOf = (line) => line.match(/^ */)[0].length;
const ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/;
function list(lines, start) {
// 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);
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) => `<li>${inline(it.text.join(" "))}${it.children.join("")}</li>`).join("");
return [`<${tag}>${body}</${tag}>`, i];
}
/** Renders Markdown; `shift` moves headings down (a `#` becomes `h{1 + shift}`). */
export function markdown(source, { shift = 0 } = {}) {
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);
out.push(`<h${level}>${inline(m[2])}</h${level}>`);
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(`<pre><code>${escape(code.join("\n"))}</code></pre>`);
} else if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
out.push("<hr>");
i++;
} else if (ITEM.test(line)) {
const [html, end] = list(lines, i);
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(`<blockquote>${markdown(quote.join("\n"), { shift })}</blockquote>`);
} 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(`<p>${inline(para.join(" "))}</p>`);
}
}
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();
}
+42
View File
@@ -0,0 +1,42 @@
// The patch notes: the latest game release in full, the ones before it folded. Shared by
// build.mjs (baked into the page) and main.js (refreshed from GitHub).
import { markdown, escape } from "./markdown.js";
const REPO = "ludovic111/horizon";
// "Horizon 2026.10.13: 100% of harnessed vanilla parity" reads as the version and its line.
function parts(r) {
const version = r.tag_name.replace(/^v/, "");
const name = r.name || "";
const line = name.includes(": ") ? name.slice(name.indexOf(": ") + 2) : name.replace(/^Horizon\s*/, "").replace(version, "").trim();
return { version, line: line.charAt(0).toUpperCase() + line.slice(1), date: (r.published_at || "").slice(0, 10) };
}
export function releasesHtml(list) {
if (!list.length) return "";
const [latest, ...older] = list;
const l = parts(latest);
const head = `<article class="release-latest">
<header class="release-head">
<p class="eyebrow">Latest release<span aria-hidden="true"> · </span><time datetime="${l.date}">${l.date}</time></p>
<h3 class="release-version">${escape(l.version)}</h3>
${l.line ? `<p class="release-line">${escape(l.line)}</p>` : ""}
<a class="link-arrow" href="${escape(latest.html_url)}">Downloads and full notes<span aria-hidden="true">→</span></a>
</header>
<div class="prose release-body">${markdown(latest.body || "")}</div>
</article>`;
const rest = older
.map((r) => {
const o = parts(r);
return `<details class="release">
<summary><span class="release-tag">${escape(o.version)}</span><span class="release-sub">${escape(o.line)}</span><time datetime="${o.date}">${o.date}</time></summary>
<div class="prose">${markdown(r.body || "")}<p><a href="${escape(r.html_url)}">Downloads and full notes on GitHub</a></p></div>
</details>`;
})
.join("");
return `${head}
<div class="release-older">
${rest ? `<p class="release-older-title">Earlier releases</p>${rest}` : ""}
<p class="release-more"><a class="link-arrow" href="https://github.com/${REPO}/releases">Every release on GitHub<span aria-hidden="true">→</span></a></p>
</div>`;
}
+644
View File
@@ -0,0 +1,644 @@
/* Horizon's design language (crates/horizon-theme), set as a page rather than a window:
the dark palette and one violet accent, Instrument Serif for display type and Geist for
everything else, 1px dividers, and short motion eased out, never bouncy. */
@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 Mono"; src: url("/fonts/GeistMono-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Instrument Serif"; src: url("/fonts/InstrumentSerif-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
:root {
--bg: #111113;
--deep: #0c0c0d;
--raised: #1a1a1d;
--hover: #202024;
--border: #26262a;
--text: #e8e8ea;
--muted: #9a9aa1;
--faint: #8a8a92; /* the token's #626269, lifted to pass WCAG AA for small text */
--accent: #7c5cc4;
--accent-text: #a48ae6; /* the accent as text, readable on --bg */
--on-accent: #ffffff;
--font: "Geist", ui-sans-serif, system-ui, sans-serif;
--display: "Instrument Serif", ui-serif, Georgia, serif;
--mono: "Geist Mono", ui-monospace, monospace;
--radius: 8px;
--radius-large: 12px;
--gutter: clamp(20px, 5vw, 72px);
--max: 1280px;
--nav: 68px;
/* GPUI's ease_out_quint: quick for hovers, longer for things entering the page. */
--ease: cubic-bezier(0.22, 1, 0.36, 1);
--quick: 180ms;
--move: 320ms;
--enter: 900ms;
}
*, *::before, *::after { box-sizing: border-box; }
html {
background: var(--bg);
color: var(--text);
font: 400 16px/1.65 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);
}
body { margin: 0; min-height: 100vh; overflow-x: clip; }
img { display: block; max-width: 100%; height: auto; }
h1, h2, h3 { margin: 0; font-weight: 400; }
p { margin: 0; }
::selection { background: rgba(124, 92, 196, 0.5); color: #fff; }
a { color: var(--accent-text); text-decoration: none; transition: color var(--quick) var(--ease); }
a:hover { color: var(--text); }
/* Links inside running text are underlined, so they don't rely on colour alone. */
p a:not(.button):not(.link-arrow) { text-decoration: underline; text-decoration-color: rgba(164, 138, 230, 0.45); text-decoration-thickness: 1px; text-underline-offset: 3px; }
p a:not(.button):not(.link-arrow):hover { text-decoration-color: currentColor; }
:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 3px; border-radius: 6px; }
code {
font: 0.86em/1.5 var(--mono);
background: var(--raised);
border: 1px solid var(--border);
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: 16px; top: 12px; z-index: 100;
padding: 10px 14px; border-radius: var(--radius);
background: var(--raised); color: var(--text); border: 1px solid var(--border);
transform: translateY(-200%); transition: transform var(--quick) var(--ease);
}
.skip:focus { transform: none; }
.container { width: 100%; max-width: calc(var(--max) + 2 * var(--gutter)); margin: 0 auto; padding: 0 var(--gutter); }
/* --- Type --------------------------------------------------------------------------- */
.eyebrow { font-size: 14px; color: var(--muted); letter-spacing: 0.01em; }
.kicker {
display: flex; align-items: center; gap: 14px;
margin-bottom: clamp(32px, 5vw, 56px);
font-size: 13px; font-weight: 500; color: var(--muted); letter-spacing: 0.02em;
}
.kicker span { font: 12px/1 var(--mono); color: var(--accent-text); }
.kicker::after { content: ""; flex: 1; height: 1px; background: var(--border); }
.title {
font: 400 clamp(46px, 6.4vw, 96px)/0.98 var(--display);
letter-spacing: -0.025em;
color: var(--text);
text-wrap: balance;
}
.lede { font-size: clamp(17px, 1.35vw, 19px); line-height: 1.6; color: var(--muted); max-width: 46ch; text-wrap: pretty; }
.meta { font-size: 13.5px; color: var(--faint); }
.prose { color: var(--muted); }
.prose > * + * { margin-top: 1em; }
.prose strong { color: var(--text); font-weight: 500; }
.prose a { text-decoration: underline; text-decoration-color: rgba(164, 138, 230, 0.4); text-underline-offset: 3px; }
.prose a:hover { text-decoration-color: currentColor; }
.prose ul, .prose ol { padding-left: 1.2em; }
.prose li + li { margin-top: 0.55em; }
.prose li::marker { color: var(--faint); }
.prose h2, .prose h3, .prose h4 { font: 400 26px/1.15 var(--display); color: var(--text); letter-spacing: -0.01em; margin-top: 1.6em; }
.prose pre { overflow-x: auto; background: var(--deep); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; }
.prose pre code { background: none; border: 0; padding: 0; }
.prose blockquote { margin-left: 0; padding-left: 16px; border-left: 1px solid var(--border); }
.prose hr { border: 0; height: 1px; background: var(--border); }
/* --- Buttons ------------------------------------------------------------------------ */
.button {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
height: 44px; padding: 0 20px;
border: 1px solid transparent; border-radius: var(--radius);
font: 500 15px/1 var(--font); white-space: nowrap;
cursor: pointer;
transition: background-color var(--quick) var(--ease), border-color var(--quick) var(--ease),
box-shadow var(--move) var(--ease), color var(--quick) var(--ease), transform var(--move) var(--ease);
}
.button.large { height: 52px; padding: 0 26px; font-size: 15.5px; }
.button.small { height: 34px; padding: 0 14px; font-size: 14px; border-radius: 7px; }
.button.primary {
background: var(--accent); color: var(--on-accent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 10px 30px -12px rgba(124, 92, 196, 0.7);
}
.button.primary:hover { background: #8a6bd1; color: var(--on-accent); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.24), 0 14px 40px -12px rgba(124, 92, 196, 0.85); }
.button.ghost {
background: rgba(255, 255, 255, 0.06); color: var(--text);
border-color: rgba(255, 255, 255, 0.14);
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
}
.button.ghost:hover { background: rgba(255, 255, 255, 0.11); border-color: rgba(255, 255, 255, 0.22); color: var(--text); }
.button.secondary { background: var(--raised); color: var(--text); border-color: var(--border); }
.button.secondary:hover { background: var(--hover); color: var(--text); }
.button:active { opacity: 0.85; }
.link-arrow { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; font-weight: 500; }
.link-arrow span { transition: transform var(--move) var(--ease); }
.link-arrow:hover span { transform: translateX(4px); }
.download-meta { font-size: 13.5px; color: var(--faint); }
.download-meta a { color: var(--accent-text); }
/* --- Navigation --------------------------------------------------------------------- */
.nav {
position: fixed; inset: 0 0 auto; z-index: 50;
border-bottom: 1px solid transparent;
transition: background-color var(--move) var(--ease), border-color var(--move) var(--ease),
-webkit-backdrop-filter var(--move) var(--ease), backdrop-filter var(--move) var(--ease);
}
.nav.is-scrolled, .nav.is-menu {
background: rgba(17, 17, 19, 0.72);
border-bottom-color: rgba(255, 255, 255, 0.07);
-webkit-backdrop-filter: blur(18px) saturate(140%); backdrop-filter: blur(18px) saturate(140%);
}
.nav-inner {
display: flex; align-items: center; gap: 24px;
height: var(--nav); max-width: calc(var(--max) + 2 * var(--gutter)); margin: 0 auto; padding: 0 var(--gutter);
}
.nav-mark { font: 400 26px/1 var(--display); color: var(--text); letter-spacing: -0.01em; padding: 10px 0; }
.nav-mark:hover { color: var(--text); }
.nav-links { display: none; gap: 4px; margin: 0 auto; }
.nav-links a {
position: relative; padding: 8px 12px; font-size: 14px; color: rgba(232, 232, 234, 0.72);
}
.nav-links a::after {
content: ""; position: absolute; left: 12px; right: 12px; bottom: 3px; height: 1px;
background: var(--accent-text); transform: scaleX(0); transform-origin: left;
transition: transform var(--move) var(--ease);
}
.nav-links a:hover { color: var(--text); }
.nav-links a[aria-current="true"] { color: var(--text); }
.nav-links a[aria-current="true"]::after { transform: scaleX(1); }
.nav-end { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.nav-github { display: none; align-items: center; min-height: 44px; padding: 0 12px; font-size: 14px; color: rgba(232, 232, 234, 0.72); }
.nav-github:hover { color: var(--text); }
.nav-end .button.small { display: none; }
.menu-button {
display: inline-flex; align-items: center; gap: 12px;
height: 44px; padding: 0 4px 0 14px;
background: none; border: 0; color: var(--text); font: 500 15px/1 var(--font); cursor: pointer;
}
.menu-icon { position: relative; width: 20px; height: 10px; }
.menu-icon::before, .menu-icon::after {
content: ""; position: absolute; left: 0; right: 0; height: 1.5px; background: currentColor; border-radius: 1px;
transition: transform var(--move) var(--ease), top var(--move) var(--ease);
}
.menu-icon::before { top: 0; }
.menu-icon::after { top: 8.5px; }
.menu-button[aria-expanded="true"] .menu-icon::before { top: 4.25px; transform: rotate(45deg); }
.menu-button[aria-expanded="true"] .menu-icon::after { top: 4.25px; transform: rotate(-45deg); }
.menu {
position: fixed; inset: 0; z-index: 40;
display: flex; flex-direction: column; justify-content: space-between;
padding: calc(var(--nav) + 24px) var(--gutter) max(28px, env(safe-area-inset-bottom));
background: rgba(12, 12, 13, 0.92);
-webkit-backdrop-filter: blur(24px); backdrop-filter: blur(24px);
opacity: 0; transition: opacity var(--move) var(--ease);
overflow-y: auto;
}
.menu.is-open { opacity: 1; }
.menu nav { display: flex; flex-direction: column; }
.menu nav a {
display: block; padding: 6px 0;
font: 400 clamp(38px, 10vw, 56px)/1.12 var(--display); color: var(--text); letter-spacing: -0.02em;
opacity: 0; transform: translateY(14px);
transition: opacity 500ms var(--ease), transform 500ms var(--ease), color var(--quick) var(--ease);
}
.menu.is-open nav a { opacity: 1; transform: none; }
.menu nav a:hover { color: var(--accent-text); }
.menu-foot { display: grid; gap: 20px; padding-top: 32px; border-top: 1px solid var(--border); margin-top: 32px; }
.menu-foot p { font-size: 13.5px; color: var(--muted); }
.menu-foot .button { justify-self: start; }
@media (min-width: 640px) { .nav-github { display: inline-flex; } }
@media (min-width: 900px) { .nav-end .button.small { display: inline-flex; } }
@media (min-width: 1120px) {
.nav-links { display: flex; }
.nav-inner { gap: 32px; }
.menu-button, .menu { display: none !important; }
.nav-end { margin-left: 0; }
}
/* --- Hero --------------------------------------------------------------------------- */
.hero {
position: relative; isolation: isolate; overflow: hidden;
display: flex; flex-direction: column; justify-content: flex-end;
min-height: 100vh; min-height: 100svh;
padding-top: var(--nav);
}
.hero-media { position: absolute; inset: 0; z-index: -1; background: #1a1418; }
.hero-parallax { position: absolute; inset: -6% 0; will-change: transform; }
.hero-parallax picture { display: block; height: 100%; animation: settle 2.8s var(--ease) both; }
.hero-parallax img {
width: 100%; height: 100%; object-fit: cover; object-position: 50% 40%;
animation: drift 48s ease-in-out 2.8s infinite alternate;
}
.hero-veil {
position: absolute; inset: 0;
background:
linear-gradient(180deg, rgba(17, 17, 19, 0.7) 0%, rgba(17, 17, 19, 0) 24%),
linear-gradient(180deg, rgba(17, 17, 19, 0) 40%, rgba(17, 17, 19, 0.62) 70%, var(--bg) 100%),
radial-gradient(130% 90% at 50% 38%, rgba(17, 17, 19, 0) 45%, rgba(10, 10, 12, 0.6) 100%),
linear-gradient(180deg, rgba(62, 40, 120, 0.22), rgba(62, 40, 120, 0) 50%);
}
.grain {
position: absolute; inset: -60%; pointer-events: none; opacity: 0.11;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.9 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
animation: grain 1.4s steps(6) infinite;
}
.hero-content {
width: 100%; max-width: calc(var(--max) + 2 * var(--gutter)); margin: 0 auto;
padding: 0 var(--gutter) clamp(44px, 9vh, 112px);
text-align: center;
}
.hero-title {
margin: 0;
font: 400 clamp(96px, 25vw, 300px)/0.84 var(--display);
letter-spacing: -0.035em; color: #f5f5f6;
text-shadow: 0 4px 60px rgba(0, 0, 0, 0.35);
}
.letters { display: inline-block; white-space: nowrap; }
.letters span { display: inline-block; animation: letter 1.3s var(--ease) both; }
.letters span:nth-child(1) { animation-delay: 0.2s; }
.letters span:nth-child(2) { animation-delay: 0.26s; }
.letters span:nth-child(3) { animation-delay: 0.32s; }
.letters span:nth-child(4) { animation-delay: 0.38s; }
.letters span:nth-child(5) { animation-delay: 0.44s; }
.letters span:nth-child(6) { animation-delay: 0.5s; }
.letters span:nth-child(7) { animation-delay: 0.56s; }
.hero-line {
max-width: 36em; margin: clamp(20px, 3vh, 32px) auto 0;
font-size: clamp(17px, 1.45vw, 20px); line-height: 1.55; color: rgba(232, 232, 234, 0.86);
text-wrap: balance;
animation: rise 1s var(--ease) 0.7s both;
}
.hero-actions {
display: flex; flex-wrap: wrap; justify-content: center; gap: 12px;
margin-top: clamp(28px, 4vh, 40px);
animation: rise 1s var(--ease) 0.82s both;
}
.hero .download-meta { margin-top: 16px; color: rgba(232, 232, 234, 0.6); animation: rise 1s var(--ease) 0.9s both; }
.hero .download-meta a { color: #c3b2f2; }
.hero-foot {
display: flex; align-items: center; justify-content: space-between; gap: 24px;
width: 100%; max-width: calc(var(--max) + 2 * var(--gutter)); margin: 0 auto;
padding: 16px var(--gutter) 18px;
animation: rise 1s var(--ease) 1s both;
}
.hero-foot::before {
content: ""; position: absolute; left: var(--gutter); right: var(--gutter); height: 1px; margin-top: -16px;
background: rgba(255, 255, 255, 0.09);
}
.hero-foot { position: relative; }
.disclaimer { font-size: 13.5px; line-height: 1.5; color: #cfcfd4; }
.disclaimer-tag {
display: inline-block; margin-right: 8px; padding: 2px 7px;
font: 11px/1.4 var(--mono); letter-spacing: 0.06em; text-transform: uppercase; color: var(--text);
border: 1px solid rgba(255, 255, 255, 0.22); border-radius: 4px; vertical-align: 1px;
}
.scroll-cue {
display: none; align-items: center; gap: 12px; min-height: 44px;
font-size: 13px; color: rgba(232, 232, 234, 0.7); white-space: nowrap;
}
.scroll-cue span { position: relative; width: 1px; height: 28px; background: rgba(255, 255, 255, 0.18); overflow: hidden; }
.scroll-cue span::after { content: ""; position: absolute; left: 0; top: 0; width: 1px; height: 12px; background: #fff; animation: cue 2.4s var(--ease) infinite; }
.scroll-cue:hover { color: var(--text); }
@media (min-width: 760px) { .scroll-cue { display: inline-flex; } }
/* --- Sections ----------------------------------------------------------------------- */
.section { padding: clamp(96px, 12vw, 168px) 0 0; }
.section-head { display: grid; gap: 24px; margin-bottom: clamp(44px, 6vw, 72px); }
.section-head .kicker { margin-bottom: 8px; }
@media (min-width: 1000px) {
.section-head { grid-template-columns: 1.2fr 1fr; align-items: end; column-gap: 64px; }
.section-head .kicker { grid-column: 1 / -1; margin-bottom: clamp(16px, 2vw, 32px); }
.section-head .lede { justify-self: end; }
}
/* What it is: a statement that lights up word by word as it scrolls past. */
.statement {
max-width: 21em;
font: 400 clamp(32px, 4.5vw, 64px)/1.1 var(--display);
letter-spacing: -0.018em; color: var(--text);
text-wrap: pretty;
}
/* The fill runs along the whole inline box, so it lights the lines in reading order. */
.js .fill {
color: transparent;
background: linear-gradient(90deg, var(--text) calc(var(--p, 0) * 1% - 2%), #3a3a40 calc(var(--p, 0) * 1% + 2%));
-webkit-background-clip: text; background-clip: text;
}
.facts { display: grid; gap: 40px; margin-top: clamp(64px, 8vw, 112px); }
.fact { padding-top: 24px; border-top: 1px solid var(--border); }
.fact h3 { font-size: 16px; font-weight: 500; color: var(--text); margin-bottom: 10px; }
.fact p { font-size: 15px; color: var(--muted); max-width: 40ch; }
@media (min-width: 900px) { .facts { grid-template-columns: repeat(3, 1fr); gap: 48px; } }
/* In game: a mosaic of large screenshots. */
.mosaic { display: grid; gap: clamp(12px, 1.6vw, 20px); grid-template-columns: 1fr; }
.tile { margin: 0; min-width: 0; }
.zoom {
position: relative; display: block; width: 100%; padding: 0; border: 0; background: var(--deep);
border-radius: var(--radius-large); overflow: hidden; cursor: zoom-in; aspect-ratio: 16 / 10;
}
.zoom::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06); pointer-events: none; }
.zoom img { width: 100%; height: 100%; object-fit: cover; transition: transform 1.6s var(--ease), scale 900ms var(--ease); }
.zoom:hover img { scale: 1.035; }
.tile figcaption { display: flex; gap: 12px; margin-top: 14px; font-size: 14px; color: var(--muted); }
.tile figcaption span { font: 12px/1.7 var(--mono); color: var(--faint); }
@media (min-width: 640px) {
.mosaic { grid-template-columns: repeat(2, 1fr); }
.tile-wide { grid-column: 1 / -1; }
}
@media (min-width: 1000px) {
.mosaic { grid-template-columns: repeat(12, 1fr); }
.tile { grid-column: span 4; }
.tile-wide { grid-column: span 8; }
.tile-tall { display: flex; flex-direction: column; }
.tile-tall .zoom { flex: 1; aspect-ratio: auto; }
.tile-wide .zoom { aspect-ratio: 16 / 9.4; }
}
/* Parity: the harness count, then the fourteen subsystems. */
.parity-hero { display: grid; gap: 32px; }
.count {
margin: 0;
font: 400 clamp(150px, 27vw, 400px)/0.78 var(--display);
letter-spacing: -0.045em;
color: var(--text);
}
/* Each digit carries the fade itself, so the digits can move on their own layers. */
.digits, .digits span {
background: linear-gradient(180deg, #f4f4f5 30%, #5c5c64 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
padding-bottom: 0.06em;
}
.digits span { display: inline-block; }
.parity-copy { display: grid; gap: 22px; max-width: 640px; }
.count-label { margin-top: 20px; font-size: clamp(16px, 1.25vw, 18px); color: var(--muted); max-width: 34ch; }
.parity-copy .title { font-size: clamp(40px, 4.8vw, 72px); }
.parity-copy .prose { font-size: 15px; max-width: 58ch; }
@media (min-width: 1000px) {
.parity-hero { grid-template-columns: auto 1fr; align-items: end; column-gap: clamp(48px, 6vw, 96px); }
}
.systems {
list-style: none; margin: clamp(64px, 8vw, 104px) 0 0; padding: 0;
display: grid; grid-template-columns: 1fr;
border: 1px solid var(--border); border-radius: var(--radius-large); overflow: hidden;
}
/* Each cell draws its own right and bottom divider; the container's border hides the outer ones. */
.system {
background: var(--bg); padding: 26px 26px 30px;
box-shadow: 1px 0 0 var(--border), 0 1px 0 var(--border);
transition: background-color var(--move) var(--ease);
}
.system:hover { background: #151517; }
.system-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 13px; }
.system-index { font: 12px/1 var(--mono); color: var(--faint); }
.system-state { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); }
.system.is-full .system-state { color: #c9bdf0; }
.check { width: 15px; height: 15px; fill: none; stroke: var(--accent-text); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.check path { stroke-dasharray: 16; stroke-dashoffset: 0; }
.system h3 { margin: clamp(28px, 3vw, 44px) 0 12px; font: 400 28px/1.08 var(--display); letter-spacing: -0.01em; color: var(--text); }
.system-detail { font-size: 14px; line-height: 1.6; color: var(--muted); }
.system-detail .gap { color: var(--text); }
.system-detail code { white-space: nowrap; font-size: 12.5px; background: none; border: 0; padding: 0; color: var(--text); }
.legend { margin-top: 20px; max-width: 80ch; }
@media (min-width: 700px) { .systems { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1140px) { .systems { grid-template-columns: repeat(3, 1fr); } }
/* The launcher: what it does, beside Horizon's own menus. */
.launcher-grid { display: grid; gap: 56px; }
.launcher-copy .title { margin-bottom: 24px; }
.features { list-style: none; margin: 40px 0 0; padding: 0; }
.features li { padding: 20px 0; border-top: 1px solid var(--border); }
.features li:last-child { border-bottom: 1px solid var(--border); }
.features h3 { font-size: 16px; font-weight: 500; color: var(--text); margin-bottom: 6px; }
.features p { font-size: 15px; color: var(--muted); max-width: 52ch; }
.launcher-actions { display: grid; justify-items: start; gap: 14px; margin-top: 36px; }
.screens { position: relative; min-width: 0; }
.screens::before {
content: ""; position: absolute; inset: 10% -6% -6%; z-index: -1; pointer-events: none;
background: radial-gradient(50% 50% at 50% 50%, rgba(124, 92, 196, 0.2), rgba(124, 92, 196, 0));
}
.tabs {
position: relative; display: inline-flex; flex-wrap: wrap; gap: 2px; padding: 4px;
background: rgba(255, 255, 255, 0.03); border: 1px solid var(--border); border-radius: 10px;
}
.tabs button {
position: relative; z-index: 1; height: 44px; padding: 0 16px;
background: none; border: 0; border-radius: 7px;
font: 500 14px/1 var(--font); color: var(--muted); cursor: pointer;
transition: color var(--quick) var(--ease);
}
.tabs button:hover, .tabs button[aria-selected="true"] { color: var(--text); }
.tab-pill {
position: absolute; left: 0; top: 0; border-radius: 7px;
background: var(--raised); border: 1px solid var(--border);
transition: transform var(--move) var(--ease), width var(--move) var(--ease);
}
.no-js .tabs { display: none; }
.screen {
position: relative; margin-top: 16px; aspect-ratio: 16 / 10;
border-radius: var(--radius-large); overflow: hidden; background: var(--deep);
border: 1px solid var(--border);
box-shadow: 0 50px 120px -50px rgba(0, 0, 0, 0.9);
}
.screen-panel {
position: absolute; inset: 0;
opacity: 0; transform: scale(1.02); filter: blur(6px);
transition: opacity 520ms var(--ease), transform 700ms var(--ease), filter 520ms var(--ease);
}
.screen-panel.is-active { opacity: 1; transform: none; filter: none; }
.screen-panel img { width: 100%; height: 100%; object-fit: cover; }
.no-js .screen { aspect-ratio: auto; display: grid; gap: 1px; background: var(--border); }
.no-js .screen-panel { position: static; opacity: 1; transform: none; filter: none; }
.screen-caption { margin-top: 14px; font-size: 13.5px; color: var(--faint); }
@media (min-width: 1000px) {
.launcher-grid { grid-template-columns: 5fr 7fr; align-items: center; column-gap: clamp(48px, 6vw, 96px); }
.tabs button { height: 36px; }
}
/* Patch notes: the latest release set like a page, the ones before it folded. */
.release-latest { display: grid; gap: 40px; }
.release-head { display: grid; align-content: start; gap: 14px; }
.release-version { font: 400 clamp(56px, 7vw, 108px)/0.92 var(--display); letter-spacing: -0.03em; color: var(--text); }
.release-line { font-size: 18px; color: var(--muted); max-width: 26ch; }
.release-head .link-arrow { margin-top: 6px; }
.release-body { max-width: 58ch; font-size: 16px; }
.release-body > p:first-child { font-size: 18px; color: var(--text); }
.release-body h3:first-child, .release-body h2:first-child { margin-top: 0; }
@media (min-width: 1000px) {
.release-latest { grid-template-columns: 4fr 7fr; column-gap: clamp(48px, 6vw, 96px); }
.release-head { position: sticky; top: calc(var(--nav) + 32px); align-self: start; }
}
.release-older { margin-top: clamp(64px, 8vw, 104px); }
.release-older-title { font-size: 13px; font-weight: 500; color: var(--muted); margin-bottom: 12px; }
.release { border-top: 1px solid var(--border); }
.release:last-of-type { border-bottom: 1px solid var(--border); }
.release summary {
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 4px 24px;
min-height: 72px; padding: 14px 0; cursor: pointer; list-style: none;
}
.release summary::-webkit-details-marker { display: none; }
.release-tag { font: 400 26px/1.1 var(--display); color: var(--text); }
.release-sub { font-size: 15px; color: var(--muted); }
.release time { font: 12.5px/1 var(--mono); color: var(--faint); }
.release summary::after {
content: ""; grid-column: 3; grid-row: 1; justify-self: end; width: 9px; height: 9px; margin-left: 56px;
border-right: 1.5px solid var(--muted); border-bottom: 1.5px solid var(--muted);
transform: translateY(-3px) rotate(45deg); transition: transform var(--move) var(--ease);
}
.release[open] summary::after { transform: translateY(2px) rotate(-135deg); }
.release summary time { grid-column: 3; grid-row: 1; margin-right: 28px; }
.release summary:hover .release-tag { color: var(--accent-text); }
.release .prose { padding: 4px 0 32px; max-width: 58ch; }
.release-more { margin-top: 20px; }
@media (max-width: 640px) {
.release summary { grid-template-columns: 1fr auto; }
.release-sub { grid-column: 1 / -1; grid-row: 2; }
.release summary time { grid-column: 2; }
.release summary::after { grid-column: 2; margin-left: 0; }
}
/* What's next: the README's roadmap, its bold leads set as headings. */
.next-grid { display: grid; gap: 48px; }
.next-head .title { margin-bottom: 24px; }
.roadmap { font-size: 16px; }
.roadmap > p { padding-top: 22px; border-top: 1px solid var(--border); margin-top: 0; margin-bottom: 36px; max-width: 56ch; }
.roadmap > p > strong:first-child { display: block; margin-bottom: 8px; font-size: 13px; letter-spacing: 0.02em; }
.roadmap > ul { list-style: none; padding: 0; margin: 0; }
.roadmap > ul > li { padding: 24px 0; border-top: 1px solid var(--border); margin: 0; max-width: 56ch; }
.roadmap > ul > li:last-child { border-bottom: 1px solid var(--border); }
.roadmap > ul > li > strong:first-child { display: block; margin-bottom: 8px; font: 400 30px/1.1 var(--display); letter-spacing: -0.01em; }
@media (min-width: 1000px) {
.next-grid { grid-template-columns: 5fr 7fr; column-gap: clamp(48px, 6vw, 96px); }
.next-head { position: sticky; top: calc(var(--nav) + 32px); align-self: start; }
}
/* Community. */
.community .title { margin-bottom: clamp(44px, 6vw, 72px); }
.cards {
display: grid; gap: 1px; background: var(--border);
border: 1px solid var(--border); border-radius: var(--radius-large); overflow: hidden;
}
.card {
position: relative; display: grid; grid-template-rows: auto 1fr auto; gap: 12px;
min-height: 220px; padding: 30px 30px 26px; background: var(--bg); color: var(--text);
transition: background-color var(--move) var(--ease);
}
.card:hover { background: #151517; color: var(--text); }
.card:focus-visible { outline-offset: -3px; border-radius: 0; }
.card-title { font: 400 44px/1 var(--display); letter-spacing: -0.02em; }
.card-text { font-size: 15px; color: var(--muted); max-width: 30ch; }
.card-arrow { justify-self: end; font-size: 22px; color: var(--muted); transition: transform var(--move) var(--ease), color var(--quick) var(--ease); }
.card:hover .card-arrow { transform: translateX(6px); color: var(--accent-text); }
@media (min-width: 860px) { .cards { grid-template-columns: repeat(3, 1fr); } }
/* --- Footer ------------------------------------------------------------------------- */
.footer { margin-top: clamp(112px, 14vw, 200px); border-top: 1px solid var(--border); padding-top: clamp(64px, 8vw, 104px); overflow: hidden; }
.footer-disclaimer { max-width: 22em; font: 400 clamp(28px, 3.4vw, 46px)/1.15 var(--display); letter-spacing: -0.01em; color: var(--text); text-wrap: balance; }
.footer-cols { display: grid; gap: 24px; margin-top: 48px; }
.footer-cols p { font-size: 14px; color: var(--muted); max-width: 62ch; }
@media (min-width: 1180px) { .footer-cols { grid-template-columns: repeat(3, 1fr); gap: 48px; } }
.footer-bar {
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 32px;
margin-top: 56px; padding: 16px 0; border-top: 1px solid var(--border);
}
.footer-mark { font: 400 24px/1 var(--display); }
.footer-links { display: flex; flex-wrap: wrap; gap: 0 22px; }
.footer-links a { display: inline-flex; align-items: center; min-height: 44px; font-size: 14px; color: var(--muted); }
.footer-links a:hover { color: var(--text); }
.footer-word {
margin: 24px 0 -0.24em; text-align: center; white-space: nowrap;
font: 400 27vw/0.9 var(--display); letter-spacing: -0.045em;
color: transparent; -webkit-text-stroke: 1px #24242a;
user-select: none;
}
/* --- Lightbox ----------------------------------------------------------------------- */
.lightbox {
max-width: min(1600px, 94vw); max-height: 94vh; padding: 0; border: 0; background: none; color: var(--text); overflow: visible;
}
.lightbox[open] { animation: open 420ms var(--ease) both; }
.lightbox::backdrop { background: rgba(8, 8, 9, 0.88); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); }
.lightbox img { max-height: 84vh; width: auto; margin: 0 auto; border-radius: var(--radius-large); }
.lightbox form { display: flex; justify-content: flex-end; margin-bottom: 12px; }
.lightbox-caption { margin-top: 12px; font-size: 14px; color: var(--muted); text-align: center; }
/* --- Motion ------------------------------------------------------------------------- */
.js .reveal { opacity: 0; transform: translateY(22px); filter: blur(6px); }
.js .reveal-media { opacity: 0; transform: translateY(44px); }
.js .reveal-media .zoom img { transform: scale(1.1); }
.js .reveal, .js .reveal-media {
transition: opacity var(--enter) var(--ease) var(--d, 0ms), transform 1.1s var(--ease) var(--d, 0ms), filter var(--enter) var(--ease) var(--d, 0ms);
}
.js .is-visible { opacity: 1; transform: none; filter: none; }
.js .is-visible .zoom img { transform: none; }
.js .systems .system { opacity: 0; transform: translateY(14px); transition: opacity 700ms var(--ease) var(--d, 0ms), transform 900ms var(--ease) var(--d, 0ms), background-color var(--move) var(--ease); }
.js .systems.is-visible .system { opacity: 1; transform: none; }
.js .systems .check path { stroke-dashoffset: 16; transition: stroke-dashoffset 700ms var(--ease) calc(var(--d, 0ms) + 350ms); }
.js .systems.is-visible .check path { stroke-dashoffset: 0; }
.js .count.is-visible .digits span { animation: letter 1.2s var(--ease) both; }
.js .count.is-visible .digits span:nth-child(2) { animation-delay: 0.08s; }
.js .count.is-visible .digits span:nth-child(3) { animation-delay: 0.16s; }
.js .count.is-visible .digits span:nth-child(4) { animation-delay: 0.24s; }
.magnetic { will-change: transform; }
@keyframes letter { from { opacity: 0; transform: translateY(0.18em); filter: blur(14px); } to { opacity: 1; transform: none; filter: none; } }
@keyframes rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@keyframes settle { from { transform: scale(1.09); } to { transform: none; } }
@keyframes drift { from { transform: scale(1.02); } to { transform: scale(1.07) translate3d(-1.2%, -0.8%, 0); } }
@keyframes grain {
0% { transform: translate(0, 0); } 17% { transform: translate(-4%, 3%); } 33% { transform: translate(3%, -5%); }
50% { transform: translate(-6%, -2%); } 67% { transform: translate(5%, 4%); } 83% { transform: translate(-2%, 6%); }
}
@keyframes cue { from { transform: translateY(-12px); } to { transform: translateY(28px); } }
@keyframes open { from { opacity: 0; transform: scale(0.97); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after { animation: none !important; transition-duration: 1ms !important; transition-delay: 0ms !important; }
.js .reveal, .js .reveal-media, .js .systems .system { opacity: 1; transform: none; filter: none; }
.js .reveal-media .zoom img { transform: none; }
.js .systems .check path { stroke-dashoffset: 0; }
.js .fill { color: var(--text); background: none; }
}
/* --- The not-found page ------------------------------------------------------------- */
.lost { display: grid; min-height: 100vh; }
.lost .hero { min-height: 100vh; }
.lost .hero-content { padding-bottom: clamp(64px, 14vh, 160px); }
.lost .eyebrow { margin-bottom: 12px; }
.lost .hero-title { font-size: clamp(88px, 16vw, 240px); }
.lost .disclaimer { margin-top: 40px; }