diff --git a/.gitignore b/.gitignore index 1e32026..bd422ab 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,6 @@ # misc .DS_Store *.pem -*.har # debug npm-debug.log* diff --git a/README.md b/README.md index 52e5c9c..3e8e94b 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ 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 @@ -55,39 +53,9 @@ 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 a797234..0bb67a9 100644 --- a/api/src/serve.rs +++ b/api/src/serve.rs @@ -25,10 +25,6 @@ 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;")?; @@ -62,8 +58,8 @@ pub async fn run() -> Result<(), Box> { .layer(CorsLayer::permissive()) .with_state(state); - let listener = tokio::net::TcpListener::bind(&listen_target).await?; - println!("Listening on {listen_target}"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:3003").await?; + println!("Listening on 127.0.0.1:3003"); axum::serve(listener, app).await?; Ok(()) } diff --git a/check_cleanup.txt b/check_cleanup.txt new file mode 100644 index 0000000..8ee83ad --- /dev/null +++ b/check_cleanup.txt @@ -0,0 +1,2 @@ +$ bun run lint && tsc --noEmit +$ eslint . diff --git a/flake.lock b/flake.lock index f194334..8b83aa9 100644 --- a/flake.lock +++ b/flake.lock @@ -1,8 +1,53 @@ { "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" + "systems": "systems_2" }, "locked": { "lastModified": 1731533236, @@ -18,6 +63,27 @@ "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, @@ -34,10 +100,46 @@ "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" + "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" } }, "systems": { @@ -54,6 +156,21 @@ "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 9ea1199..12dec4d 100644 --- a/flake.nix +++ b/flake.nix @@ -2,38 +2,93 @@ 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 - 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 - ]; + 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 + ]; + + 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 = websiteSrc; - npmDepsHash = "sha256-UDz4tXNvEa8uiDDGg16K9JbNeQZR3BsVNKtuOgcyurQ="; + 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/ + ''; installPhase = '' runHook preInstall @@ -55,6 +110,7 @@ { packages = { default = website; + cgol-wasm = cgol-wasm; inherit qa-api; }; devShells.default = pkgs.mkShell { @@ -63,11 +119,12 @@ 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 740d2c0..250ebf0 100644 --- a/index.html +++ b/index.html @@ -106,24 +106,151 @@ 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 1b5dba7..92b9669 100644 --- a/module.nix +++ b/module.nix @@ -1,139 +1,27 @@ self: -{ - config, - lib, - pkgs, - ... -}: +{ config, lib, ... }: let cfg = config.services.jetpham-website; - package = cfg.package; - qaApi = cfg.apiPackage; - apiListen = "${cfg.apiListenAddress}:${toString cfg.apiListenPort}"; - usingDefaultWebhookSecret = cfg.webhookSecretFile == null; + package = self.packages.x86_64-linux.default; + qaApi = self.packages.x86_64-linux.qa-api; webhookSecretPath = - 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 + if cfg.webhookSecretFile != null then + cfg.webhookSecretFile else - 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} - ''; + config.age.secrets.webhook-secret.path; 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."; }; - 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."; - }; - }; + tor.enable = lib.mkEnableOption "Tor hidden service for the website"; qaNotifyEmail = lib.mkOption { type = lib.types.str; @@ -156,53 +44,35 @@ in webhookSecretFile = lib.mkOption { type = lib.types.nullOr lib.types.path; default = null; - description = "File containing the WEBHOOK_SECRET for MTA Hook authentication. Defaults to the module-managed agenix secret when left unset."; + description = "File containing the WEBHOOK_SECRET for MTA Hook authentication."; }; }; config = lib.mkIf cfg.enable { - age.secrets.webhook-secret = lib.mkIf usingDefaultWebhookSecret { + age.secrets.webhook-secret = lib.mkIf (cfg.webhookSecretFile == null) { file = "${self}/secrets/webhook-secret.age"; mode = "0400"; }; - age.secrets.tor-onion-secret-key = lib.mkIf (cfg.tor.enable && usingDefaultTorSecretKey) { + age.secrets.tor-onion-secret-key = lib.mkIf cfg.tor.enable { 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 && usingDefaultTorPublicKey) { + age.secrets.tor-onion-public-key = lib.mkIf cfg.tor.enable { file = "${self}/secrets/tor-onion-public-key.age"; owner = "tor"; group = "tor"; mode = "0444"; }; - - age.secrets.tor-onion-hostname = lib.mkIf (cfg.tor.enable && usingDefaultTorHostname) { + age.secrets.tor-onion-hostname = lib.mkIf cfg.tor.enable { 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 = { @@ -211,7 +81,7 @@ in port = 80; target = { addr = "127.0.0.1"; - port = cfg.tor.port; + port = 8888; }; } ]; @@ -220,47 +90,36 @@ in systemd.services.tor-onion-keys = lib.mkIf cfg.tor.enable { description = "Copy Tor onion keys into place"; - after = lib.optional ( - usingDefaultTorSecretKey || usingDefaultTorPublicKey || usingDefaultTorHostname - ) "agenix.service"; + after = [ "agenix.service" ]; before = [ "tor.service" ]; wantedBy = [ "tor.service" ]; serviceConfig.Type = "oneshot"; script = '' dir="/var/lib/tor/onion/jetpham-website" mkdir -p "$dir" - cp ${torOnionSecretKeyPath} "$dir/hs_ed25519_secret_key" - cp ${torOnionPublicKeyPath} "$dir/hs_ed25519_public_key" - cp ${torOnionHostnamePath} "$dir/hostname" + 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" 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-online.target" ] ++ lib.optional usingDefaultWebhookSecret "agenix.service"; - wants = [ "network-online.target" ] ++ lib.optional usingDefaultWebhookSecret "agenix.service"; + after = [ "network.target" ]; 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}"; @@ -276,17 +135,47 @@ in ''; }; - services.caddy.virtualHosts.${cfg.domain} = lib.mkIf cfg.caddy.enable { - extraConfig = caddyCommonConfig; + 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."http://:${toString cfg.tor.port}" = - lib.mkIf (cfg.caddy.enable && cfg.tor.enable) - { - extraConfig = '' - bind 127.0.0.1 - ${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 + } + ''; + }; }; } diff --git a/src/lib/site.ts b/src/lib/site.ts index 713bd0d..dbdb0ae 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 a95533f..42b1ffa 100644 --- a/src/lib/webgl-background.ts +++ b/src/lib/webgl-background.ts @@ -23,9 +23,10 @@ type PointerState = { row: number; }; -type StackTuning = { +type TuningState = { blurStrength: number; blurRadius: number; + darkness: number; smallestBlock: number; largestBlock: number; levels: number; @@ -36,20 +37,6 @@ type StackTuning = { 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; @@ -167,53 +154,6 @@ 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; @@ -285,33 +225,20 @@ precision highp float; precision highp sampler2D; uniform sampler2D u_sharp; -uniform sampler2D u_inBlur; -uniform sampler2D u_outBlur; -uniform sampler2D u_outBlur2; +uniform sampler2D u_blur; uniform sampler2D u_palette; uniform vec2 u_resolution; uniform vec4 u_panelRects[${PANEL_LIMIT}]; uniform int u_panelCount; -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; +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; + out vec4 outColor; bool inRect(vec2 point, vec4 rect) { @@ -321,15 +248,6 @@ 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) { @@ -375,23 +293,23 @@ float hash12(vec2 point) { return fract((p3.x + p3.y) * p3.z); } -vec3 sampleBlock(vec2 point, float blockSize, float streakStrength, sampler2D blurTex) { +vec3 sampleBlock(vec2 point, float blockSize) { vec2 blockCoord = floor(point / blockSize); vec2 samplePoint = (blockCoord + 0.5) * blockSize; - float streak = (hash12(vec2(blockCoord.y, blockSize)) - 0.5) * blockSize * streakStrength; + float streak = (hash12(vec2(blockCoord.y, blockSize)) - 0.5) * blockSize * u_streakStrength; samplePoint.x += streak; samplePoint.x = clamp(samplePoint.x, 0.5, u_resolution.x - 0.5); vec2 sampleUv = samplePoint / u_resolution; - return texture(blurTex, sampleUv).rgb; + return texture(u_blur, sampleUv).rgb; } -float blockDetail(vec2 point, float blockSize, float edgeBias, float streakStrength, sampler2D blurTex) { +float blockDetail(vec2 point, float blockSize) { vec2 offset = vec2(blockSize * 0.35); - 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); + 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); float edgeX = abs(luma(right) - luma(left)); float edgeY = abs(luma(up) - luma(down)); @@ -402,13 +320,13 @@ float blockDetail(vec2 point, float blockSize, float edgeBias, float streakStren max(length(up - center), length(down - center)) ); - return edgeDetail * edgeBias + chromaShift * 0.2; + return edgeDetail * u_edgeBias + chromaShift * 0.2; } -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); +float chooseBlockSize(vec2 point) { + float minSize = max(u_minBlockSize, 0.25); + float maxSize = max(minSize, u_maxBlockSize); + int sections = clamp(u_levels, 1, 10); int sizeCount = sections + 1; float chosen = maxSize; float steps = float(max(sizeCount - 1, 1)); @@ -420,7 +338,7 @@ float chooseBlockSize(vec2 point, float minBlockSize, float maxBlockSize, int le float t = float(i) / steps; float candidate = exp(mix(log(maxSize), log(minSize), t)); chosen = candidate; - if (blockDetail(point, candidate, edgeBias, streakStrength, blurTex) <= detailThreshold) { + if (blockDetail(point, candidate) <= u_detailThreshold) { break; } } @@ -428,50 +346,34 @@ float chooseBlockSize(vec2 point, float minBlockSize, float maxBlockSize, int le return chosen; } -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); +vec4 stylizeBlur(vec2 frag) { + float pixelSize = chooseBlockSize(frag); vec2 blockCoord = floor(frag / pixelSize); - vec3 blurred = sampleBlock(frag, pixelSize, streakStrength, blurTex); + vec3 blurred = sampleBlock(frag, pixelSize); vec3 hsv = rgbToHsv(blurred); - float drift = (hash12(blockCoord + vec2(pixelSize, 17.0)) - 0.5) * hueDrift; + float drift = (hash12(blockCoord + vec2(pixelSize, 17.0)) - 0.5) * u_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(ditherStrength, 0.0, 1.0)); + float threshold = mix(0.5, bayer4(frag), clamp(u_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; - bool inside = false; + vec2 uv = frag / u_resolution; + for (int i = 0; i < ${PANEL_LIMIT}; i++) { if (i >= u_panelCount) { break; } if (inRect(frag, u_panelRects[i])) { - inside = true; - break; + outColor = stylizeBlur(frag); + return; } } - outColor = inside - ? stylizeBlur(frag, u_inBlur, u_inMinBlockSize, u_inMaxBlockSize, u_inLevels, u_inDetailThreshold, u_inDitherStrength, u_inEdgeBias, u_inHueDrift, u_inStreakStrength) - : glowOutside(frag); + + outColor = texture(u_sharp, uv); }`; function compileShader( @@ -653,9 +555,7 @@ function createPaletteTexture(gl: WebGL2RenderingContext) { function createInitialState(width: number, height: number) { const data = new Uint8Array(width * height * 4); - const seedBuffer = new Uint32Array(1); - globalThis.crypto.getRandomValues(seedBuffer); - let rng = seedBuffer[0] ?? 0x4a455450; + let rng = 0x4a455450; const next = () => { rng ^= rng << 13; @@ -689,6 +589,148 @@ 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) { @@ -728,12 +770,6 @@ 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, @@ -758,82 +794,45 @@ export function initWebGLBackground() { compositeFragmentSource, [ "u_sharp", - "u_inBlur", - "u_outBlur", - "u_outBlur2", + "u_blur", "u_palette", "u_resolution", "u_panelRects", "u_panelCount", - "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", + "u_minBlockSize", + "u_maxBlockSize", + "u_levels", + "u_detailThreshold", + "u_ditherStrength", + "u_edgeBias", + "u_hueDrift", + "u_streakStrength", ], ); const paletteTexture = createPaletteTexture(gl); const pointer: PointerState = { active: false, col: 0, row: 0 }; const tuning: TuningState = { - 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, + 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, }; 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 insideBlurTargets: [TextureTarget, TextureTarget] | null = null; - let outsideBlurTargets: [TextureTarget, TextureTarget] | null = null; - let emitterTarget: TextureTarget | null = null; + let blurTargets: [TextureTarget, TextureTarget] | null = null; let stateIndex = 0; let rafId = 0; let cssWidth = 0; @@ -842,8 +841,6 @@ 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, @@ -898,11 +895,8 @@ export function initWebGLBackground() { deleteTextureTarget(gl, colorTarget); deleteTextureTarget(gl, sharpTarget); 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); + deleteTextureTarget(gl, blurTargets?.[0] ?? null); + deleteTextureTarget(gl, blurTargets?.[1] ?? null); stateTargets = [ createTextureTarget(gl, gridWidth, gridHeight, gl.NEAREST), @@ -916,18 +910,11 @@ export function initWebGLBackground() { canvasHeight, gl.LINEAR, ); - insideBlurTargets = [ + blurTargets = [ 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); @@ -989,217 +976,6 @@ 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(); @@ -1265,61 +1041,75 @@ export function initWebGLBackground() { }; const blur = () => { - if (!smoothTarget || !insideBlurTargets || !outsideBlurTargets) return; + if (!smoothTarget || !sharpTarget || !blurTargets) return; - 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; - } + const iterations = Math.max(0, Math.floor(tuning.blurStrength)); + if (iterations === 0) { + setTextureUnit(gl, 0, sharpTarget.texture); gl.useProgram(blurProgram.program); gl.uniform1i(uniform(blurProgram, "u_image"), 0); - gl.uniform1f(uniform(blurProgram, "u_radius"), stack.blurRadius); + gl.uniform2f(uniform(blurProgram, "u_direction"), 0, 0); + gl.uniform1f(uniform(blurProgram, "u_radius"), tuning.blurRadius); gl.uniform2f( uniform(blurProgram, "u_resolution"), canvasWidth, canvasHeight, ); + renderPass( + blurTargets[1], + canvasWidth, + canvasHeight, + blurProgram.program, + ); + return; + } - 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); + 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, + ); - 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; - } - }; + let sourceTexture = smoothTarget.texture; - runBlurStack(tuning.inside, insideBlurTargets); - runBlurStack(tuning.outside, outsideBlurTargets); + 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; + } }; const composite = () => { - if (!insideBlurTargets || !outsideBlurTargets) return; + if (!sharpTarget || !blurTargets) return; const panelCount = updatePanelRects(); - setTextureUnit(gl, 0, sharpTarget!.texture); - setTextureUnit(gl, 1, insideBlurTargets[1].texture); - setTextureUnit(gl, 2, outsideBlurTargets[1].texture); - setTextureUnit(gl, 3, paletteTexture); + setTextureUnit(gl, 0, sharpTarget.texture); + setTextureUnit(gl, 1, blurTargets[1].texture); + setTextureUnit(gl, 2, paletteTexture); gl.useProgram(compositeProgram.program); gl.uniform1i(uniform(compositeProgram, "u_sharp"), 0); - 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.uniform1i(uniform(compositeProgram, "u_blur"), 1); + gl.uniform1i(uniform(compositeProgram, "u_palette"), 2); gl.uniform2f( uniform(compositeProgram, "u_resolution"), canvasWidth, @@ -1327,83 +1117,27 @@ export function initWebGLBackground() { ); const deviceScale = canvasWidth / cssWidth; gl.uniform1f( - uniform(compositeProgram, "u_inMinBlockSize"), - Math.max(0.25, tuning.inside.smallestBlock) * deviceScale, + uniform(compositeProgram, "u_minBlockSize"), + Math.max(0.25, tuning.smallestBlock) * deviceScale, ); gl.uniform1f( - uniform(compositeProgram, "u_inMaxBlockSize"), - Math.max(tuning.inside.largestBlock, tuning.inside.smallestBlock) * - deviceScale, + uniform(compositeProgram, "u_maxBlockSize"), + Math.max(tuning.largestBlock, tuning.smallestBlock) * deviceScale, ); - gl.uniform1i(uniform(compositeProgram, "u_inLevels"), tuning.inside.levels); + gl.uniform1i(uniform(compositeProgram, "u_levels"), tuning.levels); gl.uniform1f( - uniform(compositeProgram, "u_inDetailThreshold"), - tuning.inside.detailThreshold, + uniform(compositeProgram, "u_detailThreshold"), + tuning.detailThreshold, ); gl.uniform1f( - uniform(compositeProgram, "u_inDitherStrength"), - tuning.inside.ditherStrength, + uniform(compositeProgram, "u_ditherStrength"), + tuning.ditherStrength, ); + gl.uniform1f(uniform(compositeProgram, "u_edgeBias"), tuning.edgeBias); + gl.uniform1f(uniform(compositeProgram, "u_hueDrift"), tuning.hueDrift); gl.uniform1f( - 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, + uniform(compositeProgram, "u_streakStrength"), + tuning.streakStrength, ); gl.uniform4fv(uniform(compositeProgram, "u_panelRects"), panelRects); gl.uniform1i(uniform(compositeProgram, "u_panelCount"), panelCount); @@ -1438,9 +1172,6 @@ export function initWebGLBackground() { stamp(now); } - renderEmitters(); - injectEmitters(); - colorize(); blur(); composite(); @@ -1454,50 +1185,31 @@ export function initWebGLBackground() { pointer.active = 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( + "touchstart", + (event) => { + event.preventDefault(); + const touch = event.touches.item(0); + if (touch) { + updatePointer(touch.clientX, touch.clientY); + } + }, + { passive: false }, + ); document.addEventListener( "touchmove", (event) => { - const touch = event.touches.item(0); - 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); + const touch = event.touches.item(0); + if (touch) { + updatePointer(touch.clientX, touch.clientY); + } }, { passive: false }, ); document.addEventListener("touchend", () => { - touchDragActive = false; - pointer.active = false; - }); - - document.addEventListener("touchcancel", () => { - touchDragActive = false; pointer.active = false; }); @@ -1517,23 +1229,18 @@ export function initWebGLBackground() { window.cancelAnimationFrame(rafId); deleteTextureTarget(gl, stateTargets?.[0] ?? null); deleteTextureTarget(gl, stateTargets?.[1] ?? null); - deleteTextureTarget(gl, colorTarget); deleteTextureTarget(gl, sharpTarget); - 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); + deleteTextureTarget(gl, blurTargets?.[0] ?? null); + deleteTextureTarget(gl, blurTargets?.[1] ?? null); 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 f80f46e..c9c72e4 100644 --- a/src/pages/home.ts +++ b/src/pages/home.ts @@ -6,20 +6,19 @@ 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 dd79965..e506bd6 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -140,18 +140,12 @@ body[data-background-mode="failed"]::before { } .site-shell { - width: 100%; + width: min(100%, 60%); 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; @@ -159,7 +153,7 @@ body[data-background-mode="failed"]::before { .site-panel-frost { position: absolute; - inset: var(--panel-border-inset); + inset: 0; pointer-events: none; overflow: hidden; background: @@ -171,6 +165,40 @@ 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); @@ -221,36 +249,6 @@ 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 new file mode 100644 index 0000000..e3e3506 --- /dev/null +++ b/tor.har @@ -0,0 +1,252 @@ +{ + "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