cat / goq
clone

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

fix/release-manifest-language
goq / website / dither.js
dither.js 21.2K
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" };383 var portalManifestStatus = "loading";384 var portalManifestReason = "checking the signed release manifest";385386 function portalBuildFromManifest(manifest) {387 if (!manifest || manifest.format !== 1 || manifest.product !== "portal-client" ||388 !manifest.builds || typeof manifest.builds["macos-arm64"] !== "object") {389 throw new Error("invalid Portal release manifest");390 }391 var build = manifest.builds["macos-arm64"];392 if (build.available !== true) {393 if (build.available !== false || typeof build.reason !== "string" || !build.reason.trim()) {394 throw new Error("invalid unavailable Portal release entry");395 }396 portalManifestReason = build.reason.trim();397 return null;398 }399 var version = build.version;400 var tag = build.release_tag;401 var asset = build.asset;402 var expectedAsset = "Portal-" + version + "-arm64.dmg";403 var expectedBase = "https://github.com/FelineStateMachine/goq/releases/download/" + tag + "/";404 if (typeof version !== "string" || tag !== "v" + version ||405 build.platform !== "macos" || build.architecture !== "arm64" ||406 asset !== expectedAsset || build.download_url !== expectedBase + expectedAsset ||407 build.verification !== "adhoc-signed+github-attested+sha256") {408 throw new Error("invalid available Portal release entry");409 }410 return {411 href: build.download_url,412 label: "download Portal " + version + " · macOS arm64",413 note: "Portal " + version + " · macOS arm64 · not notarized: macOS will block the first launch until you allow it in System Settings › Privacy & Security. Verify the SHA-256 before opening.",414 };415 }416417 function pickedPortalValue(name) {418 var el = document.querySelector('input[name="' + name + '"]:checked');419 return el ? el.value : null;420 }421422 function updatePortalDownload() {423 var button = document.getElementById("portal-download");424 var note = document.getElementById("portal-release-status");425 var os = pickedPortalValue("portal-os");426 var arch = pickedPortalValue("portal-arch");427 if (!button || !note || !os || !arch) return;428 var build = PORTAL_BUILDS[os + "-" + arch];429 if (build) {430 button.classList.remove("disabled");431 button.removeAttribute("aria-disabled");432 button.setAttribute("href", build.href);433 button.textContent = build.label;434 note.textContent = build.note;435 } else {436 button.classList.add("disabled");437 button.setAttribute("aria-disabled", "true");438 button.removeAttribute("href"); // href-less anchor: unfocusable, unclickable439 button.textContent = "no " + os + " " + ARCH_NAMES[arch] + " build yet";440 if (os === "macos" && arch === "arm" && portalManifestStatus !== "loaded") {441 note.textContent = portalManifestReason + " · no download offered";442 } else if (os === "macos" && arch === "arm" && portalManifestReason) {443 note.textContent = portalManifestReason;444 } else {445 note.textContent = "portal for " + os + "/" + ARCH_NAMES[arch] +446 " is not released yet · macos arm64 comes first";447 }448 }449 }450451 function loadPortalReleaseManifest() {452 if (typeof window.fetch !== "function") {453 portalManifestStatus = "failed";454 portalManifestReason = "signed release manifest could not be loaded";455 updatePortalDownload();456 return;457 }458 window.fetch("./portal-release.json", {459 cache: "no-store",460 credentials: "same-origin",461 headers: { Accept: "application/json" },462 }).then(function (response) {463 if (!response.ok) throw new Error("Portal release manifest request failed");464 return response.json();465 }).then(function (manifest) {466 PORTAL_BUILDS["macos-arm"] = portalBuildFromManifest(manifest);467 portalManifestStatus = "loaded";468 }).catch(function () {469 PORTAL_BUILDS["macos-arm"] = null;470 portalManifestStatus = "failed";471 portalManifestReason = "signed release manifest is unavailable or invalid";472 }).then(updatePortalDownload);473 }474475 document.querySelectorAll('input[name="portal-os"], input[name="portal-arch"]').forEach(function (radio) {476 radio.addEventListener("change", updatePortalDownload);477 });478479 // Best-effort platform preselect: a visitor lands on their own OS/arch480 // and immediately sees whether a build exists. Falls back to macos+arm.481 (function preselectPortalPlatform() {482 var uad = navigator.userAgentData;483 var platform = ((uad && uad.platform) || navigator.platform || "").toLowerCase();484 var ua = (navigator.userAgent || "").toLowerCase();485 var os = null;486 if (platform.indexOf("mac") >= 0) os = "macos";487 else if (platform.indexOf("win") >= 0) os = "windows";488 else if (platform.indexOf("linux") >= 0 || ua.indexOf("linux") >= 0) os = "linux";489 // Plain UA can't distinguish Apple Silicon from Intel; default macs to490 // arm (the shipping target). Elsewhere, x86 unless the UA says ARM.491 var arch = os === "macos"492 ? "arm"493 : /aarch64|arm64|armv8/.test(ua) ? "arm" : "x86";494 if (os) {495 var osRadio = document.querySelector('input[name="portal-os"][value="' + os + '"]');496 var archRadio = document.querySelector('input[name="portal-arch"][value="' + arch + '"]');497 if (osRadio) osRadio.checked = true;498 if (archRadio) archRadio.checked = true;499 }500 updatePortalDownload();501 loadPortalReleaseManifest();502 })();503504 /* ── Copy-to-clipboard for the install command(s). ── */505 document.querySelectorAll(".install").forEach(function (box) {506 var btn = box.querySelector(".install-copy");507 if (!btn) return;508 btn.addEventListener("click", function () {509 var text = box.getAttribute("data-copy") || ""; // read current selection510 var done = function () {511 btn.textContent = "copied";512 btn.classList.add("copied");513 setTimeout(function () {514 btn.textContent = "copy";515 btn.classList.remove("copied");516 }, 1600);517 };518 if (navigator.clipboard && navigator.clipboard.writeText) {519 navigator.clipboard.writeText(text).then(done, fallback);520 } else {521 fallback();522 }523 function fallback() {524 var ta = document.createElement("textarea");525 ta.value = text;526 ta.style.position = "fixed";527 ta.style.opacity = "0";528 document.body.appendChild(ta);529 ta.select();530 try { document.execCommand("copy"); done(); } catch (e) {}531 document.body.removeChild(ta);532 }533 });534 });535536 /* ── Scroll reveal: gated behind html.js so no-JS visitors see537 everything; staggered per sibling group via a CSS var. ── */538 document.documentElement.classList.add("js");539 var revealEls = document.querySelectorAll(".reveal");540 if ("IntersectionObserver" in window && revealEls.length) {541 // stagger within each parent group (feature grid, steps, diagram)542 var groups = new Map();543 revealEls.forEach(function (el) {544 var parent = el.parentElement;545 var n = groups.get(parent) || 0;546 el.style.setProperty("--reveal-delay", (n * 0.07).toFixed(2) + "s");547 groups.set(parent, n + 1);548 });549 var io = new IntersectionObserver(function (entries) {550 entries.forEach(function (e) {551 if (e.isIntersecting) {552 e.target.classList.add("in");553 io.unobserve(e.target);554 }555 });556 }, { threshold: 0.15, rootMargin: "0px 0px -5% 0px" });557 revealEls.forEach(function (el) { io.observe(el); });558 } else {559 revealEls.forEach(function (el) { el.classList.add("in"); });560 }561})();