cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/hero-release-note
goq / website / dither.js
dither.js 20.1K
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 The pointer dents a damped wave field on the same grid, so moving7 the mouse leaves a rippling wake dithered into the terrain.8 ───────────────────────────────────────────────────────────── */9(function () {10 "use strict";1112 /* ── Two natural ramps (dark → light). A low-frequency region13 noise dithers between a warm-earth ramp and a green-tan ramp,14 so the field reads like natural terrain rather than one hue.15 Both share the pale highlight so patches blend at the light end. */16 function readPalette() {17 var s = getComputedStyle(document.documentElement);18 var pick = function (name, fallback) {19 var v = s.getPropertyValue(name).trim();20 return v || fallback;21 };22 // Gentle green lean applied to the dither colours only (not the UI23 // accent tokens): nudge green up, red/blue slightly down.24 var tint = function (c) {25 return [26 Math.max(0, Math.min(255, Math.round(c[0] * 0.97))),27 Math.max(0, Math.min(255, Math.round(c[1] * 1.05))),28 Math.max(0, Math.min(255, Math.round(c[2] * 0.98))),29 ];30 };31 var ramp = function (names) { return names.map(function (p) { return tint(hexToRgb(pick(p[0], p[1]))); }); };32 return {33 warm: ramp([34 ["--clay", "#9c5a44"],35 ["--terracotta", "#b5623b"],36 ["--ochre", "#c98a3a"],37 ["--sand", "#d8c39c"],38 ["--paper-2", "#e3d8c2"],39 ]),40 green: ramp([41 ["--moss-deep", "#46512f"],42 ["--moss", "#6b7145"],43 ["--olive", "#8b8b4e"],44 ["--tan", "#c3b184"],45 ["--paper-2", "#e3d8c2"],46 ]),47 };48 }4950 function hexToRgb(hex) {51 hex = hex.replace("#", "");52 if (hex.length === 3) {53 hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];54 }55 var n = parseInt(hex, 16);56 return [(n >> 16) & 255, (n >> 8) & 255, n & 255];57 }5859 /* ── Bayer 8x8 threshold matrix, normalised to 0..1. */60 var BAYER = (function () {61 var base = [62 [0, 32, 8, 40, 2, 34, 10, 42],63 [48, 16, 56, 24, 50, 18, 58, 26],64 [12, 44, 4, 36, 14, 46, 6, 38],65 [60, 28, 52, 20, 62, 30, 54, 22],66 [3, 35, 11, 43, 1, 33, 9, 41],67 [51, 19, 59, 27, 49, 17, 57, 25],68 [15, 47, 7, 39, 13, 45, 5, 37],69 [63, 31, 55, 23, 61, 29, 53, 21],70 ];71 var m = [];72 for (var y = 0; y < 8; y++) {73 m[y] = [];74 for (var x = 0; x < 8; x++) m[y][x] = (base[y][x] + 0.5) / 64;75 }76 return m;77 })();7879 /* ── Fractal value noise (deterministic, no Math.random). ──80 Integer-hashed lattice → smooth value noise → fBm → domain81 warp, for an organic marbled field instead of banded sines. */82 function hash2(ix, iy) {83 var n = Math.imul(ix | 0, 374761393) ^ Math.imul(iy | 0, 668265263);84 n = Math.imul(n ^ (n >>> 13), 1274126177);85 n ^= n >>> 16;86 return (n >>> 0) / 4294967296;87 }88 function vnoise(x, y) {89 var x0 = Math.floor(x), y0 = Math.floor(y);90 var fx = x - x0, fy = y - y0;91 var ux = fx * fx * (3 - 2 * fx);92 var uy = fy * fy * (3 - 2 * fy);93 var a = hash2(x0, y0), b = hash2(x0 + 1, y0);94 var c = hash2(x0, y0 + 1), d = hash2(x0 + 1, y0 + 1);95 return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy;96 }97 function fbm(x, y) {98 var v = 0, amp = 0.5, freq = 1;99 for (var o = 0; o < 3; o++) {100 v += amp * vnoise(x * freq, y * freq);101 freq *= 2.02;102 amp *= 0.5;103 }104 return v; // ~0..0.875105 }106 // cheaper 2-octave fBm for the large-scale region field107 function fbm2(x, y) {108 return 0.62 * vnoise(x, y) + 0.30 * vnoise(x * 2.02, y * 2.02);109 }110111 var canvas = document.querySelector(".hero-canvas");112 if (canvas && canvas.getContext) {113 setupDither(canvas);114 }115116 function setupDither(display) {117 var dctx = display.getContext("2d");118 // low-resolution offscreen buffer → scaled up into chunky pixels119 var buf = document.createElement("canvas");120 var bctx = buf.getContext("2d");121 var PIXEL = 4; // approximate on-screen size of one dither cell (smaller = denser)122 var palette = readPalette();123 var img, W, H;124125 function resize() {126 var r = display.getBoundingClientRect();127 var cssW = Math.max(1, Math.round(r.width));128 var cssH = Math.max(1, Math.round(r.height));129 W = Math.max(16, Math.ceil(cssW / PIXEL));130 H = Math.max(16, Math.ceil(cssH / PIXEL));131 buf.width = W;132 buf.height = H;133 display.width = W;134 display.height = H;135 dctx.imageSmoothingEnabled = false;136 palette = readPalette();137 img = bctx.createImageData(W, H);138 waveA = new Float32Array(W * H);139 waveB = new Float32Array(W * H);140 lastCellX = -1;141 }142143 /* ── Pointer ripples: a damped wave automaton on the dither grid.144 The pointer dents the height field; the dent propagates as145 rings that get dithered into the same ramps as the terrain. */146 var waveA, waveB; // waveA = current heights, waveB = previous147 var WAVE_DAMP = 0.97;148 var WAVE_WARP = 9; // wave gradient displaces the noise-sampling coords (refraction)149 var WAVE_SHADE = 0.06; // faint crest/trough shading on top of the refraction150 var lastCellX = -1, lastCellY = -1;151152 // Soft 5x5 dent: a wider impulse makes longer-wavelength rings153 // that survive the busy terrain instead of vanishing into it.154 function splash(cx, cy, strength) {155 var x = cx | 0, y = cy | 0;156 if (x < 2 || y < 2 || x >= W - 2 || y >= H - 2) return;157 for (var dy = -2; dy <= 2; dy++) {158 var row = (y + dy) * W + x;159 for (var dx = -2; dx <= 2; dx++) {160 var w = 1 - (dx * dx + dy * dy) / 6;161 if (w > 0) waveA[row + dx] -= strength * w;162 }163 }164 }165166 function stepWaves() {167 var a = waveA, b = waveB;168 for (var y = 1; y < H - 1; y++) {169 var row = y * W;170 for (var x = 1; x < W - 1; x++) {171 var i = row + x;172 b[i] = ((a[i - 1] + a[i + 1] + a[i - W] + a[i + W]) * 0.5 - b[i]) * WAVE_DAMP;173 }174 }175 waveA = b;176 waveB = a;177 }178179 function pointerCell(e) {180 var r = display.getBoundingClientRect();181 return [182 ((e.clientX - r.left) / r.width) * W,183 ((e.clientY - r.top) / r.height) * H,184 ];185 }186187 function trailTo(cx, cy) {188 if (lastCellX >= 0) {189 var dx = cx - lastCellX;190 var dy = cy - lastCellY;191 var steps = Math.min(48, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy))) || 1);192 for (var s = 1; s <= steps; s++) {193 splash(lastCellX + (dx * s) / steps, lastCellY + (dy * s) / steps, 0.22);194 }195 } else {196 splash(cx, cy, 0.22);197 }198 lastCellX = cx;199 lastCellY = cy;200 }201202 // Listen on the hero (not the canvas) so the trail continues203 // under the floating panel instead of dying at its edge.204 var hero = display.parentElement;205 var lastClientX = -1, lastClientY = -1;206 hero.addEventListener("pointermove", function (e) {207 if (reduced.matches || !raf) return;208 lastClientX = e.clientX;209 lastClientY = e.clientY;210 var c = pointerCell(e);211 trailTo(c[0], c[1]);212 });213 // Scrolling slides the field under a stationary cursor; lay the214 // same trail along that relative motion so it acts like a move.215 window.addEventListener("scroll", function () {216 if (reduced.matches || !raf || lastClientX < 0) return;217 var c = pointerCell({ clientX: lastClientX, clientY: lastClientY });218 trailTo(c[0], c[1]);219 }, { passive: true });220 hero.addEventListener("pointerdown", function (e) {221 if (reduced.matches || !raf) return;222 var c = pointerCell(e);223 splash(c[0], c[1], 1.2);224 });225 hero.addEventListener("pointerleave", function () { lastCellX = -1; lastClientX = -1; });226 hero.addEventListener("pointercancel", function () { lastCellX = -1; lastClientX = -1; });227228 // Per-load random seed: shifts sampling into a different region of229 // the infinite noise field so the background doesn't start the same230 // place every visit. (Browser Math.random — fine here.)231 var SEED_X = Math.random() * 940 + 30;232 var SEED_Y = Math.random() * 940 + 30;233234 // Domain-warped fBm: two warp layers churned by time give an235 // organic, marbled, non-repeating field.236 var FREQ = 0.014; // feature scale in cells (smaller = larger blobs)237 function field(x, y, t) {238 var nx = x * FREQ + SEED_X;239 var ny = y * FREQ + SEED_Y;240 // first warp vector241 var qx = fbm(nx, ny + t * 0.05);242 var qy = fbm(nx + 5.2, ny + 1.3 - t * 0.04);243 // second warp vector, offset by the first244 var rx = fbm(nx + 3.4 * qx + 1.7, ny + 3.4 * qy + 9.2 + t * 0.03);245 var ry = fbm(nx + 3.4 * qx + 8.3, ny + 3.4 * qy + 2.8 - t * 0.035);246 // final sample, plus a gentle diagonal bias for large-scale depth247 var v = fbm(nx + 3.2 * rx, ny + 3.2 * ry);248 v = v * 1.18 + 0.14 * (rx - ry);249 v = (v - 0.14) / 0.66; // normalise roughly to 0..1250 if (v < 0) v = 0;251 else if (v > 1) v = 1;252 return v;253 }254255 // Large-scale region field: chooses warm-earth vs green-tan patches.256 var RFREQ = 0.006;257 function regionField(x, y, t) {258 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);259 v = (v - 0.16) / 0.6;260 if (v < 0) v = 0;261 else if (v > 1) v = 1;262 return v;263 }264265 function render(t) {266 var data = img.data;267 var warm = palette.warm;268 var green = palette.green;269 var last = warm.length - 1;270 var i = 0;271 for (var y = 0; y < H; y++) {272 var brow = BAYER[y & 7];273 var brow2 = BAYER[(y + 4) & 7];274 for (var x = 0; x < W; x++) {275 // refract the terrain through the wave: the height-field276 // gradient bends where this cell samples the noise, so the277 // marbling itself churns inside the rings.278 var wi = i >> 2;279 var wx = x, wy = y;280 if (x > 0 && x < W - 1 && y > 0 && y < H - 1) {281 wx += (waveA[wi + 1] - waveA[wi - 1]) * WAVE_WARP;282 wy += (waveA[wi + W] - waveA[wi - W]) * WAVE_WARP;283 }284 var v = field(wx, wy, t) + waveA[wi] * WAVE_SHADE;285 if (v < 0) v = 0;286 else if (v > 1) v = 1;287 // ordered-dither the luminance ramp between neighbouring stops288 var scaled = v * last;289 var lo = Math.floor(scaled);290 if (lo >= last) lo = last - 1;291 if (lo < 0) lo = 0;292 var frac = scaled - lo;293 var idx = frac > brow[x & 7] ? lo + 1 : lo;294 // ordered-dither the region to pick which ramp this pixel uses295 var region = regionField(wx, wy, t);296 var arr = region > brow2[(x + 4) & 7] ? warm : green;297 var c = arr[idx];298 data[i] = c[0];299 data[i + 1] = c[1];300 data[i + 2] = c[2];301 data[i + 3] = 255;302 i += 4;303 }304 }305 bctx.putImageData(img, 0, 0);306 dctx.drawImage(buf, 0, 0);307 }308309 var reduced = window.matchMedia("(prefers-reduced-motion: reduce)");310 var raf = 0;311 var start = null;312 var lastDraw = -1e9;313 var FRAME_MS = 33; // ~30fps — the field drifts slowly, so this is plenty314315 function loop(now) {316 if (start === null) start = now;317 if (now - lastDraw >= FRAME_MS) {318 stepWaves();319 stepWaves(); // two sim ticks per drawn frame: rings spread faster320 render((now - start) / 1000);321 lastDraw = now;322 }323 raf = requestAnimationFrame(loop);324 }325326 function begin() {327 cancelAnimationFrame(raf);328 resize();329 if (reduced.matches) {330 render(6); // one representative static frame331 } else {332 start = null;333 raf = requestAnimationFrame(loop);334 }335 }336337 var rt;338 window.addEventListener("resize", function () {339 clearTimeout(rt);340 rt = setTimeout(begin, 150);341 });342 if (reduced.addEventListener) reduced.addEventListener("change", begin);343 // pause when the tab/section is off-screen to save cycles344 if ("IntersectionObserver" in window) {345 new IntersectionObserver(function (entries) {346 entries.forEach(function (e) {347 if (reduced.matches) return;348 if (e.isIntersecting) {349 if (!raf) { start = null; raf = requestAnimationFrame(loop); }350 } else {351 cancelAnimationFrame(raf);352 raf = 0;353 }354 });355 }, { threshold: 0 }).observe(display);356 }357358 begin();359 }360361 /* ── Portal is a signed download; Sigil is a machine install command. ── */362 document.querySelectorAll('input[name="release-target"]').forEach(function (radio) {363 radio.addEventListener("change", function () {364 if (!radio.checked) return;365 document.querySelectorAll("[data-release-target]").forEach(function (panel) {366 panel.hidden = panel.getAttribute("data-release-target") !== radio.value;367 });368 });369 });370371 /* ── Portal platform matrix. Availability comes only from the reviewed372 static manifest. Invalid or unavailable manifests leave every entry373 null, so no development or unsigned fallback can become a link. */374 var PORTAL_BUILDS = {375 "macos-arm": null,376 "macos-x86": null,377 "linux-arm": null,378 "linux-x86": null,379 "windows-arm": null,380 "windows-x86": null,381 };382 var ARCH_NAMES = { arm: "arm64", x86: "x86_64" };383384 function portalBuildFromManifest(manifest) {385 if (!manifest || manifest.format !== 1 || manifest.product !== "portal-client" ||386 !manifest.builds || typeof manifest.builds["macos-arm64"] !== "object") {387 throw new Error("invalid Portal release manifest");388 }389 var build = manifest.builds["macos-arm64"];390 if (build.available !== true) {391 if (build.available !== false || typeof build.reason !== "string" || !build.reason.trim()) {392 throw new Error("invalid unavailable Portal release entry");393 }394 return null;395 }396 var version = build.version;397 var tag = build.release_tag;398 var asset = build.asset;399 var expectedAsset = "Portal-" + version + "-arm64.dmg";400 var expectedBase = "https://github.com/FelineStateMachine/goq/releases/download/" + tag + "/";401 if (typeof version !== "string" || tag !== "v" + version ||402 build.platform !== "macos" || build.architecture !== "arm64" ||403 asset !== expectedAsset || build.download_url !== expectedBase + expectedAsset ||404 build.verification !== "adhoc-signed+github-attested+sha256") {405 throw new Error("invalid available Portal release entry");406 }407 return {408 href: build.download_url,409 label: "download Portal " + version + " · macOS arm64",410 };411 }412413 function pickedPortalValue(name) {414 var el = document.querySelector('input[name="' + name + '"]:checked');415 return el ? el.value : null;416 }417418 // The button label already carries the state. The hero does not repeat it as419 // prose, so nothing writes release notes into the panel.420 function updatePortalDownload() {421 var button = document.getElementById("portal-download");422 var os = pickedPortalValue("portal-os");423 var arch = pickedPortalValue("portal-arch");424 if (!button || !os || !arch) return;425 var build = PORTAL_BUILDS[os + "-" + arch];426 if (build) {427 button.classList.remove("disabled");428 button.removeAttribute("aria-disabled");429 button.setAttribute("href", build.href);430 button.textContent = build.label;431 } else {432 button.classList.add("disabled");433 button.setAttribute("aria-disabled", "true");434 button.removeAttribute("href"); // href-less anchor: unfocusable, unclickable435 button.textContent = "no " + os + " " + ARCH_NAMES[arch] + " build yet";436 }437 }438439 function loadPortalReleaseManifest() {440 if (typeof window.fetch !== "function") {441 updatePortalDownload();442 return;443 }444 window.fetch("./portal-release.json", {445 cache: "no-store",446 credentials: "same-origin",447 headers: { Accept: "application/json" },448 }).then(function (response) {449 if (!response.ok) throw new Error("Portal release manifest request failed");450 return response.json();451 }).then(function (manifest) {452 PORTAL_BUILDS["macos-arm"] = portalBuildFromManifest(manifest);453 }).catch(function () {454 PORTAL_BUILDS["macos-arm"] = null;455 }).then(updatePortalDownload);456 }457458 document.querySelectorAll('input[name="portal-os"], input[name="portal-arch"]').forEach(function (radio) {459 radio.addEventListener("change", updatePortalDownload);460 });461462 // Best-effort platform preselect: a visitor lands on their own OS/arch463 // and immediately sees whether a build exists. Falls back to macos+arm.464 (function preselectPortalPlatform() {465 var uad = navigator.userAgentData;466 var platform = ((uad && uad.platform) || navigator.platform || "").toLowerCase();467 var ua = (navigator.userAgent || "").toLowerCase();468 var os = null;469 if (platform.indexOf("mac") >= 0) os = "macos";470 else if (platform.indexOf("win") >= 0) os = "windows";471 else if (platform.indexOf("linux") >= 0 || ua.indexOf("linux") >= 0) os = "linux";472 // Plain UA can't distinguish Apple Silicon from Intel; default macs to473 // arm (the shipping target). Elsewhere, x86 unless the UA says ARM.474 var arch = os === "macos"475 ? "arm"476 : /aarch64|arm64|armv8/.test(ua) ? "arm" : "x86";477 if (os) {478 var osRadio = document.querySelector('input[name="portal-os"][value="' + os + '"]');479 var archRadio = document.querySelector('input[name="portal-arch"][value="' + arch + '"]');480 if (osRadio) osRadio.checked = true;481 if (archRadio) archRadio.checked = true;482 }483 updatePortalDownload();484 loadPortalReleaseManifest();485 })();486487 /* ── Copy-to-clipboard for the install command(s). ── */488 document.querySelectorAll(".install").forEach(function (box) {489 var btn = box.querySelector(".install-copy");490 if (!btn) return;491 btn.addEventListener("click", function () {492 var text = box.getAttribute("data-copy") || ""; // read current selection493 var done = function () {494 btn.textContent = "copied";495 btn.classList.add("copied");496 setTimeout(function () {497 btn.textContent = "copy";498 btn.classList.remove("copied");499 }, 1600);500 };501 if (navigator.clipboard && navigator.clipboard.writeText) {502 navigator.clipboard.writeText(text).then(done, fallback);503 } else {504 fallback();505 }506 function fallback() {507 var ta = document.createElement("textarea");508 ta.value = text;509 ta.style.position = "fixed";510 ta.style.opacity = "0";511 document.body.appendChild(ta);512 ta.select();513 try { document.execCommand("copy"); done(); } catch (e) {}514 document.body.removeChild(ta);515 }516 });517 });518519 /* ── Scroll reveal: gated behind html.js so no-JS visitors see520 everything; staggered per sibling group via a CSS var. ── */521 document.documentElement.classList.add("js");522 var revealEls = document.querySelectorAll(".reveal");523 if ("IntersectionObserver" in window && revealEls.length) {524 // stagger within each parent group (feature grid, steps, diagram)525 var groups = new Map();526 revealEls.forEach(function (el) {527 var parent = el.parentElement;528 var n = groups.get(parent) || 0;529 el.style.setProperty("--reveal-delay", (n * 0.07).toFixed(2) + "s");530 groups.set(parent, n + 1);531 });532 var io = new IntersectionObserver(function (entries) {533 entries.forEach(function (e) {534 if (e.isIntersecting) {535 e.target.classList.add("in");536 io.unobserve(e.target);537 }538 });539 }, { threshold: 0.15, rootMargin: "0px 0px -5% 0px" });540 revealEls.forEach(function (el) { io.observe(el); });541 } else {542 revealEls.forEach(function (el) { el.classList.add("in"); });543 }544})();