From 51dc6c9ee6994d9077ccd54875633c60fdd98c80 Mon Sep 17 00:00:00 2001 From: Jet Date: Fri, 27 Mar 2026 15:08:34 -0700 Subject: [PATCH 1/6] feat: use webgl shaders instead of wasm --- README.md | 20 +- cgol/.cargo/config.toml | 5 - cgol/.gitignore | 5 - cgol/.vscode/settings.json | 3 - cgol/Cargo.lock | 427 ---------- cgol/Cargo.toml | 47 - cgol/README.md | 84 -- cgol/src/lib.rs | 483 ----------- cgol/src/utils.rs | 10 - eslint.config.js | 65 +- index.html | 10 +- package-lock.json | 306 +------ package.json | 14 +- src/components/frosted-box.ts | 12 +- src/lib/background.ts | 78 -- src/lib/site.ts | 6 +- src/lib/webgl-background.ts | 1515 +++++++++++++++++++++++++++++++++ src/main.ts | 24 +- src/pages/home.ts | 15 +- src/router.ts | 2 + src/styles/globals.css | 83 +- tsconfig.json | 2 +- vite-plugin-ansi.ts | 96 +-- vite.config.ts | 7 +- 24 files changed, 1712 insertions(+), 1607 deletions(-) delete mode 100644 cgol/.cargo/config.toml delete mode 100644 cgol/.gitignore delete mode 100644 cgol/.vscode/settings.json delete mode 100644 cgol/Cargo.lock delete mode 100644 cgol/Cargo.toml delete mode 100644 cgol/README.md delete mode 100644 cgol/src/lib.rs delete mode 100644 cgol/src/utils.rs delete mode 100644 src/lib/background.ts create mode 100644 src/lib/webgl-background.ts diff --git a/README.md b/README.md index 8e4f453..3e8e94b 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,22 @@ Personal site for Jet Pham. -The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a Rust/WebAssembly Conway's Game of Life background. +The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a WebGL2 Conway's Game of Life background. ## Features - ASCII/ANSI-inspired visual style with the IBM VGA font -- Conway's Game of Life running in the background via Rust + WebAssembly +- Conway's Game of Life running in the background via WebGL2 - Q+A page backed by the site API - Single-file oriented frontend build with Vite -- Reduced-motion aware background controls with a static fallback mode +- Fullscreen GPU blur/composite for the frosted panel effect ## Stack - Vite - TypeScript - Tailwind CSS v4 -- Rust + WebAssembly +- WebGL2 - npm ## Development @@ -25,9 +25,6 @@ The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a ### Prerequisites - Node.js + npm -- Rust -- `wasm-pack` -- `wasm-opt` (used by `build:wasm`) ### Install @@ -35,12 +32,6 @@ The site is a small Vite app with a terminal-style UI, ANSI-rendered text, and a npm install ``` -### Build the WASM package - -```bash -npm run build:wasm -``` - ### Start the dev server ```bash @@ -63,10 +54,9 @@ npm run build ```text src/ frontend app -cgol/ Rust + WASM Conway's Game of Life module ``` ## Notes - The homepage and Q+A page are the intended public pages. -- If WebAssembly fails or motion is disabled, the site falls back to a static background treatment. +- The background renderer targets WebGL2 and falls back to the site shell background if WebGL2 is unavailable. diff --git a/cgol/.cargo/config.toml b/cgol/.cargo/config.toml deleted file mode 100644 index 39c6f19..0000000 --- a/cgol/.cargo/config.toml +++ /dev/null @@ -1,5 +0,0 @@ -[build] -target = "wasm32-unknown-unknown" - -[target.wasm32-unknown-unknown] -rustflags = ["-C", "link-arg=--strip-all"] diff --git a/cgol/.gitignore b/cgol/.gitignore deleted file mode 100644 index 9f33f25..0000000 --- a/cgol/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -/target -**/*.rs.bk -bin/ -pkg/ -wasm-pack.log \ No newline at end of file diff --git a/cgol/.vscode/settings.json b/cgol/.vscode/settings.json deleted file mode 100644 index 8974c7a..0000000 --- a/cgol/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "rust-analyzer.cargo.target": "wasm32-unknown-unknown", -} diff --git a/cgol/Cargo.lock b/cgol/Cargo.lock deleted file mode 100644 index 4a58688..0000000 --- a/cgol/Cargo.lock +++ /dev/null @@ -1,427 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" - -[[package]] -name = "cc" -version = "1.2.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cgol" -version = "0.1.0" -dependencies = [ - "console_error_panic_hook", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-test", - "web-sys", -] - -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "js-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "minicov" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" -dependencies = [ - "cc", - "walkdir", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-bindgen-test" -version = "0.3.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6311c867385cc7d5602463b31825d454d0837a3aba7cdb5e56d5201792a3f7fe" -dependencies = [ - "async-trait", - "cast", - "js-sys", - "libm", - "minicov", - "nu-ansi-term", - "num-traits", - "oorandom", - "serde", - "serde_json", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-bindgen-test-macro", - "wasm-bindgen-test-shared", -] - -[[package]] -name = "wasm-bindgen-test-macro" -version = "0.3.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67008cdde4769831958536b0f11b3bdd0380bde882be17fff9c2f34bb4549abd" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "wasm-bindgen-test-shared" -version = "0.2.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe29135b180b72b04c74aa97b2b4a2ef275161eff9a6c7955ea9eaedc7e1d4e" - -[[package]] -name = "web-sys" -version = "0.3.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/cgol/Cargo.toml b/cgol/Cargo.toml deleted file mode 100644 index 71116a6..0000000 --- a/cgol/Cargo.toml +++ /dev/null @@ -1,47 +0,0 @@ -[package] -name = "cgol" -version = "0.1.0" -authors = ["jet"] -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -wasm-bindgen = "0.2" -js-sys = "0.3" -web-sys = { version = "0.3", features = [ - "CanvasRenderingContext2d", - "Document", - "HtmlCanvasElement", - "Window", - "MouseEvent", - "Element", - "EventTarget", - "Performance", - "TouchEvent", - "Touch", - "TouchList", - "ImageData", -] } -console_error_panic_hook = { version = "0.1", optional = true } - -[features] -default = ["console_error_panic_hook"] - -[dev-dependencies] -wasm-bindgen-test = "0.3" - -[package.metadata.wasm-pack.profile.release] -wasm-opt = false - -[profile.dev] -debug = "line-tables-only" - -[profile.release] -debug = 0 -opt-level = 3 -lto = true -codegen-units = 1 -panic = "abort" -strip = true diff --git a/cgol/README.md b/cgol/README.md deleted file mode 100644 index 6b68408..0000000 --- a/cgol/README.md +++ /dev/null @@ -1,84 +0,0 @@ -
- -

wasm-pack-template

- - A template for kick starting a Rust and WebAssembly project using wasm-pack. - -

- Build Status -

- -

- Tutorial - | - Chat -

- - Built with 🦀🕸 by The Rust and WebAssembly Working Group -
- -## About - -[**📚 Read this template tutorial! 📚**][template-docs] - -This template is designed for compiling Rust libraries into WebAssembly and -publishing the resulting package to NPM. - -Be sure to check out [other `wasm-pack` tutorials online][tutorials] for other -templates and usages of `wasm-pack`. - -[tutorials]: https://rustwasm.github.io/docs/wasm-pack/tutorials/index.html -[template-docs]: https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/index.html - -## 🚴 Usage - -### 🐑 Use `cargo generate` to Clone this Template - -[Learn more about `cargo generate` here.](https://github.com/ashleygwilliams/cargo-generate) - -``` -cargo generate --git https://github.com/rustwasm/wasm-pack-template.git --name my-project -cd my-project -``` - -### 🛠️ Build with `wasm-pack build` - -``` -wasm-pack build -``` - -### 🔬 Test in Headless Browsers with `wasm-pack test` - -``` -wasm-pack test --headless --firefox -``` - -### 🎁 Publish to NPM with `wasm-pack publish` - -``` -wasm-pack publish -``` - -## 🔋 Batteries Included - -* [`wasm-bindgen`](https://github.com/rustwasm/wasm-bindgen) for communicating - between WebAssembly and JavaScript. -* [`console_error_panic_hook`](https://github.com/rustwasm/console_error_panic_hook) - for logging panic messages to the developer console. -* `LICENSE-APACHE` and `LICENSE-MIT`: most Rust projects are licensed this way, so these are included for you - -## License - -Licensed under either of - -* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) -* MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) - -at your option. - -### Contribution - -Unless you explicitly state otherwise, any contribution intentionally -submitted for inclusion in the work by you, as defined in the Apache-2.0 -license, shall be dual licensed as above, without any additional terms or -conditions. diff --git a/cgol/src/lib.rs b/cgol/src/lib.rs deleted file mode 100644 index 31d55b0..0000000 --- a/cgol/src/lib.rs +++ /dev/null @@ -1,483 +0,0 @@ -mod utils; - -use std::cell::Cell; -use std::cell::RefCell; -use std::rc::Rc; -use wasm_bindgen::prelude::*; -use wasm_bindgen::Clamped; -use wasm_bindgen::JsCast; - -use js_sys::Math; -use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData, Window}; - -const CELL_SIZE: u32 = 10; -const TICK_MS: f64 = 1000.0 / 60.0; -const HUE_PERIOD_MS: f64 = 3000.0; -const STILL_STEPS: u32 = 5; - -/// Cells: 0 = dead, 1-255 = alive with that hue value. -const DEAD: u8 = 0; - -#[inline(always)] -fn safe_hue(h: u8) -> u8 { - if h == DEAD { - 1 - } else { - h - } -} - -// ── Lookup tables (computed once) ────────────────────────────────── - -struct Luts { - /// RGBA for each hue. Index 0 = black (dead cell). - rgb: [[u8; 4]; 256], - sin: [f32; 256], - cos: [f32; 256], -} - -impl Luts { - fn new() -> Self { - let mut t = Self { - rgb: [[0, 0, 0, 255]; 256], - sin: [0.0; 256], - cos: [0.0; 256], - }; - for i in 1..256u16 { - let h = i as f32 / 255.0 * 6.0; - let x = 1.0 - (h % 2.0 - 1.0).abs(); - let (r, g, b) = match h as u32 { - 0 => (1.0f32, x, 0.0), - 1 => (x, 1.0, 0.0), - 2 => (0.0, 1.0, x), - 3 => (0.0, x, 1.0), - 4 => (x, 0.0, 1.0), - _ => (1.0, 0.0, x), - }; - t.rgb[i as usize] = [(r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8, 255]; - } - for i in 0..256 { - let rad = i as f32 * std::f32::consts::TAU / 256.0; - t.sin[i] = rad.sin(); - t.cos[i] = rad.cos(); - } - t - } - - #[inline] - fn mix(&self, hues: &[u8]) -> u8 { - let mut s = 0.0f32; - let mut c = 0.0f32; - for &h in hues { - s += self.sin[h as usize]; - c += self.cos[h as usize]; - } - safe_hue( - (s.atan2(c).rem_euclid(std::f32::consts::TAU) * 256.0 / std::f32::consts::TAU) as u8, - ) - } -} - -// ── Grid ─────────────────────────────────────────────────────────── - -struct Grid { - w: u32, - h: u32, - cells: Vec, - buf: Vec, - hues: Vec, -} - -impl Grid { - fn new(w: u32, h: u32) -> Self { - let n = (w * h) as usize; - let mut g = Self { - w, - h, - cells: vec![DEAD; n], - buf: vec![DEAD; n], - hues: Vec::with_capacity(8), - }; - g.randomize(); - g - } - - fn randomize(&mut self) { - for c in &mut self.cells { - *c = if Math::random() < 0.5 { - safe_hue((Math::random() * 255.0) as u8) - } else { - DEAD - }; - } - } - - fn tick(&mut self, luts: &Luts) { - let (w, h) = (self.w, self.h); - - for row in 0..h { - let n_off = (if row == 0 { h - 1 } else { row - 1 }) * w; - let r_off = row * w; - let s_off = (if row == h - 1 { 0 } else { row + 1 }) * w; - - for col in 0..w { - let we = if col == 0 { w - 1 } else { col - 1 }; - let ea = if col == w - 1 { 0 } else { col + 1 }; - - // Read all 8 neighbors - let ns = [ - self.cells[(n_off + we) as usize], - self.cells[(n_off + col) as usize], - self.cells[(n_off + ea) as usize], - self.cells[(r_off + we) as usize], - self.cells[(r_off + ea) as usize], - self.cells[(s_off + we) as usize], - self.cells[(s_off + col) as usize], - self.cells[(s_off + ea) as usize], - ]; - - // Count alive neighbors (branchless) - let count = ns.iter().fold(0u8, |a, &v| a + (v != DEAD) as u8); - - let i = (r_off + col) as usize; - let cell = self.cells[i]; - let alive = cell != DEAD; - - self.buf[i] = if alive { - if count == 2 || count == 3 { - cell - } else { - DEAD - } - } else if count == 3 { - // Only collect hues for births - self.hues.clear(); - for &v in &ns { - if v != DEAD { - self.hues.push(v); - } - } - luts.mix(&self.hues) - } else { - DEAD - }; - } - } - std::mem::swap(&mut self.cells, &mut self.buf); - } - - fn stamp(&mut self, cr: i32, cc: i32, half: i32, hue: u8) { - let (h, w) = (self.h as i32, self.w as i32); - let hue = safe_hue(hue); - for dr in -half..=half { - for dc in -half..=half { - let r = (cr + dr).rem_euclid(h) as u32; - let c = (cc + dc).rem_euclid(w) as u32; - self.cells[(r * self.w + c) as usize] = hue; - } - } - } -} - -// ── App state ────────────────────────────────────────────────────── - -struct App { - win: Window, - canvas: HtmlCanvasElement, - ctx: CanvasRenderingContext2d, - luts: Luts, - grid: Grid, - pixels: Vec, - cw: u32, - ch: u32, - crow: i32, - ccol: i32, - cursor_on: bool, - last_tick: f64, - dirty: bool, -} - -thread_local! { - static APP_STATE: RefCell>>> = const { RefCell::new(None) }; - static RUNNING: Cell = const { Cell::new(false) }; - static LISTENERS_READY: Cell = const { Cell::new(false) }; - static RAF_CLOSURE: RefCell>> = const { RefCell::new(None) }; -} - -impl App { - fn new() -> Result { - utils::set_panic_hook(); - let win = web_sys::window().ok_or("no window")?; - let doc = win.document().ok_or("no document")?; - let canvas: HtmlCanvasElement = doc - .get_element_by_id("canvas") - .ok_or("no canvas")? - .dyn_into()?; - let ctx: CanvasRenderingContext2d = - canvas.get_context("2d")?.ok_or("no 2d ctx")?.dyn_into()?; - ctx.set_image_smoothing_enabled(false); - - let mut app = Self { - win, - canvas, - ctx, - luts: Luts::new(), - grid: Grid::new(1, 1), - pixels: Vec::new(), - cw: 0, - ch: 0, - crow: 0, - ccol: 0, - cursor_on: false, - last_tick: 0.0, - dirty: true, - }; - app.resize(); - Ok(app) - } - - fn now(&self) -> f64 { - self.win.performance().unwrap().now() - } - - fn resize(&mut self) { - let vw = self.win.inner_width().unwrap().as_f64().unwrap(); - let vh = self.win.inner_height().unwrap().as_f64().unwrap(); - let cols = (vw as u32 / CELL_SIZE).max(1); - let rows = (vh as u32 / CELL_SIZE).max(1); - - self.cw = cols; - self.ch = rows; - self.canvas.set_width(cols); - self.canvas.set_height(rows); - self.canvas - .dyn_ref::() - .unwrap() - .set_attribute( - "style", - &format!( - "position:fixed;inset:0;width:{vw}px;height:{vh}px;image-rendering:pixelated" - ), - ) - .ok(); - self.ctx.set_image_smoothing_enabled(false); - - self.grid = Grid::new(cols, rows); - self.pixels = vec![0u8; (cols * rows * 4) as usize]; - self.dirty = true; - } - - fn draw(&mut self) { - if !self.dirty { - return; - } - self.dirty = false; - - let cells = &self.grid.cells; - let rgb = &self.luts.rgb; - let px = &mut self.pixels; - - for (i, &cell) in cells.iter().enumerate() { - px[i * 4..i * 4 + 4].copy_from_slice(&rgb[cell as usize]); - } - - if let Ok(img) = ImageData::new_with_u8_clamped_array_and_sh(Clamped(px), self.cw, self.ch) - { - self.ctx.put_image_data(&img, 0.0, 0.0).ok(); - } - } - - fn render_steps(&mut self, steps: u32) { - for _ in 0..steps { - let luts = &self.luts as *const Luts; - // SAFETY: luts is not mutated during tick() - self.grid.tick(unsafe { &*luts }); - } - self.dirty = true; - self.draw(); - } -} - -fn app() -> Result>, JsValue> { - APP_STATE.with(|state| { - if let Some(app) = state.borrow().as_ref() { - return Ok(app.clone()); - } - - let app = Rc::new(RefCell::new(App::new()?)); - state.borrow_mut().replace(app.clone()); - Ok(app) - }) -} - -fn init_listeners(app: Rc>) -> Result<(), JsValue> { - let already_ready = LISTENERS_READY.with(|ready| { - if ready.get() { - true - } else { - ready.set(true); - false - } - }); - - if already_ready { - return Ok(()); - } - - let win = app.borrow().win.clone(); - let doc = win.document().unwrap(); - - let s = app.clone(); - let cb = Closure::wrap(Box::new(move |e: web_sys::MouseEvent| { - let mut a = s.borrow_mut(); - a.ccol = e.client_x() / CELL_SIZE as i32; - a.crow = e.client_y() / CELL_SIZE as i32; - a.cursor_on = true; - }) as Box); - doc.add_event_listener_with_callback("mousemove", cb.as_ref().unchecked_ref())?; - cb.forget(); - - let s = app.clone(); - let cb = Closure::wrap(Box::new(move |e: web_sys::TouchEvent| { - e.prevent_default(); - if let Some(t) = e.touches().get(0) { - let mut a = s.borrow_mut(); - a.ccol = t.client_x() / CELL_SIZE as i32; - a.crow = t.client_y() / CELL_SIZE as i32; - a.cursor_on = true; - } - }) as Box); - doc.add_event_listener_with_callback("touchmove", cb.as_ref().unchecked_ref())?; - cb.forget(); - - let s = app.clone(); - let cb = Closure::wrap(Box::new(move |e: web_sys::TouchEvent| { - e.prevent_default(); - if let Some(t) = e.touches().get(0) { - let mut a = s.borrow_mut(); - a.ccol = t.client_x() / CELL_SIZE as i32; - a.crow = t.client_y() / CELL_SIZE as i32; - a.cursor_on = true; - } - }) as Box); - doc.add_event_listener_with_callback("touchstart", cb.as_ref().unchecked_ref())?; - cb.forget(); - - let s = app.clone(); - let cb = Closure::wrap(Box::new(move |_: web_sys::TouchEvent| { - s.borrow_mut().cursor_on = false; - }) as Box); - doc.add_event_listener_with_callback("touchend", cb.as_ref().unchecked_ref())?; - cb.forget(); - - let s = app; - let cb = Closure::wrap(Box::new(move || { - let mut a = s.borrow_mut(); - a.resize(); - if RUNNING.with(|running| running.get()) { - a.draw(); - } else { - a.render_steps(STILL_STEPS); - } - }) as Box); - win.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?; - cb.forget(); - - Ok(()) -} - -fn ensure_animation_loop(app: Rc>) { - RAF_CLOSURE.with(|slot| { - if slot.borrow().is_some() { - return; - } - - let win = app.borrow().win.clone(); - let f: Rc>>> = Rc::new(RefCell::new(None)); - let g = f.clone(); - let s = app.clone(); - let w = win.clone(); - - *g.borrow_mut() = Some(Closure::wrap(Box::new(move || { - if !RUNNING.with(|running| running.get()) { - return; - } - - let now = s.borrow().now(); - - { - let mut a = s.borrow_mut(); - - if now - a.last_tick >= TICK_MS { - a.last_tick = now; - let luts = &a.luts as *const Luts; - // SAFETY: luts is not mutated during tick() - a.grid.tick(unsafe { &*luts }); - a.dirty = true; - } - - if a.cursor_on { - let (cr, cc) = (a.crow, a.ccol); - let hue = ((now % HUE_PERIOD_MS) / HUE_PERIOD_MS * 256.0) as u8; - a.grid.stamp(cr, cc, 2, hue); - a.dirty = true; - } - - a.draw(); - } - - if RUNNING.with(|running| running.get()) { - w.request_animation_frame(f.borrow().as_ref().unwrap().as_ref().unchecked_ref()) - .ok(); - } - }) as Box)); - - slot.borrow_mut().replace(g.borrow_mut().take().unwrap()); - }); -} - -// ── Entry point ──────────────────────────────────────────────────── - -#[wasm_bindgen] -pub fn start() -> Result<(), JsValue> { - let app = app()?; - init_listeners(app.clone())?; - - if RUNNING.with(|running| running.get()) { - return Ok(()); - } - - RUNNING.with(|running| running.set(true)); - ensure_animation_loop(app.clone()); - - RAF_CLOSURE.with(|slot| { - if let Some(cb) = slot.borrow().as_ref() { - app.borrow() - .win - .request_animation_frame(cb.as_ref().unchecked_ref()) - } else { - Err(JsValue::from_str("no animation closure")) - } - })?; - - Ok(()) -} - -#[wasm_bindgen] -pub fn stop() -> Result<(), JsValue> { - let _ = app()?; - RUNNING.with(|running| running.set(false)); - Ok(()) -} - -#[wasm_bindgen] -pub fn render_still(steps: u32) -> Result<(), JsValue> { - let app = app()?; - init_listeners(app.clone())?; - RUNNING.with(|running| running.set(false)); - let steps = steps.max(1); - let mut app = app.borrow_mut(); - app.resize(); - app.render_steps(steps); - Ok(()) -} diff --git a/cgol/src/utils.rs b/cgol/src/utils.rs deleted file mode 100644 index b1d7929..0000000 --- a/cgol/src/utils.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub fn set_panic_hook() { - // When the `console_error_panic_hook` feature is enabled, we can call the - // `set_panic_hook` function at least once during initialization, and then - // we will get better error messages if our code ever panics. - // - // For more details see - // https://github.com/rustwasm/console_error_panic_hook#readme - #[cfg(feature = "console_error_panic_hook")] - console_error_panic_hook::set_once(); -} diff --git a/eslint.config.js b/eslint.config.js index 46809a3..a06087d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,39 +1,42 @@ -import tseslint from 'typescript-eslint'; +import tseslint from "typescript-eslint"; export default tseslint.config( { - ignores: ['dist', 'cgol/pkg/**/*'] + ignores: ["dist"], }, { - files: ['**/*.ts'], - extends: [ - ...tseslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - ...tseslint.configs.stylisticTypeChecked - ], - rules: { - "@typescript-eslint/array-type": "off", - "@typescript-eslint/consistent-type-definitions": "off", - "@typescript-eslint/consistent-type-imports": [ - "warn", - { prefer: "type-imports", fixStyle: "inline-type-imports" }, + files: ["**/*.ts"], + extends: [ + ...tseslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ...tseslint.configs.stylisticTypeChecked, ], - "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], - "@typescript-eslint/require-await": "off", - "@typescript-eslint/no-misused-promises": [ - "error", - { checksVoidReturn: { attributes: false } }, - ], - }, + rules: { + "@typescript-eslint/array-type": "off", + "@typescript-eslint/consistent-type-definitions": "off", + "@typescript-eslint/consistent-type-imports": [ + "warn", + { prefer: "type-imports", fixStyle: "inline-type-imports" }, + ], + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_" }, + ], + "@typescript-eslint/require-await": "off", + "@typescript-eslint/no-misused-promises": [ + "error", + { checksVoidReturn: { attributes: false } }, + ], + }, }, { - linterOptions: { - reportUnusedDisableDirectives: true - }, - languageOptions: { - parserOptions: { - projectService: true - } - } - } -) + linterOptions: { + reportUnusedDisableDirectives: true, + }, + languageOptions: { + parserOptions: { + projectService: true, + }, + }, + }, +); diff --git a/index.html b/index.html index 51ddb40..4ba9f20 100644 --- a/index.html +++ b/index.html @@ -103,13 +103,15 @@ -
+
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/styles/globals.css b/src/styles/globals.css index 1db2b17..dd79965 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -221,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; From 9a5256e8f0669e165d50dc56aacb987cec88a659 Mon Sep 17 00:00:00 2001 From: Jet Date: Sat, 28 Mar 2026 20:13:12 -0700 Subject: [PATCH 3/6] fix: cleanup flake and repo --- .gitignore | 1 + README.md | 32 ++++++ check_cleanup.txt | 2 - flake.lock | 121 +--------------------- flake.nix | 93 ++++------------- module.nix | 226 ++++++++++++++++++++++++++--------------- tor.har | 252 ---------------------------------------------- 7 files changed, 197 insertions(+), 530 deletions(-) delete mode 100644 check_cleanup.txt delete mode 100644 tor.har 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/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/module.nix b/module.nix index 92b9669..ef5d108 100644 --- a/module.nix +++ b/module.nix @@ -1,27 +1,118 @@ 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; - webhookSecretPath = - if cfg.webhookSecretFile != null then - cfg.webhookSecretFile - else - config.age.secrets.webhook-secret.path; + package = cfg.package; + qaApi = cfg.apiPackage; + apiListen = "${cfg.apiListenAddress}:${toString cfg.apiListenPort}"; + 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; @@ -42,36 +133,31 @@ in }; webhookSecretFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; + type = lib.types.path; description = "File containing the WEBHOOK_SECRET for MTA Hook authentication."; }; }; config = lib.mkIf cfg.enable { - age.secrets.webhook-secret = lib.mkIf (cfg.webhookSecretFile == null) { - file = "${self}/secrets/webhook-secret.age"; - mode = "0400"; - }; + assertions = [ + { + assertion = + !cfg.tor.enable + || ( + cfg.tor.onionSecretKeyFile != null + && cfg.tor.onionPublicKeyFile != null + && cfg.tor.onionHostnameFile != null + ); + message = "services.jetpham-website.tor requires onionSecretKeyFile, onionPublicKeyFile, and onionHostnameFile."; + } + ]; - 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 { - file = "${self}/secrets/tor-onion-public-key.age"; - owner = "tor"; - group = "tor"; - mode = "0444"; - }; - age.secrets.tor-onion-hostname = lib.mkIf cfg.tor.enable { - file = "${self}/secrets/tor-onion-hostname.age"; - owner = "tor"; - group = "tor"; - mode = "0444"; - }; + 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; @@ -81,7 +167,7 @@ in port = 80; target = { addr = "127.0.0.1"; - port = 8888; + port = cfg.tor.port; }; } ]; @@ -90,39 +176,45 @@ in systemd.services.tor-onion-keys = lib.mkIf cfg.tor.enable { description = "Copy Tor onion keys into place"; - after = [ "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 ${cfg.tor.onionSecretKeyFile} "$dir/hs_ed25519_secret_key" + cp ${cfg.tor.onionPublicKeyFile} "$dir/hs_ed25519_public_key" + cp ${cfg.tor.onionHostnameFile} "$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" ]; + wants = [ "network-online.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}" ]; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + ReadWritePaths = [ "/var/lib/jetpham-qa" ]; Restart = "on-failure"; RestartSec = 5; - LoadCredential = "webhook-secret:${webhookSecretPath}"; + LoadCredential = "webhook-secret:${cfg.webhookSecretFile}"; }; script = '' if [ ! -s "$CREDENTIALS_DIRECTORY/webhook-secret" ]; then @@ -131,51 +223,21 @@ in fi export WEBHOOK_SECRET="$(cat "$CREDENTIALS_DIRECTORY/webhook-secret")" - exec ${qaApi}/bin/jetpham-qa-api + exec ${lib.getExe qaApi} ''; }; - 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/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 From 1174299e77304d0ed489340fa72e01fd04fa09b9 Mon Sep 17 00:00:00 2001 From: Jet Date: Sat, 28 Mar 2026 20:35:52 -0700 Subject: [PATCH 4/6] feat: add in the secrets defaults and exposure again --- module.nix | 75 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/module.nix b/module.nix index ef5d108..fad860f 100644 --- a/module.nix +++ b/module.nix @@ -11,6 +11,27 @@ let package = cfg.package; qaApi = cfg.apiPackage; apiListen = "${cfg.apiListenAddress}:${toString cfg.apiListenPort}"; + usingDefaultWebhookSecret = cfg.webhookSecretFile == null; + 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 + 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" @@ -133,21 +154,44 @@ in }; webhookSecretFile = lib.mkOption { - type = lib.types.path; - description = "File containing the WEBHOOK_SECRET for MTA Hook authentication."; + 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."; }; }; config = lib.mkIf cfg.enable { + 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 && 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 && 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 && usingDefaultTorHostname) { + file = "${self}/secrets/tor-onion-hostname.age"; + owner = "tor"; + group = "tor"; + mode = "0444"; + }; + assertions = [ { assertion = !cfg.tor.enable - || ( - cfg.tor.onionSecretKeyFile != null - && cfg.tor.onionPublicKeyFile != null - && cfg.tor.onionHostnameFile != null - ); + || (torOnionSecretKeyPath != null && torOnionPublicKeyPath != null && torOnionHostnamePath != null); message = "services.jetpham-website.tor requires onionSecretKeyFile, onionPublicKeyFile, and onionHostnameFile."; } ]; @@ -176,15 +220,18 @@ 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"; before = [ "tor.service" ]; wantedBy = [ "tor.service" ]; serviceConfig.Type = "oneshot"; script = '' dir="/var/lib/tor/onion/jetpham-website" mkdir -p "$dir" - cp ${cfg.tor.onionSecretKeyFile} "$dir/hs_ed25519_secret_key" - cp ${cfg.tor.onionPublicKeyFile} "$dir/hs_ed25519_public_key" - cp ${cfg.tor.onionHostnameFile} "$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" @@ -194,8 +241,8 @@ in systemd.services.jetpham-qa-api = { description = "Jet Pham Q&A API"; - after = [ "network-online.target" ]; - wants = [ "network-online.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; @@ -214,7 +261,7 @@ in ReadWritePaths = [ "/var/lib/jetpham-qa" ]; Restart = "on-failure"; RestartSec = 5; - LoadCredential = "webhook-secret:${cfg.webhookSecretFile}"; + LoadCredential = "webhook-secret:${webhookSecretPath}"; }; script = '' if [ ! -s "$CREDENTIALS_DIRECTORY/webhook-secret" ]; then @@ -223,7 +270,7 @@ in fi export WEBHOOK_SECRET="$(cat "$CREDENTIALS_DIRECTORY/webhook-secret")" - exec ${lib.getExe qaApi} + exec ${qaApi}/bin/jetpham-qa-api ''; }; From 60c598c17d06cb405df791889d371d9f7fb96865 Mon Sep 17 00:00:00 2001 From: Jet Date: Sat, 28 Mar 2026 20:50:59 -0700 Subject: [PATCH 5/6] fix: pass the config for QA_LISTEN_ADDRESS --- api/src/serve.rs | 8 ++++++-- module.nix | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) 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/module.nix b/module.nix index fad860f..1b5dba7 100644 --- a/module.nix +++ b/module.nix @@ -253,6 +253,8 @@ in "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; From 056daa6460fdd5ec01c9602b05cc4b370bee5701 Mon Sep 17 00:00:00 2001 From: Jet Date: Sun, 29 Mar 2026 21:09:31 -0700 Subject: [PATCH 6/6] fix: mobile touch differentiates drag and tap --- src/lib/webgl-background.ts | 52 +++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/src/lib/webgl-background.ts b/src/lib/webgl-background.ts index 1e0d2b8..a95533f 100644 --- a/src/lib/webgl-background.ts +++ b/src/lib/webgl-background.ts @@ -989,6 +989,11 @@ 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; @@ -1449,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; });