diff --git a/.github/social-preview.png b/.github/social-preview.png index d8344b7..5e90be6 100644 Binary files a/.github/social-preview.png and b/.github/social-preview.png differ diff --git a/PARITY.md b/PARITY.md index 05d244d..82bed85 100644 --- a/PARITY.md +++ b/PARITY.md @@ -11,7 +11,7 @@ Measured from the checkouts named in [RESEARCH.md](RESEARCH.md), on 28 October 2 | [cursor/plugins](https://github.com/cursor/plugins) `pstack/` | `c742dd61706fbacc1b95f1a20b7b988cf096ce6c` | 58 skills, 23 playbooks, Benny automations, agents, `watch-pr`, statechart scripts | | [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent) | `ee7e0f517232fb619c72e49d7e648436bddc598e` | Skills, plugins, MCP, hooks, memory, cron, delegation, browser, terminal backends | -All three stacks are MIT. This repo still does not copy their skill text, word lists, or scripts. No `NOTICE` file until a substantial portion is included. The helpers in the build that follows this file are original. +All three stacks are MIT. This repo still does not copy their skill text, word lists, or scripts. MIT notices for the adapted material are in [THIRD_PARTY_NOTICES](https://github.com/Belweave/hstack/blob/main/THIRD_PARTY_NOTICES). The helpers in the build that follows this file are original. ## How a row is typed diff --git a/site/_headers b/site/_headers index 6f297a1..d2dfa54 100644 --- a/site/_headers +++ b/site/_headers @@ -37,3 +37,7 @@ /dl/hstack-v0.1.0.tar.gz.sha256 Content-Type: text/plain; charset=utf-8 Cache-Control: public, max-age=31536000, immutable + +/fonts/* + Access-Control-Allow-Origin: * + Cache-Control: public, max-age=31536000, immutable diff --git a/site/content/changelog.md b/site/content/changelog.md new file mode 100644 index 0000000..8d9beb7 --- /dev/null +++ b/site/content/changelog.md @@ -0,0 +1,13 @@ +--- +title: Changelog +description: HStack 0.1.0. Twelve skills, an idle index of 204 tokens, optional add-ons, and a sha256-checked install from hstack.sh. +--- + +## 0.1.0 + +28 October 2026. + +- Twelve skills. The idle index is 204 tokens, including the `hstack` category header. The planning path is `hstack`, `reframe`, and `shape` at 965 tokens. All `SKILL.md` bodies are 3,820. +- One router loads a single playbook from `skills/hstack/hstack/references/`. The parity inventory is 520 rows: 67 skills, 260 playbooks, 3 Hermes tools, 15 helper scripts, and 175 skips. +- Optional add-ons, off by default: `browser`, `github`, `docs`, `search`, `perf`, `test`, `security`, `trail`. `--with` and `--uninstall` can name one add-on. +- `install.sh` and `install.ps1` download the archive from hstack.sh, check sha256, and reject absolute paths, `..`, and symlink members. `HSTACK_VERSION=0.1.0` pins the archive. `HSTACK_BASE_URL` points at another host. diff --git a/site/content/docs/addons.md b/site/content/docs/addons.md new file mode 100644 index 0000000..e113cf7 --- /dev/null +++ b/site/content/docs/addons.md @@ -0,0 +1,36 @@ +--- +title: Add-ons +description: Optional HStack add-ons for browser, GitHub, docs, search, perf, test, security, and a decision trail. Off unless you pass --with. +--- + +A default install is skills only. Pass `--with` to turn on helpers and MCP entries. The installer does not download Playwright, gitleaks, or osv-scanner. + +:::tabs +:::tab macOS / Linux +```sh install.sh +sh install.sh --with browser,github,docs,search,perf,test,security,trail +sh install.sh --dry-run --with browser +sh install.sh --uninstall --with browser +``` +:::tab Windows +```powershell install.ps1 +powershell -ExecutionPolicy Bypass -File .\install.ps1 --with browser,github,docs,search,perf,test,security,trail +powershell -ExecutionPolicy Bypass -File .\install.ps1 --dry-run --with browser +powershell -ExecutionPolicy Bypass -File .\install.ps1 --uninstall --with browser +``` +::: + +Helpers are copied to `$HERMES_HOME/hstack/bin`. MCP entries use an `hstack_` prefix in `config.yaml`. `--dry-run` writes nothing. `--uninstall` without `--with` removes the skills and every add-on this installer created. + +| Flag | What you get | +|---|---| +| `browser` | Playwright MCP at `@playwright/mcp@0.0.83`, plus `hstack-visdiff` | +| `github` | `hstack-pr`, one `gh` snapshot | +| `docs` | Context7 MCP, URL only | +| `search` | DeepWiki MCP, URL only | +| `perf` | `hstack-bench` | +| `test` | `hstack-test` | +| `security` | `hstack-scan`, only if gitleaks or osv-scanner is already installed | +| `trail` | `hstack-decisions` | + +The browser server is `npx -y @playwright/mcp@0.0.83` under `mcp_servers.hstack_playwright`. Docs and search store a URL and no token: `https://mcp.context7.com/mcp` and `https://mcp.deepwiki.com/mcp`. diff --git a/site/content/docs/index.md b/site/content/docs/index.md new file mode 100644 index 0000000..90820ad --- /dev/null +++ b/site/content/docs/index.md @@ -0,0 +1,20 @@ +--- +title: Getting started +description: Twelve Hermes Agent skills. One router stays in the index. A playbook loads only when the task needs it. +--- + +HStack is twelve skills for Hermes Agent. Frame the job, shape the change, prove it, and open the pull request. The index stays small because only the router is required reading. + +Skills land in `~/.hermes/skills/hstack/`. Set `HERMES_HOME` when Hermes lives somewhere else. A default install is skills only. Browser, GitHub, docs, search, benchmarks, tests, security scans, and the decision trail stay off until you name them. + +`/hstack` is the one to remember. It loads a catalog, picks one playbook, and stops. Name another skill when you already know the step. + +```text session +/hstack the export drops rows over 10 MB +/reframe +/ship +``` + +The planning path is `hstack`, `reframe`, and `shape`: 965 tokens, under 2% of GStack's planning load on Hermes. The idle index is 204 tokens. All twelve `SKILL.md` bodies are 3,820. Counts use Hermes's estimator, `ceil(bytes / 4)`. + +Playbooks live under `skills/hstack/hstack/references/` and load one at a time. The [skills index](/skills) lists each skill. [Parity](/parity) has one row for every GStack, TStack, and PStack feature that was inventoried. diff --git a/site/content/docs/install.md b/site/content/docs/install.md new file mode 100644 index 0000000..33fc7d2 --- /dev/null +++ b/site/content/docs/install.md @@ -0,0 +1,67 @@ +--- +title: Install +description: Install HStack from hstack.sh on macOS, Linux, or Windows. Dry-run, uninstall, and version pin use the same flags. +--- + +The installer downloads `install.sh` and the skill archive from hstack.sh, checks the sha256, and extracts into the Hermes skills directory. It does not clone the repository. + +:::tabs +:::tab macOS / Linux +```sh install.sh +# archive is checked with sha256 before extract +curl -fsSL https://hstack.sh/install | sh +``` +:::tab Windows +```powershell install.ps1 +irm https://hstack.sh/install.ps1 -OutFile install.ps1 +powershell -ExecutionPolicy Bypass -File .\install.ps1 +``` +::: + +:::note +`irm | iex` does not pass `--dry-run`, `--uninstall`, or `--with`. `install.ps1` reads those flags from `$args` when PowerShell runs the file with `-File`. +::: + +From a checkout of the repo, `sh install.sh` installs the skills beside the script. A second run replaces the HStack skills and leaves every other skill alone. + +## Dry-run + +Dry-run prints the plan and writes nothing. + +:::tabs +:::tab macOS / Linux +```sh install.sh +curl -fsSL https://hstack.sh/install | sh -s -- --dry-run +``` +:::tab Windows +```powershell install.ps1 +powershell -ExecutionPolicy Bypass -File .\install.ps1 --dry-run +``` +::: + +## Uninstall + +Uninstall removes `skills/hstack` only when this installer created it, and it removes add-ons this installer created. `--uninstall --with browser` removes only that add-on. + +:::tabs +:::tab macOS / Linux +```sh install.sh +curl -fsSL https://hstack.sh/install | sh -s -- --uninstall +``` +:::tab Windows +```powershell install.ps1 +powershell -ExecutionPolicy Bypass -File .\install.ps1 --uninstall +``` +::: + +## Version and host + +`HSTACK_VERSION=0.1.0` pins `hstack-v0.1.0.tar.gz` instead of the latest alias. `HSTACK_BASE_URL` points the download at another host, such as a `*.vercel.app` URL before DNS is in place. + +```sh install.sh +curl -fsSL https://hstack.sh/install | HSTACK_VERSION=0.1.0 sh +``` + +The archive is one directory. Inside it: the skills, `scripts/apply_addons.py`, and the `bin/` helpers. The published checksum files are `HEX filename`, two spaces. A mismatch stops the install and does not write skills. + +WSL can run `install.sh`. The Windows script takes the same flags and the same archive. diff --git a/site/content/integrations.md b/site/content/integrations.md new file mode 100644 index 0000000..fd0c80f --- /dev/null +++ b/site/content/integrations.md @@ -0,0 +1,27 @@ +--- +title: Integrations +description: What HStack connects to Hermes. Skills are the default. Browser, GitHub, Context7, DeepWiki, and the helper scripts are opt-in. +--- + +Hermes already has a browser, a terminal, delegation, and a bundled GitHub skill. HStack does not replace those. Optional add-ons sit beside them and remove cleanly. + +| Flag | Configured name | Removes cleanly | +|---|---|---| +| `browser` | `mcp_servers.hstack_playwright`, `bin/hstack-visdiff` | yes | +| `github` | `bin/hstack-pr` | yes | +| `docs` | `mcp_servers.hstack_context7` | yes | +| `search` | `mcp_servers.hstack_deepwiki` | yes | +| `perf` | `bin/hstack-bench` | yes | +| `test` | `bin/hstack-test` | yes | +| `security` | `bin/hstack-scan` | yes | +| `trail` | `bin/hstack-decisions` | yes | + +`browser` points at `npx -y @playwright/mcp@0.0.83`. Hermes `browser_exec` stays the default browser. The add-on adds accessibility snapshots and a visual diff helper. + +`github` wraps the GitHub CLI. Hermes docs prefer `gh` over the GitHub MCP server. `hstack-pr` takes one snapshot. + +`docs` and `search` are URL-only. Context7 is `https://mcp.context7.com/mcp`. DeepWiki is `https://mcp.deepwiki.com/mcp`. No token is written. Local code search stays Hermes `search_files`. + +`security` runs gitleaks or osv-scanner only when one of them is already on `PATH`. The installer does not fetch either binary. It also does not download Playwright. + +Commands, dry-run, and the Windows `-File` form are on the [add-ons](/docs/addons) page. Upstream inventories are [GStack](https://github.com/garrytan/gstack), [TStack](https://github.com/t3dotgg/tstack), and [PStack](https://github.com/cursor/plugins/tree/main/pstack). Hermes itself is documented at [hermes-agent.nousresearch.com](https://hermes-agent.nousresearch.com/docs/). diff --git a/site/fonts/LICENSE.txt b/site/fonts/LICENSE.txt new file mode 100644 index 0000000..5e23939 --- /dev/null +++ b/site/fonts/LICENSE.txt @@ -0,0 +1,98 @@ +Paper Mono +Copyright (c) 2025 Lost Coast Labs, Inc. (Paper Design) + +Based on Geist Mono +Copyright (c) 2023 Vercel, in collaboration with basement.studio + +----------------------------------------------------------- + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND 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. diff --git a/site/fonts/PaperMono[wght].woff2 b/site/fonts/PaperMono[wght].woff2 new file mode 100644 index 0000000..80e9f10 Binary files /dev/null and b/site/fonts/PaperMono[wght].woff2 differ diff --git a/site/fonts/inter-bold.woff2 b/site/fonts/inter-bold.woff2 deleted file mode 100644 index e69189c..0000000 Binary files a/site/fonts/inter-bold.woff2 and /dev/null differ diff --git a/site/fonts/inter-regular.woff2 b/site/fonts/inter-regular.woff2 deleted file mode 100644 index 9348abc..0000000 Binary files a/site/fonts/inter-regular.woff2 and /dev/null differ diff --git a/site/index.html b/site/index.html deleted file mode 100644 index eac7c58..0000000 --- a/site/index.html +++ /dev/null @@ -1,158 +0,0 @@ - - - - - - HStack · Everything Hermes needs - - - - - - - - - - - - - - - - - - - - -
- - - HStack - - - GitHub -
-
-
-
-
-

Everything Hermes needs. Nothing it doesn't.

-
- curl -fsSL https://hstack.sh/install | sh - -
-
    -
  • MIT
  • -
  • Designed for Hermes Agent
  • -
  • 12 skills
  • -
  • ~204 idle tokens
  • -
-
- -
-

-
-
-
-

HStack
planning load

-

965tokens

-
-
- -
-

GStack
planning load

-

62,514tokens

-
-
-

Under 2% of the context. Built for Hermes.

-

A community project. Not affiliated with Nous Research.

-
-
-
- -
-

The index stays small.

-

Hermes reads every skill description on each turn. HStack keeps one short router there. A playbook loads only when the task needs it. Counts use Hermes's estimator, ceil(bytes / 4).

- -
- -
-

Twelve skills.

-

One line in the index. The body loads when you ask for that skill.

-
-
- -
-

One router. One playbook.

-

/hstack is the skill to remember. It opens the route catalog and loads one playbook. Everything else stays on disk.

-
    -
  1. frameDeciding what to build. Load reframe, then stop or hand off.
  2. -
  3. featureNew or changed behavior. Shape it, implement it, prove it.
  4. -
  5. fixSomething is broken. Use systematic-debugging, then prove the fix.
  6. -
  7. checkThe diff needs a hard look. Council only if you asked for other models.
  8. -
  9. landThe work should become a pull request. Load ship.
  10. -
-

A one-line edit does not need a playbook. If you name another HStack skill, that skill loads and the router steps aside.

-
- -
-

Use it in the session.

-

Hermes lists the skills under hstack. Ask with the skill name.

-
/hstack the export drops rows over 10 MB
-/reframe
-/ship
-/council
-
- -
-

Install

-

macOS and Linux. The script is safe to run twice. It only replaces a tree it created.

-
-
-
-

Install

- -
-
curl -fsSL https://hstack.sh/install | sh
-
-
-
-

Dry run

- -
-
curl -fsSL https://hstack.sh/install | sh -s -- --dry-run
-
-
-
-

Uninstall

- -
-
curl -fsSL https://hstack.sh/install | sh -s -- --uninstall
-
-
-

From a checkout of the repo, sh install.sh installs the skills next to the script. HSTACK_BASE_URL points the curl form at another host, which is how you try a *.vercel.app URL before DNS. Optional add-ons stay off until you pass --with.

-
-
- - - - diff --git a/site/package-lock.json b/site/package-lock.json index 0d07097..33acd46 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -6,7 +6,8 @@ "": { "name": "hstack-site", "devDependencies": { - "esbuild": "0.25.10" + "esbuild": "0.25.10", + "marked": "^15.0.12" } }, "node_modules/@esbuild/aix-ppc64": { @@ -492,6 +493,19 @@ "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" } + }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } } } } diff --git a/site/package.json b/site/package.json index a011fa6..d8905a0 100644 --- a/site/package.json +++ b/site/package.json @@ -8,6 +8,7 @@ "check": "node scripts/check-no-html.mjs && node scripts/check-pages.mjs && node scripts/check-vercel.mjs" }, "devDependencies": { - "esbuild": "0.25.10" + "esbuild": "0.25.10", + "marked": "15.0.12" } } diff --git a/site/public/404.html b/site/public/404.html deleted file mode 100644 index 4a84425..0000000 --- a/site/public/404.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - Not found · HStack - - - - -
-

That page is not here.

-

Back to HStack

-
- - diff --git a/site/public/favicon.svg b/site/public/favicon.svg index f0a64b1..1b69fb8 100644 --- a/site/public/favicon.svg +++ b/site/public/favicon.svg @@ -1,8 +1,8 @@ - - - - - + + + + + diff --git a/site/scripts/build.mjs b/site/scripts/build.mjs index d30583d..ecde0a7 100644 --- a/site/scripts/build.mjs +++ b/site/scripts/build.mjs @@ -4,8 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; import crypto from "node:crypto"; -import zlib from "node:zlib"; -import esbuild from "esbuild"; +import { emitSite } from "./render.mjs"; const site = path.resolve(import.meta.dirname, ".."); const root = path.resolve(site, ".."); @@ -41,20 +40,6 @@ function field(md, key, file) { return match[1].trim(); } -function fmt(value) { - return new Intl.NumberFormat("en-US").format(value); -} - -function esc(value) { - return value.replace(/[&<>"']/g, (ch) => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }[ch])); -} - const skills = ORDER.map((name) => { const file = path.join(skillsRoot, name, "SKILL.md"); const md = readFileSync(file, "utf8"); @@ -88,59 +73,18 @@ if (idle !== 204 || planning !== 965 || bodies !== 3820) { throw new Error(`measured idle ${idle}, planning ${planning}, bodies ${bodies}`); } -const stack = skills.map((skill) => { - const label = `${skill.name}. ${skill.description} ${skill.idle} idle tokens, ${fmt(skill.body)} body tokens.`; - return `

${esc(skill.description)}

`; -}).join(""); - -const grid = skills.map((skill) => { - return `

${esc(skill.name)}

${esc(skill.description)}

${skill.idle} idle · ${fmt(skill.body)} body

`; -}).join(""); - -function meter(title, hstack, gstack) { - const share = (hstack / gstack).toFixed(6); - return `

${title}

HStack
${fmt(hstack)}
GStack
${fmt(gstack)}
`; -} - -const compare = `
${meter("Idle, every turn", idle, GSTACK.idle)}${meter("Planning load", planning, GSTACK.planning)}${meter("All skill bodies", bodies, GSTACK.bodies)}
`; - -const css = await esbuild.transform(readFileSync(path.join(site, "src", "styles.css"), "utf8"), { - loader: "css", - minify: true, -}); - -let html = readFileSync(path.join(site, "index.html"), "utf8"); -html = html.replace("/*__CSS__*/", () => css.code); -html = html.replace("", () => stack); -html = html.replace("", () => grid); -html = html.replace("", () => compare); -if (html.includes("__")) throw new Error("unreplaced template marker"); - rmSync(dist, { recursive: true, force: true }); -mkdirSync(path.join(dist, "assets"), { recursive: true }); -mkdirSync(path.join(dist, "dl"), { recursive: true }); -mkdirSync(path.join(dist, "fonts"), { recursive: true }); -writeFileSync(path.join(dist, "index.html"), html); -cpSync(path.join(site, "public"), dist, { recursive: true }); -cpSync(path.join(site, "fonts"), path.join(dist, "fonts"), { recursive: true }); -copyFileSync(path.join(root, ".github", "social-preview.png"), path.join(dist, "social-preview.png")); -copyFileSync(path.join(root, "install.sh"), path.join(dist, "install.sh")); -copyFileSync(path.join(root, "install.ps1"), path.join(dist, "install.ps1")); -copyFileSync(path.join(site, "_headers"), path.join(dist, "_headers")); -copyFileSync(path.join(site, "_redirects"), path.join(dist, "_redirects")); - -await esbuild.build({ - entryPoints: [path.join(site, "src", "main.js")], - bundle: true, - minify: true, - format: "esm", - target: "es2020", - outfile: path.join(dist, "assets", "app.js"), +const jsGzip = await emitSite({ + site, + root, + dist, + skills, + idle, + planning, + bodies, + gstack: GSTACK, }); -const jsGzip = zlib.gzipSync(readFileSync(path.join(dist, "assets", "app.js"))).length; -if (jsGzip > 250 * 1024) throw new Error(`app.js is ${jsGzip} bytes gzipped`); - const helpers = [ "hstack-bench", "hstack-decisions", diff --git a/site/scripts/check-no-html.mjs b/site/scripts/check-no-html.mjs index d89967d..8afddd6 100644 --- a/site/scripts/check-no-html.mjs +++ b/site/scripts/check-no-html.mjs @@ -1,9 +1,8 @@ -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync, statSync } from "node:fs"; import path from "node:path"; import { createPreview } from "./preview.mjs"; const dist = path.resolve(import.meta.dirname, "..", "dist"); -const html = readFileSync(path.join(dist, "index.html"), "utf8"); const server = createPreview(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address(); @@ -14,19 +13,61 @@ function fail(message) { process.exitCode = 1; } -const urls = new Set(["/favicon.svg", "/social-preview.png", "/install", "/install/", "/install.sh", "/install.ps1"]); -for (const match of html.matchAll(/(?:src|href)="(\/[^"]+)"/g)) urls.add(match[1]); -const style = html.match(/ + + +
+
+

HStack

+

Everything Hermes needs.
Nothing it doesn't.

+
+
+
Idle${fmt(idle)}
+
Planning${fmt(planning)}
+
Bodies${fmt(bodies)}
+
+
+ + +`; +} + +export async function emitSite({ site, root, dist, skills, idle, planning, bodies, gstack }) { + const skillsRoot = path.join(root, "skills", "hstack"); + const md = createMarkdown(); + mkdirSync(path.join(dist, "assets"), { recursive: true }); + mkdirSync(path.join(dist, "dl"), { recursive: true }); + mkdirSync(path.join(dist, "fonts"), { recursive: true }); + cpSync(path.join(site, "public"), dist, { recursive: true }); + cpSync(path.join(site, "fonts"), path.join(dist, "fonts"), { recursive: true }); + cpSync(path.join(root, ".github", "social-preview.png"), path.join(dist, "social-preview.png")); + cpSync(path.join(root, "install.sh"), path.join(dist, "install.sh")); + cpSync(path.join(root, "install.ps1"), path.join(dist, "install.ps1")); + cpSync(path.join(site, "_headers"), path.join(dist, "_headers")); + cpSync(path.join(site, "_redirects"), path.join(dist, "_redirects")); + + const css = await esbuild.transform(readFileSync(path.join(site, "src", "site.css"), "utf8"), { + loader: "css", + minify: true, + }); + writeFileSync(path.join(dist, "assets", "site.css"), css.code); + await esbuild.build({ + entryPoints: [path.join(site, "src", "site.js")], + bundle: true, + minify: true, + format: "esm", + target: "es2020", + outfile: path.join(dist, "assets", "site.js"), + }); + const jsGzip = zlib.gzipSync(readFileSync(path.join(dist, "assets", "site.js"))).length; + if (jsGzip > 250 * 1024) throw new Error(`site.js is ${jsGzip} bytes gzipped`); + + function write(pathname, html) { + const rel = pathname === "/" ? "index.html" : `${pathname.slice(1)}/index.html`; + const file = path.join(dist, rel); + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, html); + } + + write("/", home({ skills, idle, planning, bodies, gstack })); + + const contentDir = path.join(site, "content"); + const pages = [ + ["/docs", path.join(contentDir, "docs", "index.md"), "Docs"], + ["/docs/install", path.join(contentDir, "docs", "install.md"), "Docs"], + ["/docs/addons", path.join(contentDir, "docs", "addons.md"), "Docs"], + ["/integrations", path.join(contentDir, "integrations.md"), null], + ["/changelog", path.join(contentDir, "changelog.md"), null], + ]; + for (const [pathname, file, section] of pages) { + const { meta, body } = readContent(file); + const article = `

${esc(section || meta.title)}

${esc(meta.title)}

${md.parse(body)}
`; + const main = section ? sheet(side(DOCS, pathname, "Docs"), article) : `
${article}
`; + write(pathname, layout({ + pathname, + title: meta.title, + description: meta.description, + main, + })); + } + + const skillLinks = skills.map((skill) => [`/skills/${skill.name}`, skill.name]); + const indexItems = skills.map((skill, index) => `
  • ${String(index + 1).padStart(2, "0")}${esc(skill.name)}${esc(skill.description)}${fmt(skill.body)} body
  • `).join(""); + write("/skills", layout({ + pathname: "/skills", + title: "Skills", + description: "Twelve HStack skills. The router is hstack. The others are reframe, shape, prove, council, ship, takeover, modernize, blast, swarm, arena, and principles.", + main: sheet(side(skillLinks, "/skills", "Skills"), `

    Skills

    Skills

    Idle cost is the index row. Body tokens are the full SKILL.md, loaded only when that skill is opened.

      ${indexItems}
    `), + })); + + const plays = references(skillsRoot); + for (const skill of skills) { + const file = path.join(skillsRoot, skill.name, "SKILL.md"); + const raw = readFileSync(file, "utf8"); + const { body } = frontmatter(raw); + const stripped = body.replace(/^\s*#\s+.+\n+/, ""); + let extra = ""; + if (skill.name === "hstack") { + extra = plays.map((play) => `
    ${md.parse(play.md, 1)}
    `).join(""); + } + const pathname = `/skills/${skill.name}`; + write(pathname, layout({ + pathname, + title: skill.name, + description: skill.description, + main: sheet(side(skillLinks, pathname, "Skills"), `

    Skill

    ${esc(skill.name)}

    ${skill.idle} idle · ${fmt(skill.body)} body · v0.1.0

    ${md.parse(stripped)}${extra}
    `), + })); + } + + const parityMd = readFileSync(path.join(root, "PARITY.md"), "utf8"); + const splitAt = parityMd.indexOf("\n## Feature table"); + const narrative = parityMd.slice(0, splitAt).replace(/^#\s+.+\n+/, ""); + write("/parity", layout({ + pathname: "/parity", + title: "Parity", + description: "520-row inventory of GStack, TStack, and PStack features, and where each one went in HStack.", + wide: true, + main: `

    Inventory

    Parity

    ${md.parse(narrative)}${parityTable(parityMd.slice(splitAt))}
    `, + })); + + function legal(pathname, title, description, file) { + const text = readFileSync(path.join(root, file), "utf8"); + write(pathname, layout({ + pathname, + title, + description, + main: `

    License

    ${esc(title)}

    `, + })); + } + legal("/license", "MIT License", "MIT license for HStack. Copyright (c) 2026 Preetham Kyanam / belweave.", "LICENSE"); + legal("/notices", "Third-party notices", "MIT notices for GStack and TStack material adapted by HStack.", "THIRD_PARTY_NOTICES"); + + const missing = layout({ + pathname: "/404", + title: "Page not found", + description: "That page is not on hstack.sh.", + main: `

    404

    Page not found

    That URL is not on this site.

    Home · Docs

    `, + }); + writeFileSync(path.join(dist, "404.html"), missing); + writeFileSync(path.join(dist, "og.html"), ogDocument({ idle, planning, bodies })); + return jsGzip; +} diff --git a/site/scripts/shoot.mjs b/site/scripts/shoot.mjs index bfb87c8..e79dd99 100644 --- a/site/scripts/shoot.mjs +++ b/site/scripts/shoot.mjs @@ -1,16 +1,18 @@ import { spawn } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; -const base = process.env.SHOT_URL || "http://127.0.0.1:4173/"; -const outDir = process.env.SHOT_DIR || "/tmp/hstack-shots"; +const base = process.env.SHOT_URL || "http://127.0.0.1:4173"; +const outDir = process.env.SHOT_DIR || "/opt/cursor/artifacts"; mkdirSync(outDir, { recursive: true }); const chrome = spawn("google-chrome", [ "--headless=new", "--no-sandbox", "--disable-gpu", - "--remote-debugging-port=9222", - "--user-data-dir=/tmp/chrome-hstack-shots", + "--hide-scrollbars", + "--remote-debugging-port=9333", + "--user-data-dir=/tmp/chrome-hstack-redesign", "about:blank", ], { stdio: "ignore" }); @@ -19,9 +21,9 @@ function sleep(ms) { } async function browserSocket() { - for (let i = 0; i < 40; i += 1) { + for (let i = 0; i < 50; i += 1) { try { - const version = await (await fetch("http://127.0.0.1:9222/json/version")).json(); + const version = await (await fetch("http://127.0.0.1:9333/json/version")).json(); return version.webSocketDebuggerUrl; } catch { await sleep(150); @@ -30,10 +32,9 @@ async function browserSocket() { throw new Error("chrome did not open a debugging port"); } -let id = 0; +let seq = 0; function cdp(ws) { const pending = new Map(); - const events = new Map(); ws.addEventListener("message", (event) => { const message = JSON.parse(event.data); if (message.id && pending.has(message.id)) { @@ -41,26 +42,14 @@ function cdp(ws) { pending.delete(message.id); if (message.error) reject(new Error(JSON.stringify(message.error))); else resolve(message.result); - } else if (message.method) { - for (const fn of events.get(message.method) || []) fn(message.params); } }); return { call(method, params = {}) { return new Promise((resolve, reject) => { - const next = ++id; - pending.set(next, { resolve, reject }); - ws.send(JSON.stringify({ id: next, method, params })); - }); - }, - once(method) { - return new Promise((resolve) => { - const list = events.get(method) || []; - list.push((params) => { - events.set(method, (events.get(method) || []).filter((fn) => fn !== list[list.length - 1])); - resolve(params); - }); - events.set(method, list); + const id = ++seq; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); }); }, }; @@ -70,49 +59,148 @@ const browser = new WebSocket(await browserSocket()); await new Promise((resolve) => browser.addEventListener("open", resolve)); const send = cdp(browser); const { targetId } = await send.call("Target.createTarget", { url: "about:blank" }); -const { webSocketDebuggerUrl } = (await (await fetch("http://127.0.0.1:9222/json/list")).json()) +const { webSocketDebuggerUrl } = (await (await fetch("http://127.0.0.1:9333/json/list")).json()) .find((target) => target.id === targetId); const page = new WebSocket(webSocketDebuggerUrl); await new Promise((resolve) => page.addEventListener("open", resolve)); const call = cdp(page); await call.call("Page.enable"); await call.call("Runtime.enable"); +try { + await call.call("Browser.grantPermissions", { + origin: base, + permissions: ["clipboardReadWrite", "clipboardSanitizedWrite"], + }); +} catch { + // Clipboard is best-effort in this headless session. +} + +async function evaluate(expression) { + const result = await call.call("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }); + if (result.exceptionDetails) { + const detail = result.exceptionDetails.exception?.description || result.exceptionDetails.text; + throw new Error(detail || "page exception"); + } + return result.result?.value; +} -async function shot(name, width, height, fan) { +async function open(urlPath, width, height, mobile) { await call.call("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, - mobile: width < 500, + mobile, }); - const loaded = call.once("Page.loadEventFired"); - const url = fan ? `${base}?spread=1` : base; - await call.call("Page.navigate", { url }); - await loaded; - await call.call("Runtime.evaluate", { - expression: `document.fonts.ready.then(() => new Promise((resolve) => { - const started = performance.now(); - const tick = () => { - const live = document.querySelector(".stage")?.classList.contains("is-live"); - if (live || performance.now() - started > 2500) resolve(live ? "live" : "poster"); - else requestAnimationFrame(tick); - }; - tick(); - }))`, - awaitPromise: true, - }); - await sleep(fan ? 700 : 1200); - const shot = await call.call("Page.captureScreenshot", { format: "png" }); - const file = `${outDir}/${name}.png`; - writeFileSync(file, Buffer.from(shot.data, "base64")); + await call.call("Page.navigate", { url: `${base}${urlPath}` }); + await call.call("Page.loadEventFired").catch(() => {}); + for (let i = 0; i < 40; i += 1) { + const ready = await evaluate("document.readyState"); + if (ready === "complete") break; + await sleep(50); + } + await evaluate("document.fonts.ready"); + await sleep(40); +} + +async function shot(name) { + const { data } = await call.call("Page.captureScreenshot", { format: "png" }); + const file = path.join(outDir, `${name}.png`); + writeFileSync(file, Buffer.from(data, "base64")); console.log(file); } +function assert(condition, message) { + if (!condition) throw new Error(message); +} + try { - await shot("desktop-hero", 1440, 900, false); - await shot("desktop-fan", 1440, 900, true); - await shot("mobile-hero", 390, 844, false); - await shot("mobile-fan", 390, 844, true); +const pages = [ + ["/ ", "home"], + ["/docs", "docs"], + ["/skills/reframe", "skill"], + ["/parity", "parity"], +]; + +for (const [urlPath, name] of pages) { + const route = urlPath.trim(); + await open(route, 1440, 900, false); + if (name === "parity") { + await evaluate("(() => { const el = document.querySelector('.filters'); window.scrollTo(0, el.getBoundingClientRect().top + window.scrollY - 80); })()"); + } + await shot(`${name}-desktop`); + await open(route, 390, 844, true); + if (name === "parity") { + await evaluate("(() => { const el = document.querySelector('.filters'); window.scrollTo(0, el.getBoundingClientRect().top + window.scrollY - 64); })()"); + } + await shot(`${name}-mobile`); +} + +await open("/", 1440, 900, false); +const fontReady = await evaluate("document.fonts.check('16px \"Paper Mono\"')"); +assert(fontReady === true, "Paper Mono did not load"); +const windows = await evaluate(` + document.querySelector("#tab-1-1").click(); + const selected = document.querySelector("#tab-1-1").getAttribute("aria-selected"); + const text = document.querySelector("#panel-1-1").innerText; + selected === "true" && text.includes("install.ps1") && document.querySelector("#panel-1-0").hidden === true +`); +assert(windows === true, "install tabs did not switch to Windows"); +const copied = await evaluate(` + (async () => { + const button = document.querySelector("#panel-1-1 [data-copy]"); + button.click(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const wrote = await navigator.clipboard.readText(); + return button.textContent === "Copied" && wrote.includes("install.ps1"); + })() +`); +assert(copied === true, "copy button did not write the Windows command"); + +await open("/parity", 1440, 900, false); +const filtered = await evaluate(`(() => { + const input = document.querySelector("#parity-q"); + input.value = "canary"; + input.dispatchEvent(new Event("input", { bubbles: true })); + const text = document.querySelector("#parity-count").textContent; + const shown = [...document.querySelectorAll("[data-parity-row]")].filter((row) => !row.hidden).length; + return text.includes("of 520") && shown > 0 && shown < 520; +})()`); +assert(filtered === true, "parity search did not filter"); +const source = await evaluate(`(() => { + const input = document.querySelector("#parity-q"); + input.value = ""; + input.dispatchEvent(new Event("input", { bubbles: true })); + document.querySelector('[data-filter="source"][data-value="tstack"]').click(); + const pressed = document.querySelector('[data-filter="source"][data-value="tstack"]').getAttribute("aria-pressed"); + const shown = [...document.querySelectorAll("[data-parity-row]")].filter((row) => !row.hidden).length; + return pressed === "true" && shown > 0 && shown < 520; +})()`); +assert(source === true, "parity source filter did not apply"); + +await open("/docs", 390, 844, true); +const menu = await evaluate(` + const button = document.querySelector(".menu"); + const before = getComputedStyle(document.querySelector(".top nav")).display; + button.click(); + const after = getComputedStyle(document.querySelector(".top nav")).display; + before === "none" && after !== "none" && button.getAttribute("aria-expanded") === "true" +`); +assert(menu === true, "mobile menu did not open"); +await shot("docs-mobile-menu"); + +await open("/og.html", 1280, 640, false); +const { data } = await call.call("Page.captureScreenshot", { format: "png", clip: { x: 0, y: 0, width: 1280, height: 640, scale: 1 } }); +const og = Buffer.from(data, "base64"); +writeFileSync(path.join(outDir, "social-preview.png"), og); +writeFileSync("/workspace/.github/social-preview.png", og); +writeFileSync("/workspace/site/dist/social-preview.png", og); +console.log("social-preview.png"); + +console.log("shots ok"); } finally { browser.close(); page.close(); diff --git a/site/src/glass.js b/site/src/glass.js deleted file mode 100644 index 56f8c60..0000000 --- a/site/src/glass.js +++ /dev/null @@ -1,182 +0,0 @@ -const VERT = `#version 300 es -layout(location=0) in vec2 aPos; -layout(location=1) in vec2 aUv; -out vec2 vUv; -void main() { - vUv = aUv; - gl_Position = vec4(aPos, 0.0, 1.0); -} -`; - -const FRAG = `#version 300 es -precision highp float; -in vec2 vUv; -uniform float uSeed; -uniform float uTime; -uniform float uHover; -uniform float uMode; -uniform vec2 uSize; -out vec4 frag; - -void main() { - if (uMode < 0.5) { - vec2 q = (vUv - 0.5) * vec2(1.15, 0.72); - float d = length(q); - float a = smoothstep(0.52, 0.12, d) * 0.22; - vec3 ink = vec3(0.22, 0.16, 0.10); - frag = vec4(ink * a, a); - return; - } - - vec2 p = vUv * 2.0 - 1.0; - float rad = min(uSize.y * 0.5, 18.0); - vec2 box = uSize * 0.5; - vec2 q = abs(p * box) - (box - rad); - float sdf = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - rad; - if (sdf > 0.6) discard; - - float edge = smoothstep(-16.0, 0.5, sdf); - float film = vUv.x * 1.35 + vUv.y * 0.45 + uSeed * 0.23 + uTime * 0.05; - vec3 irid = 0.58 + 0.42 * cos(6.2831853 * (vec3(film) + vec3(0.0, 0.33, 0.66))); - vec3 frost = vec3(0.965, 0.955, 0.94); - vec3 col = mix(frost, irid, 0.34 + edge * 0.5); - float streak = sin((p.x + uSeed) * 7.0 + uTime * 0.35); - col += irid * smoothstep(0.2, 1.0, streak) * 0.12; - col += vec3(1.0, 0.99, 0.96) * smoothstep(0.35, 0.0, vUv.y) * 0.18; - col *= mix(0.88, 1.0, smoothstep(0.95, 0.4, vUv.y)); - col = mix(col, vec3(1.0), uHover * 0.05); - float alpha = (0.8 + edge * 0.12 + uHover * 0.04) * smoothstep(0.6, -0.8, sdf); - frag = vec4(col * alpha, alpha); -} -`; - -function compile(gl, type, source) { - const shader = gl.createShader(type); - gl.shaderSource(shader, source); - gl.compileShader(shader); - if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - gl.deleteShader(shader); - return null; - } - return shader; -} - -export function mountGlass(stage) { - const canvas = stage.querySelector("canvas"); - const gl = canvas.getContext("webgl2", { - alpha: true, - antialias: true, - premultipliedAlpha: true, - powerPreference: "high-performance", - }); - if (!gl) return null; - - const vs = compile(gl, gl.VERTEX_SHADER, VERT); - const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG); - if (!vs || !fs) return null; - const program = gl.createProgram(); - gl.attachShader(program, vs); - gl.attachShader(program, fs); - gl.linkProgram(program); - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return null; - - const buffer = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, buffer); - gl.useProgram(program); - gl.enable(gl.BLEND); - gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); - const loc = { - seed: gl.getUniformLocation(program, "uSeed"), - time: gl.getUniformLocation(program, "uTime"), - hover: gl.getUniformLocation(program, "uHover"), - mode: gl.getUniformLocation(program, "uMode"), - size: gl.getUniformLocation(program, "uSize"), - }; - - const plates = () => [...stage.querySelectorAll(".plate")]; - - function draw(time, hoverIndex) { - const cssW = stage.clientWidth; - const cssH = stage.clientHeight; - if (cssW < 2 || cssH < 2) return; - const dpr = Math.min(window.devicePixelRatio || 1, 1.75); - const w = Math.round(cssW * dpr); - const h = Math.round(cssH * dpr); - if (canvas.width !== w || canvas.height !== h) { - canvas.width = w; - canvas.height = h; - } - gl.viewport(0, 0, w, h); - gl.clearColor(0, 0, 0, 0); - gl.clear(gl.COLOR_BUFFER_BIT); - - const box = stage.getBoundingClientRect(); - const list = plates(); - const rects = list.map((plate) => { - const r = plate.getBoundingClientRect(); - return { - x: (r.left - box.left) * dpr, - y: (r.top - box.top) * dpr, - w: r.width * dpr, - h: r.height * dpr, - }; - }); - - const union = rects.reduce( - (acc, r) => ({ - x: Math.min(acc.x, r.x), - y: Math.min(acc.y, r.y), - r: Math.max(acc.r, r.x + r.w), - b: Math.max(acc.b, r.y + r.h), - }), - { x: w, y: h, r: 0, b: 0 }, - ); - drawQuad(gl, buffer, loc, union.x - 24 * dpr, union.b - 8 * dpr, union.r - union.x + 48 * dpr, 54 * dpr, w, h, { - mode: 0, - seed: 0, - time, - hover: 0, - }); - - for (let i = rects.length - 1; i >= 0; i -= 1) { - const r = rects[i]; - const padX = 6 * dpr; - const padY = 1.5 * dpr; - drawQuad(gl, buffer, loc, r.x - padX, r.y - padY, r.w + padX * 2, r.h + padY * 2, w, h, { - mode: 1, - seed: i * 1.37, - time, - hover: hoverIndex === i ? 1 : 0, - }); - } - } - - return { draw }; -} - -function drawQuad(gl, buffer, loc, x, y, bw, bh, w, h, style) { - const x0 = (x / w) * 2 - 1; - const x1 = ((x + bw) / w) * 2 - 1; - const y0 = 1 - (y / h) * 2; - const y1 = 1 - ((y + bh) / h) * 2; - const data = new Float32Array([ - x0, y0, 0, 0, - x1, y0, 1, 0, - x0, y1, 0, 1, - x0, y1, 0, 1, - x1, y0, 1, 0, - x1, y1, 1, 1, - ]); - gl.bindBuffer(gl.ARRAY_BUFFER, buffer); - gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW); - gl.enableVertexAttribArray(0); - gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 16, 0); - gl.enableVertexAttribArray(1); - gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 16, 8); - gl.uniform1f(loc.mode, style.mode); - gl.uniform1f(loc.seed, style.seed); - gl.uniform1f(loc.time, style.time); - gl.uniform1f(loc.hover, style.hover); - gl.uniform2f(loc.size, bw, bh); - gl.drawArrays(gl.TRIANGLES, 0, 6); -} diff --git a/site/src/main.js b/site/src/main.js deleted file mode 100644 index 1af9841..0000000 --- a/site/src/main.js +++ /dev/null @@ -1,178 +0,0 @@ -import { mountGlass } from "./glass.js"; - -const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; -const stage = document.querySelector(".stage"); -const pin = document.querySelector(".pin"); -const stack = document.querySelector(".stack"); -const plates = [...document.querySelectorAll(".plate")]; -const live = document.querySelector("#plate-live"); -const glass = mountGlass(stage); - -let pointerX = 0; -let pointerY = 0; -let hover = -1; -let spread = 0; -let running = false; - -function clamp(value, min, max) { - return Math.min(max, Math.max(min, value)); -} - -function format(value) { - return new Intl.NumberFormat("en-US").format(value); -} - -function applySpread(value) { - spread = value; - stack.style.setProperty("--spread", value.toFixed(4)); -} - -const forcedSpread = new URLSearchParams(window.location.search).get("spread"); - -function readSpread() { - if (forcedSpread != null && forcedSpread !== "") { - const value = Number(forcedSpread); - if (Number.isFinite(value)) return clamp(value, 0, 1); - } - if (!pin) return 0; - const rect = pin.getBoundingClientRect(); - const distance = pin.offsetHeight - window.innerHeight; - if (distance <= 0) return 0; - return clamp(-rect.top / distance, 0, 1); -} - -function draw(now) { - if (!glass) return; - const t = reduced ? 0 : now / 1000; - const driftX = reduced ? pointerX : pointerX + Math.sin(t * 0.35) * 0.12; - const driftY = reduced ? pointerY : pointerY + Math.cos(t * 0.28) * 0.08; - stack.style.setProperty("--px", driftX.toFixed(4)); - stack.style.setProperty("--py", driftY.toFixed(4)); - glass.draw(t, hover); -} - -function frame(now) { - applySpread(readSpread()); - draw(now); - if (running) requestAnimationFrame(frame); -} - -function start() { - if (glass) stage.classList.add("is-live"); - if (reduced) { - applySpread(readSpread()); - draw(0); - return; - } - running = true; - requestAnimationFrame(frame); -} - -stage.addEventListener("pointermove", (event) => { - if (reduced) return; - const rect = stage.getBoundingClientRect(); - pointerX = clamp((event.clientX - rect.left) / rect.width - 0.5, -0.5, 0.5); - pointerY = clamp((event.clientY - rect.top) / rect.height - 0.5, -0.5, 0.5); -}); - -stage.addEventListener("pointerleave", () => { - pointerX = 0; - pointerY = 0; - hover = -1; - if (reduced) draw(0); -}); - -plates.forEach((plate, index) => { - plate.addEventListener("pointerenter", () => { - hover = index; - if (reduced) draw(0); - }); - plate.addEventListener("focus", () => { - hover = index; - if (live) live.textContent = plate.getAttribute("aria-label") || ""; - if (reduced) draw(0); - }); - plate.addEventListener("click", () => { - const open = plate.classList.toggle("is-open"); - plate.setAttribute("aria-expanded", open ? "true" : "false"); - plates.forEach((other) => { - if (other !== plate) { - other.classList.remove("is-open"); - other.setAttribute("aria-expanded", "false"); - } - }); - if (live) live.textContent = open ? plate.getAttribute("aria-label") || "" : ""; - hover = index; - if (reduced) draw(0); - }); -}); - -document.querySelectorAll("[data-copy]").forEach((button) => { - button.addEventListener("click", async () => { - const text = button.getAttribute("data-copy") || ""; - try { - await navigator.clipboard.writeText(text); - } catch { - return; - } - const previous = button.getAttribute("aria-label"); - button.classList.add("is-copied"); - button.setAttribute("aria-label", "Copied"); - window.setTimeout(() => { - button.classList.remove("is-copied"); - if (previous) button.setAttribute("aria-label", previous); - }, 1400); - }); -}); - -function countUp(el) { - const target = Number(el.getAttribute("data-count")); - if (!Number.isFinite(target)) return; - if (reduced) { - el.textContent = format(target); - return; - } - const started = performance.now(); - const duration = 900; - function tick(now) { - const t = clamp((now - started) / duration, 0, 1); - const eased = 1 - (1 - t) ** 3; - el.textContent = format(Math.round(target * eased)); - if (t < 1) requestAnimationFrame(tick); - } - requestAnimationFrame(tick); -} - -const counts = [...document.querySelectorAll("[data-count]")]; -if ("IntersectionObserver" in window) { - const seen = new WeakSet(); - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (!entry.isIntersecting || seen.has(entry.target)) return; - seen.add(entry.target); - countUp(entry.target); - }); - }, { threshold: 0.6 }); - counts.forEach((el) => observer.observe(el)); -} else { - counts.forEach(countUp); -} - -window.addEventListener("scroll", () => { - if (!reduced) return; - applySpread(readSpread()); - draw(0); -}, { passive: true }); - -window.addEventListener("resize", () => { - if (reduced) { - applySpread(readSpread()); - draw(0); - } -}); - -if (document.fonts && document.fonts.ready) { - document.fonts.ready.then(start); -} else { - start(); -} diff --git a/site/src/site.css b/site/src/site.css new file mode 100644 index 0000000..b7fdc04 --- /dev/null +++ b/site/src/site.css @@ -0,0 +1,438 @@ +@font-face { + font-family: "Paper Mono"; + src: url("/fonts/PaperMono%5Bwght%5D.woff2") format("woff2"); + font-weight: 100 800; + font-style: normal; + font-display: swap; +} + +:root { + --bg: #0c100e; + --surface: #141a17; + --line: #2c3833; + --text: #f4f7f5; + --muted: #a7b2ac; + --accent: #7ddcb2; + color-scheme: dark; +} + +* { box-sizing: border-box; } +html { background: var(--bg); } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: "Paper Mono", ui-monospace, monospace; + font-size: 16px; + font-weight: 400; + line-height: 1.55; + font-feature-settings: "ss02" 1, "ss03" 1; + text-rendering: optimizeLegibility; +} +code, pre, kbd, .code, .fig, td.fig { + font-feature-settings: "zero" 1; +} + +:focus { outline: none; } +:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 3px; +} +::selection { background: #1c3a2e; color: var(--text); } + +.skip { + position: absolute; + left: 16px; + top: -48px; + z-index: 8; + background: var(--accent); + color: var(--bg); + padding: 10px 14px; + text-decoration: none; +} +.skip:focus { top: 12px; } + +.top { + position: sticky; + top: 0; + z-index: 5; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0 24px; + padding: 0 28px; + background: var(--bg); + border-bottom: 1px solid var(--line); +} +.brand { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 56px; + color: var(--text); + text-decoration: none; + letter-spacing: 0.14em; + text-transform: uppercase; + font-size: 14px; +} +.brand svg { display: block; } +.menu { + display: none; + min-height: 44px; + min-width: 44px; + padding: 0 4px; + border: 0; + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; +} +.top nav { + display: flex; + align-items: center; + gap: 4px; +} +.top nav a { + display: inline-flex; + align-items: center; + min-height: 56px; + padding: 0 12px; + color: var(--muted); + text-decoration: none; + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.top nav a[aria-current="page"] { + color: var(--accent); + box-shadow: inset 0 -1px 0 var(--accent); +} + +.band { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + border-bottom: 1px solid var(--line); +} +.idx { + padding: 36px 0 0 28px; + color: var(--muted); + font-size: 13px; + letter-spacing: 0.08em; + border-right: 1px solid var(--line); +} +.band-body { + padding: 36px 32px 48px; + max-width: 980px; +} +h1, h2, h3, h4 { font-weight: 400; letter-spacing: -0.03em; line-height: 1.05; } +h1 { + margin: 0; + font-size: clamp(28px, 4.4vw, 64px); + max-width: 22em; +} +h2 { margin: 0 0 16px; font-size: clamp(28px, 3vw, 40px); max-width: 22ch; } +h3 { margin: 28px 0 8px; font-size: 20px; letter-spacing: -0.02em; } +h4 { margin: 22px 0 8px; font-size: 16px; letter-spacing: 0; } +.kicker { + margin: 0 0 16px; + color: var(--accent); + font-size: 13px; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.lede { + margin: 24px 0 0; + max-width: 42ch; + font-size: 18px; + line-height: 1.45; + color: var(--text); +} +.prose { max-width: 68ch; } +.prose > :first-child { margin-top: 0; } +.prose p, .prose li { color: var(--text); } +.prose p { margin: 0 0 16px; } +.prose ul, .prose ol { margin: 0 0 16px; padding-left: 1.25em; } +.prose li { margin: 4px 0; } +.prose a, .site-foot a, .page > p a { + color: var(--accent); + text-underline-offset: 3px; +} +.muted { color: var(--muted); } +.page > .kicker { margin-top: 0; } + +.sheet { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + align-items: start; + min-height: calc(100vh - 220px); +} +.side { + position: sticky; + top: 57px; + max-height: calc(100vh - 57px); + overflow: auto; + padding: 20px 8px 32px 20px; + border-right: 1px solid var(--line); +} +.side .kicker { padding-left: 8px; } +.side a { + display: flex; + align-items: center; + min-height: 44px; + padding: 0 8px; + color: var(--muted); + text-decoration: none; + font-size: 14px; +} +.side a[aria-current="page"] { color: var(--accent); box-shadow: inset 2px 0 0 var(--accent); } +.page { padding: 36px 32px 72px; min-width: 0; } +.page.wide .prose { max-width: none; } + +.roster { list-style: none; margin: 8px 0 0; padding: 0; } +.roster a { + display: grid; + grid-template-columns: 36px 132px minmax(0, 1fr) auto; + gap: 12px 16px; + align-items: baseline; + min-height: 44px; + padding: 12px 0; + border-top: 1px solid var(--line); + color: inherit; + text-decoration: none; +} +.roster li:last-child a { border-bottom: 1px solid var(--line); } +.roster .num, .roster .tok { color: var(--muted); font-size: 13px; } +.roster .name { color: var(--text); } +.roster a:hover .name, .roster a:focus-visible .name { color: var(--accent); } + +table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + line-height: 1.45; +} +caption { + caption-side: bottom; + padding: 12px 0 0; + text-align: left; + color: var(--muted); + font-size: 13px; +} +th, td { + padding: 10px 12px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: top; +} +th { + color: var(--muted); + font-weight: 400; + font-size: 13px; + letter-spacing: 0.06em; + text-transform: uppercase; +} +td.fig, .fig { color: var(--accent); } +.table-wrap { overflow-x: auto; margin: 8px 0 20px; } +.parity { min-width: 1120px; } +.parity th, .parity td { white-space: nowrap; } +.parity th:last-child, .parity td:last-child { white-space: normal; min-width: 420px; } + +.filters { margin: 8px 0 20px; } +.filters label { + display: block; + margin-bottom: 8px; + color: var(--muted); + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +input[type="search"] { + width: min(100%, 440px); + min-height: 44px; + padding: 0 12px; + border: 1px solid var(--line); + border-radius: 0; + background: var(--bg); + color: var(--text); + font: inherit; +} +.groups { display: flex; flex-wrap: wrap; gap: 18px 28px; margin-top: 16px; } +.group { display: flex; flex-wrap: wrap; gap: 0; } +.group button { + min-height: 44px; + padding: 0 12px; + border: 0; + border-bottom: 1px solid transparent; + border-radius: 0; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 13px; + letter-spacing: 0.04em; + cursor: pointer; +} +.group button[aria-pressed="true"] { + color: var(--accent); + border-bottom-color: var(--accent); +} +#parity-count { margin: 12px 0 0; color: var(--muted); font-size: 13px; } + +.tabs { margin: 20px 0; border: 1px solid var(--line); background: var(--surface); } +[role="tablist"] { display: flex; border-bottom: 1px solid var(--line); } +[role="tab"] { + min-height: 44px; + padding: 0 16px; + border: 0; + border-bottom: 1px solid transparent; + border-radius: 0; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 13px; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; + margin-bottom: -1px; +} +[role="tab"][aria-selected="true"] { + color: var(--accent); + border-bottom-color: var(--accent); +} +[role="tabpanel"] { padding: 0 0 4px; } +[role="tabpanel"] figure.code { border: 0; background: transparent; margin: 0; } + +figure.code { + margin: 20px 0; + border: 1px solid var(--line); + background: var(--surface); +} +figure.code figcaption { + display: flex; + align-items: stretch; + justify-content: space-between; + min-height: 44px; + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 13px; + letter-spacing: 0.04em; +} +figure.code figcaption span { + display: flex; + align-items: center; + padding: 0 14px; +} +button[data-copy] { + min-height: 44px; + min-width: 72px; + padding: 0 14px; + border: 0; + border-left: 1px solid var(--line); + border-radius: 0; + background: transparent; + color: var(--text); + font: inherit; + font-size: 13px; + letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; +} +pre { + margin: 0; + padding: 16px 14px 18px; + overflow: auto; + font-size: 14px; + line-height: 1.55; +} +code { font-family: inherit; font-size: 0.92em; } +pre code { font-size: 14px; } +.prose :not(pre) > code { + padding: 0 0.2em; + color: var(--text); +} +.cm { color: var(--muted); } +.st { color: var(--accent); } +.kw { color: var(--text); } +.fl { color: #d5e2db; } +.nu { color: #d5e2db; } + +aside.note { + margin: 20px 0; + padding: 2px 0 2px 16px; + border-left: 1px solid var(--accent); +} +aside.note .kicker { margin-bottom: 8px; } +aside.note p:last-child { margin-bottom: 0; } + +pre.legal { + margin: 0; + white-space: pre-wrap; + font-size: 13px; + line-height: 1.55; + color: var(--text); +} + +.site-foot { + padding: 28px 28px 40px; + border-top: 1px solid var(--line); +} +.foot-links { + display: flex; + flex-wrap: wrap; + gap: 8px 22px; + margin-bottom: 16px; +} +.foot-links a { font-size: 14px; } +.disclaimer, .colophon { + max-width: 78ch; + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.5; +} +.colophon { margin-top: 12px; } +.colophon a { color: var(--muted); } + +.playbook { margin-top: 36px; padding-top: 8px; border-top: 1px solid var(--line); } + +@media (max-width: 860px) { + .menu { display: inline-flex; align-items: center; justify-content: center; } + .top { padding: 0 20px; } + .top nav { + display: none; + flex-direction: column; + align-items: stretch; + width: 100%; + padding-bottom: 8px; + } + .top.is-open nav { display: flex; } + .top nav a { + min-height: 44px; + padding: 0; + border-top: 1px solid var(--line); + } + .band { grid-template-columns: 1fr; } + .idx { + padding: 12px 20px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + .band-body, .page { padding: 28px 20px 40px; } + .sheet { grid-template-columns: 1fr; } + .side { + position: static; + max-height: none; + display: flex; + flex-wrap: wrap; + gap: 0 8px; + padding: 8px 12px 12px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + .side .kicker { width: 100%; padding: 12px 8px 0; } + .roster a { grid-template-columns: 32px 1fr auto; } + .roster .desc { grid-column: 2 / -1; } +} diff --git a/site/src/site.js b/site/src/site.js new file mode 100644 index 0000000..d680c7a --- /dev/null +++ b/site/src/site.js @@ -0,0 +1,107 @@ +const menu = document.querySelector(".menu"); +const top = document.querySelector(".top"); + +function setMenu(open) { + if (!menu || !top) return; + menu.setAttribute("aria-expanded", String(open)); + menu.textContent = open ? "Close" : "Menu"; + top.classList.toggle("is-open", open); +} + +menu?.addEventListener("click", () => { + setMenu(menu.getAttribute("aria-expanded") !== "true"); +}); + +document.querySelector(".top nav")?.addEventListener("click", (event) => { + if (event.target.closest("a")) setMenu(false); +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && menu?.getAttribute("aria-expanded") === "true") { + setMenu(false); + menu.focus(); + } + const tab = event.target.closest?.("[role=tab]"); + if (!tab) return; + const tabs = [...tab.closest("[role=tablist]").querySelectorAll("[role=tab]")]; + const index = tabs.indexOf(tab); + let next = null; + if (event.key === "ArrowRight") next = tabs[(index + 1) % tabs.length]; + if (event.key === "ArrowLeft") next = tabs[(index - 1 + tabs.length) % tabs.length]; + if (event.key === "Home") next = tabs[0]; + if (event.key === "End") next = tabs[tabs.length - 1]; + if (!next) return; + event.preventDefault(); + selectTab(next); + next.focus(); +}); + +function selectTab(tab) { + const tabs = [...tab.closest("[role=tablist]").querySelectorAll("[role=tab]")]; + for (const item of tabs) { + const on = item === tab; + item.setAttribute("aria-selected", String(on)); + item.tabIndex = on ? 0 : -1; + const panel = document.getElementById(item.getAttribute("aria-controls")); + if (panel) panel.hidden = !on; + } +} + +document.addEventListener("click", (event) => { + const tab = event.target.closest?.("[role=tab]"); + if (tab) selectTab(tab); + const copy = event.target.closest?.("[data-copy]"); + if (copy) copyCode(copy); + const filter = event.target.closest?.("[data-filter]"); + if (filter) applyFilter(filter); +}); + +async function copyCode(button) { + const code = button.closest("figure")?.querySelector("code"); + if (!code || !navigator.clipboard) return; + try { + await navigator.clipboard.writeText(code.textContent); + } catch { + return; + } + const previous = button.textContent; + button.textContent = "Copied"; + window.setTimeout(() => { + button.textContent = previous; + }, 1200); +} + +const rows = [...document.querySelectorAll("[data-parity-row]")]; +const query = document.querySelector("#parity-q"); +const count = document.querySelector("#parity-count"); +const parity = { source: "", disposition: "", text: "" }; + +function applyFilter(button) { + const group = button.parentElement; + for (const item of group.querySelectorAll("[data-filter]")) { + item.setAttribute("aria-pressed", String(item === button)); + } + parity[button.dataset.filter] = button.dataset.value; + renderParity(); +} + +function renderParity() { + if (!rows.length || !count) return; + const needle = parity.text.trim().toLowerCase(); + let shown = 0; + for (const row of rows) { + const ok = (!parity.source || row.dataset.source === parity.source) + && (!parity.disposition || row.dataset.disposition === parity.disposition) + && (!needle || row.dataset.text.includes(needle)); + row.hidden = !ok; + if (ok) shown += 1; + } + count.textContent = shown === rows.length + ? `${rows.length} rows` + : `${shown} of ${rows.length} rows`; +} + +query?.addEventListener("input", () => { + parity.text = query.value; + renderParity(); +}); diff --git a/site/src/styles.css b/site/src/styles.css deleted file mode 100644 index b1f019d..0000000 --- a/site/src/styles.css +++ /dev/null @@ -1,462 +0,0 @@ -@font-face { - font-family: Inter; - src: url("/fonts/inter-regular.woff2") format("woff2"); - font-weight: 400; - font-style: normal; - font-display: swap; -} -@font-face { - font-family: Inter; - src: url("/fonts/inter-bold.woff2") format("woff2"); - font-weight: 700; - font-style: normal; - font-display: swap; -} - -:root { - --paper: #f7f4ef; - --ink: #16140f; - --muted: #433e37; - --soft: #6d675e; - --pill: #1c1a16; - --card: #fffcf8; - --line: rgba(22, 20, 15, 0.08); - --spread: 0; - --px: 0; - --py: 0; - color-scheme: light; -} - -* { box-sizing: border-box; } -html { scroll-behavior: smooth; } -body { - margin: 0; - background: var(--paper); - color: var(--ink); - font-family: Inter, ui-sans-serif, system-ui, sans-serif; - font-size: 17px; - line-height: 1.5; -} -img, svg { display: block; } -a { color: inherit; } -button, input { font: inherit; } -code, .mono { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; } - -.skip { - position: absolute; - left: 12px; - top: -40px; - background: var(--ink); - color: var(--paper); - padding: 8px 12px; - z-index: 20; -} -.skip:focus { top: 12px; } - -.top { - position: sticky; - top: 0; - z-index: 6; - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - gap: 16px; - padding: 16px 28px; - background: #f7f4ef; - border-bottom: 1px solid transparent; -} -.brand { - display: inline-flex; - align-items: center; - gap: 10px; - font-weight: 700; - letter-spacing: -0.04em; - text-decoration: none; - font-size: 18px; -} -.brand svg { width: 22px; height: 22px; } -.top nav { - display: flex; - gap: 22px; - justify-content: center; -} -.top nav a { - text-decoration: none; - color: var(--muted); - font-size: 15px; -} -.top nav a:hover { color: var(--ink); } -.gh { - justify-self: end; - text-decoration: none; - border: 1px solid rgba(22, 20, 15, 0.16); - border-radius: 999px; - padding: 8px 14px; - font-size: 14px; - background: #fff; -} - -.hero { padding: 28px 24px 8px; } -.hero-copy, .plan-card, .kicker, .aside, .section { - width: min(1080px, 100%); - margin-inline: auto; -} -h1 { - margin: 4px auto 14px; - text-align: center; - font-size: clamp(30px, 3.15vw, 46px); - line-height: 1.05; - letter-spacing: -0.048em; - font-weight: 700; -} - -.install-pill { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - width: min(560px, 100%); - margin: 0 auto 12px; - padding: 8px 8px 8px 18px; - background: var(--pill); - color: #f7f4ef; - border-radius: 999px; - box-shadow: 0 10px 30px rgba(28, 26, 22, 0.12); -} -.install-pill code { - font-family: Inter, ui-sans-serif, system-ui, sans-serif; - font-size: 14.5px; - letter-spacing: -0.02em; - white-space: nowrap; - overflow: auto; -} -.copy { - flex: 0 0 auto; - width: 38px; - height: 38px; - border: 0; - border-radius: 999px; - background: rgba(247, 244, 239, 0.12); - color: #f7f4ef; - cursor: pointer; -} -.copy:hover, .copy:focus-visible { background: rgba(247, 244, 239, 0.22); } -.copy.is-copied { background: #efe7d4; color: var(--ink); } - -.badges { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 8px; - margin: 0; - padding: 0; - list-style: none; -} -.badges li { - background: rgba(255, 252, 248, 0.86); - border: 1px solid var(--line); - box-shadow: 0 6px 18px rgba(60, 40, 20, 0.04); - border-radius: 999px; - padding: 6px 12px; - font-size: 13px; - color: var(--muted); -} - -.pin { height: 175vh; } -.pin-sticky { - position: sticky; - top: 58px; - padding: 2px 0 0; -} -.stage { - position: relative; - width: min(1040px, 100%); - margin: 0 auto; - padding: 8px 8px 10px; -} -.stage canvas { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - z-index: 0; - pointer-events: none; -} -.stack { - position: relative; - z-index: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: calc(4px + var(--spread) * 6px); - transform: translate3d(calc(var(--px) * 14px), calc(var(--py) * 8px), 0); -} -.row { - width: min(860px, 100%); - display: grid; - grid-template-columns: 1fr minmax(180px, 360px) 1fr; - column-gap: 8px; - align-items: center; -} -.plate { - grid-column: 2; - position: relative; - width: 100%; - min-height: 28px; - border: 0; - border-radius: 12px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.38)); - box-shadow: 0 10px 24px rgba(70, 50, 20, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.85); - color: var(--ink); - cursor: pointer; - padding: 6px 12px; -} -.stage.is-live .plate { - background: transparent; - box-shadow: none; -} -.pname { - display: block; - text-align: center; - font-size: 13.5px; - letter-spacing: -0.02em; -} -.pdesc { - grid-column: 3; - margin: 0; - pointer-events: none; - color: var(--muted); - font-size: 14px; - line-height: 1.3; - max-height: calc(var(--spread) * 2.8em); - overflow: hidden; - opacity: var(--spread); -} -.pmeta { - position: absolute; - left: 50%; - top: calc(100% + 8px); - transform: translateX(-50%); - background: var(--ink); - color: #f7f4ef; - border-radius: 999px; - padding: 4px 10px; - font-size: 12px; - white-space: nowrap; - opacity: 0; - pointer-events: none; - z-index: 2; -} -.plate:hover .pmeta, -.plate:focus-visible .pmeta, -.plate.is-open .pmeta { opacity: 1; } - -.plan-card { - display: grid; - grid-template-columns: 1fr auto 1fr; - gap: 12px; - align-items: end; - background: var(--card); - border: 1px solid var(--line); - border-radius: 28px; - box-shadow: 0 20px 50px rgba(60, 40, 20, 0.06); - padding: 16px 28px 14px; - margin-top: 4px; -} -.plan-card p { margin: 0; } -.plan-label { - color: var(--muted); - font-size: 15px; - margin-bottom: 8px; -} -.plan-num { - font-size: clamp(40px, 5vw, 64px); - line-height: 0.95; - letter-spacing: -0.05em; - font-weight: 700; -} -.plan-side .plan-num { font-weight: 700; color: #2a261f; } -.unit { font-size: 18px; font-weight: 400; letter-spacing: 0; margin-left: 6px; } -.underline { - width: 92px; - height: 4px; - border-radius: 4px; - background: var(--ink); - margin-top: 10px; -} -.plan-arrow { - width: 42px; - height: 42px; - align-self: center; -} -.kicker, .aside { - text-align: center; - color: var(--muted); -} -.kicker { margin: 14px auto 4px; font-size: 15px; } -.aside { margin: 0 auto 8px; font-size: 13px; } - -.section { padding: 72px 24px; } -.section h2 { - font-size: clamp(32px, 4vw, 48px); - letter-spacing: -0.045em; - line-height: 1.05; - margin: 0 0 12px; -} -.lede { max-width: 38em; color: var(--muted); margin: 0 0 28px; } - -.compare { display: grid; gap: 22px; } -.compare article { - background: var(--card); - border: 1px solid var(--line); - border-radius: 22px; - padding: 18px 20px 16px; -} -.compare h3 { margin: 0 0 12px; font-size: 16px; font-weight: 700; } -.meter { display: grid; gap: 8px; } -.meter div { display: grid; grid-template-columns: 88px 1fr auto; gap: 10px; align-items: center; font-size: 14px; } -.track { height: 10px; background: rgba(22, 20, 15, 0.06); border-radius: 999px; overflow: hidden; } -.track span { - display: block; - height: 100%; - width: max(3px, calc(var(--share) * 100%)); - background: var(--ink); - transform-origin: left center; - animation: grow 1s cubic-bezier(0.2, 0.8, 0.2, 1) both; -} -.track.ghost span { background: #c9c1b4; } -.num { font-variant-numeric: tabular-nums; } - -.grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 14px; -} -.skill { - background: rgba(255, 252, 248, 0.7); - border: 1px solid var(--line); - border-radius: 18px; - padding: 16px 16px 14px; - min-height: 148px; -} -.skill h3 { margin: 0 0 6px; letter-spacing: -0.03em; } -.skill p { margin: 0; color: var(--muted); font-size: 15px; } -.tokens { margin-top: 12px !important; color: var(--soft) !important; font-size: 13px !important; } - -.plays { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 12px; - padding: 0; - margin: 0 0 18px; - list-style: none; -} -.plays li { - background: var(--card); - border: 1px solid var(--line); - border-radius: 18px; - padding: 16px; - min-height: 160px; -} -.plays strong { display: block; margin-bottom: 8px; letter-spacing: -0.03em; } -.plays span { color: var(--muted); font-size: 14.5px; } - -.docs pre, .cmds pre { - background: var(--pill); - color: #f7f4ef; - border-radius: 18px; - padding: 18px 20px; - overflow: auto; - margin: 0; -} -.cmd { - display: grid; - gap: 8px; - margin-bottom: 18px; -} -.cmd header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; -} -.cmd h3 { margin: 0; font-size: 16px; } -.cmd button.copy { - background: var(--paper); - color: var(--ink); - width: auto; - padding: 0 14px; - border-radius: 999px; -} - -.foot { - padding: 28px 24px 48px; - text-align: center; - color: var(--muted); - font-size: 14px; -} -.foot a { color: var(--ink); } - -:focus-visible { - outline: 2px solid var(--ink); - outline-offset: 3px; -} - -.sr { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; -} - -@keyframes grow { - from { transform: scaleX(0); } - to { transform: scaleX(1); } -} - -@media (max-width: 800px) { - .top { - grid-template-columns: 1fr auto; - padding: 12px 16px 10px; - } - .top nav { - grid-column: 1 / -1; - justify-content: flex-start; - gap: 16px; - overflow-x: auto; - padding-bottom: 2px; - } - .hero { padding-inline: 16px; } - h1 { font-size: 34px; max-width: 11em; margin-inline: auto; } - .pin { height: 140vh; } - .pin-sticky { top: 96px; } - .stack { gap: 3px; } - .plate { min-height: 24px; padding: 3px 10px; } - .row { grid-template-columns: 1fr; row-gap: 0; width: min(440px, 100%); } - .plate, .pdesc { grid-column: 1; } - .pdesc { padding-inline: 8px; } - .plan-card { - grid-template-columns: 1fr; - padding: 22px 18px; - border-radius: 22px; - } - .plan-arrow { transform: rotate(90deg); } - .grid, .plays { grid-template-columns: 1fr; } - .section { padding: 48px 16px; } - .install-pill code { font-size: 12.5px; } -} - -@media (max-width: 1100px) and (min-width: 801px) { - .grid { grid-template-columns: 1fr 1fr; } - .plays { grid-template-columns: 1fr 1fr; } -} - -@media (prefers-reduced-motion: reduce) { - html { scroll-behavior: auto; } - .stack { transform: none; } - .track span { animation: none; } -} diff --git a/site/vercel.json b/site/vercel.json index a92ba4a..7013e31 100644 --- a/site/vercel.json +++ b/site/vercel.json @@ -86,6 +86,13 @@ "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] + }, + { + "source": "/fonts/(.*)", + "headers": [ + { "key": "Access-Control-Allow-Origin", "value": "*" }, + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] } ], "rewrites": [