- commit
- e25820446df5cdea8ab2a6003c66b655e02365ca
- parent
- 4c8b625e1b3499bb176c00488fb14e7ba5b60d87
- parent
- 14d74a98e6ca0e955de140570b19e6fc03db51e6
- author
- Dami <dami.etoile@gmail.com>
| A | website/ | 2 | + |
| A | website/ | 334 | +++++++++++++++ |
| A | website/ | 203 | +++++++++ |
| A | website/ | 664 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,334 @@
1+/* ─────────────────────────────────────────────────────────────2+ goq.sh — hero dither field + install copy button.3+ Vanilla, no dependencies. Ordered (Bayer 8x8) dithering of a4+ slowly drifting earth-tone gradient, rendered at low resolution5+ and scaled up (CSS image-rendering: pixelated) into chunky pixels.6+ ───────────────────────────────────────────────────────────── */7+(function () {8+ "use strict";9+10+ /* ── Two natural ramps (dark → light). A low-frequency region11+ noise dithers between a warm-earth ramp and a green-tan ramp,12+ so the field reads like natural terrain rather than one hue.13+ Both share the pale highlight so patches blend at the light end. */14+ function readPalette() {15+ var s = getComputedStyle(document.documentElement);16+ var pick = function (name, fallback) {17+ var v = s.getPropertyValue(name).trim();18+ return v || fallback;19+ };20+ // Gentle green lean applied to the dither colours only (not the UI21+ // accent tokens): nudge green up, red/blue slightly down.22+ var tint = function (c) {23+ return [24+ Math.max(0, Math.min(255, Math.round(c[0] * 0.97))),25+ Math.max(0, Math.min(255, Math.round(c[1] * 1.05))),26+ Math.max(0, Math.min(255, Math.round(c[2] * 0.98))),27+ ];28+ };29+ var ramp = function (names) { return names.map(function (p) { return tint(hexToRgb(pick(p[0], p[1]))); }); };30+ return {31+ warm: ramp([32+ ["--clay", "#9c5a44"],33+ ["--terracotta", "#b5623b"],34+ ["--ochre", "#c98a3a"],35+ ["--sand", "#d8c39c"],36+ ["--paper-2", "#e3d8c2"],37+ ]),38+ green: ramp([39+ ["--moss-deep", "#46512f"],40+ ["--moss", "#6b7145"],41+ ["--olive", "#8b8b4e"],42+ ["--tan", "#c3b184"],43+ ["--paper-2", "#e3d8c2"],44+ ]),45+ };46+ }47+48+ function hexToRgb(hex) {49+ hex = hex.replace("#", "");50+ if (hex.length === 3) {51+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];52+ }53+ var n = parseInt(hex, 16);54+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];55+ }56+57+ /* ── Bayer 8x8 threshold matrix, normalised to 0..1. */58+ var BAYER = (function () {59+ var base = [60+ [0, 32, 8, 40, 2, 34, 10, 42],61+ [48, 16, 56, 24, 50, 18, 58, 26],62+ [12, 44, 4, 36, 14, 46, 6, 38],63+ [60, 28, 52, 20, 62, 30, 54, 22],64+ [3, 35, 11, 43, 1, 33, 9, 41],65+ [51, 19, 59, 27, 49, 17, 57, 25],66+ [15, 47, 7, 39, 13, 45, 5, 37],67+ [63, 31, 55, 23, 61, 29, 53, 21],68+ ];69+ var m = [];70+ for (var y = 0; y < 8; y++) {71+ m[y] = [];72+ for (var x = 0; x < 8; x++) m[y][x] = (base[y][x] + 0.5) / 64;73+ }74+ return m;75+ })();76+77+ /* ── Fractal value noise (deterministic, no Math.random). ──78+ Integer-hashed lattice → smooth value noise → fBm → domain79+ warp, for an organic marbled field instead of banded sines. */80+ function hash2(ix, iy) {81+ var n = Math.imul(ix | 0, 374761393) ^ Math.imul(iy | 0, 668265263);82+ n = Math.imul(n ^ (n >>> 13), 1274126177);83+ n ^= n >>> 16;84+ return (n >>> 0) / 4294967296;85+ }86+ function vnoise(x, y) {87+ var x0 = Math.floor(x), y0 = Math.floor(y);88+ var fx = x - x0, fy = y - y0;89+ var ux = fx * fx * (3 - 2 * fx);90+ var uy = fy * fy * (3 - 2 * fy);91+ var a = hash2(x0, y0), b = hash2(x0 + 1, y0);92+ var c = hash2(x0, y0 + 1), d = hash2(x0 + 1, y0 + 1);93+ return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy;94+ }95+ function fbm(x, y) {96+ var v = 0, amp = 0.5, freq = 1;97+ for (var o = 0; o < 3; o++) {98+ v += amp * vnoise(x * freq, y * freq);99+ freq *= 2.02;100+ amp *= 0.5;101+ }102+ return v; // ~0..0.875103+ }104+ // cheaper 2-octave fBm for the large-scale region field105+ function fbm2(x, y) {106+ return 0.62 * vnoise(x, y) + 0.30 * vnoise(x * 2.02, y * 2.02);107+ }108+109+ var canvas = document.querySelector(".hero-canvas");110+ if (canvas && canvas.getContext) {111+ setupDither(canvas);112+ }113+114+ function setupDither(display) {115+ var dctx = display.getContext("2d");116+ // low-resolution offscreen buffer → scaled up into chunky pixels117+ var buf = document.createElement("canvas");118+ var bctx = buf.getContext("2d");119+ var PIXEL = 4; // approximate on-screen size of one dither cell (smaller = denser)120+ var palette = readPalette();121+ var img, W, H;122+123+ function resize() {124+ var r = display.getBoundingClientRect();125+ var cssW = Math.max(1, Math.round(r.width));126+ var cssH = Math.max(1, Math.round(r.height));127+ W = Math.max(16, Math.ceil(cssW / PIXEL));128+ H = Math.max(16, Math.ceil(cssH / PIXEL));129+ buf.width = W;130+ buf.height = H;131+ display.width = W;132+ display.height = H;133+ dctx.imageSmoothingEnabled = false;134+ palette = readPalette();135+ img = bctx.createImageData(W, H);136+ }137+138+ // Per-load random seed: shifts sampling into a different region of139+ // the infinite noise field so the background doesn't start the same140+ // place every visit. (Browser Math.random — fine here.)141+ var SEED_X = Math.random() * 940 + 30;142+ var SEED_Y = Math.random() * 940 + 30;143+144+ // Domain-warped fBm: two warp layers churned by time give an145+ // organic, marbled, non-repeating field.146+ var FREQ = 0.014; // feature scale in cells (smaller = larger blobs)147+ function field(x, y, t) {148+ var nx = x * FREQ + SEED_X;149+ var ny = y * FREQ + SEED_Y;150+ // first warp vector151+ var qx = fbm(nx, ny + t * 0.05);152+ var qy = fbm(nx + 5.2, ny + 1.3 - t * 0.04);153+ // second warp vector, offset by the first154+ var rx = fbm(nx + 3.4 * qx + 1.7, ny + 3.4 * qy + 9.2 + t * 0.03);155+ var ry = fbm(nx + 3.4 * qx + 8.3, ny + 3.4 * qy + 2.8 - t * 0.035);156+ // final sample, plus a gentle diagonal bias for large-scale depth157+ var v = fbm(nx + 3.2 * rx, ny + 3.2 * ry);158+ v = v * 1.18 + 0.14 * (rx - ry);159+ v = (v - 0.14) / 0.66; // normalise roughly to 0..1160+ if (v < 0) v = 0;161+ else if (v > 1) v = 1;162+ return v;163+ }164+165+ // Large-scale region field: chooses warm-earth vs green-tan patches.166+ var RFREQ = 0.006;167+ function regionField(x, y, t) {168+ var v = fbm2(x * RFREQ + SEED_X * 0.6 + 11.5 + t * 0.02, y * RFREQ + SEED_Y * 0.6 + 4.2 - t * 0.016);169+ v = (v - 0.16) / 0.6;170+ if (v < 0) v = 0;171+ else if (v > 1) v = 1;172+ return v;173+ }174+175+ function render(t) {176+ var data = img.data;177+ var warm = palette.warm;178+ var green = palette.green;179+ var last = warm.length - 1;180+ var i = 0;181+ for (var y = 0; y < H; y++) {182+ var brow = BAYER[y & 7];183+ var brow2 = BAYER[(y + 4) & 7];184+ for (var x = 0; x < W; x++) {185+ var v = field(x, y, t);186+ // ordered-dither the luminance ramp between neighbouring stops187+ var scaled = v * last;188+ var lo = Math.floor(scaled);189+ if (lo >= last) lo = last - 1;190+ if (lo < 0) lo = 0;191+ var frac = scaled - lo;192+ var idx = frac > brow[x & 7] ? lo + 1 : lo;193+ // ordered-dither the region to pick which ramp this pixel uses194+ var region = regionField(x, y, t);195+ var arr = region > brow2[(x + 4) & 7] ? warm : green;196+ var c = arr[idx];197+ data[i] = c[0];198+ data[i + 1] = c[1];199+ data[i + 2] = c[2];200+ data[i + 3] = 255;201+ i += 4;202+ }203+ }204+ bctx.putImageData(img, 0, 0);205+ dctx.drawImage(buf, 0, 0);206+ }207+208+ var reduced = window.matchMedia("(prefers-reduced-motion: reduce)");209+ var raf = 0;210+ var start = null;211+ var lastDraw = -1e9;212+ var FRAME_MS = 33; // ~30fps — the field drifts slowly, so this is plenty213+214+ function loop(now) {215+ if (start === null) start = now;216+ if (now - lastDraw >= FRAME_MS) {217+ render((now - start) / 1000);218+ lastDraw = now;219+ }220+ raf = requestAnimationFrame(loop);221+ }222+223+ function begin() {224+ cancelAnimationFrame(raf);225+ resize();226+ if (reduced.matches) {227+ render(6); // one representative static frame228+ } else {229+ start = null;230+ raf = requestAnimationFrame(loop);231+ }232+ }233+234+ var rt;235+ window.addEventListener("resize", function () {236+ clearTimeout(rt);237+ rt = setTimeout(begin, 150);238+ });239+ if (reduced.addEventListener) reduced.addEventListener("change", begin);240+ // pause when the tab/section is off-screen to save cycles241+ if ("IntersectionObserver" in window) {242+ new IntersectionObserver(function (entries) {243+ entries.forEach(function (e) {244+ if (reduced.matches) return;245+ if (e.isIntersecting) {246+ if (!raf) { start = null; raf = requestAnimationFrame(loop); }247+ } else {248+ cancelAnimationFrame(raf);249+ raf = 0;250+ }251+ });252+ }, { threshold: 0 }).observe(display);253+ }254+255+ begin();256+ }257+258+ /* ── Client / host install toggle: swap the command live. ──259+ TODO(install-script): confirm exact one-liners once goq.sh260+ serves the install scripts (client app + host runtime pkg). */261+ var INSTALL_CMD = {262+ client: "curl -fsSL https://goq.sh | sh",263+ host: "curl -fsSL https://goq.sh/host | sh",264+ };265+ document.querySelectorAll('input[name="install-target"]').forEach(function (radio) {266+ radio.addEventListener("change", function () {267+ if (!radio.checked) return;268+ var cmd = INSTALL_CMD[radio.value] || INSTALL_CMD.client;269+ var box = document.querySelector(".install");270+ if (!box) return;271+ box.setAttribute("data-copy", cmd);272+ var textEl = box.querySelector(".install-text");273+ if (textEl) textEl.textContent = cmd;274+ });275+ });276+277+ /* ── Copy-to-clipboard for the install command(s). ── */278+ document.querySelectorAll(".install").forEach(function (box) {279+ var btn = box.querySelector(".install-copy");280+ if (!btn) return;281+ btn.addEventListener("click", function () {282+ var text = box.getAttribute("data-copy") || ""; // read current selection283+ var done = function () {284+ btn.textContent = "copied";285+ btn.classList.add("copied");286+ setTimeout(function () {287+ btn.textContent = "copy";288+ btn.classList.remove("copied");289+ }, 1600);290+ };291+ if (navigator.clipboard && navigator.clipboard.writeText) {292+ navigator.clipboard.writeText(text).then(done, fallback);293+ } else {294+ fallback();295+ }296+ function fallback() {297+ var ta = document.createElement("textarea");298+ ta.value = text;299+ ta.style.position = "fixed";300+ ta.style.opacity = "0";301+ document.body.appendChild(ta);302+ ta.select();303+ try { document.execCommand("copy"); done(); } catch (e) {}304+ document.body.removeChild(ta);305+ }306+ });307+ });308+309+ /* ── Scroll reveal: gated behind html.js so no-JS visitors see310+ everything; staggered per sibling group via a CSS var. ── */311+ document.documentElement.classList.add("js");312+ var revealEls = document.querySelectorAll(".reveal");313+ if ("IntersectionObserver" in window && revealEls.length) {314+ // stagger within each parent group (feature grid, steps, diagram)315+ var groups = new Map();316+ revealEls.forEach(function (el) {317+ var parent = el.parentElement;318+ var n = groups.get(parent) || 0;319+ el.style.setProperty("--reveal-delay", (n * 0.07).toFixed(2) + "s");320+ groups.set(parent, n + 1);321+ });322+ var io = new IntersectionObserver(function (entries) {323+ entries.forEach(function (e) {324+ if (e.isIntersecting) {325+ e.target.classList.add("in");326+ io.unobserve(e.target);327+ }328+ });329+ }, { threshold: 0.15, rootMargin: "0px 0px -5% 0px" });330+ revealEls.forEach(function (el) { io.observe(el); });331+ } else {332+ revealEls.forEach(function (el) { el.classList.add("in"); });333+ }334+})();@@ -0,0 +1,203 @@
1+2+<html lang="en">3+<head>4+ <meta charset="utf-8" />5+ <meta name="viewport" content="width=device-width, initial-scale=1" />6+ <title>goq.sh: one-to-one Steam streaming over iroh</title>7+ <meta name="description" content="goq.sh pairs Portal, a native client, with Sigil, a headless Gamescope host, for games over QUIC. One-to-one, low-latency Steam streaming over iroh. Your key is the address." />8+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%23ece3d2'/%3E%3Crect x='1' y='4' width='6' height='8' fill='%23b5623b'/%3E%3Crect x='3' y='7' width='2' height='2' fill='%23ece3d2'/%3E%3Crect x='7' y='7' width='8' height='2' fill='%23c98a3a'/%3E%3Crect x='10' y='9' width='2' height='2' fill='%236b7145'/%3E%3Crect x='13' y='9' width='2' height='3' fill='%236b7145'/%3E%3C/svg%3E" />9+ <link rel="stylesheet" href="./styles.css" />10+</head>11+<body>12+ <a class="skip" href="#main">Skip to content</a>13+14+ <!--15+ goq.sh is the project and domain. Portal is the installed client;16+ sigil is the headless host CLI. "goq" is the games-over-QUIC17+ wordplay and repository name (github.com/FelineStateMachine/goq).18+ TODO(install-script): the curl one-liners below point at goq.sh but19+ those install scripts are NOT published yet. Publish them (client20+ app + host runtime package) before launch, then confirm the exact21+ commands here and in dither.js.22+ -->23+ <!-- ─── First screen: full-viewport dither with a floating panel ─── -->24+ <div class="screen">25+ <header class="topbar">26+ <a class="topbar-github" href="https://github.com/FelineStateMachine/goq" rel="noopener">source </a>27+ <nav class="topbar-nav">28+ <a href="#features">features</a>29+ <a href="#how">how</a>30+ <a href="#start">start</a>31+ </nav>32+ </header>33+34+ <section class="hero">35+ <canvas class="hero-canvas" aria-hidden="true"></canvas>36+ <div class="hero-panel">37+ <!-- pixel security key mark -->38+ <svg class="hero-key" viewBox="0 0 26 12" width="78" height="36" aria-hidden="true">39+ <rect x="0" y="1" width="8" height="10" fill="var(--terracotta)" />40+ <rect x="3" y="5" width="2" height="2" fill="var(--paper)" />41+ <rect x="8" y="5" width="18" height="2" fill="var(--ochre)" />42+ <rect x="14" y="7" width="2" height="3" fill="var(--moss)" />43+ <rect x="19" y="7" width="2" height="2" fill="var(--moss)" />44+ </svg>45+ <pre class="hero-ascii" role="img" aria-label="goq.sh">░█▀▀░█▀█░█▀█░░░█▀▀░█░█46+░█░█░█░█░█▄█░░░▀▀█░█▀█47+░▀▀▀░▀▀▀░░░█░▀░▀▀▀░▀░▀</pre>48+ <p class="hero-tagline">Your key is the address. Plug in, tap, connect.</p>49+ <div class="install-group">50+ <div class="seg" role="radiogroup" aria-label="Install as">51+ <label class="seg-opt">52+ <input type="radio" name="install-target" value="client" checked />53+ <span>client</span>54+ </label>55+ <label class="seg-opt">56+ <input type="radio" name="install-target" value="host" />57+ <span>host</span>58+ </label>59+ </div>60+ <div class="install" data-copy="curl -fsSL https://goq.sh | sh">61+ <code class="install-cmd"><span class="install-prompt">$</span> <span class="install-text">curl -fsSL https://goq.sh | sh</span></code>62+ <button class="install-copy" type="button" aria-label="Copy install command">copy</button>63+ </div>64+ </div>65+ <p class="hero-note">Games over QUIC: one-to-one Steam streaming over iroh <br> Work in progress. Not a finished product.</p>66+ </div>67+ </section>68+ </div>69+70+ <main id="main">71+ <!-- ─── Features ─────────────────────────────────────────── -->72+ <section class="features" id="features">73+ <div class="section-rule" aria-hidden="true"></div>74+ <h2 class="section-title"><span aria-hidden="true">##</span> why goq</h2>75+ <ol class="feature-grid">76+ <li class="feature reveal">77+ <h3>Your key is the address</h3>78+ <p>The FIDO2 key's hmac-secret seeds the iroh identity. Host and client derive the same node ID from the same physical key. No pairing codes, no accounts.</p>79+ </li>80+ <li class="feature reveal">81+ <h3>Headless Sigil host</h3>82+ <p>A pure-Rust daemon on bare-metal Linux, no desktop, no display. Gamescope gives Steam its own private session; PipeWire feeds a hardware H.264 encoder.</p>83+ </li>84+ <li class="feature reveal">85+ <h3>Controller-first</h3>86+ <p>A virtual Xbox-style gamepad, keyboard, and relative mouse injected via uinput. D-pad PIN pad and a Back+Start escape chord, built for a couch.</p>87+ </li>88+ <li class="feature reveal">89+ <h3>Audio included</h3>90+ <p>PipeWire capture to Opus in bounded datagrams, played back through an AudioWorklet next to the WebCodecs picture.</p>91+ </li>92+ <li class="feature reveal">93+ <h3>Bounded everything</h3>94+ <p>Frame handoff capped at four, decode queue at two, every buffer bounded. Stale frames are dropped, not queued, so latency never drifts.</p>95+ </li>96+ <li class="feature reveal">97+ <h3>One host, one client</h3>98+ <p>A single active client, enforced, over an end-to-end encrypted iroh link, relay-assisted when a direct path isn't possible. No third-party streaming service.</p>99+ </li>100+ </ol>101+ </section>102+103+ <!-- ─── How it works ─────────────────────────────────────── -->104+ <section class="how" id="how">105+ <div class="section-rule" aria-hidden="true"></div>106+ <h2 class="section-title"><span aria-hidden="true">##</span> how it works</h2>107+108+ <div class="diagram">109+ <!-- host column -->110+ <div class="dg-node reveal">111+ <span class="dg-label">sigil · bare-metal linux</span>112+ <ol class="dg-steps">113+ <li>Steam and your game</li>114+ <li>headless Gamescope session</li>115+ <li>PipeWire video + audio capture</li>116+ <li>hardware H.264 + Opus encode</li>117+ </ol>118+ <span class="dg-foot-in">gamepad + kb/m injected via uinput</span>119+ </div>120+121+ <!-- channel: media out, input back -->122+ <div class="dg-channel" aria-hidden="true">123+ <div class="dg-rail dg-video">124+ <span class="dg-pill">media · iroh</span>125+ <span class="dg-head">▶</span>126+ </div>127+ <div class="dg-rail dg-input">128+ <span class="dg-pill">input · iroh</span>129+ <span class="dg-head">◀</span>130+ </div>131+ </div>132+133+ <!-- client column -->134+ <div class="dg-node reveal">135+ <span class="dg-label">portal · native app</span>136+ <ol class="dg-steps">137+ <li>native iroh endpoint</li>138+ <li>WebCodecs + AudioWorklet</li>139+ <li>picture and sound appear</li>140+ <li>controller, keyboard, mouse</li>141+ </ol>142+ <span class="dg-foot-in">any machine, near or far</span>143+ </div>144+ </div>145+146+ <ul class="dg-notes">147+ <li>Paired by your <strong>FIDO2 security key</strong>: same key, same address. No passwords, no accounts.</li>148+ <li><strong>One host, one client</strong>. Media and input ride separate end-to-end encrypted iroh connections.</li>149+ <li><strong>Every queue bounded</strong>. Stale frames are dropped, not queued, so it never drifts behind.</li>150+ </ul>151+ </section>152+153+ <!-- ─── Quick start ──────────────────────────────────────── -->154+ <section class="start" id="start">155+ <div class="section-rule" aria-hidden="true"></div>156+ <h2 class="section-title"><span aria-hidden="true">##</span> three taps to steam</h2>157+ <ol class="start-grid">158+ <li class="start-step reveal">159+ <span class="start-num" aria-hidden="true">01</span>160+ <h3>provision sigil</h3>161+ <p>A bare-metal Linux box. The runbook walks an AMD Bazzite machine from blank to streaming. The daemon runs headless; Gamescope is the display.</p>162+ </li>163+ <li class="start-step reveal">164+ <span class="start-num" aria-hidden="true">02</span>165+ <h3>open portal</h3>166+ <p>In Portal, tap your FIDO2 key and identity is minted on the token. A D-pad PIN pad keeps it couch-friendly.</p>167+ </li>168+ <li class="start-step reveal">169+ <span class="start-num" aria-hidden="true">03</span>170+ <h3>tap to play <kbd>back+start</kbd></h3>171+ <p>Tap the same key anywhere else: Steam appears, your controller just works. Hold Back+Start for a second to escape the session.</p>172+ </li>173+ </ol>174+ <p class="start-note">You'll need a FIDO2 key with hmac-secret support (YubiKey, Google Titan, …) and a Linux machine for Sigil. Portal builds run on Linux and macOS. <a class="footer-link" href="https://github.com/FelineStateMachine/goq" rel="noopener">Runbook and source →</a></p>175+ </section>176+ </main>177+178+ <footer class="footer">179+ <div class="section-rule" aria-hidden="true"></div>180+ <div class="footer-inner">181+ <ul class="stack-row" aria-label="Tech stack">182+ <li>rust</li>183+ <li>tauri v2</li>184+ <li>iroh</li>185+ <li>gamescope</li>186+ <li>pipewire</li>187+ <li>h.264 + opus</li>188+ <li>webcodecs</li>189+ <li>uinput</li>190+ <li>ctap-hid-fido2</li>191+ <li>no npm · no build step</li>192+ </ul>193+ <div class="footer-meta">194+ <span>MIT licensed</span>195+ <a class="footer-link" href="https://github.com/FelineStateMachine/goq" rel="noopener">Source</a>196+ <span class="footer-domain">goq.sh</span>197+ </div>198+ </div>199+ </footer>200+201+ <script src="./dither.js"></script>202+</body>203+</html>@@ -0,0 +1,664 @@
1+/* ─────────────────────────────────────────────────────────────2+ goq.sh — landing page3+ Refined-minimal, earth-tone palette, dither + pixel accents.4+ ───────────────────────────────────────────────────────────── */5+6+:root {7+ /* paper + ink */8+ --paper: #ece3d2;9+ --paper-2: #e3d8c2;10+ --ink: #2b2119;11+ --ink-muted: #6b5d4a;12+ --hair: rgba(43, 33, 25, 0.14);13+ --hair-strong: rgba(43, 33, 25, 0.28);14+ --panel-shadow: rgba(43, 33, 25, 0.22);15+16+ /* earth-tone splashes */17+ --terracotta: #b5623b;18+ --ochre: #c98a3a;19+ --moss: #6b7145;20+ --clay: #9c5a44;21+ --sand: #d8c39c;22+ --accent: var(--terracotta);23+ /* natural greens + tans (second dither ramp) */24+ --moss-deep: #46512f;25+ --olive: #8b8b4e;26+ --tan: #c3b184;27+28+ --mono: "SFMono-Regular", "Cascadia Mono", "Liberation Mono", ui-monospace, monospace;29+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;30+31+ --edge: min(6vw, 64px);32+ --maxw: 1080px;33+}34+35+@media (prefers-color-scheme: dark) {36+ :root {37+ --paper: #201913;38+ --paper-2: #271f17;39+ --ink: #ece0cd;40+ --ink-muted: #a3927a;41+ --hair: rgba(236, 224, 205, 0.12);42+ --hair-strong: rgba(236, 224, 205, 0.24);43+ --panel-shadow: rgba(0, 0, 0, 0.45);44+ --terracotta: #cf7048;45+ --ochre: #d99a47;46+ --moss: #8a9155;47+ --clay: #bd6c52;48+ --sand: #cbb488;49+ --moss-deep: #5c6a3e;50+ --olive: #a6a45f;51+ --tan: #b8a77e;52+ }53+}54+55+* { margin: 0; padding: 0; box-sizing: border-box; }56+57+html { scroll-behavior: smooth; }58+@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }59+60+body {61+ background: var(--paper);62+ color: var(--ink);63+ font-family: var(--sans);64+ font-size: 17px;65+ line-height: 1.6;66+ -webkit-font-smoothing: antialiased;67+ overflow-x: hidden;68+}69+70+a { color: inherit; }71+72+.skip {73+ position: absolute;74+ left: -9999px;75+ top: 0;76+ background: var(--ink);77+ color: var(--paper);78+ padding: 8px 14px;79+ z-index: 20;80+ font-family: var(--mono);81+ font-size: 13px;82+}83+.skip:focus { left: 8px; top: 8px; }84+85+::selection { background: var(--ochre); color: var(--ink); }86+87+/* ─── Top bar ──────────────────────────────────────────────── */88+.topbar {89+ display: flex;90+ align-items: center;91+ gap: 20px;92+ padding: 18px var(--edge);93+ border-bottom: 1px solid var(--hair);94+ position: relative;95+ z-index: 5;96+}97+.topbar-github {98+ font-family: var(--mono);99+ font-weight: 500;100+ font-size: 13px;101+ letter-spacing: 0.02em;102+ text-transform: lowercase;103+ color: var(--terracotta);104+ text-decoration: none;105+ transition: color 0.12s;106+}107+.topbar-github:hover { color: var(--ink); }108+.topbar-github:focus-visible { outline: 2px solid var(--moss); outline-offset: 4px; }109+.topbar-nav {110+ margin-left: auto;111+ display: flex;112+ gap: 36px;113+ font-family: var(--mono);114+ font-size: 13px;115+}116+.topbar-nav a {117+ color: var(--ink-muted);118+ text-decoration: none;119+ text-transform: lowercase;120+ transition: color 0.12s;121+}122+.topbar-nav a:hover { color: var(--ink); }123+@media (max-width: 620px) {124+ .topbar-nav { gap: 14px; }125+}126+127+/* ─── First screen: full-viewport dither + floating panel ──── */128+.screen {129+ min-height: 100vh;130+ min-height: 100svh;131+ display: flex;132+ flex-direction: column;133+}134+.hero {135+ position: relative;136+ flex: 1;137+ min-height: 0;138+ overflow: hidden;139+ display: flex;140+ align-items: center;141+ justify-content: center;142+ padding: 24px;143+ border-bottom: 1px solid var(--hair);144+}145+.hero-canvas {146+ position: absolute;147+ inset: 0;148+ width: 100%;149+ height: 100%;150+ image-rendering: pixelated;151+ image-rendering: crisp-edges;152+}153+.hero-panel {154+ position: relative;155+ z-index: 1;156+ width: 100%;157+ max-width: 600px;158+ padding: clamp(28px, 4vw, 44px);159+ background: color-mix(in srgb, var(--paper) 88%, transparent);160+ backdrop-filter: blur(3px);161+ -webkit-backdrop-filter: blur(3px);162+ border: 1px solid var(--hair-strong);163+ box-shadow: 12px 12px 0 var(--panel-shadow);164+ display: flex;165+ flex-direction: column;166+ align-items: center;167+ gap: 22px;168+ text-align: center;169+}170+.hero-key {171+ display: block;172+ image-rendering: pixelated;173+ image-rendering: crisp-edges;174+ margin-bottom: -4px;175+}176+.hero-ascii {177+ margin: 0;178+ max-width: 100%;179+ overflow: hidden;180+ color: var(--terracotta);181+ font-family: var(--mono);182+ font-size: clamp(13px, 4.4vw, 31px);183+ line-height: 1;184+ white-space: pre;185+ letter-spacing: 0;186+ -webkit-user-select: none;187+ user-select: none;188+ -webkit-user-drag: none;189+ pointer-events: none;190+}191+.hero-tagline {192+ margin: 0;193+ font-family: var(--mono);194+ font-size: clamp(13px, 2vw, 15px);195+ color: var(--ink);196+ letter-spacing: 0.01em;197+}198+199+/* ─── Install ──────────────────────────────────────────────── */200+.install-group {201+ display: flex;202+ flex-direction: column;203+ align-items: center;204+ gap: 10px;205+ max-width: 100%;206+}207+/* platform segmented radio */208+.seg {209+ display: inline-flex;210+ border: 1px solid var(--hair-strong);211+ font-family: var(--mono);212+ font-size: 12px;213+}214+.seg-opt { position: relative; display: flex; }215+.seg-opt input {216+ position: absolute;217+ inset: 0;218+ margin: 0;219+ opacity: 0;220+ cursor: pointer;221+}222+.seg-opt span {223+ padding: 7px 18px;224+ text-transform: lowercase;225+ letter-spacing: 0.06em;226+ color: var(--ink-muted);227+ transition: background 0.12s, color 0.12s;228+}229+.seg-opt:not(:last-child) span { border-right: 1px solid var(--hair-strong); }230+.seg-opt:has(input:checked) span { background: var(--ink); color: var(--paper); }231+.seg-opt:hover span { color: var(--ink); }232+.seg-opt:has(input:checked):hover span { color: var(--paper); }233+.seg-opt input:focus-visible + span { outline: 2px solid var(--moss); outline-offset: -2px; }234+235+.install {236+ display: inline-flex;237+ align-items: stretch;238+ border: 1px solid var(--hair-strong);239+ background: color-mix(in srgb, var(--paper-2) 82%, transparent);240+ max-width: 100%;241+ overflow: hidden;242+}243+.install-cmd {244+ font-family: var(--mono);245+ font-size: 12.5px;246+ padding: 12px 12px;247+ color: var(--ink);248+ white-space: nowrap;249+ overflow-x: auto;250+ scrollbar-width: none;251+}252+.install-cmd::-webkit-scrollbar { display: none; }253+.install-prompt { color: var(--moss); margin-right: 8px; }254+.install-copy {255+ font-family: var(--mono);256+ font-size: 12px;257+ text-transform: uppercase;258+ letter-spacing: 0.06em;259+ border: none;260+ border-left: 1px solid var(--hair-strong);261+ background: transparent;262+ color: var(--ink-muted);263+ padding: 0 16px;264+ cursor: pointer;265+ transition: background 0.12s, color 0.12s;266+ flex-shrink: 0;267+}268+.install-copy:hover { background: var(--terracotta); color: var(--paper); }269+.install-copy.copied { background: var(--moss); color: var(--paper); }270+.install-copy:focus-visible { outline: 2px solid var(--moss); outline-offset: -2px; }271+272+.hero-note {273+ margin: 0;274+ font-family: var(--mono);275+ font-size: 12px;276+ color: var(--ink-muted);277+}278+.hero-note::before {279+ content: "";280+ display: inline-block;281+ width: 8px; height: 8px;282+ margin-right: 9px;283+ background: var(--ochre);284+ vertical-align: baseline;285+}286+287+/* ─── Dithered / pixel section rule ────────────────────────── */288+.section-rule {289+ height: 8px;290+ background-image:291+ repeating-linear-gradient(90deg,292+ var(--terracotta) 0 8px, transparent 8px 24px),293+ repeating-linear-gradient(90deg,294+ var(--moss) 12px 16px, transparent 16px 40px),295+ repeating-linear-gradient(90deg,296+ var(--ochre) 28px 32px, transparent 32px 56px);297+ opacity: 0.7;298+ margin-bottom: clamp(48px, 7vw, 84px);299+}300+301+/* ─── Section titles: markdown-flavoured mono headings ─────── */302+.section-title {303+ max-width: var(--maxw);304+ margin: 0 auto clamp(28px, 4vw, 44px);305+ font-family: var(--mono);306+ font-size: clamp(21px, 3.2vw, 30px);307+ font-weight: 700;308+ letter-spacing: -0.01em;309+ text-transform: lowercase;310+}311+.section-title span {312+ color: var(--ochre);313+ margin-right: 10px;314+ font-weight: 500;315+}316+317+/* ─── Features ─────────────────────────────────────────────── */318+.features { padding: clamp(56px, 8vw, 96px) var(--edge); }319+.feature-grid {320+ max-width: var(--maxw);321+ margin: 0 auto;322+ list-style: none;323+ display: grid;324+ grid-template-columns: repeat(3, 1fr);325+ gap: 1px;326+ background: var(--hair);327+ border: 1px solid var(--hair);328+}329+.feature {330+ background: var(--paper);331+ padding: clamp(22px, 2.8vw, 32px);332+ position: relative;333+}334+.feature h3 {335+ margin: 0 0 10px;336+ font-family: var(--mono);337+ font-size: 15.5px;338+ letter-spacing: -0.01em;339+ font-weight: 700;340+ text-transform: lowercase;341+ padding-right: 18px;342+}343+.feature p {344+ color: var(--ink-muted);345+ font-size: 14.5px;346+ line-height: 1.55;347+ max-width: 40ch;348+}349+/* pixel corner tick — grows on hover */350+.feature::after {351+ content: "";352+ position: absolute;353+ top: 0; right: 0;354+ width: 12px; height: 12px;355+ background: var(--sand);356+ transform-origin: top right;357+ transition: transform 0.32s cubic-bezier(0.34, 1.4, 0.64, 1);358+ will-change: transform;359+ pointer-events: none;360+}361+.feature:nth-child(2)::after { background: var(--ochre); }362+.feature:nth-child(3)::after { background: var(--moss); }363+.feature:nth-child(4)::after { background: var(--clay); }364+.feature:nth-child(5)::after { background: var(--olive); }365+.feature:nth-child(6)::after { background: var(--terracotta); }366+.feature:hover::after { transform: scale(4.2); }367+@media (prefers-reduced-motion: reduce) {368+ .feature::after { transition: none; }369+}370+@media (max-width: 900px) {371+ .feature-grid { grid-template-columns: repeat(2, 1fr); }372+}373+@media (max-width: 620px) {374+ .feature-grid { grid-template-columns: 1fr; }375+}376+377+/* ─── How it works: one-stop diagram ───────────────────────── */378+.how {379+ padding: clamp(56px, 8vw, 96px) var(--edge);380+ max-width: calc(var(--maxw) + 2 * var(--edge));381+ margin: 0 auto;382+}383+384+.diagram {385+ max-width: var(--maxw);386+ margin: 0 auto;387+ display: grid;388+ grid-template-columns: 1fr minmax(120px, 0.7fr) 1fr;389+ align-items: stretch;390+ gap: 0;391+}392+393+/* nodes */394+.dg-node {395+ border: 1px solid var(--hair-strong);396+ background: var(--paper-2);397+ box-shadow: 8px 8px 0 var(--hair);398+ padding: clamp(18px, 2.4vw, 26px);399+ display: flex;400+ flex-direction: column;401+}402+.dg-label {403+ font-family: var(--mono);404+ font-size: 11px;405+ letter-spacing: 0.08em;406+ text-transform: uppercase;407+ color: var(--terracotta);408+ margin-bottom: 16px;409+}410+.dg-steps {411+ list-style: none;412+ display: flex;413+ flex-direction: column;414+ gap: 0;415+ flex: 1;416+}417+.dg-steps li {418+ position: relative;419+ font-family: var(--mono);420+ font-size: clamp(12px, 1.5vw, 14px);421+ color: var(--ink);422+ padding: 9px 0 9px 20px;423+}424+/* pixel bullet */425+.dg-steps li::before {426+ content: "";427+ position: absolute;428+ left: 0;429+ top: 15px;430+ width: 7px;431+ height: 7px;432+ background: var(--moss);433+}434+/* connector line implying sequence */435+.dg-steps li:not(:last-child)::after {436+ content: "";437+ position: absolute;438+ left: 3px;439+ top: 22px;440+ bottom: 1px;441+ width: 1px;442+ background: var(--hair-strong);443+}444+.dg-foot-in {445+ margin-top: 16px;446+ padding-top: 14px;447+ border-top: 1px dotted var(--hair-strong);448+ font-family: var(--mono);449+ font-size: 11px;450+ color: var(--ink-muted);451+}452+453+/* channel between the two nodes */454+.dg-channel {455+ display: flex;456+ flex-direction: column;457+ justify-content: center;458+ gap: clamp(28px, 5vh, 52px);459+ padding: 0 clamp(6px, 1.4vw, 14px);460+}461+/* each rail is a dashed wire with a label pill and an arrowhead */462+.dg-rail {463+ position: relative;464+ display: flex;465+ align-items: center;466+ justify-content: center;467+ min-height: 30px;468+}469+.dg-rail::before {470+ content: "";471+ position: absolute;472+ left: 0; right: 0;473+ top: 50%;474+ border-top: 2px dashed currentColor;475+ opacity: 0.65;476+}477+.dg-pill {478+ position: relative;479+ background: var(--paper);480+ padding: 3px 9px;481+ font-family: var(--mono);482+ font-size: 10px;483+ letter-spacing: 0.12em;484+ text-transform: uppercase;485+ color: currentColor;486+ white-space: nowrap;487+}488+.dg-head {489+ position: absolute;490+ top: 50%;491+ transform: translateY(-50%);492+ font-size: 15px;493+ line-height: 0;494+}495+.dg-video { color: var(--terracotta); }496+.dg-input { color: var(--moss); }497+.dg-video .dg-head { right: -3px; } /* points into the client */498+.dg-input .dg-head { left: -3px; } /* points back into the host */499+500+/* summary notes */501+.dg-notes {502+ list-style: none;503+ max-width: var(--maxw);504+ margin: clamp(28px, 4vw, 44px) auto 0;505+ display: grid;506+ grid-template-columns: repeat(3, 1fr);507+ gap: 1px;508+ background: var(--hair);509+ border: 1px solid var(--hair);510+}511+.dg-notes li {512+ background: var(--paper);513+ padding: 18px 20px;514+ font-size: 14px;515+ color: var(--ink-muted);516+ line-height: 1.5;517+}518+.dg-notes strong { color: var(--ink); font-weight: 620; }519+520+@media (max-width: 720px) {521+ .diagram { grid-template-columns: 1fr; }522+ .dg-channel {523+ flex-direction: row;524+ justify-content: center;525+ gap: clamp(28px, 12vw, 60px);526+ padding: 18px 0;527+ }528+ .dg-rail { min-height: 64px; min-width: 84px; }529+ .dg-rail::before {530+ left: 50%; right: auto;531+ top: 0; bottom: 0;532+ border-top: none;533+ border-left: 2px dashed currentColor;534+ }535+ .dg-video .dg-head { right: auto; left: 50%; top: auto; bottom: -6px; transform: translateX(-50%) rotate(90deg); }536+ .dg-input .dg-head { right: auto; left: 50%; top: -6px; bottom: auto; transform: translateX(-50%) rotate(90deg); }537+ .dg-node { box-shadow: 6px 6px 0 var(--hair); }538+ .dg-notes { grid-template-columns: 1fr; }539+}540+541+/* ─── Quick start ──────────────────────────────────────────── */542+.start { padding: clamp(56px, 8vw, 96px) var(--edge); }543+.start-grid {544+ max-width: var(--maxw);545+ margin: 0 auto;546+ list-style: none;547+ display: grid;548+ grid-template-columns: repeat(3, 1fr);549+ gap: clamp(14px, 2vw, 22px);550+}551+.start-step {552+ position: relative;553+ border: 1px solid var(--hair-strong);554+ background: var(--paper-2);555+ box-shadow: 6px 6px 0 var(--hair);556+ padding: clamp(20px, 2.6vw, 28px);557+}558+.start-num {559+ display: block;560+ font-family: var(--mono);561+ font-size: clamp(30px, 4vw, 42px);562+ font-weight: 700;563+ line-height: 1;564+ color: var(--terracotta);565+ opacity: 0.85;566+ margin-bottom: 14px;567+}568+.start-step:nth-child(2) .start-num { color: var(--ochre); }569+.start-step:nth-child(3) .start-num { color: var(--moss); }570+.start-step h3 {571+ font-family: var(--mono);572+ font-size: 16px;573+ font-weight: 700;574+ text-transform: lowercase;575+ margin-bottom: 8px;576+ display: flex;577+ align-items: center;578+ gap: 10px;579+}580+.start-step kbd {581+ font-family: var(--mono);582+ font-size: 11px;583+ font-weight: 500;584+ line-height: 1;585+ color: var(--ink-muted);586+ border: 1px solid var(--hair-strong);587+ background: var(--paper);588+ padding: 4px 7px 5px;589+ box-shadow: 2px 2px 0 var(--hair);590+}591+.start-step p {592+ color: var(--ink-muted);593+ font-size: 14.5px;594+ line-height: 1.55;595+}596+.start-note {597+ max-width: var(--maxw);598+ margin: clamp(24px, 3.4vw, 36px) auto 0;599+ font-size: 14.5px;600+ color: var(--ink-muted);601+}602+@media (max-width: 720px) {603+ .start-grid { grid-template-columns: 1fr; }604+}605+606+/* ─── Scroll reveal (JS-gated: html.js only) ───────────────── */607+html.js .reveal {608+ opacity: 0;609+ transform: translateY(14px);610+ transition: opacity 0.55s ease, transform 0.55s ease;611+ transition-delay: var(--reveal-delay, 0s);612+}613+html.js .reveal.in { opacity: 1; transform: none; }614+@media (prefers-reduced-motion: reduce) {615+ html.js .reveal { opacity: 1; transform: none; transition: none; }616+}617+618+/* ─── Footer ───────────────────────────────────────────────── */619+.footer { padding: clamp(48px, 7vw, 80px) var(--edge) 64px; }620+.footer-inner { max-width: var(--maxw); margin: 0 auto; }621+.stack-row {622+ list-style: none;623+ display: flex;624+ flex-wrap: wrap;625+ gap: 8px;626+ margin-bottom: 30px;627+}628+.stack-row li {629+ font-family: var(--mono);630+ font-size: 11px;631+ letter-spacing: 0.04em;632+ text-transform: lowercase;633+ color: var(--ink-muted);634+ border: 1px solid var(--hair-strong);635+ padding: 5px 10px;636+ background: color-mix(in srgb, var(--paper-2) 70%, transparent);637+}638+.stack-row li:last-child { color: var(--terracotta); border-color: var(--terracotta); }639+.footer-meta {640+ display: flex;641+ flex-wrap: wrap;642+ gap: 10px 24px;643+ align-items: center;644+ padding-top: 26px;645+ border-top: 1px solid var(--hair);646+ font-family: var(--mono);647+ font-size: 12px;648+ color: var(--ink-muted);649+}650+.footer-link {651+ color: var(--ink);652+ text-decoration: none;653+ border-bottom: 1px solid var(--hair-strong);654+ padding-bottom: 1px;655+ transition: color 0.12s, border-color 0.12s;656+}657+.footer-link:hover { color: var(--terracotta); border-color: var(--terracotta); }658+.footer-link:focus-visible { outline: 2px solid var(--moss); outline-offset: 3px; }659+.footer-domain {660+ margin-left: auto;661+ color: var(--terracotta);662+ font-weight: 600;663+}664+@media (max-width: 620px) { .footer-domain { margin-left: 0; } }