diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 000000000..14a497406 --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +shots/ diff --git a/website/Caddyfile b/website/Caddyfile new file mode 100644 index 000000000..ebda45386 --- /dev/null +++ b/website/Caddyfile @@ -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 + } +} diff --git a/website/Dockerfile b/website/Dockerfile new file mode 100644 index 000000000..a5ae2f744 --- /dev/null +++ b/website/Dockerfile @@ -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"] diff --git a/website/LEGAL.md b/website/LEGAL.md new file mode 100644 index 000000000..28c3dc606 --- /dev/null +++ b/website/LEGAL.md @@ -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. diff --git a/website/README.md b/website/README.md new file mode 100644 index 000000000..7a2e6d319 --- /dev/null +++ b/website/README.md @@ -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. diff --git a/website/build.mjs b/website/build.mjs new file mode 100644 index 000000000..0edf6d53c --- /dev/null +++ b/website/build.mjs @@ -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 = ``; +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)) : `Gaps: ${inline(gaps)}`; + const state = full ? `${check}Matches vanilla` : `${escape(score)} · ${escape(scoreLabel(score))}`; + return `
  • +

    ${String(i + 1).padStart(2, "0")}${state}

    +

    ${inline(name)}

    +

    ${detail}

    +
  • `; + }) + .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) || + `

    The release notes could not be loaded here.

    Read them on GitHub`; + +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(/ { + 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(/ + + + + + + + + + +
    +
    + + +
    +

    Horizon

    +

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

    + +

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

    +
    + +
    +

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

    + Scroll +
    +
    + +
    +
    +

    01What it is

    +

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

    +
    +
    +

    Your own copy

    +

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

    +
    +
    +

    Native on three platforms

    +

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

    +
    +
    +

    Open source

    +

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

    +
    +
    +
    +
    + +
    +
    +
    +

    02In game

    +

    Drawn by Horizon

    +

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

    +
    +
    +
    01A plains village
    +
    02Moonrise
    +
    03A crimson forest in the Nether
    +
    04Salmon in a river
    +
    05Survival at dusk
    +
    +
    +
    + +
    +
    +

    03Parity

    +
    +
    + +

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

    +
    +
    +

    {{PARITY_HEADLINE}}

    +
    {{PARITY_INTRO}}
    +

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

    +
    +
    +
      + {{PARITY_CELLS}} +
    +

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

    +
    +
    + +
    +
    +
    +

    04The launcher

    +

    One small app, then play

    +

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

    +
      +
    • One-click install

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

    • +
    • Updates on its own

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

    • +
    • Client or server

      Start the client, or a dedicated server in a folder you choose, with a live console.

    • +
    • Your choice of menus

      Play with Horizon's own menus, shown here, or vanilla's classic screens.

    • +
    +
    + Download the launcher +

    {{LAUNCHER_META}}

    +
    +
    + +
    +
    + + + + + +
    +
    +
    Horizon's title screen: a sidebar, the Horizon title, Singleplayer and Multiplayer buttons and the last world played, over a blurred frozen landscape
    +
    The Singleplayer page: three worlds with their dates and game modes, and Create world and Play buttons
    +
    The Video settings in their Simple view: a preset, render distance, field of view, framerate, VSync and GUI scale, each with what it costs
    +
    The pause screen of the world Glacier Pass, with Back to game, Open to LAN and Save and quit
    +
    +

    Horizon's own menus, in the game. The launcher opens them, or vanilla's.

    +
    +
    +
    + +
    +
    +
    +

    05Patch notes

    +

    What changed

    +
    +
    + {{NOTES}} +
    +
    +
    + +
    +
    +
    +

    06What's next

    +

    With parity as the base

    +

    The roadmap from the README, as it stands on main.

    +
    +
    {{ROADMAP}}
    +
    +
    + +
    + +
    +
    + + + + +
    + + +
    + + + + diff --git a/website/src/main.js b/website/src/main.js new file mode 100644 index 000000000..053396783 --- /dev/null +++ b/website/src/main.js @@ -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 = `Other platforms`; + document.querySelectorAll("[data-download]").forEach((a) => (a.href = asset ? asset.browser_download_url : launcher.html_url || RELEASES_PAGE)); + const mb = asset && asset.size ? ` · ${(asset.size / 1e6).toFixed(0)} MB` : ""; + const meta = asset + ? `For ${escape(os.label)} · launcher ${escape(version)}${mb} · ${others}` + : `Launcher ${escape(version)} for Linux x86-64, macOS on Apple Silicon and Windows x64. It runs on a computer, not on phones or tablets.`; + document.querySelectorAll("[data-download-meta]").forEach((p) => (p.innerHTML = meta)); +} + +// --- 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) => `${escape(c)}`).join(""); + + const items = document.querySelectorAll(".reveal, .reveal-media, .systems"); + if (reducedMotion || !("IntersectionObserver" in window)) { + items.forEach((el) => el.classList.add("is-visible")); + return; + } + const observer = new IntersectionObserver( + (entries) => + entries.forEach((e) => { + if (e.isIntersecting) { + e.target.classList.add("is-visible"); + observer.unobserve(e.target); + } + }), + { rootMargin: "0px 0px -10% 0px" }, + ); + items.forEach((el) => observer.observe(el)); +} + +// 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(); diff --git a/website/src/markdown.js b/website/src/markdown.js new file mode 100644 index 000000000..a17ab4b96 --- /dev/null +++ b/website/src/markdown.js @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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, "$1") + .replace(/(^|[^\w*])\*([^*\s][^*]*)\*/g, "$1$2") + .replace(/(^|[^\w])_([^_\s][^_]*)_(?!\w)/g, "$1$2") + .replace(/<(https?:\/\/[^\s&]+)>/g, (_, u) => `${u}`); + out = out.replace(/\u0001(\d+)\u0001/g, (_, i) => { + const [label, url] = links[i]; + const target = href(url); + const body = inline(label); + return target ? `${body}` : body; + }); + return out.replace(/\u0000(\d+)\u0000/g, (_, i) => `${escape(codes[i])}`); +} + +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) => `
  • ${inline(it.text.join(" "))}${it.children.join("")}
  • `).join(""); + return [`<${tag}>${body}`, 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(`${inline(m[2])}`); + i++; + } else if ((m = line.match(/^\s*(```|~~~)/))) { + const fence = m[1]; + const code = []; + i++; + while (i < lines.length && !lines[i].trim().startsWith(fence)) code.push(lines[i++]); + i++; + out.push(`
    ${escape(code.join("\n"))}
    `); + } else if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { + out.push("
    "); + i++; + } else if (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(`
    ${markdown(quote.join("\n"), { shift })}
    `); + } else { + const para = []; + while ( + i < lines.length && + lines[i].trim() && + !/^(#{1,6}\s|>|\s*```|\s*~~~)/.test(lines[i]) && + !ITEM.test(lines[i]) + ) { + para.push(lines[i++].trim()); + } + out.push(`

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

    `); + } + } + return out.join("\n"); +} + +/** The body of the section titled `title` (any heading level), up to the next heading at its level or above. */ +export function section(source, title) { + const lines = source.replace(/\r\n?/g, "\n").split("\n"); + const level = (l) => (l.match(/^(#{1,6})\s/) || [, ""])[1].length; + const start = lines.findIndex((l) => level(l) > 0 && l.replace(/^#+/, "").trim() === title); + if (start < 0) return null; + const depth = level(lines[start]); + const end = lines.findIndex((l, i) => i > start && level(l) > 0 && level(l) <= depth); + return lines + .slice(start + 1, end < 0 ? undefined : end) + .join("\n") + .trim(); +} diff --git a/website/src/releases.js b/website/src/releases.js new file mode 100644 index 000000000..38810f10b --- /dev/null +++ b/website/src/releases.js @@ -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 = `
    +
    +

    Latest release

    +

    ${escape(l.version)}

    + ${l.line ? `

    ${escape(l.line)}

    ` : ""} + Downloads and full notes +
    +
    ${markdown(latest.body || "")}
    +
    `; + const rest = older + .map((r) => { + const o = parts(r); + return `
    + ${escape(o.version)}${escape(o.line)} +
    ${markdown(r.body || "")}

    Downloads and full notes on GitHub

    +
    `; + }) + .join(""); + return `${head} +
    + ${rest ? `

    Earlier releases

    ${rest}` : ""} +

    Every release on GitHub

    +
    `; +} diff --git a/website/src/style.css b/website/src/style.css new file mode 100644 index 000000000..ad647a1c2 --- /dev/null +++ b/website/src/style.css @@ -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; }