diff --git a/.gitignore b/.gitignore index bd422ab..1e32026 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # misc .DS_Store *.pem +*.har # debug npm-debug.log* diff --git a/README.md b/README.md index 3e8e94b..52e5c9c 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Personal site for Jet Pham. The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a WebGL2 Conway's Game of Life background. +It also ships as a Nix flake with a reusable NixOS module for serving the static frontend and the Q+A API on a host. + ## Features - ASCII/ANSI-inspired visual style with the IBM VGA font @@ -53,9 +55,39 @@ npm run build ## Structure ```text +api/ Q+A backend +module.nix NixOS module src/ frontend app ``` +## NixOS module + +Import the module from the flake and point it at the host-managed secret files you want to use. + +```nix +{ + inputs.website.url = "github:jetpham/website"; + + outputs = { self, nixpkgs, website, ... }: { + nixosConfigurations.my-host = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + website.nixosModules.default + ({ config, ... }: { + services.jetpham-website = { + enable = true; + domain = "jetpham.com"; + webhookSecretFile = config.age.secrets.webhook-secret.path; + }; + }) + ]; + }; + }; +} +``` + +Optional Tor support is configured by setting `services.jetpham-website.tor.enable = true;` and providing the three onion key file paths. + ## Notes - The homepage and Q+A page are the intended public pages. diff --git a/api/src/serve.rs b/api/src/serve.rs index 0bb67a9..a797234 100644 --- a/api/src/serve.rs +++ b/api/src/serve.rs @@ -25,6 +25,10 @@ pub async fn run() -> Result<(), Box> { let qa_reply_domain = std::env::var("QA_REPLY_DOMAIN").unwrap_or_else(|_| mail_domain.clone()); let webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set"); + let listen_address = + std::env::var("QA_LISTEN_ADDRESS").unwrap_or_else(|_| "127.0.0.1".to_string()); + let listen_port = std::env::var("QA_LISTEN_PORT").unwrap_or_else(|_| "3003".to_string()); + let listen_target = format!("{listen_address}:{listen_port}"); let conn = Connection::open(&db_path)?; conn.execute_batch("PRAGMA journal_mode=WAL;")?; @@ -58,8 +62,8 @@ pub async fn run() -> Result<(), Box> { .layer(CorsLayer::permissive()) .with_state(state); - let listener = tokio::net::TcpListener::bind("127.0.0.1:3003").await?; - println!("Listening on 127.0.0.1:3003"); + let listener = tokio::net::TcpListener::bind(&listen_target).await?; + println!("Listening on {listen_target}"); axum::serve(listener, app).await?; Ok(()) } diff --git a/check_cleanup.txt b/check_cleanup.txt deleted file mode 100644 index 8ee83ad..0000000 --- a/check_cleanup.txt +++ /dev/null @@ -1,2 +0,0 @@ -$ bun run lint && tsc --noEmit -$ eslint . diff --git a/flake.lock b/flake.lock index 8b83aa9..f194334 100644 --- a/flake.lock +++ b/flake.lock @@ -1,53 +1,8 @@ { "nodes": { - "agenix": { - "inputs": { - "darwin": "darwin", - "home-manager": "home-manager", - "nixpkgs": [ - "nixpkgs" - ], - "systems": "systems" - }, - "locked": { - "lastModified": 1770165109, - "narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=", - "owner": "ryantm", - "repo": "agenix", - "rev": "b027ee29d959fda4b60b57566d64c98a202e0feb", - "type": "github" - }, - "original": { - "owner": "ryantm", - "repo": "agenix", - "type": "github" - } - }, - "darwin": { - "inputs": { - "nixpkgs": [ - "agenix", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1744478979, - "narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=", - "owner": "lnl7", - "repo": "nix-darwin", - "rev": "43975d782b418ebf4969e9ccba82466728c2851b", - "type": "github" - }, - "original": { - "owner": "lnl7", - "ref": "master", - "repo": "nix-darwin", - "type": "github" - } - }, "flake-utils": { "inputs": { - "systems": "systems_2" + "systems": "systems" }, "locked": { "lastModified": 1731533236, @@ -63,27 +18,6 @@ "type": "github" } }, - "home-manager": { - "inputs": { - "nixpkgs": [ - "agenix", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1745494811, - "narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=", - "owner": "nix-community", - "repo": "home-manager", - "rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "home-manager", - "type": "github" - } - }, "nixpkgs": { "locked": { "lastModified": 1774386573, @@ -100,46 +34,10 @@ "type": "github" } }, - "nixpkgs_2": { - "locked": { - "lastModified": 1744536153, - "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "root": { "inputs": { - "agenix": "agenix", "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" - } - }, - "rust-overlay": { - "inputs": { - "nixpkgs": "nixpkgs_2" - }, - "locked": { - "lastModified": 1774581174, - "narHash": "sha256-258qgkMkYPkJ9qpIg63Wk8GoIbVjszkGGPU1wbVHYTk=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "a313afc75b85fc77ac154bf0e62c36f68361fd0b", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" + "nixpkgs": "nixpkgs" } }, "systems": { @@ -156,21 +54,6 @@ "repo": "default", "type": "github" } - }, - "systems_2": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 12dec4d..9ea1199 100644 --- a/flake.nix +++ b/flake.nix @@ -2,93 +2,38 @@ description = "Jet Pham's personal website"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - rust-overlay.url = "github:oxalica/rust-overlay"; flake-utils.url = "github:numtide/flake-utils"; - agenix.url = "github:ryantm/agenix"; - agenix.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = { self, nixpkgs, - rust-overlay, flake-utils, - agenix, }: (flake-utils.lib.eachDefaultSystem ( system: let - overlays = [ (import rust-overlay) ]; - pkgs = import nixpkgs { inherit system overlays; }; - agenixPkg = agenix.packages.${system}.default; - rustToolchain = pkgs.rust-bin.selectLatestNightlyWith ( - toolchain: - toolchain.default.override { - extensions = [ "rust-src" ]; - targets = [ "wasm32-unknown-unknown" ]; - } - ); - rustPlatform = pkgs.makeRustPlatform { - cargo = rustToolchain; - rustc = rustToolchain; - }; - - cgol-wasm = rustPlatform.buildRustPackage { - pname = "cgol-wasm"; - version = "0.1.0"; - src = ./cgol; - cargoLock.lockFile = ./cgol/Cargo.lock; - doCheck = false; - - nativeBuildInputs = [ - pkgs.wasm-bindgen-cli - pkgs.binaryen + pkgs = import nixpkgs { inherit system; }; + lib = pkgs.lib; + websiteSrc = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./index.html + ./package-lock.json + ./package.json + ./public + ./src + ./tsconfig.json + ./vite-plugin-ansi.ts + ./vite.config.ts ]; - - buildPhase = '' - runHook preBuild - cargo build --release --frozen --target wasm32-unknown-unknown - wasm-bindgen --target web --out-dir pkg target/wasm32-unknown-unknown/release/cgol.wasm - wasm-opt pkg/cgol_bg.wasm -o pkg/cgol_bg.wasm -O4 \ - --enable-bulk-memory --enable-nontrapping-float-to-int \ - --enable-sign-ext --low-memory-unused --converge - runHook postBuild - ''; - - installPhase = '' - runHook preInstall - mkdir -p $out - cp pkg/cgol_bg.wasm $out/ - cp pkg/cgol.js $out/ - cp pkg/cgol.d.ts $out/ - cp pkg/cgol_bg.wasm.d.ts $out/ 2>/dev/null || true - cat > $out/package.json <<'EOF' - { - "name": "cgol", - "type": "module", - "version": "0.1.0", - "files": ["cgol_bg.wasm", "cgol.js", "cgol.d.ts"], - "main": "cgol.js", - "types": "cgol.d.ts", - "sideEffects": ["./snippets/*"] - } - EOF - runHook postInstall - ''; }; - # Stage 2: Build the website with npm website = pkgs.buildNpmPackage { pname = "jet-website"; version = "0.1.0"; - src = pkgs.lib.cleanSource ./.; - npmDepsHash = "sha256-O4ZUSYyVWOxP15saIadsaZuRO47Y0AvsL4pwvo5b76U="; - - # Inject the Nix-built WASM before npm install resolves file: dep - postPatch = '' - mkdir -p cgol/pkg - cp -r ${cgol-wasm}/* cgol/pkg/ - ''; + src = websiteSrc; + npmDepsHash = "sha256-UDz4tXNvEa8uiDDGg16K9JbNeQZR3BsVNKtuOgcyurQ="; installPhase = '' runHook preInstall @@ -110,7 +55,6 @@ { packages = { default = website; - cgol-wasm = cgol-wasm; inherit qa-api; }; devShells.default = pkgs.mkShell { @@ -119,12 +63,11 @@ git curl openssl - agenixPkg typescript-language-server + rust-analyzer + rustc + cargo pkg-config - wasm-pack - binaryen - rustToolchain ]; }; } diff --git a/index.html b/index.html index 250ebf0..740d2c0 100644 --- a/index.html +++ b/index.html @@ -106,151 +106,24 @@ class="pointer-events-none fixed inset-0 z-0 h-screen w-screen" aria-hidden="true" > -
diff --git a/module.nix b/module.nix index 92b9669..1b5dba7 100644 --- a/module.nix +++ b/module.nix @@ -1,27 +1,139 @@ self: -{ config, lib, ... }: +{ + config, + lib, + pkgs, + ... +}: let cfg = config.services.jetpham-website; - package = self.packages.x86_64-linux.default; - qaApi = self.packages.x86_64-linux.qa-api; + package = cfg.package; + qaApi = cfg.apiPackage; + apiListen = "${cfg.apiListenAddress}:${toString cfg.apiListenPort}"; + usingDefaultWebhookSecret = cfg.webhookSecretFile == null; webhookSecretPath = - if cfg.webhookSecretFile != null then - cfg.webhookSecretFile + if usingDefaultWebhookSecret then config.age.secrets.webhook-secret.path else cfg.webhookSecretFile; + usingDefaultTorSecretKey = cfg.tor.onionSecretKeyFile == null; + usingDefaultTorPublicKey = cfg.tor.onionPublicKeyFile == null; + usingDefaultTorHostname = cfg.tor.onionHostnameFile == null; + torOnionSecretKeyPath = + if usingDefaultTorSecretKey then + config.age.secrets.tor-onion-secret-key.path else - config.age.secrets.webhook-secret.path; + cfg.tor.onionSecretKeyFile; + torOnionPublicKeyPath = + if usingDefaultTorPublicKey then + config.age.secrets.tor-onion-public-key.path + else + cfg.tor.onionPublicKeyFile; + torOnionHostnamePath = + if usingDefaultTorHostname then + config.age.secrets.tor-onion-hostname.path + else + cfg.tor.onionHostnameFile; + caddyCommonConfig = '' + header Cross-Origin-Opener-Policy "same-origin" + header Cross-Origin-Embedder-Policy "require-corp" + + handle /api/* { + reverse_proxy ${apiListen} + } + + handle /qa/rss.xml { + reverse_proxy ${apiListen} + } + + handle { + root * ${package} + try_files {path} /index.html + file_server + } + + ${cfg.caddy.extraConfig} + ''; in { options.services.jetpham-website = { enable = lib.mkEnableOption "Jet Pham's personal website"; + package = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.system}.default; + defaultText = lib.literalExpression "self.packages.${pkgs.system}.default"; + description = "Static site package served by Caddy."; + }; + + apiPackage = lib.mkOption { + type = lib.types.package; + default = self.packages.${pkgs.system}.qa-api; + defaultText = lib.literalExpression "self.packages.${pkgs.system}.qa-api"; + description = "Q&A API package run by systemd."; + }; + domain = lib.mkOption { type = lib.types.str; default = "jetpham.com"; description = "Domain to serve the website on."; }; - tor.enable = lib.mkEnableOption "Tor hidden service for the website"; + openFirewall = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Open HTTP and HTTPS ports when Caddy is enabled."; + }; + + apiListenAddress = lib.mkOption { + type = lib.types.str; + default = "127.0.0.1"; + description = "Address for the local Q&A API listener."; + }; + + apiListenPort = lib.mkOption { + type = lib.types.port; + default = 3003; + description = "Port for the local Q&A API listener."; + }; + + caddy.enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Serve the static site and reverse proxy the API through Caddy."; + }; + + caddy.extraConfig = lib.mkOption { + type = lib.types.lines; + default = ""; + description = "Extra Caddy directives appended inside the virtual host block."; + }; + + tor = { + enable = lib.mkEnableOption "Tor hidden service for the website"; + + port = lib.mkOption { + type = lib.types.port; + default = 8888; + description = "Local Caddy port exposed through the onion service."; + }; + + onionSecretKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to the Tor hidden service secret key file."; + }; + + onionPublicKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to the Tor hidden service public key file."; + }; + + onionHostnameFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to the Tor hidden service hostname file."; + }; + }; qaNotifyEmail = lib.mkOption { type = lib.types.str; @@ -44,35 +156,53 @@ in webhookSecretFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; - description = "File containing the WEBHOOK_SECRET for MTA Hook authentication."; + description = "File containing the WEBHOOK_SECRET for MTA Hook authentication. Defaults to the module-managed agenix secret when left unset."; }; }; config = lib.mkIf cfg.enable { - age.secrets.webhook-secret = lib.mkIf (cfg.webhookSecretFile == null) { + age.secrets.webhook-secret = lib.mkIf usingDefaultWebhookSecret { file = "${self}/secrets/webhook-secret.age"; mode = "0400"; }; - age.secrets.tor-onion-secret-key = lib.mkIf cfg.tor.enable { + age.secrets.tor-onion-secret-key = lib.mkIf (cfg.tor.enable && usingDefaultTorSecretKey) { file = "${self}/secrets/tor-onion-secret-key.age"; owner = "tor"; group = "tor"; mode = "0400"; }; - age.secrets.tor-onion-public-key = lib.mkIf cfg.tor.enable { + + age.secrets.tor-onion-public-key = lib.mkIf (cfg.tor.enable && usingDefaultTorPublicKey) { file = "${self}/secrets/tor-onion-public-key.age"; owner = "tor"; group = "tor"; mode = "0444"; }; - age.secrets.tor-onion-hostname = lib.mkIf cfg.tor.enable { + + age.secrets.tor-onion-hostname = lib.mkIf (cfg.tor.enable && usingDefaultTorHostname) { file = "${self}/secrets/tor-onion-hostname.age"; owner = "tor"; group = "tor"; mode = "0444"; }; + assertions = [ + { + assertion = + !cfg.tor.enable + || (torOnionSecretKeyPath != null && torOnionPublicKeyPath != null && torOnionHostnamePath != null); + message = "services.jetpham-website.tor requires onionSecretKeyFile, onionPublicKeyFile, and onionHostnameFile."; + } + ]; + + networking.firewall.allowedTCPPorts = lib.mkIf (cfg.caddy.enable && cfg.openFirewall) [ + 80 + 443 + ]; + + services.caddy.enable = cfg.caddy.enable; + services.tor = lib.mkIf cfg.tor.enable { enable = true; relay.onionServices.jetpham-website = { @@ -81,7 +211,7 @@ in port = 80; target = { addr = "127.0.0.1"; - port = 8888; + port = cfg.tor.port; }; } ]; @@ -90,36 +220,47 @@ in systemd.services.tor-onion-keys = lib.mkIf cfg.tor.enable { description = "Copy Tor onion keys into place"; - after = [ "agenix.service" ]; + after = lib.optional ( + usingDefaultTorSecretKey || usingDefaultTorPublicKey || usingDefaultTorHostname + ) "agenix.service"; before = [ "tor.service" ]; wantedBy = [ "tor.service" ]; serviceConfig.Type = "oneshot"; script = '' dir="/var/lib/tor/onion/jetpham-website" mkdir -p "$dir" - cp ${config.age.secrets.tor-onion-secret-key.path} "$dir/hs_ed25519_secret_key" - cp ${config.age.secrets.tor-onion-public-key.path} "$dir/hs_ed25519_public_key" - cp ${config.age.secrets.tor-onion-hostname.path} "$dir/hostname" + cp ${torOnionSecretKeyPath} "$dir/hs_ed25519_secret_key" + cp ${torOnionPublicKeyPath} "$dir/hs_ed25519_public_key" + cp ${torOnionHostnamePath} "$dir/hostname" chown -R tor:tor "$dir" chmod 700 "$dir" chmod 400 "$dir/hs_ed25519_secret_key" chmod 444 "$dir/hs_ed25519_public_key" "$dir/hostname" ''; }; - # Q&A API systemd service + systemd.services.jetpham-qa-api = { description = "Jet Pham Q&A API"; - after = [ "network.target" ]; + after = [ "network-online.target" ] ++ lib.optional usingDefaultWebhookSecret "agenix.service"; + wants = [ "network-online.target" ] ++ lib.optional usingDefaultWebhookSecret "agenix.service"; wantedBy = [ "multi-user.target" ]; serviceConfig = { DynamicUser = true; StateDirectory = "jetpham-qa"; + WorkingDirectory = "/var/lib/jetpham-qa"; Environment = [ "QA_DB_PATH=/var/lib/jetpham-qa/qa.db" "QA_NOTIFY_EMAIL=${cfg.qaNotifyEmail}" "QA_MAIL_DOMAIN=${cfg.qaMailDomain}" "QA_REPLY_DOMAIN=${cfg.qaReplyDomain}" + "QA_LISTEN_ADDRESS=${cfg.apiListenAddress}" + "QA_LISTEN_PORT=${toString cfg.apiListenPort}" ]; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + ReadWritePaths = [ "/var/lib/jetpham-qa" ]; Restart = "on-failure"; RestartSec = 5; LoadCredential = "webhook-secret:${webhookSecretPath}"; @@ -135,47 +276,17 @@ in ''; }; - services.caddy.virtualHosts.${cfg.domain} = { - extraConfig = '' - header Cross-Origin-Opener-Policy "same-origin" - header Cross-Origin-Embedder-Policy "require-corp" - - handle /api/* { - reverse_proxy 127.0.0.1:3003 - } - - handle /qa/rss.xml { - reverse_proxy 127.0.0.1:3003 - } - - handle { - root * ${package} - try_files {path} /index.html - file_server - } - ''; + services.caddy.virtualHosts.${cfg.domain} = lib.mkIf cfg.caddy.enable { + extraConfig = caddyCommonConfig; }; - services.caddy.virtualHosts."http://:8888" = lib.mkIf cfg.tor.enable { - extraConfig = '' - bind 127.0.0.1 - header Cross-Origin-Opener-Policy "same-origin" - header Cross-Origin-Embedder-Policy "require-corp" - - handle /api/* { - reverse_proxy 127.0.0.1:3003 - } - - handle /qa/rss.xml { - reverse_proxy 127.0.0.1:3003 - } - - handle { - root * ${package} - try_files {path} /index.html - file_server - } - ''; - }; + services.caddy.virtualHosts."http://:${toString cfg.tor.port}" = + lib.mkIf (cfg.caddy.enable && cfg.tor.enable) + { + extraConfig = '' + bind 127.0.0.1 + ${caddyCommonConfig} + ''; + }; }; } diff --git a/src/lib/site.ts b/src/lib/site.ts index dbdb0ae..713bd0d 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -32,13 +32,13 @@ export function renderFooter() { diff --git a/src/lib/webgl-background.ts b/src/lib/webgl-background.ts index 42b1ffa..a95533f 100644 --- a/src/lib/webgl-background.ts +++ b/src/lib/webgl-background.ts @@ -23,10 +23,9 @@ type PointerState = { row: number; }; -type TuningState = { +type StackTuning = { blurStrength: number; blurRadius: number; - darkness: number; smallestBlock: number; largestBlock: number; levels: number; @@ -37,6 +36,20 @@ type TuningState = { streakStrength: number; }; +type TuningState = { + inside: StackTuning; + outside: StackTuning; + outsideGlow: { + threshold: number; + softness: number; + boost: number; + }; + darkness: number; + imageEmit: number; + ansiEmit: number; + linkEmit: number; +}; + const fullscreenVertexSource = `#version 300 es precision highp float; @@ -154,6 +167,53 @@ void main() { outColor = state; }`; +const injectFragmentSource = `#version 300 es +precision highp float; +precision highp sampler2D; + +uniform sampler2D u_state; +uniform sampler2D u_emitter; +uniform ivec2 u_gridSize; + +out vec4 outColor; + +float hash12(vec2 point) { + vec3 p3 = fract(vec3(point.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +vec3 rgbToHsv(vec3 color) { + vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); + vec4 p = mix(vec4(color.bg, K.wz), vec4(color.gb, K.xy), step(color.b, color.g)); + vec4 q = mix(vec4(p.xyw, color.r), vec4(color.r, p.yzx), step(p.x, color.r)); + float d = q.x - min(q.w, q.y); + float e = 1.0e-10; + return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); +} + +void main() { + ivec2 cell = ivec2(gl_FragCoord.xy); + vec4 state = texelFetch(u_state, cell, 0); + vec4 emitter = texelFetch(u_emitter, cell, 0); + float strength = clamp(emitter.a, 0.0, 1.0); + + if (strength <= 0.0) { + outColor = state; + return; + } + + float noise = hash12(vec2(cell) + emitter.rg * 255.0); + if (noise > strength) { + outColor = state; + return; + } + + vec3 hsv = rgbToHsv(emitter.rgb); + int hue = int(floor(hsv.x * 254.0)) + 1; + outColor = vec4(float(hue) / 255.0, 0.0, 0.0, 1.0); +}`; + const colorFragmentSource = `#version 300 es precision highp float; precision highp sampler2D; @@ -225,20 +285,33 @@ precision highp float; precision highp sampler2D; uniform sampler2D u_sharp; -uniform sampler2D u_blur; +uniform sampler2D u_inBlur; +uniform sampler2D u_outBlur; +uniform sampler2D u_outBlur2; uniform sampler2D u_palette; uniform vec2 u_resolution; uniform vec4 u_panelRects[${PANEL_LIMIT}]; uniform int u_panelCount; -uniform float u_minBlockSize; -uniform float u_maxBlockSize; -uniform int u_levels; -uniform float u_detailThreshold; -uniform float u_ditherStrength; -uniform float u_edgeBias; -uniform float u_hueDrift; -uniform float u_streakStrength; - +uniform float u_inMinBlockSize; +uniform float u_inMaxBlockSize; +uniform int u_inLevels; +uniform float u_inDetailThreshold; +uniform float u_inDitherStrength; +uniform float u_inEdgeBias; +uniform float u_inHueDrift; +uniform float u_inStreakStrength; +uniform float u_outMinBlockSize; +uniform float u_outMaxBlockSize; +uniform int u_outLevels; +uniform float u_outDetailThreshold; +uniform float u_outDitherStrength; +uniform float u_outEdgeBias; +uniform float u_outHueDrift; +uniform float u_outStreakStrength; +uniform float u_outGlowStrength; +uniform float u_outGlowThreshold; +uniform float u_outGlowSoftness; +uniform float u_outGlowBoost; out vec4 outColor; bool inRect(vec2 point, vec4 rect) { @@ -248,6 +321,15 @@ bool inRect(vec2 point, vec4 rect) { point.y < rect.y + rect.w; } +float rectSignedDistance(vec2 point, vec4 rect) { + vec2 center = rect.xy + rect.zw * 0.5; + vec2 halfSize = rect.zw * 0.5; + vec2 delta = abs(point - center) - halfSize; + float outside = length(max(delta, 0.0)); + float inside = min(max(delta.x, delta.y), 0.0); + return outside + inside; +} + float bayer4(vec2 point) { vec2 cell = mod(floor(point), 4.0); if (cell.y < 1.0) { @@ -293,23 +375,23 @@ float hash12(vec2 point) { return fract((p3.x + p3.y) * p3.z); } -vec3 sampleBlock(vec2 point, float blockSize) { +vec3 sampleBlock(vec2 point, float blockSize, float streakStrength, sampler2D blurTex) { vec2 blockCoord = floor(point / blockSize); vec2 samplePoint = (blockCoord + 0.5) * blockSize; - float streak = (hash12(vec2(blockCoord.y, blockSize)) - 0.5) * blockSize * u_streakStrength; + float streak = (hash12(vec2(blockCoord.y, blockSize)) - 0.5) * blockSize * streakStrength; samplePoint.x += streak; samplePoint.x = clamp(samplePoint.x, 0.5, u_resolution.x - 0.5); vec2 sampleUv = samplePoint / u_resolution; - return texture(u_blur, sampleUv).rgb; + return texture(blurTex, sampleUv).rgb; } -float blockDetail(vec2 point, float blockSize) { +float blockDetail(vec2 point, float blockSize, float edgeBias, float streakStrength, sampler2D blurTex) { vec2 offset = vec2(blockSize * 0.35); - vec3 center = sampleBlock(point, blockSize); - vec3 right = sampleBlock(point + vec2(offset.x, 0.0), blockSize); - vec3 left = sampleBlock(point + vec2(-offset.x, 0.0), blockSize); - vec3 up = sampleBlock(point + vec2(0.0, offset.y), blockSize); - vec3 down = sampleBlock(point + vec2(0.0, -offset.y), blockSize); + vec3 center = sampleBlock(point, blockSize, streakStrength, blurTex); + vec3 right = sampleBlock(point + vec2(offset.x, 0.0), blockSize, streakStrength, blurTex); + vec3 left = sampleBlock(point + vec2(-offset.x, 0.0), blockSize, streakStrength, blurTex); + vec3 up = sampleBlock(point + vec2(0.0, offset.y), blockSize, streakStrength, blurTex); + vec3 down = sampleBlock(point + vec2(0.0, -offset.y), blockSize, streakStrength, blurTex); float edgeX = abs(luma(right) - luma(left)); float edgeY = abs(luma(up) - luma(down)); @@ -320,13 +402,13 @@ float blockDetail(vec2 point, float blockSize) { max(length(up - center), length(down - center)) ); - return edgeDetail * u_edgeBias + chromaShift * 0.2; + return edgeDetail * edgeBias + chromaShift * 0.2; } -float chooseBlockSize(vec2 point) { - float minSize = max(u_minBlockSize, 0.25); - float maxSize = max(minSize, u_maxBlockSize); - int sections = clamp(u_levels, 1, 10); +float chooseBlockSize(vec2 point, float minBlockSize, float maxBlockSize, int levels, float detailThreshold, float edgeBias, float streakStrength, sampler2D blurTex) { + float minSize = max(minBlockSize, 0.25); + float maxSize = max(minSize, maxBlockSize); + int sections = clamp(levels, 1, 10); int sizeCount = sections + 1; float chosen = maxSize; float steps = float(max(sizeCount - 1, 1)); @@ -338,7 +420,7 @@ float chooseBlockSize(vec2 point) { float t = float(i) / steps; float candidate = exp(mix(log(maxSize), log(minSize), t)); chosen = candidate; - if (blockDetail(point, candidate) <= u_detailThreshold) { + if (blockDetail(point, candidate, edgeBias, streakStrength, blurTex) <= detailThreshold) { break; } } @@ -346,34 +428,50 @@ float chooseBlockSize(vec2 point) { return chosen; } -vec4 stylizeBlur(vec2 frag) { - float pixelSize = chooseBlockSize(frag); +vec4 stylizeBlur(vec2 frag, sampler2D blurTex, float minBlockSize, float maxBlockSize, int levels, float detailThreshold, float ditherStrength, float edgeBias, float hueDrift, float streakStrength) { + float pixelSize = chooseBlockSize(frag, minBlockSize, maxBlockSize, levels, detailThreshold, edgeBias, streakStrength, blurTex); vec2 blockCoord = floor(frag / pixelSize); - vec3 blurred = sampleBlock(frag, pixelSize); + vec3 blurred = sampleBlock(frag, pixelSize, streakStrength, blurTex); vec3 hsv = rgbToHsv(blurred); - float drift = (hash12(blockCoord + vec2(pixelSize, 17.0)) - 0.5) * u_hueDrift; + float drift = (hash12(blockCoord + vec2(pixelSize, 17.0)) - 0.5) * hueDrift; float hueIndex = floor(fract(hsv.x + drift) * 254.0) + 1.0; vec3 hueColor = texture(u_palette, vec2(hueIndex / 255.0, 0.5)).rgb; float coverage = clamp(hsv.z * mix(0.28, 1.0, hsv.y), 0.0, 1.0); - float threshold = mix(0.5, bayer4(frag), clamp(u_ditherStrength, 0.0, 1.0)); + float threshold = mix(0.5, bayer4(frag), clamp(ditherStrength, 0.0, 1.0)); return coverage > threshold ? vec4(hueColor, 1.0) : vec4(0.0, 0.0, 0.0, 1.0); } +vec3 screenBlend(vec3 base, vec3 blend) { + return 1.0 - (1.0 - base) * (1.0 - blend); +} + +vec4 glowOutside(vec2 frag) { + vec2 uv = frag / u_resolution; + vec3 sharp = texture(u_sharp, uv).rgb; + vec3 blur = texture(u_outBlur, uv).rgb; + vec3 bloom = max(blur - vec3(u_outGlowThreshold), vec3(0.0)); + bloom = pow(bloom, vec3(1.0 / max(u_outGlowSoftness, 0.01))); + vec3 glow = bloom * u_outGlowBoost * u_outGlowStrength; + vec3 color = screenBlend(sharp, glow); + + return vec4(color, 1.0); +} + void main() { vec2 frag = gl_FragCoord.xy; - vec2 uv = frag / u_resolution; - + bool inside = false; for (int i = 0; i < ${PANEL_LIMIT}; i++) { if (i >= u_panelCount) { break; } if (inRect(frag, u_panelRects[i])) { - outColor = stylizeBlur(frag); - return; + inside = true; + break; } } - - outColor = texture(u_sharp, uv); + outColor = inside + ? stylizeBlur(frag, u_inBlur, u_inMinBlockSize, u_inMaxBlockSize, u_inLevels, u_inDetailThreshold, u_inDitherStrength, u_inEdgeBias, u_inHueDrift, u_inStreakStrength) + : glowOutside(frag); }`; function compileShader( @@ -555,7 +653,9 @@ function createPaletteTexture(gl: WebGL2RenderingContext) { function createInitialState(width: number, height: number) { const data = new Uint8Array(width * height * 4); - let rng = 0x4a455450; + const seedBuffer = new Uint32Array(1); + globalThis.crypto.getRandomValues(seedBuffer); + let rng = seedBuffer[0] ?? 0x4a455450; const next = () => { rng ^= rng << 13; @@ -589,148 +689,6 @@ function uniform( return bundle.uniforms[name] ?? null; } -function setupEffectTuner(tuning: TuningState) { - const root = document.getElementById("effect-tuner"); - const blur = document.getElementById( - "effect-blur", - ) as HTMLInputElement | null; - const radius = document.getElementById( - "effect-radius", - ) as HTMLInputElement | null; - const darkness = document.getElementById( - "effect-darkness", - ) as HTMLInputElement | null; - const smallest = document.getElementById( - "effect-smallest", - ) as HTMLInputElement | null; - const largest = document.getElementById( - "effect-largest", - ) as HTMLInputElement | null; - const levels = document.getElementById( - "effect-levels", - ) as HTMLInputElement | null; - const detail = document.getElementById( - "effect-detail", - ) as HTMLInputElement | null; - const dither = document.getElementById( - "effect-dither", - ) as HTMLInputElement | null; - const edge = document.getElementById( - "effect-edge", - ) as HTMLInputElement | null; - const hueDrift = document.getElementById( - "effect-hue-drift", - ) as HTMLInputElement | null; - const streak = document.getElementById( - "effect-streak", - ) as HTMLInputElement | null; - const blurValue = document.getElementById("effect-blur-value"); - const radiusValue = document.getElementById("effect-radius-value"); - const darknessValue = document.getElementById("effect-darkness-value"); - const smallestValue = document.getElementById("effect-smallest-value"); - const largestValue = document.getElementById("effect-largest-value"); - const levelsValue = document.getElementById("effect-levels-value"); - const detailValue = document.getElementById("effect-detail-value"); - const ditherValue = document.getElementById("effect-dither-value"); - const edgeValue = document.getElementById("effect-edge-value"); - const hueDriftValue = document.getElementById("effect-hue-drift-value"); - const streakValue = document.getElementById("effect-streak-value"); - - if ( - !root || - !blur || - !radius || - !darkness || - !smallest || - !largest || - !levels || - !detail || - !dither || - !edge || - !hueDrift || - !streak || - !blurValue || - !radiusValue || - !darknessValue || - !smallestValue || - !largestValue || - !levelsValue || - !detailValue || - !ditherValue || - !edgeValue || - !hueDriftValue || - !streakValue - ) { - return null; - } - - const update = () => { - tuning.blurStrength = Number.parseInt(blur.value, 10); - tuning.blurRadius = Number.parseFloat(radius.value); - tuning.darkness = Number.parseFloat(darkness.value); - tuning.smallestBlock = Number.parseFloat(smallest.value); - tuning.largestBlock = Number.parseFloat(largest.value); - if (tuning.largestBlock < tuning.smallestBlock) { - tuning.largestBlock = tuning.smallestBlock; - largest.value = `${tuning.largestBlock}`; - } - tuning.levels = Number.parseInt(levels.value, 10); - tuning.detailThreshold = Number.parseFloat(detail.value); - tuning.ditherStrength = Number.parseFloat(dither.value); - tuning.edgeBias = Number.parseFloat(edge.value); - tuning.hueDrift = Number.parseFloat(hueDrift.value); - tuning.streakStrength = Number.parseFloat(streak.value); - blurValue.textContent = `${tuning.blurStrength}`; - radiusValue.textContent = tuning.blurRadius.toFixed(1); - darknessValue.textContent = tuning.darkness.toFixed(2); - smallestValue.textContent = tuning.smallestBlock.toFixed(2); - largestValue.textContent = `${Math.round(tuning.largestBlock)}`; - levelsValue.textContent = `${tuning.levels}`; - detailValue.textContent = tuning.detailThreshold.toFixed(3); - ditherValue.textContent = tuning.ditherStrength.toFixed(2); - edgeValue.textContent = tuning.edgeBias.toFixed(2); - hueDriftValue.textContent = tuning.hueDrift.toFixed(3); - streakValue.textContent = tuning.streakStrength.toFixed(2); - document.documentElement.style.setProperty( - "--panel-bg-alpha", - tuning.darkness.toFixed(2), - ); - }; - - const handleInput = () => { - update(); - }; - - blur.addEventListener("input", handleInput); - radius.addEventListener("input", handleInput); - darkness.addEventListener("input", handleInput); - smallest.addEventListener("input", handleInput); - largest.addEventListener("input", handleInput); - levels.addEventListener("input", handleInput); - detail.addEventListener("input", handleInput); - dither.addEventListener("input", handleInput); - edge.addEventListener("input", handleInput); - hueDrift.addEventListener("input", handleInput); - streak.addEventListener("input", handleInput); - update(); - - return { - destroy() { - blur.removeEventListener("input", handleInput); - radius.removeEventListener("input", handleInput); - darkness.removeEventListener("input", handleInput); - smallest.removeEventListener("input", handleInput); - largest.removeEventListener("input", handleInput); - levels.removeEventListener("input", handleInput); - detail.removeEventListener("input", handleInput); - dither.removeEventListener("input", handleInput); - edge.removeEventListener("input", handleInput); - hueDrift.removeEventListener("input", handleInput); - streak.removeEventListener("input", handleInput); - }, - }; -} - export function initWebGLBackground() { const canvas = document.getElementById("canvas") as HTMLCanvasElement | null; if (!canvas) { @@ -770,6 +728,12 @@ export function initWebGLBackground() { stampFragmentSource, ["u_state", "u_gridSize", "u_center", "u_radius", "u_hue", "u_active"], ); + const injectProgram = createProgram( + gl, + fullscreenVertexSource, + injectFragmentSource, + ["u_state", "u_emitter", "u_gridSize"], + ); const colorProgram = createProgram( gl, fullscreenVertexSource, @@ -794,45 +758,82 @@ export function initWebGLBackground() { compositeFragmentSource, [ "u_sharp", - "u_blur", + "u_inBlur", + "u_outBlur", + "u_outBlur2", "u_palette", "u_resolution", "u_panelRects", "u_panelCount", - "u_minBlockSize", - "u_maxBlockSize", - "u_levels", - "u_detailThreshold", - "u_ditherStrength", - "u_edgeBias", - "u_hueDrift", - "u_streakStrength", + "u_inMinBlockSize", + "u_inMaxBlockSize", + "u_inLevels", + "u_inDetailThreshold", + "u_inDitherStrength", + "u_inEdgeBias", + "u_inHueDrift", + "u_inStreakStrength", + "u_outMinBlockSize", + "u_outMaxBlockSize", + "u_outLevels", + "u_outDetailThreshold", + "u_outDitherStrength", + "u_outEdgeBias", + "u_outHueDrift", + "u_outStreakStrength", + "u_outGlowStrength", + "u_outGlowThreshold", + "u_outGlowSoftness", + "u_outGlowBoost", ], ); const paletteTexture = createPaletteTexture(gl); const pointer: PointerState = { active: false, col: 0, row: 0 }; const tuning: TuningState = { - blurStrength: 1, - blurRadius: 20, - darkness: 0.2, - smallestBlock: 1, - largestBlock: 20, - levels: 3, - detailThreshold: 0.04, - ditherStrength: 0.75, - edgeBias: 1.35, - hueDrift: 0.08, - streakStrength: 0.35, + inside: { + blurStrength: 1, + blurRadius: 20, + smallestBlock: 0.25, + largestBlock: 63, + levels: 5, + detailThreshold: 0.103, + ditherStrength: 0.79, + edgeBias: 3, + hueDrift: 0.04, + streakStrength: 1, + }, + outside: { + blurStrength: 1, + blurRadius: 20, + smallestBlock: 1, + largestBlock: 20, + levels: 3, + detailThreshold: 0.04, + ditherStrength: 0.75, + edgeBias: 1.35, + hueDrift: 0.08, + streakStrength: 0.35, + }, + outsideGlow: { + threshold: 0, + softness: 1, + boost: 1, + }, + darkness: 0.68, + imageEmit: 1, + ansiEmit: 1, + linkEmit: 1, }; const panelRects = new Float32Array(PANEL_LIMIT * 4); - const tuner = setupEffectTuner(tuning); let stateTargets: [TextureTarget, TextureTarget] | null = null; let colorTarget: TextureTarget | null = null; let sharpTarget: TextureTarget | null = null; let smoothTarget: TextureTarget | null = null; - let blurTargets: [TextureTarget, TextureTarget] | null = null; + let insideBlurTargets: [TextureTarget, TextureTarget] | null = null; + let outsideBlurTargets: [TextureTarget, TextureTarget] | null = null; + let emitterTarget: TextureTarget | null = null; let stateIndex = 0; let rafId = 0; let cssWidth = 0; @@ -841,6 +842,8 @@ export function initWebGLBackground() { let canvasHeight = 0; let gridWidth = 0; let gridHeight = 0; + const emitterCanvas = document.createElement("canvas"); + const emitterCtx = emitterCanvas.getContext("2d"); const renderPass = ( target: TextureTarget | null, @@ -895,8 +898,11 @@ export function initWebGLBackground() { deleteTextureTarget(gl, colorTarget); deleteTextureTarget(gl, sharpTarget); deleteTextureTarget(gl, smoothTarget); - deleteTextureTarget(gl, blurTargets?.[0] ?? null); - deleteTextureTarget(gl, blurTargets?.[1] ?? null); + deleteTextureTarget(gl, insideBlurTargets?.[0] ?? null); + deleteTextureTarget(gl, insideBlurTargets?.[1] ?? null); + deleteTextureTarget(gl, outsideBlurTargets?.[0] ?? null); + deleteTextureTarget(gl, outsideBlurTargets?.[1] ?? null); + deleteTextureTarget(gl, emitterTarget); stateTargets = [ createTextureTarget(gl, gridWidth, gridHeight, gl.NEAREST), @@ -910,11 +916,18 @@ export function initWebGLBackground() { canvasHeight, gl.LINEAR, ); - blurTargets = [ + insideBlurTargets = [ createTextureTarget(gl, canvasWidth, canvasHeight, gl.LINEAR), createTextureTarget(gl, canvasWidth, canvasHeight, gl.LINEAR), ]; + outsideBlurTargets = [ + createTextureTarget(gl, canvasWidth, canvasHeight, gl.LINEAR), + createTextureTarget(gl, canvasWidth, canvasHeight, gl.LINEAR), + ]; + emitterTarget = createTextureTarget(gl, gridWidth, gridHeight, gl.LINEAR); stateIndex = 0; + emitterCanvas.width = gridWidth; + emitterCanvas.height = gridHeight; const initialState = createInitialState(gridWidth, gridHeight); setTextureUnit(gl, 0, stateTargets[0].texture); @@ -976,6 +989,217 @@ export function initWebGLBackground() { pointer.active = true; }; + const TOUCH_DRAG_THRESHOLD = 8; + let touchDragActive = false; + let touchStartX = 0; + let touchStartY = 0; + + const renderEmitters = () => { + if (!emitterCtx || !emitterTarget) { + return; + } + + emitterCtx.clearRect(0, 0, gridWidth, gridHeight); + emitterCtx.imageSmoothingEnabled = true; + + const scaleX = gridWidth / cssWidth; + const scaleY = gridHeight / cssHeight; + const intersectRect = (a: DOMRect, b: DOMRect) => { + const left = Math.max(a.left, b.left); + const top = Math.max(a.top, b.top); + const right = Math.min(a.right, b.right); + const bottom = Math.min(a.bottom, b.bottom); + if (right <= left || bottom <= top) return null; + return new DOMRect(left, top, right - left, bottom - top); + }; + const getVisibleRect = (element: HTMLElement) => { + let visible: DOMRect | null = intersectRect( + element.getBoundingClientRect(), + new DOMRect(0, 0, cssWidth, cssHeight), + ); + if (!visible) return null; + + let current = element.parentElement; + while (current && current !== document.body) { + const style = getComputedStyle(current); + const clipsX = /(auto|scroll|hidden|clip)/.test(style.overflowX); + const clipsY = /(auto|scroll|hidden|clip)/.test(style.overflowY); + if (clipsX || clipsY) { + visible = intersectRect(visible, current.getBoundingClientRect()); + if (!visible) return null; + } + current = current.parentElement; + } + + return visible; + }; + const drawRect = (rect: DOMRect, color: string, alpha: number) => { + if (alpha <= 0 || rect.width <= 0 || rect.height <= 0) return; + emitterCtx.globalAlpha = alpha; + emitterCtx.fillStyle = color; + emitterCtx.fillRect( + rect.left * scaleX, + rect.top * scaleY, + rect.width * scaleX, + rect.height * scaleY, + ); + }; + + const drawText = (element: HTMLElement, alpha: number) => { + const text = element.textContent?.trim(); + if (!text || alpha <= 0) return; + const rect = getVisibleRect(element); + if (!rect) return; + if (rect.width <= 0 || rect.height <= 0) return; + const style = getComputedStyle(element); + if (style.visibility === "hidden" || style.display === "none") return; + emitterCtx.save(); + emitterCtx.beginPath(); + emitterCtx.rect( + rect.left * scaleX, + rect.top * scaleY, + rect.width * scaleX, + rect.height * scaleY, + ); + emitterCtx.clip(); + emitterCtx.globalAlpha = alpha; + emitterCtx.fillStyle = style.color; + emitterCtx.textBaseline = "top"; + emitterCtx.font = `${style.fontStyle} ${style.fontWeight} ${Math.max(8, parseFloat(style.fontSize) * scaleY)}px ${style.fontFamily}`; + const x = rect.left * scaleX; + const y = rect.top * scaleY; + const lines = text + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean); + const lineHeight = Math.max( + 8, + parseFloat(style.lineHeight || style.fontSize) * scaleY || + parseFloat(style.fontSize) * scaleY, + ); + lines.forEach((line, index) => { + emitterCtx.fillText( + line, + x, + y + index * lineHeight, + Math.max(1, rect.width * scaleX), + ); + }); + emitterCtx.restore(); + }; + + if (tuning.imageEmit > 0) { + document + .querySelectorAll("img[data-emitter-image]") + .forEach((image) => { + const rect = getVisibleRect(image); + if (!rect) return; + if (rect.width <= 0 || rect.height <= 0 || !image.complete) return; + emitterCtx.save(); + emitterCtx.beginPath(); + emitterCtx.rect( + rect.left * scaleX, + rect.top * scaleY, + rect.width * scaleX, + rect.height * scaleY, + ); + emitterCtx.clip(); + emitterCtx.globalAlpha = tuning.imageEmit; + emitterCtx.drawImage( + image, + rect.left * scaleX, + rect.top * scaleY, + rect.width * scaleX, + rect.height * scaleY, + ); + emitterCtx.restore(); + }); + } + + if (tuning.ansiEmit > 0) { + document + .querySelectorAll("[data-emitter-ansi] span") + .forEach((span) => { + const rect = getVisibleRect(span); + if (!rect) return; + const color = getComputedStyle(span).color; + drawRect(rect, color, tuning.ansiEmit); + }); + } + + if (tuning.linkEmit > 0) { + document.querySelectorAll("a").forEach((link) => { + drawText(link, tuning.linkEmit); + }); + } + + const textEmitStrength = Math.max(tuning.linkEmit, tuning.ansiEmit * 0.8); + if (textEmitStrength > 0) { + document + .querySelectorAll( + "button, p, span, legend, label, [role='button'], .qa-meta, .qa-button, .qa-inline-action", + ) + .forEach((element) => { + if (element.closest("[data-emitter-ansi]")) { + return; + } + drawText(element, textEmitStrength); + }); + + document + .querySelectorAll("button, .qa-button, .qa-inline-action") + .forEach((element) => { + const rect = getVisibleRect(element); + if (!rect) return; + const color = getComputedStyle(element).color; + drawRect(rect, color, textEmitStrength * 0.2); + }); + + document + .querySelectorAll( + "#char-count, #qa-status, #copy-email-status", + ) + .forEach((element) => { + if (element.id === "char-count") { + const color = getComputedStyle(element).color.replace(/\s+/g, ""); + if (color !== "rgb(255,85,85)") { + return; + } + } + drawText(element, Math.max(textEmitStrength, 0.75)); + }); + } + + emitterCtx.globalAlpha = 1; + setTextureUnit(gl, 3, emitterTarget.texture); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + emitterCanvas, + ); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0); + }; + + const injectEmitters = () => { + if (!emitterTarget) return; + + const source = currentState(); + const target = nextState(); + setTextureUnit(gl, 0, source.texture); + setTextureUnit(gl, 3, emitterTarget.texture); + gl.useProgram(injectProgram.program); + gl.uniform1i(uniform(injectProgram, "u_state"), 0); + gl.uniform1i(uniform(injectProgram, "u_emitter"), 3); + gl.uniform2i(uniform(injectProgram, "u_gridSize"), gridWidth, gridHeight); + renderPass(target, gridWidth, gridHeight, injectProgram.program); + swapState(); + }; + const simulate = () => { const source = currentState(); const target = nextState(); @@ -1041,75 +1265,61 @@ export function initWebGLBackground() { }; const blur = () => { - if (!smoothTarget || !sharpTarget || !blurTargets) return; + if (!smoothTarget || !insideBlurTargets || !outsideBlurTargets) return; - const iterations = Math.max(0, Math.floor(tuning.blurStrength)); + const runBlurStack = ( + stack: StackTuning, + targets: [TextureTarget, TextureTarget], + ) => { + const iterations = Math.max(0, Math.floor(stack.blurStrength)); + if (iterations === 0) { + setTextureUnit(gl, 0, smoothTarget!.texture); + gl.useProgram(copyProgram.program); + gl.uniform1i(uniform(copyProgram, "u_image"), 0); + renderPass(targets[1], canvasWidth, canvasHeight, copyProgram.program); + return; + } - if (iterations === 0) { - setTextureUnit(gl, 0, sharpTarget.texture); gl.useProgram(blurProgram.program); gl.uniform1i(uniform(blurProgram, "u_image"), 0); - gl.uniform2f(uniform(blurProgram, "u_direction"), 0, 0); - gl.uniform1f(uniform(blurProgram, "u_radius"), tuning.blurRadius); + gl.uniform1f(uniform(blurProgram, "u_radius"), stack.blurRadius); gl.uniform2f( uniform(blurProgram, "u_resolution"), canvasWidth, canvasHeight, ); - renderPass( - blurTargets[1], - canvasWidth, - canvasHeight, - blurProgram.program, - ); - return; - } - gl.useProgram(blurProgram.program); - gl.uniform1i(uniform(blurProgram, "u_image"), 0); - gl.uniform1f(uniform(blurProgram, "u_radius"), tuning.blurRadius); - gl.uniform2f( - uniform(blurProgram, "u_resolution"), - canvasWidth, - canvasHeight, - ); + let sourceTexture = smoothTarget!.texture; + for (let index = 0; index < iterations; index++) { + setTextureUnit(gl, 0, sourceTexture); + gl.uniform2f(uniform(blurProgram, "u_direction"), 1, 0); + renderPass(targets[0], canvasWidth, canvasHeight, blurProgram.program); - let sourceTexture = smoothTarget.texture; + setTextureUnit(gl, 0, targets[0].texture); + gl.uniform2f(uniform(blurProgram, "u_direction"), 0, 1); + renderPass(targets[1], canvasWidth, canvasHeight, blurProgram.program); + sourceTexture = targets[1].texture; + } + }; - for (let index = 0; index < iterations; index++) { - setTextureUnit(gl, 0, sourceTexture); - gl.uniform2f(uniform(blurProgram, "u_direction"), 1, 0); - renderPass( - blurTargets[0], - canvasWidth, - canvasHeight, - blurProgram.program, - ); - - setTextureUnit(gl, 0, blurTargets[0].texture); - gl.uniform2f(uniform(blurProgram, "u_direction"), 0, 1); - renderPass( - blurTargets[1], - canvasWidth, - canvasHeight, - blurProgram.program, - ); - - sourceTexture = blurTargets[1].texture; - } + runBlurStack(tuning.inside, insideBlurTargets); + runBlurStack(tuning.outside, outsideBlurTargets); }; const composite = () => { - if (!sharpTarget || !blurTargets) return; + if (!insideBlurTargets || !outsideBlurTargets) return; const panelCount = updatePanelRects(); - setTextureUnit(gl, 0, sharpTarget.texture); - setTextureUnit(gl, 1, blurTargets[1].texture); - setTextureUnit(gl, 2, paletteTexture); + setTextureUnit(gl, 0, sharpTarget!.texture); + setTextureUnit(gl, 1, insideBlurTargets[1].texture); + setTextureUnit(gl, 2, outsideBlurTargets[1].texture); + setTextureUnit(gl, 3, paletteTexture); gl.useProgram(compositeProgram.program); gl.uniform1i(uniform(compositeProgram, "u_sharp"), 0); - gl.uniform1i(uniform(compositeProgram, "u_blur"), 1); - gl.uniform1i(uniform(compositeProgram, "u_palette"), 2); + gl.uniform1i(uniform(compositeProgram, "u_inBlur"), 1); + gl.uniform1i(uniform(compositeProgram, "u_outBlur"), 2); + gl.uniform1i(uniform(compositeProgram, "u_outBlur2"), 2); + gl.uniform1i(uniform(compositeProgram, "u_palette"), 3); gl.uniform2f( uniform(compositeProgram, "u_resolution"), canvasWidth, @@ -1117,27 +1327,83 @@ export function initWebGLBackground() { ); const deviceScale = canvasWidth / cssWidth; gl.uniform1f( - uniform(compositeProgram, "u_minBlockSize"), - Math.max(0.25, tuning.smallestBlock) * deviceScale, + uniform(compositeProgram, "u_inMinBlockSize"), + Math.max(0.25, tuning.inside.smallestBlock) * deviceScale, ); gl.uniform1f( - uniform(compositeProgram, "u_maxBlockSize"), - Math.max(tuning.largestBlock, tuning.smallestBlock) * deviceScale, + uniform(compositeProgram, "u_inMaxBlockSize"), + Math.max(tuning.inside.largestBlock, tuning.inside.smallestBlock) * + deviceScale, ); - gl.uniform1i(uniform(compositeProgram, "u_levels"), tuning.levels); + gl.uniform1i(uniform(compositeProgram, "u_inLevels"), tuning.inside.levels); gl.uniform1f( - uniform(compositeProgram, "u_detailThreshold"), - tuning.detailThreshold, + uniform(compositeProgram, "u_inDetailThreshold"), + tuning.inside.detailThreshold, ); gl.uniform1f( - uniform(compositeProgram, "u_ditherStrength"), - tuning.ditherStrength, + uniform(compositeProgram, "u_inDitherStrength"), + tuning.inside.ditherStrength, ); - gl.uniform1f(uniform(compositeProgram, "u_edgeBias"), tuning.edgeBias); - gl.uniform1f(uniform(compositeProgram, "u_hueDrift"), tuning.hueDrift); gl.uniform1f( - uniform(compositeProgram, "u_streakStrength"), - tuning.streakStrength, + uniform(compositeProgram, "u_inEdgeBias"), + tuning.inside.edgeBias, + ); + gl.uniform1f( + uniform(compositeProgram, "u_inHueDrift"), + tuning.inside.hueDrift, + ); + gl.uniform1f( + uniform(compositeProgram, "u_inStreakStrength"), + tuning.inside.streakStrength, + ); + gl.uniform1f( + uniform(compositeProgram, "u_outMinBlockSize"), + Math.max(0.25, tuning.outside.smallestBlock) * deviceScale, + ); + gl.uniform1f( + uniform(compositeProgram, "u_outMaxBlockSize"), + Math.max(tuning.outside.largestBlock, tuning.outside.smallestBlock) * + deviceScale, + ); + gl.uniform1i( + uniform(compositeProgram, "u_outLevels"), + tuning.outside.levels, + ); + gl.uniform1f( + uniform(compositeProgram, "u_outDetailThreshold"), + tuning.outside.detailThreshold, + ); + gl.uniform1f( + uniform(compositeProgram, "u_outDitherStrength"), + tuning.outside.ditherStrength, + ); + gl.uniform1f( + uniform(compositeProgram, "u_outEdgeBias"), + tuning.outside.edgeBias, + ); + gl.uniform1f( + uniform(compositeProgram, "u_outHueDrift"), + tuning.outside.hueDrift, + ); + gl.uniform1f( + uniform(compositeProgram, "u_outStreakStrength"), + tuning.outside.streakStrength, + ); + gl.uniform1f( + uniform(compositeProgram, "u_outGlowStrength"), + tuning.outside.blurStrength, + ); + gl.uniform1f( + uniform(compositeProgram, "u_outGlowThreshold"), + tuning.outsideGlow.threshold, + ); + gl.uniform1f( + uniform(compositeProgram, "u_outGlowSoftness"), + tuning.outsideGlow.softness, + ); + gl.uniform1f( + uniform(compositeProgram, "u_outGlowBoost"), + tuning.outsideGlow.boost, ); gl.uniform4fv(uniform(compositeProgram, "u_panelRects"), panelRects); gl.uniform1i(uniform(compositeProgram, "u_panelCount"), panelCount); @@ -1172,6 +1438,9 @@ export function initWebGLBackground() { stamp(now); } + renderEmitters(); + injectEmitters(); + colorize(); blur(); composite(); @@ -1185,31 +1454,50 @@ export function initWebGLBackground() { pointer.active = false; }); - document.addEventListener( - "touchstart", - (event) => { - event.preventDefault(); - const touch = event.touches.item(0); - if (touch) { - updatePointer(touch.clientX, touch.clientY); - } - }, - { passive: false }, - ); + document.addEventListener("touchstart", (event) => { + const touch = event.touches.item(0); + if (touch) { + touchDragActive = false; + touchStartX = touch.clientX; + touchStartY = touch.clientY; + updatePointer(touch.clientX, touch.clientY); + } + }); document.addEventListener( "touchmove", (event) => { - event.preventDefault(); const touch = event.touches.item(0); - if (touch) { - updatePointer(touch.clientX, touch.clientY); + if (!touch) { + return; } + + if (!touchDragActive) { + const distance = Math.hypot( + touch.clientX - touchStartX, + touch.clientY - touchStartY, + ); + + if (distance < TOUCH_DRAG_THRESHOLD) { + return; + } + + touchDragActive = true; + } + + event.preventDefault(); + updatePointer(touch.clientX, touch.clientY); }, { passive: false }, ); document.addEventListener("touchend", () => { + touchDragActive = false; + pointer.active = false; + }); + + document.addEventListener("touchcancel", () => { + touchDragActive = false; pointer.active = false; }); @@ -1229,18 +1517,23 @@ export function initWebGLBackground() { window.cancelAnimationFrame(rafId); deleteTextureTarget(gl, stateTargets?.[0] ?? null); deleteTextureTarget(gl, stateTargets?.[1] ?? null); + deleteTextureTarget(gl, colorTarget); deleteTextureTarget(gl, sharpTarget); - deleteTextureTarget(gl, blurTargets?.[0] ?? null); - deleteTextureTarget(gl, blurTargets?.[1] ?? null); + deleteTextureTarget(gl, smoothTarget); + deleteTextureTarget(gl, insideBlurTargets?.[0] ?? null); + deleteTextureTarget(gl, insideBlurTargets?.[1] ?? null); + deleteTextureTarget(gl, outsideBlurTargets?.[0] ?? null); + deleteTextureTarget(gl, outsideBlurTargets?.[1] ?? null); + deleteTextureTarget(gl, emitterTarget); gl.deleteTexture(paletteTexture); gl.deleteVertexArray(vao); gl.deleteProgram(simulationProgram.program); gl.deleteProgram(stampProgram.program); + gl.deleteProgram(injectProgram.program); gl.deleteProgram(colorProgram.program); gl.deleteProgram(copyProgram.program); gl.deleteProgram(blurProgram.program); gl.deleteProgram(compositeProgram.program); - tuner?.destroy(); }, }; } diff --git a/src/pages/home.ts b/src/pages/home.ts index c9c72e4..f80f46e 100644 --- a/src/pages/home.ts +++ b/src/pages/home.ts @@ -6,19 +6,20 @@ export function homePage(outlet: HTMLElement) { outlet.innerHTML = `
${frostedBox(` -
+

Jet Pham

- +

Software Extremist

A picture of Jet wearing a beanie in purple and blue lighting
diff --git a/src/styles/globals.css b/src/styles/globals.css index e506bd6..dd79965 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -140,12 +140,18 @@ body[data-background-mode="failed"]::before { } .site-shell { - width: min(100%, 60%); + width: 100%; box-sizing: border-box; margin: 0 auto; user-select: text; } +@media (min-width: 768px) { + .site-shell { + width: min(100%, 60%); + } +} + .site-panel-frame { position: relative; overflow: hidden; @@ -153,7 +159,7 @@ body[data-background-mode="failed"]::before { .site-panel-frost { position: absolute; - inset: 0; + inset: var(--panel-border-inset); pointer-events: none; overflow: hidden; background: @@ -165,40 +171,6 @@ body[data-background-mode="failed"]::before { var(--panel-bg); } -.effect-tuner { - position: fixed; - right: 1rem; - bottom: 1rem; - z-index: 40; - width: min(26rem, calc(100vw - 2rem)); - padding: 1ch 1.5ch; - border: 2px solid var(--white); - background: rgba(0, 0, 0, 0.88); - color: var(--white); -} - -.effect-tuner h2 { - margin: 0 0 1ch; - font-size: 1em; - font-weight: normal; -} - -.effect-tuner label { - display: grid; - grid-template-columns: 11ch 1fr 5ch; - align-items: center; - gap: 1ch; - margin-top: 0.75ch; -} - -.effect-tuner input[type="range"] { - width: 100%; -} - -.effect-tuner output { - text-align: right; -} - .site-panel-border { position: absolute; inset: var(--panel-border-inset); @@ -249,6 +221,36 @@ a[aria-current="page"] { text-decoration: underline; } +.site-nav-link { + display: inline-flex; + align-items: center; + color: var(--light-blue); + text-decoration: none; +} + +.site-nav-link:hover, +.site-nav-link:focus-visible { + background-color: transparent; + color: var(--light-blue); + text-decoration: none; +} + +.site-nav-link[aria-current="page"] { + color: var(--yellow); + text-decoration: none; +} + +.site-nav-marker { + display: inline-block; + width: 1ch; + color: currentColor; + visibility: hidden; +} + +.site-nav-link[aria-current="page"] .site-nav-marker { + visibility: visible; +} + .skip-link { position: absolute; left: 1rem; diff --git a/tor.har b/tor.har deleted file mode 100644 index e3e3506..0000000 --- a/tor.har +++ /dev/null @@ -1,252 +0,0 @@ -{ - "log": { - "version": "1.2", - "creator": { - "name": "Firefox", - "version": "140.8.0" - }, - "browser": { - "name": "Firefox", - "version": "140.8.0" - }, - "pages": [ - { - "id": "page_1", - "pageTimings": { - "onContentLoad": 1578, - "onLoad": 1578 - }, - "startedDateTime": "2026-03-26T20:03:41.824-07:00", - "title": "http://jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion/" - } - ], - "entries": [ - { - "startedDateTime": "2026-03-26T20:03:41.824-07:00", - "request": { - "bodySize": 0, - "method": "GET", - "url": "http://jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion/", - "httpVersion": "HTTP/1.1", - "headers": [ - { - "name": "Host", - "value": "jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion" - }, - { - "name": "User-Agent", - "value": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" - }, - { - "name": "Accept", - "value": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" - }, - { - "name": "Accept-Language", - "value": "en-US,en;q=0.5" - }, - { - "name": "Accept-Encoding", - "value": "gzip, deflate, br, zstd" - }, - { - "name": "Sec-GPC", - "value": "1" - }, - { - "name": "Connection", - "value": "keep-alive" - }, - { - "name": "Upgrade-Insecure-Requests", - "value": "1" - }, - { - "name": "Sec-Fetch-Dest", - "value": "document" - }, - { - "name": "Sec-Fetch-Mode", - "value": "navigate" - }, - { - "name": "Sec-Fetch-Site", - "value": "none" - }, - { - "name": "Priority", - "value": "u=0, i" - }, - { - "name": "Pragma", - "value": "no-cache" - }, - { - "name": "Cache-Control", - "value": "no-cache" - } - ], - "cookies": [], - "queryString": [], - "headersSize": 521 - }, - "response": { - "status": 200, - "statusText": "OK", - "httpVersion": "HTTP/1.1", - "headers": [ - { - "name": "Server", - "value": "Caddy" - }, - { - "name": "Date", - "value": "Fri, 27 Mar 2026 03:03:43 GMT" - }, - { - "name": "Content-Length", - "value": "0" - } - ], - "cookies": [], - "content": { - "mimeType": "text/plain", - "size": 0, - "text": "" - }, - "redirectURL": "", - "headersSize": 90, - "bodySize": 90 - }, - "cache": {}, - "timings": { - "blocked": 894, - "dns": 0, - "connect": 894, - "ssl": 0, - "send": 0, - "wait": 617, - "receive": 0 - }, - "time": 2405, - "_securityState": "insecure", - "serverIPAddress": "0.0.0.0", - "connection": "80", - "pageref": "page_1" - }, - { - "startedDateTime": "2026-03-26T20:03:43.440-07:00", - "request": { - "bodySize": 0, - "method": "GET", - "url": "http://jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion/favicon.ico", - "httpVersion": "HTTP/1.1", - "headers": [ - { - "name": "Host", - "value": "jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion" - }, - { - "name": "User-Agent", - "value": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" - }, - { - "name": "Accept", - "value": "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5" - }, - { - "name": "Accept-Language", - "value": "en-US,en;q=0.5" - }, - { - "name": "Accept-Encoding", - "value": "gzip, deflate, br, zstd" - }, - { - "name": "Referer", - "value": "http://jet7tetd43snvjx3ng5jrhuwm2yhyp76tjtct5mtofg64apokcgq7fqd.onion/" - }, - { - "name": "Sec-GPC", - "value": "1" - }, - { - "name": "Connection", - "value": "keep-alive" - }, - { - "name": "Sec-Fetch-Dest", - "value": "image" - }, - { - "name": "Sec-Fetch-Mode", - "value": "no-cors" - }, - { - "name": "Sec-Fetch-Site", - "value": "same-origin" - }, - { - "name": "Priority", - "value": "u=6" - }, - { - "name": "Pragma", - "value": "no-cache" - }, - { - "name": "Cache-Control", - "value": "no-cache" - } - ], - "cookies": [], - "queryString": [], - "headersSize": 589 - }, - "response": { - "status": 200, - "statusText": "OK", - "httpVersion": "HTTP/1.1", - "headers": [ - { - "name": "Server", - "value": "Caddy" - }, - { - "name": "Date", - "value": "Fri, 27 Mar 2026 03:03:43 GMT" - }, - { - "name": "Content-Length", - "value": "0" - } - ], - "cookies": [], - "content": { - "mimeType": "text/plain", - "size": 0, - "text": "" - }, - "redirectURL": "", - "headersSize": 90, - "bodySize": 90 - }, - "cache": {}, - "timings": { - "blocked": 0, - "dns": 0, - "connect": 0, - "ssl": 0, - "send": 0, - "wait": 604, - "receive": 0 - }, - "time": 604, - "_securityState": "insecure", - "serverIPAddress": "0.0.0.0", - "connection": "80", - "pageref": "page_1" - } - ] - } -} \ No newline at end of file